WP-01: a CAS receipt is only evidence when it names the expected key and the
exact next revision. Write and remove now share one validator, so a stale or
arbitrary repository receipt can no longer be packaged as a confirmed control.
WP-05: a pre-aborted command records the operation the caller requested instead
of always reporting an inspection.
WP-06: bounded fan-out is reported honestly. The subscriptionchange client
handoff and the notification cleanup both emit countBucket and truncated, and an
incomplete cleanup returns { complete: false } and is observed as DEGRADED
separately from revoke authority.
WP-07: the user-visible native notification effect is tracked through
NOT_APPLIED, MAYBE_APPLIED and CONFIRMED phases and surfaced as observation
evidence, never as retry authorization.
WP-02, WP-03 and WP-04 stay open: they need the V2 wire protocol with server
request-shape negotiation, which belongs to the versioned-migration task rather
than this correctness pass. The ledger records them as DEFERRED_TO_MIGRATION.
Web Push remains NOT_SELECTED and AVAILABLE_NOT_COMPOSED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
742 lines
21 KiB
TypeScript
742 lines
21 KiB
TypeScript
import {
|
|
WEB_PUSH_LIMITS,
|
|
WEB_PUSH_PROTOCOLS,
|
|
samePushAuthority,
|
|
webPushFailure,
|
|
webPushSuccess,
|
|
type PushAuthoritySnapshot,
|
|
type PushControlAssociationV1,
|
|
type PushControlV1,
|
|
type WebPushOperation,
|
|
type WebPushResult,
|
|
} from "../../contracts/web-push.ts";
|
|
import {
|
|
withAbortableDeadline,
|
|
type TimeoutScheduler,
|
|
} from "./runtime-support.ts";
|
|
|
|
const CONTROL_KEY = "push-control-v1";
|
|
const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
const ISO_INSTANT =
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
|
|
|
|
export type PushControlReceipt = Readonly<{
|
|
control: PushControlV1;
|
|
revision: number;
|
|
}>;
|
|
|
|
export type PushControlWriteReceipt = Readonly<{
|
|
key: string;
|
|
revision: number;
|
|
replayed: boolean;
|
|
}>;
|
|
|
|
/**
|
|
* The failure surface this adapter consumes. `code` is intentionally a plain
|
|
* string: the storage taxonomy is owned by whichever runtime backs the store,
|
|
* and an unrecognised code maps to the safe default in
|
|
* {@link mapRepositoryFailure} rather than failing to compile.
|
|
*/
|
|
export type PushControlStoreFailure = Readonly<{
|
|
code: string;
|
|
retryable: boolean;
|
|
}>;
|
|
|
|
export type PushControlStoreResult<Value> =
|
|
| Readonly<{ ok: true; value: Value }>
|
|
| Readonly<{ ok: false; error: PushControlStoreFailure }>;
|
|
|
|
/**
|
|
* The narrow durable store this capability requires: revisioned
|
|
* compare-and-swap over a single key.
|
|
*
|
|
* It is declared here, structurally, rather than imported from the browser
|
|
* file/storage port so the two capabilities stay independently removable. The
|
|
* generic IndexedDB repository satisfies it as-is; the composition root is
|
|
* where the two are joined, and it owns connection, migration, transaction,
|
|
* timeout, codec and version-change policy.
|
|
*/
|
|
export type PushControlRepository = Readonly<{
|
|
open(signal?: AbortSignal): Promise<PushControlStoreResult<void>>;
|
|
read(
|
|
key: string,
|
|
signal?: AbortSignal,
|
|
): Promise<
|
|
PushControlStoreResult<
|
|
Readonly<{ value: PushControlV1; revision: number }> | null
|
|
>
|
|
>;
|
|
compareAndSwap(
|
|
input: Readonly<{
|
|
key: string;
|
|
value: PushControlV1;
|
|
expectedRevision: number | null;
|
|
idempotencyKey: string;
|
|
signal?: AbortSignal;
|
|
}>,
|
|
): Promise<PushControlStoreResult<PushControlWriteReceipt>>;
|
|
remove(
|
|
input: Readonly<{
|
|
key: string;
|
|
expectedRevision: number | null;
|
|
idempotencyKey: string;
|
|
signal?: AbortSignal;
|
|
}>,
|
|
): Promise<PushControlStoreResult<PushControlWriteReceipt>>;
|
|
close(): void;
|
|
}>;
|
|
|
|
export interface PushAssociationFenceStore {
|
|
read(input?: Readonly<{
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<PushControlReceipt | null>>;
|
|
|
|
prepare(input: Readonly<{
|
|
authority: PushAuthoritySnapshot;
|
|
updatedAt: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<PushControlReceipt>>;
|
|
|
|
activate(input: Readonly<{
|
|
expectedRevision: number;
|
|
authority: PushAuthoritySnapshot;
|
|
associationEpoch: string;
|
|
updatedAt: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<PushControlReceipt>>;
|
|
|
|
markRevoked(input: Readonly<{
|
|
expectedRevision: number;
|
|
authority: PushAuthoritySnapshot;
|
|
updatedAt: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<PushControlReceipt>>;
|
|
|
|
rotateAndRevoke(input: Readonly<{
|
|
expectedRevision: number;
|
|
previousAuthority: PushAuthoritySnapshot;
|
|
nextAuthority: PushAuthoritySnapshot;
|
|
updatedAt: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<PushControlReceipt>>;
|
|
|
|
purge(input: Readonly<{
|
|
expectedRevision: number;
|
|
authority: PushAuthoritySnapshot;
|
|
associationEpoch: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<void>>;
|
|
|
|
close(): void;
|
|
}
|
|
|
|
export type PushAssociationFenceStoreDependencies = Readonly<{
|
|
repository: PushControlRepository;
|
|
idempotencyKeyFactory?: () => string;
|
|
operationDeadlineMs?: number;
|
|
scheduler?: TimeoutScheduler;
|
|
}>;
|
|
|
|
export function createPushAssociationFenceStore(
|
|
dependencies: PushAssociationFenceStoreDependencies,
|
|
): PushAssociationFenceStore {
|
|
if (
|
|
!dependencies ||
|
|
typeof dependencies !== "object" ||
|
|
!validRepository(dependencies.repository)
|
|
) {
|
|
throw new TypeError("Web Push fence store configuration is invalid.");
|
|
}
|
|
const repository = dependencies.repository;
|
|
const operationDeadlineMs =
|
|
dependencies.operationDeadlineMs ??
|
|
WEB_PUSH_LIMITS.fenceOperationDeadlineMs;
|
|
if (
|
|
!Number.isSafeInteger(operationDeadlineMs) ||
|
|
operationDeadlineMs < 1 ||
|
|
operationDeadlineMs > WEB_PUSH_LIMITS.fenceOperationDeadlineMs
|
|
) {
|
|
throw new TypeError("Web Push fence deadline is invalid.");
|
|
}
|
|
const idempotencyKeyFactory =
|
|
dependencies.idempotencyKeyFactory ??
|
|
(() => `push-control-${globalThis.crypto.randomUUID()}`);
|
|
let closed = false;
|
|
|
|
const store: PushAssociationFenceStore = {
|
|
async read(input = {}) {
|
|
return await bounded(
|
|
"CONTROL_READ",
|
|
input.signal,
|
|
(signal) => readReceipt("CONTROL_READ", signal),
|
|
);
|
|
},
|
|
|
|
async prepare(input) {
|
|
if (
|
|
!validAuthority(input.authority) ||
|
|
!validInstant(input.updatedAt)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "CONTROL_PREPARE");
|
|
}
|
|
return await bounded(
|
|
"CONTROL_PREPARE",
|
|
input.signal,
|
|
async (signal) => {
|
|
const current = await readReceipt(
|
|
"CONTROL_PREPARE",
|
|
signal,
|
|
);
|
|
if (!current.ok) return current;
|
|
if (current.value) {
|
|
return samePushAuthority(
|
|
current.value.control,
|
|
input.authority,
|
|
)
|
|
? webPushSuccess(current.value)
|
|
: webPushFailure(
|
|
"STALE_AUTHORITY",
|
|
"CONTROL_PREPARE",
|
|
);
|
|
}
|
|
return await compareAndSwap(
|
|
"CONTROL_PREPARE",
|
|
null,
|
|
controlSnapshot({
|
|
protocol: WEB_PUSH_PROTOCOLS.control,
|
|
...input.authority,
|
|
updatedAt: input.updatedAt,
|
|
association: Object.freeze({
|
|
state: "UNASSOCIATED",
|
|
}),
|
|
}),
|
|
signal,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
|
|
async activate(input) {
|
|
if (
|
|
!validRevision(input.expectedRevision) ||
|
|
!validAuthority(input.authority) ||
|
|
!validOpaqueId(input.associationEpoch) ||
|
|
!validInstant(input.updatedAt)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "CONTROL_ACTIVATE");
|
|
}
|
|
return await bounded(
|
|
"CONTROL_ACTIVATE",
|
|
input.signal,
|
|
async (signal) => {
|
|
const current = await currentForMutation(
|
|
"CONTROL_ACTIVATE",
|
|
input.expectedRevision,
|
|
input.authority,
|
|
signal,
|
|
);
|
|
if (!current.ok) return current;
|
|
if (
|
|
current.value.control.association.state === "ACTIVE" &&
|
|
current.value.control.association.associationEpoch ===
|
|
input.associationEpoch
|
|
) {
|
|
return current;
|
|
}
|
|
if (
|
|
current.value.control.association.state === "REVOKED" &&
|
|
current.value.control.association.associationEpoch ===
|
|
input.associationEpoch
|
|
) {
|
|
return webPushFailure(
|
|
"TOMBSTONE_CONFLICT",
|
|
"CONTROL_ACTIVATE",
|
|
);
|
|
}
|
|
return await compareAndSwap(
|
|
"CONTROL_ACTIVATE",
|
|
input.expectedRevision,
|
|
controlSnapshot({
|
|
...current.value.control,
|
|
updatedAt: input.updatedAt,
|
|
association: Object.freeze({
|
|
state: "ACTIVE",
|
|
associationEpoch: input.associationEpoch,
|
|
}),
|
|
}),
|
|
signal,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
|
|
async markRevoked(input) {
|
|
if (
|
|
!validRevision(input.expectedRevision) ||
|
|
!validAuthority(input.authority) ||
|
|
!validInstant(input.updatedAt)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE");
|
|
}
|
|
return await bounded(
|
|
"CONTROL_REVOKE",
|
|
input.signal,
|
|
async (signal) => {
|
|
const current = await currentForMutation(
|
|
"CONTROL_REVOKE",
|
|
input.expectedRevision,
|
|
input.authority,
|
|
signal,
|
|
);
|
|
if (!current.ok) return current;
|
|
if (
|
|
current.value.control.association.state ===
|
|
"UNASSOCIATED"
|
|
) {
|
|
return current;
|
|
}
|
|
return await compareAndSwap(
|
|
"CONTROL_REVOKE",
|
|
input.expectedRevision,
|
|
controlSnapshot({
|
|
...current.value.control,
|
|
updatedAt: input.updatedAt,
|
|
association: Object.freeze({
|
|
state: "REVOKED",
|
|
associationEpoch:
|
|
current.value.control.association
|
|
.associationEpoch,
|
|
}),
|
|
}),
|
|
signal,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
|
|
async rotateAndRevoke(input) {
|
|
if (
|
|
!validRevision(input.expectedRevision) ||
|
|
!validAuthority(input.previousAuthority) ||
|
|
!validAuthority(input.nextAuthority) ||
|
|
input.previousAuthority.fenceGeneration ===
|
|
input.nextAuthority.fenceGeneration ||
|
|
!validInstant(input.updatedAt)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE");
|
|
}
|
|
return await bounded(
|
|
"CONTROL_REVOKE",
|
|
input.signal,
|
|
async (signal) => {
|
|
const current = await currentForMutation(
|
|
"CONTROL_REVOKE",
|
|
input.expectedRevision,
|
|
input.previousAuthority,
|
|
signal,
|
|
);
|
|
if (!current.ok) return current;
|
|
const association: PushControlAssociationV1 =
|
|
current.value.control.association.state ===
|
|
"UNASSOCIATED"
|
|
? Object.freeze({ state: "UNASSOCIATED" })
|
|
: Object.freeze({
|
|
state: "REVOKED",
|
|
associationEpoch:
|
|
current.value.control.association
|
|
.associationEpoch,
|
|
});
|
|
return await compareAndSwap(
|
|
"CONTROL_REVOKE",
|
|
input.expectedRevision,
|
|
controlSnapshot({
|
|
protocol: WEB_PUSH_PROTOCOLS.control,
|
|
...input.nextAuthority,
|
|
updatedAt: input.updatedAt,
|
|
association,
|
|
}),
|
|
signal,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
|
|
async purge(input) {
|
|
if (
|
|
!validRevision(input.expectedRevision) ||
|
|
!validAuthority(input.authority) ||
|
|
!validOpaqueId(input.associationEpoch)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "CONTROL_PURGE");
|
|
}
|
|
return await bounded(
|
|
"CONTROL_PURGE",
|
|
input.signal,
|
|
async (signal) => {
|
|
const current = await currentForMutation(
|
|
"CONTROL_PURGE",
|
|
input.expectedRevision,
|
|
input.authority,
|
|
signal,
|
|
);
|
|
if (!current.ok) return current;
|
|
if (
|
|
current.value.control.association.state !== "REVOKED" ||
|
|
current.value.control.association.associationEpoch !==
|
|
input.associationEpoch
|
|
) {
|
|
return webPushFailure(
|
|
"ASSOCIATION_MISMATCH",
|
|
"CONTROL_PURGE",
|
|
);
|
|
}
|
|
return await removeControl(input.expectedRevision, signal);
|
|
},
|
|
);
|
|
},
|
|
|
|
close() {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
repository.close();
|
|
} catch {
|
|
// The local authority is terminal even if host cleanup throws.
|
|
}
|
|
},
|
|
};
|
|
|
|
return Object.freeze(store);
|
|
|
|
async function bounded<Value>(
|
|
operation: WebPushOperation,
|
|
signal: AbortSignal | undefined,
|
|
task: (
|
|
boundedSignal: AbortSignal,
|
|
) => Promise<WebPushResult<Value>>,
|
|
): Promise<WebPushResult<Value>> {
|
|
return await withAbortableDeadline(task, {
|
|
deadlineMs: operationDeadlineMs,
|
|
operation,
|
|
signal,
|
|
scheduler: dependencies.scheduler,
|
|
});
|
|
}
|
|
|
|
async function currentForMutation(
|
|
operation:
|
|
| "CONTROL_ACTIVATE"
|
|
| "CONTROL_REVOKE"
|
|
| "CONTROL_PURGE",
|
|
expectedRevision: number,
|
|
authority: PushAuthoritySnapshot,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<WebPushResult<PushControlReceipt>> {
|
|
const current = await readReceipt(operation, signal);
|
|
if (!current.ok) return current;
|
|
if (!current.value || current.value.revision !== expectedRevision) {
|
|
return webPushFailure("STALE_REVISION", operation);
|
|
}
|
|
if (!samePushAuthority(current.value.control, authority)) {
|
|
return webPushFailure("STALE_AUTHORITY", operation);
|
|
}
|
|
return webPushSuccess(current.value);
|
|
}
|
|
|
|
async function readReceipt(
|
|
operation: WebPushOperation,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<WebPushResult<PushControlReceipt | null>> {
|
|
if (closed) return webPushFailure("NATIVE_FAILURE", operation);
|
|
if (signal?.aborted) return webPushFailure("ABORTED", operation);
|
|
const opened = await callRepository(
|
|
() => repository.open(signal),
|
|
operation,
|
|
);
|
|
if (!opened.ok) return opened;
|
|
const read = await callRepository(
|
|
() => repository.read(CONTROL_KEY, signal),
|
|
operation,
|
|
);
|
|
if (!read.ok) return read;
|
|
if (!read.value) return webPushSuccess(null);
|
|
const control = decodeControl(read.value.value);
|
|
if (!control || !validRevision(read.value.revision)) {
|
|
return webPushFailure("CONTROL_CORRUPT", operation);
|
|
}
|
|
return webPushSuccess(
|
|
Object.freeze({
|
|
control,
|
|
revision: read.value.revision,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function compareAndSwap(
|
|
operation:
|
|
| "CONTROL_PREPARE"
|
|
| "CONTROL_ACTIVATE"
|
|
| "CONTROL_REVOKE",
|
|
expectedRevision: number | null,
|
|
control: PushControlV1,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<WebPushResult<PushControlReceipt>> {
|
|
let idempotencyKey: string;
|
|
try {
|
|
idempotencyKey = idempotencyKeyFactory();
|
|
} catch {
|
|
return webPushFailure("NATIVE_FAILURE", operation);
|
|
}
|
|
if (!validIdempotencyKey(idempotencyKey)) {
|
|
return webPushFailure("INVALID_INPUT", operation);
|
|
}
|
|
const written = await callRepository(
|
|
() =>
|
|
repository.compareAndSwap({
|
|
key: CONTROL_KEY,
|
|
value: control,
|
|
expectedRevision,
|
|
idempotencyKey,
|
|
...(signal ? { signal } : {}),
|
|
}),
|
|
operation,
|
|
);
|
|
if (!written.ok) return written;
|
|
if (!validWriteReceipt(written.value, expectedRevision)) {
|
|
return webPushFailure("CONTROL_CORRUPT", operation);
|
|
}
|
|
return webPushSuccess(
|
|
Object.freeze({
|
|
control,
|
|
revision: written.value.revision,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function removeControl(
|
|
expectedRevision: number,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<WebPushResult<void>> {
|
|
let idempotencyKey: string;
|
|
try {
|
|
idempotencyKey = idempotencyKeyFactory();
|
|
} catch {
|
|
return webPushFailure("NATIVE_FAILURE", "CONTROL_PURGE");
|
|
}
|
|
if (!validIdempotencyKey(idempotencyKey)) {
|
|
return webPushFailure("INVALID_INPUT", "CONTROL_PURGE");
|
|
}
|
|
const removed = await callRepository(
|
|
() =>
|
|
repository.remove({
|
|
key: CONTROL_KEY,
|
|
expectedRevision,
|
|
idempotencyKey,
|
|
...(signal ? { signal } : {}),
|
|
}),
|
|
"CONTROL_PURGE",
|
|
);
|
|
if (!removed.ok) return removed;
|
|
if (!validWriteReceipt(removed.value, expectedRevision)) {
|
|
return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE");
|
|
}
|
|
return webPushSuccess(undefined);
|
|
}
|
|
}
|
|
|
|
async function callRepository<Value>(
|
|
call: () => Promise<PushControlStoreResult<Value>>,
|
|
operation: WebPushOperation,
|
|
): Promise<WebPushResult<Value>> {
|
|
try {
|
|
const result = await call();
|
|
return result.ok
|
|
? webPushSuccess(result.value)
|
|
: mapRepositoryFailure(result.error, operation);
|
|
} catch {
|
|
return webPushFailure("NATIVE_FAILURE", operation, true);
|
|
}
|
|
}
|
|
|
|
function mapRepositoryFailure(
|
|
failure: PushControlStoreFailure,
|
|
operation: WebPushOperation,
|
|
): WebPushResult<never> {
|
|
switch (failure.code) {
|
|
case "ABORTED":
|
|
return webPushFailure("ABORTED", operation);
|
|
case "BLOCKED":
|
|
return webPushFailure("BLOCKED", operation, failure.retryable);
|
|
case "CONFLICT":
|
|
case "STALE_RESULT":
|
|
return webPushFailure(
|
|
"STALE_REVISION",
|
|
operation,
|
|
failure.retryable,
|
|
);
|
|
case "CORRUPT_DATA":
|
|
case "EXPIRED_RESOURCE":
|
|
case "INTEGRITY_FAILED":
|
|
case "MIGRATION_FAILED":
|
|
return webPushFailure(
|
|
"CONTROL_CORRUPT",
|
|
operation,
|
|
failure.retryable,
|
|
);
|
|
case "INVALID_INPUT":
|
|
return webPushFailure("INVALID_INPUT", operation);
|
|
case "LIMIT_EXCEEDED":
|
|
return webPushFailure(
|
|
"LIMIT_EXCEEDED",
|
|
operation,
|
|
failure.retryable,
|
|
);
|
|
case "UNSUPPORTED":
|
|
return webPushFailure("UNSUPPORTED", operation);
|
|
default:
|
|
return webPushFailure(
|
|
"NATIVE_FAILURE",
|
|
operation,
|
|
failure.retryable,
|
|
);
|
|
}
|
|
}
|
|
|
|
function controlSnapshot(value: PushControlV1): PushControlV1 {
|
|
const association: PushControlAssociationV1 =
|
|
value.association.state === "UNASSOCIATED"
|
|
? Object.freeze({ state: "UNASSOCIATED" })
|
|
: Object.freeze({
|
|
state: value.association.state,
|
|
associationEpoch: value.association.associationEpoch,
|
|
});
|
|
return Object.freeze({
|
|
protocol: WEB_PUSH_PROTOCOLS.control,
|
|
fenceGeneration: value.fenceGeneration,
|
|
sessionBindingEpoch: value.sessionBindingEpoch,
|
|
releaseEpoch: value.releaseEpoch,
|
|
updatedAt: value.updatedAt,
|
|
association,
|
|
});
|
|
}
|
|
|
|
function decodeControl(value: unknown): PushControlV1 | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
const keys = Object.keys(record).sort();
|
|
if (
|
|
keys.length !== 6 ||
|
|
keys.join("|") !==
|
|
"association|fenceGeneration|protocol|releaseEpoch|sessionBindingEpoch|updatedAt" ||
|
|
record.protocol !== WEB_PUSH_PROTOCOLS.control ||
|
|
!validOpaqueId(record.fenceGeneration) ||
|
|
!validOpaqueId(record.sessionBindingEpoch) ||
|
|
!validOpaqueId(record.releaseEpoch) ||
|
|
!validInstant(record.updatedAt) ||
|
|
!validAssociation(record.association)
|
|
) {
|
|
return null;
|
|
}
|
|
return controlSnapshot({
|
|
protocol: WEB_PUSH_PROTOCOLS.control,
|
|
fenceGeneration: record.fenceGeneration,
|
|
sessionBindingEpoch: record.sessionBindingEpoch,
|
|
releaseEpoch: record.releaseEpoch,
|
|
updatedAt: record.updatedAt,
|
|
association: record.association,
|
|
});
|
|
}
|
|
|
|
function validAssociation(
|
|
value: unknown,
|
|
): value is PushControlAssociationV1 {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const record = value as Record<string, unknown>;
|
|
const keys = Object.keys(record).sort();
|
|
if (record.state === "UNASSOCIATED") {
|
|
return keys.length === 1 && keys[0] === "state";
|
|
}
|
|
return (
|
|
(record.state === "ACTIVE" || record.state === "REVOKED") &&
|
|
keys.length === 2 &&
|
|
keys[0] === "associationEpoch" &&
|
|
keys[1] === "state" &&
|
|
validOpaqueId(record.associationEpoch)
|
|
);
|
|
}
|
|
|
|
function validAuthority(
|
|
value: PushAuthoritySnapshot,
|
|
): value is PushAuthoritySnapshot {
|
|
return (
|
|
Boolean(value) &&
|
|
validOpaqueId(value.fenceGeneration) &&
|
|
validOpaqueId(value.sessionBindingEpoch) &&
|
|
validOpaqueId(value.releaseEpoch)
|
|
);
|
|
}
|
|
|
|
function validRepository(
|
|
value: unknown,
|
|
): value is PushControlRepository {
|
|
if (!value || typeof value !== "object") return false;
|
|
const repository = value as Partial<PushControlRepository>;
|
|
return (
|
|
typeof repository.open === "function" &&
|
|
typeof repository.read === "function" &&
|
|
typeof repository.compareAndSwap === "function" &&
|
|
typeof repository.remove === "function" &&
|
|
typeof repository.close === "function"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* WP-01. One validator for both write and remove.
|
|
*
|
|
* A CAS receipt is only evidence when it names the expected key and the exact
|
|
* next revision. Accepting any well-typed revision let a stale or arbitrary
|
|
* repository receipt be packaged as a confirmed control, after which the whole
|
|
* CAS authority is wrong. A replayed receipt must still carry that exact
|
|
* revision, since replay means "this command already produced this revision".
|
|
*/
|
|
function validWriteReceipt(
|
|
value: PushControlWriteReceipt,
|
|
expectedRevision: number | null,
|
|
): value is PushControlWriteReceipt {
|
|
return (
|
|
Boolean(value) &&
|
|
value.key === CONTROL_KEY &&
|
|
validRevision(value.revision) &&
|
|
typeof value.replayed === "boolean" &&
|
|
value.revision === (expectedRevision ?? 0) + 1
|
|
);
|
|
}
|
|
|
|
function validIdempotencyKey(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.length >= 1 &&
|
|
value.length <= 200
|
|
);
|
|
}
|
|
|
|
function validOpaqueId(value: unknown): value is string {
|
|
return typeof value === "string" && OPAQUE_ID.test(value);
|
|
}
|
|
|
|
function validRevision(value: unknown): value is number {
|
|
return (
|
|
typeof value === "number" &&
|
|
Number.isSafeInteger(value) &&
|
|
value >= 1
|
|
);
|
|
}
|
|
|
|
function validInstant(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
ISO_INSTANT.test(value) &&
|
|
Number.isFinite(Date.parse(value))
|
|
);
|
|
}
|