import { createHash, randomUUID } from "node:crypto"; import type { AssetReleaseManifestItem, AssetReleaseManifestProjection, AssetReleaseReader, } from "@dada/asset-release-manifest"; import { auditRetentionMilliseconds, serializeAuditSummary } from "./audit-policy.js"; import type { RegistrationService } from "./registration.js"; export type PreviewBatchStatus = "active" | "closed"; export type PreviewGrantStatus = "active" | "revoked" | "expired"; export interface PreviewBatchView { batchId: string; createdAt: number; createdBy: string; name: string; status: PreviewBatchStatus; } export interface PreviewGrantView { batchId: string; expiresAt: number; grantId: string; grantedAt: number; grantedBy: string; status: PreviewGrantStatus; userId: string; } export class PreviewGrantError extends Error { constructor( public readonly reason: | "admin_invalid" | "batch_closed" | "batch_not_found" | "grant_not_found" | "invalid_expiry" | "invalid_request" | "resource_not_found" | "user_not_eligible", ) { super(reason); this.name = "PreviewGrantError"; } } interface PreviewGrantServiceOptions { assetReleases: AssetReleaseReader; clock?: () => number; registration: RegistrationService; } interface PreviewManifestItemMapping { releaseVersion: string; resourceId: string; userId: string; } function isUuid(value: string) { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } function assertText(value: string, name: string) { const normalized = value.trim(); if (!normalized || normalized.length > 160) throw new PreviewGrantError("invalid_request"); if (name === "batchId" && !isUuid(normalized)) throw new PreviewGrantError("invalid_request"); return normalized; } function manifestHash(items: readonly AssetReleaseManifestItem[], releaseVersion: string) { return createHash("sha256") .update(JSON.stringify({ items, release_version: releaseVersion, schema_version: "AssetReleaseManifest/v1", })) .digest("hex"); } /** * Owns the P0-A preview grant state. Preview URLs are deliberately ephemeral: * the random item id is kept only in this process and every read rechecks the * persisted grant, so revocation and expiry take effect without cache busting. */ export class AssetPreviewGrantService { readonly database: RegistrationService["database"]; readonly options: Required> & PreviewGrantServiceOptions; private readonly itemMappings = new Map(); constructor(options: PreviewGrantServiceOptions) { this.database = options.registration.database; this.options = { ...options, clock: options.clock ?? Date.now }; this.migrate(); } createBatch(input: { adminUserId: string; batchId?: string; name: string }): PreviewBatchView { const adminUserId = assertText(input.adminUserId, "adminUserId"); const name = assertText(input.name, "name"); const batchId = input.batchId ? assertText(input.batchId, "batchId") : randomUUID(); const now = this.options.clock(); this.assertAdmin(adminUserId, now); this.immediate(() => { this.database.prepare(` INSERT INTO test_batches (batch_id, name, status, created_by, created_at, closed_at) VALUES (?, ?, 'active', ?, ?, NULL) `).run(batchId, name, adminUserId, now); this.audit({ actorRef: adminUserId, afterSummary: { batch_id: batchId, status: "active" }, beforeSummary: null, operationType: "preview_batch_create", targetRef: batchId, targetType: "preview_batch", }, now); }); return { batchId, createdAt: now, createdBy: adminUserId, name, status: "active" }; } closeBatch(input: { adminUserId: string; batchId: string }): PreviewBatchView { const adminUserId = assertText(input.adminUserId, "adminUserId"); const batchId = assertText(input.batchId, "batchId"); const now = this.options.clock(); this.assertAdmin(adminUserId, now); return this.immediate(() => { const batch = this.readBatch(batchId); if (!batch) throw new PreviewGrantError("batch_not_found"); if (batch.status === "active") { this.database.prepare("UPDATE test_batches SET status = 'closed', closed_at = ? WHERE batch_id = ?").run(now, batchId); this.audit({ actorRef: adminUserId, afterSummary: { batch_id: batchId, status: "closed" }, beforeSummary: { batch_id: batchId, status: batch.status }, operationType: "preview_batch_close", targetRef: batchId, targetType: "preview_batch", }, now); } return { ...batch, status: "closed" as const }; }); } addBatchItems(input: { adminUserId: string; batchId: string; releaseVersion: string; resourceIds: readonly string[]; }) { const adminUserId = assertText(input.adminUserId, "adminUserId"); const batchId = assertText(input.batchId, "batchId"); const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); const resourceIds = [...new Set(input.resourceIds.map((resourceId) => assertText(resourceId, "resourceId")))]; if (resourceIds.length === 0) throw new PreviewGrantError("invalid_request"); const now = this.options.clock(); this.assertAdmin(adminUserId, now); for (const resourceId of resourceIds) { if (!this.options.assetReleases.read("internal_preview_asset", releaseVersion, resourceId)) { throw new PreviewGrantError("resource_not_found"); } } this.immediate(() => { const batch = this.readBatch(batchId); if (!batch) throw new PreviewGrantError("batch_not_found"); if (batch.status !== "active") throw new PreviewGrantError("batch_closed"); const insert = this.database.prepare(` INSERT OR IGNORE INTO test_batch_items (test_batch_id, release_version, resource_id) VALUES (?, ?, ?) `); for (const resourceId of resourceIds) insert.run(batchId, releaseVersion, resourceId); this.audit({ actorRef: adminUserId, afterSummary: { batch_id: batchId, item_count: resourceIds.length, release_version: releaseVersion }, beforeSummary: null, operationType: "preview_batch_items_add", targetRef: batchId, targetType: "preview_batch", }, now); }); return { batchId, releaseVersion, resourceIds }; } grant(input: { adminUserId: string; batchId: string; expiresAt: number; userId: string; }): PreviewGrantView { const adminUserId = assertText(input.adminUserId, "adminUserId"); const batchId = assertText(input.batchId, "batchId"); const userId = assertText(input.userId, "userId"); if (!isUuid(userId)) throw new PreviewGrantError("invalid_request"); const now = this.options.clock(); if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= now) throw new PreviewGrantError("invalid_expiry"); this.assertAdmin(adminUserId, now); return this.immediate(() => { const batch = this.readBatch(batchId); if (!batch) throw new PreviewGrantError("batch_not_found"); if (batch.status !== "active") throw new PreviewGrantError("batch_closed"); const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined; if (!user || user.role !== "user" || user.status !== "active") throw new PreviewGrantError("user_not_eligible"); const grantId = randomUUID(); this.database.prepare(` INSERT INTO asset_preview_grants ( grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status ) VALUES (?, ?, ?, ?, ?, ?, 'active') `).run(grantId, userId, batchId, adminUserId, now, input.expiresAt); this.audit({ actorRef: adminUserId, afterSummary: { batch_id: batchId, expires_at: input.expiresAt, grant_id: grantId, status: "active", user_id: userId }, beforeSummary: null, operationType: "preview_grant_create", targetRef: grantId, targetType: "preview_grant", }, now); return { batchId, expiresAt: input.expiresAt, grantId, grantedAt: now, grantedBy: adminUserId, status: "active" as const, userId, }; }); } revoke(input: { adminUserId: string; grantId: string }): PreviewGrantView { const adminUserId = assertText(input.adminUserId, "adminUserId"); const grantId = assertText(input.grantId, "grantId"); const now = this.options.clock(); this.assertAdmin(adminUserId, now); return this.immediate(() => { this.expireDue(now); const grant = this.readGrant(grantId); if (!grant) throw new PreviewGrantError("grant_not_found"); if (grant.status === "active") { this.database.prepare("UPDATE asset_preview_grants SET status = 'revoked' WHERE grant_id = ? AND status = 'active'").run(grantId); this.audit({ actorRef: adminUserId, afterSummary: { grant_id: grantId, status: "revoked" }, beforeSummary: { grant_id: grantId, status: grant.status }, operationType: "preview_grant_revoke", targetRef: grantId, targetType: "preview_grant", }, now); } return { ...grant, status: "revoked" as const }; }); } listBatches(input: { adminUserId: string }): PreviewBatchView[] { const adminUserId = assertText(input.adminUserId, "adminUserId"); this.assertAdmin(adminUserId, this.options.clock()); return (this.database.prepare(` SELECT batch_id, name, status, created_by, created_at FROM test_batches ORDER BY created_at DESC, batch_id DESC `).all() as Array<{ batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus }>).map((row) => ({ batchId: row.batch_id, createdAt: row.created_at, createdBy: row.created_by, name: row.name, status: row.status, })); } listGrants(input: { adminUserId: string; batchId?: string; userId?: string }): PreviewGrantView[] { const adminUserId = assertText(input.adminUserId, "adminUserId"); this.assertAdmin(adminUserId, this.options.clock()); const batchId = input.batchId ? assertText(input.batchId, "batchId") : undefined; const userId = input.userId ? assertText(input.userId, "userId") : undefined; const now = this.options.clock(); return this.immediate(() => { this.expireDue(now); const rows = this.database.prepare(` SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status FROM asset_preview_grants WHERE (? IS NULL OR test_batch_id = ?) AND (? IS NULL OR user_id = ?) ORDER BY granted_at DESC, grant_id DESC `).all(batchId ?? null, batchId ?? null, userId ?? null, userId ?? null) as Array<{ expires_at: number; grant_id: string; granted_at: number; granted_by: string; status: PreviewGrantStatus; test_batch_id: string; user_id: string; }>; return rows.map((row) => ({ batchId: row.test_batch_id, expiresAt: row.expires_at, grantId: row.grant_id, grantedAt: row.granted_at, grantedBy: row.granted_by, status: row.status, userId: row.user_id, })); }); } projectManifest(input: { releaseVersion: string; userId: string }): AssetReleaseManifestProjection | undefined { const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); const userId = assertText(input.userId, "userId"); const base = this.options.assetReleases.project("internal_preview_asset", releaseVersion); if (!base) return undefined; const authorized = base.items.filter((item) => this.authorizeAsset({ releaseVersion, resourceId: item.resource_id, userId })); if (authorized.length === 0) return undefined; const items = authorized.map((item) => { const manifestItemId = randomUUID(); const mapped: AssetReleaseManifestItem = { ...item, resource_id: manifestItemId, url: `/api/v1/assets/preview/${releaseVersion}/${manifestItemId}`, }; this.itemMappings.set(manifestItemId, { releaseVersion, resourceId: item.resource_id, userId, }); return mapped; }); return Object.freeze({ items: Object.freeze(items.map((item) => Object.freeze(item))), manifest_sha256: manifestHash(items, releaseVersion), release_version: releaseVersion, schema_version: "AssetReleaseManifest/v1" as const, }); } authorizeAsset(input: { releaseVersion: string; resourceId: string; userId: string }) { const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); const resourceId = assertText(input.resourceId, "resourceId"); const userId = assertText(input.userId, "userId"); const now = this.options.clock(); return this.immediate(() => { this.expireDue(now); const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined; if (!user || user.role !== "user" || user.status !== "active") return false; const row = this.database.prepare(` SELECT 1 AS authorized FROM asset_preview_grants g JOIN test_batch_items i ON i.test_batch_id = g.test_batch_id WHERE g.user_id = ? AND g.status = 'active' AND g.expires_at > ? AND i.release_version = ? AND i.resource_id = ? LIMIT 1 `).get(userId, now, releaseVersion, resourceId) as { authorized: 1 } | undefined; return Boolean(row); }); } readManifestItem(input: { manifestItemId: string; releaseVersion: string; userId: string }) { const manifestItemId = assertText(input.manifestItemId, "manifestItemId"); const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); const userId = assertText(input.userId, "userId"); const mapping = this.itemMappings.get(manifestItemId); if (!mapping || mapping.releaseVersion !== releaseVersion || mapping.userId !== userId) return undefined; if (!this.authorizeAsset({ releaseVersion, resourceId: mapping.resourceId, userId })) { this.itemMappings.delete(manifestItemId); return undefined; } const resource = this.options.assetReleases.read("internal_preview_asset", releaseVersion, mapping.resourceId); return resource ? { ...resource, resourceId: manifestItemId } : undefined; } private migrate() { this.database.exec(` CREATE TABLE IF NOT EXISTS test_batches ( batch_id TEXT PRIMARY KEY, name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160), status TEXT NOT NULL CHECK (status IN ('active', 'closed')), created_by TEXT NOT NULL REFERENCES users(user_id), created_at INTEGER NOT NULL, closed_at INTEGER ); CREATE TABLE IF NOT EXISTS test_batch_items ( test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id), release_version TEXT NOT NULL, resource_id TEXT NOT NULL, PRIMARY KEY (test_batch_id, release_version, resource_id) ); CREATE TABLE IF NOT EXISTS asset_preview_grants ( grant_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id), test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id), granted_by TEXT NOT NULL REFERENCES users(user_id), granted_at INTEGER NOT NULL, expires_at INTEGER NOT NULL CHECK (expires_at > granted_at), status TEXT NOT NULL CHECK (status IN ('active', 'revoked', 'expired')) ); CREATE INDEX IF NOT EXISTS asset_preview_grants_user_status ON asset_preview_grants(user_id, status, expires_at); `); } private immediate(action: () => T): T { this.database.exec("BEGIN IMMEDIATE"); try { const value = action(); this.database.exec("COMMIT"); return value; } catch (error) { if (this.database.inTransaction) this.database.exec("ROLLBACK"); throw error; } } private assertAdmin(adminUserId: string, now: number) { const admin = this.database.prepare(` SELECT 1 AS allowed FROM users u JOIN admin_access a ON a.user_id = u.user_id WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 `).get(adminUserId) as { allowed: 1 } | undefined; if (!admin) throw new PreviewGrantError("admin_invalid"); void now; } private readBatch(batchId: string): PreviewBatchView | undefined { const row = this.database.prepare(` SELECT batch_id, name, status, created_by, created_at FROM test_batches WHERE batch_id = ? `).get(batchId) as { batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus } | undefined; return row ? { batchId: row.batch_id, createdAt: row.created_at, createdBy: row.created_by, name: row.name, status: row.status, } : undefined; } private readGrant(grantId: string): PreviewGrantView | undefined { const row = this.database.prepare(` SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status FROM asset_preview_grants WHERE grant_id = ? `).get(grantId) as { expires_at: number; grant_id: string; granted_at: number; granted_by: string; status: PreviewGrantStatus; test_batch_id: string; user_id: string; } | undefined; return row ? { batchId: row.test_batch_id, expiresAt: row.expires_at, grantId: row.grant_id, grantedAt: row.granted_at, grantedBy: row.granted_by, status: row.status, userId: row.user_id, } : undefined; } private expireDue(now: number) { const rows = this.database.prepare(` SELECT grant_id, user_id, test_batch_id FROM asset_preview_grants WHERE status = 'active' AND expires_at <= ? `).all(now) as Array<{ grant_id: string; test_batch_id: string; user_id: string }>; if (rows.length === 0) return; this.database.prepare("UPDATE asset_preview_grants SET status = 'expired' WHERE status = 'active' AND expires_at <= ?").run(now); for (const row of rows) { this.audit({ actorRef: "preview_grant_expiry", afterSummary: { grant_id: row.grant_id, status: "expired" }, beforeSummary: { grant_id: row.grant_id, status: "active" }, operationType: "preview_grant_expire", targetRef: row.grant_id, targetType: "preview_grant", }, now, "system"); } } private audit(input: { actorRef: string; afterSummary: Record | null; beforeSummary: Record | null; operationType: string; targetRef: string; targetType: string; }, now: number, actorType: "super_admin" | "system" = "super_admin") { this.database.prepare(` INSERT INTO admin_operation_logs ( log_id, actor_type, actor_ref, operation_type, target_type, target_ref, result, before_summary, after_summary, occurred_at, expires_at ) VALUES (?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?) `).run( randomUUID(), actorType, input.actorRef, input.operationType, input.targetType, input.targetRef, serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now, now + auditRetentionMilliseconds, ); } }