/** * Auth-lease store (SPEC.md §7.3.1). * * A lease is a server-issued, time-bounded grant recording the actor's * resolved allowed scopes at issuance, keyed by `(partition, clientId)`. * It is the authorization the server falls to during a host-authorization * outage (§7.3.3), and the client's offline-trust window (§7.3.5). * * The store is OPTIONAL, wired only when `leases` is configured * (`leases` absent ⇒ the feature is off, zero cost — the blob-store * pattern). Records are host-owned; a production store signs them at rest * and verifies on read (§7.3.1). The reference stores keep them plain in * memory / sqlite — the seam is what matters for conformance. */ import type { ScopeMap } from '@syncular/core'; /** The stored lease record (§7.3.1). `revoked` is durable (§7.3.4). */ export interface LeaseRecord { readonly leaseId: string; readonly actorId: string; /** The actor's resolved allowed scopes at issuance (§3.2 step 3). */ readonly allowedScopes: ScopeMap; readonly issuedAtMs: number; readonly expiresAtMs: number; readonly revoked: boolean; } export interface LeaseStore { /** The lease for `(partition, clientId)`, if any (revoked or not). */ get(partition: string, clientId: string): Promise; /** * Issue or refresh the lease for `(partition, clientId)` (§7.3.3). A * refresh slides the SAME `leaseId` — implementations MUST reuse the * stored id for the pair unless it is revoked (a revoked handle never * silently resurrects: a fresh id is minted only once the host clears * the revocation). Returns the stored record. */ issue( partition: string, clientId: string, actorId: string, allowedScopes: ScopeMap, nowMs: number, ttlMs: number, ): Promise; /** Revoke a lease by `leaseId` (§7.3.4); durable, survives refresh. */ revoke(partition: string, leaseId: string): Promise; } /** Stable-per-pair id generator; overridable for deterministic tests. */ export type LeaseIdFactory = () => string; function defaultLeaseId(): string { return `lease_${crypto.randomUUID()}`; } export class MemoryLeaseStore implements LeaseStore { #entries = new Map(); /** Revoked leaseIds per partition — a revoked id never resurrects. */ #revokedIds = new Map>(); readonly #newId: LeaseIdFactory; constructor(options?: { readonly leaseId?: LeaseIdFactory }) { this.#newId = options?.leaseId ?? defaultLeaseId; } #key(partition: string, clientId: string): string { return `${partition}${clientId}`; } async get( partition: string, clientId: string, ): Promise { return this.#entries.get(this.#key(partition, clientId)); } async issue( partition: string, clientId: string, actorId: string, allowedScopes: ScopeMap, nowMs: number, ttlMs: number, ): Promise { const key = this.#key(partition, clientId); const existing = this.#entries.get(key); const revokedSet = this.#revokedIds.get(partition); // Reuse the stable id unless the pair's current id is revoked (§7.3.4). const reuse = existing !== undefined && existing.actorId === actorId && !existing.revoked && !(revokedSet?.has(existing.leaseId) ?? false); const leaseId = reuse ? existing.leaseId : this.#newId(); const record: LeaseRecord = { leaseId, actorId, allowedScopes, issuedAtMs: nowMs, expiresAtMs: nowMs + ttlMs, revoked: false, }; this.#entries.set(key, record); return record; } async revoke(partition: string, leaseId: string): Promise { let set = this.#revokedIds.get(partition); if (set === undefined) { set = new Set(); this.#revokedIds.set(partition, set); } set.add(leaseId); for (const [key, record] of this.#entries) { if (key.startsWith(`${partition}`) && record.leaseId === leaseId) { this.#entries.set(key, { ...record, revoked: true }); } } } }