@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +352 -36
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/schema.js +9 -2
  5. package/dist/blocks/validate.js +57 -8
  6. package/dist/cli.js +26 -2
  7. package/dist/config.js +67 -1
  8. package/dist/extension.d.ts +189 -15
  9. package/dist/harness/agent-env.js +62 -0
  10. package/dist/harness/ambient.js +39 -185
  11. package/dist/harness/blocks/entries.js +47 -0
  12. package/dist/harness/blocks/state.js +58 -0
  13. package/dist/harness/blocks/tools.js +274 -0
  14. package/dist/harness/boot.js +253 -20
  15. package/dist/harness/builtins.js +15 -11
  16. package/dist/harness/event-bus.js +105 -10
  17. package/dist/harness/identity/accounts.js +186 -0
  18. package/dist/harness/identity/groups.js +14 -0
  19. package/dist/harness/identity/index.js +13 -6
  20. package/dist/harness/identity/json-store.js +15 -4
  21. package/dist/harness/identity/setup-code-check.js +9 -0
  22. package/dist/harness/identity/tokens.js +28 -5
  23. package/dist/harness/identity/users.js +111 -12
  24. package/dist/harness/interactions.js +292 -136
  25. package/dist/harness/loader.js +271 -17
  26. package/dist/harness/packages.js +1 -1
  27. package/dist/harness/reserved-sections.js +21 -0
  28. package/dist/harness/seam-host.js +80 -6
  29. package/dist/harness/settings.js +63 -2
  30. package/dist/harness/stores.js +241 -4
  31. package/dist/harness/user-services.js +391 -0
  32. package/dist/harness/user-tools.js +187 -0
  33. package/dist/harness/workspace.js +61 -55
  34. package/dist/jobs/fire.js +10 -16
  35. package/dist/sandbox/availability.js +162 -0
  36. package/dist/sandbox/command-observer.js +59 -0
  37. package/dist/sandbox/grants.js +159 -0
  38. package/dist/sandbox/helper.js +29 -0
  39. package/dist/sandbox/isolation.js +29 -0
  40. package/dist/sandbox/launch.js +20 -0
  41. package/dist/sandbox/os-account.js +49 -0
  42. package/dist/sandbox/policy.js +253 -0
  43. package/dist/sandbox/pool.js +548 -0
  44. package/dist/sandbox/protocol.js +167 -0
  45. package/dist/sandbox/sandboxed-env.js +245 -0
  46. package/dist/sandbox/unavailable-env.js +49 -0
  47. package/dist/sandbox/worker.js +281 -0
  48. package/dist/serve.js +82 -7
  49. package/dist/server/auth/access-principal.js +16 -0
  50. package/dist/server/auth/pipeline.js +47 -33
  51. package/dist/server/blocks/answers.js +94 -0
  52. package/dist/server/blocks/chat-surfaces.js +393 -0
  53. package/dist/server/blocks/notes.js +216 -0
  54. package/dist/server/blocks/summaries.js +328 -0
  55. package/dist/server/commands.js +5 -0
  56. package/dist/server/content-parts-lazy.js +13 -0
  57. package/dist/server/conversation-handler.js +2 -5
  58. package/dist/server/conversation-ops.js +138 -37
  59. package/dist/server/helpers.js +18 -6
  60. package/dist/server/host-bridge.js +40 -20
  61. package/dist/server/images/cache.js +205 -0
  62. package/dist/server/images/convert.js +13 -0
  63. package/dist/server/images/fetch.js +302 -0
  64. package/dist/server/images/ranges.js +42 -0
  65. package/dist/server/images/rewrite.js +94 -0
  66. package/dist/server/images/sign.js +84 -0
  67. package/dist/server/images/sniff.js +62 -0
  68. package/dist/server/index.js +144 -4
  69. package/dist/server/login/flows.js +126 -0
  70. package/dist/server/login/http.js +31 -0
  71. package/dist/server/login/resolve.js +133 -0
  72. package/dist/server/login/setup-claim.js +55 -0
  73. package/dist/server/login/usernames.js +15 -0
  74. package/dist/server/ownership.js +13 -0
  75. package/dist/server/routes/admin.js +58 -0
  76. package/dist/server/routes/auth.js +88 -2
  77. package/dist/server/routes/conversations.js +3 -6
  78. package/dist/server/routes/events.js +28 -3
  79. package/dist/server/routes/extensions.js +37 -5
  80. package/dist/server/routes/images.js +267 -0
  81. package/dist/server/routes/interactions.js +57 -68
  82. package/dist/server/routes/jobs.js +2 -8
  83. package/dist/server/routes/login.js +223 -0
  84. package/dist/server/routes/me.js +90 -0
  85. package/dist/server/routes/meta.js +15 -0
  86. package/dist/server/routes/onboarding.js +94 -84
  87. package/dist/server/routes/settings-sections.js +190 -0
  88. package/dist/server/routes/settings.js +16 -4
  89. package/dist/server/routes/submissions.js +1 -1
  90. package/dist/server/routes/surfaces.js +175 -23
  91. package/dist/server/routes/users.js +116 -20
  92. package/dist/server/schema-fixture.js +213 -0
  93. package/dist/server/session-meta.js +3 -0
  94. package/dist/server/shutdown.js +11 -4
  95. package/dist/server/static.js +18 -0
  96. package/dist/server/surfaces/dedup.js +9 -2
  97. package/dist/server/surfaces/service.js +181 -13
  98. package/dist/server/surfaces/settings-surfaces.js +597 -0
  99. package/dist/server/turns/submit-turn.js +26 -0
  100. package/dist/server/views.js +16 -0
  101. package/dist/settings/core-sections.js +532 -0
  102. package/dist/settings/pending.js +181 -0
  103. package/dist/settings/registry.js +453 -0
  104. package/dist/skills/index.js +6 -0
  105. package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
  106. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  107. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  108. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  109. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  110. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  111. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  112. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  113. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  114. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  115. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  116. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  117. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  118. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  119. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  120. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  121. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  122. package/node_modules/@sigrun/shared/package.json +1 -1
  123. package/package.json +13 -8
  124. package/webui/dist/assets/index-CxB969qp.js +55 -0
  125. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  126. package/webui/dist/index.html +2 -2
  127. package/webui/dist/assets/index-B4-GYqEb.js +0 -50
  128. package/webui/dist/assets/index-BiSMljer.css +0 -1
