@sigrun/sigrun 0.1.6 → 0.2.1
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/CHANGELOG.md +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +259 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +211 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +213 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +152 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/notifications.js +21 -4
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +274 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +19 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
package/dist/server/index.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* enumeration; the storage scan is authoritative.
|
|
32
32
|
*/
|
|
33
33
|
import Fastify from "fastify";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { LoginFlows } from "./login/flows.js";
|
|
34
36
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
35
37
|
import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
|
|
36
38
|
import { effectiveMethods } from "../config.js";
|
|
@@ -41,7 +43,9 @@ import { storeAgentChange } from "./helpers.js";
|
|
|
41
43
|
import { registerCoreOwnershipParticipant } from "./ownership.js";
|
|
42
44
|
import { createHostBridge } from "./host-bridge.js";
|
|
43
45
|
import { registerJobsOwnershipParticipant } from "../jobs/transfer.js";
|
|
46
|
+
import { registerAdminRoutes } from "./routes/admin.js";
|
|
44
47
|
import { registerAuthRoutes } from "./routes/auth.js";
|
|
48
|
+
import { registerLoginRoutes } from "./routes/login.js";
|
|
45
49
|
import { registerConversationRoutes } from "./routes/conversations.js";
|
|
46
50
|
import { registerEventRoutes } from "./routes/events.js";
|
|
47
51
|
import { registerExtensionRoutes as registerExtensionSettingsRoutes } from "./routes/extensions.js";
|
|
@@ -50,17 +54,26 @@ import { registerExtAssetRoutes } from "./routes/ext-assets.js";
|
|
|
50
54
|
import { registerFileRoutes } from "./routes/files.js";
|
|
51
55
|
import { registerJobRoutes } from "./routes/jobs.js";
|
|
52
56
|
import { registerInteractionRoutes } from "./routes/interactions.js";
|
|
57
|
+
import { registerMeRoutes } from "./routes/me.js";
|
|
53
58
|
import { registerMetaRoutes } from "./routes/meta.js";
|
|
54
59
|
import { registerMetricsRoutes } from "./routes/metrics.js";
|
|
55
60
|
import { registerOnboardingRoutes } from "./routes/onboarding.js";
|
|
56
61
|
import { registerProbeRoutes } from "./routes/probes.js";
|
|
57
62
|
import { registerProviderRoutes } from "./routes/providers.js";
|
|
58
63
|
import { registerSettingsRoutes } from "./routes/settings.js";
|
|
64
|
+
import { registerSettingsSectionsRoutes } from "./routes/settings-sections.js";
|
|
59
65
|
import { registerStreamRoutes } from "./routes/stream.js";
|
|
60
66
|
import { startNotifications } from "./notifications.js";
|
|
67
|
+
import { selfPackageRoot } from "../harness/packages.js";
|
|
61
68
|
import { registerSubmissionRoutes } from "./routes/submissions.js";
|
|
62
69
|
import { registerSurfaceRoutes } from "./routes/surfaces.js";
|
|
70
|
+
import { subscribeChatSurfaceChanged } from "../harness/blocks/tools.js";
|
|
71
|
+
import { loadOrCreateKey, signUrl } from "./images/sign.js";
|
|
72
|
+
import { ImageCache } from "./images/cache.js";
|
|
73
|
+
import { registerImageRoutes } from "./routes/images.js";
|
|
63
74
|
import { SurfaceService } from "./surfaces/service.js";
|
|
75
|
+
import { settingsSurfaceFor } from "./surfaces/settings-surfaces.js";
|
|
76
|
+
import { CORE_SETTINGS_SECTIONS } from "../settings/core-sections.js";
|
|
64
77
|
import { registerUserRoutes } from "./routes/users.js";
|
|
65
78
|
import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
66
79
|
/**
|
|
@@ -79,8 +92,10 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
|
79
92
|
* checks, the public surface, the env service token, the setup gate,
|
|
80
93
|
* then the configured methods — the resolved principal stamps
|
|
81
94
|
* `SessionMeta.owner` at create/fork/import.
|
|
82
|
-
* @param options.listenHost the host the server will bind (
|
|
83
|
-
* `"0.0.0.0"`
|
|
95
|
+
* @param options.listenHost the host the server will bind (fallback
|
|
96
|
+
* `"0.0.0.0"` when omitted; `serve()` passes its ONE resolved host —
|
|
97
|
+
* `options.host` → `SIGRUN_LISTEN_HOST` → the dual-stack `::`, serving
|
|
98
|
+
* spec §1); the pipeline's Host check and the default method list
|
|
84
99
|
* (`effectiveMethods`) both derive from it.
|
|
85
100
|
* @param contributedBundles the extension-contributed webui-bundle registry
|
|
86
101
|
* (build-and-webui Task 6 threads it here from boot; the serve-time
|
|
@@ -93,7 +108,17 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
|
93
108
|
* env-less boot).
|
|
94
109
|
*/
|
|
95
110
|
export async function createServer(opened, config, contributedBundles = [], options) {
|
|
96
|
-
const app = Fastify({
|
|
111
|
+
const app = Fastify({
|
|
112
|
+
logger: false,
|
|
113
|
+
// U7 Task 3: the image route's GET `src=data:` form carries up to
|
|
114
|
+
// 192 KiB of base64 in the query string, which counts toward
|
|
115
|
+
// Node's 16 KiB default header cap and would be 431-rejected at
|
|
116
|
+
// the HTTP layer before the route's own §11.1 413 could answer.
|
|
117
|
+
// 256 KiB admits the cap-sized URL; the route enforces the real
|
|
118
|
+
// limit (192 KiB) itself. The POST form (the body path) stays the
|
|
119
|
+
// recommended shape for long data URLs.
|
|
120
|
+
http: { maxHeaderSize: 256 * 1024 },
|
|
121
|
+
});
|
|
97
122
|
const listenHost = options?.listenHost ?? "0.0.0.0";
|
|
98
123
|
const env = options?.env ?? {};
|
|
99
124
|
// The one body-limit error shape (spec §6.2, W2): Fastify's
|
|
@@ -162,13 +187,38 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
162
187
|
// effective method list), and the boot env (the settings routes'
|
|
163
188
|
// `effective` block reads it).
|
|
164
189
|
const sse = new SseRegistry();
|
|
190
|
+
// The chat-surface seam's unsubscribe (filled in just below, after
|
|
191
|
+
// the events hub exists to publish onto; never reassigned after).
|
|
192
|
+
let unsubscribeChatSurfaceChanged = () => { };
|
|
165
193
|
// Decorated for the shutdown path (serve.ts): fastify's own drain waits
|
|
166
194
|
// on in-flight requests, so the SSE registry must be endable from the
|
|
167
195
|
// OUTSIDE before app.close() — a decoration is the typed, discoverable
|
|
168
196
|
// way to hand that handle up without widening ServerContext's consumers.
|
|
169
197
|
app.decorate("sse", sse);
|
|
198
|
+
// The image-proxy signer (U7 Task 1, spec §11.2): the key is loaded
|
|
199
|
+
// (created at first boot) from the state dir — deleting the file
|
|
200
|
+
// rotates it — and every surface tree/data answer rewrites https
|
|
201
|
+
// image sources through it at serve time.
|
|
202
|
+
const imageKey = await loadOrCreateKey(config.stateDir);
|
|
203
|
+
const imageSign = (u) => signUrl(imageKey, u, Date.now());
|
|
204
|
+
// The proxy cache + fetch deps (U7 Task 3, spec §11.2): the on-disk
|
|
205
|
+
// LRU under `<cacheDir>/images/`; the deps are the real resolver +
|
|
206
|
+
// clock unless a test injects its own.
|
|
207
|
+
const imageCache = new ImageCache(config.cacheDir);
|
|
208
|
+
const imageDeps = {
|
|
209
|
+
resolve: async (host) => {
|
|
210
|
+
const { lookup } = await import("node:dns/promises");
|
|
211
|
+
const addresses = await lookup(host, { all: true, verbatim: true });
|
|
212
|
+
return addresses.map((entry) => entry.address);
|
|
213
|
+
},
|
|
214
|
+
now: Date.now,
|
|
215
|
+
...options?.imageFetch,
|
|
216
|
+
};
|
|
170
217
|
const surfacesService = new SurfaceService(opened.extensionSurfaces, {
|
|
171
218
|
maxUploadBytes: config.maxUploadBytes,
|
|
219
|
+
// Signs https image URLs into expiring proxy URLs (U7 Task 1,
|
|
220
|
+
// spec §11.2) on every tree/data response.
|
|
221
|
+
imageSign,
|
|
172
222
|
// Task 4: bumpRev (actions AND the host seam's changed()) fans the
|
|
173
223
|
// surface.changed event out to the hub; the events route's
|
|
174
224
|
// visibility case delivers it to watching streams only.
|
|
@@ -180,21 +230,97 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
180
230
|
opened.events.publish(event);
|
|
181
231
|
},
|
|
182
232
|
});
|
|
233
|
+
// The settings sections (UI-blocks U4 Task 3, spec §13.3): each
|
|
234
|
+
// declared section adapts to a settings-placement surface served under
|
|
235
|
+
// the declaring extension's own `/v1/ext/<slug>/surfaces/` mount. The
|
|
236
|
+
// save handlers close over the service (the optimistic-concurrency
|
|
237
|
+
// rev read), so the adaptation attaches AFTER the service exists.
|
|
238
|
+
surfacesService.attachSettingsSurfaces(opened.extensionSettingsSections.map((binding) => ({
|
|
239
|
+
extension: binding.extension,
|
|
240
|
+
defs: binding.defs.map((section) => settingsSurfaceFor(binding.extension, section, {
|
|
241
|
+
stores: opened.extensionStores,
|
|
242
|
+
publishSettingsChanged: opened.publishSettingsChanged,
|
|
243
|
+
service: surfacesService,
|
|
244
|
+
})),
|
|
245
|
+
})));
|
|
246
|
+
// The core settings sections (U4 Task 4, spec §13.4): the same
|
|
247
|
+
// renderer under the reserved `core` slug — core and extensions run
|
|
248
|
+
// ONE code path. The core saves write the core SettingsStore (dotted
|
|
249
|
+
// names land at their nested paths, apply-typed), not the extension
|
|
250
|
+
// settings dir.
|
|
251
|
+
surfacesService.attachSettingsSurfaces([
|
|
252
|
+
{
|
|
253
|
+
extension: "core",
|
|
254
|
+
defs: CORE_SETTINGS_SECTIONS.map((section) => settingsSurfaceFor("core", section, {
|
|
255
|
+
stores: opened.extensionStores,
|
|
256
|
+
publishSettingsChanged: opened.publishSettingsChanged,
|
|
257
|
+
service: surfacesService,
|
|
258
|
+
core: {
|
|
259
|
+
settings: opened.settings,
|
|
260
|
+
bootSnapshot: opened.settingsBootSnapshot,
|
|
261
|
+
env,
|
|
262
|
+
// The Tools section's default-tools options (the boot
|
|
263
|
+
// registry's tool names, deduped — the roster the
|
|
264
|
+
// checkbox list edits against).
|
|
265
|
+
toolRoster: [
|
|
266
|
+
...new Set(opened.registry
|
|
267
|
+
.snapshot()
|
|
268
|
+
.tools()
|
|
269
|
+
.map((entry) => entry.tool.name)),
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
})),
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
183
275
|
app.decorate("surfaces", surfacesService);
|
|
276
|
+
// The chat-block change seam (UI-blocks U5 Task 2, spec §8.2):
|
|
277
|
+
// show_blocks/update_blocks AND the user-edit route publish
|
|
278
|
+
// `surface.changed` through the Task-1 in-process seam; binding it
|
|
279
|
+
// here forwards every event onto the hub, where the events route's
|
|
280
|
+
// exhaustive switch gives the CHAT shape the conversation-scoped
|
|
281
|
+
// delivery path (SessionMeta re-snapshot + accessOf per event).
|
|
282
|
+
unsubscribeChatSurfaceChanged = subscribeChatSurfaceChanged((event) => {
|
|
283
|
+
// SAFETY: the chat event IS the wire union's second surface.changed
|
|
284
|
+
// member (same declaration, re-exported through the seam type) —
|
|
285
|
+
// the publish types on the union the events switch discriminates.
|
|
286
|
+
opened.events.publish(event);
|
|
287
|
+
});
|
|
288
|
+
const roots = opened.extensionStores.roots;
|
|
184
289
|
const ctx = {
|
|
185
290
|
opened,
|
|
186
291
|
config,
|
|
292
|
+
managedPaths: {
|
|
293
|
+
roots,
|
|
294
|
+
storagePath: config.storagePath,
|
|
295
|
+
coreRoot: selfPackageRoot() ?? process.cwd(),
|
|
296
|
+
userAreas: (user) => join(roots.dataDir, "users", user),
|
|
297
|
+
},
|
|
187
298
|
rootId,
|
|
188
299
|
webuiBundle,
|
|
189
300
|
commandCatalog,
|
|
190
301
|
listenHost,
|
|
191
302
|
methods,
|
|
303
|
+
// The listener record (release-readiness Task 1, serving spec §2):
|
|
304
|
+
// host + port are the server construction's inputs; `families`
|
|
305
|
+
// starts EMPTY (never listened) and `serve()` fills it in place
|
|
306
|
+
// with the measured families after `app.listen` resolves — the
|
|
307
|
+
// meta route reads whichever state exists at request time, never
|
|
308
|
+
// probing itself. A hand-built test server serves intent only.
|
|
309
|
+
listener: { host: listenHost, port: config.port, families: [] },
|
|
192
310
|
env,
|
|
311
|
+
// U4 §13.2: the harness's settings-changed fan-out (hub publish +
|
|
312
|
+
// extension onSettingsChanged + the scheduler re-arm).
|
|
313
|
+
publishSettingsChanged: opened.publishSettingsChanged,
|
|
193
314
|
watchersByConversation: new Map(),
|
|
194
315
|
surfaces: surfacesService,
|
|
195
316
|
sse,
|
|
317
|
+
imageProxy: { cache: imageCache, deps: imageDeps, key: imageKey },
|
|
196
318
|
...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
|
|
319
|
+
...(options?.onRestart === undefined
|
|
320
|
+
? {}
|
|
321
|
+
: { requestRestart: options.onRestart }),
|
|
197
322
|
};
|
|
323
|
+
app.decorate("sigrunContext", ctx);
|
|
198
324
|
// The host bridge (W5 Task 5, spec §9.5/§9.7): ONE bridge over this
|
|
199
325
|
// server's context, bound onto every seam host — the extension-facing
|
|
200
326
|
// conversations/users/access/identity surface then runs the SAME
|
|
@@ -202,7 +328,11 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
202
328
|
// implementation). Bound here, after the context exists and before any
|
|
203
329
|
// route or extension code can serve a request; calls issued earlier
|
|
204
330
|
// (a factory body) have been waiting on exactly this assignment.
|
|
205
|
-
|
|
331
|
+
const hostBridge = createHostBridge(ctx);
|
|
332
|
+
opened.bindHostBridge(hostBridge);
|
|
333
|
+
// Decorated like `sse`/`surfaces`: the bridge is the server-owned
|
|
334
|
+
// handle tests (and only tests) reach the W5 bridge path through.
|
|
335
|
+
app.decorate("hostBridge", hostBridge);
|
|
206
336
|
// The notifications surface (W5 Task 6, spec §9.6): one hub
|
|
207
337
|
// subscription turning host events into per-recipient
|
|
208
338
|
// NotificationEvents for every collected notifier contribution,
|
|
@@ -217,10 +347,16 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
217
347
|
// `request.principal` before any route runs.
|
|
218
348
|
registerAuthPipeline(app, ctx);
|
|
219
349
|
registerAuthRoutes(app, ctx);
|
|
350
|
+
registerLoginRoutes(app, ctx, {
|
|
351
|
+
...(options?.loginResolver === undefined ? {} : { resolver: options.loginResolver }),
|
|
352
|
+
...(options?.loginNow === undefined ? {} : { flows: new LoginFlows(options.loginNow) }),
|
|
353
|
+
});
|
|
220
354
|
registerOnboardingRoutes(app, ctx);
|
|
221
355
|
registerUserRoutes(app, ctx);
|
|
356
|
+
registerMeRoutes(app, ctx);
|
|
222
357
|
registerProbeRoutes(app, ctx);
|
|
223
358
|
registerMetaRoutes(app, ctx);
|
|
359
|
+
registerAdminRoutes(app, ctx);
|
|
224
360
|
registerMetricsRoutes(app, ctx);
|
|
225
361
|
registerConversationRoutes(app, ctx);
|
|
226
362
|
registerStreamRoutes(app, ctx);
|
|
@@ -230,6 +366,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
230
366
|
registerFileRoutes(app, ctx);
|
|
231
367
|
registerProviderRoutes(app, ctx);
|
|
232
368
|
registerSettingsRoutes(app, ctx);
|
|
369
|
+
registerSettingsSectionsRoutes(app, ctx);
|
|
233
370
|
registerExtensionSettingsRoutes(app, ctx);
|
|
234
371
|
// The extension HTTP routes (W5 Task 2, spec §9.2): every collected
|
|
235
372
|
// `routes` contribution mounted at `/v1/ext/<slug>/<path>` behind the
|
|
@@ -244,6 +381,10 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
244
381
|
// `/v1/ext/<slug>/assets/*` serves the package's declared
|
|
245
382
|
// `sigrun.assets` dir, realpath-contained, sniffed content types.
|
|
246
383
|
registerExtAssetRoutes(app, ctx);
|
|
384
|
+
// The image-proxy routes (UI-blocks U7 Task 3, spec §11.2): signed
|
|
385
|
+
// remote fetches through the on-disk LRU, the `src=` asset/data form,
|
|
386
|
+
// and the POST long-data-URL form.
|
|
387
|
+
registerImageRoutes(app, ctx);
|
|
247
388
|
// The jobs routes (W4 Task 4, spec §8.4): the access-scoped CRUD +
|
|
248
389
|
// run-now + history surface over `OpenedHarness.jobs` (Task 3's
|
|
249
390
|
// service). Hidden-is-404 per §5.9; `file:`/`ext:` ids are immutable
|
|
@@ -263,5 +404,12 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
263
404
|
// next boot's reconciliation warns (the declared owner no longer
|
|
264
405
|
// exists — the job runs ownerless until the file is edited).
|
|
265
406
|
registerJobsOwnershipParticipant(ctx);
|
|
407
|
+
// The chat-surface seam's teardown: the subscription lives for the
|
|
408
|
+
// app's lifetime (the module seam is process-global), but the
|
|
409
|
+
// unsubscribe is kept so a future embedder (a second createServer
|
|
410
|
+
// over one harness in tests) can detach a bound server cleanly.
|
|
411
|
+
app.addHook("onClose", async () => {
|
|
412
|
+
unsubscribeChatSurfaceChanged();
|
|
413
|
+
});
|
|
266
414
|
return app;
|
|
267
415
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const FLOW_TTL_MS = 600_000;
|
|
3
|
+
/** Generous per-server ceiling; oldest entries are evicted under pressure. */
|
|
4
|
+
export const MAX_ACTIVE_LOGIN_FLOWS = 10_000;
|
|
5
|
+
/** One short-lived random browser/state credential. */
|
|
6
|
+
function key() {
|
|
7
|
+
return randomBytes(32).toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* In-memory, per-server login flows. `take` is atomic and single-use: a
|
|
11
|
+
* replay, a different browser key, or an expired entry returns undefined.
|
|
12
|
+
*/
|
|
13
|
+
export class LoginFlows {
|
|
14
|
+
#now;
|
|
15
|
+
#flows = new Map();
|
|
16
|
+
#consumed = new Map();
|
|
17
|
+
#pending = new Map();
|
|
18
|
+
constructor(now = Date.now) {
|
|
19
|
+
this.#now = now;
|
|
20
|
+
}
|
|
21
|
+
get size() {
|
|
22
|
+
return this.#flows.size;
|
|
23
|
+
}
|
|
24
|
+
get consumedSize() {
|
|
25
|
+
return this.#consumed.size;
|
|
26
|
+
}
|
|
27
|
+
start(provider, opts) {
|
|
28
|
+
this.#prune();
|
|
29
|
+
const state = key();
|
|
30
|
+
const browserKey = key();
|
|
31
|
+
const flow = {
|
|
32
|
+
provider,
|
|
33
|
+
state,
|
|
34
|
+
callbackUrl: "",
|
|
35
|
+
data: {},
|
|
36
|
+
returnTo: opts.returnTo,
|
|
37
|
+
...(opts.link === undefined ? {} : { link: opts.link }),
|
|
38
|
+
browserKey,
|
|
39
|
+
expiresAt: this.#now() + FLOW_TTL_MS,
|
|
40
|
+
};
|
|
41
|
+
this.#flows.set(state, flow);
|
|
42
|
+
this.#evictOldest(this.#flows);
|
|
43
|
+
return flow;
|
|
44
|
+
}
|
|
45
|
+
/** Save provider scratch data and the callback URL after `begin` succeeds. */
|
|
46
|
+
save(state, flow) {
|
|
47
|
+
const stored = this.#flows.get(state);
|
|
48
|
+
if (stored === undefined)
|
|
49
|
+
return;
|
|
50
|
+
stored.callbackUrl = flow.callbackUrl;
|
|
51
|
+
stored.data = flow.data;
|
|
52
|
+
}
|
|
53
|
+
take(state, browserKey) {
|
|
54
|
+
this.#prune();
|
|
55
|
+
const flow = this.#flows.get(state);
|
|
56
|
+
if (flow === undefined || flow.browserKey !== browserKey)
|
|
57
|
+
return undefined;
|
|
58
|
+
this.#flows.delete(state);
|
|
59
|
+
this.#consumed.set(state, {
|
|
60
|
+
browserKey,
|
|
61
|
+
expiresAt: flow.expiresAt,
|
|
62
|
+
});
|
|
63
|
+
this.#evictOldest(this.#consumed);
|
|
64
|
+
return flow;
|
|
65
|
+
}
|
|
66
|
+
/** Park a verified proof for ten more minutes under a separate, single-use key. */
|
|
67
|
+
park(flow, result) {
|
|
68
|
+
this.#prune();
|
|
69
|
+
const pendingKey = key();
|
|
70
|
+
const pending = {
|
|
71
|
+
provider: flow.provider,
|
|
72
|
+
result,
|
|
73
|
+
returnTo: flow.returnTo,
|
|
74
|
+
expiresAt: this.#now() + FLOW_TTL_MS,
|
|
75
|
+
};
|
|
76
|
+
this.#pending.set(pendingKey, pending);
|
|
77
|
+
this.#evictOldest(this.#pending);
|
|
78
|
+
return { key: pendingKey, pending };
|
|
79
|
+
}
|
|
80
|
+
/** Put a pending proof back after a rejected code, preserving its original expiry. */
|
|
81
|
+
restorePending(pendingKey, pending) {
|
|
82
|
+
this.#prune();
|
|
83
|
+
this.#pending.set(pendingKey, pending);
|
|
84
|
+
this.#evictOldest(this.#pending);
|
|
85
|
+
}
|
|
86
|
+
/** Consume a code-resume key atomically; replay/expiry/restart all fail closed. */
|
|
87
|
+
takePending(pendingKey) {
|
|
88
|
+
this.#prune();
|
|
89
|
+
const pending = this.#pending.get(pendingKey);
|
|
90
|
+
if (pending !== undefined)
|
|
91
|
+
this.#pending.delete(pendingKey);
|
|
92
|
+
return pending;
|
|
93
|
+
}
|
|
94
|
+
/** Explain a failed take without exposing flow data (replay vs expired/unknown). */
|
|
95
|
+
failure(state, browserKey) {
|
|
96
|
+
this.#prune();
|
|
97
|
+
if (this.#flows.has(state))
|
|
98
|
+
return "invalid_state";
|
|
99
|
+
return this.#consumed.get(state)?.browserKey === browserKey
|
|
100
|
+
? "invalid_state"
|
|
101
|
+
: "login_expired";
|
|
102
|
+
}
|
|
103
|
+
#evictOldest(entries) {
|
|
104
|
+
while (entries.size > MAX_ACTIVE_LOGIN_FLOWS) {
|
|
105
|
+
const oldest = entries.keys().next();
|
|
106
|
+
if (oldest.done === true)
|
|
107
|
+
break;
|
|
108
|
+
entries.delete(oldest.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
#prune() {
|
|
112
|
+
const now = this.#now();
|
|
113
|
+
for (const [state, flow] of this.#flows) {
|
|
114
|
+
if (flow.expiresAt <= now)
|
|
115
|
+
this.#flows.delete(state);
|
|
116
|
+
}
|
|
117
|
+
for (const [state, consumed] of this.#consumed) {
|
|
118
|
+
if (consumed.expiresAt <= now)
|
|
119
|
+
this.#consumed.delete(state);
|
|
120
|
+
}
|
|
121
|
+
for (const [pendingKey, pending] of this.#pending) {
|
|
122
|
+
if (pending.expiresAt <= now)
|
|
123
|
+
this.#pending.delete(pendingKey);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { buildSessionCookie, sessionTtlMs } from "../routes/auth.js";
|
|
2
|
+
/** Translate one policy outcome into the browser's redirect/cookie response. */
|
|
3
|
+
export async function sendLoginResolution(outcome, input) {
|
|
4
|
+
if (outcome.kind === "refused") {
|
|
5
|
+
if (input.asJson === true)
|
|
6
|
+
return input.reply.code(403).send({ error: outcome.error });
|
|
7
|
+
return input.reply.code(403)
|
|
8
|
+
.header("location", `/login?error=${encodeURIComponent(outcome.error)}`)
|
|
9
|
+
.send();
|
|
10
|
+
}
|
|
11
|
+
if (outcome.kind === "needs_code") {
|
|
12
|
+
if (input.asJson === true)
|
|
13
|
+
return input.reply.code(403).send({ error: "invalid_code" });
|
|
14
|
+
return input.reply.code(403).header("location", "/login?error=invalid_code").send();
|
|
15
|
+
}
|
|
16
|
+
if (outcome.kind === "linked") {
|
|
17
|
+
const returnTo = "/settings?section=accounts";
|
|
18
|
+
if (input.asJson === true)
|
|
19
|
+
return input.reply.send({ returnTo });
|
|
20
|
+
return input.reply.code(302).header("location", returnTo).send();
|
|
21
|
+
}
|
|
22
|
+
const session = await input.ctx.opened.identity.tokens.createSession(outcome.user, input.provider, sessionTtlMs(input.ctx));
|
|
23
|
+
const cookie = buildSessionCookie(session.token, input.request, input.ctx);
|
|
24
|
+
if (input.asJson === true) {
|
|
25
|
+
return input.reply.header("set-cookie", cookie).send({ returnTo: input.returnTo });
|
|
26
|
+
}
|
|
27
|
+
return input.reply.code(302)
|
|
28
|
+
.header("location", input.returnTo)
|
|
29
|
+
.header("set-cookie", cookie)
|
|
30
|
+
.send();
|
|
31
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
|
|
2
|
+
import { AdmissionRefusedError, } from "../../harness/identity/users.js";
|
|
3
|
+
import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
|
|
4
|
+
import { hashToken } from "../../harness/identity/tokens.js";
|
|
5
|
+
import { withClaimLock } from "../auth/claim-lock.js";
|
|
6
|
+
import { assignUsername } from "./usernames.js";
|
|
7
|
+
function allowed(result, policy) {
|
|
8
|
+
if (policy.allowedGroups !== undefined &&
|
|
9
|
+
!policy.allowedGroups.some((group) => result.groups?.includes(group) === true))
|
|
10
|
+
return false;
|
|
11
|
+
if (policy.allowedDomains !== undefined) {
|
|
12
|
+
if (result.profile.emailVerified !== true)
|
|
13
|
+
return false;
|
|
14
|
+
const email = result.profile.email;
|
|
15
|
+
const domain = email?.slice(email.lastIndexOf("@") + 1).toLowerCase();
|
|
16
|
+
if (email === undefined ||
|
|
17
|
+
email.lastIndexOf("@") <= 0 ||
|
|
18
|
+
!policy.allowedDomains.some((allowedDomain) => allowedDomain.toLowerCase() === domain))
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
function profileFields(profile) {
|
|
24
|
+
return {
|
|
25
|
+
...(profile.name === undefined ? {} : { name: profile.name }),
|
|
26
|
+
...(profile.email === undefined ? {} : { email: profile.email }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Resolve one verified external identity without ever inferring an existing-user link. */
|
|
30
|
+
export async function resolveLogin(input, deps) {
|
|
31
|
+
const { provider, result } = input;
|
|
32
|
+
try {
|
|
33
|
+
if (input.linkUser !== undefined) {
|
|
34
|
+
await deps.accounts.link({
|
|
35
|
+
provider,
|
|
36
|
+
subject: result.subject,
|
|
37
|
+
user: input.linkUser,
|
|
38
|
+
via: "login",
|
|
39
|
+
});
|
|
40
|
+
// A link records identity only. Groups arrive on this account's
|
|
41
|
+
// next sign-in, never on a session/token-authenticated request.
|
|
42
|
+
return { kind: "linked", user: input.linkUser };
|
|
43
|
+
}
|
|
44
|
+
const existing = deps.accounts.lookup(provider, result.subject);
|
|
45
|
+
if (existing !== undefined) {
|
|
46
|
+
if (deps.users.get(existing.user) === undefined) {
|
|
47
|
+
return { kind: "refused", error: "not_allowed" };
|
|
48
|
+
}
|
|
49
|
+
await deps.users.setGroups(existing.user, provider, result.groups ?? []);
|
|
50
|
+
return { kind: "session", user: existing.user };
|
|
51
|
+
}
|
|
52
|
+
const setupResolution = await withClaimLock(async () => {
|
|
53
|
+
const validSetupCode = input.code !== undefined && setupCodeMatches(input.code, deps.setupCode());
|
|
54
|
+
if (deps.users.isClaimed()) {
|
|
55
|
+
// A setup code never creates a user on a claimed server. It may
|
|
56
|
+
// only finish the exact claim whose subject/user/code were recorded.
|
|
57
|
+
if (!validSetupCode)
|
|
58
|
+
return undefined;
|
|
59
|
+
const claim = deps.setupClaims.matches(provider, result.subject, input.code);
|
|
60
|
+
if (claim === undefined || deps.users.get(claim.user) === undefined) {
|
|
61
|
+
return { kind: "refused", error: "not_allowed" };
|
|
62
|
+
}
|
|
63
|
+
await deps.accounts.link({ provider, subject: result.subject, user: claim.user, via: "login" });
|
|
64
|
+
await deps.users.setGroups(claim.user, provider, result.groups ?? []);
|
|
65
|
+
await deps.clearSetupCode();
|
|
66
|
+
await deps.setupClaims.clear();
|
|
67
|
+
return { kind: "session", user: claim.user };
|
|
68
|
+
}
|
|
69
|
+
if (!validSetupCode)
|
|
70
|
+
return { kind: "needs_code", reason: "setup" };
|
|
71
|
+
const claim = deps.setupClaims.matches(provider, result.subject, input.code);
|
|
72
|
+
const user = claim?.user ?? assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
|
|
73
|
+
// Persist the recovery breadcrumb before creating the user. On a
|
|
74
|
+
// retry, only this exact subject may finish the claim; if the user
|
|
75
|
+
// write did not happen, it is retried under the recorded name.
|
|
76
|
+
await deps.setupClaims.record({
|
|
77
|
+
user,
|
|
78
|
+
provider,
|
|
79
|
+
subject: result.subject,
|
|
80
|
+
codeSha256: hashToken(input.code),
|
|
81
|
+
});
|
|
82
|
+
const record = deps.users.get(user) ?? await deps.users.add({
|
|
83
|
+
user,
|
|
84
|
+
...profileFields(result.profile),
|
|
85
|
+
admin: true,
|
|
86
|
+
source: "login",
|
|
87
|
+
});
|
|
88
|
+
await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
|
|
89
|
+
await deps.users.setGroups(user, provider, result.groups ?? []);
|
|
90
|
+
await deps.clearSetupCode();
|
|
91
|
+
await deps.setupClaims.clear();
|
|
92
|
+
return { kind: "session", user: record.user };
|
|
93
|
+
});
|
|
94
|
+
if (setupResolution !== undefined)
|
|
95
|
+
return setupResolution;
|
|
96
|
+
if (deps.policy.autoProvision === true && !allowed(result, deps.policy)) {
|
|
97
|
+
return { kind: "refused", error: "not_allowed" };
|
|
98
|
+
}
|
|
99
|
+
if (deps.policy.autoProvision !== true && input.code === undefined) {
|
|
100
|
+
return { kind: "needs_code", reason: "invite" };
|
|
101
|
+
}
|
|
102
|
+
// Admission is checked before redemption so an M1 refusal burns no invite.
|
|
103
|
+
deps.users.checkAdmission();
|
|
104
|
+
const user = assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
|
|
105
|
+
const grant = deps.policy.autoProvision === true
|
|
106
|
+
? { admin: false }
|
|
107
|
+
: await deps.invites.redeem(input.code, user);
|
|
108
|
+
if (grant === undefined)
|
|
109
|
+
return { kind: "refused", error: "not_allowed" };
|
|
110
|
+
const record = await deps.users.add({
|
|
111
|
+
user,
|
|
112
|
+
...profileFields(result.profile),
|
|
113
|
+
admin: grant.admin,
|
|
114
|
+
source: "login",
|
|
115
|
+
});
|
|
116
|
+
await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
|
|
117
|
+
await deps.users.setGroups(user, provider, result.groups ?? []);
|
|
118
|
+
return { kind: "session", user: record.user };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:")))
|
|
122
|
+
return { kind: "refused", error: "account_link_conflict" };
|
|
123
|
+
if (error instanceof AdmissionRefusedError)
|
|
124
|
+
return { kind: "refused", error: error.code };
|
|
125
|
+
if (error instanceof Error && error.message.startsWith("isolation_required:")) {
|
|
126
|
+
return { kind: "refused", error: "isolation_required" };
|
|
127
|
+
}
|
|
128
|
+
if (error instanceof Error && error.message.startsWith("auth_required:")) {
|
|
129
|
+
return { kind: "refused", error: "auth_required" };
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hashToken } from "../../harness/identity/tokens.js";
|
|
4
|
+
import { writeJson } from "../../harness/identity/json-store.js";
|
|
5
|
+
/**
|
|
6
|
+
* A durable breadcrumb for the one setup-code-paid claim. It lets a retry
|
|
7
|
+
* finish a crashed link without ever inferring ownership from a username.
|
|
8
|
+
*/
|
|
9
|
+
export class SetupClaimStore {
|
|
10
|
+
#path;
|
|
11
|
+
#memory;
|
|
12
|
+
constructor(cacheDir) {
|
|
13
|
+
this.#path = cacheDir === undefined ? undefined : join(cacheDir, "login-setup-claim.json");
|
|
14
|
+
}
|
|
15
|
+
read() {
|
|
16
|
+
if (this.#path === undefined)
|
|
17
|
+
return this.#memory;
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(readFileSync(this.#path, "utf8"));
|
|
20
|
+
if (typeof value !== "object" || value === null)
|
|
21
|
+
throw new Error("not an object");
|
|
22
|
+
const record = value;
|
|
23
|
+
if (typeof record.user !== "string" || typeof record.provider !== "string" ||
|
|
24
|
+
typeof record.subject !== "string" || typeof record.codeSha256 !== "string")
|
|
25
|
+
throw new Error("invalid fields");
|
|
26
|
+
return record;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === "ENOENT")
|
|
30
|
+
return undefined;
|
|
31
|
+
throw new Error(`sigrun: malformed login setup claim in ${this.#path}`, { cause: error });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async record(claim) {
|
|
35
|
+
if (this.#path === undefined) {
|
|
36
|
+
this.#memory = claim;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await writeJson(this.#path, claim);
|
|
40
|
+
}
|
|
41
|
+
matches(provider, subject, code) {
|
|
42
|
+
const claim = this.read();
|
|
43
|
+
return claim?.provider === provider && claim.subject === subject && claim.codeSha256 === hashToken(code)
|
|
44
|
+
? claim
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
47
|
+
async clear() {
|
|
48
|
+
if (this.#path === undefined) {
|
|
49
|
+
this.#memory = undefined;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const { rm } = await import("node:fs/promises");
|
|
53
|
+
await rm(this.#path, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Assign a new directory username; this never selects an existing user. */
|
|
2
|
+
export function assignUsername(profile, subject, taken) {
|
|
3
|
+
const candidate = profile.username ?? profile.email?.split("@", 1)[0] ?? subject;
|
|
4
|
+
const base = candidate
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9._-]/g, "-")
|
|
7
|
+
.replace(/^[^a-z0-9]+/, "") || "user";
|
|
8
|
+
if (!taken(base))
|
|
9
|
+
return base;
|
|
10
|
+
for (let suffix = 2;; suffix++) {
|
|
11
|
+
const candidateWithSuffix = `${base}-${suffix}`;
|
|
12
|
+
if (!taken(candidateWithSuffix))
|
|
13
|
+
return candidateWithSuffix;
|
|
14
|
+
}
|
|
15
|
+
}
|