@sigrun/sigrun 0.2.1 → 0.3.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 (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +234 -1
  3. package/dist/cli.js +117 -19
  4. package/dist/config.js +21 -2
  5. package/dist/extension.d.ts +7 -1
  6. package/dist/harness/ambient.js +2 -0
  7. package/dist/harness/blocks/state.js +1 -0
  8. package/dist/harness/boot.js +86 -6
  9. package/dist/harness/context.js +25 -0
  10. package/dist/harness/event-bus.js +25 -14
  11. package/dist/harness/identity/accounts.js +3 -2
  12. package/dist/harness/identity/index.js +2 -1
  13. package/dist/harness/identity/invites.js +26 -9
  14. package/dist/harness/identity/link-sweep.js +47 -0
  15. package/dist/harness/identity/typed-username.js +15 -0
  16. package/dist/harness/identity/users.js +10 -7
  17. package/dist/harness/interactions.js +2 -0
  18. package/dist/harness/loader.js +57 -1
  19. package/dist/harness/migration-snapshot.js +97 -0
  20. package/dist/harness/reports.js +100 -0
  21. package/dist/harness/seam-host.js +11 -1
  22. package/dist/harness/settings.js +52 -4
  23. package/dist/jobs/docs.js +3 -0
  24. package/dist/jobs/fire.js +2 -0
  25. package/dist/jobs/parked.js +38 -0
  26. package/dist/jobs/task.js +37 -8
  27. package/dist/serve.js +23 -14
  28. package/dist/server/auth/ip.js +9 -4
  29. package/dist/server/auth/own-host.js +115 -0
  30. package/dist/server/auth/pipeline.js +64 -48
  31. package/dist/server/auth/proxy-diagnostic.js +60 -0
  32. package/dist/server/auth/proxy-identity.js +134 -0
  33. package/dist/server/auth/proxy-only.js +28 -0
  34. package/dist/server/login/resolve.js +88 -26
  35. package/dist/server/routes/admin.js +97 -0
  36. package/dist/server/routes/login.js +8 -0
  37. package/dist/server/routes/metrics.js +18 -2
  38. package/dist/server/routes/onboarding.js +97 -37
  39. package/dist/server/session-meta.js +1 -0
  40. package/dist/server/shutdown.js +93 -16
  41. package/dist/settings/registry.js +6 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
  43. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  44. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  45. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  46. package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
  47. package/node_modules/@sigrun/shared/package.json +5 -1
  48. package/package.json +14 -12
  49. package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
  50. package/webui/dist/index.html +1 -1
  51. package/dist/server/login/usernames.js +0 -15
@@ -139,7 +139,13 @@ export type ExtensionLifecyclePayload = {
139
139
  export type ExtensionRunSettledHandler = (event: RunSettledEvent, api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
140
140
  /** A factory-result lifecycle handler for one event, deadline-bounded. */
141
141
  export type ExtensionLifecycleHandler<K extends keyof ExtensionLifecyclePayload> = (event: ExtensionLifecyclePayload[K], api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
142
- /** A factory-result `harnessShutdown` handler (no event payload — shutdown is conversation-less). */
142
+ /**
143
+ * A factory-result `harnessShutdown` handler (no event payload — shutdown is
144
+ * conversation-less). All extension handlers run CONCURRENTLY under one
145
+ * shared hook deadline, in no guaranteed relative execution or commit order.
146
+ * Handlers may still commit; a wedged handler is abandoned without undoing
147
+ * other handlers' durable commits.
148
+ */
143
149
  export type ExtensionHarnessShutdownHandler = (api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
144
150
  /**
145
151
  * The `userDeleted` payload (core hardening W1): which directory user
@@ -42,6 +42,7 @@ export const AmbientDoc = defineDocFamily({
42
42
  kind: "sigrun.ambient",
43
43
  version: 1,
44
44
  scope: "conversation",
45
+ checkpointWhen: () => false,
45
46
  history: "latest",
46
47
  fork: "current",
47
48
  family: true,
@@ -57,6 +58,7 @@ export const AmbientOwnersDoc = defineDoc({
57
58
  kind: "sigrun.ambient.owners",
58
59
  version: 1,
59
60
  scope: "session",
61
+ checkpointWhen: () => false,
60
62
  initial: () => ({ owners: {} }),
61
63
  });
62
64
  const PLACES = new Set(["status", "footer", "panel"]);
@@ -28,6 +28,7 @@ export const BlocksStateDoc = defineDocFamily({
28
28
  kind: "sigrun.blocks.state",
29
29
  version: 1,
30
30
  scope: "conversation",
31
+ checkpointWhen: () => false,
31
32
  history: "latest",
32
33
  fork: "current",
33
34
  family: true,
@@ -12,10 +12,14 @@ import { join } from "node:path";
12
12
  import { Harness, } from "@earendil-works/pi-durable";
13
13
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
14
14
  import { effectiveMethods } from "../config.js";
15
+ import { requiresProxyVouch } from "../server/auth/proxy-only.js";
15
16
  import { wireActivity } from "./activity.js";
17
+ import { snapshotBeforeMigration } from "./migration-snapshot.js";
16
18
  import { createScheduleTask } from "../jobs/task.js";
17
19
  import { createJobsService, jobWireOf } from "../jobs/index.js";
18
20
  import { reconcileJobs } from "../jobs/reconcile.js";
21
+ import { applyParkedJobs } from "../jobs/parked.js";
22
+ import { createDurableReports } from "./reports.js";
19
23
  import { registerJobObservers } from "../jobs/observe.js";
20
24
  import { JobDoc, JobRunDoc, listRevisions } from "../jobs/docs.js";
21
25
  import { EventBus } from "./event-bus.js";
@@ -49,6 +53,7 @@ import { resolveWorkerEntry, sandboxLauncher, WorkerPool } from "../sandbox/pool
49
53
  import { IsolationState } from "../sandbox/isolation.js";
50
54
  import { probeOsUsers } from "../sandbox/availability.js";
51
55
  import { createAdmissionGuard } from "./identity/users.js";
56
+ import { sweepAccountLinks } from "./identity/link-sweep.js";
52
57
  import { SessionMetaDoc } from "../server/session-meta.js";
53
58
  import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
54
59
  import { resolveBuiltins, } from "./builtins.js";
@@ -169,6 +174,25 @@ export async function openHarness(config, options = {}) {
169
174
  // I/O here (each file materializes on its first write), so this is
170
175
  // pure construction over paths boot already owns.
171
176
  const identity = options.identity ?? openIdentityStores(roots);
177
+ // The account-link sweep (username-derivation Task 8): once at boot,
178
+ // before serving — dangling links are removed, proxy-source crash
179
+ // orphans are logged with their repair command (never auto-repaired:
180
+ // the original proxy username is not derivable from the key).
181
+ // BEST-EFFORT posture: a sweep failure (e.g. a corrupt accounts.json)
182
+ // must not crash-loop a serving deployment; the stores' own readers
183
+ // still fail loud on the request paths if the file is truly corrupt.
184
+ try {
185
+ const linkSweep = await sweepAccountLinks(identity.users, identity.accounts);
186
+ if (linkSweep.removedDangling > 0) {
187
+ console.log(`identity: removed ${linkSweep.removedDangling} dangling account link(s)`);
188
+ }
189
+ for (const orphan of linkSweep.orphans) {
190
+ console.log(`identity: proxy user "${orphan}" has no proxy link; repair with: sigrun user link ${orphan} --proxy <username>`);
191
+ }
192
+ }
193
+ catch (error) {
194
+ console.warn(`identity: account-link sweep failed: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
172
196
  // The caller-supplied host wins (serving spec §1: `serve()` resolves
173
197
  // the ONE effective host — options.host → SIGRUN_LISTEN_HOST →
174
198
  // DEFAULT_LISTEN_HOST — and passes it here). The env read below is
@@ -209,8 +233,10 @@ export async function openHarness(config, options = {}) {
209
233
  // is 0600 + atomic, and the cache root is never a backup section).
210
234
  // `SIGRUN_SETUP_CODE` pins the value instead (the k8s Secret shape).
211
235
  // Claimed boots (every identity-seeded test, every post-onboarding
212
- // deploy) write nothing.
213
- if (!identity.users.isClaimed()) {
236
+ // deploy) write nothing. A proxy-only non-loopback boot writes and
237
+ // logs NOTHING either (C2): account creation there is vouch-only, so
238
+ // a setup code would be an unvouched account-creation path.
239
+ if (!identity.users.isClaimed() && !requiresProxyVouch(methods, listenHost)) {
214
240
  // `identity.cacheDir`, not `roots.cacheDir`: the claim route reads
215
241
  // `readSetupCode(identity.cacheDir)`, and an injected identity
216
242
  // bundle (the test seam) may root elsewhere than the resolved
@@ -269,7 +295,18 @@ export async function openHarness(config, options = {}) {
269
295
  now: () => Date.now(),
270
296
  managed: managedPaths,
271
297
  });
272
- const storage = await openNodeSqliteStorage(config.storagePath);
298
+ const storage = await (async () => {
299
+ // D4: preserve the last schema the previous release can read BEFORE
300
+ // openNodeSqliteStorage applies its one-way durable migration. The
301
+ // injected clock keeps snapshots deterministic in tests; a needed
302
+ // snapshot failure aborts boot here, before any migration write.
303
+ await snapshotBeforeMigration(config.storagePath, {
304
+ env,
305
+ now: options.now ?? (() => new Date()),
306
+ log: (line) => console.log(line),
307
+ });
308
+ return await openNodeSqliteStorage(config.storagePath);
309
+ })();
273
310
  const registry = createRegistry();
274
311
  // Late-filled by the loader: factories publish through the same registry
275
312
  // instance, and selection rewrites read the declared set LIVE.
@@ -660,6 +697,25 @@ export async function openHarness(config, options = {}) {
660
697
  onReadinessChange: observeReadiness,
661
698
  snapshotReadiness,
662
699
  });
700
+ // Per-model compaction overrides (D9): after the boot refresh the
701
+ // catalog is as complete as boot gets, so an override key matching no
702
+ // known model is almost always a typo (or a stale chain name). Warn
703
+ // once per unmatched key — never an error: catalogs move and an
704
+ // unmatched override is simply inert. pi-durable's own key composition
705
+ // is `${provider}/${modelId}`, so a chain-shaped model id yields a
706
+ // doubled prefix (e.g. "bifrost/bifrost/<chain>").
707
+ void bootRefreshDone.then(() => {
708
+ const overrides = config.compaction.modelOverrides ??
709
+ bootSettings.server?.compaction?.modelOverrides;
710
+ if (overrides === undefined)
711
+ return;
712
+ const known = new Set(models.getModels().map((model) => `${model.provider}/${model.id}`));
713
+ for (const key of Object.keys(overrides)) {
714
+ if (!known.has(key)) {
715
+ console.warn(`compaction override "${key}" matches no known model`);
716
+ }
717
+ }
718
+ });
663
719
  // The boot-wired single-provider refresh (W3 Task 6): the route
664
720
  // surface's refresh — the same bound policy `resolveServedModelRefreshing`
665
721
  // runs on a miss, with the same readiness observation so a provider a
@@ -724,15 +780,23 @@ export async function openHarness(config, options = {}) {
724
780
  const fileSettings = settingsStore.harnessSettings();
725
781
  const callerSettings = options.settings;
726
782
  // The ONE boot clock (options.now ?? real): the activity stamper AND
727
- // the harness clock share it (the schedule task's `runtime.sleep`
728
- // reads the harness clock; pinning the seam pins both — the jobs
729
- // tests' time-injection lever).
783
+ // the harness clock share it (the schedule task's `sleepUntil`
784
+ // deadlines live on the harness clock; pinning the seam pins both —
785
+ // the jobs tests' time-injection lever).
730
786
  const clockNow = options.now ?? (() => new Date());
787
+ // D5: reports may arrive inside Harness.open. The collector logs/counts
788
+ // synchronously and queues parks; job patches drain after binding below.
789
+ const reports = createDurableReports({
790
+ log: (line) => console.error(line),
791
+ onReport: options.onReport,
792
+ });
731
793
  const harness = await Harness.open(storage, {
732
794
  models,
733
795
  registry,
734
796
  env: envBuilder,
735
797
  now: () => clockNow().getTime(),
798
+ setTimer: options.setTimer,
799
+ onReport: reports.onReport,
736
800
  // Context-management policy (Task 8): config's `SIGRUN_COMPACTION_*`
737
801
  // tuning, with any caller-set field winning over it. Upstream merges
738
802
  // the result over `DEFAULT_COMPACTION_POLICY` per field. Task 8a: the
@@ -744,6 +808,14 @@ export async function openHarness(config, options = {}) {
744
808
  // snapshots the file's boot-time values, silently freezing the
745
809
  // queue modes and clobbering the caller seam (the round-1 defects).
746
810
  // `extensions`/`toolExecution` have no file backing — plain passthrough.
811
+ // Compaction (D9): ONLY `modelOverrides` comes from the file here —
812
+ // it has no env spelling, so this adds a file tier without touching
813
+ // the four env-backed knobs' precedence (env over file, resolved by
814
+ // loadConfig). serve already folds the file into config; the
815
+ // conditional spread keeps a config-resolved map authoritative and
816
+ // covers direct `openHarness` boots. Read ONCE (boot-time), so a
817
+ // PATCH is restart-pending exactly like the other compaction keys.
818
+ // Order: caller seam > config (env over file) > this file fallback.
747
819
  settings: {
748
820
  get steeringMode() {
749
821
  return callerSettings?.steeringMode ?? fileSettings.steeringMode;
@@ -767,6 +839,10 @@ export async function openHarness(config, options = {}) {
767
839
  toolExecution: callerSettings?.toolExecution,
768
840
  compaction: {
769
841
  ...config.compaction,
842
+ ...(config.compaction.modelOverrides === undefined &&
843
+ bootSettings.server?.compaction?.modelOverrides !== undefined
844
+ ? { modelOverrides: bootSettings.server.compaction.modelOverrides }
845
+ : {}),
770
846
  ...callerSettings?.compaction,
771
847
  },
772
848
  },
@@ -786,6 +862,9 @@ export async function openHarness(config, options = {}) {
786
862
  // JobsService — happens after this point.
787
863
  jobRuntimeDeps.harness = harness;
788
864
  jobRuntimeDeps.storage = storage;
865
+ // Drain the restart-limit parks reported during open before scheduling
866
+ // resumes. A failed status patch is logged and dropped by applyParkedJobs.
867
+ await applyParkedJobs(harness, reports.takeParked(), BACKGROUND_CONTEXT);
789
868
  // The memory-seam event bus (Tasks 2-4): subscribe BEFORE `resume()` so
790
869
  // recovery settles (faulted/orphaned submissions the scheduler drains)
791
870
  // are observed like any other commit. Extension handlers register under
@@ -1063,6 +1142,7 @@ export async function openHarness(config, options = {}) {
1063
1142
  userToolSweep,
1064
1143
  models,
1065
1144
  jobs,
1145
+ reports,
1066
1146
  jobRunTotals: () => jobRunTotals,
1067
1147
  defaultModel,
1068
1148
  providerCollisions: sink.collisions,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Normalize the extension contract's structural invocation context to the
3
+ * full chord Context required by pi-durable 1.4.0.
4
+ *
5
+ * Extensions deliberately depend only on HandlerContext (`abortSignal`), so
6
+ * they may pass a plain object. Durable publishes read telemetry and other
7
+ * context values, however; forwarding that literal would make the first
8
+ * entry/submission write fail at the host boundary. Deriving from
9
+ * BACKGROUND_CONTEXT supplies the runtime defaults while preserving caller
10
+ * cancellation. A real Context passes through BY IDENTITY so its telemetry
11
+ * parent and application values are never discarded.
12
+ */
13
+ import { BACKGROUND_CONTEXT, withAbortSignal, } from "@earendil-works/chord/context";
14
+ /** Upgrade a structural handler context, or retain an existing chord context. */
15
+ export function toDurableContext(context) {
16
+ const candidate = context;
17
+ if (typeof candidate.telemetry === "object" &&
18
+ candidate.telemetry !== null &&
19
+ typeof candidate.value === "function") {
20
+ return context;
21
+ }
22
+ return context.abortSignal === undefined
23
+ ? BACKGROUND_CONTEXT
24
+ : withAbortSignal(context.abortSignal, BACKGROUND_CONTEXT);
25
+ }
@@ -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 { toDurableContext } from "./context.js";
67
68
  import { beginUserDeletion } from "./stores.js";
68
69
  /** Project a SessionMeta doc value into the tracked basis. */
69
70
  function metaBasisOf(value) {
@@ -84,9 +85,9 @@ function metaBasisOf(value) {
84
85
  return basis;
85
86
  }
86
87
  /** How long shutdown waits for in-flight worker runs before abandoning (spec §3). */
87
- const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
88
+ export const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
88
89
  /** The default last-admission window (gateway spec §8.13). */
89
- const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
90
+ export const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
90
91
  /** Short pause between live-run checks while the commit side lane works. */
91
92
  const SHUTDOWN_SETTLE_POLL_MS = 10;
92
93
  /**
@@ -398,10 +399,13 @@ export class EventBus {
398
399
  * 3. drain pending settles and wait (bounded) for placed live runs —
399
400
  * a run settling during this last admission window delivers before
400
401
  * the shutdown flush; a wedged run logs its conversation ids;
401
- * 4. deliver `harnessShutdown` to every extension in install order,
402
- * each deadline-bounded. Handlers may commit — admission is still
403
- * open in this window (the seal is `Harness.close`, the caller's
404
- * next step), which is exactly the flush window the spec promises;
402
+ * 4. deliver `harnessShutdown` to every extension CONCURRENTLY under
403
+ * ONE shared hook deadline (D6). Handlers may commit — admission is
404
+ * still open in this window (the seal is `Harness.close`, the
405
+ * caller's next step), which is exactly the flush window the spec
406
+ * promises — but their relative execution and commit order is not
407
+ * guaranteed. A wedged handler is abandoned at the shared deadline;
408
+ * other handlers' completed commits remain durable;
405
409
  * 5. close the bus (idempotent; boot's `subscribeClose` wiring makes a
406
410
  * later `harness.close` a no-op here).
407
411
  */
@@ -418,11 +422,18 @@ export class EventBus {
418
422
  // harnessShutdown carries no conversation: the conversation-scoped
419
423
  // reads resolve empty and `document` serves any conversation.
420
424
  const api = this.#apiFor(undefined);
421
- for (const [name, handlers] of this.#extensions) {
422
- const handler = handlers.harnessShutdown;
423
- if (handler === undefined)
424
- continue;
425
- await this.#bounded(`extension ${name} harnessShutdown`, () => handler(api, context));
425
+ const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers.harnessShutdown !== undefined);
426
+ const calls = registered.map(([name, handlers]) => this.#bounded(`extension ${name} harnessShutdown`, () => handlers.harnessShutdown(api, context)));
427
+ let timer;
428
+ const sharedDeadline = new Promise((resolve) => {
429
+ timer = setTimeout(resolve, Math.max(0, this.#hookDeadlineMs));
430
+ });
431
+ try {
432
+ await Promise.race([Promise.allSettled(calls), sharedDeadline]);
433
+ }
434
+ finally {
435
+ if (timer !== undefined)
436
+ clearTimeout(timer);
426
437
  }
427
438
  this.close();
428
439
  }
@@ -1266,15 +1277,15 @@ export class EventBus {
1266
1277
  return {
1267
1278
  entry: async () => undefined,
1268
1279
  entries: async () => ({ items: [] }),
1269
- document: ((token, id, context) => harness.snapshot(token, id, context)),
1280
+ document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
1270
1281
  };
1271
1282
  }
1272
1283
  return {
1273
1284
  entry: (id, context) => this.#readEntry(conversationId, id, context),
1274
1285
  entries: (query, limit, cursor, context) => this.#entriesOf(conversationId, query, limit, cursor, context),
1275
1286
  // The snapshot overloads are generic per call; the narrowed shape
1276
- // carries one conversation-doc read, forwarded verbatim.
1277
- document: ((token, id, context) => harness.snapshot(token, id, context)),
1287
+ // carries one conversation-doc read with the D1 context upgrade.
1288
+ document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
1278
1289
  };
1279
1290
  }
1280
1291
  /** `Conversation.entries` over the settled conversation (read failures resolve empty). */
@@ -35,6 +35,7 @@ const VIAS = new Set([
35
35
  "self",
36
36
  "pairing",
37
37
  "admin",
38
+ "proxy",
38
39
  ]);
39
40
  /**
40
41
  * A `(provider, subject)` already linked to a different user. The
@@ -52,8 +53,8 @@ export class AccountLinkConflictError extends Error {
52
53
  this.subject = subject;
53
54
  }
54
55
  }
55
- /** `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
56
- const PROVIDER_PATTERN = /^(login|chat):[a-z0-9][a-z0-9._:-]*$/;
56
+ /** `proxy` / `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
57
+ const PROVIDER_PATTERN = /^(proxy|(login|chat):[a-z0-9][a-z0-9._:-]*)$/;
57
58
  /** A subject is opaque, non-empty, and bounded. */
58
59
  const SUBJECT_MAX_LENGTH = 512;
59
60
  /**
@@ -21,7 +21,8 @@ import { AccountStore, AccountLinkConflictError, } from "./accounts.js";
21
21
  import { TokenStore, generateToken, hashToken, } from "./tokens.js";
22
22
  import { InviteStore, DEFAULT_INVITE_EXPIRY_MS, } from "./invites.js";
23
23
  import { ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, } from "./setup-code.js";
24
- export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, };
24
+ import { sweepAccountLinks, } from "./link-sweep.js";
25
+ export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, sweepAccountLinks, };
25
26
  /** The file names each store owns under its root. */
26
27
  const USERS_FILE = "users.json";
27
28
  const TOKENS_FILE = "api-tokens.json";
@@ -33,8 +33,17 @@ function matchesCode(record, digest) {
33
33
  return false;
34
34
  return timingSafeEqual(Buffer.from(record.sha256, "hex"), Buffer.from(digest, "hex"));
35
35
  }
36
+ /** Locate the valid, unused row for one code, or undefined without effects. */
37
+ function validInviteIndex(records, code, now) {
38
+ const digest = hashToken(code);
39
+ const index = records.findIndex((record) => matchesCode(record, digest));
40
+ if (index === -1)
41
+ return -1;
42
+ const record = records[index];
43
+ return record.usedBy === undefined && now <= record.expiresAt ? index : -1;
44
+ }
36
45
  /**
37
- * The file-backed invite store. `redeem` is the single mutator; both
46
+ * The file-backed invite store. `redeem` is the single mutator; all three
38
47
  * methods serialize on the store's {@link MutationQueue}.
39
48
  */
40
49
  export class InviteStore {
@@ -66,6 +75,19 @@ export class InviteStore {
66
75
  return { id: record.id, code, expiresAt: record.expiresAt };
67
76
  });
68
77
  }
78
+ /**
79
+ * Validate a code without consuming it: the same digest, single-use, and
80
+ * expiry rules as {@link redeem}, returning its grant while leaving the row
81
+ * untouched. Callers must still redeem last; this is a preflight, not a
82
+ * reservation against another writer.
83
+ */
84
+ peek(code, now = Date.now()) {
85
+ return this.mutations.run(async () => {
86
+ const records = this.list();
87
+ const index = validInviteIndex(records, code, now);
88
+ return index === -1 ? undefined : { admin: records[index].admin };
89
+ });
90
+ }
69
91
  /**
70
92
  * Redeem one code for `user`: undefined when no record matches the
71
93
  * digest, when the row is already used (single-use, forever), or
@@ -77,15 +99,10 @@ export class InviteStore {
77
99
  redeem(code, user, now = Date.now()) {
78
100
  return this.mutations.run(async () => {
79
101
  const records = this.list();
80
- const digest = hashToken(code);
81
- const index = records.findIndex((record) => matchesCode(record, digest));
82
- const record = index === -1 ? undefined : records[index];
83
- if (record === undefined)
84
- return undefined;
85
- if (record.usedBy !== undefined)
86
- return undefined;
87
- if (now > record.expiresAt)
102
+ const index = validInviteIndex(records, code, now);
103
+ if (index === -1)
88
104
  return undefined;
105
+ const record = records[index];
89
106
  records[index] = { ...record, usedBy: user };
90
107
  await writeJson(this.path, records);
91
108
  return { admin: record.admin };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The boot-time account-link sweep (username-derivation Task 8, spec
3
+ * §11.3): runs ONCE at boot, after the identity stores open and before
4
+ * the listener binds — never on the request path, and never inside the
5
+ * claim lock (boot is single-threaded before serving).
6
+ *
7
+ * Two repair classes:
8
+ *
9
+ * - DANGLING LINKS: an `accounts.json` row whose `user` no longer
10
+ * exists (a hand-edited file, or a crash between a user deletion and
11
+ * its link cleanup). The sweep REMOVES them — the link names an
12
+ * identity that cannot resolve, so keeping it only preserves a
13
+ * conflict block against a future re-add.
14
+ *
15
+ * - CRASH ORPHANS: a `source === "proxy"` user with NO proxy link
16
+ * (Task 3's crash window: the user row landed, the link write never
17
+ * did). The sweep LISTS them, never repairs: the original proxy
18
+ * username is not derivable from the directory key (collisions
19
+ * suffix it), so the operator repairs with
20
+ * `sigrun user link <key> --proxy <username>`.
21
+ */
22
+ /**
23
+ * Remove links whose user is gone and list proxy-source users with no
24
+ * proxy link. After the initial reads and the serialized unlinks, the
25
+ * orphan pass deliberately RE-READS accounts.json so a removed link
26
+ * cannot leave stale state in the orphan list.
27
+ */
28
+ export async function sweepAccountLinks(users, accounts) {
29
+ const known = new Set(users.list().map((record) => record.user));
30
+ const links = accounts.list();
31
+ const dangling = links.filter((link) => !known.has(link.user));
32
+ let removedDangling = 0;
33
+ for (const link of dangling) {
34
+ if (await accounts.unlink(link.provider, link.subject)) {
35
+ removedDangling += 1;
36
+ }
37
+ }
38
+ const linkedProxyUsers = new Set(accounts
39
+ .list()
40
+ .filter((link) => link.provider === "proxy")
41
+ .map((link) => link.user));
42
+ const orphans = users
43
+ .list()
44
+ .filter((record) => record.source === "proxy" && !linkedProxyUsers.has(record.user))
45
+ .map((record) => record.user);
46
+ return { removedDangling, orphans };
47
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Resolve an operator-typed username to its canonical key. Typed entry
3
+ * points refuse collisions rather than silently suffixing them; the caller
4
+ * owns the surrounding claim/admission locking and supplies `taken`.
5
+ */
6
+ import { deriveUsername } from "@sigrun/shared/usernames";
7
+ /** Derive a typed username, refusing unusable input and existing keys. */
8
+ export function normalizeTypedUsername(input, taken) {
9
+ const key = deriveUsername(input);
10
+ if (key === undefined)
11
+ return { ok: false, error: "invalid_username" };
12
+ if (taken(key))
13
+ return { ok: false, error: "username_taken" };
14
+ return { ok: true, key };
15
+ }
@@ -11,9 +11,8 @@
11
11
  * after the first add (`isClaimed`); `admin` is a property of the USER
12
12
  * (tokens carry their user's rights — never their own admin flag).
13
13
  */
14
+ import { isUsernameKey } from "@sigrun/shared/usernames";
14
15
  import { MutationQueue, readRecords, writeJson } from "./json-store.js";
15
- /** The username rule (spec §5.8): lowercase-first, then `[a-z0-9._-]`. */
16
- const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
17
16
  /**
18
17
  * Strip the retired C8 field at every write boundary. A legacy on-disk
19
18
  * value is readable for compatibility, but never survives another write
@@ -61,9 +60,13 @@ export class UserDirectory {
61
60
  async writeRecords(records) {
62
61
  await writeJson(this.path, records.map(withoutLegacyGroupAdmin));
63
62
  }
64
- /** Run the same admission guard used by add, before consuming an invite. */
63
+ /** Run the shared admission guard before consuming an invite. Writes nothing. */
65
64
  checkAdmission() {
66
- this.options.admit?.(this.list().length);
65
+ this.checkAdmissionFor(this.list());
66
+ }
67
+ checkAdmissionFor(records) {
68
+ if (records.length >= 1)
69
+ this.options.admit?.(records.length);
67
70
  }
68
71
  /** Every user, insertion order (the first add is the onboarding claim). */
69
72
  list() {
@@ -88,15 +91,15 @@ export class UserDirectory {
88
91
  */
89
92
  add(user) {
90
93
  return this.mutations.run(async () => {
91
- if (!USERNAME_PATTERN.test(user.user)) {
92
- throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$`);
94
+ if (!isUsernameKey(user.user)) {
95
+ throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$ and be at most 128 characters`);
93
96
  }
94
97
  const records = this.list();
95
98
  if (records.some((record) => record.user === user.user)) {
96
99
  throw new Error(`user "${user.user}" already exists`);
97
100
  }
98
101
  if (records.length >= 1) {
99
- this.options.admit?.(records.length);
102
+ this.checkAdmissionFor(records);
100
103
  if (records.length === 1 && records[0].workspace === undefined && this.options.firstWorkspace !== undefined) {
101
104
  records[0] = { ...records[0], workspace: this.options.firstWorkspace };
102
105
  }
@@ -62,6 +62,7 @@ export const InteractionDoc = defineDocFamily({
62
62
  kind: "sigrun.interaction",
63
63
  version: 1,
64
64
  scope: "conversation",
65
+ checkpointWhen: () => false,
65
66
  history: "latest",
66
67
  fork: "current",
67
68
  family: true,
@@ -76,6 +77,7 @@ export const InteractionsIndexDoc = defineDoc({
76
77
  kind: "sigrun.interactions.index",
77
78
  version: 1,
78
79
  scope: "session",
80
+ checkpointWhen: () => false,
79
81
  initial: () => ({ pending: {} }),
80
82
  });
81
83
  /** Terminal statuses: answering one of these is a no-op (first wins).
@@ -89,6 +89,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
89
89
  import { createRequire } from "node:module";
90
90
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
91
91
  import { fileURLToPath } from "node:url";
92
+ import { satisfies, validRange } from "semver";
92
93
  import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
93
94
  import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
94
95
  import { slugifyName } from "./stores.js";
@@ -997,6 +998,57 @@ function checkHostModules(moduleDir) {
997
998
  `host runs ${hostVersion} (same major, allowed)`);
998
999
  }
999
1000
  }
1001
+ /**
1002
+ * The Earendil host modules whose DECLARED peer ranges the loader checks.
1003
+ * Other peers (typebox and @sigrun/sigrun) have different compatibility
1004
+ * contracts and are deliberately outside this guard.
1005
+ */
1006
+ const DECLARED_PEER_MODULES = [
1007
+ "@earendil-works/pi-durable",
1008
+ "@earendil-works/chord",
1009
+ "@earendil-works/pi-ai",
1010
+ ];
1011
+ /**
1012
+ * The declared-peer guard (pi-durable 1.4.0 D7): after the resolved-version
1013
+ * guard, reject an Earendil peer range that excludes the running host.
1014
+ * Non-semver specifiers (file:, link:, git:, URLs, workspace:) and wildcards
1015
+ * pass; a missing or unreadable manifest also passes because the guard never
1016
+ * quarantines what it cannot read.
1017
+ *
1018
+ * @param moduleDir the extension ENTRY's directory; the owning package
1019
+ * manifest is the nearest manifest at or above it.
1020
+ * @returns the quarantine reason, or undefined when the declaration admits
1021
+ * the host or cannot be evaluated.
1022
+ */
1023
+ function checkDeclaredPeers(moduleDir) {
1024
+ const manifestPath = nearestManifest(moduleDir);
1025
+ if (manifestPath === undefined)
1026
+ return undefined;
1027
+ let peers;
1028
+ try {
1029
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
1030
+ if (typeof parsed.peerDependencies !== "object" ||
1031
+ parsed.peerDependencies === null ||
1032
+ Array.isArray(parsed.peerDependencies)) {
1033
+ return undefined;
1034
+ }
1035
+ peers = parsed.peerDependencies;
1036
+ }
1037
+ catch {
1038
+ return undefined;
1039
+ }
1040
+ for (const name of DECLARED_PEER_MODULES) {
1041
+ const range = peers[name];
1042
+ if (typeof range !== "string" || validRange(range) === null)
1043
+ continue;
1044
+ const hostVersion = hostModuleVersion(name);
1045
+ if (hostVersion !== undefined &&
1046
+ !satisfies(hostVersion, range)) {
1047
+ return `peer ${name} ${range} excludes host ${hostVersion}`;
1048
+ }
1049
+ }
1050
+ return undefined;
1051
+ }
1000
1052
  /**
1001
1053
  * The extension module's DIRECTORY — the anchor a contributed bundle's
1002
1054
  * relative `dir` resolves against (build-and-webui Task 6: an
@@ -1497,8 +1549,12 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1497
1549
  // here rides the existing per-extension try (quarantine, boot
1498
1550
  // continues) exactly like the reserved-section check below.
1499
1551
  const moduleDir = extensionModuleDir(loaded);
1500
- if (moduleDir !== undefined)
1552
+ if (moduleDir !== undefined) {
1501
1553
  checkHostModules(moduleDir);
1554
+ const peerReason = checkDeclaredPeers(moduleDir);
1555
+ if (peerReason !== undefined)
1556
+ throw new Error(peerReason);
1557
+ }
1502
1558
  // Two-phase host: the factory receives the host at CALL time, but
1503
1559
  // the extension name and kinds it declares are only known from its
1504
1560
  // RESULT. The loader therefore invokes the host factory TWICE with