gestalt-mobile 0.9.0 → 0.11.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 (40) hide show
  1. package/README.md +83 -14
  2. package/dist/client/assets/{index-DrULMr7l.css → index-Bkm5GDCs.css} +1 -1
  3. package/dist/client/assets/index-DviggEIS.js +19 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +52 -3
  6. package/dist/server/server/cli.js +6 -0
  7. package/dist/server/server/composition.js +212 -150
  8. package/dist/server/server/config.js +64 -2
  9. package/dist/server/server/features/auth/application/ports.js +8 -0
  10. package/dist/server/server/features/auth/devices/application.js +26 -0
  11. package/dist/server/server/features/auth/devices/list/endpoint.js +12 -0
  12. package/dist/server/server/features/auth/devices/rename/endpoint.js +37 -0
  13. package/dist/server/server/features/auth/devices/revoke/endpoint.js +48 -0
  14. package/dist/server/server/features/auth/domain/authorization.js +101 -0
  15. package/dist/server/server/features/auth/domain/device-nickname.js +13 -0
  16. package/dist/server/server/features/auth/domain/errors.js +7 -0
  17. package/dist/server/server/features/auth/domain/identifiers.js +45 -0
  18. package/dist/server/server/features/auth/enrollment-tickets/cancel/endpoint.js +14 -0
  19. package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +17 -0
  20. package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +13 -0
  21. package/dist/server/server/features/auth/login/options/endpoint.js +44 -0
  22. package/dist/server/server/features/auth/login/verify/endpoint.js +88 -0
  23. package/dist/server/server/features/auth/logout/endpoint.js +16 -0
  24. package/dist/server/server/features/auth/register/options/endpoint.js +34 -0
  25. package/dist/server/server/features/auth/register/verify/endpoint.js +56 -0
  26. package/dist/server/server/features/auth/status/endpoint.js +24 -0
  27. package/dist/server/server/features/sessions/get-history/endpoint.js +23 -1
  28. package/dist/server/server/features/sessions/restore-session/endpoint.js +24 -4
  29. package/dist/server/server/features/sessions/restore-session/use-case.js +20 -0
  30. package/dist/server/server/features/sessions/session-events/endpoint.js +7 -2
  31. package/dist/server/server/platform/auth/simple-webauthn-adapter.js +75 -0
  32. package/dist/server/server/platform/auth/sqlite-authorization-store.js +478 -0
  33. package/dist/server/server/platform/codex/json-rpc-client.js +36 -2
  34. package/dist/server/server/platform/codex/session-runtime.js +64 -22
  35. package/dist/server/server/platform/events/websocket-heartbeat.js +5 -1
  36. package/dist/server/server/platform/http/authorization-boundary.js +60 -0
  37. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +30 -11
  38. package/dist/server/shared/contracts/plan-signal.js +17 -0
  39. package/package.json +8 -2
  40. package/dist/client/assets/index-D7-LoAAR.js +0 -12
