@sigrun/sigrun 0.1.5 → 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.
- package/CHANGELOG.md +136 -0
- package/README.md +352 -36
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/agent-env.js +62 -0
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -55
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -8
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B4-GYqEb.js +0 -50
- package/webui/dist/assets/index-BiSMljer.css +0 -1
|
@@ -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
|
-
*
|
|
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
|
|
320
|
-
*
|
|
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
|
|
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.
|
|
365
|
+
const handler = handlers.onSettingsChanged;
|
|
333
366
|
if (handler === undefined)
|
|
334
367
|
continue;
|
|
335
|
-
await this.#bounded(`extension ${name}
|
|
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
|
|
366
|
-
*
|
|
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
|
|
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
|
|
4
|
-
* roots — the object `openHarness` exposes as
|
|
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 (
|
|
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
|
|
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
|
|
84
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
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) {
|