Skip to content

Commit 68922bb

Browse files
committed
Close Registry authorisation recovery gaps
1 parent f714630 commit 68922bb

9 files changed

Lines changed: 231 additions & 95 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,32 @@
1010
nor the resulting key ID to the browser. The publishing key is written to
1111
the OS keychain as `pending` before the browser opens, promoted to `active`
1212
only when either successful status returns the matching key ID, and removed
13-
on cancellation or expiry. This closes the process-exit window after wallet
14-
approval without treating local state as Registry authority. The browser
13+
only after the Registry confirms cancellation or pending-session expiry. A
14+
local polling deadline performs one final authoritative read and otherwise
15+
preserves the pending key. Completed sessions remain poll-readable for 24
16+
hours after their 15-minute approval window, closing the boundary race in
17+
which wallet approval commits just before the CLI's next poll. This closes
18+
the process-exit window after wallet approval without treating local state
19+
as Registry authority. The browser
1520
token survives same-tab refresh in `sessionStorage` and is removed on
16-
completion or expiry. Session mode now
21+
completion or expiry, with an executable storage-lifecycle regression test.
22+
Session mode now
1723
lists only connectors that can actually complete the browser flow and folds
1824
challenge creation, wallet signing, and completion into one **Approve
1925
publishing access** action; the full external-wallet directory remains in
2026
the explicit manual CLI path. Session completion atomically consumes the
2127
nonce, records the publishing key, claims or reviews the namespace, updates
2228
the session, and writes its audit trail. Concurrent or replayed completion
23-
returns the committed result without duplicating authority. Submit
24-
distinguishes detected in-browser connectors from the complete
25-
external-signature directory, uses
26-
plain publishing-access language on the first-run path, states the
27-
non-replaceable release rule directly, and preserves the explicit CLI path
28-
for external wallets and CI. Artifact details now derive one recommended
29+
returns the committed result without duplicating authority. The Publish page
30+
is now session-first: a direct visit presents one `cellc publish --authorise`
31+
starting command, while a CLI session becomes a one-screen wallet approval
32+
surface with one current action and end-to-end release progress. Artifact
33+
identity is read-only in session mode because cellc and the manifest remain
34+
authoritative. External signing, manifest scaffolding, and existing-key
35+
checks remain available in a deliberately secondary advanced workspace.
36+
Technical scope and session identifiers stay collapsed by default, and
37+
loading, expiry, retry, review-pending, and terminal-continuation states keep
38+
the same stable layout. Artifact details now derive one recommended
2939
action from availability, verification, deployment, and consumption state.
3040
- Add an isolated Pudge Testnet Registry Sandbox. Its API, Postgres database,
3141
object volume, signing origin, RPC identity, website build, wallet storage,

docs/CELLSCRIPT_GATE_POLICY.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,19 @@ The CLI coverage includes both first-publish admission paths: the explicit
6969
`cellc publish` sequence, and the short-lived `cellc publish --authorise`
7070
browser session in which the private publishing key remains in the local OS
7171
keychain as pending while the CLI polls with a one-time secret, becomes active
72-
only after the server returns the matching key ID, and is removed on terminal
73-
cancellation or expiry. The browser token survives a same-tab refresh but is
74-
cleared after completion or expiry. Browser-session completion is one atomic admission boundary across
72+
only after the server returns the matching key ID, and is removed only after
73+
the server confirms terminal cancellation or pending-session expiry. A local
74+
polling deadline performs a final authoritative read and preserves the pending
75+
key if the result is still pending or unreachable. Completed sessions remain
76+
poll-readable for a bounded 24-hour recovery window. The browser token survives
77+
a same-tab refresh but is cleared after completion or expiry; the website build
78+
runs the fragment-store-refresh-clear lifecycle regression. Browser-session
79+
completion is one atomic admission boundary across
7580
nonce consumption, publishing-key registration, namespace claim/review,
7681
session state, and audit events. API tests cover expiry, wrong browser/poll/
7782
challenge tokens, challenge replay, concurrent completion, conflicting
78-
namespace ownership, review-pending admission, and injected mid-transaction
79-
failure. Publisher maintenance additionally uses the capability-signed
83+
namespace ownership, review-pending admission, post-expiry terminal reads, and
84+
injected mid-transaction failure. Publisher maintenance additionally uses the capability-signed
8085
`cellc artifact set-availability` path, and `cellc artifact cell-dep` performs a
8186
fresh mainnet liveness check before producing a transaction-builder descriptor.
8287
Independent reproducibility builders use `cellc auth reproducer create`; CLI

