@sigrun/sigrun 0.0.0-stage → 0.1.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 (130) hide show
  1. package/CHANGELOG.md +204 -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 +90 -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,317 @@
1
+ /**
2
+ * Submission lifecycle routes: submit (content-part resolution + the §7.1
3
+ * async admit shape — 202 at admission, optional `waitMs`, status route),
4
+ * configure (model/thinking change), abort (withdraw the active
5
+ * submission), and compact (manual compaction).
6
+ * Every route resolves its conversation through the `loadConversation`
7
+ * pre-handler (404 for an unknown id) before touching the body.
8
+ */
9
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
10
+ import { UnservedModelError } from "../../harness/providers/index.js";
11
+ import { ConversationOpError, submitOp, } from "../conversation-ops.js";
12
+ import { loadConversation } from "../conversation-handler.js";
13
+ import { ID_PARAMS_SCHEMA, THINKING_LEVELS, submissionId, } from "../helpers.js";
14
+ /** Integer path params for the submission status route (§7.1). */
15
+ const SUBMISSION_PARAMS_SCHEMA = {
16
+ type: "object",
17
+ properties: { id: { type: "integer" }, submissionId: { type: "integer" } },
18
+ required: ["id", "submissionId"],
19
+ additionalProperties: false,
20
+ };
21
+ /** Path params for the extension-command invoke route (W5 Task 3). */
22
+ const COMMAND_INVOKE_PARAMS_SCHEMA = {
23
+ type: "object",
24
+ properties: { id: { type: "integer" }, name: { type: "string" } },
25
+ required: ["id", "name"],
26
+ additionalProperties: false,
27
+ };
28
+ /** Register the submission/configure/abort/compact routes. */
29
+ export function registerSubmissionRoutes(app, ctx) {
30
+ const { opened } = ctx;
31
+ // Submissions: content-part resolution + upstream idempotence;
32
+ // `whenBusy` pass-through. The shared submit op (W5 Task 5) is the
33
+ // SAME code path the extension host bridge drives — content-part
34
+ // resolution, the served-model check, and activity stamping are
35
+ // identical by construction; this route owns the HTTP mapping only.
36
+ app.post("/v1/conversations/:id/submissions", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
37
+ const conversation = request.conversation;
38
+ const body = request.body;
39
+ if (body === undefined ||
40
+ typeof body !== "object" ||
41
+ Array.isArray(body)) {
42
+ return await reply.code(400).send({ error: "invalid_body" });
43
+ }
44
+ try {
45
+ const record = await submitOp(ctx, conversation, body.input, {
46
+ ...(body.requestId === undefined
47
+ ? {}
48
+ : { requestId: String(body.requestId) }),
49
+ ...(body.whenBusy === undefined ? {} : { whenBusy: body.whenBusy }),
50
+ ...(request.query?.waitMs ===
51
+ undefined
52
+ ? {}
53
+ : {
54
+ waitMs: request.query.waitMs,
55
+ }),
56
+ });
57
+ return await reply.code(202).send({ submission: record });
58
+ }
59
+ catch (error) {
60
+ if (error instanceof ConversationOpError) {
61
+ return await reply.code(400).send(error.body);
62
+ }
63
+ if (error instanceof UnservedModelError) {
64
+ return await reply.code(400).send({
65
+ error: "unserved_model",
66
+ model: `${error.ref.provider}/${error.ref.modelId}`,
67
+ available: error.available,
68
+ });
69
+ }
70
+ throw error;
71
+ }
72
+ });
73
+ // Submission status (§7.1): the record as it stands — the poll
74
+ // counterpart of the admission-202 submit. The submission must belong
75
+ // to THIS conversation: an unknown id and another conversation's id
76
+ // are the same 404 (never a cross-conversation leak).
77
+ app.get("/v1/conversations/:id/submissions/:submissionId", {
78
+ schema: { params: SUBMISSION_PARAMS_SCHEMA },
79
+ preHandler: loadConversation(ctx, "read"),
80
+ }, async (request, reply) => {
81
+ const conversation = request.conversation;
82
+ const { submissionId: rawSubmissionId } = request.params;
83
+ const submission = await opened.harness.submission(submissionId(rawSubmissionId), BACKGROUND_CONTEXT);
84
+ const record = submission === undefined
85
+ ? undefined
86
+ : await submission.status(BACKGROUND_CONTEXT);
87
+ if (record === undefined ||
88
+ record.conversationId !== conversation.id) {
89
+ return await reply
90
+ .code(404)
91
+ .send({ error: "submission_not_found" });
92
+ }
93
+ return { submission: record };
94
+ });
95
+ // Configure: change the conversation's model and/or thinking level.
96
+ // v1 accepts EXACTLY `model` + `thinking` — any other key is a fail-loud
97
+ // 400 (the agent's other knobs are not wire surface yet).
98
+ app.post("/v1/conversations/:id/configure", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
99
+ const conversation = request.conversation;
100
+ const body = request.body;
101
+ if (body === undefined ||
102
+ body === null ||
103
+ typeof body !== "object" ||
104
+ Array.isArray(body)) {
105
+ return await reply.code(400).send({ error: "invalid_body" });
106
+ }
107
+ const unknownKey = Object.keys(body).find((key) => key !== "model" && key !== "thinking");
108
+ if (unknownKey !== undefined) {
109
+ return await reply
110
+ .code(400)
111
+ .send({ error: "invalid_field", field: unknownKey });
112
+ }
113
+ const model = body.model;
114
+ const thinking = body.thinking;
115
+ if (model === undefined && thinking === undefined) {
116
+ // A configure that changes nothing is a client error, not a no-op.
117
+ return await reply.code(400).send({ error: "invalid_body" });
118
+ }
119
+ // `model` is `"provider/modelId"`, split on the FIRST `/` (a modelId
120
+ // may itself contain slashes); both parts must be non-empty.
121
+ let parsedModel;
122
+ if (model !== undefined) {
123
+ if (typeof model !== "string" || !model.includes("/")) {
124
+ return await reply
125
+ .code(400)
126
+ .send({ error: "invalid_field", field: "model" });
127
+ }
128
+ const slash = model.indexOf("/");
129
+ const provider = model.slice(0, slash);
130
+ const modelId = model.slice(slash + 1);
131
+ if (provider.length === 0 || modelId.length === 0) {
132
+ return await reply
133
+ .code(400)
134
+ .send({ error: "invalid_field", field: "model" });
135
+ }
136
+ parsedModel = { provider, modelId };
137
+ }
138
+ if (thinking !== undefined &&
139
+ (typeof thinking !== "string" ||
140
+ !THINKING_LEVELS.includes(thinking))) {
141
+ return await reply
142
+ .code(400)
143
+ .send({ error: "invalid_field", field: "thinking" });
144
+ }
145
+ // perModelThinking: a model change re-applies the store's
146
+ // override for the NEW model unless the request pins `thinking` —
147
+ // the map is the deployment default, the explicit field the caller's
148
+ // win. Read from the store here, so a PATCH reaches later configures.
149
+ const storeThinking = parsedModel === undefined
150
+ ? undefined
151
+ : opened.settings.read().perModelThinking?.[`${parsedModel.provider}/${parsedModel.modelId}`];
152
+ await conversation.configure({
153
+ ...(parsedModel === undefined ? {} : { model: parsedModel }),
154
+ ...(thinking !== undefined
155
+ ? { thinkingLevel: thinking }
156
+ : storeThinking === undefined
157
+ ? {}
158
+ : { thinkingLevel: storeThinking }),
159
+ }, BACKGROUND_CONTEXT);
160
+ // Read the agent back and project the configured surface only —
161
+ // resolved extensions/tools/sections stay server-side.
162
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
163
+ return {
164
+ ...(agent.model === undefined ? {} : { model: agent.model }),
165
+ thinkingLevel: agent.thinkingLevel,
166
+ };
167
+ });
168
+ // Abort: withdraw/settle the conversation's active submission.
169
+ // `inspect()` is the read-only union of placed run inputs and queued inbox
170
+ // items (all conversations); filtered to this conversation, the LOWEST id
171
+ // is the active one — anything queued behind a busy conversation was
172
+ // admitted later and has a higher id.
173
+ app.post("/v1/conversations/:id/abort", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
174
+ const conversation = request.conversation;
175
+ const inspection = await opened.harness.inspect(BACKGROUND_CONTEXT);
176
+ // Lowest live id = oldest admitted = the placed run input over later-
177
+ // queued inbox items. reduce() with a first-element seed is ordering-
178
+ // independent (a future pi-durable inspect() ordering change cannot
179
+ // silently target the wrong submission); empty → undefined.
180
+ const active = inspection.submissions
181
+ .filter((s) => s.conversationId === conversation.id)
182
+ .reduce((min, s) => (min === undefined || s.id < min.id ? s : min), undefined);
183
+ if (active === undefined) {
184
+ return await reply
185
+ .code(404)
186
+ .send({ error: "no_active_submission" });
187
+ }
188
+ const result = await opened.harness.abortSubmission(active.id, BACKGROUND_CONTEXT, conversation.id);
189
+ // `not_found` means the submission's record vanished between `inspect`
190
+ // and `abort` — the conversation is no longer busy, so the honest answer
191
+ // is the same as nothing-active.
192
+ if (result === "not_found") {
193
+ return await reply
194
+ .code(404)
195
+ .send({ error: "no_active_submission" });
196
+ }
197
+ // `already_placed`/`settled` are still 200: abort is idempotent and
198
+ // the body reports which state the submission was found in.
199
+ return { result, submissionId: active.id };
200
+ });
201
+ // Manual compaction: `POST .../compact` (pi parity).
202
+ // `Conversation.compact(instructions)` (pi-durable's own manual-
203
+ // compaction admission, dist/harness/types.d.ts) creates a
204
+ // conversation-owned durable compaction task with `reason: "manual"`:
205
+ // it compacts even when the conversation is idle — a user's explicit
206
+ // intent — and places its summary through a write submission (at the
207
+ // next boundary when busy). Manual compaction ignores the policy's
208
+ // `enabled` (upstream doc: "manual compaction ignores `enabled`");
209
+ // `compact()` resumes the scheduler itself. The task id is handed back —
210
+ // the SSE stream carries the `pi.compaction` entry when the summary
211
+ // lands.
212
+ app.post("/v1/conversations/:id/compact", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
213
+ const conversation = request.conversation;
214
+ // Absent body is valid (no instructions); an object may carry
215
+ // ONLY `instructions` — any other key is a fail-loud 400, the
216
+ // configure convention.
217
+ const body = request.body;
218
+ let instructions;
219
+ if (body !== undefined && body !== null) {
220
+ if (typeof body !== "object" || Array.isArray(body)) {
221
+ return await reply.code(400).send({ error: "invalid_body" });
222
+ }
223
+ const record = body;
224
+ const unknownKey = Object.keys(record).find((key) => key !== "instructions");
225
+ if (unknownKey !== undefined) {
226
+ return await reply
227
+ .code(400)
228
+ .send({ error: "invalid_field", field: unknownKey });
229
+ }
230
+ if (record.instructions !== undefined &&
231
+ typeof record.instructions !== "string") {
232
+ return await reply
233
+ .code(400)
234
+ .send({ error: "invalid_field", field: "instructions" });
235
+ }
236
+ instructions =
237
+ record.instructions === undefined || record.instructions === ""
238
+ ? undefined
239
+ : record.instructions;
240
+ }
241
+ const taskId = await conversation.compact(instructions, BACKGROUND_CONTEXT);
242
+ // 202: like submissions, the compaction is admitted, not settled —
243
+ // the task runs asynchronously and its summary lands as a view
244
+ // revision (the SSE stream).
245
+ return await reply.code(202).send({ taskId });
246
+ });
247
+ // Extension-command invocation (core hardening W5 Task 3, spec §9.3):
248
+ // `POST /v1/conversations/:id/commands/:name {args?}` runs the winning
249
+ // extension declaration for `name` (the merged catalog's `runnable`
250
+ // map — core names and dropped/collided losers resolve to 404
251
+ // `command_not_found`, never a run). WRITE-gated like every other
252
+ // conversation-mutating route (`loadConversation(ctx, "write")`: a
253
+ // read-only sharee gets 403). The body is at most `{args: string}`
254
+ // (absent body = empty args); the run ctx mirrors the ext-route
255
+ // adapter's — the caller's principal and the extension's seam host
256
+ // (`ctx.host` may be undefined for arity-0 factories, same Task 2
257
+ // semantics). A THROWING run is 500 `command_failed` naming the
258
+ // extension (logged; the server lives on); a normal return is 200
259
+ // with the command's optional `{notice}`.
260
+ app.post("/v1/conversations/:id/commands/:name", {
261
+ schema: { params: COMMAND_INVOKE_PARAMS_SCHEMA },
262
+ preHandler: loadConversation(ctx, "write"),
263
+ }, async (request, reply) => {
264
+ const conversation = request.conversation;
265
+ const { name } = request.params;
266
+ // The body convention of configure/compact: absent is valid (no
267
+ // args); an object may carry ONLY `args`, a non-string `args` is a
268
+ // fail-loud 400.
269
+ const body = request.body;
270
+ let args;
271
+ if (body !== undefined && body !== null) {
272
+ if (typeof body !== "object" || Array.isArray(body)) {
273
+ return await reply.code(400).send({ error: "invalid_body" });
274
+ }
275
+ const record = body;
276
+ const unknownKey = Object.keys(record).find((key) => key !== "args");
277
+ if (unknownKey !== undefined) {
278
+ return await reply
279
+ .code(400)
280
+ .send({ error: "invalid_field", field: unknownKey });
281
+ }
282
+ if (record.args !== undefined && typeof record.args !== "string") {
283
+ return await reply
284
+ .code(400)
285
+ .send({ error: "invalid_field", field: "args" });
286
+ }
287
+ args = record.args;
288
+ }
289
+ const merged = ctx.commandCatalog.runnable.get(name);
290
+ if (merged === undefined) {
291
+ return await reply.code(404).send({ error: "command_not_found" });
292
+ }
293
+ // The extension's seam host — the Task 2 ext-route semantics: the
294
+ // captured host instance, cast across the not-yet-bridged members
295
+ // (and possibly undefined when the loader ran without a host
296
+ // context factory).
297
+ const host = ctx.opened.seamHosts.get(merged.extension);
298
+ try {
299
+ const result = await merged.run({
300
+ conversationId: conversation.id,
301
+ args: args ?? "",
302
+ principal: request.principal,
303
+ host: host,
304
+ });
305
+ return await reply.code(200).send(result === undefined || result.notice === undefined
306
+ ? {}
307
+ : { notice: result.notice });
308
+ }
309
+ catch (cause) {
310
+ console.warn(`extension command failed: ${merged.extension} ${name}: ${cause instanceof Error ? cause.message : String(cause)}`);
311
+ return await reply.code(500).send({
312
+ error: "command_failed",
313
+ extension: merged.extension,
314
+ });
315
+ }
316
+ });
317
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The users + invites routes (core hardening W1 Task 4, spec §5.8): the
3
+ * admin-gated directory surface — list, patch (with the last-record-admin
4
+ * guard: group-derived admins never count, Authelia can revoke them at
5
+ * any moment), delete-with-transfer (ownership rewrite → token revocation
6
+ * → record removal → the `userDeleted` lifecycle event), and invite
7
+ * issuance.
8
+ *
9
+ * The admin gate reads `request.principal.admin` — record admin, service
10
+ * token, the naive loopback superuser, or a proxy-group grant. The
11
+ * last-admin GUARDS read the directory records only (`recordAdminCount`).
12
+ */
13
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
14
+ import { withClaimLock } from "../auth/claim-lock.js";
15
+ 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
+ function userWire(record) {
19
+ return {
20
+ user: record.user,
21
+ ...(record.name === undefined ? {} : { name: record.name }),
22
+ ...(record.email === undefined ? {} : { email: record.email }),
23
+ admin: record.admin,
24
+ createdAt: record.createdAt,
25
+ source: record.source,
26
+ };
27
+ }
28
+ /** Register the users + invites routes. */
29
+ export function registerUserRoutes(app, ctx) {
30
+ const { identity, eventBus } = ctx.opened;
31
+ app.get("/v1/users", async (request, reply) => {
32
+ if (!request.principal.admin) {
33
+ return await reply.code(403).send({ error: "forbidden" });
34
+ }
35
+ return { users: identity.users.list().map(userWire) };
36
+ });
37
+ app.patch("/v1/users/:user", async (request, reply) => {
38
+ if (!request.principal.admin) {
39
+ return await reply.code(403).send({ error: "forbidden" });
40
+ }
41
+ const { user } = request.params;
42
+ const target = identity.users.get(user);
43
+ if (target === undefined) {
44
+ return await reply.code(404).send({ error: "unknown_user" });
45
+ }
46
+ const body = request.body;
47
+ if (typeof body !== "object" ||
48
+ body === null ||
49
+ Array.isArray(body)) {
50
+ return await reply.code(400).send({ error: "invalid_body" });
51
+ }
52
+ const patch = {};
53
+ for (const key of Object.keys(body)) {
54
+ const value = body[key];
55
+ switch (key) {
56
+ case "name":
57
+ case "email":
58
+ // Absent = unchanged (clearing is not patch surface v1 —
59
+ // null is a wrong type, not a clear operator, here).
60
+ if (typeof value !== "string") {
61
+ return await reply.code(400).send({ error: "invalid_body" });
62
+ }
63
+ patch[key] = value;
64
+ break;
65
+ case "admin":
66
+ if (typeof value !== "boolean") {
67
+ return await reply.code(400).send({ error: "invalid_body" });
68
+ }
69
+ patch.admin = value;
70
+ break;
71
+ default:
72
+ return await reply
73
+ .code(400)
74
+ .send({ error: "invalid_field", field: key });
75
+ }
76
+ }
77
+ if (Object.keys(patch).length === 0) {
78
+ return await reply.code(400).send({ error: "invalid_body" });
79
+ }
80
+ // The last record-admin may not demote themselves — the directory
81
+ // would hold no admin at all (group admins never count).
82
+ if (patch.admin === false &&
83
+ target.admin &&
84
+ identity.users.recordAdminCount() === 1) {
85
+ return await reply.code(409).send({ error: "last_admin" });
86
+ }
87
+ const updated = await identity.users.update(user, {
88
+ ...(patch.name === undefined ? {} : { name: patch.name }),
89
+ ...(patch.email === undefined ? {} : { email: patch.email }),
90
+ ...(patch.admin === undefined ? {} : { admin: patch.admin }),
91
+ });
92
+ return userWire(updated);
93
+ });
94
+ app.post("/v1/users/invites", async (request, reply) => {
95
+ if (!request.principal.admin) {
96
+ return await reply.code(403).send({ error: "forbidden" });
97
+ }
98
+ const body = request.body;
99
+ if (typeof body !== "object" ||
100
+ body === null ||
101
+ Array.isArray(body)) {
102
+ return await reply.code(400).send({ error: "invalid_body" });
103
+ }
104
+ const admin = body.admin === undefined
105
+ ? false
106
+ : body.admin;
107
+ if (typeof admin !== "boolean") {
108
+ return await reply.code(400).send({ error: "invalid_body" });
109
+ }
110
+ const expiresInMsRaw = body.expiresInMs;
111
+ let expiresInMs;
112
+ if (expiresInMsRaw !== undefined) {
113
+ if (typeof expiresInMsRaw !== "number" ||
114
+ !Number.isFinite(expiresInMsRaw) ||
115
+ expiresInMsRaw <= 0) {
116
+ return await reply.code(400).send({ error: "invalid_body" });
117
+ }
118
+ expiresInMs = expiresInMsRaw;
119
+ }
120
+ for (const key of Object.keys(body)) {
121
+ if (key !== "admin" && key !== "expiresInMs") {
122
+ return await reply
123
+ .code(400)
124
+ .send({ error: "invalid_field", field: key });
125
+ }
126
+ }
127
+ // Issuance rides the claim lock too: an invite minted while a claim
128
+ // is mid-flight cannot interleave with the redemption path's
129
+ // read-decide-write.
130
+ const invite = await withClaimLock(() => identity.invites.create({ admin, ...(expiresInMs === undefined ? {} : { expiresInMs }) }));
131
+ const response = {
132
+ id: invite.id,
133
+ code: invite.code,
134
+ expiresAt: invite.expiresAt,
135
+ };
136
+ return await reply.code(201).send(response);
137
+ });
138
+ app.delete("/v1/users/:user", async (request, reply) => {
139
+ if (!request.principal.admin) {
140
+ return await reply.code(403).send({ error: "forbidden" });
141
+ }
142
+ const { user } = request.params;
143
+ const target = identity.users.get(user);
144
+ if (target === undefined) {
145
+ return await reply.code(404).send({ error: "unknown_user" });
146
+ }
147
+ // transferTo is required and must name ANOTHER directory user.
148
+ const query = request.query;
149
+ if (query.transferTo === undefined ||
150
+ query.transferTo === "" ||
151
+ query.transferTo === user ||
152
+ identity.users.get(query.transferTo) === undefined) {
153
+ return await reply.code(400).send({ error: "invalid_transfer" });
154
+ }
155
+ // The last record-admin may not be deleted — admin is not a
156
+ // transferable property (transferTo moves resources, not rights).
157
+ if (target.admin && identity.users.recordAdminCount() === 1) {
158
+ return await reply.code(409).send({ error: "last_admin" });
159
+ }
160
+ const to = query.transferTo;
161
+ await transferOwnership(ctx, user, to);
162
+ await identity.tokens.revokeAllFor(user);
163
+ await identity.users.remove(user);
164
+ // The lifecycle event: extensions transfer their own records
165
+ // (deadlocked-ordering free — the core transfer already landed).
166
+ await eventBus.emitUserDeleted({ user, transferTo: to }, BACKGROUND_CONTEXT);
167
+ return await reply.code(204).send();
168
+ });
169
+ }