@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +352 -36
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/schema.js +9 -2
  5. package/dist/blocks/validate.js +57 -8
  6. package/dist/cli.js +26 -2
  7. package/dist/config.js +67 -1
  8. package/dist/extension.d.ts +189 -15
  9. package/dist/harness/agent-env.js +62 -0
  10. package/dist/harness/ambient.js +39 -185
  11. package/dist/harness/blocks/entries.js +47 -0
  12. package/dist/harness/blocks/state.js +58 -0
  13. package/dist/harness/blocks/tools.js +274 -0
  14. package/dist/harness/boot.js +253 -20
  15. package/dist/harness/builtins.js +15 -11
  16. package/dist/harness/event-bus.js +105 -10
  17. package/dist/harness/identity/accounts.js +186 -0
  18. package/dist/harness/identity/groups.js +14 -0
  19. package/dist/harness/identity/index.js +13 -6
  20. package/dist/harness/identity/json-store.js +15 -4
  21. package/dist/harness/identity/setup-code-check.js +9 -0
  22. package/dist/harness/identity/tokens.js +28 -5
  23. package/dist/harness/identity/users.js +111 -12
  24. package/dist/harness/interactions.js +292 -136
  25. package/dist/harness/loader.js +271 -17
  26. package/dist/harness/packages.js +1 -1
  27. package/dist/harness/reserved-sections.js +21 -0
  28. package/dist/harness/seam-host.js +80 -6
  29. package/dist/harness/settings.js +63 -2
  30. package/dist/harness/stores.js +241 -4
  31. package/dist/harness/user-services.js +391 -0
  32. package/dist/harness/user-tools.js +187 -0
  33. package/dist/harness/workspace.js +61 -55
  34. package/dist/jobs/fire.js +10 -16
  35. package/dist/sandbox/availability.js +162 -0
  36. package/dist/sandbox/command-observer.js +59 -0
  37. package/dist/sandbox/grants.js +159 -0
  38. package/dist/sandbox/helper.js +29 -0
  39. package/dist/sandbox/isolation.js +29 -0
  40. package/dist/sandbox/launch.js +20 -0
  41. package/dist/sandbox/os-account.js +49 -0
  42. package/dist/sandbox/policy.js +253 -0
  43. package/dist/sandbox/pool.js +548 -0
  44. package/dist/sandbox/protocol.js +167 -0
  45. package/dist/sandbox/sandboxed-env.js +245 -0
  46. package/dist/sandbox/unavailable-env.js +49 -0
  47. package/dist/sandbox/worker.js +281 -0
  48. package/dist/serve.js +82 -7
  49. package/dist/server/auth/access-principal.js +16 -0
  50. package/dist/server/auth/pipeline.js +47 -33
  51. package/dist/server/blocks/answers.js +94 -0
  52. package/dist/server/blocks/chat-surfaces.js +393 -0
  53. package/dist/server/blocks/notes.js +216 -0
  54. package/dist/server/blocks/summaries.js +328 -0
  55. package/dist/server/commands.js +5 -0
  56. package/dist/server/content-parts-lazy.js +13 -0
  57. package/dist/server/conversation-handler.js +2 -5
  58. package/dist/server/conversation-ops.js +138 -37
  59. package/dist/server/helpers.js +18 -6
  60. package/dist/server/host-bridge.js +40 -20
  61. package/dist/server/images/cache.js +205 -0
  62. package/dist/server/images/convert.js +13 -0
  63. package/dist/server/images/fetch.js +302 -0
  64. package/dist/server/images/ranges.js +42 -0
  65. package/dist/server/images/rewrite.js +94 -0
  66. package/dist/server/images/sign.js +84 -0
  67. package/dist/server/images/sniff.js +62 -0
  68. package/dist/server/index.js +144 -4
  69. package/dist/server/login/flows.js +126 -0
  70. package/dist/server/login/http.js +31 -0
  71. package/dist/server/login/resolve.js +133 -0
  72. package/dist/server/login/setup-claim.js +55 -0
  73. package/dist/server/login/usernames.js +15 -0
  74. package/dist/server/ownership.js +13 -0
  75. package/dist/server/routes/admin.js +58 -0
  76. package/dist/server/routes/auth.js +88 -2
  77. package/dist/server/routes/conversations.js +3 -6
  78. package/dist/server/routes/events.js +28 -3
  79. package/dist/server/routes/extensions.js +37 -5
  80. package/dist/server/routes/images.js +267 -0
  81. package/dist/server/routes/interactions.js +57 -68
  82. package/dist/server/routes/jobs.js +2 -8
  83. package/dist/server/routes/login.js +223 -0
  84. package/dist/server/routes/me.js +90 -0
  85. package/dist/server/routes/meta.js +15 -0
  86. package/dist/server/routes/onboarding.js +94 -84
  87. package/dist/server/routes/settings-sections.js +190 -0
  88. package/dist/server/routes/settings.js +16 -4
  89. package/dist/server/routes/submissions.js +1 -1
  90. package/dist/server/routes/surfaces.js +175 -23
  91. package/dist/server/routes/users.js +116 -20
  92. package/dist/server/schema-fixture.js +213 -0
  93. package/dist/server/session-meta.js +3 -0
  94. package/dist/server/shutdown.js +11 -4
  95. package/dist/server/static.js +18 -0
  96. package/dist/server/surfaces/dedup.js +9 -2
  97. package/dist/server/surfaces/service.js +181 -13
  98. package/dist/server/surfaces/settings-surfaces.js +597 -0
  99. package/dist/server/turns/submit-turn.js +26 -0
  100. package/dist/server/views.js +16 -0
  101. package/dist/settings/core-sections.js +532 -0
  102. package/dist/settings/pending.js +181 -0
  103. package/dist/settings/registry.js +453 -0
  104. package/dist/skills/index.js +6 -0
  105. package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
  106. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  107. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  108. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  109. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  110. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  111. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  112. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  113. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  114. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  115. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  116. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  117. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  118. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  119. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  120. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  121. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  122. package/node_modules/@sigrun/shared/package.json +1 -1
  123. package/package.json +13 -8
  124. package/webui/dist/assets/index-CxB969qp.js +55 -0
  125. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  126. package/webui/dist/index.html +2 -2
  127. package/webui/dist/assets/index-B4-GYqEb.js +0 -50
  128. package/webui/dist/assets/index-BiSMljer.css +0 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The settings sections route (UI-blocks U4 Task 4, spec §13.4):
