gestalt-mobile 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/client/assets/index-BOHLkK8Y.css +1 -0
  2. package/dist/client/assets/index-DK64debX.js +24 -0
  3. package/dist/client/icons/gestalt-mobile-180.png +0 -0
  4. package/dist/client/icons/gestalt-mobile-192.png +0 -0
  5. package/dist/client/icons/gestalt-mobile-512.png +0 -0
  6. package/dist/client/icons/gestalt-mobile-maskable-512.png +0 -0
  7. package/dist/client/icons/gestalt-mobile-maskable.svg +10 -0
  8. package/dist/client/icons/gestalt-mobile.svg +10 -0
  9. package/dist/client/index.html +8 -3
  10. package/dist/client/manifest.webmanifest +32 -0
  11. package/dist/client/service-worker.js +9 -0
  12. package/dist/server/server/composition.js +16 -5
  13. package/dist/server/server/features/auth/application/ports.js +7 -1
  14. package/dist/server/server/features/auth/login/verify/endpoint.js +1 -1
  15. package/dist/server/server/features/auth/register/verify/endpoint.js +19 -4
  16. package/dist/server/server/features/auth/register-routes.js +4 -1
  17. package/dist/server/server/features/sessions/get-history/endpoint.js +15 -4
  18. package/dist/server/server/features/sessions/get-history/history-mapper.js +9 -0
  19. package/dist/server/server/features/sessions/register-routes.js +5 -1
  20. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +19 -3
  21. package/dist/server/server/features/sessions/session-events/endpoint.js +47 -12
  22. package/dist/server/server/features/sessions/start-turn/endpoint.js +38 -0
  23. package/dist/server/server/platform/auth/simple-webauthn-adapter.js +32 -6
  24. package/dist/server/server/platform/codex/session-runtime.js +1 -0
  25. package/dist/server/server/platform/persistence/migrate.js +10 -1
  26. package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +43 -6
  27. package/dist/server/shared/contracts/chat-snapshot.js +6 -0
  28. package/package.json +1 -1
  29. package/dist/client/assets/index-C5qFcFUg.css +0 -1
  30. package/dist/client/assets/index-tMyOolAm.js +0 -24
@@ -0,0 +1,10 @@
1
+ <!--
2
+ Copyright (C) 2026 Dyne.org foundation
3
+ Designed by Denis Roio <jaromil@dyne.org>
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+ -->
6
+
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
8
+ <rect width="512" height="512" fill="#141414" />
9
+ <image href="../branding/p_glogo_white.svg" x="102" y="102" width="308" height="308" />
10
+ </svg>
@@ -0,0 +1,10 @@
1
+ <!--
2
+ Copyright (C) 2026 Dyne.org foundation
3
+ Designed by Denis Roio <jaromil@dyne.org>
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+ -->
6
+
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
8
+ <rect width="512" height="512" fill="#141414" />
9
+ <image href="../branding/p_glogo_white.svg" x="64" y="64" width="384" height="384" />
10
+ </svg>
@@ -10,9 +10,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later
10
10
  <meta charset="UTF-8" />
11
11
  <meta name="viewport" content="width=device-width, initial-scale=1" />
12
12
  <meta name="color-scheme" content="light dark" />
13
- <title>Codex Relay</title>
14
- <script type="module" crossorigin src="/assets/index-tMyOolAm.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-C5qFcFUg.css">
13
+ <meta name="theme-color" content="#141414" />
14
+ <meta name="application-name" content="Gestalt Mobile" />
15
+ <link rel="manifest" href="/manifest.webmanifest" />
16
+ <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
+ <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
+ <title>Gestalt Mobile</title>
19
+ <script type="module" crossorigin src="/assets/index-DK64debX.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BOHLkK8Y.css">
16
21
  </head>
17
22
  <body>
18
23
  <div id="app"></div>
