gestalt-mobile 0.12.0 → 0.14.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 (79) hide show
  1. package/README.md +42 -11
  2. package/dist/client/assets/index-C5qFcFUg.css +1 -0
  3. package/dist/client/assets/index-tMyOolAm.js +24 -0
  4. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  5. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  6. package/dist/client/assets/jetbrains-mono-cyrillic-ext-400-normal-Bh0R7Dhr.woff +0 -0
  7. package/dist/client/assets/jetbrains-mono-cyrillic-ext-400-normal-C_uLvvQ5.woff2 +0 -0
  8. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  9. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  10. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  11. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  12. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  13. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  14. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-ByoDsISC.woff2 +0 -0
  15. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  16. package/dist/client/assets/syne-greek-600-normal-BJs4kvyH.woff +0 -0
  17. package/dist/client/assets/syne-greek-600-normal-BpFII5Ja.woff2 +0 -0
  18. package/dist/client/assets/syne-latin-600-normal-BiwQbQXw.woff2 +0 -0
  19. package/dist/client/assets/syne-latin-600-normal-dRu9QuIh.woff +0 -0
  20. package/dist/client/assets/syne-latin-ext-600-normal-DEzBMW8B.woff +0 -0
  21. package/dist/client/assets/syne-latin-ext-600-normal-ZnizrDKU.woff2 +0 -0
  22. package/dist/client/index.html +3 -2
  23. package/dist/server/server/app.js +22 -227
  24. package/dist/server/server/cli.js +3 -1
  25. package/dist/server/server/composition.js +25 -26
  26. package/dist/server/server/features/auth/application/ceremony-attempts.js +22 -0
  27. package/dist/server/server/features/auth/devices/revoke/endpoint.js +3 -1
  28. package/dist/server/server/features/auth/domain/errors.js +7 -0
  29. package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +7 -2
  30. package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +3 -1
  31. package/dist/server/server/features/auth/http/cookies.js +20 -0
  32. package/dist/server/server/features/auth/login/options/endpoint.js +22 -20
  33. package/dist/server/server/features/auth/login/verify/endpoint.js +6 -14
  34. package/dist/server/server/features/auth/logout/endpoint.js +2 -6
  35. package/dist/server/server/features/auth/register/options/endpoint.js +43 -11
  36. package/dist/server/server/features/auth/register/verify/endpoint.js +74 -18
  37. package/dist/server/server/features/auth/register-routes.js +41 -0
  38. package/dist/server/server/features/git/checkout-branch/endpoint.js +1 -1
  39. package/dist/server/server/features/git/clone-repository/endpoint.js +1 -1
  40. package/dist/server/server/features/git/register-routes.js +34 -0
  41. package/dist/server/server/features/plans/application/measurement-snapshot.js +1 -1
  42. package/dist/server/server/features/plans/get-measurement/endpoint.js +1 -3
  43. package/dist/server/server/features/plans/register-routes.js +22 -0
  44. package/dist/server/server/features/sessions/interaction/kind.js +7 -1
  45. package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +3 -1
  46. package/dist/server/server/features/sessions/model/relay-session.js +3 -1
  47. package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +1 -1
  48. package/dist/server/server/features/sessions/register-routes.js +138 -0
  49. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
  50. package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
  51. package/dist/server/server/features/sessions/start-session/endpoint.js +5 -2
  52. package/dist/server/server/features/sessions/start-session/use-case.js +1 -1
  53. package/dist/server/server/features/sessions/start-turn/endpoint.js +2 -1
  54. package/dist/server/server/features/skills/delete-profile/endpoint.js +12 -3
  55. package/dist/server/server/features/skills/list-available/endpoint.js +24 -6
  56. package/dist/server/server/features/skills/list-profiles/endpoint.js +29 -5
  57. package/dist/server/server/features/skills/model/skill-profile.js +23 -3
  58. package/dist/server/server/features/skills/register-routes.js +17 -0
  59. package/dist/server/server/features/skills/replace-profile/endpoint.js +20 -5
  60. package/dist/server/server/platform/auth/simple-webauthn-adapter.js +23 -3
  61. package/dist/server/server/platform/auth/sqlite-authorization-store.js +29 -6
  62. package/dist/server/server/platform/catalog/profile-command.js +8 -1
  63. package/dist/server/server/platform/codex/normalizer.js +50 -6
  64. package/dist/server/server/platform/codex/protocol-compatibility.js +19 -1
  65. package/dist/server/server/platform/codex/server-request.js +2 -0
  66. package/dist/server/server/platform/codex/session-runtime.js +235 -126
  67. package/dist/server/server/platform/codex/thread-plan-name.js +5 -2
  68. package/dist/server/server/platform/http/problem-handler.js +10 -0
  69. package/dist/server/server/platform/http/problem-reply.js +13 -0
  70. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +11 -6
  71. package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +4 -1
  72. package/dist/server/server/platform/plans/plan-measurement-command.js +4 -1
  73. package/dist/server/server/platform/skills/codex-skill-catalog.js +29 -7
  74. package/dist/server/server/platform/skills/filesystem-skill-profile-store.js +5 -1
  75. package/dist/server/shared/contracts/quiz.js +30 -9
  76. package/package.json +4 -1
  77. package/dist/client/assets/index-B7KwEjTE.js +0 -19
  78. package/dist/client/assets/index-CMs5ZzxJ.css +0 -1
  79. package/dist/server/server/features/plans/application/native-plan-alignment.js +0 -39
