@sigrun/sigrun 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The resolved caller (core hardening W1 Task 3, spec §5.2/§5.8): what
3
+ * the auth pipeline stamps on every request and what `whoami` serves.
4
+ * `method` names the proof — the empty-method naive path ("none"), a
5
+ * verified token ("token"), or a trusted forward-auth header set
6
+ * ("proxy"). `service` marks the env service token (it skips the
7
+ * setup gate; it is not a directory user).
8
+ *
9
+ * SINGLE-SOURCE BRIDGE (core hardening W5 Task 2, the Task 1 review's
10
+ * binding carry-forward): the shape is DECLARED ONCE, on the extension
11
+ * contract (`Principal` in src/extension.ts, shipped as the types-only
12
+ * `@sigrun/sigrun/extension` subpath) — this server-side name is a re-export of
13
+ * it, so the pipeline, the routes, and every extension handler share
14
+ * one spelling that cannot drift. The bridge lives HERE (server-side)
15
+ * because the contract's import allowlist is pi-durable/pi-ai/shared:
16
+ * the contract cannot import this module.
17
+ */
18
+ /**
19
+ * Whether `p` may act as the superuser (spec §5.8): the unauthenticated
20
+ * naive principal on a loopback workstation (`method "none"` with no
21
+ * user header — the single-operator default) or a service token.
22
+ * Authenticated principals are superusers only via `admin`.
23
+ */
24
+ export function isSuperuser(p) {
25
+ return (p.method === "none" && p.user === undefined) || p.service;
26
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The slash-command catalog (Task 7, pi parity): a THIN registry of command
3
+ * METADATA — name, description, args hint, and the §5 endpoint each command
4
+ * drives. Deliberately NO server-side `execute` indirection: the composer
5
+ * owns the client calls, the registry only tells it what exists and where
6
+ * it goes (the task plan's "thin catalog over the endpoints Tasks 4–6
7
+ * built").
8
+ *
9
+ * SPLIT (build-and-webui Task 1): the WIRE types (`SlashCommandEndpoint`,
10
+ * `SlashCommand`, `CommandsResponse`) live in the `@sigrun/shared` package
11
+ * (`shared/src/commands.ts` — the canonical declarations both clients
12
+ * import); this module owns the CATALOG runtime and re-exports the types
13
+ * for server-side importers.
14
+ *
15
+ * Scope (controller rulings): ONLY commands mapping to EXISTING endpoints.
16
+ * `/login`/`/logout` are core-spec §6.4 exclusions (credentials never cross
17
+ * the API), and `/skill:name` + prompt templates were this slice's
18
+ * deferral — Task 2c resolves them as `kind: "expand"` catalog entries
19
+ * (no endpoint: the composer inserts `/name ` into the text and the
20
+ * SERVER expands it at admission; see src/commands/expand.ts). `/model`
21
+ * and `/thinking` ARE included: both map cleanly onto `POST
22
+ * .../configure`'s existing `{model?, thinking?}` body (each sends
23
+ * exactly one of the two fields).
24
+ *
25
+ * EXTENSION COMMANDS (core hardening W5 Task 3, spec §9.3): the core
26
+ * table below stays untouched; `buildCommandCatalog` merges the
27
+ * loader-collected extension declarations into the served list.
28
+ * Extension entries carry the contributing `extension` slug and the ONE
29
+ * server-side invoke endpoint (`POST /v1/conversations/:id/commands/
30
+ * <name>`) — for those, unlike core commands, the composer is a pure
31
+ * POST-and-render client.
32
+ */
33
+ /** The catalog, keyed by bare name. */
34
+ export const SLASH_COMMANDS = new Map([
35
+ {
36
+ name: "new",
37
+ description: "Start a new conversation",
38
+ endpoint: { method: "POST", path: "/v1/conversations" },
39
+ },
40
+ {
41
+ name: "fork",
42
+ description: "Fork this conversation at an entry (default: newest)",
43
+ argsHint: "[at]",
44
+ endpoint: {
45
+ method: "POST",
46
+ path: "/v1/conversations/:id/fork",
47
+ },
48
+ },
49
+ {
50
+ name: "compact",
51
+ description: "Compact this conversation's history",
52
+ argsHint: "[instructions]",
53
+ endpoint: {
54
+ method: "POST",
55
+ path: "/v1/conversations/:id/compact",
56
+ },
57
+ },
58
+ {
59
+ name: "export",
60
+ description: "Download this conversation as JSONL",
61
+ endpoint: { method: "GET", path: "/v1/conversations/:id/export" },
62
+ },
63
+ {
64
+ name: "import",
65
+ description: "Create a conversation from pasted JSONL",
66
+ argsHint: "<jsonl>",
67
+ endpoint: {
68
+ method: "POST",
69
+ path: "/v1/conversations/import",
70
+ },
71
+ },
72
+ {
73
+ name: "name",
74
+ description: "Name this conversation",
75
+ argsHint: "<name>",
76
+ endpoint: {
77
+ method: "PATCH",
78
+ path: "/v1/conversations/:id/meta",
79
+ },
80
+ },
81
+ {
82
+ name: "model",
83
+ description: "Switch this conversation's model",
84
+ argsHint: "<provider/model>",
85
+ endpoint: {
86
+ method: "POST",
87
+ path: "/v1/conversations/:id/configure",
88
+ },
89
+ },
90
+ {
91
+ name: "thinking",
92
+ description: "Set this conversation's thinking level",
93
+ argsHint: "<level>",
94
+ endpoint: {
95
+ method: "POST",
96
+ path: "/v1/conversations/:id/configure",
97
+ },
98
+ },
99
+ {
100
+ name: "jobs",
101
+ description: "List jobs (name, next run, last outcome)",
102
+ endpoint: { method: "GET", path: "/v1/jobs" },
103
+ },
104
+ ].map((command) => [command.name, command]));
105
+ /**
106
+ * Merge the core slash-command table with every extension's `commands`
107
+ * declaration (spec §9.3). Called ONCE per server (the catalog is a boot
108
+ * constant, like the model catalog); the two collision rules:
109
+ *
110
+ * - CORE names are reserved: an extension command naming one is DROPPED
111
+ * with one console warning and NOT recorded in `collisions` — a core
112
+ * command is not replaceable, so there is no winner to report. The
113
+ * dropped name never reaches the served list NOR `runnable` (invoking
114
+ * it is 404 `command_not_found`, never the extension's run).
115
+ * - ext-vs-ext collisions are LAST-REGISTERED-WINS (the load order the
116
+ * loader collected): warned, recorded in `collisions`, and the winner
117
+ * replaces the loser's catalog entry and `runnable` binding in place.
118
+ */
119
+ export function buildCommandCatalog(core, bindings) {
120
+ const commands = [...core.values()];
121
+ const collisions = [];
122
+ const runnable = new Map();
123
+ for (const binding of bindings) {
124
+ for (const contribution of binding.commands) {
125
+ if (core.has(contribution.name)) {
126
+ console.warn(`extension command dropped: "${binding.extension}" declares ` +
127
+ `command "${contribution.name}" — core command names are reserved`);
128
+ continue;
129
+ }
130
+ const command = {
131
+ name: contribution.name,
132
+ description: contribution.description,
133
+ ...(contribution.args === undefined
134
+ ? {}
135
+ : { argsHint: contribution.args }),
136
+ extension: binding.extension,
137
+ endpoint: {
138
+ method: "POST",
139
+ path: `/v1/conversations/:id/commands/${contribution.name}`,
140
+ },
141
+ };
142
+ const previous = runnable.get(contribution.name);
143
+ if (previous !== undefined) {
144
+ collisions.push({
145
+ kind: "command",
146
+ name: contribution.name,
147
+ previous: previous.extension,
148
+ contributor: binding.extension,
149
+ });
150
+ console.warn(`extension command collision: "${binding.extension}" replaces ` +
151
+ `"${previous.extension}" command "${contribution.name}" ` +
152
+ "(last-registered-wins)");
153
+ // Replace in place: the loser's position in the served list
154
+ // is the winner's (stable ordering, one entry per name).
155
+ const index = commands.findIndex((entry) => entry.name === contribution.name);
156
+ if (index !== -1)
157
+ commands[index] = command;
158
+ }
159
+ else {
160
+ commands.push(command);
161
+ }
162
+ runnable.set(contribution.name, {
163
+ command,
164
+ extension: binding.extension,
165
+ run: contribution.run,
166
+ });
167
+ }
168
+ }
169
+ return { commands, collisions, runnable };
170
+ }
171
+ // ─── The expand entries (Task 2c: /skill:name and prompt templates) ─────────
172
+ /**
173
+ * Build the `kind: "expand"` catalog entries for the discovered skills
174
+ * and templates (Task 2c): every skill becomes `skill:<name>` (including
175
+ * model-hidden ones — `/skill:name` works for them; the description is
176
+ * the skill's frontmatter description, no args hint), every template
177
+ * becomes `{name, description, argsHint?}` from its frontmatter. The
178
+ * caller (the served list) appends these AFTER the core + extension
179
+ * commands; they carry NO endpoint — the composer inserts `/name ` into
180
+ * the text and the server expands the submission at admission.
181
+ */
182
+ export function expandCommandEntries(skills, templates) {
183
+ return [
184
+ ...skills.map((skill) => ({
185
+ name: `skill:${skill.name}`,
186
+ description: skill.description,
187
+ kind: "expand",
188
+ })),
189
+ ...templates.map((template) => ({
190
+ name: template.name,
191
+ description: template.description,
192
+ ...(template.argsHint === undefined
193
+ ? {}
194
+ : { argsHint: template.argsHint }),
195
+ kind: "expand",
196
+ })),
197
+ ];
198
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * §5 content-part resolution (Task 9b, Review Focus #1).
3
+ *
4
+ * `POST /v1/conversations/:id/submissions` accepts wire parts —
5
+ * `{type:"text",text}` and `{type:"image"|"file",path,mime?}` with
6
+ * workspace-relative `path` — and this module resolves them to the inline
7
+ * `UserInput` content pi-durable stores (`TextContent` / base64
8
+ * `ImageContent`) at SUBMIT time, through the conversation's `ExecutionEnv`
9
+ * (the same `envBuilder` seam tool calls use, so remote workspaces stay
10
+ * correct, spec §3.4/§4.5).
11
+ *
12
+ * Resolution never silently drops a part: a `not_found` path becomes an
13
+ * explicit error text part addressed to the model (it stays visible in the
14
+ * transcript, and the model can react), and any other `FileError`
15
+ * (permission denied, aborted, …) fails the whole request loud — the repo
16
+ * rule "fail-loud on workspace read errors except not_found". A binary file
17
+ * part that is not valid UTF-8 also becomes an explicit error part
18
+ * (`invalid_utf8`) rather than mojibake text.
19
+ */
20
+ /** The explicit error-part marker for a part that could not be resolved. */
21
+ function errorPart(path, reason) {
22
+ return { type: "text", text: `[part error] ${path}: ${reason}` };
23
+ }
24
+ /** Extension → mime type for image parts without an explicit `mime`. */
25
+ const IMAGE_MIME_BY_EXTENSION = {
26
+ png: "image/png",
27
+ jpg: "image/jpeg",
28
+ jpeg: "image/jpeg",
29
+ gif: "image/gif",
30
+ webp: "image/webp",
31
+ };
32
+ /**
33
+ * Validate a submission `input` body: the §5 content-part array, or a bare
34
+ * string (upstream `UserInput` also admits a plain string — simple clients
35
+ * submit text without wrapping it in a parts array; the server maps it to
36
+ * one text part). Part paths must be workspace-relative (no absolute paths,
37
+ * no `..` traversal — `NodeExecutionEnv` resolves against its `cwd` but
38
+ * accepts absolute paths as-is, so the server must confine client paths
39
+ * itself).
40
+ */
41
+ export function parseSubmissionInput(input) {
42
+ if (typeof input === "string") {
43
+ return { ok: true, parts: [{ type: "text", text: input }] };
44
+ }
45
+ if (!Array.isArray(input) || input.length === 0)
46
+ return { ok: false };
47
+ const parts = [];
48
+ for (const candidate of input) {
49
+ if (typeof candidate !== "object" || candidate === null) {
50
+ return { ok: false };
51
+ }
52
+ const part = candidate;
53
+ if (part.type === "text") {
54
+ if (typeof part.text !== "string")
55
+ return { ok: false };
56
+ parts.push({ type: "text", text: part.text });
57
+ continue;
58
+ }
59
+ if (part.type === "image" || part.type === "file") {
60
+ if (typeof part.path !== "string" || !isConfinedPath(part.path)) {
61
+ return { ok: false };
62
+ }
63
+ if (part.mime !== undefined && typeof part.mime !== "string") {
64
+ return { ok: false };
65
+ }
66
+ parts.push({
67
+ type: part.type,
68
+ path: part.path,
69
+ ...(part.mime === undefined ? {} : { mime: part.mime }),
70
+ });
71
+ continue;
72
+ }
73
+ return { ok: false };
74
+ }
75
+ return { ok: true, parts };
76
+ }
77
+ /**
78
+ * A path the server will hand to the workspace env: relative, and without a
79
+ * `..` segment. `.` and empty are fine (`.` is the workspace root in list
80
+ * calls).
81
+ */
82
+ export function isConfinedPath(path) {
83
+ if (path.length === 0)
84
+ return true;
85
+ if (path.startsWith("/") || path.includes("\\"))
86
+ return false;
87
+ const segments = path.split("/");
88
+ return !segments.includes("..");
89
+ }
90
+ /** Read one workspace file strictly-UTF-8: `undefined` when not valid UTF-8. */
91
+ function decodeStrictUtf8(bytes) {
92
+ try {
93
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ /** Resolve one wire part against the conversation's env. */
100
+ async function resolvePart(part, env, context) {
101
+ if (part.type === "text")
102
+ return { type: "text", text: part.text };
103
+ const read = await env.readBinaryFile(part.path, context);
104
+ if (!read.ok) {
105
+ if (read.error.code === "not_found") {
106
+ // Review Focus #1: explicit error part, never a silent drop.
107
+ return errorPart(part.path, "not_found");
108
+ }
109
+ // Every other workspace failure is a server-side fault: fail loud.
110
+ throw read.error;
111
+ }
112
+ if (part.type === "image") {
113
+ const mime = part.mime ??
114
+ IMAGE_MIME_BY_EXTENSION[extensionOf(part.path)] ??
115
+ "application/octet-stream";
116
+ return {
117
+ type: "image",
118
+ data: Buffer.from(read.value).toString("base64"),
119
+ mimeType: mime,
120
+ };
121
+ }
122
+ const text = decodeStrictUtf8(read.value);
123
+ if (text === undefined) {
124
+ return errorPart(part.path, "invalid_utf8");
125
+ }
126
+ return { type: "text", text };
127
+ }
128
+ function extensionOf(path) {
129
+ const dot = path.lastIndexOf(".");
130
+ return dot === -1 ? "" : path.slice(dot + 1).toLowerCase();
131
+ }
132
+ /**
133
+ * Resolve a validated parts array to inline content. Rejects (throws the
134
+ * `FileError`) on any non-`not_found` workspace failure — fail-loud.
135
+ */
136
+ export async function resolveParts(parts, env, context) {
137
+ const resolved = [];
138
+ for (const part of parts) {
139
+ resolved.push(await resolvePart(part, env, context));
140
+ }
141
+ return resolved;
142
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The shared server context (server restructure W0): what every route
3
+ * module receives from `createServer` instead of capturing harness or
4
+ * config state directly — one immutable record, so route modules stay
5
+ * pure functions of `(app, ctx)`.
6
+ *
7
+ * This module also owns the Fastify request augmentation (`declare
8
+ * module "fastify"`): the pipeline's `principal` and the
9
+ * conversation pre-handler's `conversation` decoration are declared
10
+ * here once, so every module that imports this context (a type-only
11
+ * import suffices) sees the augmented fields.
12
+ */
13
+ export {};
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The conversation pre-handler: one hook every `:id` route declares, so
3
+ * conversation resolution, the unknown-id 404, and the §5.9 content-
4
+ * access check are uniform across route modules. It runs BEFORE the
5
+ * handler — the SSE route's 404 lands as plain JSON before
6
+ * `reply.hijack()` — and after Fastify's param validation (routes keep
7
+ * `schema: { params: ID_PARAMS_SCHEMA }`).
8
+ *
9
+ * Access (spec §5.9): the hook snapshots the session-meta doc and
10
+ * resolves `accessOf(principal, meta)`:
11
+ *
12
+ * - `"none"` (hidden) → 404 `conversation_not_found` — a hidden
13
+ * conversation is indistinguishable from an unknown one (no 403 tell);
14
+ * - `"read"` on a WRITE route → 403 `forbidden` (the route names its need
15
+ * with `loadConversation(ctx, need)`; GETs are `"read"`, everything
16
+ * else is `"write"`).
17
+ *
18
+ * The polling surfaces (GET interactions/ambient) do NOT take this hook —
19
+ * they apply the visibility half of the rule inside their handlers (a
20
+ * channel polling a torn-down conversation must not error; see
21
+ * routes/interactions.ts).
22
+ */
23
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
24
+ import { accessOf } from "@sigrun/shared/access";
25
+ import { isSuperuser } from "./auth/principal.js";
26
+ import { conversationId } from "./helpers.js";
27
+ import { SessionMetaDoc } from "./session-meta.js";
28
+ /**
29
+ * Build the pre-handler: resolve `params.id` through the harness's
30
+ * storage-backed line, enforce the §5.9 access rule for `need`, and
31
+ * decorate `request.conversation`; an id no storage ever recorded — or a
32
+ * conversation the caller may not see — replies 404
33
+ * `{error: "conversation_not_found"}`; a read-only caller on a write
34
+ * route gets 403 `{error: "forbidden"}`.
35
+ */
36
+ export function loadConversation(ctx, need = "read") {
37
+ return async (request, reply) => {
38
+ const { id } = request.params;
39
+ const conversation = await ctx.opened.harness.conversation(conversationId(id), BACKGROUND_CONTEXT);
40
+ if (conversation === undefined) {
41
+ return await reply
42
+ .code(404)
43
+ .send({ error: "conversation_not_found" });
44
+ }
45
+ const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
46
+ const access = accessOf({
47
+ user: request.principal.user,
48
+ superuser: isSuperuser(request.principal),
49
+ }, meta ?? {});
50
+ if (access === "none") {
51
+ // Hidden is 404 on every :id route (spec §5.9).
52
+ return await reply
53
+ .code(404)
54
+ .send({ error: "conversation_not_found" });
55
+ }
56
+ if (access === "read" && need === "write") {
57
+ return await reply.code(403).send({ error: "forbidden" });
58
+ }
59
+ request.conversation = conversation;
60
+ };
61
+ }