@@ -0,0 +1,32 @@
1
+ {
2
+ "id": "/",
3
+ "name": "Gestalt Mobile",
4
+ "short_name": "Gestalt",
5
+ "description": "A mobile interface for durable Codex development sessions.",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#141414",
10
+ "theme_color": "#141414",
11
+ "categories": ["productivity", "utilities"],
12
+ "icons": [
13
+ {
14
+ "src": "/icons/gestalt-mobile-192.png",
15
+ "sizes": "192x192",
16
+ "type": "image/png",
17
+ "purpose": "any"
18
+ },
19
+ {
20
+ "src": "/icons/gestalt-mobile-512.png",
21
+ "sizes": "512x512",
22
+ "type": "image/png",
23
+ "purpose": "any"
24
+ },
25
+ {
26
+ "src": "/icons/gestalt-mobile-maskable-512.png",
27
+ "sizes": "512x512",
28
+ "type": "image/png",
29
+ "purpose": "maskable"
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+
7
+ // Installability only: authenticated relay data must always remain network-fresh.
8
+ self.addEventListener('install', () => self.skipWaiting());
9
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
@@ -124,10 +124,15 @@ export async function composeRelayApp(options) {
124
124
  }
125
125
  events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
126
126
  }, (sessionId, request) => {
127
- const interaction = toPendingInteraction(request);
127
+ const rawInteraction = toPendingInteraction(request);
128
128
  const session = withPendingInteractions(sessions.find(sessionId));
129
- if (!interaction || !session)
129
+ if (!rawInteraction || !session)
130
130
  return false;
131
+ const interaction = {
132
+ ...rawInteraction,
133
+ turnId: session.activeTurnId,
134
+ requestedAt: new Date().toISOString(),
135
+ };
131
136
  interactions.add(sessionId, interaction);
132
137
  const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
133
138
  sessions.save(updated);
@@ -305,8 +310,11 @@ export async function composeRelayApp(options) {
305
310
  replyInteraction: runtime
306
311
  ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
307
312
  : undefined,
308
- interactionResolved: (sessionId, requestId, occurredAt) => {
309
- events.publish(journal.append(sessionId, 'interaction.resolved', { requestId }, occurredAt));
313
+ interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
314
+ const interaction = interactions
315
+ .snapshot(sessionId)
316
+ .find((item) => item.requestId === requestId);
317
+ events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
310
318
  },
311
319
  },
312
320
  sessionEvents: {
@@ -341,7 +349,10 @@ export async function composeRelayApp(options) {
341
349
  }
342
350
  : {}),
343
351
  interactions: {
344
- resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
352
+ resolve: (sessionId, requestId, resolvedAt, outcome) => interactions.resolve(sessionId, requestId, resolvedAt, outcome),
353
+ alreadyResolved: (sessionId, requestId) => interactions.resolved(sessionId, requestId),
354
+ snapshot: (sessionId) => interactions.snapshot(sessionId),
355
+ pending: (sessionId, requestId) => interactions.find(sessionId, requestId) !== null,
345
356
  validate: (sessionId, requestId, value) => {
346
357
  const interaction = interactions.find(sessionId, requestId);
347
358
  if (!interaction)
@@ -3,6 +3,12 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- /** Expected proof failure; adapters must translate library-specific failures to this type. */
6
+ /** Expected proof failure; adapters translate library details to a safe diagnostic category. */
7
7
  export class PasskeyVerificationError extends Error {
8
+ reason;
9
+ constructor(reason = 'VERIFIER_REJECTED') {
10
+ super('PASSKEY_VERIFICATION_FAILED');
11
+ this.reason = reason;
12
+ this.name = 'PasskeyVerificationError';
13
+ }
8
14
  }
@@ -50,7 +50,7 @@ export function registerLoginVerification(app, deps) {
50
50
  },
51
51
  });
52
52
  if (!verified.userVerified || verified.credentialId !== device.credentialId)
53
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
53
+ throw new PasskeyVerificationError('USER_VERIFICATION_REQUIRED');
54
54
  const session = deps.identifiers.sessionId();
55
55
  if (Buffer.from(session, 'base64url').length !== 32)
56
56
  throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
@@ -35,11 +35,20 @@ export function registerRegistrationVerification(app, deps) {
35
35
  app.post('/api/auth/register/verify', { bodyLimit: 128 * 1024 }, async (request, reply) => {
36
36
  const parsed = requestSchema.safeParse(request.body);
37
37
  const token = request.cookies.gestalt_mobile_registration;
38
- if (!parsed.success || typeof token !== 'string')
38
+ if (!parsed.success) {
39
+ deps.reportVerificationFailure?.('INVALID_RESPONSE_SHAPE');
39
40
  return reply
40
41
  .code(400)
41
42
  .type('application/problem+json')
42
43
  .send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
44
+ }
45
+ if (typeof token !== 'string') {
46
+ deps.reportVerificationFailure?.('MISSING_CEREMONY_COOKIE');
47
+ return reply
48
+ .code(400)
49
+ .type('application/problem+json')
50
+ .send(problem('REGISTRATION_COOKIE_MISSING', 400, 'The registration cookie is missing.'));
51
+ }
43
52
  let nickname;
44
53
  try {
45
54
  nickname = deviceNickname(parsed.data.nickname);
@@ -54,11 +63,13 @@ export function registerRegistrationVerification(app, deps) {
54
63
  }
55
64
  const now = deps.clock.now();
56
65
  const ceremony = deps.repository.readCeremony(passkeyCeremonyId(token), now.toISOString());
57
- if (!ceremony || ceremony.purpose !== 'registration')
66
+ if (!ceremony || ceremony.purpose !== 'registration') {
67
+ deps.reportVerificationFailure?.('CEREMONY_NOT_AVAILABLE');
58
68
  return reply
59
69
  .code(400)
60
70
  .type('application/problem+json')
61
71
  .send(problem('REGISTRATION_NOT_AVAILABLE', 400, 'Registration could not be completed.'));
72
+ }
62
73
  try {
63
74
  const verified = await deps.webauthn.verifyRegistration({
64
75
  response: parsed.data.response,
@@ -67,7 +78,7 @@ export function registerRegistrationVerification(app, deps) {
67
78
  rpId: ceremony.rpId,
68
79
  });
69
80
  if (!verified.userVerified)
70
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
81
+ throw new PasskeyVerificationError('USER_VERIFICATION_REQUIRED');
71
82
  const device = {
72
83
  id: deps.identifiers.deviceId(),
73
84
  credentialId: verified.credentialId,
@@ -101,11 +112,15 @@ export function registerRegistrationVerification(app, deps) {
101
112
  return reply.code(201).send(successSchema.parse({ status: 'authenticated' }));
102
113
  }
103
114
  catch (error) {
104
- if (error instanceof PasskeyVerificationError || error instanceof AuthorizationDomainError)
115
+ if (error instanceof PasskeyVerificationError || error instanceof AuthorizationDomainError) {
116
+ deps.reportVerificationFailure?.(error instanceof PasskeyVerificationError
117
+ ? `WEBAUTHN_${error.reason}`
118
+ : 'AUTHORIZATION_REJECTED');
105
119
  return reply
106
120
  .code(400)
107
121
  .type('application/problem+json')
108
122
  .send(problem('REGISTRATION_VERIFICATION_FAILED', 400, 'Registration could not be completed.'));
123
+ }
109
124
  throw error;
110
125
  }
111
126
  });
@@ -24,7 +24,10 @@ export function registerAuthRoutes(app, deps) {
24
24
  ceremonyAttempts: deps.auth.ceremonyAttempts ?? new ExpiringCeremonyAttemptGate(),
25
25
  };
26
26
  registerRegistrationOptions(app, auth);
27
- registerRegistrationVerification(app, deps.auth);
27
+ registerRegistrationVerification(app, {
28
+ ...deps.auth,
29
+ reportVerificationFailure: (reason) => deps.logger.warn(`Passkey registration verification failed: ${reason}`),
30
+ });
28
31
  registerLoginOptions(app, auth);
29
32
  registerLoginVerification(app, deps.auth);
30
33
  registerAuthStatus(app, deps.auth);
@@ -3,12 +3,18 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- import { toChatItems } from './history-mapper.js';
6
+ import { toChatItems, toChatTurns } from './history-mapper.js';
7
+ /**
8
+ * ChatSnapshot is a lower-bound cut: baseSequence is sampled before the
9
+ * upstream read, therefore every event with a higher sequence remains eligible
10
+ * for ordered replay even when its effect is already visible in the snapshot.
11
+ */
7
12
  export function registerGetHistory(app, deps) {
8
13
  app.get('/api/sessions/:id/history', async (request, reply) => {
9
14
  const session = deps.find(request.params.id);
10
15
  if (!session)
11
16
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
17
+ const baseSequence = deps.currentSequence(session.id);
12
18
  let history;
13
19
  try {
14
20
  history = await deps.read(session);
@@ -33,10 +39,15 @@ export function registerGetHistory(app, deps) {
33
39
  });
34
40
  }
35
41
  const items = toChatItems(history.turns);
36
- return reply.send({
42
+ const snapshot = {
37
43
  items,
44
+ turns: toChatTurns(history.turns),
38
45
  activeTurnId: history.activeTurnId,
39
- currentSequence: deps.currentSequence(session.id),
40
- });
46
+ interactions: deps.interactions?.(session.id) ?? [],
47
+ baseSequence,
48
+ // Temporary compatibility for clients which have not adopted ChatSnapshot.
49
+ currentSequence: baseSequence,
50
+ };
51
+ return reply.send(snapshot);
41
52
  });
42
53
  }
@@ -3,6 +3,15 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ /** Canonical grouping preserves the upstream Codex turn identity across reloads. */
7
+ export function toChatTurns(turns) {
8
+ return turns.map((turn, index) => ({
9
+ id: turn.id ?? `history-turn-${index}`,
10
+ items: turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn))),
11
+ startedAt: turn.startedAt,
12
+ completedAt: turn.completedAt,
13
+ }));
14
+ }
6
15
  export function toChatItems(turns) {
7
16
  return turns.flatMap((turn) => turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn))));
8
17
  }
@@ -54,6 +54,7 @@ export function registerSessionRoutes(app, deps) {
54
54
  find: sessions.find,
55
55
  read: sessions.readHistory,
56
56
  currentSequence: sessions.currentSequence ?? (() => 0),
57
+ interactions: deps.interactions?.snapshot,
57
58
  });
