@sigrun/sigrun 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +352 -36
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/agent-env.js +62 -0
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -55
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -8
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B4-GYqEb.js +0 -50
- package/webui/dist/assets/index-BiSMljer.css +0 -1
|
@@ -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)
|
|
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.
|
|
37
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
* →
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
}
|