340 lines
9.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
});
|
|
}
|