Release v0.1.1 2026-07-10
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
import { AccountRepository } from "./accountRepository";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
const testHasher = async (password: string, salt: string) => {
|
||||
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
|
||||
return `derived:${salt}:${checksum}`;
|
||||
};
|
||||
|
||||
describe("AccountRepository", () => {
|
||||
it("requires both a username and password", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
});
|
||||
|
||||
it("requires account creation before sign-in", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
|
||||
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
});
|
||||
|
||||
it("rejects an incorrect password and duplicate account names", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
await repository.create("Wayfinder", "correct");
|
||||
|
||||
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
|
||||
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
|
||||
});
|
||||
|
||||
it("persists only a derived password verifier", async () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
|
||||
await repository.create("healer", "plaintext-secret");
|
||||
|
||||
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
|
||||
expect(persisted).toContain("derived:unique-salt:");
|
||||
expect(persisted).not.toContain("plaintext-secret");
|
||||
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
|
||||
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
|
||||
const PASSWORD_ITERATIONS = 120_000;
|
||||
|
||||
interface AccountRecord {
|
||||
username: string;
|
||||
salt: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
type AccountMap = Record<string, AccountRecord>;
|
||||
type PasswordHasher = (password: string, salt: string) => Promise<string>;
|
||||
|
||||
export type AccountResult =
|
||||
| { ok: true; username: string }
|
||||
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
|
||||
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
function encodeBytes(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hashPassword(password: string, salt: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits({
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: decodeBytes(salt),
|
||||
iterations: PASSWORD_ITERATIONS,
|
||||
}, key, 256);
|
||||
return encodeBytes(new Uint8Array(bits));
|
||||
}
|
||||
|
||||
function randomSalt() {
|
||||
const salt = new Uint8Array(16);
|
||||
crypto.getRandomValues(salt);
|
||||
return encodeBytes(salt);
|
||||
}
|
||||
|
||||
function canonicalUsername(username: string) {
|
||||
return username.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function parseAccounts(raw: string | null): AccountMap {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AccountMap;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local prototype account registry. Passwords are salted and derived before
|
||||
* persistence; replace this adapter with the server authentication API when
|
||||
* remote sync leaves local prototype storage.
|
||||
*/
|
||||
export class AccountRepository {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly hasher: PasswordHasher = hashPassword,
|
||||
private readonly createSalt: () => string = randomSalt,
|
||||
) {}
|
||||
|
||||
async create(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const username = usernameInput.trim();
|
||||
const canonical = canonicalUsername(username);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const accounts = this.read();
|
||||
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
|
||||
|
||||
try {
|
||||
const salt = this.createSalt();
|
||||
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
|
||||
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||
return { ok: true, username };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const canonical = canonicalUsername(usernameInput);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const account = this.read()[canonical];
|
||||
if (!account) return { ok: false, reason: "account-not-found" };
|
||||
|
||||
try {
|
||||
const passwordHash = await this.hasher(password, account.salt);
|
||||
return passwordHash === account.passwordHash
|
||||
? { ok: true, username: account.username }
|
||||
: { ok: false, reason: "invalid-password" };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
private read() {
|
||||
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
|
||||
}
|
||||
}
|
||||
+84
-5
@@ -1,10 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||
import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
|
||||
function loadSettings(): GameSettings {
|
||||
@@ -24,7 +26,19 @@ function persistSettings(settings: GameSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
interface FrontendState {
|
||||
function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "sign-in" | "create") {
|
||||
switch (result.reason) {
|
||||
case "missing-credentials": return "Enter both username and password.";
|
||||
case "account-exists": return "Account already exists. Sign in with its password.";
|
||||
case "account-not-found": return "Account not found. Create an account before enabling online sync.";
|
||||
case "invalid-password": return "Username or password is incorrect.";
|
||||
case "storage-unavailable": return action === "create"
|
||||
? "Account could not be saved on this device. Continue offline or try again."
|
||||
: "Account could not be verified on this device. Continue offline or try again.";
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrontendState {
|
||||
screen: AppScreen;
|
||||
accountId: string | null;
|
||||
slots: SaveSlotState[];
|
||||
@@ -34,7 +48,8 @@ interface FrontendState {
|
||||
selectedBossId: BossId;
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
signIn: (accountId: string) => void;
|
||||
signIn: (username: string, password: string) => Promise<boolean>;
|
||||
createAccount: (username: string, password: string) => Promise<boolean>;
|
||||
continueOffline: () => void;
|
||||
signOut: () => void;
|
||||
navigate: (screen: AppScreen) => void;
|
||||
@@ -70,9 +85,23 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
signIn: (rawAccountId) => {
|
||||
const accountId = rawAccountId.trim() || "wayfinder";
|
||||
set({ accountId, slots: repository.list(accountId), screen: "saves", notice: `Online sync connected as ${accountId}.` });
|
||||
signIn: async (username, password) => {
|
||||
const result = await accounts.authenticate(username, password);
|
||||
if (!result.ok) {
|
||||
set({ notice: accountNotice(result, "sign-in") });
|
||||
return false;
|
||||
}
|
||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
},
|
||||
createAccount: async (username, password) => {
|
||||
const result = await accounts.create(username, password);
|
||||
if (!result.ok) {
|
||||
set({ notice: accountNotice(result, "create") });
|
||||
return false;
|
||||
}
|
||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
},
|
||||
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
|
||||
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
|
||||
@@ -168,6 +197,56 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
clearNotice: () => set({ notice: "" }),
|
||||
}));
|
||||
|
||||
export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "signIn"
|
||||
| "createAccount"
|
||||
| "continueOffline"
|
||||
| "signOut"
|
||||
| "navigate"
|
||||
| "selectSlot"
|
||||
| "createSlot"
|
||||
| "playSlot"
|
||||
| "deleteSlot"
|
||||
| "copySlot"
|
||||
| "uploadSlot"
|
||||
| "downloadSlot"
|
||||
| "selectMode"
|
||||
| "selectBoss"
|
||||
| "selectHealerClass"
|
||||
| "updateActiveHealerInventory"
|
||||
| "updateSetting"
|
||||
| "touchActiveSave"
|
||||
| "recordBossVictory"
|
||||
| "clearNotice"
|
||||
>;
|
||||
|
||||
export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
const {
|
||||
signIn: _signIn,
|
||||
createAccount: _createAccount,
|
||||
continueOffline: _continueOffline,
|
||||
signOut: _signOut,
|
||||
navigate: _navigate,
|
||||
selectSlot: _selectSlot,
|
||||
createSlot: _createSlot,
|
||||
playSlot: _playSlot,
|
||||
deleteSlot: _deleteSlot,
|
||||
copySlot: _copySlot,
|
||||
uploadSlot: _uploadSlot,
|
||||
downloadSlot: _downloadSlot,
|
||||
selectMode: _selectMode,
|
||||
selectBoss: _selectBoss,
|
||||
selectHealerClass: _selectHealerClass,
|
||||
updateActiveHealerInventory: _updateActiveHealerInventory,
|
||||
updateSetting: _updateSetting,
|
||||
touchActiveSave: _touchActiveSave,
|
||||
recordBossVictory: _recordBossVictory,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
} = useFrontendStore.getState();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useActiveHunter(): HunterSave | null {
|
||||
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user