Release v0.1.1 2026-07-10

This commit is contained in:
Warren H
2026-07-10 23:47:37 -04:00
parent 537a311f52
commit 6726c600e4
40 changed files with 2987 additions and 192 deletions
+132
View File
@@ -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));
}
}