@sigrun/sigrun 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The event bus (memory-seam Tasks 2 + 4): commit observation + bounded
3
- * side lane + idle detection + shutdown sequencing. See
4
- * docs/superpowers/specs/2026-10-15-sigrun-memory-seam.md §"Design".
3
+ * side lane + idle detection + shutdown sequencing. See the memory-seam
4
+ * spec §"Design".
5
5
  *
6
6
  * The bus subscribes to pi-durable's `Session.subscribeCommits` (sigrun's
7
7
  * harness IS a Session) and reduces each `CommitPublication` into the
@@ -64,6 +64,7 @@ import { SessionMetaDoc } from "../server/session-meta.js";
64
64
  import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
65
65
  import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions.js";
66
66
  import { AmbientDoc } from "./ambient.js";
67
+ import { beginUserDeletion } from "./stores.js";
67
68
  /** Project a SessionMeta doc value into the tracked basis. */
68
69
  function metaBasisOf(value) {
69
70
  const basis = {
@@ -84,6 +85,10 @@ function metaBasisOf(value) {
84
85
  }
85
86
  /** How long shutdown waits for in-flight worker runs before abandoning (spec §3). */
86
87
  const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
88
+ /** The default last-admission window (gateway spec §8.13). */
89
+ const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
90
+ /** Short pause between live-run checks while the commit side lane works. */
91
+ const SHUTDOWN_SETTLE_POLL_MS = 10;
87
92
  /**
88
93
  * The commit-observation side lane. One instance per harness; open after
89
94
  * boot, close before the harness closes (`shutdownSequence` for the
@@ -129,6 +134,7 @@ export class EventBus {
129
134
  /** The worker states, in registration order (memory-seam Task 5). */
130
135
  #workers = [];
131
136
  #workerDeadlineMs;
137
+ #shutdownSettleWaitMs;
132
138
  #closed = false;
133
139
  constructor(harness, options) {
134
140
  this.#harness = harness;
@@ -136,6 +142,7 @@ export class EventBus {
136
142
  this.#hookDeadlineMs = options.hookDeadlineMs;
137
143
  this.#idleThresholdMs = options.idleThresholdMs;
138
144
  this.#workerDeadlineMs = options.workerDeadlineMs;
145
+ this.#shutdownSettleWaitMs = options.shutdownSettleWaitMs ?? SHUTDOWN_SETTLE_WAIT_MS;
139
146
  if (options.onHostEvent !== undefined) {
140
147
  this.onHostEvent(options.onHostEvent);
141
148
  }
@@ -316,8 +323,9 @@ export class EventBus {
316
323
  }
317
324
  /**
318
325
  * Emit one `userDeleted` directly (core hardening W1 Task 4): the
319
- * DELETE /v1/users route's final step, AFTER the ownership transfer,
320
- * token revocation, and record removal have landed. Delivered exactly
326
+ * DELETE /v1/users route's step 3, AFTER transfer/revocation/link
327
+ * removal and service stop, but BEFORE the directory record is removed,
328
+ * so per-user secrets remain readable. Delivered exactly
321
329
  * like `harnessShutdown` — inline to every registered extension's
322
330
  * handler in install order, each deadline-bounded, never queued —
323
331
  * because the directory is not durable conversation state (there is
@@ -327,12 +335,37 @@ export class EventBus {
327
335
  async emitUserDeleted(payload, context) {
328
336
  if (this.#closed)
329
337
  return;
330
- const api = this.#apiFor(undefined);
338
+ const endDeletion = beginUserDeletion(payload.user);
339
+ try {
340
+ const api = this.#apiFor(undefined);
341
+ for (const [name, handlers] of this.#extensions) {
342
+ const handler = handlers.userDeleted;
343
+ if (handler === undefined)
344
+ continue;
345
+ await this.#bounded(`extension ${name} userDeleted`, () => handler(payload, api, context));
346
+ }
347
+ }
348
+ finally {
349
+ endDeletion();
350
+ }
351
+ }
352
+ /**
353
+ * Emit one `settings.changed` fan-out (UI-blocks U4, spec §13.2):
354
+ * after a settings write COMMITS, every registered extension's
355
+ * `onSettingsChanged` receives the flattened keys the write touched.
356
+ * Delivered like `userDeleted` — inline, deadline-bounded, rejections
357
+ * caught and logged — because settings are not durable conversation
358
+ * state (no commit to derive from), and §13.2's rule is explicit: a
359
+ * throwing handler never fails the write.
360
+ */
361
+ async emitSettingsChanged(keys, user) {
362
+ if (this.#closed)
363
+ return;
331
364
  for (const [name, handlers] of this.#extensions) {
332
- const handler = handlers.userDeleted;
365
+ const handler = handlers.onSettingsChanged;
333
366
  if (handler === undefined)
334
367
  continue;
335
- await this.#bounded(`extension ${name} userDeleted`, () => handler(payload, api, context));
368
+ await this.#bounded(`extension ${name} onSettingsChanged`, () => handler(keys, user));
336
369
  }
337
370
  }
338
371
  /**
@@ -362,8 +395,9 @@ export class EventBus {
362
395
  * 2. stop idle workers FIRST (Task 5, spec §3): pending wakes dropped,
363
396
  * in-flight runs awaited up to 30s then abandoned — a curator is not
364
397
  * competing with the flush handlers for the window;
365
- * 3. drain pending settles — a run that settled just before shutdown
366
- * delivers before the shutdown flush (the ordering constraint);
398
+ * 3. drain pending settles and wait (bounded) for placed live runs —
399
+ * a run settling during this last admission window delivers before
400
+ * the shutdown flush; a wedged run logs its conversation ids;
367
401
  * 4. deliver `harnessShutdown` to every extension in install order,
368
402
  * each deadline-bounded. Handlers may commit — admission is still
369
403
  * open in this window (the seal is `Harness.close`, the caller's
@@ -380,6 +414,7 @@ export class EventBus {
380
414
  }
381
415
  await this.#stopWorkers();
382
416
  await this.drain(this.#hookDeadlineMs, context);
417
+ await this.#drainLiveRuns(context);
383
418
  // harnessShutdown carries no conversation: the conversation-scoped
384
419
  // reads resolve empty and `document` serves any conversation.
385
420
  const api = this.#apiFor(undefined);
@@ -429,6 +464,23 @@ export class EventBus {
429
464
  clearTimeout(timer);
430
465
  });
431
466
  }
467
+ /** Drain runs placed before shutdown, bounded so a wedged run cannot hang close. */
468
+ async #drainLiveRuns(context) {
469
+ const deadline = Date.now() + this.#shutdownSettleWaitMs;
470
+ for (;;) {
471
+ const live = [...this.#liveInputs.entries()]
472
+ .filter(([, count]) => count > 0)
473
+ .map(([conversationId]) => conversationId);
474
+ if (live.length === 0)
475
+ return;
476
+ if (Date.now() >= deadline) {
477
+ console.warn("event-bus: shutdown settle wait expired; stranded conversations: %s", live.map(String).join(", "));
478
+ return;
479
+ }
480
+ await new Promise((resolve) => setTimeout(resolve, SHUTDOWN_SETTLE_POLL_MS));
481
+ await this.drain(this.#hookDeadlineMs, context);
482
+ }
483
+ }
432
484
  /** Stop everything: unsubscribe, empty the queue, stop the sweep + workers, and let a running loop exit. */
433
485
  close() {
434
486
  if (this.#closed)
@@ -640,6 +692,20 @@ export class EventBus {
640
692
  if (typeof record.callId === "string" &&
641
693
  typeof record.kind === "string" &&
642
694
  typeof record.status === "string") {
695
+ // C6 widening: carry the attribution + the STORED
696
+ // (masked-by-construction) answer values — the stored
697
+ // copy never holds a secret's true value, so these are
698
+ // render-safe without any delivery-table read.
699
+ const recordAnsweredByUser = typeof record.answeredByUser === "string"
700
+ ? record.answeredByUser
701
+ : undefined;
702
+ const recordAnswerValues = typeof record.answer === "object" &&
703
+ record.answer !== null &&
704
+ typeof record.answer.values ===
705
+ "object" &&
706
+ record.answer.values !== null
707
+ ? record.answer.values
708
+ : undefined;
643
709
  this.#emitHost({
644
710
  type: INTERACTION_TERMINAL.has(record.status)
645
711
  ? "interaction.settled"
@@ -666,6 +732,12 @@ export class EventBus {
666
732
  conversationId: change.conversationId,
667
733
  callId: record.callId,
668
734
  kind: record.kind,
735
+ ...(recordAnsweredByUser === undefined
736
+ ? {}
737
+ : { answeredByUser: recordAnsweredByUser }),
738
+ ...(recordAnswerValues === undefined
739
+ ? {}
740
+ : { answer: recordAnswerValues }),
669
741
  // SAFETY: `record.status` is a narrowed `string`, but
670
742
  // the payload wants the terminal union — sound because
671
743
  // the union and the TERMINAL set share one definition
@@ -1015,7 +1087,11 @@ export class EventBus {
1015
1087
  const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers[type] !== undefined);
1016
1088
  if (registered.length === 0)
1017
1089
  return;
1018
- const payload = this.#lifecyclePayload(event);
1090
+ const owner = await this.#ownerOf(this.#lifecycleConversation(event), context);
1091
+ const payload = {
1092
+ ...this.#lifecyclePayload(event),
1093
+ ...(owner === undefined ? {} : { owner }),
1094
+ };
1019
1095
  const api = this.#apiFor(this.#lifecycleConversation(event));
1020
1096
  for (const [name, handlers] of registered) {
1021
1097
  const handler = handlers[type];
@@ -1042,6 +1118,10 @@ export class EventBus {
1042
1118
  callId: event.callId,
1043
1119
  kind: event.kind,
1044
1120
  status: event.status,
1121
+ ...(event.answeredByUser === undefined
1122
+ ? {}
1123
+ : { answeredByUser: event.answeredByUser }),
1124
+ ...(event.answer === undefined ? {} : { answer: event.answer }),
1045
1125
  };
1046
1126
  }
1047
1127
  }
@@ -1083,6 +1163,8 @@ export class EventBus {
1083
1163
  * (immutable once committed; defensive) downgrades to unanswered.
1084
1164
  */
1085
1165
  async #resolveRunSettled(group, context) {
1166
+ const owner = await this.#ownerOf(group.conversationId, context);
1167
+ const ownerField = owner === undefined ? {} : { owner };
1086
1168
  const inputs = [];
1087
1169
  const inputTexts = [];
1088
1170
  for (const one of group.settled) {
@@ -1103,6 +1185,7 @@ export class EventBus {
1103
1185
  if (message !== undefined && message.role === "assistant") {
1104
1186
  return {
1105
1187
  conversationId: group.conversationId,
1188
+ ...ownerField,
1106
1189
  inputs,
1107
1190
  inputTexts,
1108
1191
  outcome: { status: "answered", answerEntryId, answer: message },
@@ -1113,6 +1196,7 @@ export class EventBus {
1113
1196
  const firstUnanswered = all.find((one) => one.status === "unanswered");
1114
1197
  return {
1115
1198
  conversationId: group.conversationId,
1199
+ ...ownerField,
1116
1200
  inputs,
1117
1201
  inputTexts,
1118
1202
  outcome: {
@@ -1121,6 +1205,17 @@ export class EventBus {
1121
1205
  },
1122
1206
  };
1123
1207
  }
1208
+ /** The current SessionMeta owner; absent means the key is omitted from the event. */
1209
+ async #ownerOf(conversationId, context) {
1210
+ try {
1211
+ const meta = await this.#harness.snapshot(SessionMetaDoc, conversationId, context);
1212
+ return meta?.owner;
1213
+ }
1214
+ catch (error) {
1215
+ console.error("event-bus: owner read failed:", error);
1216
+ return undefined;
1217
+ }
1218
+ }
1124
1219
  /** One visible entry by exact id (`undefined` on absence or read failure). */
1125
1220
  async #readEntry(conversationId, id, context) {
1126
1221
  try {
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The account-link store (multi-user M3a, spec §11.3): ONE
3
+ * `<configDir>/accounts.json` linking external identities to directory
4
+ * users — chat senders (`chat:<channel>`, written by the gateway) and
5
+ * login subjects (`login:<slug>:<id>`, written by the login routes) in
6
+ * the same table.
7
+ *
8
+ * It lives in CORE, not in an extension, for one reason: an extension
9
+ * runs inside sigrun's process, so a link table owned by an extension
10
+ * is a second write path to the same identity data. Core owns the file,
11
+ * extensions go through `host.accounts`, and the gateway and the login
12
+ * seam cannot disagree about who a sender is.
13
+ *
14
+ * A link is refused, never re-pointed, when `(provider, subject)` is
15
+ * already linked to a DIFFERENT user: re-pointing an existing external
16
+ * identity is an account-takeover path (an attacker who controls the
17
+ * other side of a stale link would inherit the account). The holder or
18
+ * an admin removes the old link first. Re-linking to the SAME user is a
19
+ * no-op that returns the existing record.
20
+ *
21
+ * Store discipline is `users.json`'s (`json-store.ts`): synchronous
22
+ * mtime-reloading reads, atomic 0600 writes, mutations serialized on a
23
+ * per-instance {@link MutationQueue}. A second writer on the same file
24
+ * (another sigrun process, or a future CLI accounts command) is honored
25
+ * live by the per-access stat check.
26
+ */
27
+ import { MutationQueue, readRecords, writeJson } from "./json-store.js";
28
+ /**
29
+ * The allowed `via` values, as a runtime set: `accounts.json` is a file,
30
+ * so a hand-edited or future-written record can carry anything, and the
31
+ * value reaches the wire verbatim. Validated like every other field.
32
+ */
33
+ const VIAS = new Set([
34
+ "login",
35
+ "self",
36
+ "pairing",
37
+ "admin",
38
+ ]);
39
+ /**
40
+ * A `(provider, subject)` already linked to a different user. The
41
+ * message never names the current holder — the caller learns only that
42
+ * the identity is taken (extensions match on the
43
+ * `account_link_conflict:` prefix, like `command_not_found:`).
44
+ */
45
+ export class AccountLinkConflictError extends Error {
46
+ provider;
47
+ subject;
48
+ constructor(provider, subject) {
49
+ super(`account_link_conflict: ${provider} is linked to another user`);
50
+ this.name = "AccountLinkConflictError";
51
+ this.provider = provider;
52
+ this.subject = subject;
53
+ }
54
+ }
55
+ /** `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
56
+ const PROVIDER_PATTERN = /^(login|chat):[a-z0-9][a-z0-9._:-]*$/;
57
+ /** A subject is opaque, non-empty, and bounded. */
58
+ const SUBJECT_MAX_LENGTH = 512;
59
+ /**
60
+ * The file-backed link table. Reads are synchronous (mtime reload per
61
+ * access); every mutation is a whole-file read-modify-write serialized
62
+ * on the store's {@link MutationQueue}, with validation INSIDE the
63
+ * mutation so the user-existence and conflict checks see the same file
64
+ * state as the write.
65
+ */
66
+ export class AccountStore {
67
+ path;
68
+ users;
69
+ mutations = new MutationQueue();
70
+ constructor(path, users) {
71
+ this.path = path;
72
+ this.users = users;
73
+ }
74
+ /** Every link, insertion order. */
75
+ list() {
76
+ return readRecords(this.path);
77
+ }
78
+ /** The link for one external identity; absent → undefined. */
79
+ lookup(provider, subject) {
80
+ return this.list().find((link) => link.provider === provider && link.subject === subject);
81
+ }
82
+ /** One user's links, insertion order. */
83
+ listForUser(user) {
84
+ return this.list().filter((link) => link.user === user);
85
+ }
86
+ /**
87
+ * Link an external identity to a directory user. Same-user re-link is
88
+ * a no-op returning the existing record (its `via` and `linkedAt` are
89
+ * the first link's); a different user is refused with
90
+ * {@link AccountLinkConflictError}. Throws `unknown user "<user>"`,
91
+ * `invalid_provider: <provider>`, `invalid_subject`, or
92
+ * `invalid_via: <via>`.
93
+ */
94
+ link(input) {
95
+ return this.mutations.run(async () => {
96
+ const { provider, subject, user, via } = input;
97
+ if (!PROVIDER_PATTERN.test(provider)) {
98
+ throw new Error(`invalid_provider: ${provider}`);
99
+ }
100
+ if (subject === "" || subject.length > SUBJECT_MAX_LENGTH) {
101
+ throw new Error("invalid_subject");
102
+ }
103
+ if (!VIAS.has(via)) {
104
+ throw new Error(`invalid_via: ${String(via)}`);
105
+ }
106
+ if (this.users.get(user) === undefined) {
107
+ throw new Error(`unknown user "${user}"`);
108
+ }
109
+ const records = this.list();
110
+ const existing = records.find((link) => link.provider === provider && link.subject === subject);
111
+ if (existing !== undefined) {
112
+ if (existing.user !== user) {
113
+ throw new AccountLinkConflictError(provider, subject);
114
+ }
115
+ return existing;
116
+ }
117
+ const record = {
118
+ provider,
119
+ subject,
120
+ user,
121
+ linkedAt: Date.now(),
122
+ via,
123
+ };
124
+ records.push(record);
125
+ await writeJson(this.path, records);
126
+ return record;
127
+ });
128
+ }
129
+ /** Remove one link; `false` when it was not there. */
130
+ unlink(provider, subject) {
131
+ return this.mutations.run(async () => {
132
+ const records = this.list();
133
+ const index = records.findIndex((link) => link.provider === provider && link.subject === subject);
134
+ if (index === -1)
135
+ return false;
136
+ records.splice(index, 1);
137
+ await writeJson(this.path, records);
138
+ return true;
139
+ });
140
+ }
141
+ /**
142
+ * Remove one link, deciding AUTHORIZATION inside the mutation against
143
+ * the record the write will actually replace.
144
+ *
145
+ * The route cannot check ownership itself: it reads the link, then
146
+ * awaits {@link unlink}, whose read happens later — in the window the
147
+ * subject could be unlinked and re-linked to a different user, and the
148
+ * stale check would remove the NEW holder's link. That is the same
149
+ * doctrine this store's validation follows: the check and the write
150
+ * see one file state.
151
+ *
152
+ * `principal.admin` and an absent `principal.user` (the naive
153
+ * single-operator superuser, a userless service token) may remove
154
+ * anyone's link; anyone else may remove only their own.
155
+ */
156
+ unlinkAuthorized(provider, subject, principal) {
157
+ return this.mutations.run(async () => {
158
+ const records = this.list();
159
+ const index = records.findIndex((link) => link.provider === provider && link.subject === subject);
160
+ if (index === -1)
161
+ return "absent";
162
+ const record = records[index];
163
+ if (!principal.admin && record.user !== principal.user) {
164
+ return "forbidden";
165
+ }
166
+ records.splice(index, 1);
167
+ await writeJson(this.path, records);
168
+ return "removed";
169
+ });
170
+ }
171
+ /**
172
+ * Remove every link of one user (user deletion, spec §10.6 step 1).
173
+ * Links never transfer: credentials belong to a person.
174
+ */
175
+ removeUser(user) {
176
+ return this.mutations.run(async () => {
177
+ const records = this.list();
178
+ const kept = records.filter((link) => link.user !== user);
179
+ const removed = records.length - kept.length;
180
+ if (removed === 0)
181
+ return 0;
182
+ await writeJson(this.path, kept);
183
+ return removed;
184
+ });
185
+ }
186
+ }
@@ -0,0 +1,14 @@
1
+ /** Group-derived views over the user directory (multi-user M3b §11.5). */
2
+ /** The deduplicated union of a user's stored groups, in source order. */
3
+ export function storedGroups(record) {
4
+ const groups = new Set();
5
+ for (const entry of record.groups ?? []) {
6
+ for (const value of entry.values)
7
+ groups.add(value);
8
+ }
9
+ return [...groups];
10
+ }
11
+ /** Effective admin reads stored groups only, never request-supplied groups. */
12
+ export function effectiveAdmin(record, adminGroups) {
13
+ return record.admin || storedGroups(record).some((group) => adminGroups.includes(group));
14
+ }
@@ -1,26 +1,31 @@
1
1
  /**
2
2
  * The identity stores bundle (core hardening W1 Task 2): the directory,
3
- * tokens, invites, and the setup code, opened over the resolved store
4
- * roots — the object `openHarness` exposes as `OpenedHarness.identity`
5
- * and the auth pipeline/routes consume.
3
+ * tokens, account links, invites, and the setup code, opened over the
4
+ * resolved store roots — the object `openHarness` exposes as
5
+ * `OpenedHarness.identity` and the auth pipeline/routes consume.
6
6
  *
7
7
  - `<configDir>/users.json` — the user directory (0600; holds emails);
8
8
  - `<configDir>/api-tokens.json` — the token digests (0600);
9
+ - `<configDir>/accounts.json` — external-identity links (0600);
9
10
  - `<stateDir>/invites.json` — the invite digests (0600);
10
11
  - `<cacheDir>` — the setup code's home (pod-local, never backed up).
11
12
  *
12
13
  All four files: atomic writes (temp + rename), mtime-reload reads per
13
- access (the CLI is a second writer), serialized mutations per store.
14
+ access (a second writer — the CLI, or another sigrun process sharing a
15
+ config root — is honored live), serialized mutations per store.
14
16
  */
15
17
  import { join } from "node:path";
16
18
  import { UserDirectory } from "./users.js";
19
+ import { effectiveAdmin, storedGroups } from "./groups.js";
20
+ import { AccountStore, AccountLinkConflictError, } from "./accounts.js";
17
21
  import { TokenStore, generateToken, hashToken, } from "./tokens.js";
18
22
  import { InviteStore, DEFAULT_INVITE_EXPIRY_MS, } from "./invites.js";
19
23
  import { ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, } from "./setup-code.js";
20
- export { UserDirectory, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, };
24
+ export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, };
21
25
  /** The file names each store owns under its root. */
22
26
  const USERS_FILE = "users.json";
23
27
  const TOKENS_FILE = "api-tokens.json";
28
+ const ACCOUNTS_FILE = "accounts.json";
24
29
  const INVITES_FILE = "invites.json";
25
30
  /**
26
31
  * Open the identity stores over resolved roots (cheap — no I/O; every
@@ -28,9 +33,11 @@ const INVITES_FILE = "invites.json";
28
33
  * `ensureStoreRoots`, and by tests directly over tmp dirs.
29
34
  */
30
35
  export function openIdentityStores(roots) {
36
+ const users = new UserDirectory(join(roots.configDir, USERS_FILE));
31
37
  return {
32
- users: new UserDirectory(join(roots.configDir, USERS_FILE)),
38
+ users,
33
39
  tokens: new TokenStore(join(roots.configDir, TOKENS_FILE)),
40
+ accounts: new AccountStore(join(roots.configDir, ACCOUNTS_FILE), users),
34
41
  invites: new InviteStore(join(roots.stateDir, INVITES_FILE)),
35
42
  cacheDir: roots.cacheDir,
36
43
  };
@@ -76,16 +76,27 @@ export function readRecords(path) {
76
76
  }
77
77
  return value;
78
78
  }
79
+ /** Invalidate one path after a writer outside writeJson replaces it. */
80
+ export function invalidateJsonCache(path) {
81
+ cache.delete(path);
82
+ }
79
83
  /**
80
84
  * Write one JSON value the house way (tabs, trailing newline),
81
85
  * atomically, mode `0600` — identity files all carry credential
82
86
  * material (token hashes, emails, invite hashes). The path's cache
83
- * entry is invalidated (never eagerly re-statted): the next read
84
- * re-stats and sees the new file.
87
+ * entry is invalidated whether the write succeeds or fails: a FAILED
88
+ * write leaves the file as it was, so a cache entry that outlived it
89
+ * would serve a value the disk no longer holds (a revoked link that
90
+ * silently comes back, or a link that is not on disk authorizing a
91
+ * sender). Re-stating on the next read is cheap and always correct.
85
92
  */
86
93
  export async function writeJson(path, value) {
87
- await writeFileAtomic(path, `${JSON.stringify(value, null, "\t")}\n`, 0o600);
88
- cache.delete(path);
94
+ try {
95
+ await writeFileAtomic(path, `${JSON.stringify(value, null, "\t")}\n`, 0o600);
96
+ }
97
+ finally {
98
+ cache.delete(path);
99
+ }
89
100
  }
90
101
  /**
91
102
  * A per-store-instance FIFO mutex (the "store semantics" serialization
@@ -0,0 +1,9 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ /** Constant-time setup-code comparison shared by every claim surface. */
3
+ export function setupCodeMatches(presented, expected) {
4
+ if (presented === undefined || expected === undefined)
5
+ return false;
6
+ const left = Buffer.from(presented, "utf8");
7
+ const right = Buffer.from(expected, "utf8");
8
+ return left.length === right.length && timingSafeEqual(left, right);
9
+ }
@@ -16,6 +16,10 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual, } from "node:cryp
16
16
  import { MutationQueue, readRecords, writeJson } from "./json-store.js";
17
17
  /** The token wire prefix (the `sk-`/`sgr_` convention; spec §5.4). */
18
18
  const TOKEN_PREFIX = "sgr_";
19
+ /** Drop expired session records before every store mutation is written. */
20
+ function pruneExpired(records, now) {
21
+ return records.filter((record) => record.expiresAt === undefined || record.expiresAt > now);
22
+ }
19
23
  /** A fresh token: `sgr_` + 32 random bytes in base64url (43 chars). */
20
24
  export function generateToken() {
21
25
  return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
@@ -52,9 +56,10 @@ export class TokenStore {
52
56
  * Verify a presented token: hash it, compare against every stored
53
57
  * digest with `timingSafeEqual`. Absent/unknown/malformed → undefined.
54
58
  */
55
- verify(token) {
59
+ verify(token, now = Date.now()) {
56
60
  const digest = hashToken(token);
57
- return this.list().find((record) => sameDigest(record.sha256, digest));
61
+ return this.list().find((record) => sameDigest(record.sha256, digest) &&
62
+ (record.expiresAt === undefined || record.expiresAt > now));
58
63
  }
59
64
  /**
60
65
  * Mint one token for `user`: generates the secret, stores ONLY its
@@ -70,14 +75,32 @@ export class TokenStore {
70
75
  sha256: hashToken(token),
71
76
  createdAt: Date.now(),
72
77
  };
73
- await writeJson(this.path, [...this.list(), record]);
78
+ await writeJson(this.path, [...pruneExpired(this.list(), Date.now()), record]);
79
+ return { record, token };
80
+ });
81
+ }
82
+ /** Mint a revocable, expiring browser session for an IdP sign-in. */
83
+ createSession(user, provider, ttlMs, now = Date.now()) {
84
+ return this.mutations.run(async () => {
85
+ const token = generateToken();
86
+ const record = {
87
+ id: randomUUID(),
88
+ name: `login:${provider}`,
89
+ user,
90
+ sha256: hashToken(token),
91
+ createdAt: now,
92
+ kind: "session",
93
+ provider,
94
+ expiresAt: now + ttlMs,
95
+ };
96
+ await writeJson(this.path, [...pruneExpired(this.list(), now), record]);
74
97
  return { record, token };
75
98
  });
76
99
  }
77
100
  /** Revoke by record id; false when no such record existed. */
78
101
  revoke(id) {
79
102
  return this.mutations.run(async () => {
80
- const records = this.list();
103
+ const records = pruneExpired(this.list(), Date.now());
81
104
  const index = records.findIndex((record) => record.id === id);
82
105
  if (index === -1)
83
106
  return false;
@@ -89,7 +112,7 @@ export class TokenStore {
89
112
  /** Revoke every token of one user (user removal's companion); returns the count. */
90
113
  revokeAllFor(user) {
91
114
  return this.mutations.run(async () => {
92
- const records = this.list();
115
+ const records = pruneExpired(this.list(), Date.now());
93
116
  const kept = records.filter((record) => record.user !== user);
94
117
  const removed = records.length - kept.length;
95
118
  if (removed > 0) {