@@ -1,22 +1,31 @@
1
1
  import { z } from 'zod';
2
- import { problem } from '../../../../platform/http/problem.js';
2
+ import { sendProblem } from '../../../../platform/http/problem-reply.js';
3
3
  import { parseEnrollmentTicketId, passkeyCeremonyId } from '../../domain/identifiers.js';
4
+ import { CeremonyCapacityError } from '../../domain/errors.js';
5
+ import { setAuthCookie } from '../../http/cookies.js';
4
6
  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();
7
+ const optionsResponseSchema = z
8
+ .object({ options: z.object({ challenge: z.string().min(1) }).passthrough() })
9
+ .strict();
6
10
  const lifetimeMs = 10 * 60 * 1000;
7
11
  export function registerRegistrationOptions(app, deps) {
8
- app.post('/api/auth/register/options', async (request, reply) => {
12
+ app.post('/api/auth/register/options', { bodyLimit: 4 * 1024 }, async (request, reply) => {
13
+ if (!deps.ceremonyAttempts.allow(`register:${request.ip}`, deps.clock.now()))
14
+ return sendProblem(reply, 'REGISTRATION_NOT_AVAILABLE', 400, 'Registration could not be completed.');
9
15
  const parsed = requestSchema.safeParse(request.body);
10
16
  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);
17
+ return sendProblem(reply, 'INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.');
18
+ const ticket = parsed.data.enrollmentTicket === undefined
19
+ ? undefined
20
+ : parseEnrollmentTicketId(parsed.data.enrollmentTicket);
13
21
  if (ticket === null)
14
- return reply.code(400).type('application/problem+json').send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
22
+ return sendProblem(reply, 'INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.');
15
23
  const owner = deps.repository.readOwner();
16
24
  const devices = deps.repository.listAuthorizedDevices();
17
25
  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.'));
26
+ if (devices.length > 0 &&
27
+ (!ticket || !deps.repository.ticketAvailable(ticket, now.toISOString())))
28
+ return sendProblem(reply, 'ENROLLMENT_NOT_AUTHORIZED', 403, 'Registration is not authorized.');
20
29
  const ownerHandle = owner ? undefined : deps.random.bytes(32);
21
30
  if (ownerHandle && ownerHandle.length !== 32)
22
31
  throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
@@ -26,9 +35,32 @@ export function registerRegistrationOptions(app, deps) {
26
35
  if (correlation.length !== 32 || challenge.length !== 32)
27
36
  throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
28
37
  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 });
38
+ const options = optionsResponseSchema.parse({
39
+ options: await deps.webauthn.registrationOptions({
40
+ challenge,
41
+ rpId: deps.relyingParty.rpId,
42
+ userVerification: 'required',
43
+ rpName: deps.relyingParty.rpName,
44
+ userHandle: initialized.userHandle,
45
+ excludeCredentialIds: devices.map((device) => device.credentialId),
46
+ }),
47
+ });
48
+ try {
49
+ deps.repository.saveCeremony(token, {
50
+ purpose: 'registration',
51
+ challenge,
52
+ expectedOrigin: deps.relyingParty.publicOrigin,
53
+ rpId: deps.relyingParty.rpId,
54
+ expiresAt: new Date(now.getTime() + lifetimeMs).toISOString(),
55
+ ...(ticket ? { enrollmentTicket: ticket } : {}),
56
+ }, now.toISOString());
57
+ }
58
+ catch (error) {
59
+ if (error instanceof CeremonyCapacityError)
60
+ return sendProblem(reply, 'REGISTRATION_NOT_AVAILABLE', 400, 'Registration could not be completed.');
61
+ throw error;
62
+ }
63
+ setAuthCookie(reply, 'gestalt_mobile_registration', token, deps.relyingParty.publicOrigin);
32
64
  return reply.send(options);
33
65
  });
34
66
  }