58
59
  if (sessions.startTurn)
59
60
  registerStartTurn(app, {
@@ -61,6 +62,7 @@ export function registerSessionRoutes(app, deps) {
61
62
  start: sessions.startTurn,
62
63
  save: sessions.save,
63
64
  onStarted: sessions.onTurnStarted,
65
+ idempotency: sessions.idempotency,
64
66
  });
65
67
  if (sessions.models)
66
68
  registerSelectModel(app, {
@@ -108,8 +110,10 @@ export function registerSessionRoutes(app, deps) {
108
110
  if (sessions.replyInteraction && deps.interactions)
109
111
  registerRespondInteraction(app, {
110
112
  exists: (id) => sessions.find(id) !== null,
111
- resolve: (sessionId, requestId, resolvedAt) => deps.interactions.resolve(sessionId, requestId, resolvedAt),
113
+ resolve: (sessionId, requestId, resolvedAt, outcome) => deps.interactions.resolve(sessionId, requestId, resolvedAt, outcome),
112
114
  validate: deps.interactions.validate,
115
+ pending: deps.interactions.pending,
116
+ alreadyResolved: deps.interactions.alreadyResolved,
113
117
  reply: sessions.replyInteraction,
114
118
  resolved: sessions.interactionResolved,
115
119
  now: sessions.now,
@@ -11,14 +11,30 @@ export function registerRespondInteraction(app, deps) {
11
11
  const value = request.body;
12
12
  if (typeof value !== 'object' || value === null || Array.isArray(value))
13
13
  return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
14
+ const recorded = deps.alreadyResolved?.(id, requestId);
15
+ if (recorded)
16
+ return reply.code(202).send({ accepted: true, ...recorded });
17
+ if (deps.pending && !deps.pending(id, requestId))
18
+ return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
14
19
  if (deps.validate && !deps.validate(id, requestId, value))
15
20
  return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
16
21
  if (!deps.reply(id, requestId, value))
17
22
  return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
18
23
  const resolvedAt = deps.now();
19
- if (!deps.resolve(id, requestId, resolvedAt))
24
+ const outcome = safeOutcome(value);
25
+ if (!deps.resolve(id, requestId, resolvedAt, outcome))
20
26
  return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
21
- deps.resolved?.(id, requestId, resolvedAt);
22
- return reply.code(202).send({ accepted: true });
27
+ deps.resolved?.(id, requestId, resolvedAt, outcome);
28
+ return reply.code(202).send({ accepted: true, resolvedAt, outcome });
23
29
  });
24
30
  }
31
+ function safeOutcome(value) {
32
+ if (typeof value !== 'object' || value === null)
33
+ return 'answered';
34
+ const decision = value.decision;
35
+ if (decision === 'approved' || decision === 'accept')
36
+ return 'approved';
37
+ if (decision === 'denied' || decision === 'decline')
38
+ return 'denied';
39
+ return 'answered';
40
+ }
@@ -24,25 +24,60 @@ export function registerSessionEvents(app, deps) {
24
24
  }
25
25
  server.handleUpgrade(request, socket, head, (connection) => {
26
26
  const sessionId = match[1];
27
+ let lastSentSequence = after;
27
28
  const send = (event) => {
28
- if (isSlowClient(connection.bufferedAmount))
29
+ // A subscription intentionally overlaps the durable replay. The sequence
30
+ // is the authoritative identity of an event, so a bounded high-water mark
31
+ // de-duplicates both replay overlap and stale live callbacks.
32
+ if (event.sessionId !== sessionId ||
33
+ !Number.isSafeInteger(event.sequence) ||
34
+ event.sequence < 1)
35
+ return;
36
+ if (event.sequence <= lastSentSequence)
37
+ return;
38
+ lastSentSequence = event.sequence;
39
+ if (isSlowClient(deps.bufferedAmount?.(connection) ?? connection.bufferedAmount))
29
40
  return connection.close(1013, 'slow client');
30
41
  connection.send(JSON.stringify({ type: 'relay.event', event }));
31
42
  };
32
- const replay = replayOrResync(deps.since(sessionId, after), after);
33
- if (replay.kind === 'resync') {
34
- const currentSequence = deps.since(sessionId, 0).at(-1)?.sequence ?? after;
35
- connection.send(JSON.stringify({ type: 'relay.resyncRequired', currentSequence }));
43
+ let unsubscribe;
44
+ let removeHeartbeat;
45
+ let replaying = true;
46
+ const liveDuringReplay = [];
47
+ const cleanup = () => {
48
+ removeHeartbeat?.();
49
+ removeHeartbeat = undefined;
50
+ unsubscribe?.();
51
+ unsubscribe = undefined;
52
+ };
53
+ try {
54
+ // Subscribe before reading the journal: every event after this point is
55
+ // either in replay or delivered live, and the sequence de-duplicates it.
56
+ unsubscribe = deps.subscribe(sessionId, (event) => {
57
+ if (replaying)
58
+ liveDuringReplay.push(event);
59
+ else
60
+ send(event);
61
+ });
62
+ const replay = replayOrResync(deps.since(sessionId, after), after);
63
+ if (replay.kind === 'resync') {
64
+ const currentSequence = deps.since(sessionId, 0).at(-1)?.sequence ?? after;
65
+ connection.send(JSON.stringify({ type: 'relay.resyncRequired', currentSequence }));
66
+ cleanup();
67
+ connection.close();
68
+ return;
69
+ }
70
+ replay.events.forEach(send);
71
+ replaying = false;
72
+ liveDuringReplay.sort((left, right) => left.sequence - right.sequence).forEach(send);
73
+ }
74
+ catch {
75
+ cleanup();
36
76
  connection.close();
37
77
  return;
38
78
  }
39
- replay.events.forEach(send);
40
- const unsubscribe = deps.subscribe(sessionId, send);
41
- const removeHeartbeat = installWebSocketHeartbeat(connection, deps.heartbeatIntervalMs ?? 25_000, () => deps.authorized?.(request.headers.cookie) ?? true);
42
- connection.on('close', () => {
43
- removeHeartbeat();
44
- unsubscribe();
45
- });
79
+ removeHeartbeat = installWebSocketHeartbeat(connection, deps.heartbeatIntervalMs ?? 25_000, () => deps.authorized?.(request.headers.cookie) ?? true);
80
+ connection.on('close', cleanup);
46
81
  });
47
82
  });
48
83
  app.addHook('onClose', async () => server.close());
@@ -3,7 +3,11 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { createHash } from 'node:crypto';
6
7
  export function registerStartTurn(app, deps) {
8
+ const inflight = new Map();
9
+ // Durable idempotency results are session-scoped and replay for as long as the
10
+ // store retains them; a reused key with a different prompt is rejected.
7
11
  // Text is capped at 100,000 characters; leave room for JSON encoding only.
8
12
  app.post('/api/sessions/:id/turns', { bodyLimit: 128 * 1024 }, async (request, reply) => {
9
13
  const session = deps.find(request.params.id);
@@ -14,9 +18,43 @@ export function registerStartTurn(app, deps) {
14
18
  return reply.code(400).send({ code: 'TURN_INPUT_TOO_LONG' });
15
19
  if (!text || session.state !== 'ready')
16
20
  return reply.code(409).send({ code: 'SESSION_NOT_READY' });
21
+ const key = request.headers['idempotency-key'];
22
+ if (typeof key === 'string' && key && deps.idempotency) {
23
+ const scope = `start-turn:${session.id}`;
24
+ const fingerprint = createHash('sha256').update(text).digest('hex');
25
+ const cached = deps.idempotency.get(scope, key);
26
+ if (cached)
27
+ return replay(cached, fingerprint, reply);
28
+ const inflightKey = `${scope}:${key}`;
29
+ const operation = inflight.get(inflightKey) ??
30
+ (async () => {
31
+ const started = await deps.start(session, text);
32
+ deps.save(started);
33
+ deps.onStarted?.(started);
34
+ const result = {
35
+ statusCode: 202,
36
+ body: JSON.stringify({ fingerprint, response: started }),
37
+ };
38
+ deps.idempotency.put(scope, key, result.statusCode, result.body);
39
+ return result;
40
+ })();
41
+ inflight.set(inflightKey, operation);
42
+ try {
43
+ return replay(await operation, fingerprint, reply);
44
+ }
45
+ finally {
46
+ inflight.delete(inflightKey);
47
+ }
48
+ }
17
49
  const started = await deps.start(session, text);
18
50
  deps.save(started);
19
51
  deps.onStarted?.(started);
20
52
  return reply.code(202).send(started);
21
53
  });
22
54
  }
55
+ function replay(result, fingerprint, reply) {
56
+ const cached = JSON.parse(result.body);
57
+ if (cached.fingerprint !== fingerprint)
58
+ return reply.code(409).send({ code: 'IDEMPOTENCY_KEY_REUSED' });
59
+ return reply.code(result.statusCode).send(cached.response);
60
+ }
@@ -7,6 +7,32 @@ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthe
7
7
  import { PasskeyVerificationError } from '../../features/auth/application/ports.js';
8
8
  import { webAuthnCredentialId } from '../../features/auth/domain/identifiers.js';
9
9
  const transports = new Set(['ble', 'hybrid', 'internal', 'nfc', 'usb']);
10
+ function verificationFailure(error) {
11
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
12
+ if (message.includes('challenge'))
13
+ return 'CHALLENGE_MISMATCH';
14
+ if (message.includes('origin'))
15
+ return 'ORIGIN_MISMATCH';
16
+ if (message.includes('rp id') || message.includes('rpid'))
17
+ return 'RP_ID_MISMATCH';
18
+ if (message.includes('user presence'))
19
+ return 'USER_PRESENCE_REQUIRED';
20
+ if (message.includes('user verification'))
21
+ return 'USER_VERIFICATION_REQUIRED';
22
+ if (message.includes('credential id') || message.includes('base64'))
23
+ return 'CREDENTIAL_ENCODING_INVALID';
24
+ if (message.includes('alg') || message.includes('cose'))
25
+ return 'ALGORITHM_UNSUPPORTED';
26
+ if (message.includes('public key') ||
27
+ message.includes('aaguid') ||
28
+ message.includes('authenticator data'))
29
+ return 'CREDENTIAL_MATERIAL_MISSING';
30
+ if (message.includes('attestation'))
31
+ return 'ATTESTATION_UNSUPPORTED';
32
+ if (message.includes('signature'))
33
+ return 'SIGNATURE_INVALID';
34
+ return 'VERIFIER_REJECTED';
35
+ }
10
36
  /** Translates SimpleWebAuthn DTOs at the platform boundary. */
11
37
  export class SimpleWebAuthnAdapter {
12
38
  async registrationOptions(input) {
@@ -47,11 +73,11 @@ export class SimpleWebAuthnAdapter {
47
73
  supportedAlgorithmIDs: [-7, -257],
48
74
  });
49
75
  }
50
- catch {
51
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
76
+ catch (error) {
77
+ throw new PasskeyVerificationError(verificationFailure(error));
52
78
  }
53
79
  if (!result.verified || !result.registrationInfo?.userVerified)
54
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
80
+ throw new PasskeyVerificationError('VERIFIER_REJECTED');
55
81
  const credential = result.registrationInfo.credential;
56
82
  return {
57
83
  credentialId: webAuthnCredentialId(credential.id),
@@ -81,11 +107,11 @@ export class SimpleWebAuthnAdapter {
81
107
  },
82
108
  });
83
109
  }
84
- catch {
85
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
110
+ catch (error) {
111
+ throw new PasskeyVerificationError(verificationFailure(error));
86
112
  }
87
113
  if (!result.verified || !result.authenticationInfo?.userVerified)
88
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
114
+ throw new PasskeyVerificationError('VERIFIER_REJECTED');
89
115
  return {
90
116
  credentialId: webAuthnCredentialId(value.response.id),
91
117
  counter: result.authenticationInfo.newCounter,
@@ -180,6 +180,7 @@ export class CodexSessionRuntime {
180
180
  const activeTurn = rawTurns.find((turn) => turn.status === 'inProgress' && typeof turn.id === 'string');
181
181
  return {
182
182
  turns: rawTurns.map((turn) => ({
183
+ ...(typeof turn.id === 'string' ? { id: turn.id } : {}),
183
184
  items: turn.items ?? [],
184
185
  startedAt: typeof turn.startedAt === 'number' ? turn.startedAt : null,
185
186
  completedAt: typeof turn.completedAt === 'number' ? turn.completedAt : null,
@@ -3,7 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, resolved_at TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
6
+ const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
7
7
  export function migrate(database) {
8
8
  database.exec(schema);
9
9
  const columns = database.prepare('PRAGMA table_info(relay_sessions)').all();
@@ -15,4 +15,13 @@ export function migrate(database) {
15
15
  database.exec('ALTER TABLE relay_sessions ADD COLUMN model TEXT');
16
16
  if (!columns.some((column) => column.name === 'branch'))
17
17
  database.exec('ALTER TABLE relay_sessions ADD COLUMN branch TEXT');
18
+ const interactionColumns = database
19
+ .prepare('PRAGMA table_info(pending_interactions)')
20
+ .all();
21
+ if (!interactionColumns.some((column) => column.name === 'turn_id'))
22
+ database.exec('ALTER TABLE pending_interactions ADD COLUMN turn_id TEXT');
23
+ if (!interactionColumns.some((column) => column.name === 'requested_at'))
24
+ database.exec('ALTER TABLE pending_interactions ADD COLUMN requested_at TEXT');
25
+ if (!interactionColumns.some((column) => column.name === 'outcome'))
26
+ database.exec('ALTER TABLE pending_interactions ADD COLUMN outcome TEXT');
18
27
  }