@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.
- package/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- 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
|
+
}
|