@@ -0,0 +1,31 @@
1
+ import { buildSessionCookie, sessionTtlMs } from "../routes/auth.js";
2
+ /** Translate one policy outcome into the browser's redirect/cookie response. */
3
+ export async function sendLoginResolution(outcome, input) {
4
+ if (outcome.kind === "refused") {
5
+ if (input.asJson === true)
6
+ return input.reply.code(403).send({ error: outcome.error });
7
+ return input.reply.code(403)
8
+ .header("location", `/login?error=${encodeURIComponent(outcome.error)}`)
9
+ .send();
10
+ }
11
+ if (outcome.kind === "needs_code") {
12
+ if (input.asJson === true)
13
+ return input.reply.code(403).send({ error: "invalid_code" });
14
+ return input.reply.code(403).header("location", "/login?error=invalid_code").send();
15
+ }
16
+ if (outcome.kind === "linked") {
17
+ const returnTo = "/settings?section=accounts";
18
+ if (input.asJson === true)
19
+ return input.reply.send({ returnTo });
20
+ return input.reply.code(302).header("location", returnTo).send();
21
+ }
22
+ const session = await input.ctx.opened.identity.tokens.createSession(outcome.user, input.provider, sessionTtlMs(input.ctx));
23
+ const cookie = buildSessionCookie(session.token, input.request, input.ctx);
24
+ if (input.asJson === true) {
25
+ return input.reply.header("set-cookie", cookie).send({ returnTo: input.returnTo });
26
+ }
27
+ return input.reply.code(302)
28
+ .header("location", input.returnTo)
29
+ .header("set-cookie", cookie)
30
+ .send();
31
+ }
@@ -0,0 +1,133 @@
1
+ import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
2
+ import { AdmissionRefusedError, } from "../../harness/identity/users.js";
3
+ import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
4
+ import { hashToken } from "../../harness/identity/tokens.js";
5
+ import { withClaimLock } from "../auth/claim-lock.js";
6
+ import { assignUsername } from "./usernames.js";
7
+ function allowed(result, policy) {
8
+ if (policy.allowedGroups !== undefined &&
9
+ !policy.allowedGroups.some((group) => result.groups?.includes(group) === true))
10
+ return false;
11
+ if (policy.allowedDomains !== undefined) {
12
+ if (result.profile.emailVerified !== true)
13
+ return false;
14
+ const email = result.profile.email;
15
+ const domain = email?.slice(email.lastIndexOf("@") + 1).toLowerCase();
16
+ if (email === undefined ||
17
+ email.lastIndexOf("@") <= 0 ||
18
+ !policy.allowedDomains.some((allowedDomain) => allowedDomain.toLowerCase() === domain))
19
+ return false;
20
+ }
21
+ return true;
22
+ }
23
+ function profileFields(profile) {
24
+ return {
25
+ ...(profile.name === undefined ? {} : { name: profile.name }),
26
+ ...(profile.email === undefined ? {} : { email: profile.email }),
27
+ };
28
+ }
29
+ /** Resolve one verified external identity without ever inferring an existing-user link. */
30
+ export async function resolveLogin(input, deps) {
31
+ const { provider, result } = input;
32
+ try {
33
+ if (input.linkUser !== undefined) {
34
+ await deps.accounts.link({
35
+ provider,
36
+ subject: result.subject,
37
+ user: input.linkUser,
38
+ via: "login",
39
+ });
40
+ // A link records identity only. Groups arrive on this account's
41
+ // next sign-in, never on a session/token-authenticated request.
42
+ return { kind: "linked", user: input.linkUser };
43
+ }
44
+ const existing = deps.accounts.lookup(provider, result.subject);
45
+ if (existing !== undefined) {
46
+ if (deps.users.get(existing.user) === undefined) {
47
+ return { kind: "refused", error: "not_allowed" };
48
+ }
49
+ await deps.users.setGroups(existing.user, provider, result.groups ?? []);
50
+ return { kind: "session", user: existing.user };
51
+ }
52
+ const setupResolution = await withClaimLock(async () => {
53
+ const validSetupCode = input.code !== undefined && setupCodeMatches(input.code, deps.setupCode());
54
+ if (deps.users.isClaimed()) {
55
+ // A setup code never creates a user on a claimed server. It may
56
+ // only finish the exact claim whose subject/user/code were recorded.
57
+ if (!validSetupCode)
58
+ return undefined;
59
+ const claim = deps.setupClaims.matches(provider, result.subject, input.code);
60
+ if (claim === undefined || deps.users.get(claim.user) === undefined) {
61
+ return { kind: "refused", error: "not_allowed" };
62
+ }
63
+ await deps.accounts.link({ provider, subject: result.subject, user: claim.user, via: "login" });
64
+ await deps.users.setGroups(claim.user, provider, result.groups ?? []);
65
+ await deps.clearSetupCode();
66
+ await deps.setupClaims.clear();
67
+ return { kind: "session", user: claim.user };
68
+ }
69
+ if (!validSetupCode)
70
+ return { kind: "needs_code", reason: "setup" };
71
+ const claim = deps.setupClaims.matches(provider, result.subject, input.code);
72
+ const user = claim?.user ?? assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
73
+ // Persist the recovery breadcrumb before creating the user. On a
74
+ // retry, only this exact subject may finish the claim; if the user
75
+ // write did not happen, it is retried under the recorded name.
76
+ await deps.setupClaims.record({
77
+ user,
78
+ provider,
79
+ subject: result.subject,
80
+ codeSha256: hashToken(input.code),
81
+ });
82
+ const record = deps.users.get(user) ?? await deps.users.add({
83
+ user,
84
+ ...profileFields(result.profile),
85
+ admin: true,
86
+ source: "login",
87
+ });
88
+ await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
89
+ await deps.users.setGroups(user, provider, result.groups ?? []);
90
+ await deps.clearSetupCode();
91
+ await deps.setupClaims.clear();
92
+ return { kind: "session", user: record.user };
93
+ });
94
+ if (setupResolution !== undefined)
95
+ return setupResolution;
96
+ if (deps.policy.autoProvision === true && !allowed(result, deps.policy)) {
97
+ return { kind: "refused", error: "not_allowed" };
98
+ }
99
+ if (deps.policy.autoProvision !== true && input.code === undefined) {
100
+ return { kind: "needs_code", reason: "invite" };
101
+ }
102
+ // Admission is checked before redemption so an M1 refusal burns no invite.
103
+ deps.users.checkAdmission();
104
+ const user = assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
105
+ const grant = deps.policy.autoProvision === true
106
+ ? { admin: false }
107
+ : await deps.invites.redeem(input.code, user);
108
+ if (grant === undefined)
109
+ return { kind: "refused", error: "not_allowed" };
110
+ const record = await deps.users.add({
111
+ user,
112
+ ...profileFields(result.profile),
113
+ admin: grant.admin,
114
+ source: "login",
115
+ });
116
+ await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
117
+ await deps.users.setGroups(user, provider, result.groups ?? []);
118
+ return { kind: "session", user: record.user };
119
+ }
120
+ catch (error) {
121
+ if (error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:")))
122
+ return { kind: "refused", error: "account_link_conflict" };
123
+ if (error instanceof AdmissionRefusedError)
124
+ return { kind: "refused", error: error.code };
125
+ if (error instanceof Error && error.message.startsWith("isolation_required:")) {
126
+ return { kind: "refused", error: "isolation_required" };
127
+ }
128
+ if (error instanceof Error && error.message.startsWith("auth_required:")) {
129
+ return { kind: "refused", error: "auth_required" };
130
+ }
131
+ throw error;
132
+ }
133
+ }
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { hashToken } from "../../harness/identity/tokens.js";
4
+ import { writeJson } from "../../harness/identity/json-store.js";
5
+ /**
6
+ * A durable breadcrumb for the one setup-code-paid claim. It lets a retry
7
+ * finish a crashed link without ever inferring ownership from a username.
8
+ */
9
+ export class SetupClaimStore {
10
+ #path;
11
+ #memory;
12
+ constructor(cacheDir) {
13
+ this.#path = cacheDir === undefined ? undefined : join(cacheDir, "login-setup-claim.json");
14
+ }
15
+ read() {
16
+ if (this.#path === undefined)
17
+ return this.#memory;
18
+ try {
19
+ const value = JSON.parse(readFileSync(this.#path, "utf8"));
20
+ if (typeof value !== "object" || value === null)
21
+ throw new Error("not an object");
22
+ const record = value;
23
+ if (typeof record.user !== "string" || typeof record.provider !== "string" ||
24
+ typeof record.subject !== "string" || typeof record.codeSha256 !== "string")
25
+ throw new Error("invalid fields");
26
+ return record;
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return undefined;
31
+ throw new Error(`sigrun: malformed login setup claim in ${this.#path}`, { cause: error });
32
+ }
33
+ }
34
+ async record(claim) {
35
+ if (this.#path === undefined) {
36
+ this.#memory = claim;
37
+ return;
38
+ }
39
+ await writeJson(this.#path, claim);
40
+ }
41
+ matches(provider, subject, code) {
42
+ const claim = this.read();
43
+ return claim?.provider === provider && claim.subject === subject && claim.codeSha256 === hashToken(code)
44
+ ? claim
45
+ : undefined;
46
+ }
47
+ async clear() {
48
+ if (this.#path === undefined) {
49
+ this.#memory = undefined;
50
+ return;
51
+ }
52
+ const { rm } = await import("node:fs/promises");
53
+ await rm(this.#path, { force: true });
54
+ }
55
+ }
@@ -0,0 +1,15 @@
1
+ /** Assign a new directory username; this never selects an existing user. */
2
+ export function assignUsername(profile, subject, taken) {
3
+ const candidate = profile.username ?? profile.email?.split("@", 1)[0] ?? subject;
4
+ const base = candidate
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9._-]/g, "-")
7
+ .replace(/^[^a-z0-9]+/, "") || "user";
8
+ if (!taken(base))
9
+ return base;
10
+ for (let suffix = 2;; suffix++) {
11
+ const candidateWithSuffix = `${base}-${suffix}`;
12
+ if (!taken(candidateWithSuffix))
13
+ return candidateWithSuffix;
14
+ }
15
+ }
@@ -13,6 +13,8 @@
13
13
  * receive, while the registry is server-instance state.
14
14
  */
15
15
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
16
+ import { AgentDoc } from "@earendil-works/pi-durable";
17
+ import { isAbsolute, join, resolve } from "node:path";
16
18
  import { SCAN_PAGE_SIZE } from "./helpers.js";
17
19
  import { SessionMetaDoc } from "./session-meta.js";
18
20
  /** The per-server participant lists (registration order = run order). */
@@ -46,6 +48,8 @@ export async function transferOwnership(ctx, from, to) {
46
48
  export function registerCoreOwnershipParticipant(ctx) {
47
49
  registerOwnershipParticipant(ctx, async (from, to) => {
48
50
  const { opened } = ctx;
51
+ const fromWorkspace = opened.identity.users.get(from)?.workspace ??
52
+ join(ctx.managedPaths.userAreas(from), "workspace");
49
53
  let cursor;
50
54
  do {
51
55
  const page = await opened.storage.scanConversations({ order: "ascending" }, SCAN_PAGE_SIZE, cursor, BACKGROUND_CONTEXT);
@@ -59,6 +63,15 @@ export function registerCoreOwnershipParticipant(ctx) {
59
63
  await conversation.commit(async (tx) => {
60
64
  const draft = await tx.doc(SessionMetaDoc, conversation.id);
61
65
  draft.owner = to;
66
+ const agent = await tx.doc(AgentDoc, conversation.id);
67
+ // Pin the old owner's resolution before ownership changes: a
68
+ // relative cwd must not retarget into the new owner's workspace.
69
+ if (agent.cwd === undefined)
70
+ agent.cwd = fromWorkspace;
71
+ else if (!isAbsolute(agent.cwd)) {
72
+ agent.cwd = resolve(fromWorkspace, agent.cwd);
73
+ }
74
+ await opened.rewriteUserSelection(tx, conversation.id, from, to);
62
75
  }, BACKGROUND_CONTEXT);
63
76
  }
64
77
  cursor = page.next;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Admin routes (UI-blocks U4 Task 2, spec §13.3):
3
+ * `POST /v1/admin/restart` — the graceful in-place restart.
4
+ *
5
+ * The route exists ONLY under the supervised posture: `server.supervised`
6
+ * (env `SIGRUN_SUPERVISED`, config resolution) promises that something
7
+ * restarts the process after an intentional `exit 0` — the Helm chart
8
+ * and docker compose set it (both restart the container); the
9
+ * Dockerfile deliberately does NOT, so a bare `docker run` reports
10
+ * `not_supervised` rather than dying permanently.
11
+ *
12
+ * Semantics: admin-only (403 before any body read, like PATCH
13
+ * /v1/settings); unsupervised → 409 `{error: "not_supervised"}` and
14
+ * NOTHING runs; supervised admin → 202 `{restarting: true}` and the
15
+ * restart callback is scheduled on the NEXT tick (`setImmediate`) so
16
+ * the response always flushes before the drain begins. The callback
17
+ * (ServerContext.requestRestart, the serve composition's
18
+ * graceful-shutdown + `process.exit(0)`) owns the exit — the route
19
+ * never calls exit itself.
20
+ *
21
+ * Durability (Review Focus 4): the restart rides the SAME ordered
22
+ * shutdown as the SIGTERM path — the event bus drains pending settles
23
+ * and the harness close seals storage, so a run in flight at restart
24
+ * has its submission durable before exit; pi-durable resumes it after
25
+ * the supervisor brings the process back.
26
+ */
27
+ /** Register the `/v1/admin` routes. */
28
+ export function registerAdminRoutes(app, ctx) {
29
+ app.post("/v1/admin/restart", async (request, reply) => {
30
+ // Admin-only: a restart is a control-plane action (it kills every
31
+ // in-flight connection, including other users' streams) — the
32
+ // gate runs before the supervised check so a non-admin learns
33
+ // nothing about the deploy's supervision posture.
34
+ if (!request.principal.admin) {
35
+ return await reply.code(403).send({ error: "forbidden" });
36
+ }
37
+ if (!ctx.config.supervised) {
38
+ return await reply.code(409).send({ error: "not_supervised" });
39
+ }
40
+ // Deferred past the reply: the 202 must reach the client before
41
+ // the drain closes its connection. A supervisor is expected, so a
42
+ // missing callback (a hand-built server in a test that forgot the
43
+ // seam) is a loud boot-composition error, not a silent no-op.
44
+ if (ctx.requestRestart === undefined) {
45
+ throw new Error("admin restart: supervised but no restart callback wired (serve composition error)");
46
+ }
47
+ const restart = ctx.requestRestart;
48
+ setImmediate(() => {
49
+ void Promise.resolve(restart()).catch((error) => {
50
+ // The shutdown path is already committed at this point —
51
+ // nothing to roll back to; log loud and let the exit/error
52
+ // surface in the deploy's logs.
53
+ console.error("sigrun: restart shutdown failed:", error);
54
+ });
55
+ });
56
+ return await reply.code(202).send({ restarting: true });
57
+ });
58
+ }
@@ -19,9 +19,18 @@
19
19
  * never a delegated act); listing serves the caller's tokens, with
20
20
  * `?user=` the admin-only window into another's; revocation deletes the
21
21
  * caller's own tokens, or any for an admin.
22
+ *
23
+ * Account links (multi-user M3a, spec §11.3) mirror the token routes
24
+ * exactly in who-may-see-what: `GET /v1/auth/accounts` lists the
25
+ * caller's own links (`?user=` is the admin-only window into another's),
26
+ * and `DELETE /v1/auth/accounts` removes one — the caller's own, or any
27
+ * for an admin. Both take QUERY parameters rather than path segments:
28
+ * provider ids contain `:` and subjects may contain `/`, `+`, or `@`.
22
29
  */
23
30
  import { ipAllowed } from "../auth/ip.js";
31
+ import { sessionCookieOf } from "../auth/pipeline.js";
24
32
  import { LoginLimiter } from "../auth/login-limiter.js";
33
+ import { accountLinkWire } from "../views.js";
25
34
  /** The session cookie name (spec §5.4). */
26
35
  export const SESSION_COOKIE = "sigrun_session";
27
36
  /** The failed-login delay (ms), per the brief. */
@@ -37,9 +46,16 @@ export function buildSessionCookie(token, request, ctx) {
37
46
  const forwarded = request.headers["x-forwarded-proto"];
38
47
  const forwardedProto = Array.isArray(forwarded) ? forwarded[0] : forwarded;
39
48
  const secure = request.protocol === "https" || (trusted && forwardedProto === "https");
40
- return (`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/` +
49
+ const maxAge = sessionTtlMs(ctx) / 1_000;
50
+ return (`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}` +
41
51
  (secure ? "; Secure" : ""));
42
52
  }
53
+ /** The live session TTL consumed by the login flow (days → milliseconds). */
54
+ export function sessionTtlMs(ctx) {
55
+ const days = ctx.opened.settings.read().server?.auth?.sessionDays ??
56
+ ctx.config.auth.sessionDays;
57
+ return days * 86_400_000;
58
+ }
43
59
  /** The wire form of one token record — never the digest. */
44
60
  function tokenWire(record) {
45
61
  return {
@@ -47,6 +63,8 @@ function tokenWire(record) {
47
63
  name: record.name,
48
64
  user: record.user,
49
65
  createdAt: record.createdAt,
66
+ ...(record.kind === undefined ? {} : { kind: record.kind }),
67
+ ...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }),
50
68
  };
51
69
  }
52
70
  /** Register the auth routes: login, logout, whoami, and token management. */
@@ -81,7 +99,14 @@ export function registerAuthRoutes(app, ctx) {
81
99
  .header("set-cookie", buildSessionCookie(token, request, ctx))
82
100
  .send({ user: user.user });
83
101
  });
84
- app.post("/v1/auth/logout", async (_request, reply) => {
102
+ app.post("/v1/auth/logout", async (request, reply) => {
103
+ const presented = sessionCookieOf(request);
104
+ const record = presented
105
+ ? ctx.opened.identity.tokens.verify(presented)
106
+ : undefined;
107
+ if (record?.kind === "session") {
108
+ await ctx.opened.identity.tokens.revoke(record.id);
109
+ }
85
110
  return await reply
86
111
  .code(204)
87
112
  .header("set-cookie", `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`)
@@ -89,13 +114,19 @@ export function registerAuthRoutes(app, ctx) {
89
114
  });
90
115
  app.get("/v1/auth/whoami", async (request) => {
91
116
  const principal = request.principal;
117
+ const record = principal.user === undefined
118
+ ? undefined
119
+ : ctx.opened.identity.users.get(principal.user);
92
120
  return {
93
121
  ...(principal.user === undefined ? {} : { user: principal.user }),
94
122
  ...(principal.name === undefined ? {} : { name: principal.name }),
95
123
  ...(principal.email === undefined ? {} : { email: principal.email }),
124
+ ...(record?.workspace === undefined ? {} : { workspace: record.workspace }),
125
+ ...(record?.grants === undefined ? {} : { grants: record.grants }),
96
126
  admin: principal.admin,
97
127
  service: principal.service,
98
128
  method: principal.method,
129
+ ...(principal.provider === undefined ? {} : { provider: principal.provider }),
99
130
  };
100
131
  });
101
132
  // ─── Token management (Task 4) ─────────────────────────────────────
@@ -169,4 +200,59 @@ export function registerAuthRoutes(app, ctx) {
169
200
  await ctx.opened.identity.tokens.revoke(id);
170
201
  return await reply.code(204).send();
171
202
  });
203
+ // ─── Account links (multi-user M3a, spec §11.3) ────────────────────
204
+ app.get("/v1/auth/accounts", async (request, reply) => {
205
+ const principal = request.principal;
206
+ const query = request.query;
207
+ // `?user=` is the admin-only window into another's links; naming
208
+ // oneself is the same payload as no query (the identity is theirs
209
+ // either way) and is allowed for the same reason the token list
210
+ // allows it.
211
+ if (query.user !== undefined) {
212
+ if (!principal.admin && query.user !== principal.user) {
213
+ return await reply.code(403).send({ error: "forbidden" });
214
+ }
215
+ const response = {
216
+ accounts: ctx.opened.identity.accounts
217
+ .listForUser(query.user)
218
+ .map(accountLinkWire),
219
+ };
220
+ return response;
221
+ }
222
+ // The caller's own links — a principal with no directory user (the
223
+ // naive single-operator superuser, a userless service principal)
224
+ // gets the whole store, which for that posture IS the operator's own
225
+ // (the same rule the token list applies).
226
+ const response = {
227
+ accounts: principal.user === undefined
228
+ ? ctx.opened.identity.accounts.list().map(accountLinkWire)
229
+ : ctx.opened.identity.accounts
230
+ .listForUser(principal.user)
231
+ .map(accountLinkWire),
232
+ };
233
+ return response;
234
+ });
235
+ app.delete("/v1/auth/accounts", async (request, reply) => {
236
+ const principal = request.principal;
237
+ const query = request.query;
238
+ if (query.provider === undefined ||
239
+ query.provider === "" ||
240
+ query.subject === undefined ||
241
+ query.subject === "") {
242
+ return await reply.code(400).send({ error: "invalid_query" });
243
+ }
244
+ // The lookup, the ownership check, and the removal happen INSIDE one
245
+ // serialized mutation: reading the link here and removing it later
246
+ // would leave a window where the subject is unlinked and re-linked
247
+ // to a different user, and this request would delete the NEW
248
+ // holder's link on the strength of a stale check.
249
+ const outcome = await ctx.opened.identity.accounts.unlinkAuthorized(query.provider, query.subject, { user: principal.user, admin: principal.admin });
250
+ if (outcome === "absent") {
251
+ return await reply.code(404).send({ error: "unknown_account" });
252
+ }
253
+ if (outcome === "forbidden") {
254
+ return await reply.code(403).send({ error: "forbidden" });
255
+ }
256
+ return await reply.code(204).send();
257
+ });
172
258
  }
@@ -7,7 +7,7 @@
7
7
  * list, import) take no pre-handler.
8
8
  */
9
9
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
10
- import { isSuperuser } from "../auth/principal.js";
10
+ import { accessPrincipalOf } from "../auth/access-principal.js";
11
11
  import { ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readMetaOp, } from "../conversation-ops.js";
12
12
  import { loadConversation } from "../conversation-handler.js";
13
13
  import { ID_PARAMS_SCHEMA, readView, stampSessionOwner, storeAgentChange, } from "../helpers.js";
@@ -70,10 +70,7 @@ export function registerConversationRoutes(app, ctx) {
70
70
  : { archivedOnly: query.archived === "true" }),
71
71
  ...(query.mine === "true" ? { mine: true } : {}),
72
72
  };
73
- const conversations = await listConversationsOp(ctx, {
74
- user: request.principal.user,
75
- superuser: isSuperuser(request.principal),
76
- }, filters);
73
+ const conversations = await listConversationsOp(ctx, accessPrincipalOf(request.principal, opened.identity.users), filters);
77
74
  return { conversations };
78
75
  });
79
76
  app.get("/v1/conversations/:id", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request) => {
@@ -234,7 +231,7 @@ export function registerConversationRoutes(app, ctx) {
234
231
  }, BACKGROUND_CONTEXT);
235
232
  // The imported conversation is the IMPORTER's, not the source's
236
233
  // (the JSONL never carries meta — the stamp is the only owner).
237
- await stampSessionOwner(conversation, request.principal.user);
234
+ await stampSessionOwner(conversation, request.principal.user, opened);
238
235
  const view = await readView(conversation);
239
236
  return await reply.code(201).send({ id: conversation.id, view });
240
237
  });
@@ -31,14 +31,15 @@
31
31
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
32
32
  import { randomUUID } from "node:crypto";
33
33
  import { accessOf } from "@sigrun/shared/access";
34
+ import { accessPrincipalOf } from "../auth/access-principal.js";
34
35
  import { SessionMetaDoc } from "../session-meta.js";
35
36
  /** The heartbeat cadence (the /stream pattern: proxy idle-timeout survival). */
36
37
  const HEARTBEAT_MS = 15_000;
37
38
  /** Register the global events stream route. */
38
39
  export function registerEventRoutes(app, ctx) {
39
40
  app.get("/v1/events", async (request, reply) => {
40
- const principal = request.principal;
41
41
  const { opened } = ctx;
42
+ const { principal } = request;
42
43
  reply.hijack();
43
44
  const raw = reply.raw;
44
45
  raw.writeHead(200, {
@@ -84,6 +85,9 @@ export function registerEventRoutes(app, ctx) {
84
85
  * principal-facing).
85
86
  */
86
87
  const visible = async (event) => {
88
+ // Like SessionMeta, stored groups are current PER EVENT: group
89
+ // changes affect the next event on an existing connection.
90
+ const accessPrincipal = accessPrincipalOf(principal, opened.identity.users);
87
91
  switch (event.type) {
88
92
  // Conversation-scoped: SessionMeta access, re-snapshotted per
89
93
  // event (share → the NEXT event arrives; un-share withholds the
@@ -103,7 +107,7 @@ export function registerEventRoutes(app, ctx) {
103
107
  console.error("events route: SessionMeta snapshot failed:", error);
104
108
  return undefined; // a raced read withholds that one event
105
109
  }
106
- return accessOf(principal, meta ?? {}) !== "none" ? event : undefined;
110
+ return accessOf(accessPrincipal, meta ?? {}) !== "none" ? event : undefined;
107
111
  }
108
112
  // Broadcast: every authenticated principal.
109
113
  case "providers.changed":
@@ -112,7 +116,7 @@ export function registerEventRoutes(app, ctx) {
112
116
  // carries the caller's access (the wire adds what the hub's
113
117
  // access-neutral copy omits).
114
118
  case "job.changed": {
115
- const access = accessOf(principal, {
119
+ const access = accessOf(accessPrincipal, {
116
120
  ...(event.job.owner === undefined
117
121
  ? {}
118
122
  : { owner: event.job.owner }),
@@ -129,6 +133,12 @@ export function registerEventRoutes(app, ctx) {
129
133
  // principal-facing wire event.
130
134
  case "taskSettled":
131
135
  return undefined;
136
+ // UI-blocks U4 (spec §13.2): settings changes are OPERATOR
137
+ // surface — delivered to ADMINS only, both scopes (a
138
+ // server-scope PATCH and an extension's settings PUT alike;
139
+ // the admin gate is the same one the writes carry).
140
+ case "settings.changed":
141
+ return request.principal.admin ? event : undefined;
132
142
  // UI-blocks U3 Task 4 (spec §7.3): delivered to a watching
133
143
  // stream ONLY. The hub copy is the service's internal event
134
144
  // (SurfaceChangedInternal) which carries the resolved stream
@@ -137,6 +147,21 @@ export function registerEventRoutes(app, ctx) {
137
147
  // The DELIVERED frame is the wire shape (the internal field
138
148
  // stripped — the stream list is routing metadata, never wire).
139
149
  case "surface.changed": {
150
+ // The CHAT shape (U5 Task 2, spec §8.2): conversation-scoped —
151
+ // the event names its (conversationId, entryId) directly, so
152
+ // visibility is the conversation's own (SessionMeta
153
+ // re-snapshotted per event; hidden conversations see nothing).
154
+ if ("conversationId" in event) {
155
+ let meta;
156
+ try {
157
+ meta = await opened.harness.snapshot(SessionMetaDoc, event.conversationId, BACKGROUND_CONTEXT);
158
+ }
159
+ catch (error) {
160
+ console.error("events route: SessionMeta snapshot failed:", error);
161
+ return undefined;
162
+ }
163
+ return accessOf(principal, meta ?? {}) !== "none" ? event : undefined;
164
+ }
140
165
  // SAFETY: the hub carries the service's internal event (the
141
166
  // wire shape plus the publish-time stream list); every
142
167
  // surface.changed the hub sees comes from bumpRev, which