feat: enforce classified browser storage registry
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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<string, ReturnType<typeof defineStorageKey>>} */ (
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user