gestalt-mobile 0.10.0 → 0.12.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 (42) hide show
  1. package/README.md +64 -14
  2. package/dist/client/assets/index-B7KwEjTE.js +19 -0
  3. package/dist/client/assets/index-CMs5ZzxJ.css +1 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +58 -3
  6. package/dist/server/server/cli.js +6 -0
  7. package/dist/server/server/composition.js +221 -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/plans/application/native-plan-alignment.js +39 -0
  28. package/dist/server/server/features/plans/domain/workspace-plan-catalog.js +6 -0
  29. package/dist/server/server/features/plans/get-workspace-plan/endpoint.js +23 -0
  30. package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +18 -0
  31. package/dist/server/server/features/sessions/session-events/endpoint.js +7 -2
  32. package/dist/server/server/platform/auth/simple-webauthn-adapter.js +75 -0
  33. package/dist/server/server/platform/auth/sqlite-authorization-store.js +478 -0
  34. package/dist/server/server/platform/codex/session-runtime.js +28 -0
  35. package/dist/server/server/platform/codex/thread-plan-name.js +23 -0
  36. package/dist/server/server/platform/events/websocket-heartbeat.js +5 -1
  37. package/dist/server/server/platform/http/authorization-boundary.js +60 -0
  38. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +29 -8
  39. package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +113 -0
  40. package/package.json +7 -2
  41. package/dist/client/assets/index-Cjxvsp9k.js +0 -12
  42. package/dist/client/assets/index-DrULMr7l.css +0 -1
