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.
- package/README.md +64 -14
- package/dist/client/assets/index-B7KwEjTE.js +19 -0
- package/dist/client/assets/index-CMs5ZzxJ.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +58 -3
- package/dist/server/server/cli.js +6 -0
- package/dist/server/server/composition.js +221 -150
- package/dist/server/server/config.js +64 -2
- package/dist/server/server/features/auth/application/ports.js +8 -0
- package/dist/server/server/features/auth/devices/application.js +26 -0
- package/dist/server/server/features/auth/devices/list/endpoint.js +12 -0
- package/dist/server/server/features/auth/devices/rename/endpoint.js +37 -0
- package/dist/server/server/features/auth/devices/revoke/endpoint.js +48 -0
- package/dist/server/server/features/auth/domain/authorization.js +101 -0
- package/dist/server/server/features/auth/domain/device-nickname.js +13 -0
- package/dist/server/server/features/auth/domain/errors.js +7 -0
- package/dist/server/server/features/auth/domain/identifiers.js +45 -0
- package/dist/server/server/features/auth/enrollment-tickets/cancel/endpoint.js +14 -0
- package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +17 -0
- package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +13 -0
- package/dist/server/server/features/auth/login/options/endpoint.js +44 -0
- package/dist/server/server/features/auth/login/verify/endpoint.js +88 -0
- package/dist/server/server/features/auth/logout/endpoint.js +16 -0
- package/dist/server/server/features/auth/register/options/endpoint.js +34 -0
- package/dist/server/server/features/auth/register/verify/endpoint.js +56 -0
- package/dist/server/server/features/auth/status/endpoint.js +24 -0
- package/dist/server/server/features/plans/application/native-plan-alignment.js +39 -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/sessions/session-events/endpoint.js +7 -2
- package/dist/server/server/platform/auth/simple-webauthn-adapter.js +75 -0
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +478 -0
- package/dist/server/server/platform/codex/session-runtime.js +28 -0
- package/dist/server/server/platform/codex/thread-plan-name.js +23 -0
- package/dist/server/server/platform/events/websocket-heartbeat.js +5 -1
- package/dist/server/server/platform/http/authorization-boundary.js +60 -0
- package/dist/server/server/platform/plans/filesystem-plan-status-source.js +29 -8
- package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +113 -0
- package/package.json +7 -2
- package/dist/client/assets/index-Cjxvsp9k.js +0 -12
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|