@@ -1,55 +1,111 @@
1
1
  import { z } from 'zod';
2
2
  import { problem } from '../../../../platform/http/problem.js';
3
- import { PasskeyVerificationError } from '../../application/ports.js';
3
+ import { PasskeyVerificationError, } from '../../application/ports.js';
4
4
  import { deviceNickname } from '../../domain/device-nickname.js';
5
5
  import { passkeyCeremonyId } from '../../domain/identifiers.js';
6
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();
7
+ import { clearAuthCookie, setAuthCookie } from '../../http/cookies.js';
8
+ const registrationResponseSchema = z
9
+ .object({
10
+ id: z.string().min(1),
11
+ rawId: z.string().min(1),
12
+ type: z.literal('public-key'),
13
+ response: z
14
+ .object({
15
+ clientDataJSON: z.string().min(1),
16
+ attestationObject: z.string().min(1),
17
+ authenticatorData: z.string().min(1).optional(),
18
+ transports: z
19
+ .array(z.enum(['ble', 'hybrid', 'internal', 'nfc', 'usb', 'cable', 'smart-card']))
20
+ .optional(),
21
+ publicKeyAlgorithm: z.number().int().optional(),
22
+ publicKey: z.string().min(1).optional(),
23
+ })
24
+ .strict(),
25
+ authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(),
26
+ clientExtensionResults: z.record(z.string(), z.unknown()),
27
+ })
28
+ .strict();
29
+ const requestSchema = z
30
+ .object({ response: registrationResponseSchema, nickname: z.string() })
31
+ .strict();
13
32
  const successSchema = z.object({ status: z.literal('authenticated') }).strict();
14
33
  const sessionLifetimeMs = 30 * 24 * 60 * 60 * 1000;
15
34
  export function registerRegistrationVerification(app, deps) {
16
- app.post('/api/auth/register/verify', async (request, reply) => {
35
+ app.post('/api/auth/register/verify', { bodyLimit: 128 * 1024 }, async (request, reply) => {
17
36
  const parsed = requestSchema.safeParse(request.body);
18
37
  const token = request.cookies.gestalt_mobile_registration;
19
38
  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.'));
39
+ return reply
40
+ .code(400)
41
+ .type('application/problem+json')
42
+ .send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
21
43
  let nickname;
22
44
  try {
23
45
  nickname = deviceNickname(parsed.data.nickname);
24
46
  }
25
47
  catch (error) {
26
48
  if (error instanceof AuthorizationDomainError)
27
- return reply.code(400).type('application/problem+json').send(problem('INVALID_DEVICE_NICKNAME', 400, 'The device nickname is invalid.'));
49
+ return reply
50
+ .code(400)
51
+ .type('application/problem+json')
52
+ .send(problem('INVALID_DEVICE_NICKNAME', 400, 'The device nickname is invalid.'));
28
53
  throw error;
29
54
  }
30
55
  const now = deps.clock.now();
31
56
  const ceremony = deps.repository.readCeremony(passkeyCeremonyId(token), now.toISOString());
32
57
  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.'));
58
+ return reply
59
+ .code(400)
60
+ .type('application/problem+json')
61
+ .send(problem('REGISTRATION_NOT_AVAILABLE', 400, 'Registration could not be completed.'));
34
62
  try {
35
- const verified = await deps.webauthn.verifyRegistration({ response: parsed.data.response, challenge: ceremony.challenge, expectedOrigin: ceremony.expectedOrigin, rpId: ceremony.rpId });
63
+ const verified = await deps.webauthn.verifyRegistration({
64
+ response: parsed.data.response,
65
+ challenge: ceremony.challenge,
66
+ expectedOrigin: ceremony.expectedOrigin,
67
+ rpId: ceremony.rpId,
68
+ });
36
69
  if (!verified.userVerified)
37
70
  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() };
71
+ const device = {
72
+ id: deps.identifiers.deviceId(),
73
+ credentialId: verified.credentialId,
74
+ publicKey: verified.publicKey,
75
+ counter: verified.counter,
76
+ transports: verified.transports,
77
+ deviceType: verified.deviceType,
78
+ backedUp: verified.backedUp,
79
+ nickname,
80
+ createdAt: now.toISOString(),
81
+ };
39
82
  const session = deps.identifiers.sessionId();
40
83
  if (Buffer.from(session, 'base64url').length !== 32)
41
84
  throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
42
85
  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 } });
86
+ const outcome = deps.repository.completeRegistration({
87
+ ceremony: passkeyCeremonyId(token),
88
+ now: now.toISOString(),
89
+ device,
90
+ session: { id: session, deviceId: device.id, expiresAt },
91
+ });
44
92
  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: '/' });