@@ -3,12 +3,16 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export function installWebSocketHeartbeat(socket, intervalMs = 25_000) {
6
+ export function installWebSocketHeartbeat(socket, intervalMs = 25_000, isAuthorized = () => true) {
7
7
  let alive = true;
8
8
  socket.on('pong', () => {
9
9
  alive = true;
10
10
  });
11
11
  const interval = setInterval(() => {
12
+ if (!isAuthorized()) {
13
+ socket.terminate();
14
+ return;
15
+ }
12
16
  if (!alive) {
13
17
  socket.terminate();
14
18
  return;
@@ -0,0 +1,60 @@
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 { parseAuthorizationSessionId } from '../../features/auth/domain/identifiers.js';
7
+ import { problem } from './problem.js';
8
+ const publicApi = new Set([
9
+ 'GET /api/auth/status',
10
+ 'POST /api/auth/login/options',
11
+ 'POST /api/auth/login/verify',
12
+ 'POST /api/auth/logout',
13
+ 'POST /api/auth/register/options',
14
+ 'POST /api/auth/register/verify',
15
+ ]);
16
+ export function registerAuthorizationBoundary(app, deps) {
17
+ app.addHook('onRequest', async (request, reply) => {
18
+ if (!request.url.startsWith('/api/'))
19
+ return;
20
+ if (isUnsafe(request.method) && request.headers.origin !== deps.publicOrigin)
21
+ return reply
22
+ .code(403)
23
+ .type('application/problem+json')
24
+ .send(problem('ORIGIN_NOT_ALLOWED', 403, 'The request origin is not allowed.'));
25
+ if (publicApi.has(`${request.method} ${request.routeOptions.url}`))
26
+ return;
27
+ if (authorizationSessionDevice(request.headers.cookie, deps) !== null)
28
+ return;
29
+ return reply
30
+ .code(401)
31
+ .type('application/problem+json')
32
+ .send(problem('AUTH_REQUIRED', 401, 'Authentication is required.'));
33
+ });
34
+ }
35
+ /** Shared by HTTP and raw WebSocket upgrades; the opaque cookie is never logged. */
36
+ export function authorizationSessionDevice(cookieHeader, deps) {
37
+ const token = cookieValue(cookieHeader, 'gestalt_mobile_session');
38
+ const session = parseAuthorizationSessionId(token);
39
+ return session === null
40
+ ? null
41
+ : deps.repository.sessionDevice(session, deps.clock.now().toISOString());
42
+ }
43
+ function cookieValue(header, name) {
44
+ if (!header)
45
+ return undefined;
46
+ let found;
47
+ for (const part of header.split(';')) {
48
+ const [key, ...value] = part.trim().split('=');
49
+ if (key !== name)
50
+ continue;
51
+ const candidate = value.join('=');
52
+ if (!candidate || found !== undefined)
53
+ return undefined;
54
+ found = candidate;
55
+ }
56
+ return found;
57
+ }
58
+ function isUnsafe(method) {
59
+ return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
60
+ }
@@ -50,13 +50,21 @@ export class FilesystemPlanStatusSource {
50
50
  async remove(sessionId, identity) {
51
51
  const statusPath = this.activeStatusPaths.get(sessionId);
52
52
  const signal = statusPath ? await this.readStatusForRollback(statusPath) : undefined;
53
- if (statusPath)
54
- await this.statusRemovalFilesystem.rm(statusPath, { force: true });
53
+ if (statusPath) {
54
+ try {
55
+ await this.statusRemovalFilesystem.rm(statusPath, { force: true });
56
+ }
57
+ catch (error) {
58
+ this.leases.get(sessionId)?.allowRepeatedUpdate();
59
+ throw error;
60
+ }
61
+ }
55
62
  try {
56
63
  if (identity)
57
64
  await this.dismiss(sessionId, identity);
58
65
  }
59
66
  catch (error) {
67
+ this.leases.get(sessionId)?.allowRepeatedUpdate();
60
68
  if (signal !== undefined && statusPath)
61
69
  await this.restoreStatus(statusPath, signal);
62
70
  throw error;
@@ -147,6 +155,7 @@ class ActiveLease {
147
155
  debounce;
148
156
  closed = false;
149
157
  activeStatusPath;
158
+ lastEmittedUpdate;
150
159
  constructor(statusDirectory, sessionId, workspacePath, listener, planReadFilesystem, isDismissed, onActiveStatusPath, onClose) {
151
160
  this.statusDirectory = statusDirectory;
152
161
  this.sessionId = sessionId;
@@ -164,7 +173,7 @@ class ActiveLease {
164
173
  void this.consumeChanges();
165
174
  }
166
175
  catch {
167
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
176
+ this.emitUnavailable();
168
177
  }
169
178
  }
170
179
  close() {
@@ -180,6 +189,9 @@ class ActiveLease {
180
189
  if (this.activeStatusPath)
181
190
  await rm(this.activeStatusPath, { force: true });
182
191
  }
192
+ allowRepeatedUpdate() {
193
+ this.lastEmittedUpdate = undefined;
194
+ }
183
195
  async consumeChanges() {
184
196
  try {
185
197
  for await (const event of this.watcher) {
@@ -197,7 +209,7 @@ class ActiveLease {
197
209
  }
198
210
  catch {
199
211
  if (!this.closed)
200
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
212
+ this.emitUnavailable();
201
213
  }
202
214
  }
203
215
  pendingStatusPath;
@@ -233,7 +245,7 @@ class ActiveLease {
233
245
  }
234
246
  catch {
235
247
  if (!this.closed)
236
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
248
+ this.emitUnavailable();
237
249
  }
238
250
  }
239
251
  async refresh(statusPath, parsedSignal) {
@@ -260,13 +272,18 @@ class ActiveLease {
260
272
  const previousStatusPath = this.activeStatusPath;
261
273
  this.activeStatusPath = statusPath;
262
274
  this.onActiveStatusPath(statusPath);
263
- this.listener({ kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason });
275
+ const update = { kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason };
276
+ const serializedUpdate = JSON.stringify(update);
277
+ if (serializedUpdate !== this.lastEmittedUpdate) {
278
+ this.lastEmittedUpdate = serializedUpdate;
279
+ this.listener(update);
280
+ }
264
281
  if (previousStatusPath && previousStatusPath !== statusPath)
265
282
  await rm(previousStatusPath, { force: true }).catch(() => { });
266
283
  }
267
284
  }
268
285
  else if (!this.closed) {
269
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
286
+ this.emitUnavailable();
270
287
  }
271
288
  }
272
289
  catch (error) {
@@ -275,9 +292,13 @@ class ActiveLease {
275
292
  statusPath !== this.activeStatusPath)
276
293
  return;
277
294
  if (!this.closed)
278
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
295
+ this.emitUnavailable();
279
296
  }
280
297
  }
298
+ emitUnavailable() {
299
+ this.lastEmittedUpdate = undefined;
300
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
301
+ }
281
302
  }
282
303
  function parseSignal(source) {
283
304
  try {
@@ -0,0 +1,113 @@
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 { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
7
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
8
+ import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
9
+ const maximumPlans = 100;
10
+ const maximumBytes = 1_048_576;
11
+ /**
12
+ * Bounded, passive access to direct `.gestalt/*.org` children. Every path is
13
+ * revalidated after discovery so a workspace cannot escape through symlinks or
14
+ * replacement races.
15
+ */
16
+ export class FilesystemWorkspacePlanCatalog {
17
+ filesystem;
18
+ constructor(filesystem = { lstat, readdir, readFile, realpath, stat }) {
19
+ this.filesystem = filesystem;
20
+ }
21
+ async list(workspacePath) {
22
+ const directory = await this.directory(workspacePath);
23
+ if (!directory)
24
+ return [];
25
+ let names;
26
+ try {
27
+ names = (await this.filesystem.readdir(directory, { encoding: 'utf8' }))
28
+ .filter((name) => isPlanName(name))
29
+ .sort((left, right) => left.localeCompare(right));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ const entries = [];
35
+ for (const name of names.slice(0, maximumPlans)) {
36
+ const result = await this.readFromDirectory(workspacePath, directory, name);
37
+ if (result.kind !== 'available')
38
+ continue;
39
+ entries.push(toEntry(name, result.plan));
40
+ }
41
+ return entries;
42
+ }
43
+ async read(workspacePath, planName) {
44
+ if (!isPlanName(planName))
45
+ return { kind: 'missing' };
46
+ const directory = await this.directory(workspacePath);
47
+ if (!directory)
48
+ return { kind: 'missing' };
49
+ return this.readFromDirectory(workspacePath, directory, planName);
50
+ }
51
+ async directory(workspacePath) {
52
+ if (!isAbsolute(workspacePath))
53
+ return null;
54
+ const workspace = resolve(workspacePath);
55
+ const directory = join(workspace, '.gestalt');
56
+ try {
57
+ const metadata = await this.filesystem.lstat(directory);
58
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
59
+ return null;
60
+ const [canonicalWorkspace, canonicalDirectory] = await Promise.all([
61
+ this.filesystem.realpath(workspace),
62
+ this.filesystem.realpath(directory),
63
+ ]);
64
+ return dirname(canonicalDirectory) === canonicalWorkspace ? canonicalDirectory : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ async readFromDirectory(workspacePath, directory, planName) {
71
+ const path = join(directory, planName);
72
+ try {
73
+ const before = await this.filesystem.lstat(path);
74
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maximumBytes)
75
+ return { kind: 'unavailable' };
76
+ const canonical = await this.filesystem.realpath(path);
77
+ if (dirname(canonical) !== directory || basename(canonical) !== planName)
78
+ return { kind: 'unavailable' };
79
+ const source = await this.filesystem.readFile(canonical, 'utf8');
80
+ const after = await this.filesystem.stat(canonical);
81
+ if (!after.isFile() || after.size !== before.size || after.ino !== before.ino || after.dev !== before.dev)
82
+ return { kind: 'unavailable' };
83
+ const parsed = parseSupervisedPlan({ source, planPath: canonical, workspacePath });
84
+ return parsed.kind === 'available' ? parsed : { kind: 'unavailable' };
85
+ }
86
+ catch (error) {
87
+ return error.code === 'ENOENT'
88
+ ? { kind: 'missing' }
89
+ : { kind: 'unavailable' };
90
+ }
91
+ }
92
+ }
93
+ function isPlanName(value) {
94
+ return (value.endsWith('.org') &&
95
+ value !== '.org' &&
96
+ !isAbsolute(value) &&
97
+ basename(value) === value &&
98
+ !value.includes('\\') &&
99
+ value !== '.' &&
100
+ value !== '..');
101
+ }
102
+ function toEntry(planName, plan) {
103
+ return {
104
+ planName,
105
+ title: plan.title,
106
+ ...(plan.subtitle === undefined ? {} : { subtitle: plan.subtitle }),
107
+ ...(plan.date === undefined ? {} : { date: plan.date }),
108
+ ...(plan.keywords === undefined ? {} : { keywords: plan.keywords }),
109
+ totalSteps: plan.totalSteps,
110
+ doneSteps: plan.doneSteps,
111
+ allDone: plan.allDone,
112
+ };
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "dev": "node scripts/dev.mjs",
36
- "dev:server": "tsx watch src/server/main.ts -- --cwd ..",
36
+ "dev:server": "tsx watch src/server/main.ts -- --cwd .. --public-origin http://localhost:5173",
37
37
  "start": "node dist/server/server/main.js",
38
38
  "dev:client": "vite",
39
39
  "build": "npm run build:server && npm run build:client",
@@ -55,8 +55,12 @@
55
55
  "license:apply": "node scripts/apply-license-headers.mjs"
56
56
  },
57
57
  "dependencies": {
58
+ "@fastify/cookie": "11.1.2",
58
59
  "@fastify/static": "^10.1.0",
60
+ "@simplewebauthn/browser": "13.3.0",
61
+ "@simplewebauthn/server": "13.3.2",
59
62
  "fastify": "^5.10.0",
63
+ "qrcode": "^1.5.4",
60
64
  "ws": "^8.21.0",
61
65
  "yaml": "^2.9.0",
62
66
  "zod": "^4.4.3"
@@ -66,6 +70,7 @@
66
70
  "@sveltejs/vite-plugin-svelte": "^7.2.0",
67
71
  "@testing-library/svelte": "^5.4.2",
68
72
  "@types/node": "^24.13.3",
73
+ "@types/qrcode": "^1.5.6",
69
74
  "@types/ws": "^8.18.1",
70
75
  "eslint": "^10.7.0",
71
76
  "jsdom": "^29.1.1",