services/registry-api/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,21 @@ registration, namespace claim or review state, session completion, and audit
176176
events in one store transaction. A concurrent call returns the committed
177177
session instead of creating a second capability use. Expired sessions, stale
178178
challenge tokens, and conflicting namespace owners leave the session pending
179-
and create none of those records.
179+
and create none of those records. The 15-minute expiry applies only while a
180+
session is pending. `authorised` and `review_pending` results remain readable
181+
to the polling CLI for 24 hours, then cleanup removes them; this lets a CLI
182+
recover a wallet approval committed immediately before the approval window
183+
closed.
180184

181185
For an interactive first publish, `cellc publish --authorise` creates a
182186
15-minute, exact-coordinate browser session and opens the matching Registry
183187
site. The CLI generates the delegated P-256 key first and keeps its private key
184188
in the OS keychain as pending before opening the browser, then promotes it to
185189
active only after `authorised` or `review_pending` returns the same key ID.
186-
Cancellation and expiry remove the pending entry; an interrupted CLI can still
187-
recover the key through the key ID printed before the browser opens if the
188-
wallet completed first. The API stores only the public key plus hashes of separate
190+
Only Registry-confirmed cancellation or pending-session expiry removes the
191+
pending entry. A local polling deadline performs one final Registry read and
192+
otherwise leaves the pending key recoverable through the key ID printed before
193+
the browser opens. The API stores only the public key plus hashes of separate
189194
one-time CLI-polling and browser-approval tokens. The browser token travels in
190195
the URL fragment, not the query string, so it is absent from HTTP logs and
191196
Referer headers; browser reads never return the polling token or resulting