93
+ return reply
94
+ .code(outcome === 'bootstrapAlreadyClaimed' ? 409 : 400)
95
+ .type('application/problem+json')
96
+ .send(problem(outcome === 'bootstrapAlreadyClaimed'
97
+ ? 'BOOTSTRAP_ALREADY_CLAIMED'
98
+ : 'REGISTRATION_NOT_AVAILABLE', outcome === 'bootstrapAlreadyClaimed' ? 409 : 400, 'Registration could not be completed.'));
99
+ setAuthCookie(reply, 'gestalt_mobile_session', session, deps.relyingParty.publicOrigin);
100
+ clearAuthCookie(reply, 'gestalt_mobile_registration', deps.relyingParty.publicOrigin);
48
101
  return reply.code(201).send(successSchema.parse({ status: 'authenticated' }));
49
102
  }
50
103
  catch (error) {
51
104
  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.'));
105
+ return reply
106
+ .code(400)
107
+ .type('application/problem+json')
108
+ .send(problem('REGISTRATION_VERIFICATION_FAILED', 400, 'Registration could not be completed.'));
53
109
  throw error;
54
110
  }
55
111
  });
@@ -0,0 +1,41 @@
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 { ExpiringCeremonyAttemptGate } from './application/ceremony-attempts.js';
7
+ import { registerListAuthorizedDevices } from './devices/list/endpoint.js';
8
+ import { registerRenameAuthorizedDevice } from './devices/rename/endpoint.js';
9
+ import { registerRevokeAuthorizedDevice } from './devices/revoke/endpoint.js';
10
+ import { registerCancelEnrollmentTicket } from './enrollment-tickets/cancel/endpoint.js';
11
+ import { registerCreateEnrollmentTicket } from './enrollment-tickets/endpoint.js';
12
+ import { registerEnrollmentTicketStatus } from './enrollment-tickets/status/endpoint.js';
13
+ import { registerLoginOptions } from './login/options/endpoint.js';
14
+ import { registerLoginVerification } from './login/verify/endpoint.js';
15
+ import { registerLogout } from './logout/endpoint.js';
16
+ import { registerRegistrationOptions } from './register/options/endpoint.js';
17
+ import { registerRegistrationVerification } from './register/verify/endpoint.js';
18
+ import { registerAuthStatus, registerDisabledAuthStatus } from './status/endpoint.js';
19
+ /** Registers the complete passkey surface; authorization hooks are composed by app.ts first. */
20
+ export function registerAuthRoutes(app, deps) {
21
+ if (deps.auth) {
22
+ const auth = {
23
+ ...deps.auth,
24
+ ceremonyAttempts: deps.auth.ceremonyAttempts ?? new ExpiringCeremonyAttemptGate(),
25
+ };
26
+ registerRegistrationOptions(app, auth);
27
+ registerRegistrationVerification(app, deps.auth);
28
+ registerLoginOptions(app, auth);
29
+ registerLoginVerification(app, deps.auth);
30
+ registerAuthStatus(app, deps.auth);
31
+ registerLogout(app, deps.auth);
32
+ registerListAuthorizedDevices(app, deps.auth);
33
+ registerRenameAuthorizedDevice(app, deps.auth);
34
+ registerRevokeAuthorizedDevice(app, deps.auth);
35
+ registerCreateEnrollmentTicket(app, deps.auth);
36
+ registerEnrollmentTicketStatus(app, deps.auth);
37
+ registerCancelEnrollmentTicket(app, deps.auth);
38
+ }
39
+ else if (deps.passkeyAuthDisabled)
40
+ registerDisabledAuthStatus(app);
41
+ }
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { resolveRepositoryTarget } from '../application/repository-target.js';
8
8
  const requestSchema = z.object({ branch: z.string().min(1) });
