@sigrun/sigrun 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. 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
+ opened.userServices.settingsChanged(slug, 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
+ }
@@ -120,6 +120,21 @@ export function registerMetaRoutes(app, ctx) {
120
120
  // §14.2/§14.4: the development flag — gates the gallery and the
121
121
  // development error bodies (a boot constant, cached like the rest).
122
122
  dev: config.dev,
123
+ // §13.3 (U4 Task 2): the supervised posture — the settings panel's
124
+ // pendingRestart callout renders its Restart button only for a
125
+ // supervised admin (an unsupervised deploy's restart exits and
126
+ // STAYS down, so the button must not exist there).
127
+ supervised: config.supervised,
128
+ isolation: {
129
+ mode: config.isolation.mode,
130
+ active: opened.isolationState.active(),
131
+ available: opened.isolation.available,
132
+ abi: opened.isolation.abi,
133
+ ...(opened.isolation.reason === undefined
134
+ ? {}
135
+ : { reason: opened.isolation.reason }),
136
+ osUsers: opened.osUsers.status,
137
+ },
123
138
  // The seam inventory: declaration-derived, no doc reads.
124
139
  seam: {
125
140
  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 === readSetupCode(identity.cacheDir);
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
- const claimed = identity.users.isClaimed();
144
- if (!claimed) {
145
- // The first admin: proxy headers, loopback bind, or the setup
146
- // code ("the first invite" — same shape, admin grant).
147
- let grant;
148
- if (proxyIdentity !== undefined) {
149
- // Proxy claims take identity from the headers, verbatim
150
- // (body identity fields are ignored — the proxy vouched).
151
- grant = { ...proxyIdentity, source: "proxy" };
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
- else if (loopback) {
154
- if (validated.user === undefined)
155
- return { status: 400, error: "invalid_body" };
156
- grant = {
157
- user: validated.user,
158
- ...(validated.name === undefined ? {} : { name: validated.name }),
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
- else if (codeWasSetup) {
164
- if (validated.user === undefined)
165
- return { status: 400, error: "invalid_body" };
166
- grant = {
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
- source: "cli",
171
- };
220
+ admin: invite.admin,
221
+ source: "invite",
222
+ });
223
+ return { status: 200, record };
172
224
  }
173
- else {
174
- // Wrong or absent code where one is required.
175
- return { status: 403, error: "invalid_code" };
176
- }
177
- if (!USERNAME_PATTERN.test(grant.user)) {
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: grant.user,
182
- ...(grant.name === undefined ? {} : { name: grant.name }),
183
- ...(grant.email === undefined ? {} : { email: grant.email }),
184
- admin: true,
185
- source: grant.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
- // Claimed: an invite code admits one more user; a codeless
200
- // proxy/loopback caller auto-provisions (or re-surfaces); a code
201
- // that WAS the setup code lost the race — 409, per the mutex
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
- const record = await identity.users.add({
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 });