@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -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 (default
83
- * `"0.0.0.0"`); the pipeline's Host check and the default method list
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({ logger: false });
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
- opened.bindHostBridge(createHostBridge(ctx));
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
+ }