@sigrun/sigrun 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- 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 +259 -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 +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- 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 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +211 -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 +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -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 +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- 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 +213 -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 +152 -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/notifications.js +21 -4
- 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 +274 -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 +19 -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 +11 -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 +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { ipAllowed } from "../auth/ip.js";
|
|
2
|
+
import { bearerOf, resolveToken, sessionCookieOf } from "../auth/pipeline.js";
|
|
3
|
+
import { LoginFlows } from "../login/flows.js";
|
|
4
|
+
import { clearSetupCode, readSetupCode } from "../../harness/identity/setup-code.js";
|
|
5
|
+
import { resolveLogin } from "../login/resolve.js";
|
|
6
|
+
import { sendLoginResolution } from "../login/http.js";
|
|
7
|
+
import { SetupClaimStore } from "../login/setup-claim.js";
|
|
8
|
+
export const LOGIN_STATE_COOKIE = "sigrun_login_state";
|
|
9
|
+
/** Only local absolute paths may survive the external-identity round trip. */
|
|
10
|
+
export function safeReturnTo(value) {
|
|
11
|
+
if (typeof value !== "string" ||
|
|
12
|
+
!value.startsWith("/") ||
|
|
13
|
+
value.startsWith("//") ||
|
|
14
|
+
value.includes("\\") ||
|
|
15
|
+
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value))
|
|
16
|
+
return "/";
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function cookieValue(request, name) {
|
|
20
|
+
const header = request.headers.cookie;
|
|
21
|
+
if (typeof header !== "string")
|
|
22
|
+
return undefined;
|
|
23
|
+
for (const pair of header.split(";")) {
|
|
24
|
+
const eq = pair.indexOf("=");
|
|
25
|
+
if (eq !== -1 && pair.slice(0, eq).trim() === name) {
|
|
26
|
+
return pair.slice(eq + 1).trim() || undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function stateCookie(key, request, ctx) {
|
|
32
|
+
const trusted = ipAllowed(request.socket.remoteAddress, ctx.config.auth.proxy.trustedProxies);
|
|
33
|
+
const forwarded = request.headers["x-forwarded-proto"];
|
|
34
|
+
const secure = request.protocol === "https" ||
|
|
35
|
+
(trusted && (Array.isArray(forwarded) ? forwarded[0] : forwarded) === "https");
|
|
36
|
+
return `${LOGIN_STATE_COOKIE}=${key}; HttpOnly; SameSite=Lax; Path=/v1/auth/callback` +
|
|
37
|
+
(secure ? "; Secure" : "");
|
|
38
|
+
}
|
|
39
|
+
function browserError(reply, statusCode, error, asJson = false) {
|
|
40
|
+
if (asJson)
|
|
41
|
+
return reply.code(statusCode).send({ error });
|
|
42
|
+
return reply.code(statusCode).header("location", `/login?error=${encodeURIComponent(error)}`).send();
|
|
43
|
+
}
|
|
44
|
+
function queryOf(request) {
|
|
45
|
+
const query = {};
|
|
46
|
+
for (const [key, value] of Object.entries(request.query)) {
|
|
47
|
+
if (typeof value === "string")
|
|
48
|
+
query[key] = value;
|
|
49
|
+
else if (Array.isArray(value) && typeof value[0] === "string")
|
|
50
|
+
query[key] = value[0];
|
|
51
|
+
}
|
|
52
|
+
return query;
|
|
53
|
+
}
|
|
54
|
+
/** Register core-owned login flows over extension-contributed identity proofs. */
|
|
55
|
+
export function registerLoginRoutes(app, ctx, options = {}) {
|
|
56
|
+
// Deliberately per-server, never a harness singleton: restart invalidates
|
|
57
|
+
// in-flight logins and separate server instances never share browser state.
|
|
58
|
+
const flows = options.flows ?? new LoginFlows();
|
|
59
|
+
const setupClaims = new SetupClaimStore(ctx.opened.identity.cacheDir);
|
|
60
|
+
const providers = new Map(ctx.opened.loginProviders.map((entry) => [entry.provider, entry]));
|
|
61
|
+
const resolve = options.resolver ?? (async (input) => {
|
|
62
|
+
const outcome = await resolveLogin({
|
|
63
|
+
provider: input.provider,
|
|
64
|
+
result: input.result,
|
|
65
|
+
...(input.flow.link === undefined ? {} : { linkUser: input.flow.link }),
|
|
66
|
+
}, {
|
|
67
|
+
accounts: ctx.opened.identity.accounts,
|
|
68
|
+
users: ctx.opened.identity.users,
|
|
69
|
+
invites: ctx.opened.identity.invites,
|
|
70
|
+
setupCode: () => readSetupCode(ctx.opened.identity.cacheDir),
|
|
71
|
+
clearSetupCode: () => clearSetupCode(ctx.opened.identity.cacheDir),
|
|
72
|
+
setupClaims,
|
|
73
|
+
policy: providers.get(input.provider)?.contribution.policy ?? {},
|
|
74
|
+
});
|
|
75
|
+
if (outcome.kind === "needs_code") {
|
|
76
|
+
const parked = flows.park(input.flow, input.result);
|
|
77
|
+
return await input.reply.code(302).header("location", `/login/code?key=${encodeURIComponent(parked.key)}&reason=${outcome.reason}`).send();
|
|
78
|
+
}
|
|
79
|
+
return await sendLoginResolution(outcome, {
|
|
80
|
+
provider: input.provider,
|
|
81
|
+
returnTo: input.flow.returnTo,
|
|
82
|
+
request: input.request,
|
|
83
|
+
reply: input.reply,
|
|
84
|
+
ctx,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
app.get("/v1/auth/providers", async () => ({
|
|
88
|
+
// External sign-in creates token-backed sessions, so a proxy-only
|
|
89
|
+
// deployment cannot use it. Do not advertise a flow that would loop.
|
|
90
|
+
providers: ctx.methods.includes("token")
|
|
91
|
+
? ctx.opened.loginProviders.map(({ provider, contribution }) => ({
|
|
92
|
+
provider,
|
|
93
|
+
label: contribution.label,
|
|
94
|
+
...(contribution.icon === undefined ? {} : { icon: contribution.icon }),
|
|
95
|
+
}))
|
|
96
|
+
: [],
|
|
97
|
+
}));
|
|
98
|
+
app.get("/v1/auth/login/:provider", async (request, reply) => {
|
|
99
|
+
const { provider } = request.params;
|
|
100
|
+
const binding = providers.get(provider);
|
|
101
|
+
if (binding === undefined)
|
|
102
|
+
return browserError(reply, 404, "unknown_provider");
|
|
103
|
+
const query = queryOf(request);
|
|
104
|
+
let link;
|
|
105
|
+
if (query.link === "1") {
|
|
106
|
+
const presented = bearerOf(request) ?? sessionCookieOf(request);
|
|
107
|
+
const principal = presented === undefined ? { status: 401 } : resolveToken(request, ctx);
|
|
108
|
+
if ("status" in principal)
|
|
109
|
+
return await reply.code(401).send({ error: "unauthenticated" });
|
|
110
|
+
if (principal.user === undefined)
|
|
111
|
+
return await reply.code(401).send({ error: "unauthenticated" });
|
|
112
|
+
link = principal.user;
|
|
113
|
+
}
|
|
114
|
+
const returnTo = safeReturnTo(query.return);
|
|
115
|
+
const started = flows.start(provider, { returnTo, ...(link === undefined ? {} : { link }) });
|
|
116
|
+
const callbackUrl = `${ctx.config.publicUrl ?? `${request.protocol}://${request.headers.host ?? "localhost"}`}/v1/auth/callback/${encodeURIComponent(provider)}`;
|
|
117
|
+
const flow = {
|
|
118
|
+
state: started.state,
|
|
119
|
+
callbackUrl,
|
|
120
|
+
data: started.data,
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
const begun = await binding.contribution.begin(flow, {
|
|
124
|
+
abortSignal: request.raw.signal,
|
|
125
|
+
});
|
|
126
|
+
flows.save(started.state, flow);
|
|
127
|
+
return await reply
|
|
128
|
+
.code(302)
|
|
129
|
+
.header("location", begun.redirectUrl)
|
|
130
|
+
.header("set-cookie", stateCookie(started.browserKey, request, ctx))
|
|
131
|
+
.send();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error(`sigrun: login provider ${provider} begin failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
|
+
flows.take(started.state, started.browserKey);
|
|
136
|
+
return browserError(reply, 500, "provider_error");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const callback = async (request, reply) => {
|
|
140
|
+
const { provider } = request.params;
|
|
141
|
+
const binding = providers.get(provider);
|
|
142
|
+
if (binding === undefined)
|
|
143
|
+
return browserError(reply, 404, "unknown_provider");
|
|
144
|
+
const query = queryOf(request);
|
|
145
|
+
const state = query.state ?? "";
|
|
146
|
+
const browserKey = cookieValue(request, LOGIN_STATE_COOKIE) ?? "";
|
|
147
|
+
const flow = flows.take(state, browserKey);
|
|
148
|
+
if (flow === undefined)
|
|
149
|
+
return browserError(reply, 400, flows.failure(state, browserKey));
|
|
150
|
+
// State names ONE provider. A flow remains consumed on mismatch, so it
|
|
151
|
+
// cannot be replayed through the original provider after this attempt.
|
|
152
|
+
if (flow.provider !== provider)
|
|
153
|
+
return browserError(reply, 400, "invalid_state");
|
|
154
|
+
// Validate again at the trust boundary: a resolver must never receive an
|
|
155
|
+
// open redirect even if the stored flow was constructed elsewhere.
|
|
156
|
+
const safeFlow = { ...flow, returnTo: safeReturnTo(flow.returnTo) };
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
result = await binding.contribution.complete({ state: flow.state, callbackUrl: flow.callbackUrl, data: flow.data }, { query, ...(request.body === undefined ? {} : { body: request.body }) }, { abortSignal: request.raw.signal });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
console.error(`sigrun: login provider ${provider} complete failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
+
return browserError(reply, 400, "provider_error");
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return await resolve({ provider, result, flow: safeFlow, request, reply });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (reply.sent)
|
|
170
|
+
throw error;
|
|
171
|
+
console.error(`sigrun: login resolver failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
return browserError(reply, 500, "login_failed");
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
app.get("/v1/auth/callback/:provider", callback);
|
|
176
|
+
app.post("/v1/auth/callback/:provider", callback);
|
|
177
|
+
app.post("/v1/auth/login/code", async (request, reply) => {
|
|
178
|
+
const body = request.body;
|
|
179
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
180
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
181
|
+
}
|
|
182
|
+
const record = body;
|
|
183
|
+
for (const field of Object.keys(record)) {
|
|
184
|
+
if (field !== "key" && field !== "code") {
|
|
185
|
+
return await reply.code(400).send({ error: "invalid_field", field });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (typeof record.key !== "string" || typeof record.code !== "string") {
|
|
189
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
190
|
+
}
|
|
191
|
+
// The webui client requests JSON so fetch never follows a redirect
|
|
192
|
+
// into an HTML document. Existing form callers keep the 302 contract.
|
|
193
|
+
const asJson = request.headers.accept?.includes("application/json") === true;
|
|
194
|
+
const pending = flows.takePending(record.key);
|
|
195
|
+
if (pending === undefined)
|
|
196
|
+
return browserError(reply, 400, "login_expired", asJson);
|
|
197
|
+
const outcome = await resolveLogin({
|
|
198
|
+
provider: pending.provider,
|
|
199
|
+
result: pending.result,
|
|
200
|
+
code: record.code,
|
|
201
|
+
}, {
|
|
202
|
+
accounts: ctx.opened.identity.accounts,
|
|
203
|
+
users: ctx.opened.identity.users,
|
|
204
|
+
invites: ctx.opened.identity.invites,
|
|
205
|
+
setupCode: () => readSetupCode(ctx.opened.identity.cacheDir),
|
|
206
|
+
clearSetupCode: () => clearSetupCode(ctx.opened.identity.cacheDir),
|
|
207
|
+
setupClaims,
|
|
208
|
+
policy: providers.get(pending.provider)?.contribution.policy ?? {},
|
|
209
|
+
});
|
|
210
|
+
if (outcome.kind === "needs_code") {
|
|
211
|
+
flows.restorePending(record.key, pending);
|
|
212
|
+
return browserError(reply, 403, "invalid_code", asJson);
|
|
213
|
+
}
|
|
214
|
+
return await sendLoginResolution(outcome, {
|
|
215
|
+
provider: pending.provider,
|
|
216
|
+
returnTo: pending.returnTo,
|
|
217
|
+
request,
|
|
218
|
+
reply,
|
|
219
|
+
ctx,
|
|
220
|
+
asJson,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-user M2 Task 2: self-service per-user extension stores.
|
|
3
|
+
*
|
|
4
|
+
* Every operation resolves from request.principal.user — never a query
|
|
5
|
+
* parameter. There is no cross-user selector: an admin asking for
|
|
6
|
+
* `?user=alice` still addresses the admin's own stores, so another user's
|
|
7
|
+
* data is never returned. Secret values are accepted on PUT and read
|
|
8
|
+
* in-process by extensions, but no HTTP route here ever returns one.
|
|
9
|
+
*/
|
|
10
|
+
/** Register the self-only settings/secrets routes. */
|
|
11
|
+
export function registerMeRoutes(app, ctx) {
|
|
12
|
+
const { opened } = ctx;
|
|
13
|
+
app.get("/v1/me/services", async (request, reply) => {
|
|
14
|
+
const user = request.principal.user;
|
|
15
|
+
if (user === undefined || opened.identity.users.get(user) === undefined) {
|
|
16
|
+
return await reply.code(400).send({ error: "named_user_required" });
|
|
17
|
+
}
|
|
18
|
+
return { services: opened.userServices.statuses().filter((status) => status.user === user) };
|
|
19
|
+
});
|
|
20
|
+
/** Resolve the caller + slug before any store I/O. */
|
|
21
|
+
const resolve = (request, reply) => {
|
|
22
|
+
const user = request.principal.user;
|
|
23
|
+
if (user === undefined || opened.identity.users.get(user) === undefined) {
|
|
24
|
+
void reply.code(400).send({ error: "named_user_required" });
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const { slug } = request.params;
|
|
28
|
+
if (!opened.extensionOutcomes.some((outcome) => outcome.name === slug)) {
|
|
29
|
+
void reply.code(404).send({ error: "extension_not_found" });
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return opened.extensionStores.userExtensionStores(slug, user);
|
|
33
|
+
};
|
|
34
|
+
app.get("/v1/me/extensions/:slug/settings", async (request, reply) => {
|
|
35
|
+
const stores = resolve(request, reply);
|
|
36
|
+
if (stores === undefined)
|
|
37
|
+
return reply;
|
|
38
|
+
const response = {
|
|
39
|
+
settings: await stores.settings.get(),
|
|
40
|
+
pendingRestart: [],
|
|
41
|
+
};
|
|
42
|
+
return response;
|
|
43
|
+
});
|
|
44
|
+
app.put("/v1/me/extensions/:slug/settings", async (request, reply) => {
|
|
45
|
+
const stores = resolve(request, reply);
|
|
46
|
+
if (stores === undefined)
|
|
47
|
+
return reply;
|
|
48
|
+
const body = request.body;
|
|
49
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
50
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
51
|
+
}
|
|
52
|
+
const settings = body;
|
|
53
|
+
await stores.settings.set(settings);
|
|
54
|
+
const { slug } = request.params;
|
|
55
|
+
ctx.publishSettingsChanged({ extension: slug }, Object.keys(settings), request.principal.user);
|
|
56
|
+
const response = { settings, pendingRestart: [] };
|
|
57
|
+
return response;
|
|
58
|
+
});
|
|
59
|
+
app.get("/v1/me/extensions/:slug/secrets", async (request, reply) => {
|
|
60
|
+
const stores = resolve(request, reply);
|
|
61
|
+
if (stores === undefined)
|
|
62
|
+
return reply;
|
|
63
|
+
// This store method lists names only; values have no HTTP read surface.
|
|
64
|
+
return { keys: await opened.extensionStores.userSecretKeys(request.params.slug, request.principal.user) };
|
|
65
|
+
});
|
|
66
|
+
app.put("/v1/me/extensions/:slug/secrets/:key", async (request, reply) => {
|
|
67
|
+
const stores = resolve(request, reply);
|
|
68
|
+
if (stores === undefined)
|
|
69
|
+
return reply;
|
|
70
|
+
const body = request.body;
|
|
71
|
+
if (typeof body !== "object" ||
|
|
72
|
+
body === null ||
|
|
73
|
+
Array.isArray(body) ||
|
|
74
|
+
typeof body.value !== "string" ||
|
|
75
|
+
body.value === "") {
|
|
76
|
+
return await reply.code(400).send({ error: "invalid_field", field: "value" });
|
|
77
|
+
}
|
|
78
|
+
const { key } = request.params;
|
|
79
|
+
await stores.secrets.set(key, body.value);
|
|
80
|
+
return await reply.code(204).send();
|
|
81
|
+
});
|
|
82
|
+
app.delete("/v1/me/extensions/:slug/secrets/:key", async (request, reply) => {
|
|
83
|
+
const stores = resolve(request, reply);
|
|
84
|
+
if (stores === undefined)
|
|
85
|
+
return reply;
|
|
86
|
+
const { key } = request.params;
|
|
87
|
+
await stores.secrets.delete(key);
|
|
88
|
+
return await reply.code(204).send();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -39,6 +39,10 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
39
39
|
version: SIGRUN_VERSION,
|
|
40
40
|
harnessVersion: HARNESS_VERSION,
|
|
41
41
|
schemaVersion: SCHEMA_VERSION,
|
|
42
|
+
// The retained listener facts (release-readiness Task 1, serving
|
|
43
|
+
// spec §2): measured once at listen, projected verbatim — this
|
|
44
|
+
// route never probes (a pure synchronous projection).
|
|
45
|
+
listener: ctx.listener,
|
|
42
46
|
extensions: opened.extensionOutcomes.map((outcome) => {
|
|
43
47
|
const workers = workerStatuses.get(outcome.name);
|
|
44
48
|
const surfaces = opened.extensionSurfaces.find((binding) => binding.extension === outcome.name);
|
|
@@ -120,6 +124,21 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
120
124
|
// §14.2/§14.4: the development flag — gates the gallery and the
|
|
121
125
|
// development error bodies (a boot constant, cached like the rest).
|
|
122
126
|
dev: config.dev,
|
|
127
|
+
// §13.3 (U4 Task 2): the supervised posture — the settings panel's
|
|
128
|
+
// pendingRestart callout renders its Restart button only for a
|
|
129
|
+
// supervised admin (an unsupervised deploy's restart exits and
|
|
130
|
+
// STAYS down, so the button must not exist there).
|
|
131
|
+
supervised: config.supervised,
|
|
132
|
+
isolation: {
|
|
133
|
+
mode: config.isolation.mode,
|
|
134
|
+
active: opened.isolationState.active(),
|
|
135
|
+
available: opened.isolation.available,
|
|
136
|
+
abi: opened.isolation.abi,
|
|
137
|
+
...(opened.isolation.reason === undefined
|
|
138
|
+
? {}
|
|
139
|
+
: { reason: opened.isolation.reason }),
|
|
140
|
+
osUsers: opened.osUsers.status,
|
|
141
|
+
},
|
|
123
142
|
// The seam inventory: declaration-derived, no doc reads.
|
|
124
143
|
seam: {
|
|
125
144
|
interactions: [...opened.seamKinds]
|
|
@@ -18,6 +18,8 @@ import { withClaimLock } from "../auth/claim-lock.js";
|
|
|
18
18
|
import { ipAllowed } from "../auth/ip.js";
|
|
19
19
|
import { isLoopbackHost } from "../auth/loopback.js";
|
|
20
20
|
import { readSetupCode, clearSetupCode } from "../../harness/identity/index.js";
|
|
21
|
+
import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
|
|
22
|
+
import { AdmissionRefusedError } from "../../harness/identity/users.js";
|
|
21
23
|
import { stampSessionOwner } from "../helpers.js";
|
|
22
24
|
import { buildSessionCookie } from "./auth.js";
|
|
23
25
|
/** One header read (Fastify headers are string | string[]; first wins). */
|
|
@@ -136,106 +138,114 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
136
138
|
// valid code — it gets 409 `already_claimed` (the brief's "one
|
|
137
139
|
// claimant wins and the rest get 409"), never a misleading 403.
|
|
138
140
|
const codeWasSetup = validated.code !== undefined &&
|
|
139
|
-
validated.code
|
|
141
|
+
setupCodeMatches(validated.code, readSetupCode(identity.cacheDir));
|
|
140
142
|
// The claim critical section: read state, decide, add — all inside
|
|
141
143
|
// the one mutex, so concurrent claims cannot both pass the gate.
|
|
142
144
|
const outcome = await withClaimLock(async () => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
try {
|
|
146
|
+
const claimed = identity.users.isClaimed();
|
|
147
|
+
if (!claimed) {
|
|
148
|
+
// The first admin: proxy headers, loopback bind, or the setup
|
|
149
|
+
// code ("the first invite" — same shape, admin grant).
|
|
150
|
+
let grant;
|
|
151
|
+
if (proxyIdentity !== undefined) {
|
|
152
|
+
// Proxy claims take identity from the headers, verbatim
|
|
153
|
+
// (body identity fields are ignored — the proxy vouched).
|
|
154
|
+
grant = { ...proxyIdentity, source: "proxy" };
|
|
155
|
+
}
|
|
156
|
+
else if (loopback) {
|
|
157
|
+
if (validated.user === undefined)
|
|
158
|
+
return { status: 400, error: "invalid_body" };
|
|
159
|
+
grant = {
|
|
160
|
+
user: validated.user,
|
|
161
|
+
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
162
|
+
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
163
|
+
source: "cli",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
else if (codeWasSetup) {
|
|
167
|
+
if (validated.user === undefined)
|
|
168
|
+
return { status: 400, error: "invalid_body" };
|
|
169
|
+
grant = {
|
|
170
|
+
user: validated.user,
|
|
171
|
+
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
172
|
+
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
173
|
+
source: "cli",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Wrong or absent code where one is required.
|
|
178
|
+
return { status: 403, error: "invalid_code" };
|
|
179
|
+
}
|
|
180
|
+
if (!USERNAME_PATTERN.test(grant.user)) {
|
|
181
|
+
return { status: 400, error: "invalid_username" };
|
|
182
|
+
}
|
|
183
|
+
const record = await identity.users.add({
|
|
184
|
+
user: grant.user,
|
|
185
|
+
...(grant.name === undefined ? {} : { name: grant.name }),
|
|
186
|
+
...(grant.email === undefined ? {} : { email: grant.email }),
|
|
187
|
+
admin: true,
|
|
188
|
+
source: grant.source,
|
|
189
|
+
});
|
|
190
|
+
// The setup code dies with the claim (single-shot); the
|
|
191
|
+
// pinned env form re-materializes at the next unclaimed
|
|
192
|
+
// boot, which no longer exists once claimed.
|
|
193
|
+
await clearSetupCode(identity.cacheDir);
|
|
194
|
+
// The reserved root conversation belongs to the claimant —
|
|
195
|
+
// the server's first configured resource.
|
|
196
|
+
const root = await ctx.opened.harness.conversation(ctx.rootId, BACKGROUND_CONTEXT);
|
|
197
|
+
if (root !== undefined) {
|
|
198
|
+
await stampSessionOwner(root, record.user, ctx.opened);
|
|
199
|
+
}
|
|
200
|
+
return { status: 200, record };
|
|
152
201
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
160
|
-
source: "cli",
|
|
161
|
-
};
|
|
202
|
+
// Claimed: an invite code admits one more user; a codeless
|
|
203
|
+
// proxy/loopback caller auto-provisions (or re-surfaces); a code
|
|
204
|
+
// that WAS the setup code lost the race — 409, per the mutex
|
|
205
|
+
// contract (one winner, the rest already_claimed).
|
|
206
|
+
if (codeWasSetup) {
|
|
207
|
+
return { status: 409, error: "already_claimed" };
|
|
162
208
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
209
|
+
if (validated.code !== undefined) {
|
|
210
|
+
const invite = validated.user === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: await identity.invites.redeem(validated.code, validated.user);
|
|
213
|
+
if (invite === undefined) {
|
|
214
|
+
return { status: 403, error: "invalid_code" };
|
|
215
|
+
}
|
|
216
|
+
const record = await identity.users.add({
|
|
167
217
|
user: validated.user,
|
|
168
218
|
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
169
219
|
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
170
|
-
|
|
171
|
-
|
|
220
|
+
admin: invite.admin,
|
|
221
|
+
source: "invite",
|
|
222
|
+
});
|
|
223
|
+
return { status: 200, record };
|
|
172
224
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return { status: 400, error: "invalid_username" };
|
|
225
|
+
// Claimed + no code: only a trusted proxy identity still
|
|
226
|
+
// admits (auto-provision, or the existing record back) — a
|
|
227
|
+
// loopback/token caller has “nothing valid” and gets 409.
|
|
228
|
+
if (proxyIdentity === undefined) {
|
|
229
|
+
return { status: 409, error: "already_claimed" };
|
|
179
230
|
}
|
|
231
|
+
const existing = identity.users.get(proxyIdentity.user);
|
|
232
|
+
if (existing !== undefined)
|
|
233
|
+
return { status: 200, record: existing };
|
|
180
234
|
const record = await identity.users.add({
|
|
181
|
-
user:
|
|
182
|
-
...(
|
|
183
|
-
...(
|
|
184
|
-
admin:
|
|
185
|
-
source:
|
|
235
|
+
user: proxyIdentity.user,
|
|
236
|
+
...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
|
|
237
|
+
...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
|
|
238
|
+
admin: false,
|
|
239
|
+
source: "proxy",
|
|
186
240
|
});
|
|
187
|
-
// The setup code dies with the claim (single-shot); the
|
|
188
|
-
// pinned env form re-materializes at the next unclaimed
|
|
189
|
-
// boot, which no longer exists once claimed.
|
|
190
|
-
await clearSetupCode(identity.cacheDir);
|
|
191
|
-
// The reserved root conversation belongs to the claimant —
|
|
192
|
-
// the server's first configured resource.
|
|
193
|
-
const root = await ctx.opened.harness.conversation(ctx.rootId, BACKGROUND_CONTEXT);
|
|
194
|
-
if (root !== undefined) {
|
|
195
|
-
await stampSessionOwner(root, record.user);
|
|
196
|
-
}
|
|
197
241
|
return { status: 200, record };
|
|
198
242
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// contract (one winner, the rest already_claimed).
|
|
203
|
-
if (codeWasSetup) {
|
|
204
|
-
return { status: 409, error: "already_claimed" };
|
|
205
|
-
}
|
|
206
|
-
if (validated.code !== undefined) {
|
|
207
|
-
const invite = validated.user === undefined
|
|
208
|
-
? undefined
|
|
209
|
-
: await identity.invites.redeem(validated.code, validated.user);
|
|
210
|
-
if (invite === undefined) {
|
|
211
|
-
return { status: 403, error: "invalid_code" };
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof AdmissionRefusedError) {
|
|
245
|
+
return { status: 409, error: error.code };
|
|
212
246
|
}
|
|
213
|
-
|
|
214
|
-
user: validated.user,
|
|
215
|
-
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
216
|
-
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
217
|
-
admin: invite.admin,
|
|
218
|
-
source: "invite",
|
|
219
|
-
});
|
|
220
|
-
return { status: 200, record };
|
|
221
|
-
}
|
|
222
|
-
// Claimed + no code: only a trusted proxy identity still
|
|
223
|
-
// admits (auto-provision, or the existing record back) — a
|
|
224
|
-
// loopback/token caller has “nothing valid” and gets 409.
|
|
225
|
-
if (proxyIdentity === undefined) {
|
|
226
|
-
return { status: 409, error: "already_claimed" };
|
|
247
|
+
throw error;
|
|
227
248
|
}
|
|
228
|
-
const existing = identity.users.get(proxyIdentity.user);
|
|
229
|
-
if (existing !== undefined)
|
|
230
|
-
return { status: 200, record: existing };
|
|
231
|
-
const record = await identity.users.add({
|
|
232
|
-
user: proxyIdentity.user,
|
|
233
|
-
...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
|
|
234
|
-
...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
|
|
235
|
-
admin: false,
|
|
236
|
-
source: "proxy",
|
|
237
|
-
});
|
|
238
|
-
return { status: 200, record };
|
|
239
249
|
});
|
|
240
250
|
if (outcome.status !== 200) {
|
|
241
251
|
return await reply.code(outcome.status).send({ error: outcome.error });
|