From 176751340587a19e7d6e450d0e95f973354bfc3e Mon Sep 17 00:00:00 2001 From: Stephen Zweibel Date: Tue, 22 Sep 2026 13:30:26 -0400 Subject: [PATCH] Restore legacy project archives with public redirects --- AGENTS.md | 24 +- README.md | 32 +- docs/security-and-recovery.md | 49 +- .../app/src/agents/mutation-coordinator.ts | 122 ++++- packages/app/src/index.ts | 9 +- packages/app/src/lib/legacy-recovery.ts | 442 ++++++++++++++++++ packages/app/src/lib/migration.ts | 30 +- packages/app/src/lib/recovery-contract.ts | 102 ++++ packages/app/src/recovery-admin.test.ts | 359 ++++++++++++++ packages/app/src/recovery-admin.ts | 147 ++++++ packages/app/src/routes/projects.ts | 29 +- packages/app/src/routes/publish.ts | 72 +++ 12 files changed, 1392 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/lib/legacy-recovery.ts create mode 100644 packages/app/src/lib/recovery-contract.ts create mode 100644 packages/app/src/recovery-admin.test.ts create mode 100644 packages/app/src/recovery-admin.ts diff --git a/AGENTS.md b/AGENTS.md index 22ecc1c..3e1826b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,14 +97,16 @@ site-studio/ ## Compatibility Position -This is a greenfield product with one narrow data-preservation exception. +This is a greenfield product with two narrow data-preservation exceptions. Canonical published URLs are `/u/:handle/:slug/*`; the user's separate handle mapping and project slug are the durable public identity while `PUBLISHED_BASE_URL` is the deployment mount. API links derive that current base at read time, so moving the mount does not -rewrite or republish projects. There is no `/sites` route, forwarding pointer, -migration window, dual-read, or compatibility API. Any DNS or redirect work -for an old mount belongs to deployment configuration, not project storage. +rewrite or republish projects. There is no general `/sites` namespace, +forwarding pointer, migration window, dual-read, or compatibility API. The +only `/sites` behavior is the receipt-bound operator recovery alias described +below. Any DNS or redirect work for an old mount belongs to deployment +configuration, not project storage. On a user's first verified CAIL login only, an old `site-studio-session` cookie may resolve an unexpired R2 legacy session. Its server-stored anonymous owner is @@ -115,6 +117,17 @@ failure returns a private retryable error without replacing the old cookie. After success, only the subject store is authoritative. Never infer a mapping from email, content, or a caller-provided identifier. +An operator may also restore a hash-pinned backup through the private +`SiteStudioRecoveryAdmin` service entrypoint after Admission uniquely matches +an existing canonical subject. The manifest selects the disposable anonymous +source namespace; the caller cannot select files, a public handle, or a +destination other than that matched subject. Recovery uses the ordinary owner +queues and migration stamps, leaves first-login markers unchanged, and records +its own durable receipt. A completed receipt may preserve only an explicitly +listed formerly public `/sites/:legacyOwner/:slug/*` address. Remove that alias +when the verified surviving old link no longer needs compatibility; it is not +a general legacy namespace or dual-read path. + Do not infer R2 multi-object atomicity from a successful operation. Adopted project/file writes use the owner-scoped mutation coordinator and recovery journal; account import and handle mapping use their own conditional contracts. @@ -154,7 +167,8 @@ must not be presented as evidence of either. - Prefer editing the Worker app, not inventing parallel backend code - Keep preview, publishing, and public serving in that same Worker - Keep the app static-file oriented; runtime build tools are out of scope -- Do not add compatibility layers beyond the first-login import above +- Do not add compatibility layers beyond the first-login import and the + receipt-bound operator recovery redirect described above - Preserve the exact two-leg CAIL identity and direct CAIL Gateway model path - Do not impose arbitrary model token, step, timeout, or message caps - Call tests E2E only when they cross real process and resource boundaries diff --git a/README.md b/README.md index 22f0b99..1845e02 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ KV, and chat/mutation state in Durable Objects. `SiteBuilderAgent` extends Cloudflare's `AIChatAgent`; `@cloudflare/codemode` runs project operations in a sandboxed Dynamic Worker. Preview and published content are served by the app itself. There is no publisher service, release manifest, copied publish tree, -deployment matrix, or compatibility routing layer. +deployment matrix, or general compatibility routing layer. The only legacy +route is the receipt-bound operator recovery redirect described below. Publishing sets live project metadata to `published: true` and reserves the project's durable `slug`. The single public shape is `/u/:handle/:slug/*`, where @@ -96,17 +97,35 @@ public links are derived from the configured `PUBLISHED_BASE_URL`. An error returns a private retryable 503 and does not write completion or clear the legacy cookie. A later login retries. Verified identity remains the sole authentication source; there is no subject session cookie or subject -session KV record. After completion, the new subject store is authoritative: -there is no dual-read, fallback, sync, migration window, bulk job, forwarding -pointer, or legacy public route. +session KV record. After completion, the new subject store is authoritative. +The first-login path creates no dual-read, fallback, sync, migration window, +bulk job, forwarding pointer, or legacy public route; the separate operator +exception below is the only receipt-bound `/sites` redirect. Import and project rename transfer chat history through the SDK's `persistMessages` API. They do not start a new model turn. This mechanism can import only a namespace whose legacy R2 session record is still resolvable. Historical anonymous namespaces without that mapping cannot -be assigned safely; operators must not infer ownership from email, content, or -an arbitrary lookup table. +be assigned through the browser flow. A separate private operator exception can +restore a hash-pinned backup after Admission uniquely matches an existing +canonical subject. The fixed manifest names every source object and the private +Site Studio receiver validates the complete disposable source before reusing +the same claim-once migration and owner mutation queues. It does not mint a +session, change `imports/:subject`, recover undeclared snapshots/uploads/chat, +or infer ownership from email or project content. + +Operator recovery records a separate durable receipt before reporting success. +Retries with the same recovery id, target, and idempotency key resume or return +that receipt after the staged source has been retired. A published project may +receive one explicit compatibility alias at `/sites/:legacyOwner/:legacySlug/*`. +That alias resolves only while the recovery receipt is complete, the destination +still carries the matching import stamps, and the project remains published. It +redirects without caching to the current primary handle, or to a forward-only +recovery handle derived from the already-public legacy owner. The recovery +handle does not occupy the subject's user-chosen primary handle slot. Private +projects receive no alias. Remove +the alias when the verified surviving old link no longer needs compatibility. ## Routes @@ -118,6 +137,7 @@ an arbitrary lookup table. - `POST /api/projects/:projectId/unpublish` - `ALL /api/agents/site-builder/:projectId` - `GET /u/:handle/:slug/*` +- `GET /sites/:legacyOwner/:legacySlug/*` only for an explicit completed recovery alias Publishing returns `409 handle_required` until the owner claims a handle. Slashless public roots redirect to the trailing-slash form so relative assets diff --git a/docs/security-and-recovery.md b/docs/security-and-recovery.md index 7791a21..e03f8b1 100644 --- a/docs/security-and-recovery.md +++ b/docs/security-and-recovery.md @@ -41,10 +41,12 @@ and sends them through the existing Gateway vision-model path. ## One-time legacy import -The only compatibility behavior is a lazy import during a user's first -verified login. The presented old cookie must resolve to an unexpired R2 legacy -session record whose server-stored user id has the anonymous `user_…` shape. -Caller input and email cannot name an import source. +The ordinary browser compatibility behavior is a lazy import during a user's +first verified login. The presented old cookie must resolve to an unexpired R2 +legacy session record whose server-stored user id has the anonymous `user_…` +shape. Caller input and email cannot name an import source. The only other +compatibility behavior is the private, receipt-bound operator recovery and +public redirect exception described below. A `MigrationCoordinator` keyed by the anonymous owner grants that namespace to at most one subject. Before claiming it, the subject's mutation coordinator @@ -74,9 +76,10 @@ exists. A failure returns a private 503, leaves completion absent, and preserves a source cookie or pending resume marker for the next login. Successful import retires the legacy session and clears its cookie; the subject store then becomes the sole read and write -authority. Verified identity is the sole authentication source. There is no -time window, dual-read, forwarding pointer, compatibility API, background job, -or legacy `/sites` route. +authority. Verified identity is the sole authentication source. The first-login +path creates no time window, dual-read, forwarding pointer, compatibility API, +background job, or legacy `/sites` route. The separate operator exception below +is the only receipt-bound `/sites` redirect. R2 cannot reveal which verified subject owns an anonymous namespace when its legacy session mapping is gone. Such data must remain untouched unless an @@ -192,3 +195,35 @@ Published responses revalidate mutable paths with ETag and Last-Modified. Unpublish removes visibility but cannot revoke bytes already downloaded. Project snapshots are the product's content rollback. R2 backup/versioning, disaster recovery, and deployment rollback are operator responsibilities. + +### Verified legacy backup recovery + +The private `SiteStudioRecoveryAdmin` Worker entrypoint is the sole exception +for an anonymous namespace whose old session mapping no longer exists. It has +no HTTP route. Admission lists only bounded canonical subjects already present +under `imports/`, uniquely matches the administrator-selected active member, +and calls the receiver with a fixed recovery id, that exact subject, and an +idempotency key. Site Studio itself owns the recovery manifest and source. + +Before the first copy, the receiver validates the manifest hash, every declared +object path, size, and SHA-256, each project's publication metadata, and the +absence of undeclared source objects, snapshots, uploads, or handle mappings. +It atomically claims the anonymous owner for one subject. The target owner queue +then contains migration, destination verification, alias creation, and receipt +completion as one serialized operation; the source owner queue contains source +validation and consumption. The original backup remains read-only outside this +bucket, while the staged anonymous namespace is disposable after verification. +The normal `imports/:subject` first-login marker is neither removed nor written. + +A completed recovery can create aliases only for manifest projects marked +published. `/sites/:legacyOwner/:legacySlug/*` checks the completed receipt, +the exact target project and import stamps, and current published state on every +request. It returns an uncached temporary redirect, preserving the safe path +suffix and query, to the current primary handle when one exists. Otherwise it +uses a forward-only recovery handle derived from the public legacy owner. That +mapping never writes `userhandles/:subject`, so the user can still choose their +primary handle normally. Unpublishing or deleting the target immediately makes +the legacy alias return 404. +This compatibility path can be removed when the verified surviving legacy link +no longer has a caller; it never reads the old bucket or resolves arbitrary +legacy owners. diff --git a/packages/app/src/agents/mutation-coordinator.ts b/packages/app/src/agents/mutation-coordinator.ts index 5cbcd4d..c1f5a2f 100644 --- a/packages/app/src/agents/mutation-coordinator.ts +++ b/packages/app/src/agents/mutation-coordinator.ts @@ -1,7 +1,13 @@ import { DurableObject } from "cloudflare:workers"; import type { Env } from "../types"; import { OwnerMutationService, type OwnerMutation, type OwnerMutationResult } from "../lib/owner-mutations"; -import { migrateAnonymousData, type MigrationResult } from "../lib/migration"; +import { + findImportedProjectMap, + migrateAnonymousData, + migrationClaimKey, + type MigrationClaim, + type MigrationResult, +} from "../lib/migration"; import { runSubjectImport, SessionStoreUnavailableError, @@ -12,6 +18,17 @@ import { createSiteStudioBoundaryContext, type SiteStudioLoggingContextData, } from "../lib/logging"; +import { + LegacyRecoveryError, + claimRecoveryHandle, + loadRecoveryManifest, + markRecoveryComplete, + readRecoveryAuthorization, + validateStagedRecovery, + verifyRecoveredDestination, + writeRecoveryAliases, +} from "../lib/legacy-recovery"; +import { getUserHandle } from "../lib/handles"; export class SerializedOperationQueue { private tail: Promise = Promise.resolve(); @@ -79,6 +96,109 @@ export class MutationCoordinator extends DurableObject { }); } + /** Consume an operator-staged file-only source without probing agent chat. */ + async migrateStagedLegacyProjects( + recoveryId: string, + anonUserId: string, + subject: string, + ): Promise { + return this.mutations.run(async () => { + await new OwnerMutationService( + this.env.SITE_STUDIO_BUCKET, + this.ctx.storage, + undefined, + createProjectHistoryLifecycle(this.env), + ).recover(anonUserId); + const { manifest } = await loadRecoveryManifest(this.env.SITE_STUDIO_BUCKET, recoveryId); + if (manifest.source.owner !== anonUserId) throw new LegacyRecoveryError("conflict"); + // The first source-queue entry must revalidate immediately before copy. + // A later entry with a migration claim is a resume: source deletion may + // already be partial or complete, so destination stamps become the retry + // evidence instead of requiring disposable staged bytes to reappear. + const migrationClaim = await this.env.SESSION_KV.get( + migrationClaimKey(anonUserId), + "json", + ); + await validateStagedRecovery(this.env.SITE_STUDIO_BUCKET, manifest, { + allowMissing: Boolean(migrationClaim), + }); + return migrateAnonymousData({ + bucket: this.env.SITE_STUDIO_BUCKET, + kv: this.env.SESSION_KV, + anonUserId, + subject, + }); + }); + } + + /** + * Run a verified operator recovery through both ordinary owner queues. This + * entry is called only on `owner:${subject}` by the private recovery + * entrypoint; the disposable source copy is then consumed on its own queue. + * It deliberately does not touch the subject's first-login import marker. + */ + async restoreLegacyProjects( + recoveryId: string, + anonUserId: string, + subject: string, + ): Promise { + return this.mutations.run(async () => { + await new OwnerMutationService( + this.env.SITE_STUDIO_BUCKET, + this.ctx.storage, + undefined, + createProjectHistoryLifecycle(this.env), + ).recover(subject); + const namespace = this.env.MUTATION_COORDINATOR; + if (!namespace) throw new Error("MUTATION_COORDINATOR is not configured"); + const result = await namespace + .get(namespace.idFromName(`owner:${anonUserId}`)) + .migrateStagedLegacyProjects(recoveryId, anonUserId, subject); + const { manifest } = await loadRecoveryManifest(this.env.SITE_STUDIO_BUCKET, recoveryId); + const importedProjectMap = await findImportedProjectMap( + this.env.SITE_STUDIO_BUCKET, + subject, + anonUserId, + ); + const projectMap = { ...importedProjectMap, ...result.projects }; + await verifyRecoveredDestination({ + bucket: this.env.SITE_STUDIO_BUCKET, + manifest, + targetSubject: subject, + projectMap, + }); + const recovery = await readRecoveryAuthorization(this.env.SITE_STUDIO_BUCKET, recoveryId); + if (!recovery) throw new LegacyRecoveryError("unavailable"); + let recoveryHandle = recovery.value.recoveryHandle; + if ( + manifest.projects.some((project) => project.published) && + !await getUserHandle(this.env.SITE_STUDIO_BUCKET, subject) && + !recoveryHandle + ) { + recoveryHandle = await claimRecoveryHandle( + this.env.SITE_STUDIO_BUCKET, + anonUserId, + subject, + recovery.value.startedAt, + ); + } + await writeRecoveryAliases({ + bucket: this.env.SITE_STUDIO_BUCKET, + manifest, + targetSubject: subject, + projectMap, + recoveryHandle, + }); + await markRecoveryComplete( + this.env.SITE_STUDIO_BUCKET, + recovery, + projectMap, + recoveryHandle, + ); + return { ...result, projects: projectMap }; + }); + } + /** * Serialize first-login import decisions by CAIL subject. This method must * run on `owner:${subject}`, while the copy itself runs on the distinct diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 59933a3..39025ca 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -2,9 +2,16 @@ import worker from "./worker"; import { SiteBuilderAgent } from "./agents/site-builder"; import { MigrationCoordinator } from "./agents/migration-coordinator"; import { MutationCoordinator } from "./agents/mutation-coordinator"; +import { SiteStudioRecoveryAdmin } from "./recovery-admin"; // Worker entry: the Hono app is assembled in ./app (importable from tests); // this module only adds the Durable Object exports, whose dependency trees need // the Workers runtime (`cloudflare:` modules). export default worker; -export { SiteBuilderAgent, MigrationCoordinator, MutationCoordinator }; +export { SiteBuilderAgent, MigrationCoordinator, MutationCoordinator, SiteStudioRecoveryAdmin }; +export type { + RecoveryCandidateResult, + RecoveryRestoreResult, + SiteStudioRecoveryAdminContract, + SiteStudioLegacyRecovery, +} from "./lib/recovery-contract"; diff --git a/packages/app/src/lib/legacy-recovery.ts b/packages/app/src/lib/legacy-recovery.ts new file mode 100644 index 0000000..779e9f8 --- /dev/null +++ b/packages/app/src/lib/legacy-recovery.ts @@ -0,0 +1,442 @@ +import { z } from "zod"; +import { + handleRecordKey, + resolveHandleOwner, + validateHandle, +} from "./handles"; +import { legacyRecoveryManifestSchema, type LegacyRecoveryManifest } from "./recovery-contract"; + +const canonicalSubject = /^cail-[a-f0-9]{32}$/; +const recoveryMetadataSchema = z.object({ + id: z.string(), + published: z.boolean(), + slug: z.string().optional(), + importedFrom: z.string().optional(), + importedOriginalId: z.string().optional(), + creatingOperationId: z.string().optional(), +}).passthrough(); +const handleOwnerSchema = z.object({ ownerId: z.string() }).passthrough(); +const authorizationSchema = z.object({ + version: z.literal(1), + recoveryId: z.string().uuid(), + sourceOwner: z.string(), + manifestSha256: z.string().regex(/^[a-f0-9]{64}$/), + targetSubject: z.string().regex(canonicalSubject), + idempotencyKey: z.string().uuid(), + status: z.enum(["pending", "complete"]), + projectCount: z.number().int().positive(), + fileCount: z.number().int().positive(), + byteCount: z.number().int().nonnegative(), + startedAt: z.string(), + completedAt: z.string().optional(), + projectMap: z.record(z.string(), z.string()).optional(), + recoveryHandle: z.string().optional(), +}).strict(); + +export type LegacyRecoveryAuthorization = z.infer; + +const aliasSchema = z.object({ + version: z.literal(1), + recoveryId: z.string().uuid(), + sourceOwner: z.string(), + sourceSlug: z.string(), + sourceProjectId: z.string(), + targetSubject: z.string().regex(canonicalSubject), + targetProjectId: z.string(), + recoveryHandle: z.string().optional(), +}).strict(); + +export type LegacyRecoveryAlias = z.infer; + +export class LegacyRecoveryError extends Error { + constructor(readonly code: "invalid" | "conflict" | "unavailable") { + super(code); + this.name = "LegacyRecoveryError"; + } +} + +export function recoveryManifestKey(recoveryId: string): string { + return `recoveries/${recoveryId}/manifest.json`; +} + +export function recoveryAuthorizationKey(recoveryId: string): string { + return `recoveries/${recoveryId}/authorization.json`; +} + +function aliasKey(sourceOwner: string, sourceSlug: string): string { + return `recovery-aliases/${sourceOwner}/${sourceSlug}.json`; +} + +async function sha256Hex(bytes: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function listKeys(bucket: R2Bucket, prefix: string): Promise { + const keys: string[] = []; + let cursor: string | undefined; + do { + const page = await bucket.list({ prefix, cursor }); + keys.push(...page.objects.map((object) => object.key)); + cursor = page.truncated ? page.cursor : undefined; + } while (cursor); + return keys; +} + +export async function loadRecoveryManifest( + bucket: R2Bucket, + recoveryId: string, +): Promise<{ manifest: LegacyRecoveryManifest; sha256: string }> { + const object = await bucket.get(recoveryManifestKey(recoveryId)); + if (!object) throw new LegacyRecoveryError("invalid"); + const bytes = await object.arrayBuffer(); + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new LegacyRecoveryError("invalid"); + } + const parsed = legacyRecoveryManifestSchema.safeParse(payload); + if (!parsed.success || parsed.data.recoveryId !== recoveryId) { + throw new LegacyRecoveryError("invalid"); + } + return { manifest: parsed.data, sha256: await sha256Hex(bytes) }; +} + +/** Validate the entire disposable source before any destination copy starts. */ +export async function validateStagedRecovery( + bucket: R2Bucket, + manifest: LegacyRecoveryManifest, + options: { allowMissing?: boolean } = {}, +): Promise { + const expected = new Map( + manifest.objects.map((object) => [ + `${manifest.source.prefix}${object.projectId}/${object.path}`, + object, + ]), + ); + const actual = await listKeys(bucket, manifest.source.prefix); + if ( + actual.some((key) => !expected.has(key)) || + (!options.allowMissing && actual.length !== expected.size) + ) { + throw new LegacyRecoveryError("invalid"); + } + for (const [key, declaration] of expected) { + const object = await bucket.get(key); + if (!object) { + if (options.allowMissing) continue; + throw new LegacyRecoveryError("invalid"); + } + if (object.size !== declaration.size) throw new LegacyRecoveryError("invalid"); + const bytes = await object.arrayBuffer(); + if (await sha256Hex(bytes) !== declaration.sha256) { + throw new LegacyRecoveryError("invalid"); + } + if (declaration.path === ".metadata.json") { + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new LegacyRecoveryError("invalid"); + } + const metadata = recoveryMetadataSchema.safeParse(payload); + const project = manifest.projects.find((candidate) => candidate.id === declaration.projectId); + if ( + !project || !metadata.success || metadata.data.id !== project.id || + metadata.data.published !== project.published || + metadata.data.slug !== project.slug + ) { + throw new LegacyRecoveryError("invalid"); + } + } + } + for (const prefix of [`snapshots/${manifest.source.owner}/`, `uploads/${manifest.source.owner}/`]) { + if ((await listKeys(bucket, prefix)).length > 0) throw new LegacyRecoveryError("invalid"); + } +} + +/** The staged source must never have participated in the public handle map. */ +export async function assertNoSourceHandleExposure( + bucket: R2Bucket, + sourceOwner: string, +): Promise { + if (await bucket.get(`userhandles/${sourceOwner}.json`)) { + throw new LegacyRecoveryError("conflict"); + } + for (const key of await listKeys(bucket, "handles/")) { + const object = await bucket.get(key); + if (!object) continue; + let payload: unknown; + try { + payload = JSON.parse(await object.text()); + } catch { + throw new LegacyRecoveryError("unavailable"); + } + const record = handleOwnerSchema.safeParse(payload); + if (!record.success) throw new LegacyRecoveryError("unavailable"); + if (record.data.ownerId === sourceOwner) throw new LegacyRecoveryError("conflict"); + } +} + +export async function readRecoveryAuthorization( + bucket: R2Bucket, + recoveryId: string, +): Promise<{ value: LegacyRecoveryAuthorization; etag: string } | null> { + const object = await bucket.get(recoveryAuthorizationKey(recoveryId)); + if (!object) return null; + let payload: unknown; + try { + payload = JSON.parse(await object.text()); + } catch { + throw new LegacyRecoveryError("unavailable"); + } + const parsed = authorizationSchema.safeParse(payload); + if (!parsed.success) throw new LegacyRecoveryError("unavailable"); + return { value: parsed.data, etag: object.etag }; +} + +export async function createPendingRecovery( + bucket: R2Bucket, + value: LegacyRecoveryAuthorization, +): Promise { + const wrote = await bucket.put(recoveryAuthorizationKey(value.recoveryId), JSON.stringify(value), { + httpMetadata: { contentType: "application/json" }, + onlyIf: { etagDoesNotMatch: "*" }, + }); + if (wrote) return value; + const existing = await readRecoveryAuthorization(bucket, value.recoveryId); + if (!existing) throw new LegacyRecoveryError("unavailable"); + return existing.value; +} + +export function assertSameRecovery( + state: LegacyRecoveryAuthorization, + input: { targetSubject: string; idempotencyKey: string }, + manifestSha256: string, + sourceOwner: string, +): void { + if ( + state.targetSubject !== input.targetSubject || + state.idempotencyKey !== input.idempotencyKey || + state.manifestSha256 !== manifestSha256 || + state.sourceOwner !== sourceOwner + ) { + throw new LegacyRecoveryError("conflict"); + } +} + +export async function verifyRecoveredDestination(options: { + bucket: R2Bucket; + manifest: LegacyRecoveryManifest; + targetSubject: string; + projectMap: Record; +}): Promise { + const { bucket, manifest, targetSubject, projectMap } = options; + for (const project of manifest.projects) { + if (!projectMap[project.id]) throw new LegacyRecoveryError("unavailable"); + } + for (const declaration of manifest.objects) { + const targetProjectId = projectMap[declaration.projectId]; + const key = `projects/${targetSubject}/${targetProjectId}/${declaration.path}`; + const object = await bucket.get(key); + if (!object) throw new LegacyRecoveryError("unavailable"); + if (declaration.path === ".metadata.json") { + let payload: unknown; + try { + payload = JSON.parse(await object.text()); + } catch { + throw new LegacyRecoveryError("unavailable"); + } + const metadata = recoveryMetadataSchema.safeParse(payload); + const project = manifest.projects.find((candidate) => candidate.id === declaration.projectId); + if ( + !project || !metadata.success || metadata.data.id !== targetProjectId || + metadata.data.importedFrom !== manifest.source.owner || + metadata.data.importedOriginalId !== declaration.projectId || + metadata.data.published !== project.published + ) { + throw new LegacyRecoveryError("unavailable"); + } + continue; + } + if (object.size !== declaration.size || await sha256Hex(await object.arrayBuffer()) !== declaration.sha256) { + throw new LegacyRecoveryError("unavailable"); + } + } +} + +export async function writeRecoveryAliases(options: { + bucket: R2Bucket; + manifest: LegacyRecoveryManifest; + targetSubject: string; + projectMap: Record; + recoveryHandle?: string; +}): Promise { + const { bucket, manifest, targetSubject, projectMap } = options; + for (const project of manifest.projects) { + if (!project.published || !project.slug) continue; + const alias: LegacyRecoveryAlias = { + version: 1, + recoveryId: manifest.recoveryId, + sourceOwner: manifest.source.owner, + sourceSlug: project.slug, + sourceProjectId: project.id, + targetSubject, + targetProjectId: projectMap[project.id], + recoveryHandle: options.recoveryHandle, + }; + const key = aliasKey(manifest.source.owner, project.slug); + const serialized = JSON.stringify(alias); + const wrote = await bucket.put(key, serialized, { + httpMetadata: { contentType: "application/json" }, + onlyIf: { etagDoesNotMatch: "*" }, + }); + if (wrote) continue; + const existing = await bucket.get(key); + if (!existing || await existing.text() !== serialized) throw new LegacyRecoveryError("conflict"); + } +} + +export async function markRecoveryComplete( + bucket: R2Bucket, + current: { value: LegacyRecoveryAuthorization; etag: string }, + projectMap: Record, + recoveryHandle?: string, + now = () => new Date().toISOString(), +): Promise { + const complete: LegacyRecoveryAuthorization = { + ...current.value, + status: "complete", + completedAt: current.value.completedAt ?? now(), + projectMap, + recoveryHandle, + }; + const wrote = await bucket.put(recoveryAuthorizationKey(current.value.recoveryId), JSON.stringify(complete), { + httpMetadata: { contentType: "application/json" }, + onlyIf: { etagMatches: current.etag }, + }); + if (wrote) return complete; + const existing = await readRecoveryAuthorization(bucket, current.value.recoveryId); + if (!existing || existing.value.status !== "complete") throw new LegacyRecoveryError("unavailable"); + return existing.value; +} + +export function recoveryHandleForSource(sourceOwner: string): string { + const suffix = sourceOwner.match(/^user_([a-f0-9]{32})$/)?.[1]; + if (!suffix) throw new LegacyRecoveryError("invalid"); + const candidate = `legacy-${suffix.slice(0, 25)}`; + const validation = validateHandle(candidate); + if (!validation.valid) throw new LegacyRecoveryError("invalid"); + return validation.handle; +} + +/** + * Reserve a forward-only public recovery handle. It intentionally leaves the + * subject's primary `userhandles/` slot free for a later user choice. + */ +export async function claimRecoveryHandle( + bucket: R2Bucket, + sourceOwner: string, + targetSubject: string, + claimedAt: string, +): Promise { + const handle = recoveryHandleForSource(sourceOwner); + const wrote = await bucket.put( + handleRecordKey(handle), + JSON.stringify({ ownerId: targetSubject, claimedAt }), + { + httpMetadata: { contentType: "application/json" }, + onlyIf: { etagDoesNotMatch: "*" }, + }, + ); + if (!wrote && await resolveHandleOwner(bucket, handle) !== targetSubject) { + throw new LegacyRecoveryError("conflict"); + } + return handle; +} + +/** Read an alias only after its exact recovery receipt is complete. */ +export async function resolveCompletedRecoveryAlias( + bucket: R2Bucket, + sourceOwner: string, + sourceSlug: string, +): Promise { + const object = await bucket.get(aliasKey(sourceOwner, sourceSlug)); + if (!object) return null; + let payload: unknown; + try { + payload = JSON.parse(await object.text()); + } catch { + return null; + } + const parsed = aliasSchema.safeParse(payload); + if (!parsed.success || parsed.data.sourceOwner !== sourceOwner || parsed.data.sourceSlug !== sourceSlug) return null; + const recovery = await readRecoveryAuthorization(bucket, parsed.data.recoveryId).catch(() => null); + if (!recovery || recovery.value.status !== "complete") return null; + if (recovery.value.targetSubject !== parsed.data.targetSubject) return null; + if (recovery.value.projectMap?.[parsed.data.sourceProjectId] !== parsed.data.targetProjectId) return null; + return parsed.data; +} + +export async function findCompletedRecoveryAliasForTarget( + bucket: R2Bucket, + sourceOwner: string, + targetSubject: string, + targetProjectId: string, +): Promise { + for (const key of await listKeys(bucket, `recovery-aliases/${sourceOwner}/`)) { + const object = await bucket.get(key); + if (!object) continue; + let payload: unknown; + try { + payload = JSON.parse(await object.text()); + } catch { + continue; + } + const parsed = aliasSchema.safeParse(payload); + if (parsed.success && parsed.data.targetSubject === targetSubject) { + const completed = await resolveCompletedRecoveryAlias( + bucket, + parsed.data.sourceOwner, + parsed.data.sourceSlug, + ); + if ( + completed && + await findCurrentImportedProjectId(bucket, completed) === targetProjectId + ) { + return completed; + } + } + } + return null; +} + +export async function findCurrentImportedProjectId( + bucket: R2Bucket, + alias: LegacyRecoveryAlias, +): Promise { + let matched: string | null = null; + for (const key of await listKeys(bucket, `projects/${alias.targetSubject}/`)) { + if (!key.endsWith("/.metadata.json")) continue; + const object = await bucket.get(key); + if (!object) continue; + let payload: unknown; + try { + payload = JSON.parse(await object.text()); + } catch { + continue; + } + const metadata = recoveryMetadataSchema.safeParse(payload); + if ( + !metadata.success || metadata.data.importedFrom !== alias.sourceOwner || + metadata.data.importedOriginalId !== alias.sourceProjectId || + metadata.data.creatingOperationId + ) { + continue; + } + if (matched !== null) return null; + matched = metadata.data.id; + } + return matched; +} diff --git a/packages/app/src/lib/migration.ts b/packages/app/src/lib/migration.ts index 3390164..4699a3d 100644 --- a/packages/app/src/lib/migration.ts +++ b/packages/app/src/lib/migration.ts @@ -72,6 +72,7 @@ export interface MigrationClaim { status: "pending" | "complete"; startedAt: string; completedAt?: string; + projects?: Record; } /** @@ -575,7 +576,7 @@ export async function migrateAnonymousData(options: { // Safe to swallow: clearing the resume marker is best-effort cleanup. A // leftover marker only triggers a harmless no-op resume on the next login. await kv.delete(migrationPendingKey(subject)).catch(() => undefined); - return { status: "already-complete", projects: {} }; + return { status: "already-complete", projects: existingClaim.projects ?? {} }; } const claim: MigrationClaim = existingClaim ?? { @@ -601,6 +602,7 @@ export async function migrateAnonymousData(options: { ...claim, status: "complete", completedAt: now(), + projects, } satisfies MigrationClaim), ); // Safe to swallow: best-effort resume-marker cleanup (see above). @@ -668,3 +670,29 @@ export async function migrateAnonymousData(options: { return finish("migrated", projectMap); } + +/** + * Recover the stable project map after an uncertain migration response. The + * destination metadata stamps are written before source retirement and are + * the authoritative retry evidence for this exact anonymous owner. + */ +export async function findImportedProjectMap( + bucket: R2Bucket, + subject: string, + anonUserId: string, +): Promise> { + const projects: Record = {}; + for (const projectId of await listProjectIds(bucket, subject)) { + const metadata = await getMetadata(bucket, subject, projectId); + if ( + metadata?.importedFrom === anonUserId && + metadata.importedOriginalId + ) { + if (projects[metadata.importedOriginalId] !== undefined) { + throw new Error("Anonymous-data migration found ambiguous destination stamps."); + } + projects[metadata.importedOriginalId] = projectId; + } + } + return projects; +} diff --git a/packages/app/src/lib/recovery-contract.ts b/packages/app/src/lib/recovery-contract.ts new file mode 100644 index 0000000..675ca3b --- /dev/null +++ b/packages/app/src/lib/recovery-contract.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; + +export const recoveryIdSchema = z.string().uuid(); +export const manifestSha256Schema = z.string().regex(/^[a-f0-9]{64}$/); +const anonymousOwnerSchema = z.string().regex(/^user_[a-f0-9]{32}$/); +const safeSegmentSchema = z.string().min(1).max(200).refine( + (value) => value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\") && !value.includes("\0"), + "Unsafe path segment", +); +const relativePathSchema = z.string().min(1).max(1024).refine((value) => { + if (value.startsWith("/") || value.includes("\\") || value.includes("\0")) return false; + return value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); +}, "Unsafe relative path"); + +export const legacyRecoveryManifestSchema = z.object({ + version: z.literal(1), + recoveryId: recoveryIdSchema, + source: z.object({ + account: z.string().regex(/^[a-f0-9]{32}$/), + bucket: z.string().min(1).max(128), + owner: anonymousOwnerSchema, + prefix: z.string().min(1), + }).strict(), + projects: z.array(z.object({ + id: safeSegmentSchema, + published: z.boolean(), + slug: safeSegmentSchema.optional(), + }).strict()).min(1).max(100), + objects: z.array(z.object({ + projectId: safeSegmentSchema, + path: relativePathSchema, + size: z.number().int().nonnegative(), + sha256: manifestSha256Schema, + contentType: z.string().min(1).max(200).optional(), + }).strict()).min(1).max(10_000), +}).strict().superRefine((manifest, context) => { + if (manifest.source.prefix !== `projects/${manifest.source.owner}/`) { + context.addIssue({ code: "custom", path: ["source", "prefix"], message: "Source prefix does not match owner" }); + } + const projectIds = new Set(); + for (const [index, project] of manifest.projects.entries()) { + if (projectIds.has(project.id)) { + context.addIssue({ code: "custom", path: ["projects", index, "id"], message: "Duplicate project" }); + } + projectIds.add(project.id); + if (project.published && !project.slug) { + context.addIssue({ code: "custom", path: ["projects", index, "slug"], message: "Published project requires a slug" }); + } + if (!project.published && project.slug !== undefined) { + context.addIssue({ code: "custom", path: ["projects", index, "slug"], message: "Private project cannot declare a public slug" }); + } + } + const objectKeys = new Set(); + const metadataProjects = new Set(); + for (const [index, object] of manifest.objects.entries()) { + if (!projectIds.has(object.projectId)) { + context.addIssue({ code: "custom", path: ["objects", index, "projectId"], message: "Unknown project" }); + } + const key = `${object.projectId}/${object.path}`; + if (objectKeys.has(key)) { + context.addIssue({ code: "custom", path: ["objects", index, "path"], message: "Duplicate object" }); + } + objectKeys.add(key); + if (object.path === ".metadata.json") metadataProjects.add(object.projectId); + } + for (const [index, project] of manifest.projects.entries()) { + if (!metadataProjects.has(project.id)) { + context.addIssue({ code: "custom", path: ["projects", index], message: "Project metadata is missing" }); + } + } +}); + +export type LegacyRecoveryManifest = z.infer; + +export type RecoveryAdminFailureCode = "invalid" | "conflict" | "unavailable"; +export type RecoveryCandidateResult = + | { ok: true; subjects: string[]; nextCursor: string | null } + | { ok: false; code: "invalid" | "unavailable" }; +export type RecoveryRestoreResult = + | { + ok: true; + recoveryId: string; + state: "restored" | "already_restored"; + manifestSha256: string; + projectCount: number; + fileCount: number; + byteCount: number; + appUrl: string; + restoredUrl: string | null; + } + | { ok: false; code: RecoveryAdminFailureCode }; + +export interface SiteStudioRecoveryAdminContract { + listLegacyRecoveryCandidates(input: { cursor?: string }): Promise; + restoreLegacyProjects(input: { + recoveryId: string; + targetSubject: string; + idempotencyKey: string; + }): Promise; +} + +export type SiteStudioLegacyRecovery = SiteStudioRecoveryAdminContract; diff --git a/packages/app/src/recovery-admin.test.ts b/packages/app/src/recovery-admin.test.ts new file mode 100644 index 0000000..c6363f5 --- /dev/null +++ b/packages/app/src/recovery-admin.test.ts @@ -0,0 +1,359 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Env, ProjectMetadata } from "./types"; +import { SiteStudioRecoveryAdmin } from "./recovery-admin"; +import { + findImportedProjectMap, + migrateAnonymousData, + migrationClaimKey, + type MigrationClaim, +} from "./lib/migration"; +import { + loadRecoveryManifest, + claimRecoveryHandle, + markRecoveryComplete, + readRecoveryAuthorization, + validateStagedRecovery, + verifyRecoveredDestination, + writeRecoveryAliases, +} from "./lib/legacy-recovery"; +import { createMockKV, createTestNamespace, createTestR2Object } from "./lib/test-utils"; +import { createPublishRouter } from "./routes/publish"; +import { MutationCoordinator, SerializedOperationQueue } from "./agents/mutation-coordinator"; +import { MigrationCoordinator } from "./agents/migration-coordinator"; + +type Entry = { bytes: Uint8Array; etag: string; httpMetadata?: R2HTTPMetadata }; +type RecoveryProjectFixture = { id: string; published: boolean; slug?: string }; + +function testConditional(options?: R2PutOptions): R2Conditional | undefined { + return options?.onlyIf instanceof Headers ? undefined : options?.onlyIf; +} + +function createBucket() { + let revision = 0; + const store = new Map(); + const encode = (value: string | ArrayBuffer | Uint8Array) => { + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (value instanceof Uint8Array) return value; + return new TextEncoder().encode(value); + }; + const putDirect = (key: string, value: string | Uint8Array) => { + const bytes = encode(value); + store.set(key, { bytes, etag: `e${++revision}` }); + }; + const fixture = { + store, + putDirect, + get: vi.fn(async (key: string) => { + const entry = store.get(key); + if (!entry) return null; + const body = { + ...createTestR2Object(key, entry.etag, entry.bytes.byteLength, { httpMetadata: entry.httpMetadata }), + body: new ReadableStream(), + bodyUsed: false, + text: async () => new TextDecoder().decode(entry.bytes), + json: async () => JSON.parse(new TextDecoder().decode(entry.bytes)), + arrayBuffer: async () => entry.bytes.slice().buffer, + blob: async () => new Blob([entry.bytes.slice().buffer]), + }; + // SAFETY: This object implements every R2ObjectBody member consumed by the tests. + return body as R2ObjectBody; + }), + put: vi.fn(async (key: string, value: string | ArrayBuffer | Uint8Array, options?: R2PutOptions) => { + const conditional = testConditional(options); + const current = store.get(key); + if (conditional?.etagDoesNotMatch === "*" && current) return null; + if (conditional?.etagMatches && current?.etag !== conditional.etagMatches) return null; + const bytes = encode(value); + const etag = `e${++revision}`; + store.set(key, { + bytes, + etag, + httpMetadata: options?.httpMetadata instanceof Headers ? undefined : options?.httpMetadata, + }); + return createTestR2Object(key, etag, bytes.byteLength); + }), + delete: vi.fn(async (key: string) => { store.delete(key); }), + head: vi.fn(async (key: string) => { + const entry = store.get(key); + return entry ? createTestR2Object(key, entry.etag, entry.bytes.byteLength) : null; + }), + list: vi.fn(async ({ prefix = "", limit }: R2ListOptions = {}) => ({ + objects: [...store.entries()] + .filter(([key]) => key.startsWith(prefix)) + .slice(0, limit) + .map(([key, entry]) => createTestR2Object(key, entry.etag, entry.bytes.byteLength)), + truncated: false, + delimitedPrefixes: [], + })), + createMultipartUpload: vi.fn(async () => { throw new Error("multipart upload is not used"); }), + resumeMultipartUpload: vi.fn(() => { throw new Error("multipart upload is not used"); }), + }; + // SAFETY: The fixture supplies every R2 method exercised by this suite. + const bucket = fixture as R2Bucket & { + store: Map; + putDirect: (key: string, value: string | Uint8Array) => void; + }; + return bucket; +} + +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +const RECOVERY_ID = "11111111-1111-4111-8111-111111111111"; +const IDEMPOTENCY_KEY = "22222222-2222-4222-8222-222222222222"; +const SUBJECT = `cail-${"a".repeat(32)}`; +const OTHER_SUBJECT = `cail-${"b".repeat(32)}`; +const SOURCE = `user_${"c".repeat(32)}`; + +async function seedRecovery(bucket: ReturnType) { + const projects = Array.from({ length: 7 }, (_, index) => { + const project: RecoveryProjectFixture = { + id: `project-${index + 1}`, + published: index === 0, + }; + if (index === 0) project.slug = "published-lesson"; + return project; + }); + const objects: Array<{ projectId: string; path: string; size: number; sha256: string }> = []; + for (const project of projects) { + const metadataValue: ProjectMetadata = { + id: project.id, + name: project.id, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + published: project.published, + }; + if (project.slug) metadataValue.slug = project.slug; + const metadata = JSON.stringify(metadataValue); + const html = `

${project.id}

`; + for (const [path, content] of [[".metadata.json", metadata], ["index.html", html]] as const) { + bucket.putDirect(`projects/${SOURCE}/${project.id}/${path}`, content); + objects.push({ projectId: project.id, path, size: new TextEncoder().encode(content).byteLength, sha256: await sha256(content) }); + } + } + const manifest = JSON.stringify({ + version: 1, + recoveryId: RECOVERY_ID, + source: { account: "d".repeat(32), bucket: "site-studio", owner: SOURCE, prefix: `projects/${SOURCE}/` }, + projects, + objects, + }); + bucket.putDirect(`recoveries/${RECOVERY_ID}/manifest.json`, manifest); + bucket.putDirect(`imports/${encodeURIComponent(SUBJECT)}`, ""); + return { projects, objects }; +} + +function createAdmin(bucket: ReturnType) { + const kv = createMockKV(); + const claim = vi.fn(async () => ({ granted: true, resume: false })); + const restore = vi.fn(async (recoveryId: string, anonUserId: string, subject: string) => { + const result = await migrateAnonymousData({ bucket, kv, anonUserId, subject }); + const projectMap = { + ...await findImportedProjectMap(bucket, subject, anonUserId), + ...result.projects, + }; + const { manifest } = await loadRecoveryManifest(bucket, recoveryId); + const migrationClaim = await kv.get(migrationClaimKey(anonUserId), "json"); + await validateStagedRecovery(bucket, manifest, { allowMissing: Boolean(migrationClaim) }); + await verifyRecoveredDestination({ bucket, manifest, targetSubject: subject, projectMap }); + const recovery = await readRecoveryAuthorization(bucket, recoveryId); + if (!recovery) throw new Error("missing recovery"); + const recoveryHandle = await claimRecoveryHandle(bucket, anonUserId, subject, recovery.value.startedAt); + await writeRecoveryAliases({ bucket, manifest, targetSubject: subject, projectMap, recoveryHandle }); + await markRecoveryComplete(bucket, recovery, projectMap, recoveryHandle); + return { ...result, projects: projectMap }; + }); + // SAFETY: Tests use only the stable name/toString identity fields of this inert object id. + const idFromName = (name: string) => ({ name, toString: () => name }) as DurableObjectId; + const env: Env = { + // SAFETY: Recovery unit tests never load a Dynamic Worker. + LOADER: {} as WorkerLoader, + // SAFETY: Recovery unit tests never call a SiteBuilderAgent. + SITE_BUILDER_AGENT: {} as Env["SITE_BUILDER_AGENT"], + SITE_STUDIO_BUCKET: bucket, + SESSION_KV: kv, + PUBLISHED_BASE_URL: "https://tools.example/site-studio", + MIGRATION_COORDINATOR: createTestNamespace({ idFromName, get: () => ({ claim }) }), + MUTATION_COORDINATOR: createTestNamespace({ idFromName, get: () => ({ restoreLegacyProjects: restore }) }), + }; + // SAFETY: The WorkerEntrypoint shim has no constructor; assigning env creates the test instance. + const admin = Object.assign(Object.create(SiteStudioRecoveryAdmin.prototype), { env }) as SiteStudioRecoveryAdmin; + return { admin, claim, restore, env }; +} + +function createActualCoordinatorAdmin(bucket: ReturnType) { + const kv = createMockKV(); + const claim = vi.fn(async () => ({ granted: true, resume: false })); + // SAFETY: Tests use only the stable name/toString identity fields of this inert object id. + const ids = (name: string) => ({ name, toString: () => name }) as DurableObjectId; + const coordinators = new Map(); + let env: Env; + const namespace = { + idFromName: ids, + get(id: DurableObjectId) { + const name = id.toString(); + let coordinator = coordinators.get(name); + if (!coordinator) { + const values = new Map(); + const storage = { + // SAFETY: Each journal key is read with the same type used for its write. + get: async (key: string) => values.get(key) as T | undefined, + put: async (key: string, value: T) => { values.set(key, value); }, + delete: async (key: string) => values.delete(key), + }; + // SAFETY: The DO shim has no constructor; these are the fields used by the real methods. + coordinator = Object.assign(Object.create(MutationCoordinator.prototype), { + env, + ctx: { storage }, + mutations: new SerializedOperationQueue(), + }) as MutationCoordinator; + coordinators.set(name, coordinator); + } + return coordinator; + }, + }; + env = { + // SAFETY: Recovery unit tests never load a Dynamic Worker. + LOADER: {} as WorkerLoader, + // SAFETY: Recovery unit tests never call a SiteBuilderAgent. + SITE_BUILDER_AGENT: {} as Env["SITE_BUILDER_AGENT"], + SITE_STUDIO_BUCKET: bucket, + SESSION_KV: kv, + PUBLISHED_BASE_URL: "https://tools.example/site-studio", + MIGRATION_COORDINATOR: createTestNamespace({ idFromName: ids, get: () => ({ claim }) }), + MUTATION_COORDINATOR: createTestNamespace(namespace), + }; + // SAFETY: The WorkerEntrypoint shim has no constructor; assigning env creates the test instance. + const admin = Object.assign(Object.create(SiteStudioRecoveryAdmin.prototype), { env }) as SiteStudioRecoveryAdmin; + return { admin, env }; +} + +describe("SiteStudioRecoveryAdmin", () => { + let bucket: ReturnType; + + beforeEach(() => { bucket = createBucket(); }); + + it("lists only bounded canonical import candidates", async () => { + bucket.putDirect(`imports/${encodeURIComponent(SUBJECT)}`, ""); + const { admin } = createAdmin(bucket); + await expect(admin.listLegacyRecoveryCandidates({})).resolves.toEqual({ + ok: true, + subjects: [SUBJECT], + nextCursor: null, + }); + }); + + it("fails closed on an incomplete or hash-mismatched source before claiming", async () => { + const { objects } = await seedRecovery(bucket); + bucket.store.delete(`projects/${SOURCE}/${objects[0].projectId}/${objects[0].path}`); + const { admin, claim, restore } = createAdmin(bucket); + await expect(admin.restoreLegacyProjects({ recoveryId: RECOVERY_ID, targetSubject: SUBJECT, idempotencyKey: IDEMPOTENCY_KEY })) + .resolves.toEqual({ ok: false, code: "invalid" }); + expect(claim).not.toHaveBeenCalled(); + expect(restore).not.toHaveBeenCalled(); + }); + + it("restores six private and one published project, preserves collisions, and replays after source deletion", async () => { + await seedRecovery(bucket); + const unrelated = JSON.stringify({ id: "project-1", name: "existing", createdAt: "x", updatedAt: "x", published: false }); + bucket.putDirect(`projects/${SUBJECT}/project-1/.metadata.json`, unrelated); + bucket.putDirect(`projects/${SUBJECT}/project-1/index.html`, "

unrelated

"); + const markerBefore = bucket.store.get(`imports/${encodeURIComponent(SUBJECT)}`)?.bytes; + const { admin, restore, env } = createAdmin(bucket); + const first = await admin.restoreLegacyProjects({ recoveryId: RECOVERY_ID, targetSubject: SUBJECT, idempotencyKey: IDEMPOTENCY_KEY }); + expect(first).toMatchObject({ ok: true, state: "restored", projectCount: 7, fileCount: 14 }); + expect(bucket.store.has(`projects/${SUBJECT}/project-1-imported/index.html`)).toBe(true); + expect(new TextDecoder().decode(bucket.store.get(`projects/${SUBJECT}/project-1/index.html`)?.bytes)).toBe("

unrelated

"); + expect([...bucket.store.keys()].some((key) => key.startsWith(`projects/${SOURCE}/`))).toBe(false); + expect(bucket.store.get(`imports/${encodeURIComponent(SUBJECT)}`)?.bytes).toEqual(markerBefore); + expect(bucket.store.has(`recovery-aliases/${SOURCE}/published-lesson.json`)).toBe(true); + expect([...bucket.store.keys()].filter((key) => key.startsWith(`recovery-aliases/${SOURCE}/`))).toHaveLength(1); + expect(bucket.store.has(`userhandles/${SUBJECT}.json`)).toBe(false); + + const replay = await admin.restoreLegacyProjects({ recoveryId: RECOVERY_ID, targetSubject: SUBJECT, idempotencyKey: IDEMPOTENCY_KEY }); + expect(replay).toMatchObject({ ok: true, state: "already_restored" }); + expect(restore).toHaveBeenCalledTimes(1); + await expect(admin.restoreLegacyProjects({ recoveryId: RECOVERY_ID, targetSubject: OTHER_SUBJECT, idempotencyKey: IDEMPOTENCY_KEY })) + .resolves.toEqual({ ok: false, code: "conflict" }); + + const app = new Hono<{ Bindings: Env }>(); + app.route("/", createPublishRouter()); + const served = await app.request(`http://local/sites/${SOURCE}/published-lesson/`, {}, env); + expect(served.status).toBe(302); + expect(served.headers.get("location")).toBe(`/site-studio/u/legacy-${"c".repeat(25)}/published-lesson/`); + expect(served.headers.get("cache-control")).toBe("no-store"); + bucket.putDirect("handles/restored-owner.json", JSON.stringify({ ownerId: SUBJECT, claimedAt: "2026-01-01T00:00:00.000Z" })); + bucket.putDirect(`userhandles/${SUBJECT}.json`, JSON.stringify({ handle: "restored-owner", claimedAt: "2026-01-01T00:00:00.000Z" })); + const redirected = await app.request( + `http://local/sites/${SOURCE}/published-lesson/notes.html?view=full`, + {}, + env, + ); + expect(redirected.status).toBe(302); + expect(redirected.headers.get("location")).toBe("/site-studio/u/restored-owner/published-lesson/notes.html?view=full"); + expect(redirected.headers.get("cache-control")).toBe("no-store"); + const importedMetadataKey = `projects/${SUBJECT}/project-1-imported/.metadata.json`; + // SAFETY: Migration wrote this object from the validated ProjectMetadata fixture. + const imported = JSON.parse(new TextDecoder().decode(bucket.store.get(importedMetadataKey)?.bytes)) as ProjectMetadata; + const renamedMetadataKey = `projects/${SUBJECT}/renamed-project/.metadata.json`; + bucket.putDirect(renamedMetadataKey, JSON.stringify({ ...imported, id: "renamed-project" })); + bucket.putDirect(`projects/${SUBJECT}/renamed-project/index.html`, "

