gestalt-mobile 0.11.0 → 0.13.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.
- package/dist/client/assets/index-CMs5ZzxJ.css +1 -0
- package/dist/client/assets/index-D2sT9ZHY.js +19 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +22 -221
- package/dist/server/server/composition.js +4 -0
- package/dist/server/server/features/auth/application/ceremony-attempts.js +22 -0
- package/dist/server/server/features/auth/domain/errors.js +7 -0
- package/dist/server/server/features/auth/http/cookies.js +10 -0
- package/dist/server/server/features/auth/login/options/endpoint.js +22 -20
- package/dist/server/server/features/auth/login/verify/endpoint.js +6 -14
- package/dist/server/server/features/auth/logout/endpoint.js +2 -6
- package/dist/server/server/features/auth/register/options/endpoint.js +43 -11
- package/dist/server/server/features/auth/register/verify/endpoint.js +4 -3
- package/dist/server/server/features/auth/register-routes.js +41 -0
- package/dist/server/server/features/git/checkout-branch/endpoint.js +1 -1
- package/dist/server/server/features/git/clone-repository/endpoint.js +1 -1
- package/dist/server/server/features/git/register-routes.js +34 -0
- package/dist/server/server/features/plans/domain/workspace-plan-catalog.js +6 -0
- package/dist/server/server/features/plans/get-workspace-plan/endpoint.js +23 -0
- package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +18 -0
- package/dist/server/server/features/plans/register-routes.js +22 -0
- package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +1 -1
- package/dist/server/server/features/sessions/register-routes.js +138 -0
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
- package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
- package/dist/server/server/features/sessions/start-session/endpoint.js +5 -2
- package/dist/server/server/features/sessions/start-turn/endpoint.js +2 -1
- package/dist/server/server/features/skills/register-routes.js +17 -0
- package/dist/server/server/features/skills/replace-profile/endpoint.js +20 -5
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +29 -6
- package/dist/server/server/platform/codex/normalizer.js +32 -6
- package/dist/server/server/platform/codex/protocol-compatibility.js +19 -1
- package/dist/server/server/platform/codex/server-request.js +2 -0
- package/dist/server/server/platform/codex/session-runtime.js +207 -111
- package/dist/server/server/platform/codex/thread-plan-name.js +23 -0
- package/dist/server/server/platform/http/problem-handler.js +10 -0
- package/dist/server/server/platform/http/problem-reply.js +10 -0
- package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +113 -0
- package/package.json +2 -1
- package/dist/client/assets/index-Bkm5GDCs.css +0 -1
- package/dist/client/assets/index-DviggEIS.js +0 -19
|
@@ -8,51 +8,25 @@ import fastifyCookie from '@fastify/cookie';
|
|
|
8
8
|
import fastify, {} from 'fastify';
|
|
9
9
|
import { registerGetHealth } from './features/health/get-health/endpoint.js';
|
|
10
10
|
import { registerGetBootstrap } from './features/catalog/get-bootstrap/endpoint.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { registerCheckoutBranch } from './features/git/checkout-branch/endpoint.js';
|
|
16
|
-
import { registerCloneRepository } from './features/git/clone-repository/endpoint.js';
|
|
17
|
-
import { registerGetSession } from './features/sessions/get-session/endpoint.js';
|
|
18
|
-
import { registerListSessions } from './features/sessions/list-sessions/endpoint.js';
|
|
19
|
-
import { registerListRecentThreads, } from './features/sessions/list-recent-threads/endpoint.js';
|
|
20
|
-
import { registerPromoteRecentThread } from './features/sessions/promote-recent-thread/endpoint.js';
|
|
21
|
-
import { registerGetHistory } from './features/sessions/get-history/endpoint.js';
|
|
22
|
-
import { registerStartSession } from './features/sessions/start-session/endpoint.js';
|
|
23
|
-
import { registerStartTurn } from './features/sessions/start-turn/endpoint.js';
|
|
24
|
-
import { registerSelectModel } from './features/sessions/select-model/endpoint.js';
|
|
25
|
-
import { registerInterruptTurn } from './features/sessions/interrupt-turn/endpoint.js';
|
|
26
|
-
import { registerStopSession } from './features/sessions/stop-session/endpoint.js';
|
|
27
|
-
import { registerRestoreSession } from './features/sessions/restore-session/endpoint.js';
|
|
28
|
-
import { registerReleaseSession } from './features/sessions/release-session/endpoint.js';
|
|
29
|
-
import { registerForgetSession } from './features/sessions/forget-session/endpoint.js';
|
|
30
|
-
import { registerRespondInteraction } from './features/sessions/respond-interaction/endpoint.js';
|
|
31
|
-
import { stopSession } from './features/sessions/lifecycle/use-case.js';
|
|
32
|
-
import { registerSessionEvents } from './features/sessions/session-events/endpoint.js';
|
|
33
|
-
import { registerGetPlan } from './features/plans/get-plan/endpoint.js';
|
|
34
|
-
import { registerClosePlan } from './features/plans/close-plan/endpoint.js';
|
|
35
|
-
import { registerGetPlanMeasurement } from './features/plans/get-measurement/endpoint.js';
|
|
11
|
+
import { registerAuthRoutes } from './features/auth/register-routes.js';
|
|
12
|
+
import { registerGitRoutes } from './features/git/register-routes.js';
|
|
13
|
+
import { registerPlanRoutes } from './features/plans/register-routes.js';
|
|
14
|
+
import { registerSessionRoutes } from './features/sessions/register-routes.js';
|
|
36
15
|
import { registerProblemHandler } from './platform/http/problem-handler.js';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import { registerListSkillProfiles, } from './features/skills/list-profiles/endpoint.js';
|
|
40
|
-
import { registerReplaceSkillProfile, } from './features/skills/replace-profile/endpoint.js';
|
|
41
|
-
import { registerDeleteSkillProfile, } from './features/skills/delete-profile/endpoint.js';
|
|
42
|
-
import { registerRegistrationOptions } from './features/auth/register/options/endpoint.js';
|
|
43
|
-
import { registerRegistrationVerification } from './features/auth/register/verify/endpoint.js';
|
|
44
|
-
import { registerLoginOptions } from './features/auth/login/options/endpoint.js';
|
|
45
|
-
import { registerLoginVerification } from './features/auth/login/verify/endpoint.js';
|
|
46
|
-
import { registerAuthStatus, registerDisabledAuthStatus, } from './features/auth/status/endpoint.js';
|
|
47
|
-
import { registerLogout } from './features/auth/logout/endpoint.js';
|
|
48
|
-
import { registerListAuthorizedDevices } from './features/auth/devices/list/endpoint.js';
|
|
49
|
-
import { registerRenameAuthorizedDevice } from './features/auth/devices/rename/endpoint.js';
|
|
50
|
-
import { registerRevokeAuthorizedDevice } from './features/auth/devices/revoke/endpoint.js';
|
|
51
|
-
import { registerCreateEnrollmentTicket } from './features/auth/enrollment-tickets/endpoint.js';
|
|
52
|
-
import { registerEnrollmentTicketStatus } from './features/auth/enrollment-tickets/status/endpoint.js';
|
|
53
|
-
import { registerCancelEnrollmentTicket } from './features/auth/enrollment-tickets/cancel/endpoint.js';
|
|
16
|
+
import { registerAuthorizationBoundary } from './platform/http/authorization-boundary.js';
|
|
17
|
+
import { registerSkillRoutes } from './features/skills/register-routes.js';
|
|
54
18
|
export async function buildApp(deps) {
|
|
55
|
-
|
|
19
|
+
// Keep the default boundary finite even when an endpoint forgot a narrower schema.
|
|
20
|
+
const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
|
|
21
|
+
app.addHook('onSend', async (_request, reply, payload) => {
|
|
22
|
+
reply.header('Content-Security-Policy', "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'");
|
|
23
|
+
reply.header('X-Frame-Options', 'DENY');
|
|
24
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
25
|
+
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
26
|
+
// WebAuthn is same-origin; explicitly allow only this origin's ceremony calls.
|
|
27
|
+
reply.header('Permissions-Policy', 'publickey-credentials-get=(self), publickey-credentials-create=(self)');
|
|
28
|
+
return payload;
|
|
29
|
+
});
|
|
56
30
|
await app.register(fastifyCookie);
|
|
57
31
|
if (deps.staticDir)
|
|
58
32
|
await app.register(fastifyStatic, { root: deps.staticDir });
|
|
@@ -63,186 +37,13 @@ export async function buildApp(deps) {
|
|
|
63
37
|
publicOrigin: deps.auth.relyingParty.publicOrigin,
|
|
64
38
|
});
|
|
65
39
|
registerGetHealth(app, deps.health);
|
|
66
|
-
|
|
67
|
-
registerRegistrationOptions(app, deps.auth);
|
|
68
|
-
registerRegistrationVerification(app, deps.auth);
|
|
69
|
-
registerLoginOptions(app, deps.auth);
|
|
70
|
-
registerLoginVerification(app, deps.auth);
|
|
71
|
-
registerAuthStatus(app, deps.auth);
|
|
72
|
-
registerLogout(app, deps.auth);
|
|
73
|
-
registerListAuthorizedDevices(app, deps.auth);
|
|
74
|
-
registerRenameAuthorizedDevice(app, deps.auth);
|
|
75
|
-
registerRevokeAuthorizedDevice(app, deps.auth);
|
|
76
|
-
registerCreateEnrollmentTicket(app, deps.auth);
|
|
77
|
-
registerEnrollmentTicketStatus(app, deps.auth);
|
|
78
|
-
registerCancelEnrollmentTicket(app, deps.auth);
|
|
79
|
-
}
|
|
80
|
-
else if (deps.passkeyAuthDisabled)
|
|
81
|
-
registerDisabledAuthStatus(app);
|
|
40
|
+
registerAuthRoutes(app, deps);
|
|
82
41
|
if (deps.bootstrap)
|
|
83
42
|
registerGetBootstrap(app, deps.bootstrap);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const session = deps.sessionRoutes
|
|
89
|
-
?.list?.()
|
|
90
|
-
.find((candidate) => candidate.threadId === threadId);
|
|
91
|
-
if (!session)
|
|
92
|
-
return null;
|
|
93
|
-
return {
|
|
94
|
-
...(session.model === undefined ? {} : { model: session.model }),
|
|
95
|
-
...(session.effectiveSkillSelection?.selectedProfileName === undefined
|
|
96
|
-
? {}
|
|
97
|
-
: { skillProfile: session.effectiveSkillSelection.selectedProfileName }),
|
|
98
|
-
...(session.lastOrgPlan === undefined
|
|
99
|
-
? {}
|
|
100
|
-
: { orgPlanFilename: session.lastOrgPlan.filename }),
|
|
101
|
-
};
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
if (deps.sessionRoutes) {
|
|
105
|
-
registerStartSession(app, {
|
|
106
|
-
...deps.sessionRoutes,
|
|
107
|
-
reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
|
|
108
|
-
});
|
|
109
|
-
registerGetSession(app, deps.sessionRoutes.find);
|
|
110
|
-
if (deps.sessionRoutes.list)
|
|
111
|
-
registerListSessions(app, { list: deps.sessionRoutes.list });
|
|
112
|
-
if (deps.sessionRoutes.readHistory)
|
|
113
|
-
registerGetHistory(app, {
|
|
114
|
-
find: deps.sessionRoutes.find,
|
|
115
|
-
read: deps.sessionRoutes.readHistory,
|
|
116
|
-
currentSequence: deps.sessionRoutes.currentSequence ?? (() => 0),
|
|
117
|
-
});
|
|
118
|
-
if (deps.sessionRoutes.startTurn)
|
|
119
|
-
registerStartTurn(app, {
|
|
120
|
-
find: deps.sessionRoutes.find,
|
|
121
|
-
start: deps.sessionRoutes.startTurn,
|
|
122
|
-
save: deps.sessionRoutes.save,
|
|
123
|
-
onStarted: deps.sessionRoutes.onTurnStarted,
|
|
124
|
-
});
|
|
125
|
-
if (deps.sessionRoutes.models)
|
|
126
|
-
registerSelectModel(app, {
|
|
127
|
-
find: deps.sessionRoutes.find,
|
|
128
|
-
models: () => deps.sessionRoutes.models.list(),
|
|
129
|
-
now: deps.sessionRoutes.now,
|
|
130
|
-
save: deps.sessionRoutes.save,
|
|
131
|
-
});
|
|
132
|
-
if (deps.sessionRoutes.interruptTurn)
|
|
133
|
-
registerInterruptTurn(app, {
|
|
134
|
-
find: deps.sessionRoutes.find,
|
|
135
|
-
interrupt: deps.sessionRoutes.interruptTurn,
|
|
136
|
-
});
|
|
137
|
-
if (deps.sessionRoutes.restore)
|
|
138
|
-
registerRestoreSession(app, {
|
|
139
|
-
find: deps.sessionRoutes.find,
|
|
140
|
-
restore: deps.sessionRoutes.restore,
|
|
141
|
-
save: deps.sessionRoutes.save,
|
|
142
|
-
idempotency: deps.sessionRoutes.idempotency,
|
|
143
|
-
});
|
|
144
|
-
if (deps.recentThreads && deps.sessionRoutes.promoteRecent)
|
|
145
|
-
registerPromoteRecentThread(app, {
|
|
146
|
-
list: () => deps.recentThreads.list(),
|
|
147
|
-
promote: deps.sessionRoutes.promoteRecent,
|
|
148
|
-
});
|
|
149
|
-
if (deps.sessionRoutes.release && deps.sessionRoutes.close)
|
|
150
|
-
registerReleaseSession(app, {
|
|
151
|
-
find: deps.sessionRoutes.find,
|
|
152
|
-
release: deps.sessionRoutes.release,
|
|
153
|
-
save: deps.sessionRoutes.save,
|
|
154
|
-
close: deps.sessionRoutes.close,
|
|
155
|
-
idempotency: deps.sessionRoutes.idempotency,
|
|
156
|
-
});
|
|
157
|
-
if (deps.sessionRoutes.remove && deps.sessionRoutes.close)
|
|
158
|
-
registerForgetSession(app, {
|
|
159
|
-
find: deps.sessionRoutes.find,
|
|
160
|
-
close: deps.sessionRoutes.close,
|
|
161
|
-
remove: deps.sessionRoutes.remove,
|
|
162
|
-
});
|
|
163
|
-
if (deps.sessionRoutes.close)
|
|
164
|
-
registerStopSession(app, {
|
|
165
|
-
find: deps.sessionRoutes.find,
|
|
166
|
-
stop: (session) => stopSession(session, deps.sessionRoutes.now()),
|
|
167
|
-
save: deps.sessionRoutes.save,
|
|
168
|
-
close: deps.sessionRoutes.close,
|
|
169
|
-
idempotency: deps.sessionRoutes.idempotency,
|
|
170
|
-
});
|
|
171
|
-
if (deps.sessionRoutes.replyInteraction && deps.interactions)
|
|
172
|
-
registerRespondInteraction(app, {
|
|
173
|
-
exists: (id) => deps.sessionRoutes.find(id) !== null,
|
|
174
|
-
resolve: (sessionId, requestId, resolvedAt) => deps.interactions.resolve(sessionId, requestId, resolvedAt),
|
|
175
|
-
validate: deps.interactions.validate,
|
|
176
|
-
reply: deps.sessionRoutes.replyInteraction,
|
|
177
|
-
resolved: deps.sessionRoutes.interactionResolved,
|
|
178
|
-
now: deps.sessionRoutes.now,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
if (deps.sessionEvents)
|
|
182
|
-
registerSessionEvents(app, {
|
|
183
|
-
...deps.sessionEvents,
|
|
184
|
-
...(deps.auth
|
|
185
|
-
? {
|
|
186
|
-
publicOrigin: deps.auth.relyingParty.publicOrigin,
|
|
187
|
-
authorized: (cookieHeader) => authorizationSessionDevice(cookieHeader, deps.auth) !== null,
|
|
188
|
-
}
|
|
189
|
-
: {}),
|
|
190
|
-
});
|
|
191
|
-
if (deps.planRoutes) {
|
|
192
|
-
registerGetPlan(app, deps.planRoutes);
|
|
193
|
-
registerClosePlan(app, deps.planRoutes);
|
|
194
|
-
}
|
|
195
|
-
if (deps.planMeasurementRoutes)
|
|
196
|
-
registerGetPlanMeasurement(app, deps.planMeasurementRoutes);
|
|
197
|
-
if (deps.gitSummary)
|
|
198
|
-
registerGetGitSummary(app, {
|
|
199
|
-
workspaces: deps.gitSummary.workspaces,
|
|
200
|
-
inspect: deps.gitSummary.inspect,
|
|
201
|
-
});
|
|
202
|
-
if (deps.gitSummary)
|
|
203
|
-
registerPushUpstream(app, {
|
|
204
|
-
workspaces: deps.gitSummary.workspaces,
|
|
205
|
-
inspect: deps.gitSummary.inspectForPush ?? deps.gitSummary.inspect,
|
|
206
|
-
push: deps.gitSummary.push,
|
|
207
|
-
idempotency: deps.gitSummary.idempotency,
|
|
208
|
-
});
|
|
209
|
-
if (deps.gitSummary)
|
|
210
|
-
registerRefreshGit(app, {
|
|
211
|
-
workspaces: deps.gitSummary.workspaces,
|
|
212
|
-
refresh: deps.gitSummary.refresh,
|
|
213
|
-
idempotency: deps.gitSummary.idempotency,
|
|
214
|
-
});
|
|
215
|
-
if (deps.gitSummary?.pull && deps.gitSummary.checkout) {
|
|
216
|
-
registerPullRebase(app, {
|
|
217
|
-
workspaces: deps.gitSummary.workspaces,
|
|
218
|
-
pull: deps.gitSummary.pull,
|
|
219
|
-
});
|
|
220
|
-
registerCheckoutBranch(app, {
|
|
221
|
-
workspaces: deps.gitSummary.workspaces,
|
|
222
|
-
checkout: deps.gitSummary.checkout,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
if (deps.gitSummary?.clone)
|
|
226
|
-
registerCloneRepository(app, {
|
|
227
|
-
workspaces: deps.gitSummary.workspaces,
|
|
228
|
-
clone: deps.gitSummary.clone,
|
|
229
|
-
});
|
|
230
|
-
if (deps.skills) {
|
|
231
|
-
registerListAvailableSkills(app, deps.skills);
|
|
232
|
-
registerListSkillProfiles(app, deps.skills);
|
|
233
|
-
registerReplaceSkillProfile(app, deps.skills);
|
|
234
|
-
registerDeleteSkillProfile(app, deps.skills);
|
|
235
|
-
}
|
|
43
|
+
registerSessionRoutes(app, deps);
|
|
44
|
+
registerPlanRoutes(app, deps);
|
|
45
|
+
registerGitRoutes(app, deps);
|
|
46
|
+
registerSkillRoutes(app, deps);
|
|
236
47
|
registerProblemHandler(app, Boolean(deps.staticDir));
|
|
237
48
|
return app;
|
|
238
49
|
}
|
|
239
|
-
function safeErrorLabel(error) {
|
|
240
|
-
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
241
|
-
const code = error.code;
|
|
242
|
-
if (typeof code === 'string' && /^[A-Z0-9_]+$/.test(code))
|
|
243
|
-
return code;
|
|
244
|
-
}
|
|
245
|
-
if (error instanceof Error && /^[A-Z0-9_]+$/.test(error.message))
|
|
246
|
-
return error.message;
|
|
247
|
-
return error instanceof Error ? error.name : 'UNKNOWN_ERROR';
|
|
248
|
-
}
|
|
@@ -41,6 +41,7 @@ import { CachedSkillCatalog } from './platform/skills/cached-skill-catalog.js';
|
|
|
41
41
|
import { compileSkillOverride } from './features/skills/model/skill-profile.js';
|
|
42
42
|
import { SupervisedPlanRegistry } from './features/plans/application/supervised-plan-registry.js';
|
|
43
43
|
import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
|
|
44
|
+
import { FilesystemWorkspacePlanCatalog } from './platform/plans/filesystem-workspace-plan-catalog.js';
|
|
44
45
|
import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
|
|
45
46
|
import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
|
|
46
47
|
import { createRelyingPartyConfig, } from './config.js';
|
|
@@ -73,6 +74,7 @@ export async function composeRelayApp(options) {
|
|
|
73
74
|
const idempotency = new SqliteIdempotencyStore(database);
|
|
74
75
|
const supervisedPlans = new SupervisedPlanRegistry();
|
|
75
76
|
const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
|
|
77
|
+
const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog();
|
|
76
78
|
const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
|
|
77
79
|
const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
|
|
78
80
|
const events = new SessionEventBus();
|
|
@@ -135,6 +137,7 @@ export async function composeRelayApp(options) {
|
|
|
135
137
|
supervisedPlans.accept(sessionId, update);
|
|
136
138
|
planMeasurementRefresh?.accept(sessionId, update);
|
|
137
139
|
if (update.kind === 'updated') {
|
|
140
|
+
void runtime?.syncThreadPlanName(sessionId, update.plan);
|
|
138
141
|
const occurredAt = new Date().toISOString();
|
|
139
142
|
const session = sessions.find(sessionId);
|
|
140
143
|
if (session) {
|
|
@@ -318,6 +321,7 @@ export async function composeRelayApp(options) {
|
|
|
318
321
|
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
319
322
|
},
|
|
320
323
|
},
|
|
324
|
+
workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
|
|
321
325
|
...(runtime
|
|
322
326
|
? {
|
|
323
327
|
planMeasurementRoutes: {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Process-local by design: the relay's authorization database is local state. */
|
|
2
|
+
export class ExpiringCeremonyAttemptGate {
|
|
3
|
+
limit;
|
|
4
|
+
windowMs;
|
|
5
|
+
#attempts = new Map();
|
|
6
|
+
constructor(limit = 8, windowMs = 10 * 60 * 1000) {
|
|
7
|
+
this.limit = limit;
|
|
8
|
+
this.windowMs = windowMs;
|
|
9
|
+
}
|
|
10
|
+
allow(key, now) {
|
|
11
|
+
const old = this.#attempts.get(key);
|
|
12
|
+
const expires = now.getTime() + this.windowMs;
|
|
13
|
+
if (!old || old.expires <= now.getTime()) {
|
|
14
|
+
this.#attempts.set(key, { count: 1, expires });
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (old.count >= this.limit)
|
|
18
|
+
return false;
|
|
19
|
+
old.count++;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -5,3 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export class AuthorizationDomainError extends Error {
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A finite persistent ceremony pool prevents abandoned browser attempts from
|
|
10
|
+
* indefinitely consuming local authorization state. Endpoints intentionally
|
|
11
|
+
* map this to their existing non-enumerating failure response.
|
|
12
|
+
*/
|
|
13
|
+
export class CeremonyCapacityError extends AuthorizationDomainError {
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const seconds = { gestalt_mobile_login: 600, gestalt_mobile_registration: 600, gestalt_mobile_session: 30 * 24 * 60 * 60 };
|
|
2
|
+
function options(name, origin) {
|
|
3
|
+
return { httpOnly: true, sameSite: 'strict', path: '/', secure: origin.startsWith('https://'), maxAge: seconds[name] };
|
|
4
|
+
}
|
|
5
|
+
export function setAuthCookie(reply, name, value, origin) {
|
|
6
|
+
reply.setCookie(name, value, options(name, origin));
|
|
7
|
+
}
|
|
8
|
+
export function clearAuthCookie(reply, name, origin) {
|
|
9
|
+
reply.clearCookie(name, options(name, origin));
|
|
10
|
+
}
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { sendProblem } from '../../../../platform/http/problem-reply.js';
|
|
3
3
|
import { passkeyCeremonyId } from '../../domain/identifiers.js';
|
|
4
|
+
import { CeremonyCapacityError } from '../../domain/errors.js';
|
|
5
|
+
import { setAuthCookie } from '../../http/cookies.js';
|
|
4
6
|
const lifetimeMs = 10 * 60 * 1000;
|
|
5
7
|
const responseSchema = z
|
|
6
8
|
.object({ options: z.object({ challenge: z.string().min(1) }).passthrough() })
|
|
7
9
|
.strict();
|
|
8
10
|
export function registerLoginOptions(app, deps) {
|
|
9
|
-
app.post('/api/auth/login/options', async (
|
|
11
|
+
app.post('/api/auth/login/options', { bodyLimit: 1024 }, async (request, reply) => {
|
|
12
|
+
if (!deps.ceremonyAttempts.allow(`login:${request.ip}`, deps.clock.now()))
|
|
13
|
+
return sendProblem(reply, 'AUTHENTICATION_FAILED', 400, 'Authentication could not be completed.');
|
|
10
14
|
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
|
+
return sendProblem(reply, 'AUTHENTICATION_FAILED', 400, 'Authentication could not be completed.');
|
|
15
16
|
const correlation = deps.random.bytes(32);
|
|
16
17
|
const challenge = deps.random.bytes(32);
|
|
17
18
|
if (correlation.length !== 32 || challenge.length !== 32)
|
|
@@ -25,20 +26,21 @@ export function registerLoginOptions(app, deps) {
|
|
|
25
26
|
}),
|
|
26
27
|
});
|
|
27
28
|
const token = passkeyCeremonyId(Buffer.from(correlation).toString('base64url'));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
29
|
+
try {
|
|
30
|
+
deps.repository.saveCeremony(token, {
|
|
31
|
+
purpose: 'authentication',
|
|
32
|
+
challenge,
|
|
33
|
+
expectedOrigin: deps.relyingParty.publicOrigin,
|
|
34
|
+
rpId: deps.relyingParty.rpId,
|
|
35
|
+
expiresAt: new Date(now.getTime() + lifetimeMs).toISOString(),
|
|
36
|
+
}, now.toISOString());
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof CeremonyCapacityError)
|
|
40
|
+
return sendProblem(reply, 'AUTHENTICATION_FAILED', 400, 'Authentication could not be completed.');
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
setAuthCookie(reply, 'gestalt_mobile_login', token, deps.relyingParty.publicOrigin);
|
|
42
44
|
return reply.send(options);
|
|
43
45
|
});
|
|
44
46
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { sendProblem } from '../../../../platform/http/problem-reply.js';
|
|
3
3
|
import { PasskeyVerificationError, } from '../../application/ports.js';
|
|
4
4
|
import { passkeyCeremonyId, webAuthnCredentialId } from '../../domain/identifiers.js';
|
|
5
|
+
import { clearAuthCookie, setAuthCookie } from '../../http/cookies.js';
|
|
5
6
|
const responseSchema = z
|
|
6
7
|
.object({
|
|
7
8
|
id: z.string().min(1),
|
|
@@ -22,13 +23,10 @@ const responseSchema = z
|
|
|
22
23
|
const requestSchema = z.object({ response: responseSchema }).strict();
|
|
23
24
|
const lifetimeMs = 30 * 24 * 60 * 60 * 1000;
|
|
24
25
|
export function registerLoginVerification(app, deps) {
|
|
25
|
-
app.post('/api/auth/login/verify', async (request, reply) => {
|
|
26
|
+
app.post('/api/auth/login/verify', { bodyLimit: 64 * 1024 }, async (request, reply) => {
|
|
26
27
|
const parsed = requestSchema.safeParse(request.body);
|
|
27
28
|
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.'));
|
|
29
|
+
const fail = () => sendProblem(reply, 'AUTHENTICATION_FAILED', 400, 'Authentication could not be completed.');
|
|
32
30
|
if (!parsed.success || typeof token !== 'string')
|
|
33
31
|
return fail();
|
|
34
32
|
const now = deps.clock.now();
|
|
@@ -69,14 +67,8 @@ export function registerLoginVerification(app, deps) {
|
|
|
69
67
|
});
|
|
70
68
|
if (!authenticated)
|
|
71
69
|
return fail();
|
|
72
|
-
reply
|
|
73
|
-
|
|
74
|
-
sameSite: 'strict',
|
|
75
|
-
path: '/',
|
|
76
|
-
secure: deps.relyingParty.publicOrigin.startsWith('https://'),
|
|
77
|
-
maxAge: 2592000,
|
|
78
|
-
});
|
|
79
|
-
reply.clearCookie('gestalt_mobile_login', { path: '/' });
|
|
70
|
+
setAuthCookie(reply, 'gestalt_mobile_session', session, deps.relyingParty.publicOrigin);
|
|
71
|
+
clearAuthCookie(reply, 'gestalt_mobile_login', deps.relyingParty.publicOrigin);
|
|
80
72
|
return reply.send({ status: 'authenticated' });
|
|
81
73
|
}
|
|
82
74
|
catch (error) {
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import { parseAuthorizationSessionId } from '../domain/identifiers.js';
|
|
2
|
+
import { clearAuthCookie } from '../http/cookies.js';
|
|
2
3
|
export function registerLogout(app, deps) {
|
|
3
4
|
app.post('/api/auth/logout', async (request, reply) => {
|
|
4
5
|
const token = request.cookies.gestalt_mobile_session;
|
|
5
6
|
const session = parseAuthorizationSessionId(token);
|
|
6
7
|
if (session !== null)
|
|
7
8
|
deps.repository.revokeSession(session, deps.clock.now().toISOString());
|
|
8
|
-
reply
|
|
9
|
-
path: '/',
|
|
10
|
-
httpOnly: true,
|
|
11
|
-
sameSite: 'strict',
|
|
12
|
-
secure: deps.relyingParty.publicOrigin.startsWith('https://'),
|
|
13
|
-
});
|
|
9
|
+
clearAuthCookie(reply, 'gestalt_mobile_session', deps.relyingParty.publicOrigin);
|
|
14
10
|
return reply.code(204).send();
|
|
15
11
|
});
|
|
16
12
|
}
|
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
12
|
-
const ticket = parsed.data.enrollmentTicket === undefined
|
|
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
|
|
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 &&
|
|
19
|
-
|
|
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({
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|
|
@@ -4,6 +4,7 @@ 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
|
+
import { clearAuthCookie, setAuthCookie } from '../../http/cookies.js';
|
|
7
8
|
const registrationResponseSchema = z.object({
|
|
8
9
|
id: z.string().min(1), rawId: z.string().min(1), type: z.literal('public-key'),
|
|
9
10
|
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(),
|
|
@@ -13,7 +14,7 @@ const requestSchema = z.object({ response: registrationResponseSchema, nickname:
|
|
|
13
14
|
const successSchema = z.object({ status: z.literal('authenticated') }).strict();
|
|
14
15
|
const sessionLifetimeMs = 30 * 24 * 60 * 60 * 1000;
|
|
15
16
|
export function registerRegistrationVerification(app, deps) {
|
|
16
|
-
app.post('/api/auth/register/verify', async (request, reply) => {
|
|
17
|
+
app.post('/api/auth/register/verify', { bodyLimit: 128 * 1024 }, async (request, reply) => {
|
|
17
18
|
const parsed = requestSchema.safeParse(request.body);
|
|
18
19
|
const token = request.cookies.gestalt_mobile_registration;
|
|
19
20
|
if (!parsed.success || typeof token !== 'string')
|
|
@@ -43,8 +44,8 @@ export function registerRegistrationVerification(app, deps) {
|
|
|
43
44
|
const outcome = deps.repository.completeRegistration({ ceremony: passkeyCeremonyId(token), now: now.toISOString(), device, session: { id: session, deviceId: device.id, expiresAt } });
|
|
44
45
|
if (outcome !== 'registered')
|
|
45
46
|
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
|
|
47
|
-
reply
|
|
47
|
+
setAuthCookie(reply, 'gestalt_mobile_session', session, deps.relyingParty.publicOrigin);
|
|
48
|
+
clearAuthCookie(reply, 'gestalt_mobile_registration', deps.relyingParty.publicOrigin);
|
|
48
49
|
return reply.code(201).send(successSchema.parse({ status: 'authenticated' }));
|
|
49
50
|
}
|
|
50
51
|
catch (error) {
|
|
@@ -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' });
|