Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
49 changes: 42 additions & 7 deletions docs/security-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
122 changes: 121 additions & 1 deletion packages/app/src/agents/mutation-coordinator.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void> = Promise.resolve();
Expand Down Expand Up @@ -79,6 +96,109 @@ export class MutationCoordinator extends DurableObject<Env> {
});
}

/** Consume an operator-staged file-only source without probing agent chat. */
async migrateStagedLegacyProjects(
recoveryId: string,
anonUserId: string,
subject: string,
): Promise<MigrationResult> {
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<MigrationClaim>(
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<MigrationResult> {
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
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading