Skip to content

feat: mount the public incoming migration API (TECH-681 plan 012) - #29

Draft
Ashex wants to merge 2 commits into
tech-681/migration-credentials-readinessfrom
tech-681/public-arrival-api
Draft

Ashex wants to merge 2 commits into
tech-681/migration-credentials-readinessfrom
tech-681/public-arrival-api

Conversation

@Ashex

@Ashex Ashex commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Part of TECH-681. Stacked on #28. Implements plan 012: the public arrival API and pending-guard integration.

Draft. This PR mounts the incoming-migration routes on every deployment, with no configuration key or opt-out. The live public journey and the stock PDS callback now pass in Atmosphere in a Box on SQLite (see Validation). It stays a draft until the open decisions below are settled and both developers have reviewed it.

What changes

Browser surfaces (verified session, same-origin and CSRF on every POST, no-store, strict bodies)

Route Purpose Fresh email code
GET /incoming Saved user's own arrival state no
POST /incoming/code Send a sign-in code to the verified email no
POST /incoming/prepare Prepare an arrival yes
POST /incoming/renew, /cancel, /recover, /activate Lifecycle, credential recovery, activation yes
POST /incoming/resume Continue saved, already admitted steps no

Another user's operation and an unknown one return the same 404. A repeat prepare by the same saved user for the same DID returns the existing operation ID, so a lost preparation page is recoverable.

Tool surfaces (standard XRPC, no cookie)

  • submitPlcOperation and createAccount are incoming only when the Entryway-Arrival-Code header is present. With the header they are never handled as hosted or managed movement; without it the ordinary routes are unchanged.
  • The arrival code selects the arrival and its saved user. The method-bound source JWT is verified before the ownership lease is taken, and again at admission.
  • createAccount returns tracked credentials only after binding and committed family association.
  • refreshSession and activateAccount serve a tracked arrival credential and otherwise fall through to the ordinary routes. After a completed migration, a later ordinary deactivation and reactivation use the ordinary route.

Pending guard

  • New checks in registration, the account console, OAuth consent and the signup page.
  • The guard is not one enforced gate. Status change, handle change, deletion and PLC operations rely on the existing assertNoMigration; login, security changes, admin and OAuth paths rely on the provisioning status. A single enforced guard is follow-up work.
  • Activation from the browser, Entryway XRPC and the stock PDS callback all go through plan 011's one operation. Ordinary setStatus cannot activate a pending arrival.

Changes to shared and other plans' code

These need review from both developers.

  • Account storage: new bindPlacedAccount(did, userId). It runs in the arrival's binding transaction and fails closed with IdentityConflict if the DID is already bound to another user. No schema change.
  • Plan 010 composition (compose-incoming-arrival.mjs): now builds the production arrival storage and authority, and adds the tool-facing admission.
  • Plan 011 (activate-arrival.ts): the committed-retry result is returned only while the account is active.
  • request-event.ts: four error codes added to the safe list so they are not reported as RequestFailed.
  • Ordinary refreshSession and activateAccount now do one arrival lookup before the ordinary handler.

Decisions

Made

  • Mount unconditionally, with the PR held as a draft until qualification passes.
  • Return the operation ID on a repeat prepare by the same saved user.
  • Document the real guard mechanism in this PR; no broad enforcement change.

Still open (marked as pending in docs/incoming-migration.md and the Changeset)

  • Cookie-less admission: the arrival code selects the arrival and saved user. This is implemented in one place (tool-admission.ts) so it can change.
  • createAccount accepts only handle plus optional did, email and inviteCode. It rejects password, so an unmodified stock tool that sends it does not work.
  • Exact-retry semantics. A createAccount retry after the destination move gets 403 AccountMismatch, because the source key no longer verifies; recovery or resume is the path.
  • The refresh and activate fall-through design.
  • Browser surfaces beyond the C1 table: /incoming/activate, /resume, /code and the status page.
  • Per-IP budget keys incoming-browser-ip and incoming-tool-ip, at the existing 30 per 10 minutes. Submit and create share one tool bucket, and retries spend it.

Validation

Run at c7f65b7.