services/registry-api/src/index.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2768,7 +2768,7 @@ async function handleGetAuthorisationSession(
27682768
sessionIdFromPath: string,
27692769
): Promise<Response> {
27702770
const sessionId = validateAuthorisationSessionId(sessionIdFromPath);
2771-
const session = await requireLiveAuthorisationSession(store, sessionId, now);
2771+
const session = await requireReadableAuthorisationSession(store, sessionId, now);
27722772
const authorization = request.headers.get("authorization");
27732773
const token = authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
27742774
if (!token) throw new ApiError(401, "authorisation_session_token_required", "authorisation session bearer token is required");
@@ -2804,8 +2804,11 @@ async function handlePrepareAuthorisationSession(
28042804
): Promise<Response> {
28052805
await throttleRequestSource(store, request, requestId, "authorisation_session_challenge", 60, 60, now);
28062806
const sessionId = validateAuthorisationSessionId(sessionIdFromPath);
2807-
const session = await requireLiveAuthorisationSession(store, sessionId, now);
2807+
const session = await requireReadableAuthorisationSession(store, sessionId, now);
28082808
await requireAuthorisationBrowserToken(request, session.browser_token_hash);
2809+
if (session.status !== "pending") {
2810+
throw new ApiError(409, "authorisation_session_complete", "authorisation session has already completed");
2811+
}
28092812
if (session.registry_origin !== registryOrigin) {
28102813
throw new ApiError(409, "authorisation_session_origin_mismatch", "authorisation session belongs to another Registry origin");
28112814
}
@@ -2857,7 +2860,7 @@ async function handleCompleteAuthorisationSession(
28572860
): Promise<Response> {
28582861
await throttleRequestSource(store, request, requestId, "authorisation_session_complete", 40, 60, now);
28592862
const sessionId = validateAuthorisationSessionId(sessionIdFromPath);
2860-
const session = await requireLiveAuthorisationSession(store, sessionId, now);
2863+
const session = await requireReadableAuthorisationSession(store, sessionId, now);
28612864
await requireAuthorisationBrowserToken(request, session.browser_token_hash);
28622865
if (session.status !== "pending") {
28632866
return json({
@@ -2928,10 +2931,10 @@ function validateAuthorisationSessionId(value: string): string {
29282931
return sessionId;
29292932
}
29302933

2931-
async function requireLiveAuthorisationSession(store: RegistryStore, sessionId: string, now: Date) {
2934+
async function requireReadableAuthorisationSession(store: RegistryStore, sessionId: string, now: Date) {
29322935
const session = await store.getAuthorisationSession(sessionId);
29332936
if (!session) throw new ApiError(404, "authorisation_session_not_found", "authorisation session was not found");
2934-
if (Date.parse(session.expires_at) <= now.getTime()) {
2937+
if (session.status === "pending" && Date.parse(session.expires_at) <= now.getTime()) {
29352938
throw new ApiError(410, "authorisation_session_expired", "authorisation session has expired; start again from cellc");
29362939
}
29372940
return session;

services/registry-api/src/sql-store.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Client } from "pg";
22
import {
33
assertPromotionTransition,
4+
AUTHORISATION_SESSION_TERMINAL_RETENTION_HOURS,
45
deriveRegistryEntryStatus,
56
packageVersionRequiresReproduction,
67
type AuditEventInput,
@@ -281,13 +282,13 @@ export class SqlRegistryStore implements RegistryStore {
281282
const sessionRow = sessionResult.rows[0];
282283
if (!sessionRow) throw new ApiError(404, "authorisation_session_not_found", "authorisation session was not found");
283284
const session = authorisationSessionFromRow(sessionRow);
284-
if (Date.parse(session.expires_at) <= Date.parse(input.now_iso)) {
285-
throw new ApiError(410, "authorisation_session_expired", "authorisation session has expired");
286-
}
287285
if (session.status !== "pending") {
288286
await client.query("commit");
289287
return { session, replayed: true };
290288
}
289+
if (Date.parse(session.expires_at) <= Date.parse(input.now_iso)) {
290+
throw new ApiError(410, "authorisation_session_expired", "authorisation session has expired");
291+
}
291292
if (session.challenge_token_hash !== input.expected_challenge_token_hash
292293
|| !session.payload
293294
|| canonicalJson(session.payload) !== canonicalJson(input.payload)) {
@@ -2177,7 +2178,13 @@ export class SqlRegistryStore implements RegistryStore {
21772178
try {
21782179
const usedNonces = await client.query("delete from used_nonces where expires_at < $1", [input.now_iso]);
21792180
const idempotencyKeys = await client.query("delete from idempotency_keys where expires_at < $1", [input.now_iso]);
2180-
const authorisationSessions = await client.query("delete from authorisation_sessions where expires_at < $1", [input.now_iso]);
2181+
const authorisationSessions = await client.query(
2182+
`delete from authorisation_sessions
2183+
where (status = 'pending' and expires_at < $1)
2184+
or (status <> 'pending'
2185+
and coalesce(completed_at, updated_at) < $1::timestamptz - ($2 * interval '1 hour'))`,
2186+
[input.now_iso, AUTHORISATION_SESSION_TERMINAL_RETENTION_HOURS],
2187+
);
21812188
const quotaEvents = await client.query("delete from quota_events where created_at < $1", [input.quota_events_before_iso]);
21822189
const expiredVersions = await client.query(
21832190
`update package_versions

services/registry-api/src/store.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface CapabilityRecord {
3535
}
3636

3737
export type AuthorisationSessionStatus = "pending" | "authorised" | "review_pending";
38+
export const AUTHORISATION_SESSION_TERMINAL_RETENTION_HOURS = 24;
3839

3940
export interface AuthorisationSessionRecord {
4041
session_id: string;
@@ -666,10 +667,10 @@ export class MemoryRegistryStore implements RegistryStore {
666667
return this.withAuthorisationSessionCompletionLock("authorisation-store", async () => {
667668
const existing = this.authorisationSessions.get(input.session_id);
668669
if (!existing) throw new ApiError(404, "authorisation_session_not_found", "authorisation session was not found");
670+
if (existing.status !== "pending") return { session: existing, replayed: true };
669671
if (Date.parse(existing.expires_at) <= Date.parse(input.now_iso)) {
670672
throw new ApiError(410, "authorisation_session_expired", "authorisation session has expired");
671673
}
672-
if (existing.status !== "pending") return { session: existing, replayed: true };
673674
if (existing.challenge_token_hash !== input.expected_challenge_token_hash
674675
|| !existing.payload
675676
|| canonicalJson(existing.payload) !== canonicalJson(input.payload)) {
@@ -1405,7 +1406,12 @@ export class MemoryRegistryStore implements RegistryStore {
14051406
}
14061407
}
14071408
for (const [key, record] of this.authorisationSessions.entries()) {
1408-
if (Date.parse(record.expires_at) < now) {
1409+
const terminalRetentionDeadline = Date.parse(record.completed_at ?? record.updated_at)
1410+
+ AUTHORISATION_SESSION_TERMINAL_RETENTION_HOURS * 60 * 60 * 1000;
1411+
const shouldDelete = record.status === "pending"
1412+
? Date.parse(record.expires_at) < now
1413+
: terminalRetentionDeadline < now;
1414+
if (shouldDelete) {
14091415
this.authorisationSessions.delete(key);
14101416
authorisationSessionsDeleted += 1;
14111417
}

services/registry-api/test/registry-api.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,43 @@ describe("registry api", () => {
916916
expect(store.usedNonces.size).toBe(0);
917917
});
918918

919+
it("keeps a completed authorisation session readable after its approval window closes", async () => {
920+
const store = new MemoryRegistryStore();
921+
const { app } = testApp(store);
922+
const { created, browserToken } = await createBrowserAuthorisationSession(app, "terminalread", "demo");
923+
const challenge = await prepareBrowserAuthorisationChallenge(app, created.session_id, browserToken);
924+
const completed = await completeBrowserAuthorisationSession(app, created.session_id, browserToken, challenge);
925+
expect(completed.status).toBe(201);
926+
927+
const afterExpiry = testApp(store, undefined, {
928+
now: () => new Date("2026-06-23T12:16:00Z"),
929+
}).app;
930+
const poll = await get(afterExpiry, `/v1/authorisation-sessions/${created.session_id}`, {}, {
931+
authorization: `Bearer ${created.poll_token}`,
932+
});
933+
934+
expect(poll.status).toBe(200);
935+
expect(await poll.json()).toMatchObject({
936+
status: "authorised",
937+
namespace_status: "active",
938+
capability_key_id: await capabilityKeyId(reproducerPublicKeys["builder-a"]),
939+
});
940+
941+
const retained = await store.cleanupExpiredState({
942+
now_iso: "2026-06-23T12:16:00.000Z",
943+
quota_events_before_iso: "2026-06-22T12:16:00.000Z",
944+
});
945+
expect(retained.authorisation_sessions_deleted).toBe(0);
946+
expect(await store.getAuthorisationSession(created.session_id)).not.toBeNull();
947+
948+
const purged = await store.cleanupExpiredState({
949+
now_iso: "2026-06-24T12:01:00.000Z",
950+
quota_events_before_iso: "2026-06-23T12:01:00.000Z",
951+
});
952+
expect(purged.authorisation_sessions_deleted).toBe(1);
953+
expect(await store.getAuthorisationSession(created.session_id)).toBeNull();
954+
});
955+
919956
it("rejects browser, poll, and challenge token substitution", async () => {
920957
const { app, store } = testApp();
921958
const { created, browserToken } = await createBrowserAuthorisationSession(app);

0 commit comments

Comments
 (0)