diff --git a/src/adapters/storage/browser-storage-adapter.js b/src/adapters/storage/browser-storage-adapter.js new file mode 100644 index 0000000..e05c5bb --- /dev/null +++ b/src/adapters/storage/browser-storage-adapter.js @@ -0,0 +1,137 @@ +import { createFailure } from "../../contracts/errors.js"; +import { getStorageDefinition } from "../../contracts/storage-keys.js"; + +/** + * @param {{ + * localStorage?: Storage, + * sessionStorage?: Storage, + * now?: () => number + * }} [dependencies] + * @returns {import("../../application/ports/storage-port.js").StoragePort} + */ +export function createBrowserStorageAdapter(dependencies = {}) { + const memory = new Map(); + const now = dependencies.now ?? Date.now; + + /** @param {string} name */ + function backendFor(name) { + if (name === "localStorage") return dependencies.localStorage; + if (name === "sessionStorage") return dependencies.sessionStorage; + return undefined; + } + + return Object.freeze({ + read(logicalName) { + let definition; + try { + definition = getStorageDefinition(logicalName); + } catch { + return unavailable("read", logicalName); + } + + const backend = backendFor(definition.backend); + try { + const raw = backend?.getItem(definition.physicalKey); + if (raw === null || raw === undefined) { + return { ok: true, value: memory.get(definition.physicalKey) }; + } + const envelope = JSON.parse(raw); + if ( + !envelope || + typeof envelope !== "object" || + envelope.schemaVersion !== definition.schemaVersion + ) { + backend?.removeItem(definition.physicalKey); + return { ok: true, value: undefined }; + } + if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) { + backend?.removeItem(definition.physicalKey); + return { ok: true, value: undefined }; + } + return { ok: true, value: structuredClone(envelope.value) }; + } catch { + return unavailable("read", logicalName); + } + }, + + write(logicalName, value) { + let definition; + try { + definition = getStorageDefinition(logicalName); + } catch { + return unavailable("write", logicalName); + } + + const expiresAt = + typeof definition.ttl === "number" ? now() + definition.ttl : null; + const envelope = { + schemaVersion: definition.schemaVersion, + expiresAt, + value: structuredClone(value), + }; + const backend = backendFor(definition.backend); + + try { + if (!backend) throw new DOMException("Storage unavailable", "SecurityError"); + backend.setItem(definition.physicalKey, JSON.stringify(envelope)); + return { ok: true }; + } catch (error) { + const quota = + error instanceof DOMException && + ["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name); + + if (definition.quotaFallback === "memory") { + memory.set(definition.physicalKey, structuredClone(value)); + return { + ok: false, + error: storageFailure(quota, "write", logicalName), + fallback: "memory", + }; + } + return { + ok: false, + error: storageFailure(quota, "write", logicalName), + fallback: definition.quotaFallback, + }; + } + }, + + remove(logicalName) { + let definition; + try { + definition = getStorageDefinition(logicalName); + } catch { + return unavailable("remove", logicalName); + } + try { + backendFor(definition.backend)?.removeItem(definition.physicalKey); + memory.delete(definition.physicalKey); + return { ok: true }; + } catch { + return unavailable("remove", logicalName); + } + }, + }); +} + +/** @param {boolean} quota @param {string} phase @param {string} logicalName */ +function storageFailure(quota, phase, logicalName) { + return createFailure( + quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE", + "STORAGE", + 0, + { + code: `${logicalName}_${phase.toUpperCase()}_${ + quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE" + }`, + }, + ); +} + +/** @param {string} phase @param {string} logicalName */ +function unavailable(phase, logicalName) { + return { + ok: /** @type {false} */ (false), + error: storageFailure(false, phase, logicalName), + }; +} diff --git a/src/application/ports/storage-port.js b/src/application/ports/storage-port.js index 98dae43..e200efd 100644 --- a/src/application/ports/storage-port.js +++ b/src/application/ports/storage-port.js @@ -1,8 +1,12 @@ /** * @typedef {{ - * read(logicalName: string): { ok: true, value: unknown } | { ok: false, error: unknown }, - * write(logicalName: string, value: unknown): { ok: true } | { ok: false, error: unknown }, - * remove(logicalName: string): { ok: true } | { ok: false, error: unknown } + * read(logicalName: string): { ok: true, value: unknown } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure }, + * write(logicalName: string, value: unknown): { ok: true } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure, + * fallback?: string }, + * remove(logicalName: string): { ok: true } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure } * }} StoragePort */ diff --git a/src/contracts/storage-keys.js b/src/contracts/storage-keys.js new file mode 100644 index 0000000..2e3e2fc --- /dev/null +++ b/src/contracts/storage-keys.js @@ -0,0 +1,102 @@ +const APP_NAMESPACE = "ca-frontend"; + +export const STORAGE_REGISTRY = Object.freeze({ + COLOR_SCHEME: defineStorageKey({ + logicalName: "COLOR_SCHEME", + scope: "preference", + name: "color-scheme", + backend: "localStorage", + classification: "public-preference", + schemaVersion: 1, + ttl: null, + migration: "discard", + quotaFallback: "memory", + }), + CHUNK_RELOAD_GUARD: defineStorageKey({ + logicalName: "CHUNK_RELOAD_GUARD", + scope: "release", + name: "chunk-reload-guard", + backend: "sessionStorage", + classification: "opaque-cache", + schemaVersion: 1, + ttl: "session", + migration: "discard", + quotaFallback: "no-persist", + }), + QUERY_PERSISTENCE: defineStorageKey({ + logicalName: "QUERY_PERSISTENCE", + scope: "cache", + name: "query-persistence", + backend: "disabled", + classification: "sensitive-forbidden", + schemaVersion: 1, + ttl: null, + migration: "discard", + quotaFallback: "feature-disable", + }), + AUTH_TOKEN: defineStorageKey({ + logicalName: "AUTH_TOKEN", + scope: "auth", + name: "auth-token", + backend: "forbidden", + classification: "sensitive-forbidden", + schemaVersion: 1, + ttl: null, + migration: "discard", + quotaFallback: "feature-disable", + }), +}); + +/** + * @typedef {{ + * logicalName: string, + * scope: string, + * name: string, + * backend: "memory" | "sessionStorage" | "localStorage" | "indexedDB" | + * "disabled" | "forbidden", + * classification: "public-preference" | "opaque-cache" | "sensitive-forbidden", + * schemaVersion: number, + * ttl: number | "session" | null, + * migration: "discard" | ((value: unknown) => unknown), + * quotaFallback: "memory" | "no-persist" | "feature-disable" + * }} StorageKeyInput + */ + +/** @param {StorageKeyInput} definition */ +export function defineStorageKey(definition) { + if (definition.classification === "sensitive-forbidden") { + if (!["disabled", "forbidden"].includes(definition.backend)) { + throw new Error("Sensitive client storage registration is forbidden"); + } + } + if (!Number.isInteger(definition.schemaVersion) || definition.schemaVersion < 1) { + throw new Error("Storage schemaVersion must be a positive integer"); + } + + return Object.freeze({ + ...definition, + physicalKey: buildPhysicalKey( + definition.scope, + definition.schemaVersion, + definition.name, + ), + }); +} + +/** @param {string} scope @param {number} schemaVersion @param {string} name */ +export function buildPhysicalKey(scope, schemaVersion, name) { + return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`; +} + +/** @param {string} logicalName */ +export function getStorageDefinition(logicalName) { + const registry = /** @type {Record>} */ ( + STORAGE_REGISTRY + ); + const definition = registry[logicalName]; + if (!definition) throw new Error(`Unregistered storage key: ${logicalName}`); + if (definition.classification === "sensitive-forbidden") { + throw new Error(`Forbidden storage key: ${logicalName}`); + } + return definition; +} diff --git a/tests/unit/storage-registry.test.js b/tests/unit/storage-registry.test.js new file mode 100644 index 0000000..fa75250 --- /dev/null +++ b/tests/unit/storage-registry.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.js"; +import { + STORAGE_REGISTRY, + buildPhysicalKey, + defineStorageKey, +} from "../../src/contracts/storage-keys.js"; + +function createStorage({ quota = false } = {}) { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + if (quota) throw new DOMException("full", "QuotaExceededError"); + values.set(key, value); + }, + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + key: () => null, + get length() { + return values.size; + }, + }; +} + +describe("storage registry", () => { + it("builds namespace and schema-versioned physical keys", () => { + expect(buildPhysicalKey("preference", 2, "theme")).toBe( + "ca-frontend:preference:v2:theme", + ); + expect(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey).toContain(":v1:"); + }); + + it("rejects token or secret persistence registrations", () => { + expect(() => + defineStorageKey({ + logicalName: "TOKEN", + scope: "auth", + name: "token", + backend: "localStorage", + classification: "sensitive-forbidden", + schemaVersion: 1, + ttl: null, + migration: "discard", + quotaFallback: "feature-disable", + }), + ).toThrow("Sensitive client storage registration is forbidden"); + }); + + it("round-trips public preferences through the adapter", () => { + const localStorage = createStorage(); + const adapter = createBrowserStorageAdapter({ localStorage }); + expect(adapter.write("COLOR_SCHEME", "dark")).toEqual({ ok: true }); + expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" }); + }); + + it("falls back to memory when preference storage quota is exceeded", () => { + const localStorage = createStorage({ quota: true }); + const adapter = createBrowserStorageAdapter({ localStorage }); + expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({ + ok: false, + fallback: "memory", + error: { kind: "STORAGE_QUOTA_EXCEEDED" }, + }); + expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" }); + }); + + it("discards data from a previous schema version", () => { + const localStorage = createStorage(); + localStorage.setItem( + STORAGE_REGISTRY.COLOR_SCHEME.physicalKey, + JSON.stringify({ schemaVersion: 0, value: "dark" }), + ); + const adapter = createBrowserStorageAdapter({ localStorage }); + expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined }); + }); +});