Native, Node 24

  • npm run build, check, lint, format:check, schema:check, changeset:check: exit 0.
  • npm run test:unit: 817 tests, 798 pass, 0 fail, 19 skipped.
  • npm run test:incoming-reconciliation: 23 pass, 0 fail.
  • Mutation checks: removing the guard, header dispatch, origin and CSRF checks, owner checks, both budgets, no-store, the pre-lease JWT verification, the active-account check and the tracked-family fall-through each make tests fail.

Live, owned Atmosphere in a Box project, rootless Docker, SQLite single node

Group Result
incoming-public-journey 7 passed, 0 skipped
incoming-public-mechanisms 18 pass, 0 fail, 0 skipped
browser 53 passed, 0 failed
contracts 940 tests, 913 pass, 0 fail, 27 skipped; reconciliation 23 pass

The journey uses only public pages and XRPC: a stock standalone source with one record and one blob, browser email proof and preparation, source-signed handoff, createAccount returning tracked credentials for a paused destination, direct repository and blob transfer, refresh, and activation by calling activateAccount on the stock destination PDS, which forwards to Entryway. That last step is the stock-callback proof.

What the live run found

The first live incoming-public-mechanisms run failed. An ordinary deactivated hosted account calling activateAccount with its old access token got 400 from the new incoming handler; the ordinary route answers 401. The handler ran its strict token check before checking whether the session family belonged to an arrival. The same applied to expired ordinary refresh tokens and to expired or revoked sessions of an account whose migration had completed. c7f65b7 fixes this: anything not tracked to an unfinished arrival falls through to the ordinary route with no writes. Native regression tests cover each case.

The same commit updates one stale step in that test: the password form moved to the password page in 9184773.

Limits

  • The live run covers SQLite single node only. PostgreSQL is covered by the databases and profiles CI jobs, not by a live journey.
  • The journey checks one record and one blob, not a complete inventory.
  • Renew, cancel, recover, browser activation and the rate limits are covered by native tests only, not live.
  • The last ten-line change in c7f65b7 (finished-arrival sessions falling through) was not independently reviewed.
  • The managed ./scripts/changesets.sh authoring flow was not run; the Changeset was written by hand and changeset:check passes.

🤖 Generated with Claude Code

Mount the incoming migration flow that earlier changes shipped dormant.

Browser surfaces under /incoming let the saved user prepare, renew,
cancel, recover, resume and activate an arrival and see its state. Each
POST needs the verified session, same-origin and CSRF checks and, where
the action is sensitive, a fresh email code. A repeat prepare by the
same saved user returns the existing operation ID.

Tool surfaces reuse standard XRPC. submitPlcOperation and createAccount
are treated as incoming only when the Entryway-Arrival-Code header is
present and are then never handled as hosted or managed movement. The
arrival code selects the arrival and its saved user, and the
method-bound source JWT is verified before the ownership lease is
taken. createAccount returns tracked credentials only after the account
is bound. refreshSession and activateAccount serve a tracked arrival
credential and otherwise fall through to the ordinary routes.

Pending arrivals are refused by registration, the account console,
OAuth consent and signup, in addition to the existing status and
migration checks. Activation by any consumer goes through the single
guarded operation.

Account storage gains bindPlacedAccount, which fails closed on a
binding to another user. No schema change and no configuration key.

The live public journey, the contracts group and the stock PDS callback
have not been run for this change.

This work was assisted by a machine
@coderabbitai

coderabbitai Bot commented Nov 12, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

…outes

The incoming activateAccount and refreshSession handlers run before the
ordinary routes and must answer only for a tracked arrival credential.
They ran the strict token check first, so an ordinary account whose
access token had expired or whose session family was revoked got the
incoming 400 ExpiredToken where the ordinary route answers 401. An
expired ordinary refresh token had the same flaw. A live run against a
stock PDS found it: delegated activation of a deactivated hosted
account returned 400.

The handlers now verify the token's signature, type, issuer and
audience while tolerating expiry, look up whether the session family is
tracked to an arrival, and fall through for anything untracked without
writing anything. The strict checks still run before any incoming
answer. A finished arrival's family is an ordinary session, so once the
session store finds it expired or revoked it also falls through. A
tracked family of an unfinished arrival keeps the incoming refusal.

Also update the stale password step in the public-mechanisms test: the
form moved to the password page.

This work was assisted by a machine
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant