Files
clean-architecture-frontend…/src/adapters/storage/indexeddb/indexeddb-governance.ts
T

340 lines
9.9 KiB
TypeScript

import type { IndexedDbDatasetScope } from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
import {
assertValidStoragePolicy,
type BrowserStoragePolicy,
} from "../../../application/ports/browser-file-storage/shared.ts";
export const INDEXEDDB_DATASET_BINDING_KEY = "dataset-binding";
export const INDEXEDDB_DATASET_BUDGET_KEY = "dataset-budget";
const OPAQUE_SCOPE_TOKEN = /^[A-Za-z0-9_-]{16,48}$/u;
type StoredDatasetBinding = Readonly<{
bindingKey: typeof INDEXEDDB_DATASET_BINDING_KEY;
bindingVersion: 1;
scope: IndexedDbDatasetScope;
storagePolicy: BrowserStoragePolicy;
}>;
export type IndexedDbBindingVerification =
| Readonly<{ ok: true }>
| Readonly<{
ok: false;
reason: "ABORTED" | "CORRUPT" | "MISMATCH" | "MISSING" | "NATIVE_ERROR";
error?: unknown;
}>;
function validOpaqueToken(value: unknown): value is string {
return typeof value === "string" && OPAQUE_SCOPE_TOKEN.test(value);
}
export function assertValidIndexedDbDatasetGovernance(
scope: IndexedDbDatasetScope,
storagePolicy: BrowserStoragePolicy,
): void {
assertValidStoragePolicy(storagePolicy);
if (
!scope ||
typeof scope !== "object" ||
!validOpaqueToken(scope.authorityToken) ||
!validOpaqueToken(scope.namespaceToken) ||
!validOpaqueToken(scope.partitionToken) ||
new Set([
scope.authorityToken,
scope.namespaceToken,
scope.partitionToken,
]).size !== 3 ||
scope.accountScope !== storagePolicy.accountScope ||
scope.authorityToken === storagePolicy.owner ||
scope.namespaceToken === storagePolicy.namespace ||
scope.partitionToken === storagePolicy.namespace ||
(storagePolicy.classification === "PERSONAL" &&
scope.accountScope !== "OPAQUE_PARTITION") ||
(storagePolicy.classification === "CONFIDENTIAL" &&
scope.accountScope !== "OPAQUE_PARTITION")
) {
throw new TypeError("IndexedDB dataset governance is invalid.");
}
}
/**
* Physical identity is derived exclusively from opaque registry tokens. The
* readable policy namespace and all business/account identifiers are excluded.
*/
export function indexedDbPhysicalDatabaseName(
scope: IndexedDbDatasetScope,
): string {
if (
!scope ||
typeof scope !== "object" ||
!validOpaqueToken(scope.authorityToken) ||
!validOpaqueToken(scope.namespaceToken) ||
!validOpaqueToken(scope.partitionToken)
) {
throw new TypeError("IndexedDB dataset scope is invalid.");
}
return `ca-idb-v1:${scope.authorityToken}.${scope.namespaceToken}.${scope.partitionToken}`;
}
export function createIndexedDbDatasetBinding(
scope: IndexedDbDatasetScope,
storagePolicy: BrowserStoragePolicy,
): StoredDatasetBinding {
assertValidIndexedDbDatasetGovernance(scope, storagePolicy);
return Object.freeze({
bindingKey: INDEXEDDB_DATASET_BINDING_KEY,
bindingVersion: 1,
scope: Object.freeze({ ...scope }),
storagePolicy: Object.freeze({
...storagePolicy,
retention: Object.freeze({ ...storagePolicy.retention }),
}),
});
}
function isStoredDatasetBinding(
value: unknown,
): value is StoredDatasetBinding {
if (!value || typeof value !== "object") return false;
const binding = value as Partial<StoredDatasetBinding>;
if (
binding.bindingKey !== INDEXEDDB_DATASET_BINDING_KEY ||
binding.bindingVersion !== 1 ||
!binding.scope ||
!binding.storagePolicy
) {
return false;
}
try {
assertValidIndexedDbDatasetGovernance(
binding.scope,
binding.storagePolicy,
);
return true;
} catch {
return false;
}
}
function canonicalPolicy(policy: BrowserStoragePolicy): string {
return JSON.stringify([
policy.owner,
policy.namespace,
policy.classification,
policy.authority,
policy.accountScope,
policy.retention.kind,
policy.retention.kind === "TTL"
? policy.retention.maxAgeMs
: null,
policy.softBudgetBytes,
policy.hardBudgetBytes,
policy.evictionPriority,
policy.logoutAction,
policy.accountDeletionAction,
policy.pressureAction,
policy.unavailableFallback,
]);
}
export function sameIndexedDbDatasetBinding(
value: unknown,
expected: StoredDatasetBinding,
): boolean {
if (!isStoredDatasetBinding(value)) return false;
return (
value.scope.authorityToken === expected.scope.authorityToken &&
value.scope.namespaceToken === expected.scope.namespaceToken &&
value.scope.partitionToken === expected.scope.partitionToken &&
value.scope.accountScope === expected.scope.accountScope &&
canonicalPolicy(value.storagePolicy) ===
canonicalPolicy(expected.storagePolicy)
);
}
/**
* Queues binding validation inside the versionchange transaction. Any mismatch
* aborts that transaction, so schema changes cannot commit under the wrong
* namespace or policy.
*/
export function queueIndexedDbUpgradeBinding(
transaction: IDBTransaction,
governanceStore: string,
expected: StoredDatasetBinding,
oldVersion: number,
onRejected: () => void,
): void {
const store = transaction.objectStore(governanceStore);
if (oldVersion === 0) {
let addRequest: IDBRequest<IDBValidKey>;
try {
addRequest = store.add(expected);
} catch {
onRejected();
transaction.abort();
return;
}
addRequest.onerror = () => onRejected();
let budgetRequest: IDBRequest<IDBValidKey>;
try {
budgetRequest = store.add(
Object.freeze({
bindingKey: INDEXEDDB_DATASET_BUDGET_KEY,
budgetVersion: 1,
usedBytes: 0,
receiptCount: 0,
}),
);
} catch {
onRejected();
transaction.abort();
return;
}
budgetRequest.onerror = () => onRejected();
return;
}
const request = store.get(INDEXEDDB_DATASET_BINDING_KEY);
request.onerror = () => {
onRejected();
try {
transaction.abort();
} catch {
// The native request/transaction error owns the terminal state.
}
};
request.onsuccess = () => {
if (!sameIndexedDbDatasetBinding(request.result, expected)) {
onRejected();
try {
transaction.abort();
} catch {
// The mismatch remains fail-closed even if abort already won.
}
}
};
}
/**
* Post-open verification protects non-upgrade opens and maintenance callers.
*/
export function verifyIndexedDbDatasetBinding(
database: IDBDatabase,
governanceStore: string,
expected: StoredDatasetBinding,
signal?: AbortSignal,
): Promise<IndexedDbBindingVerification> {
if (signal?.aborted) {
return Promise.resolve({ ok: false, reason: "ABORTED" });
}
let transaction: IDBTransaction;
try {
transaction = database.transaction(governanceStore, "readonly");
} catch (error) {
return Promise.resolve({
ok: false,
reason: "NATIVE_ERROR",
error,
});
}
return new Promise((resolve) => {
let settled = false;
let observed: unknown;
let observedBudget: unknown;
let requestError: unknown;
let callerAborted = false;
const finish = (result: IndexedDbBindingVerification) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", onAbort);
resolve(result);
};
function onAbort(): void {
callerAborted = true;
try {
transaction.abort();
} catch {
// Completion determines the race.
}
}
signal?.addEventListener("abort", onAbort, { once: true });
transaction.onerror = () => {
requestError ??= transaction.error;
};
transaction.onabort = () =>
finish(
callerAborted
? { ok: false, reason: "ABORTED" }
: {
ok: false,
reason: "NATIVE_ERROR",
error: requestError ?? transaction.error,
},
);
transaction.oncomplete = () => {
if (observed === undefined) {
finish({ ok: false, reason: "MISSING" });
} else if (!isStoredDatasetBinding(observed)) {
finish({ ok: false, reason: "CORRUPT" });
} else if (!sameIndexedDbDatasetBinding(observed, expected)) {
finish({ ok: false, reason: "MISMATCH" });
} else if (
!observedBudget ||
typeof observedBudget !== "object" ||
(observedBudget as { bindingKey?: unknown }).bindingKey !==
INDEXEDDB_DATASET_BUDGET_KEY ||
(observedBudget as { budgetVersion?: unknown }).budgetVersion !==
1 ||
!Number.isSafeInteger(
(observedBudget as { usedBytes?: unknown }).usedBytes,
) ||
typeof (observedBudget as { usedBytes?: unknown }).usedBytes !==
"number" ||
(observedBudget as { usedBytes: number }).usedBytes < 0 ||
(observedBudget as { usedBytes: number }).usedBytes >
expected.storagePolicy.hardBudgetBytes
||
!Number.isSafeInteger(
(observedBudget as { receiptCount?: unknown }).receiptCount,
) ||
typeof (observedBudget as { receiptCount?: unknown })
.receiptCount !== "number" ||
(observedBudget as { receiptCount: number }).receiptCount < 0
) {
finish({ ok: false, reason: "CORRUPT" });
} else {
finish({ ok: true });
}
};
try {
const request = transaction
.objectStore(governanceStore)
.get(INDEXEDDB_DATASET_BINDING_KEY);
request.onerror = () => {
requestError ??= request.error;
};
request.onsuccess = () => {
observed = request.result;
};
const budgetRequest = transaction
.objectStore(governanceStore)
.get(INDEXEDDB_DATASET_BUDGET_KEY);
budgetRequest.onerror = () => {
requestError ??= budgetRequest.error;
};
budgetRequest.onsuccess = () => {
observedBudget = budgetRequest.result;
};
} catch (error) {
requestError = error;
try {
transaction.abort();
} catch {
finish({ ok: false, reason: "NATIVE_ERROR", error });
}
}
});
}