3
+ * `GET /v1/settings/sections` serves the settings panel's nav list and
4
+ * the search index in one payload —
5
+ *
6
+ * ```
7
+ * {
8
+ * sections: { slug, id, title, icon }[], // core first, then extensions alphabetically
9
+ * index: { sectionId, slug, label, help, kind }[] // every searchable row
10
+ * }
11
+ * ```
12
+ *
13
+ * The search index carries one row per core SETTINGS key (registry-driven —
14
+ * `src/settings/registry.ts`, with the section + labels resolved from
15
+ * `src/settings/core-sections.ts`), one row per extension-declared
16
+ * section field (labels + help walked out of the form trees), and one
17
+ * row per core section title (so "interface" lands somewhere). Section
18
+ * trees are static per extension version, so the index builds ONCE per
19
+ * request from the loaded bindings (cheap: the forms are small), never
20
+ * persisted.
21
+ */
22
+ import { isSuperuser } from "../auth/principal.js";
23
+ import { CORE_SECTION_IDS, CORE_SETTINGS_SECTIONS, } from "../../settings/core-sections.js";
24
+ import { CORE_SETTING_KEYS } from "../../settings/registry.js";
25
+ function isRec(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ /**
29
+ * Yield every field-ish node of one settings form: a record carrying a
30
+ * string `name` and a `type` that is a FIELD type (not a container).
31
+ * Walks `root`/`children`/`fields`/`item` — the loader-validated tree's
32
+ * only nesting paths.
33
+ */
34
+ function* walkFields(tree) {
35
+ const stack = [tree];
36
+ while (stack.length > 0) {
37
+ const node = stack.pop();
38
+ if (Array.isArray(node)) {
39
+ stack.push(...node);
40
+ continue;
41
+ }
42
+ if (!isRec(node))
43
+ continue;
44
+ for (const key of ["children", "fields", "root", "item"]) {
45
+ if (node[key] !== undefined)
46
+ stack.push(node[key]);
47
+ }
48
+ if (typeof node.name === "string" && typeof node.type === "string") {
49
+ const type = node.type;
50
+ if (type !== "section" && type !== "form" && type !== "page" && type !== "stack") {
51
+ yield {
52
+ name: node.name,
53
+ label: typeof node.label === "string" ? node.label : node.name,
54
+ ...(typeof node.help === "string" ? { help: node.help } : {}),
55
+ };
56
+ }
57
+ }
58
+ }
59
+ }
60
+ /** The core field a key renders as, plus the section it renders IN
61
+ * (the section id comes from the WALK — the section the field
62
+ * actually renders under — never from the registry's `section`
63
+ * metadata, which drifts from where fields land: the retry and
64
+ * stream keys say `general` but render under Advanced). A key no core section
65
+ * renders has no row (the index covers only what the panel shows). */
66
+ function coreFieldEntry(key) {
67
+ for (const section of CORE_SETTINGS_SECTIONS) {
68
+ for (const field of walkFields(section.form)) {
69
+ if (field.name === key) {
70
+ return {
71
+ sectionId: section.id,
72
+ label: field.label,
73
+ ...(field.help === undefined ? {} : { help: field.help }),
74
+ };
75
+ }
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+ /** The index entry for one core registry key. */
81
+ function coreKeyEntry(key) {
82
+ const registryEntry = CORE_SETTING_KEYS.find((entry) => entry.key === key);
83
+ if (registryEntry === undefined)
84
+ return undefined;
85
+ const field = coreFieldEntry(key);
86
+ if (field === undefined)
87
+ return undefined;
88
+ return {
89
+ slug: "core",
90
+ sectionId: field.sectionId,
91
+ kind: "core-key",
92
+ label: field.label,
93
+ ...(field.help === undefined ? {} : { help: field.help }),
94
+ key,
95
+ };
96
+ }
97
+ /** Build the section list + search index from the boot's bindings. */
98
+ export function buildSettingsSections(extensionSections) {
99
+ const sections = CORE_SETTINGS_SECTIONS.map((section) => ({
100
+ slug: "core",
101
+ id: section.id,
102
+ title: section.title,
103
+ icon: section.icon ?? "key",
104
+ }));
105
+ const sorted = [...extensionSections].sort((a, b) => a.extension.localeCompare(b.extension));
106
+ for (const binding of sorted) {
107
+ for (const def of binding.defs) {
108
+ sections.push({
109
+ slug: binding.extension,
110
+ id: def.id,
111
+ title: def.title,
112
+ icon: def.icon ?? "key",
113
+ ...(def.scope === "user" ? { scope: "user" } : {}),
114
+ ...(def.scope === "user" ? { admin: def.admin === true } : {}),
115
+ });
116
+ }
117
+ }
118
+ const index = [];
119
+ // Core section titles (a hit on the title lands on the section).
120
+ for (const [position, id] of CORE_SECTION_IDS.entries()) {
121
+ const section = CORE_SETTINGS_SECTIONS[position];
122
+ if (section === undefined)
123
+ continue;
124
+ index.push({
125
+ slug: "core",
126
+ sectionId: id,
127
+ kind: "section",
128
+ label: section.title,
129
+ });
130
+ }
131
+ // Core keys (registry-driven — every registry key whose section
132
+ // renders gets a row). Cove-rendered sections (Interface / Providers
133
+ // / Extensions — the channels draw them natively) drop their keys:
134
+ // a hit must land on a form field, and a cove has none (the search
135
+ // would strand the user on a section whose row it named but whose
136
+ // field does not exist).
137
+ const COVE_SECTIONS = new Set([
138
+ "interface",
139
+ "providers",
140
+ "extensions",
141
+ ]);
142
+ for (const entry of CORE_SETTING_KEYS) {
143
+ const row = coreKeyEntry(entry.key);
144
+ if (row === undefined)
145
+ continue;
146
+ if (COVE_SECTIONS.has(row.sectionId))
147
+ continue;
148
+ index.push(row);
149
+ }
150
+ // Extension sections: titles + every field's label/help.
151
+ for (const binding of sorted) {
152
+ for (const def of binding.defs) {
153
+ index.push({
154
+ slug: binding.extension,
155
+ sectionId: def.id,
156
+ kind: "section",
157
+ label: def.title,
158
+ });
159
+ for (const field of walkFields(def.form)) {
160
+ index.push({
161
+ slug: binding.extension,
162
+ sectionId: def.id,
163
+ kind: "field",
164
+ label: field.label,
165
+ ...(field.help === undefined ? {} : { help: field.help }),
166
+ key: field.name,
167
+ });
168
+ }
169
+ }
170
+ }
171
+ return { sections, index };
172
+ }
173
+ /** Register `GET /v1/settings/sections` (any authenticated principal —
174
+ * U4 Task 4 fix wave: the client-only Interface prefs die with the
175
+ * panel when a non-admin can't load the list; the per-section SURFACES
176
+ * keep their own access gates, so a non-admin's admin-section save
177
+ * still rejects 404/forbidden server-side). */
178
+ export function registerSettingsSectionsRoutes(app, ctx) {
179
+ app.get("/v1/settings/sections", async (request) => {
180
+ const response = buildSettingsSections(ctx.opened.extensionSettingsSections);
181
+ const admin = isSuperuser(request.principal) || request.principal.admin;
182
+ const allowed = new Set(response.sections
183
+ .filter((section) => section.scope !== "user" || section.admin !== true || admin)
184
+ .map((section) => `${section.slug}\u0000${section.id}`));
185
+ return {
186
+ sections: response.sections.filter((section) => allowed.has(`${section.slug}\u0000${section.id}`)),
187
+ index: response.index.filter((entry) => allowed.has(`${entry.slug}\u0000${entry.sectionId}`)),
188
+ };
189
+ });
190
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Settings routes over the file-backed store on the harness's live
3
3
  * settings (pi parity): GET serves the current effective settings
4
- * (absent file = `{}` — every default); PATCH validates + merges +
5
- * persists through the store.
4
+ * (absent file = `{}` — every default) plus the pending-restart diff
5
+ * (U4 §13.2); PATCH validates + merges + persists through the store's
6
+ * `apply()` — the write classifies live vs. restart against the §13.1
7
+ * registry, reports both, and publishes `settings.changed` after the
8
+ * write commits.
6
9
  */
7
10
  import { SettingsValidationError } from "../../harness/settings.js";
8
11
  import { settingsResponse } from "../helpers.js";
@@ -33,8 +36,17 @@ export function registerSettingsRoutes(app, ctx) {
33
36
  return await reply.code(400).send({ error: "invalid_body" });
34
37
  }
35
38
  try {
36
- opened.settings.update(body);
37
- return settingsResponse(opened, config, env);
39
+ const outcome = opened.settings.apply(body);
40
+ // §13.2: the store's onWrite observer (boot's settings-changed
41
+ // sink) published `settings.changed` when the write committed —
42
+ // hub publication, extension onSettingsChanged fan-out, and the
43
+ // scheduler re-arm all ride that ONE seam, so the route only
44
+ // reports the write's own classification here. Observer errors
45
+ // never fail the write (§13.2's caught-and-logged rule).
46
+ return settingsResponse(opened, config, env, {
47
+ applied: outcome.applied,
48
+ pending: outcome.pending,
49
+ });
38
50
  }
39
51
  catch (error) {
40
52
  if (error instanceof SettingsValidationError) {
@@ -96,7 +96,7 @@ export function registerSubmissionRoutes(app, ctx) {
96
96
  // The shared configure op (gateway C4a) is the SAME code path the
97
97
  // extension host bridge drives — the validation order and error bodies
98
98
  // moved verbatim into the op; this route owns the HTTP mapping only.
99
- // v1 accepts EXACTLY `model` + `thinking` — any other key is a fail-loud
99
+ // Configure accepts `model` + `thinking` + `extensions` — any other key is a fail-loud
100
100
  // 400 (the agent's other knobs are not wire surface yet).
101
101
  app.post("/v1/conversations/:id/configure", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
102
102
  try {
@@ -32,8 +32,15 @@
32
32
  * extension stays loaded.
33
33
  */
34
34
  import { PassThrough } from "node:stream";
35
- import { instanceKey } from "../surfaces/instance.js";
35
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
36
36
  import { ActionRateLimitedError, SurfaceFailedError, UnknownActionError, } from "../surfaces/service.js";
37
+ import { NamedUserRequiredError } from "../surfaces/settings-surfaces.js";
38
+ import { applyChatAction, chatActionStartsTurn, readChatBlock, resolveChatHandlers } from "../blocks/chat-surfaces.js";
39
+ import { answerBlock } from "../blocks/answers.js";
40
+ import { accessOf } from "@sigrun/shared/access";
41
+ import { accessPrincipalOf } from "../auth/access-principal.js";
42
+ import { SessionMetaDoc } from "../session-meta.js";
43
+ import { conversationId as brandConversationId } from "../helpers.js";
37
44
  /**
38
45
  * The one upload-name rule, shared with core's conversation upload
39
46
  * route (files.ts §6.2 — kept in sync by hand): no `/` or `\\`, not
@@ -89,6 +96,9 @@ export function registerSurfaceRoutes(app, ctx) {
89
96
  const uploadCaps = new WeakMap();
90
97
  /** The shared failure mapping (logged; errors only under server.dev). */
91
98
  const failureReply = async (reply, slug, id, cause) => {
99
+ if (cause instanceof NamedUserRequiredError) {
100
+ return reply.code(400).send({ error: "named_user_required" });
101
+ }
92
102
  const failed = cause instanceof SurfaceFailedError
93
103
  ? cause
94
104
  : new SurfaceFailedError(cause instanceof Error ? cause.message : String(cause));
@@ -169,21 +179,16 @@ export function registerSurfaceRoutes(app, ctx) {
169
179
  }
170
180
  // The §6.5 access gate: cache or re-render decides BEFORE the
171
181
  // handler runs. A located-check first keeps unknown slugs/ids on
172
- // the same 404 body without a render.
173
- const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
174
- const def = binding?.defs.find((entry) => entry.id === id);
175
- if (def === undefined) {
176
- return await reply.code(404).send(NOT_FOUND);
177
- }
178
- // A throwing `instanceKey` is an extension fault: 500
179
- // surface_failed, the extension stays loaded (deferred minor from
180
- // Task 2 — this ran outside the try before).
182
+ // the same 404 body without a render; a throwing extension
183
+ // `instanceKey` is a 500 (the extension-fault path). The service's
184
+ // locate covers the settings surfaces too (spec §13.4).
181
185
  let key;
182
186
  try {
183
- key =
184
- def.instanceKey !== undefined
185
- ? def.instanceKey(params)
186
- : instanceKey(params);
187
+ const probe = surfaces.instanceKeyFor(slug, id, params, request.principal);
188
+ if (probe === undefined) {
189
+ return await reply.code(404).send(NOT_FOUND);
190
+ }
191
+ key = probe;
187
192
  }
188
193
  catch (cause) {
189
194
  return await failureReply(reply, slug, id, cause);
@@ -245,17 +250,13 @@ export function registerSurfaceRoutes(app, ctx) {
245
250
  .code(400)
246
251
  .send({ error: "invalid_field", field: "params" });
247
252
  }
248
- const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
249
- const def = binding?.defs.find((entry) => entry.id === id);
250
- if (def === undefined) {
251
- return await reply.code(404).send(NOT_FOUND);
252
- }
253
253
  let key;
254
254
  try {
255
- key =
256
- def.instanceKey !== undefined
257
- ? def.instanceKey(parsed.params)
258
- : instanceKey(parsed.params);
255
+ const probe = surfaces.instanceKeyFor(slug, id, parsed.params, request.principal);
256
+ if (probe === undefined) {
257
+ return await reply.code(404).send(NOT_FOUND);
258
+ }
259
+ key = probe;
259
260
  }
260
261
  catch (cause) {
261
262
  return await failureReply(reply, slug, id, cause);
@@ -384,4 +385,155 @@ export function registerSurfaceRoutes(app, ctx) {
384
385
  return await failureReply(reply, slug, id, cause);
385
386
  }
386
387
  });
388
+ // ── Chat block surfaces (UI-blocks U5 Task 2, spec §8.2) ──────────────
389
+ // A chat block reuses this machinery with CORE as the handler: the
390
+ // address is (conversationId, entryId), access is the CONVERSATION's
391
+ // (write edits; read sees; hidden is 404 — indistinguishable from an
392
+ // unknown conversation), and ops are kept/returned like sharedData
393
+ // (chat data is the same for everyone who can see the conversation).
394
+ /** One conversation's access for the caller (§5.9; the pre-handler's
395
+ * same resolution, inlined because the routes below own the body). */
396
+ const conversationAccess = async (id, principal) => {
397
+ const conversation = await ctx.opened.harness.conversation(brandConversationId(id), BACKGROUND_CONTEXT);
398
+ if (conversation === undefined)
399
+ return undefined;
400
+ const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
401
+ return accessOf(accessPrincipalOf(principal, ctx.opened.identity.users), meta ?? {});
402
+ };
403
+ /** The chat-block route params: one integer id + one entryId. */
404
+ const BLOCK_PARAMS_SCHEMA = {
405
+ type: "object",
406
+ properties: { id: { type: "integer" }, entryId: { type: "string", pattern: "^[0-9]+$" } },
407
+ required: ["id", "entryId"],
408
+ additionalProperties: false,
409
+ };
410
+ app.get("/v1/conversations/:id/blocks/:entryId", { schema: { params: BLOCK_PARAMS_SCHEMA } }, async (request, reply) => {
411
+ const { id, entryId } = request.params;
412
+ const access = await conversationAccess(id, request.principal);
413
+ if (access === undefined || access === "none") {
414
+ return await reply.code(404).send({ error: "conversation_not_found" });
415
+ }
416
+ const block = await readChatBlock(ctx.opened.harness, id, entryId);
417
+ if (block === undefined) {
418
+ return await reply.code(404).send({ error: "block_not_found" });
419
+ }
420
+ return await reply.send({
421
+ tree: block.tree,
422
+ data: block.state.data,
423
+ rev: block.state.rev,
424
+ treeRev: 0,
425
+ access,
426
+ });
427
+ });
428
+ app.get("/v1/conversations/:id/blocks/:entryId/data", { schema: { params: BLOCK_PARAMS_SCHEMA } }, async (request, reply) => {
429
+ const { id, entryId } = request.params;
430
+ const access = await conversationAccess(id, request.principal);
431
+ if (access === undefined || access === "none") {
432
+ return await reply.code(404).send({ error: "conversation_not_found" });
433
+ }
434
+ const block = await readChatBlock(ctx.opened.harness, id, entryId);
435
+ if (block === undefined) {
436
+ return await reply.code(404).send({ error: "block_not_found" });
437
+ }
438
+ const since = parseSince(request.query.since);
439
+ if (since === null) {
440
+ return await reply
441
+ .code(400)
442
+ .send({ error: "invalid_field", field: "since" });
443
+ }
444
+ // The sharedData answer (§7.3): ops since `since` while the kept
445
+ // window still covers it AND the in-memory rev matches the durable
446
+ // one just read; anything else is the full data.
447
+ const delta = since === undefined
448
+ ? undefined
449
+ : ctx.surfaces.chatOpsSince({ conversationId: id, entryId }, since, block.state.rev);
450
+ if (delta !== undefined) {
451
+ return await reply.send({ rev: delta.rev, treeRev: 0, ops: delta.ops });
452
+ }
453
+ return await reply.send({
454
+ rev: block.state.rev,
455
+ treeRev: 0,
456
+ data: block.state.data,
457
+ });
458
+ });
459
+ app.post("/v1/conversations/:id/blocks/:entryId/actions", { schema: { params: BLOCK_PARAMS_SCHEMA } }, async (request, reply) => {
460
+ const { id, entryId } = request.params;
461
+ const body = request.body ?? {};
462
+ if (typeof body.action !== "string") {
463
+ return await reply
464
+ .code(400)
465
+ .send({ error: "invalid_field", field: "action" });
466
+ }
467
+ const actionId = typeof body.actionId === "string" ? body.actionId : undefined;
468
+ // The conversation's access gates BEFORE anything runs (§6.5/
469
+ // §8.2): hidden is the conversation 404, read is 403.
470
+ const access = await conversationAccess(id, request.principal);
471
+ if (access === undefined || access === "none") {
472
+ return await reply.code(404).send({ error: "conversation_not_found" });
473
+ }
474
+ if (access === "read") {
475
+ return await reply.code(403).send({ error: "forbidden" });
476
+ }
477
+ // An unknown entry id: the same 404 as the GETs (§8.2 mirrors
478
+ // §7.2's not-found posture).
479
+ const block = await readChatBlock(ctx.opened.harness, id, entryId);
480
+ if (block === undefined) {
481
+ return await reply.code(404).send({ error: "block_not_found" });
482
+ }
483
+ // U3's action gates, over the chat address: rate limit then dedup
484
+ // (the ordering the ext route chose — a replayed actionId still
485
+ // costs a token).
486
+ const address = { conversationId: id, entryId };
487
+ if (!ctx.surfaces.chatTakeToken(address, request.principal.user ?? "")) {
488
+ return await reply.code(429).send({ error: "rate_limited" });
489
+ }
490
+ const fingerprint = JSON.stringify({ action: body.action, payload: body.payload });
491
+ if (actionId !== undefined) {
492
+ const hit = ctx.surfaces.chatDedupCheck(actionId, address, fingerprint);
493
+ if (hit !== undefined && hit !== null)
494
+ return await reply.send(hit);
495
+ }
496
+ // A handler name no node in the tree declares is unknown
497
+ // (validateTree's handlers-set check, applied at route time:
498
+ // chat names are free labels).
499
+ if (!resolveChatHandlers(block.tree).has(body.action)) {
500
+ return await reply.code(400).send({ error: "unknown_action" });
501
+ }
502
+ // The answer path (§8.4): a turn action submits a user message
503
+ // through `submitOp` for the acting user — the fold and the
504
+ // `notedThrough` advance happen inside `answerBlock`. The §6.3
505
+ // response wraps no ops (the surface's data is unchanged by the
506
+ // answer itself; the channel already applied the optimistic edits
507
+ // that produced the payload).
508
+ if (chatActionStartsTurn(block.tree, body.action)) {
509
+ const answerPayload = (body.payload ?? null);
510
+ const wrapped = { ok: true, rev: block.state.rev };
511
+ await answerBlock(ctx, id, entryId, body.action, answerPayload, actionId);
512
+ if (actionId !== undefined) {
513
+ ctx.surfaces.chatDedupRecord(actionId, address, fingerprint, wrapped);
514
+ }
515
+ return await reply.send(wrapped);
516
+ }
517
+ const outcome = await applyChatAction(ctx.opened.harness, id, entryId, request.principal.user ?? "", body.action, (body.payload ?? null));
518
+ if (outcome === undefined) {
519
+ return await reply.code(404).send({ error: "block_not_found" });
520
+ }
521
+ if (!outcome.ok) {
522
+ return await reply.send(outcome);
523
+ }
524
+ // Record the ops (sharedData semantics: the kept window) and the
525
+ // dedup entry, then answer the §6.3 accept-with-ops shape.
526
+ const wrapped = {
527
+ ok: true,
528
+ rev: outcome.rev,
529
+ ops: outcome.ops,
530
+ };
531
+ ctx.surfaces.announceChatRev(address, outcome.rev, {
532
+ ops: outcome.ops,
533
+ });
534
+ if (actionId !== undefined) {
535
+ ctx.surfaces.chatDedupRecord(actionId, address, fingerprint, wrapped);
536
+ }
537
+ return await reply.send(wrapped);
538
+ });
387
539
  }
@@ -3,29 +3,34 @@
3
3
  * admin-gated directory surface — list, patch (with the last-record-admin
4
4
  * guard: group-derived admins never count, Authelia can revoke them at
5
5
  * any moment), delete-with-transfer (ownership rewrite → token revocation
6
- * → record removal → the `userDeleted` lifecycle event), and invite
7
- * issuance.
6
+ * → account-link removal → stop services → the `userDeleted` lifecycle
7
+ * event → record removal, per spec §10.6), and invite issuance.
8
8
  *
9
9
  * The admin gate reads `request.principal.admin` — record admin, service
10
10
  * token, the naive loopback superuser, or a proxy-group grant. The
11
11
  * last-admin GUARDS read the directory records only (`recordAdminCount`).
12
12
  */
13
13
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
14
+ import { join } from "node:path";
14
15
  import { withClaimLock } from "../auth/claim-lock.js";
16
+ import { effectiveAdmin, storedGroups } from "../../harness/identity/groups.js";
15
17
  import { transferOwnership } from "../ownership.js";
16
- /** The wire form of one directory user (the record minus nothing — the
17
- * routes serving it are admin-gated, and emails are admin material).
18
- * `admin` stays the STORED flag; `groupAdmin` (gateway C8) is the
19
- * persisted proxy-group grant alongside it. */
20
- function userWire(record) {
18
+ import { validateGrantPath } from "../../sandbox/grants.js";
19
+ /** The wire form of one directory user. `admin` stays the STORED flag;
20
+ * `groupAdmin` is DERIVED from stored groups and retained for one release
21
+ * so the users UI keeps rendering. */
22
+ export function userWire(record, adminGroups) {
23
+ const effective = effectiveAdmin(record, adminGroups);
21
24
  return {
22
25
  user: record.user,
23
26
  ...(record.name === undefined ? {} : { name: record.name }),
24
27
  ...(record.email === undefined ? {} : { email: record.email }),
25
28
  admin: record.admin,
26
- ...(record.groupAdmin === undefined
27
- ? {}
28
- : { groupAdmin: record.groupAdmin }),
29
+ groupAdmin: effective && !record.admin,
30
+ groups: storedGroups(record),
31
+ ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
32
+ ...(record.grants === undefined ? {} : { grants: record.grants }),
33
+ ...(record.osUser === undefined ? {} : { osUser: record.osUser }),
29
34
  createdAt: record.createdAt,
30
35
  source: record.source,
31
36
  };
@@ -37,7 +42,7 @@ export function registerUserRoutes(app, ctx) {
37
42
  if (!request.principal.admin) {
38
43
  return await reply.code(403).send({ error: "forbidden" });
39
44
  }
40
- return { users: identity.users.list().map(userWire) };
45
+ return { users: identity.users.list().map((record) => userWire(record, ctx.config.auth.adminGroups)) };
41
46
  });
42
47
  app.patch("/v1/users/:user", async (request, reply) => {
43
48
  if (!request.principal.admin) {
@@ -73,6 +78,61 @@ export function registerUserRoutes(app, ctx) {
73
78
  }
74
79
  patch.admin = value;
75
80
  break;
81
+ case "workspace": {
82
+ if (typeof value !== "string") {
83
+ return await reply.code(400).send({ error: "invalid_body" });
84
+ }
85
+ const result = validateGrantPath(value, {
86
+ managed: ctx.managedPaths,
87
+ user,
88
+ });
89
+ if (!result.ok) {
90
+ return await reply.code(400).send({
91
+ error: "invalid_grant",
92
+ path: value,
93
+ reason: result.reason,
94
+ });
95
+ }
96
+ patch.workspace = result.realpath;
97
+ break;
98
+ }
99
+ case "grants": {
100
+ if (!Array.isArray(value)) {
101
+ return await reply.code(400).send({ error: "invalid_body" });
102
+ }
103
+ const grants = [];
104
+ for (const entry of value) {
105
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
106
+ return await reply.code(400).send({ error: "invalid_body" });
107
+ }
108
+ const candidate = entry;
109
+ if (typeof candidate.path !== "string" ||
110
+ (candidate.access !== "ro" && candidate.access !== "rw") ||
111
+ Object.keys(candidate).some((key) => key !== "path" && key !== "access")) {
112
+ return await reply.code(400).send({ error: "invalid_body" });
113
+ }
114
+ const result = validateGrantPath(candidate.path, {
115
+ managed: ctx.managedPaths,
116
+ user,
117
+ });
118
+ if (!result.ok) {
119
+ return await reply.code(400).send({
120
+ error: "invalid_grant",
121
+ path: candidate.path,
122
+ reason: result.reason,
123
+ });
124
+ }
125
+ grants.push({ path: result.realpath, access: candidate.access });
126
+ }
127
+ patch.grants = grants;
128
+ break;
129
+ }
130
+ case "osUser":
131
+ if (typeof value !== "string" || value === "") {
132
+ return await reply.code(400).send({ error: "invalid_body" });
133
+ }
134
+ patch.osUser = value;
135
+ break;
76
136
  default:
77
137
  return await reply
78
138
  .code(400)
@@ -89,12 +149,8 @@ export function registerUserRoutes(app, ctx) {
89
149
  identity.users.recordAdminCount() === 1) {
90
150
  return await reply.code(409).send({ error: "last_admin" });
91
151
  }
92
- const updated = await identity.users.update(user, {
93
- ...(patch.name === undefined ? {} : { name: patch.name }),
94
- ...(patch.email === undefined ? {} : { email: patch.email }),
95
- ...(patch.admin === undefined ? {} : { admin: patch.admin }),
96
- });
97
- return userWire(updated);
152
+ const updated = await identity.users.update(user, patch);
153
+ return userWire(updated, ctx.config.auth.adminGroups);
98
154
  });
99
155
  app.post("/v1/users/invites", async (request, reply) => {
100
156
  if (!request.principal.admin) {
@@ -157,18 +213,58 @@ export function registerUserRoutes(app, ctx) {
157
213
  identity.users.get(query.transferTo) === undefined) {
158
214
  return await reply.code(400).send({ error: "invalid_transfer" });
159
215
  }
216
+ if (target.osUser !== undefined) {
217
+ return await reply.code(409).send({
218
+ error: "invalid_transfer",
219
+ reason: "osUser workspace",
220
+ });
221
+ }
160
222
  // The last record-admin may not be deleted — admin is not a
161
223
  // transferable property (transferTo moves resources, not rights).
162
224
  if (target.admin && identity.users.recordAdminCount() === 1) {
163
225
  return await reply.code(409).send({ error: "last_admin" });
164
226
  }
165
227
  const to = query.transferTo;
228
+ const workspace = target.workspace ??
229
+ join(ctx.managedPaths.roots.dataDir, "users", user, "workspace");
230
+ // Complete the only grant-dependent failure decision BEFORE the
231
+ // irreversible transfer/revocation. Invalid grants remain non-fatal.
232
+ const validatedGrant = validateGrantPath(workspace, {
233
+ managed: ctx.managedPaths,
234
+ user: to,
235
+ inheritedFrom: user,
236
+ });
237
+ const grant = validatedGrant.ok
238
+ ? {
239
+ path: validatedGrant.realpath,
240
+ access: "rw",
241
+ inheritedFrom: user,
242
+ }
243
+ : undefined;
244
+ if (!validatedGrant.ok) {
245
+ console.warn(`user delete: skipping departing workspace grant for ${to}: ${workspace}: ${validatedGrant.reason}`);
246
+ }
166
247
  await transferOwnership(ctx, user, to);
167
248
  await identity.tokens.revokeAllFor(user);
168
- await identity.users.remove(user);
169
- // The lifecycle event: extensions transfer their own records
170
- // (deadlocked-ordering free — the core transfer already landed).
249
+ // External identities never transfer (multi-user §10.6 step 1):
250
+ // credentials belong to a person, and a transferred link would hand
251
+ // the receiving user's account to whoever holds that chat identity.
252
+ await identity.accounts.removeUser(user);
253
+ // §10.6 step 2: stop the user's worker and credential-bearing
254
+ // services, then uninstall every *@user entry.
255
+ await ctx.opened.sandboxPool.stopUser(user);
256
+ await ctx.opened.userServices.stopUser(user);
257
+ ctx.opened.userTools.removeUser(user);
258
+ // §10.6 step 3: provider credentials remain readable throughout the
259
+ // fan-out. Always release the deletion marker before record removal.
171
260
  await eventBus.emitUserDeleted({ user, transferTo: to }, BACKGROUND_CONTEXT);
261
+ if (grant === undefined)
262
+ await identity.users.remove(user);
263
+ else
264
+ await identity.users.removeWithGrant(user, to, grant);
265
+ // §10.6 step 5: data/state stay for the recipient; core owns only
266
+ // the departing user's credentials, settings, and every cache slug.
267
+ await ctx.opened.extensionStores.removeUserData(user);
172
268
  return await reply.code(204).send();
173
269
  });
174
270
  }