project-1

"); + bucket.store.delete(importedMetadataKey); + bucket.store.delete(`projects/${SUBJECT}/project-1-imported/index.html`); + expect((await app.request(`http://local/sites/${SOURCE}/published-lesson/`, {}, env)).status).toBe(302); + bucket.putDirect(renamedMetadataKey, JSON.stringify({ ...imported, id: "renamed-project", published: false })); + expect((await app.request(`http://local/sites/${SOURCE}/published-lesson/`, {}, env)).status).toBe(404); + }); + + it("finishes the durable receipt when a retry starts after staged source deletion", async () => { + await seedRecovery(bucket); + const { admin } = createActualCoordinatorAdmin(bucket); + const putMock = vi.mocked(bucket.put); + const originalPut = putMock.getMockImplementation(); + if (!originalPut) throw new Error("missing put fixture"); + let failReceipt = true; + putMock.mockImplementation(async (key, value, options) => { + const conditional = testConditional(options); + if (failReceipt && key.endsWith("/authorization.json") && conditional?.etagMatches) { + failReceipt = false; + throw new Error("injected failure before receipt"); + } + return originalPut(key, value, options); + }); + const input = { recoveryId: RECOVERY_ID, targetSubject: SUBJECT, idempotencyKey: IDEMPOTENCY_KEY }; + await expect(admin.restoreLegacyProjects(input)).resolves.toEqual({ ok: false, code: "unavailable" }); + expect([...bucket.store.keys()].some((key) => key.startsWith(`projects/${SOURCE}/`))).toBe(false); + await expect(admin.restoreLegacyProjects(input)).resolves.toMatchObject({ ok: true, state: "restored" }); + const receipt = await readRecoveryAuthorization(bucket, RECOVERY_ID); + expect(receipt?.value.status).toBe("complete"); + expect(Object.keys(receipt?.value.projectMap ?? {})).toHaveLength(7); + }); + + it("resumes after a staged delete fails mid-inventory without duplicating projects", async () => { + await seedRecovery(bucket); + const deleteMock = vi.mocked(bucket.delete); + const originalDelete = deleteMock.getMockImplementation(); + if (!originalDelete) throw new Error("missing delete fixture"); + let projectDeletes = 0; + deleteMock.mockImplementation(async (key: string | string[]) => { + if (Array.isArray(key)) return originalDelete(key); + if (key.startsWith(`projects/${SOURCE}/`) && ++projectDeletes === 2) { + throw new Error("injected delete failure"); + } + return originalDelete(key); + }); + const { admin } = createActualCoordinatorAdmin(bucket); + const input = { recoveryId: RECOVERY_ID, targetSubject: SUBJECT, idempotencyKey: IDEMPOTENCY_KEY }; + await expect(admin.restoreLegacyProjects(input)).resolves.toEqual({ ok: false, code: "unavailable" }); + deleteMock.mockImplementation(originalDelete); + await expect(admin.restoreLegacyProjects(input)).resolves.toMatchObject({ ok: true, state: "restored" }); + const importedMetadata = [...bucket.store.keys()].filter( + (key) => key.startsWith(`projects/${SUBJECT}/`) && key.endsWith("/.metadata.json"), + ); + expect(importedMetadata).toHaveLength(7); + }); +}); diff --git a/packages/app/src/recovery-admin.ts b/packages/app/src/recovery-admin.ts new file mode 100644 index 0000000..2aa2ee5 --- /dev/null +++ b/packages/app/src/recovery-admin.ts @@ -0,0 +1,147 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { z } from "zod"; +import type { Env } from "./types"; +import { + type RecoveryCandidateResult, + type RecoveryRestoreResult, + recoveryIdSchema, +} from "./lib/recovery-contract"; +import { + LegacyRecoveryError, + assertNoSourceHandleExposure, + assertSameRecovery, + createPendingRecovery, + loadRecoveryManifest, + readRecoveryAuthorization, + validateStagedRecovery, +} from "./lib/legacy-recovery"; +import { normalizePublishedBaseUrl } from "./lib/published-url"; + +const restoreInputSchema = z.object({ + recoveryId: recoveryIdSchema, + targetSubject: z.string().regex(/^cail-[a-f0-9]{32}$/), + idempotencyKey: z.string().uuid(), +}).strict(); +const candidateInputSchema = z.object({ cursor: z.string().min(1).max(512).optional() }).strict(); +const CANDIDATE_PAGE_SIZE = 32; + +export class SiteStudioRecoveryAdmin extends WorkerEntrypoint { + async listLegacyRecoveryCandidates(input: { cursor?: string }): Promise { + const parsed = candidateInputSchema.safeParse(input); + if (!parsed.success) return { ok: false, code: "invalid" }; + try { + const page = await this.env.SITE_STUDIO_BUCKET.list({ + prefix: "imports/", + cursor: parsed.data.cursor, + limit: CANDIDATE_PAGE_SIZE, + }); + const subjects: string[] = []; + const seen = new Set(); + for (const object of page.objects) { + const encoded = object.key.slice("imports/".length); + let subject: string; + try { + subject = decodeURIComponent(encoded); + } catch { + return { ok: false, code: "unavailable" }; + } + if ( + !/^cail-[a-f0-9]{32}$/.test(subject) || + encodeURIComponent(subject) !== encoded || + seen.has(subject) + ) { + return { ok: false, code: "unavailable" }; + } + seen.add(subject); + subjects.push(subject); + } + return { + ok: true, + subjects, + nextCursor: page.truncated ? page.cursor ?? null : null, + }; + } catch { + return { ok: false, code: "unavailable" }; + } + } + + async restoreLegacyProjects(input: { + recoveryId: string; + targetSubject: string; + idempotencyKey: string; + }): Promise { + const parsed = restoreInputSchema.safeParse(input); + if (!parsed.success) return { ok: false, code: "invalid" }; + const { recoveryId, targetSubject, idempotencyKey } = parsed.data; + + try { + const { manifest, sha256: manifestSha256 } = await loadRecoveryManifest( + this.env.SITE_STUDIO_BUCKET, + recoveryId, + ); + const projectCount = manifest.projects.length; + const fileCount = manifest.objects.length; + const byteCount = manifest.objects.reduce((total, object) => total + object.size, 0); + let stored = await readRecoveryAuthorization(this.env.SITE_STUDIO_BUCKET, recoveryId); + let wasComplete = false; + + if (!stored) { + await validateStagedRecovery(this.env.SITE_STUDIO_BUCKET, manifest); + await assertNoSourceHandleExposure(this.env.SITE_STUDIO_BUCKET, manifest.source.owner); + + const claim = await this.env.MIGRATION_COORDINATOR + .get(this.env.MIGRATION_COORDINATOR.idFromName(manifest.source.owner)) + .claim(manifest.source.owner, targetSubject); + if (!claim.granted) throw new LegacyRecoveryError("conflict"); + + const pending = await createPendingRecovery(this.env.SITE_STUDIO_BUCKET, { + version: 1, + recoveryId, + sourceOwner: manifest.source.owner, + manifestSha256, + targetSubject, + idempotencyKey, + status: "pending", + projectCount, + fileCount, + byteCount, + startedAt: new Date().toISOString(), + }); + assertSameRecovery(pending, { targetSubject, idempotencyKey }, manifestSha256, manifest.source.owner); + stored = await readRecoveryAuthorization(this.env.SITE_STUDIO_BUCKET, recoveryId); + } else { + assertSameRecovery(stored.value, { targetSubject, idempotencyKey }, manifestSha256, manifest.source.owner); + wasComplete = stored.value.status === "complete"; + } + + if (!stored) throw new LegacyRecoveryError("unavailable"); + if (stored.value.status !== "complete") { + const namespace = this.env.MUTATION_COORDINATOR; + if (!namespace) throw new LegacyRecoveryError("unavailable"); + await namespace + .get(namespace.idFromName(`owner:${targetSubject}`)) + .restoreLegacyProjects(recoveryId, manifest.source.owner, targetSubject); + } + + const baseUrl = normalizePublishedBaseUrl(this.env.PUBLISHED_BASE_URL ?? ""); + const published = manifest.projects.filter((project) => project.published && project.slug); + const restoredUrl = published.length === 1 && baseUrl + ? `${baseUrl}/sites/${manifest.source.owner}/${published[0].slug}/` + : null; + return { + ok: true, + recoveryId, + state: wasComplete ? "already_restored" : "restored", + manifestSha256, + projectCount, + fileCount, + byteCount, + appUrl: baseUrl ? `${baseUrl}/` : "/", + restoredUrl, + }; + } catch (error) { + if (error instanceof LegacyRecoveryError) return { ok: false, code: error.code }; + return { ok: false, code: "unavailable" }; + } + } +} diff --git a/packages/app/src/routes/projects.ts b/packages/app/src/routes/projects.ts index 7171cad..ce58291 100644 --- a/packages/app/src/routes/projects.ts +++ b/packages/app/src/routes/projects.ts @@ -19,8 +19,9 @@ import { type LoggingVariables, } from "../lib/logging"; import { executeOwnerMutation } from "../lib/owner-mutations"; -import { getUserHandle } from "../lib/handles"; +import { getUserHandle, resolveHandleOwner } from "../lib/handles"; import { getPublishedBaseUrl, publishedProjectUrl } from "../lib/published-url"; +import { findCompletedRecoveryAliasForTarget } from "../lib/legacy-recovery"; const createProjectSchema = z.object({ name: z.string().min(1).max(100), @@ -35,15 +36,31 @@ const createSnapshotSchema = z.object({ label: z.string().min(1).max(160).optional() }); -function toProjectSummary( +async function toProjectSummary( id: string, metadata: ProjectMetadata | null, publishedBaseUrl: string, handle: string | null, + bucket: R2Bucket, + subject: string, ) { - const publishedUrl = metadata?.published && metadata.slug && handle + let publishedUrl = metadata?.published && metadata.slug && handle ? publishedProjectUrl(publishedBaseUrl, handle, metadata.slug) : undefined; + if (!publishedUrl && metadata?.published && metadata.slug && metadata.importedFrom) { + const alias = await findCompletedRecoveryAliasForTarget( + bucket, + metadata.importedFrom, + subject, + id, + ); + if ( + alias?.recoveryHandle && + await resolveHandleOwner(bucket, alias.recoveryHandle) === subject + ) { + publishedUrl = publishedProjectUrl(publishedBaseUrl, alias.recoveryHandle, metadata.slug); + } + } return { id, name: metadata?.name || id, @@ -80,6 +97,8 @@ export function createProjectRouter() { await storage.getProjectMetadata(user.id, projectId), publishedBaseUrl, handle, + c.env.SITE_STUDIO_BUCKET, + user.id, )) ); @@ -183,11 +202,13 @@ export function createProjectRouter() { } throw error; } - return c.json(toProjectSummary( + return c.json(await toProjectSummary( nextId, updated, getPublishedBaseUrl(c.req.url, c.env.PUBLISHED_BASE_URL), await getUserHandle(c.env.SITE_STUDIO_BUCKET, user.id), + c.env.SITE_STUDIO_BUCKET, + user.id, )); }); diff --git a/packages/app/src/routes/publish.ts b/packages/app/src/routes/publish.ts index 2be55a5..a7cb5c8 100644 --- a/packages/app/src/routes/publish.ts +++ b/packages/app/src/routes/publish.ts @@ -47,6 +47,10 @@ import { normalizePublishedBaseUrl, publishedProjectUrl, } from "../lib/published-url"; +import { + findCurrentImportedProjectId, + resolveCompletedRecoveryAlias, +} from "../lib/legacy-recovery"; const MAX_PUBLISH_A11Y_FINDINGS = 50; @@ -451,9 +455,77 @@ export function createPublishRouter() { return serveByHandle(c, filePath); }); + // Narrow recovery compatibility: an explicit completed receipt may retain + // one old published address. No arbitrary legacy owner namespace is served. + app.get("/sites/:legacyOwner/:legacySlug", async (c) => { + return serveRecoveredAlias(c, "index.html"); + }); + + app.get("/sites/:legacyOwner/:legacySlug/*", async (c) => { + const base = `/sites/${c.req.param("legacyOwner")}/${c.req.param("legacySlug")}/`; + const url = new URL(c.req.url); + const rawPath = url.pathname.slice(base.length); + const filePath = decodeServedPath(rawPath); + if (filePath === null) return publishedNotFound(c, rawPath); + return serveRecoveredAlias(c, filePath); + }); + return app; } +async function serveRecoveredAlias(c: AppContext, rawPath: string) { + const sourceOwner = c.req.param("legacyOwner"); + const sourceSlug = c.req.param("legacySlug"); + if (!sourceOwner || !sourceSlug) return publishedNotFound(c, rawPath); + const alias = await resolveCompletedRecoveryAlias(c.env.SITE_STUDIO_BUCKET, sourceOwner, sourceSlug); + if (!alias) return publishedNotFound(c, rawPath); + + const storage = new R2ProjectStorage(c.env.SITE_STUDIO_BUCKET, getLoggingContext(c)); + const currentProjectId = await findCurrentImportedProjectId(c.env.SITE_STUDIO_BUCKET, alias); + if (!currentProjectId) return publishedNotFound(c, rawPath); + const metadata = await storage.getProjectMetadata(alias.targetSubject, currentProjectId); + if ( + !metadata?.published || !metadata.slug || + metadata.importedFrom !== alias.sourceOwner || + metadata.importedOriginalId !== alias.sourceProjectId + ) { + return publishedNotFound(c, rawPath); + } + + const url = new URL(c.req.url); + const legacyRoot = `/sites/${sourceOwner}/${sourceSlug}`; + if (url.pathname === legacyRoot) { + return new Response(null, { + status: 302, + headers: { + Location: `${getPublishedPathPrefix(c)}${legacyRoot}/${url.search}`, + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }, + }); + } + + const primaryHandle = await getUserHandle(c.env.SITE_STUDIO_BUCKET, alias.targetSubject); + const recoveryHandle = alias.recoveryHandle && + await resolveHandleOwner(c.env.SITE_STUDIO_BUCKET, alias.recoveryHandle) === alias.targetSubject + ? alias.recoveryHandle + : null; + const handle = primaryHandle ?? recoveryHandle; + if (!handle) { + return publishedNotFound(c, rawPath); + } + const suffix = rawPath === "index.html" ? "" : rawPath; + const target = `${getPublishedPathPrefix(c)}/u/${handle}/${metadata.slug}/${suffix}${url.search}`; + return new Response(null, { + status: 302, + headers: { + Location: target, + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }, + }); +} + /** Serve a file for a canonical /u/{handle}/{slug}/ request. */ async function serveByHandle(c: AppContext, rawPath: string) { const storage = new R2ProjectStorage(c.env.SITE_STUDIO_BUCKET, getLoggingContext(c));