@@ -0,0 +1,26 @@
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
+ import { deviceNickname } from '../domain/device-nickname.js';
7
+ /** Maps a credential to deliberately non-WebAuthn management metadata. */
8
+ export function listAuthorizedDeviceMetadata(repository, currentDeviceId) {
9
+ return repository.listAuthorizedDevices().map((device) => ({
10
+ id: device.id,
11
+ nickname: device.nickname,
12
+ createdAt: device.createdAt,
13
+ ...(device.lastUsedAt === undefined ? {} : { lastUsedAt: device.lastUsedAt }),
14
+ current: device.id === currentDeviceId,
15
+ }));
16
+ }
17
+ export function renameAuthorizedDevice(repository, id, nickname) {
18
+ const validated = deviceNickname(nickname);
19
+ const current = repository.findDevice(id);
20
+ if (!current)
21
+ return 'notFound';
22
+ return repository.renameDevice(id, current.version ?? 0, validated);
23
+ }
24
+ export function revokeAuthorizedDevice(repository, id, revokedAt) {
25
+ return repository.revokeDevice(id, revokedAt);
26
+ }
@@ -0,0 +1,12 @@
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
+ import { authorizationSessionDevice } from '../../../../platform/http/authorization-boundary.js';
7
+ import { listAuthorizedDeviceMetadata } from '../application.js';
8
+ export function registerListAuthorizedDevices(app, deps) {
9
+ app.get('/api/auth/devices', async (request) => ({
10
+ devices: listAuthorizedDeviceMetadata(deps.repository, authorizationSessionDevice(request.headers.cookie, deps)),
11
+ }));
12
+ }
@@ -0,0 +1,37 @@
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
+ import { z } from 'zod';
7
+ import { AuthorizationDomainError } from '../../domain/errors.js';
8
+ import { authorizedDeviceId } from '../../domain/identifiers.js';
9
+ import { problem } from '../../../../platform/http/problem.js';
10
+ import { renameAuthorizedDevice } from '../application.js';
11
+ const parameters = z.object({ deviceId: z.string().min(1).max(200) }).strict();
12
+ const requestBody = z.object({ nickname: z.string() }).strict();
13
+ export function registerRenameAuthorizedDevice(app, deps) {
14
+ app.patch('/api/auth/devices/:deviceId', async (request, reply) => {
15
+ const params = parameters.safeParse(request.params);
16
+ const body = requestBody.safeParse(request.body);
17
+ if (!params.success || !body.success)
18
+ return unavailable(reply, 400, 'INVALID_DEVICE_REQUEST');
19
+ try {
20
+ const outcome = renameAuthorizedDevice(deps.repository, authorizedDeviceId(params.data.deviceId), body.data.nickname);
21
+ if (outcome === 'renamed')
22
+ return reply.code(204).send();
23
+ return unavailable(reply, outcome === 'stale' ? 409 : 404, 'DEVICE_NOT_AVAILABLE');
24
+ }
25
+ catch (error) {
26
+ if (error instanceof AuthorizationDomainError)
27
+ return unavailable(reply, 400, 'INVALID_DEVICE_REQUEST');
28
+ throw error;
29
+ }
30
+ });
31
+ }
32
+ function unavailable(reply, status, code) {
33
+ return reply
34
+ .code(status)
35
+ .type('application/problem+json')
36
+ .send(problem(code, status, 'The requested device is not available.'));
37
+ }
@@ -0,0 +1,48 @@
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
+ import { z } from 'zod';
7
+ import { AuthorizationDomainError } from '../../domain/errors.js';
8
+ import { authorizedDeviceId } from '../../domain/identifiers.js';
9
+ import { problem } from '../../../../platform/http/problem.js';
10
+ import { authorizationSessionDevice } from '../../../../platform/http/authorization-boundary.js';
11
+ import { revokeAuthorizedDevice } from '../application.js';
12
+ const parameters = z.object({ deviceId: z.string().min(1).max(200) }).strict();
13
+ export function registerRevokeAuthorizedDevice(app, deps) {
14
+ app.delete('/api/auth/devices/:deviceId', async (request, reply) => {
15
+ const params = parameters.safeParse(request.params);
16
+ if (!params.success)
17
+ return unavailable(reply, 400, 'INVALID_DEVICE_REQUEST');
18
+ try {
19
+ const id = authorizedDeviceId(params.data.deviceId);
20
+ const current = authorizationSessionDevice(request.headers.cookie, deps);
21
+ const outcome = revokeAuthorizedDevice(deps.repository, id, deps.clock.now().toISOString());
22
+ if (outcome === 'finalDevice')
23
+ return reply
24
+ .code(409)
25
+ .type('application/problem+json')
26
+ .send(problem('LAST_DEVICE_REQUIRED', 409, 'At least one authorized device is required.'));
27
+ if (outcome === 'notFound')
28
+ return unavailable(reply, 404, 'DEVICE_NOT_AVAILABLE');
29
+ if (current === id)
30
+ reply.clearCookie('gestalt_mobile_session', {
31
+ path: '/', httpOnly: true, sameSite: 'strict',
32
+ secure: deps.relyingParty.publicOrigin.startsWith('https://'),
33
+ });
34
+ return reply.code(204).send();
35
+ }
36
+ catch (error) {
37
+ if (error instanceof AuthorizationDomainError)
38
+ return unavailable(reply, 400, 'INVALID_DEVICE_REQUEST');
39
+ throw error;
40
+ }
41
+ });
42
+ }
43
+ function unavailable(reply, status, code) {
44
+ return reply
45
+ .code(status)
46
+ .type('application/problem+json')
47
+ .send(problem(code, status, 'The requested device is not available.'));
48
+ }
@@ -0,0 +1,101 @@
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
+ import { AuthorizationDomainError } from './errors.js';
7
+ export class LocalOwnerAuthorization {
8
+ ownerValue;
9
+ devices;
10
+ constructor(ownerValue, devices) {
11
+ this.ownerValue = ownerValue;
12
+ this.devices = devices;
13
+ }
14
+ static bootstrap(owner, firstDevice) {
15
+ const device = copyDevice(firstDevice);
16
+ assertUniqueDeviceIds([device]);
17
+ return withOutcome(new LocalOwnerAuthorization(copyOwner(owner), [device]), {
18
+ kind: 'firstDeviceAuthorized',
19
+ device: copyDevice(device),
20
+ });
21
+ }
22
+ static rehydrate(owner, devices) {
23
+ if (devices.length < 1)
24
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_REQUIRED');
25
+ assertUniqueDeviceIds(devices);
26
+ assertUniqueCredentials(devices);
27
+ return new LocalOwnerAuthorization(copyOwner(owner), devices.map(copyDevice));
28
+ }
29
+ get owner() {
30
+ return copyOwner(this.ownerValue);
31
+ }
32
+ get authorizedDevices() {
33
+ return this.devices.map(copyDevice);
34
+ }
35
+ authorizeDevice(device) {
36
+ assertUniqueDeviceIds([...this.devices, device]);
37
+ assertUniqueCredentials([...this.devices, device]);
38
+ return withOutcome(new LocalOwnerAuthorization(this.ownerValue, [...this.devices, copyDevice(device)]), { kind: 'deviceAuthorized', device: copyDevice(device) });
39
+ }
40
+ renameDevice(id, nickname) {
41
+ const device = this.requireDevice(id);
42
+ const renamed = { ...device, nickname };
43
+ return withOutcome(new LocalOwnerAuthorization(this.ownerValue, this.devices.map((entry) => (entry.id === id ? renamed : entry))), { kind: 'deviceRenamed', device: copyDevice(renamed) });
44
+ }
45
+ recordAssertion(id, counter, usedAt) {
46
+ const device = this.requireDevice(id);
47
+ if (!Number.isInteger(counter) || counter < 0)
48
+ throw new AuthorizationDomainError('CREDENTIAL_COUNTER_INVALID');
49
+ const allowsZeroCounter = device.counter === 0 && counter === 0 && device.deviceType === 'multiDevice';
50
+ if (!allowsZeroCounter && counter <= device.counter)
51
+ throw new AuthorizationDomainError('CREDENTIAL_COUNTER_NOT_MONOTONIC');
52
+ const updated = { ...device, counter, lastUsedAt: usedAt };
53
+ return new LocalOwnerAuthorization(this.ownerValue, this.devices.map((entry) => (entry.id === id ? updated : entry)));
54
+ }
55
+ revokeDevice(id) {
56
+ this.requireDevice(id);
57
+ if (this.devices.length === 1)
58
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_REQUIRED');
59
+ return withOutcome(new LocalOwnerAuthorization(this.ownerValue, this.devices.filter((device) => device.id !== id)), { kind: 'deviceRevoked', deviceId: id });
60
+ }
61
+ requireDevice(id) {
62
+ const device = this.devices.find((entry) => entry.id === id);
63
+ if (!device)
64
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_NOT_FOUND');
65
+ return device;
66
+ }
67
+ }
68
+ function withOutcome(value, outcome) {
69
+ return Object.freeze(Object.assign(value, { outcome }));
70
+ }
71
+ function assertUniqueCredentials(devices) {
72
+ if (new Set(devices.map((device) => device.credentialId)).size !== devices.length)
73
+ throw new AuthorizationDomainError('CREDENTIAL_ALREADY_AUTHORIZED');
74
+ }
75
+ function assertUniqueDeviceIds(devices) {
76
+ if (new Set(devices.map((device) => device.id)).size !== devices.length)
77
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_ID_ALREADY_AUTHORIZED');
78
+ }
79
+ function copyOwner(owner) {
80
+ if (owner.userHandle.length < 1)
81
+ throw new AuthorizationDomainError('LOCAL_OWNER_HANDLE_INVALID');
82
+ return Object.freeze({ ...owner, userHandle: new Uint8Array(owner.userHandle) });
83
+ }
84
+ function copyDevice(device) {
85
+ if (!Number.isInteger(device.counter) || device.counter < 0)
86
+ throw new AuthorizationDomainError('CREDENTIAL_COUNTER_INVALID');
87
+ if (device.version !== undefined && (!Number.isInteger(device.version) || device.version < 0))
88
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_VERSION_INVALID');
89
+ if (device.publicKey.length < 1)
90
+ throw new AuthorizationDomainError('CREDENTIAL_PUBLIC_KEY_INVALID');
91
+ if (Number.isNaN(Date.parse(device.createdAt)))
92
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_TIMESTAMP_INVALID');
93
+ if (device.lastUsedAt && Number.isNaN(Date.parse(device.lastUsedAt)))
94
+ throw new AuthorizationDomainError('AUTHORIZED_DEVICE_TIMESTAMP_INVALID');
95
+ return Object.freeze({
96
+ ...device,
97
+ version: device.version ?? 0,
98
+ publicKey: new Uint8Array(device.publicKey),
99
+ transports: Object.freeze([...device.transports]),
100
+ });
101
+ }
@@ -0,0 +1,13 @@
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
+ import { AuthorizationDomainError } from './errors.js';
7
+ export function deviceNickname(value) {
8
+ const normalized = Array.from(value.trim()).join('');
9
+ const length = Array.from(normalized).length;
10
+ if (length < 1 || length > 64)
11
+ throw new AuthorizationDomainError('DEVICE_NICKNAME_INVALID');
12
+ return normalized;
13
+ }
@@ -0,0 +1,7 @@
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
+ export class AuthorizationDomainError extends Error {
7
+ }
@@ -0,0 +1,45 @@
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
+ import { AuthorizationDomainError } from './errors.js';
7
+ export function localOwnerId(value) {
8
+ return opaqueId(value, 'LOCAL_OWNER_ID_INVALID');
9
+ }
10
+ export function authorizedDeviceId(value) {
11
+ return opaqueId(value, 'AUTHORIZED_DEVICE_ID_INVALID');
12
+ }
13
+ export function webAuthnCredentialId(value) {
14
+ return opaqueId(value, 'CREDENTIAL_ID_INVALID');
15
+ }
16
+ export function passkeyCeremonyId(value) {
17
+ return opaqueId(value, 'PASSKEY_CEREMONY_ID_INVALID');
18
+ }
19
+ export function enrollmentTicketId(value) {
20
+ const parsed = parseEnrollmentTicketId(value);
21
+ if (parsed === null)
22
+ throw new AuthorizationDomainError('ENROLLMENT_TICKET_ID_INVALID');
23
+ return parsed;
24
+ }
25
+ export function authorizationSessionId(value) {
26
+ const parsed = parseAuthorizationSessionId(value);
27
+ if (parsed === null)
28
+ throw new AuthorizationDomainError('AUTHORIZATION_SESSION_ID_INVALID');
29
+ return parsed;
30
+ }
31
+ export function parseEnrollmentTicketId(value) {
32
+ return opaqueIdOrNull(value);
33
+ }
34
+ export function parseAuthorizationSessionId(value) {
35
+ return opaqueIdOrNull(value);
36
+ }
37
+ function opaqueId(value, code) {
38
+ const parsed = opaqueIdOrNull(value);
39
+ if (parsed === null)
40
+ throw new AuthorizationDomainError(code);
41
+ return parsed;
42
+ }
43
+ function opaqueIdOrNull(value) {
44
+ return typeof value === 'string' && value.length > 0 && value.trim() === value ? value : null;
45
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { parseAuthorizationSessionId } from '../../domain/identifiers.js';
3
+ const responseSchema = z.object({ status: z.enum(['none', 'used', 'expired']) }).strict();
4
+ export function registerCancelEnrollmentTicket(app, deps) {
5
+ app.delete('/api/auth/enrollment-tickets/current', async (request, reply) => {
6
+ const rawSession = request.cookies.gestalt_mobile_session;
7
+ const session = parseAuthorizationSessionId(rawSession);
8
+ const now = deps.clock.now().toISOString();
9
+ if (session === null || deps.repository.sessionDevice(session, now) === null)
10
+ return reply.code(401).send();
11
+ deps.repository.cancelEnrollmentTicket(session, now);
12
+ return reply.send(responseSchema.parse({ status: deps.repository.enrollmentTicketStatus(session, now) }));
13
+ });
14
+ }
@@ -0,0 +1,17 @@
1
+ import { enrollmentTicketId, parseAuthorizationSessionId } from '../domain/identifiers.js';
2
+ const lifetimeMs = 10 * 60 * 1000;
3
+ export function registerCreateEnrollmentTicket(app, deps) {
4
+ app.post('/api/auth/enrollment-tickets', async (request, reply) => {
5
+ const rawSession = request.cookies.gestalt_mobile_session;
6
+ const session = parseAuthorizationSessionId(rawSession);
7
+ if (session === null || deps.repository.sessionDevice(session, deps.clock.now().toISOString()) === null)
8
+ return reply.code(401).send();
9
+ const entropy = deps.random.bytes(32);
10
+ if (entropy.length < 32)
11
+ throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
12
+ const ticket = enrollmentTicketId(Buffer.from(entropy).toString('base64url'));
13
+ const expiresAt = new Date(deps.clock.now().getTime() + lifetimeMs).toISOString();
14
+ deps.repository.issueEnrollmentTicket(ticket, session, expiresAt);
15
+ return reply.code(201).send({ ticket, url: `${deps.relyingParty.publicOrigin.replace(/\/$/, '')}/#enroll=${ticket}`, expiresAt });
16
+ });
17
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import { parseAuthorizationSessionId } from '../../domain/identifiers.js';
3
+ const responseSchema = z.object({ status: z.enum(['none', 'pending', 'used', 'expired']) }).strict();
4
+ export function registerEnrollmentTicketStatus(app, deps) {
5
+ app.get('/api/auth/enrollment-tickets/current', async (request, reply) => {
6
+ const rawSession = request.cookies.gestalt_mobile_session;
7
+ const session = parseAuthorizationSessionId(rawSession);
8
+ const now = deps.clock.now().toISOString();
9
+ if (session === null || deps.repository.sessionDevice(session, now) === null)
10
+ return reply.code(401).send();
11
+ return reply.send(responseSchema.parse({ status: deps.repository.enrollmentTicketStatus(session, now) }));
12
+ });
13
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { problem } from '../../../../platform/http/problem.js';
3
+ import { passkeyCeremonyId } from '../../domain/identifiers.js';
4
+ const lifetimeMs = 10 * 60 * 1000;
5
+ const responseSchema = z
6
+ .object({ options: z.object({ challenge: z.string().min(1) }).passthrough() })
7
+ .strict();
8
+ export function registerLoginOptions(app, deps) {
9
+ app.post('/api/auth/login/options', async (_request, reply) => {
10
+ if (deps.repository.listAuthorizedDevices().length === 0)
11
+ return reply
12
+ .code(400)
13
+ .type('application/problem+json')
14
+ .send(problem('AUTHENTICATION_FAILED', 400, 'Authentication could not be completed.'));
15
+ const correlation = deps.random.bytes(32);
16
+ const challenge = deps.random.bytes(32);
17
+ if (correlation.length !== 32 || challenge.length !== 32)
18
+ throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
19
+ const now = deps.clock.now();
20
+ const options = responseSchema.parse({
21
+ options: await deps.webauthn.authenticationOptions({
22
+ challenge,
23
+ rpId: deps.relyingParty.rpId,
24
+ userVerification: 'required',
25
+ }),
26
+ });
27
+ const token = passkeyCeremonyId(Buffer.from(correlation).toString('base64url'));
28
+ deps.repository.saveCeremony(token, {
29
+ purpose: 'authentication',
30
+ challenge,
31
+ expectedOrigin: deps.relyingParty.publicOrigin,
32
+ rpId: deps.relyingParty.rpId,
33
+ expiresAt: new Date(now.getTime() + lifetimeMs).toISOString(),
34
+ });
35
+ reply.setCookie('gestalt_mobile_login', token, {
36
+ httpOnly: true,
37
+ sameSite: 'strict',
38
+ path: '/',
39
+ secure: deps.relyingParty.publicOrigin.startsWith('https://'),
40
+ maxAge: 600,
41
+ });
42
+ return reply.send(options);
43
+ });
44
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { problem } from '../../../../platform/http/problem.js';
3
+ import { PasskeyVerificationError, } from '../../application/ports.js';
4
+ import { passkeyCeremonyId, webAuthnCredentialId } from '../../domain/identifiers.js';
5
+ const responseSchema = z
6
+ .object({
7
+ id: z.string().min(1),
8
+ rawId: z.string().min(1),
9
+ type: z.literal('public-key'),
10
+ response: z
11
+ .object({
12
+ clientDataJSON: z.string().min(1),
13
+ authenticatorData: z.string().min(1),
14
+ signature: z.string().min(1),
15
+ userHandle: z.string().nullable().optional(),
16
+ })
17
+ .strict(),
18
+ authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(),
19
+ clientExtensionResults: z.record(z.string(), z.unknown()),
20
+ })
21
+ .strict();
22
+ const requestSchema = z.object({ response: responseSchema }).strict();
23
+ const lifetimeMs = 30 * 24 * 60 * 60 * 1000;
24
+ export function registerLoginVerification(app, deps) {
25
+ app.post('/api/auth/login/verify', async (request, reply) => {
26
+ const parsed = requestSchema.safeParse(request.body);
27
+ const token = request.cookies.gestalt_mobile_login;
28
+ const fail = () => reply
29
+ .code(400)
30
+ .type('application/problem+json')
31
+ .send(problem('AUTHENTICATION_FAILED', 400, 'Authentication could not be completed.'));
32
+ if (!parsed.success || typeof token !== 'string')
33
+ return fail();
34
+ const now = deps.clock.now();
35
+ const ceremony = deps.repository.readCeremony(passkeyCeremonyId(token), now.toISOString());
36
+ if (!ceremony || ceremony.purpose !== 'authentication')
37
+ return fail();
38
+ const device = deps.repository.findDeviceByCredentialId(webAuthnCredentialId(parsed.data.response.id));
39
+ if (!device)
40
+ return fail();
41
+ try {
42
+ const verified = await deps.webauthn.verifyAuthentication({
43
+ response: parsed.data.response,
44
+ challenge: ceremony.challenge,
45
+ expectedOrigin: ceremony.expectedOrigin,
46
+ rpId: ceremony.rpId,
47
+ credential: {
48
+ id: device.credentialId,
49
+ publicKey: device.publicKey,
50
+ counter: device.counter,
51
+ transports: device.transports,
52
+ },
53
+ });
54
+ if (!verified.userVerified || verified.credentialId !== device.credentialId)
55
+ throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
56
+ const session = deps.identifiers.sessionId();
57
+ if (Buffer.from(session, 'base64url').length !== 32)
58
+ throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
59
+ const authenticated = deps.repository.completeAuthentication({
60
+ ceremony: passkeyCeremonyId(token),
61
+ now: now.toISOString(),
62
+ device,
63
+ nextCounter: verified.counter,
64
+ session: {
65
+ id: session,
66
+ deviceId: device.id,
67
+ expiresAt: new Date(now.getTime() + lifetimeMs).toISOString(),
68
+ },
69
+ });
70
+ if (!authenticated)
71
+ return fail();
72
+ reply.setCookie('gestalt_mobile_session', session, {
73
+ httpOnly: true,
74
+ sameSite: 'strict',
75
+ path: '/',
76
+ secure: deps.relyingParty.publicOrigin.startsWith('https://'),
77
+ maxAge: 2592000,
78
+ });
79
+ reply.clearCookie('gestalt_mobile_login', { path: '/' });
80
+ return reply.send({ status: 'authenticated' });
81
+ }
82
+ catch (error) {
83
+ if (error instanceof PasskeyVerificationError)
84
+ return fail();
85
+ throw error;
86
+ }
87
+ });
88
+ }
@@ -0,0 +1,16 @@
1
+ import { parseAuthorizationSessionId } from '../domain/identifiers.js';
2
+ export function registerLogout(app, deps) {
3
+ app.post('/api/auth/logout', async (request, reply) => {
4
+ const token = request.cookies.gestalt_mobile_session;
5
+ const session = parseAuthorizationSessionId(token);
6
+ if (session !== null)
7
+ deps.repository.revokeSession(session, deps.clock.now().toISOString());
8
+ reply.clearCookie('gestalt_mobile_session', {
9
+ path: '/',
10
+ httpOnly: true,
11
+ sameSite: 'strict',
12
+ secure: deps.relyingParty.publicOrigin.startsWith('https://'),
13
+ });
14
+ return reply.code(204).send();
15
+ });
16
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { problem } from '../../../../platform/http/problem.js';
3
+ import { parseEnrollmentTicketId, passkeyCeremonyId } from '../../domain/identifiers.js';
4
+ const requestSchema = z.object({ enrollmentTicket: z.string().min(1).optional() }).strict();
5
+ const optionsResponseSchema = z.object({ options: z.object({ challenge: z.string().min(1) }).passthrough() }).strict();
6
+ const lifetimeMs = 10 * 60 * 1000;
7
+ export function registerRegistrationOptions(app, deps) {
8
+ app.post('/api/auth/register/options', async (request, reply) => {
9
+ const parsed = requestSchema.safeParse(request.body);
10
+ if (!parsed.success)
11
+ return reply.code(400).type('application/problem+json').send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
12
+ const ticket = parsed.data.enrollmentTicket === undefined ? undefined : parseEnrollmentTicketId(parsed.data.enrollmentTicket);
13
+ if (ticket === null)
14
+ return reply.code(400).type('application/problem+json').send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
15
+ const owner = deps.repository.readOwner();
16
+ const devices = deps.repository.listAuthorizedDevices();
17
+ const now = deps.clock.now();
18
+ if (devices.length > 0 && (!ticket || !deps.repository.ticketAvailable(ticket, now.toISOString())))
19
+ return reply.code(403).type('application/problem+json').send(problem('ENROLLMENT_NOT_AUTHORIZED', 403, 'Registration is not authorized.'));
20
+ const ownerHandle = owner ? undefined : deps.random.bytes(32);
21
+ if (ownerHandle && ownerHandle.length !== 32)
22
+ throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
23
+ const initialized = owner ?? deps.repository.initializeOwner(ownerHandle);
24
+ const correlation = deps.random.bytes(32);
25
+ const challenge = deps.random.bytes(32);
26
+ if (correlation.length !== 32 || challenge.length !== 32)
27
+ throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
28
+ const token = passkeyCeremonyId(Buffer.from(correlation).toString('base64url'));
29
+ const options = optionsResponseSchema.parse({ options: await deps.webauthn.registrationOptions({ challenge, rpId: deps.relyingParty.rpId, userVerification: 'required', rpName: deps.relyingParty.rpName, userHandle: initialized.userHandle, excludeCredentialIds: devices.map((device) => device.credentialId) }) });
30
+ deps.repository.saveCeremony(token, { purpose: 'registration', challenge, expectedOrigin: deps.relyingParty.publicOrigin, rpId: deps.relyingParty.rpId, expiresAt: new Date(now.getTime() + lifetimeMs).toISOString(), ...(ticket ? { enrollmentTicket: ticket } : {}) });
31
+ reply.setCookie('gestalt_mobile_registration', token, { httpOnly: true, sameSite: 'strict', path: '/', secure: deps.relyingParty.publicOrigin.startsWith('https://'), maxAge: 600 });
32
+ return reply.send(options);
33
+ });
34
+ }
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { problem } from '../../../../platform/http/problem.js';
3
+ import { PasskeyVerificationError } from '../../application/ports.js';
4
+ import { deviceNickname } from '../../domain/device-nickname.js';
5
+ import { passkeyCeremonyId } from '../../domain/identifiers.js';
6
+ import { AuthorizationDomainError } from '../../domain/errors.js';
7
+ const registrationResponseSchema = z.object({
8
+ id: z.string().min(1), rawId: z.string().min(1), type: z.literal('public-key'),
9
+ response: z.object({ clientDataJSON: z.string().min(1), attestationObject: z.string().min(1), authenticatorData: z.string().min(1).optional(), transports: z.array(z.enum(['ble', 'hybrid', 'internal', 'nfc', 'usb', 'cable', 'smart-card'])).optional(), publicKeyAlgorithm: z.number().int().optional(), publicKey: z.string().min(1).optional() }).strict(),
10
+ authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(), clientExtensionResults: z.record(z.string(), z.unknown()),
11
+ }).strict();
12
+ const requestSchema = z.object({ response: registrationResponseSchema, nickname: z.string() }).strict();
13
+ const successSchema = z.object({ status: z.literal('authenticated') }).strict();
14
+ const sessionLifetimeMs = 30 * 24 * 60 * 60 * 1000;
15
+ export function registerRegistrationVerification(app, deps) {
16
+ app.post('/api/auth/register/verify', async (request, reply) => {
17
+ const parsed = requestSchema.safeParse(request.body);
18
+ const token = request.cookies.gestalt_mobile_registration;
19
+ if (!parsed.success || typeof token !== 'string')
20
+ return reply.code(400).type('application/problem+json').send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
21
+ let nickname;
22
+ try {
23
+ nickname = deviceNickname(parsed.data.nickname);
24
+ }
25
+ catch (error) {
26
+ if (error instanceof AuthorizationDomainError)
27
+ return reply.code(400).type('application/problem+json').send(problem('INVALID_DEVICE_NICKNAME', 400, 'The device nickname is invalid.'));
28
+ throw error;
29
+ }
30
+ const now = deps.clock.now();
31
+ const ceremony = deps.repository.readCeremony(passkeyCeremonyId(token), now.toISOString());
32
+ if (!ceremony || ceremony.purpose !== 'registration')
33
+ return reply.code(400).type('application/problem+json').send(problem('REGISTRATION_NOT_AVAILABLE', 400, 'Registration could not be completed.'));
34
+ try {
35
+ const verified = await deps.webauthn.verifyRegistration({ response: parsed.data.response, challenge: ceremony.challenge, expectedOrigin: ceremony.expectedOrigin, rpId: ceremony.rpId });
36
+ if (!verified.userVerified)
37
+ throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
38
+ const device = { id: deps.identifiers.deviceId(), credentialId: verified.credentialId, publicKey: verified.publicKey, counter: verified.counter, transports: verified.transports, deviceType: verified.deviceType, backedUp: verified.backedUp, nickname, createdAt: now.toISOString() };
39
+ const session = deps.identifiers.sessionId();
40
+ if (Buffer.from(session, 'base64url').length !== 32)
41
+ throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
42
+ const expiresAt = new Date(now.getTime() + sessionLifetimeMs).toISOString();
43
+ const outcome = deps.repository.completeRegistration({ ceremony: passkeyCeremonyId(token), now: now.toISOString(), device, session: { id: session, deviceId: device.id, expiresAt } });
44
+ if (outcome !== 'registered')
45
+ return reply.code(outcome === 'bootstrapAlreadyClaimed' ? 409 : 400).type('application/problem+json').send(problem(outcome === 'bootstrapAlreadyClaimed' ? 'BOOTSTRAP_ALREADY_CLAIMED' : 'REGISTRATION_NOT_AVAILABLE', outcome === 'bootstrapAlreadyClaimed' ? 409 : 400, 'Registration could not be completed.'));
46
+ reply.setCookie('gestalt_mobile_session', session, { httpOnly: true, sameSite: 'strict', path: '/', secure: deps.relyingParty.publicOrigin.startsWith('https://'), maxAge: 30 * 24 * 60 * 60 });
47
+ reply.clearCookie('gestalt_mobile_registration', { path: '/' });
48
+ return reply.code(201).send(successSchema.parse({ status: 'authenticated' }));
49
+ }
50
+ catch (error) {
51
+ if (error instanceof PasskeyVerificationError || error instanceof AuthorizationDomainError)
52
+ return reply.code(400).type('application/problem+json').send(problem('REGISTRATION_VERIFICATION_FAILED', 400, 'Registration could not be completed.'));
53
+ throw error;
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,24 @@
1
+ import { parseAuthorizationSessionId } from '../domain/identifiers.js';
2
+ export function registerAuthStatus(app, deps) {
3
+ app.get('/api/auth/status', async (request) => {
4
+ const token = request.cookies.gestalt_mobile_session;
5
+ const session = parseAuthorizationSessionId(token);
6
+ const authenticated = session !== null &&
7
+ deps.repository.sessionDevice(session, deps.clock.now().toISOString()) !== null;
8
+ return {
9
+ status: authenticated
10
+ ? 'authenticated'
11
+ : deps.repository.listAuthorizedDevices().length
12
+ ? 'locked'
13
+ : 'bootstrap',
14
+ publicOrigin: deps.relyingParty.publicOrigin,
15
+ };
16
+ });
17
+ }
18
+ export function registerDisabledAuthStatus(app) {
19
+ app.get('/api/auth/status', async () => ({
20
+ status: 'authenticated',
21
+ publicOrigin: '',
22
+ passkeyAuthEnabled: false,
23
+ }));
24
+ }
@@ -9,7 +9,29 @@ export function registerGetHistory(app, deps) {
9
9
  const session = deps.find(request.params.id);
10
10
  if (!session)
11
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
- const history = await deps.read(session);
12
+ let history;
13
+ try {
14
+ history = await deps.read(session);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof Error && error.message === 'CODEX_SESSION_NOT_RUNNING')
18
+ return reply.code(409).type('application/problem+json').send({
19
+ type: 'urn:gestalt-mobile:error:session-history-unavailable',
20
+ title: 'Session history unavailable',
21
+ status: 409,
22
+ detail: 'GET /api/sessions/:id/history requires an active Codex session process. Open the session to restore it, then retry.',
23
+ code: 'SESSION_HISTORY_UNAVAILABLE',
24
+ retryable: true,
25
+ });
26
+ return reply.code(502).type('application/problem+json').send({
27
+ type: 'urn:gestalt-mobile:error:session-history-read-failed',
28
+ title: 'Session history read failed',
29
+ status: 502,
30
+ detail: 'GET /api/sessions/:id/history reached the relay, but Codex could not read this session history. The Codex process may have stopped during recovery; open the session again and inspect the running relay output if it persists.',
31
+ code: 'SESSION_HISTORY_READ_FAILED',
32
+ retryable: true,
33
+ });
34
+ }
13
35
  const items = toChatItems(history.turns);
14
36
  return reply.send({
15
37
  items,