9
9
  export function registerCheckoutBranch(app, deps) {
10
- app.post('/api/git/repositories/:workspaceId/checkout', async (request, reply) => {
10
+ app.post('/api/git/repositories/:workspaceId/checkout', { bodyLimit: 8 * 1024 }, async (request, reply) => {
11
11
  const target = await resolveRepositoryTarget(deps.workspaces, request.params.workspaceId);
12
12
  if (!target.ok)
13
13
  return reply.code(target.statusCode).send({ code: target.code });
@@ -10,7 +10,7 @@ const requestSchema = z.object({
10
10
  workspaceId: z.string().trim().min(1),
11
11
  });
12
12
  export function registerCloneRepository(app, deps) {
13
- app.post('/api/git/clone', async (request, reply) => {
13
+ app.post('/api/git/clone', { bodyLimit: 8 * 1024 }, async (request, reply) => {
14
14
  const parsed = requestSchema.safeParse(request.body);
15
15
  if (!parsed.success)
16
16
  return reply.code(400).send({ code: 'INVALID_CLONE_ADDRESS' });
@@ -0,0 +1,34 @@
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 { registerCheckoutBranch } from './checkout-branch/endpoint.js';
7
+ import { registerCloneRepository } from './clone-repository/endpoint.js';
8
+ import { registerGetGitSummary } from './get-summary/endpoint.js';
9
+ import { registerPullRebase } from './pull-rebase/endpoint.js';
10
+ import { registerPushUpstream } from './push-upstream/endpoint.js';
11
+ import { registerRefreshGit } from './refresh/endpoint.js';
12
+ export function registerGitRoutes(app, deps) {
13
+ const git = deps.gitSummary;
14
+ if (!git)
15
+ return;
16
+ registerGetGitSummary(app, { workspaces: git.workspaces, inspect: git.inspect });
17
+ registerPushUpstream(app, {
18
+ workspaces: git.workspaces,
19
+ inspect: git.inspectForPush ?? git.inspect,
20
+ push: git.push,
21
+ idempotency: git.idempotency,
22
+ });
23
+ registerRefreshGit(app, {
24
+ workspaces: git.workspaces,
25
+ refresh: git.refresh,
26
+ idempotency: git.idempotency,
27
+ });
28
+ if (git.pull && git.checkout) {
29
+ registerPullRebase(app, { workspaces: git.workspaces, pull: git.pull });
30
+ registerCheckoutBranch(app, { workspaces: git.workspaces, checkout: git.checkout });
31
+ }
32
+ if (git.clone)
33
+ registerCloneRepository(app, { workspaces: git.workspaces, clone: git.clone });
34
+ }
@@ -19,7 +19,7 @@ export function weeklyRateLimitWindow(windows) {
19
19
  Number.isFinite(window.usedPercent) &&
20
20
  window.usedPercent >= 0 &&
21
21
  window.usedPercent <= 100);
22
- return valid.reduce((weekly, window) => (!weekly || window.durationSeconds > weekly.durationSeconds ? window : weekly), null);
22
+ return valid.reduce((weekly, window) => !weekly || window.durationSeconds > weekly.durationSeconds ? window : weekly, null);
23
23
  }
24
24
  export function weeklyRemainingPercent(windows) {
25
25
  const weekly = weeklyRateLimitWindow(windows);
@@ -6,9 +6,7 @@
6
6
  export function registerGetPlanMeasurement(app, deps) {
7
7
  app.get('/api/sessions/:id/plan-measurement', async (request, reply) => {
8
8
  const id = request.params.id;
9
- const authorization = typeof request.headers.authorization === 'string'
10
- ? request.headers.authorization
11
- : undefined;
9
+ const authorization = typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined;
12
10
  if (!deps.exists(id) || !deps.authorize(id, authorization))
13
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
14
12
  try {
@@ -0,0 +1,22 @@
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 { registerClosePlan } from './close-plan/endpoint.js';
7
+ import { registerGetPlanMeasurement } from './get-measurement/endpoint.js';
8
+ import { registerGetPlan } from './get-plan/endpoint.js';
9
+ import { registerGetWorkspacePlan } from './get-workspace-plan/endpoint.js';
10
+ import { registerListWorkspacePlans } from './list-workspace-plans/endpoint.js';
11
+ export function registerPlanRoutes(app, deps) {
12
+ if (deps.planRoutes) {
13
+ registerGetPlan(app, deps.planRoutes);
14
+ registerClosePlan(app, deps.planRoutes);
15
+ }
16
+ if (deps.workspacePlanRoutes) {
17
+ registerListWorkspacePlans(app, deps.workspacePlanRoutes);
18
+ registerGetWorkspacePlan(app, deps.workspacePlanRoutes);
19
+ }
20
+ if (deps.planMeasurementRoutes)
21
+ registerGetPlanMeasurement(app, deps.planMeasurementRoutes);
22
+ }
@@ -4,5 +4,11 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export function isInteractionKind(value) {
7
- return ['commandApproval', 'fileChangeApproval', 'permissionsApproval', 'userInput', 'quiz'].includes(value);
7
+ return [
8
+ 'commandApproval',
9
+ 'fileChangeApproval',
10
+ 'permissionsApproval',
11
+ 'userInput',
12
+ 'quiz',
13
+ ].includes(value);
8
14
  }
@@ -15,7 +15,9 @@ export function registerListRecentThreads(app, deps) {
15
15
  recencyAt,
16
16
  ...(metadata?.model === undefined ? {} : { model: metadata.model }),
17
17
  ...(metadata?.skillProfile === undefined ? {} : { skillProfile: metadata.skillProfile }),
18
- ...(metadata?.orgPlanFilename === undefined ? {} : { orgPlanFilename: metadata.orgPlanFilename }),
18
+ ...(metadata?.orgPlanFilename === undefined
19
+ ? {}
20
+ : { orgPlanFilename: metadata.orgPlanFilename }),
19
21
  resumeCommand: buildResumeCommand({ profile, threadId: id, workspacePath: cwd }),
20
22
  };
21
23
  });
@@ -136,7 +136,9 @@ function copy(snapshot) {
136
136
  ...snapshot,
137
137
  ...(snapshot.effectiveSkillSelection === undefined
138
138
  ? {}
139
- : { effectiveSkillSelection: createEffectiveSkillSelection(snapshot.effectiveSkillSelection) }),
139
+ : {
140
+ effectiveSkillSelection: createEffectiveSkillSelection(snapshot.effectiveSkillSelection),
141
+ }),
140
142
  pendingInteractions: snapshot.pendingInteractions.map((item) => ({ ...item })),
141
143
  };
142
144
  }
@@ -9,7 +9,7 @@ const requestSchema = z.object({
9
9
  cwd: z.string().startsWith('/'),
10
10
  });
11
11
  export function registerPromoteRecentThread(app, deps) {
12
- app.post('/api/sessions/recent-threads/open', async (request, reply) => {
12
+ app.post('/api/sessions/recent-threads/open', { bodyLimit: 8 * 1024 }, async (request, reply) => {
13
13
  const parsed = requestSchema.safeParse(request.body);
14
14
  if (!parsed.success)
15
15
  return reply.code(400).send({ code: 'INVALID_RECENT_THREAD' });
@@ -0,0 +1,138 @@
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 { stopSession } from './lifecycle/use-case.js';
8
+ import { registerForgetSession } from './forget-session/endpoint.js';
9
+ import { registerGetHistory } from './get-history/endpoint.js';
10
+ import { registerGetSession } from './get-session/endpoint.js';
11
+ import { registerInterruptTurn } from './interrupt-turn/endpoint.js';
12
+ import { registerListRecentThreads } from './list-recent-threads/endpoint.js';
13
+ import { registerListSessions } from './list-sessions/endpoint.js';
14
+ import { registerPromoteRecentThread } from './promote-recent-thread/endpoint.js';
15
+ import { registerReleaseSession } from './release-session/endpoint.js';
16
+ import { registerRestoreSession } from './restore-session/endpoint.js';
17
+ import { registerRespondInteraction } from './respond-interaction/endpoint.js';
18
+ import { registerSelectModel } from './select-model/endpoint.js';
19
+ import { registerSessionEvents } from './session-events/endpoint.js';
20
+ import { registerStartSession } from './start-session/endpoint.js';
21
+ import { registerStartTurn } from './start-turn/endpoint.js';
22
+ import { registerStopSession } from './stop-session/endpoint.js';
23
+ export function registerSessionRoutes(app, deps) {
24
+ const sessions = deps.sessionRoutes;
25
+ if (deps.recentThreads) {
26
+ registerListRecentThreads(app, {
27
+ ...deps.recentThreads,
28
+ metadata: (threadId) => {
29
+ const session = sessions?.list?.().find((candidate) => candidate.threadId === threadId);
30
+ if (!session)
31
+ return null;
32
+ return {
33
+ ...(session.model === undefined ? {} : { model: session.model }),
34
+ ...(session.effectiveSkillSelection?.selectedProfileName === undefined
35
+ ? {}
36
+ : { skillProfile: session.effectiveSkillSelection.selectedProfileName }),
37
+ ...(session.lastOrgPlan === undefined
38
+ ? {}
39
+ : { orgPlanFilename: session.lastOrgPlan.filename }),
40
+ };
41
+ },
42
+ });
43
+ }
44
+ if (sessions) {
45
+ registerStartSession(app, {
46
+ ...sessions,
47
+ reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
48
+ });
49
+ registerGetSession(app, sessions.find);
50
+ if (sessions.list)
51
+ registerListSessions(app, { list: sessions.list });
52
+ if (sessions.readHistory)
53
+ registerGetHistory(app, {
54
+ find: sessions.find,
55
+ read: sessions.readHistory,
56
+ currentSequence: sessions.currentSequence ?? (() => 0),
57
+ });
58
+ if (sessions.startTurn)
59
+ registerStartTurn(app, {
60
+ find: sessions.find,
61
+ start: sessions.startTurn,
62
+ save: sessions.save,
63
+ onStarted: sessions.onTurnStarted,
64
+ });
65
+ if (sessions.models)
66
+ registerSelectModel(app, {
67
+ find: sessions.find,
68
+ models: () => sessions.models.list(),
69
+ now: sessions.now,
70
+ save: sessions.save,
71
+ });
72
+ if (sessions.interruptTurn)
73
+ registerInterruptTurn(app, { find: sessions.find, interrupt: sessions.interruptTurn });
74
+ if (sessions.restore)
75
+ registerRestoreSession(app, {
76
+ find: sessions.find,
77
+ restore: sessions.restore,
78
+ save: sessions.save,
79
+ idempotency: sessions.idempotency,
80
+ });
81
+ if (deps.recentThreads && sessions.promoteRecent)
82
+ registerPromoteRecentThread(app, {
83
+ list: () => deps.recentThreads.list(),
84
+ promote: sessions.promoteRecent,
85
+ });
86
+ if (sessions.release && sessions.close)
87
+ registerReleaseSession(app, {
88
+ find: sessions.find,
89
+ release: sessions.release,
90
+ save: sessions.save,
91
+ close: sessions.close,
92
+ idempotency: sessions.idempotency,
93
+ });
94
+ if (sessions.remove && sessions.close)
95
+ registerForgetSession(app, {
96
+ find: sessions.find,
97
+ close: sessions.close,
98
+ remove: sessions.remove,
99
+ });
100
+ if (sessions.close)
101
+ registerStopSession(app, {
102
+ find: sessions.find,
103
+ stop: (session) => stopSession(session, sessions.now()),
104
+ save: sessions.save,
105
+ close: sessions.close,
106
+ idempotency: sessions.idempotency,
107
+ });
108
+ if (sessions.replyInteraction && deps.interactions)
109
+ registerRespondInteraction(app, {
110
+ exists: (id) => sessions.find(id) !== null,
111
+ resolve: (sessionId, requestId, resolvedAt) => deps.interactions.resolve(sessionId, requestId, resolvedAt),
112
+ validate: deps.interactions.validate,
113
+ reply: sessions.replyInteraction,
114
+ resolved: sessions.interactionResolved,
115
+ now: sessions.now,
116
+ });
117
+ }
118
+ if (deps.sessionEvents)
119
+ registerSessionEvents(app, {
120
+ ...deps.sessionEvents,
121
+ ...(deps.auth
122
+ ? {
123
+ publicOrigin: deps.auth.relyingParty.publicOrigin,
124
+ authorized: (cookieHeader) => authorizationSessionDevice(cookieHeader, deps.auth) !== null,
125
+ }
126
+ : {}),
127
+ });
128
+ }
129
+ function safeErrorLabel(error) {
130
+ if (typeof error === 'object' && error !== null && 'code' in error) {
131
+ const code = error.code;
132
+ if (typeof code === 'string' && /^[A-Z0-9_]+$/.test(code))
133
+ return code;
134
+ }
135
+ if (error instanceof Error && /^[A-Z0-9_]+$/.test(error.message))
136
+ return error.message;
137
+ return error instanceof Error ? error.name : 'UNKNOWN_ERROR';
138
+ }
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export function registerRespondInteraction(app, deps) {
7
- app.post('/api/sessions/:id/interactions/:requestId', async (request, reply) => {
7
+ app.post('/api/sessions/:id/interactions/:requestId', { bodyLimit: 64 * 1024 }, async (request, reply) => {
8
8
  const { id, requestId } = request.params;
9
9
  if (!deps.exists(id))
10
10
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
7
  export function registerSelectModel(app, deps) {
8
- app.post('/api/sessions/:id/model', async (request, reply) => {
8
+ app.post('/api/sessions/:id/model', { bodyLimit: 4 * 1024 }, async (request, reply) => {
9
9
  const session = deps.find(request.params.id);
10
10
  if (!session)
11
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -9,7 +9,7 @@ import { startSession } from './use-case.js';
9
9
  import { SkillProfileError } from '../../skills/model/errors.js';
10
10
  import { problem } from '../../../platform/http/problem.js';
11
11
  export function registerStartSession(app, deps) {
12
- app.post('/api/sessions', async (request, reply) => {
12
+ app.post('/api/sessions', { bodyLimit: 4 * 1024 }, async (request, reply) => {
13
13
  const key = idempotencyKey(request.headers);
14
14
  const prior = key ? deps.idempotency?.get('start-session', key) : null;
15
15
  if (prior)
@@ -31,7 +31,10 @@ export function registerStartSession(app, deps) {
31
31
  catch (error) {
32
32
  deps.reportFailure?.('start-session', error);
33
33
  if (error instanceof SkillProfileError)
34
- return reply.code(400).type('application/problem+json').send(problem(error.code, 400, error.code === 'UNKNOWN_SKILL_PROFILE'
34
+ return reply
35
+ .code(400)
36
+ .type('application/problem+json')
37
+ .send(problem(error.code, 400, error.code === 'UNKNOWN_SKILL_PROFILE'
35
38
  ? 'The selected skill profile does not exist.'
36
39
  : 'The selected skill profile is invalid.'));
37
40
  throw error;
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
- import { DEFAULT_SESSION_MODEL, } from '../application/start-settings.js';
7
+ import { DEFAULT_SESSION_MODEL } from '../application/start-settings.js';
8
8
  import { applySkillSelectionSnapshot, } from '../../skills/model/skill-profile.js';
9
9
  import { SkillProfileError } from '../../skills/model/errors.js';
10
10
  export async function startSession(input, deps) {
@@ -4,7 +4,8 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export function registerStartTurn(app, deps) {
7
- app.post('/api/sessions/:id/turns', async (request, reply) => {
7
+ // Text is capped at 100,000 characters; leave room for JSON encoding only.
8
+ app.post('/api/sessions/:id/turns', { bodyLimit: 128 * 1024 }, async (request, reply) => {
8
9
  const session = deps.find(request.params.id);
9
10
  if (!session)
10
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -12,13 +12,22 @@ export function registerDeleteSkillProfile(app, deps) {
12
12
  try {
13
13
  const name = normalizeSkillProfileName(request.params.name);
14
14
  if (!(await deps.deleteGlobalProfile(name)))
15
- return reply.code(404).type('application/problem+json').send(problem('SKILL_PROFILE_NOT_FOUND', 404, 'The skill profile was not found.'));
15
+ return reply
16
+ .code(404)
17
+ .type('application/problem+json')
18
+ .send(problem('SKILL_PROFILE_NOT_FOUND', 404, 'The skill profile was not found.'));
16
19
  return reply.code(204).send();
17
20
  }
18
21
  catch (error) {
19
22
  if (error instanceof SkillProfileError)
20
- return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_PROFILE', 400, 'The skill profile could not be deleted.'));
21
- return reply.code(500).type('application/problem+json').send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'The skill profile could not be deleted.', true));
23
+ return reply
24
+ .code(400)
25
+ .type('application/problem+json')
26
+ .send(problem('INVALID_SKILL_PROFILE', 400, 'The skill profile could not be deleted.'));
27
+ return reply
28
+ .code(500)
29
+ .type('application/problem+json')
30
+ .send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'The skill profile could not be deleted.', true));
22
31
  }
23
32
  });
24
33
  }