toolroll 0.9.89 → 0.9.90

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 (136) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +16 -10
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agent-board.d.ts +9 -0
  5. package/dist/agent-board.js +20 -0
  6. package/dist/agent-fence.js +20 -2
  7. package/dist/approval-authority.d.ts +7 -0
  8. package/dist/approval-authority.js +13 -2
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
  10. package/dist/browser/terminal.js +42 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +32 -32
  13. package/dist/browser-workspace.d.ts +13 -0
  14. package/dist/builder.js +5 -2
  15. package/dist/chat-actions.d.ts +5 -0
  16. package/dist/chat-actions.js +7 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-task-actions.d.ts +1 -0
  19. package/dist/check-access.d.ts +3 -0
  20. package/dist/check-access.js +4 -1
  21. package/dist/cli-help.d.ts +1 -1
  22. package/dist/cli-help.js +6 -2
  23. package/dist/cli.d.ts +6 -0
  24. package/dist/cli.js +23 -0
  25. package/dist/coding-ui.js +4 -2
  26. package/dist/connect-cli.js +2 -1
  27. package/dist/console-instance.d.ts +34 -1
  28. package/dist/console-instance.js +124 -15
  29. package/dist/contracts/cli.d.ts +72 -0
  30. package/dist/contracts/cli.js +13 -0
  31. package/dist/contracts/console-api.d.ts +9 -2
  32. package/dist/contracts/console-api.js +9 -2
  33. package/dist/desktop-host.js +8 -3
  34. package/dist/desktop-update.js +2 -1
  35. package/dist/dispatch.d.ts +3 -0
  36. package/dist/dispatch.js +22 -2
  37. package/dist/easy-start-args.d.ts +13 -0
  38. package/dist/easy-start-args.js +24 -0
  39. package/dist/easy-start.d.ts +77 -0
  40. package/dist/easy-start.js +321 -0
  41. package/dist/first-run.d.ts +77 -0
  42. package/dist/first-run.js +45 -0
  43. package/dist/guides.js +1 -1
  44. package/dist/invoke.js +11 -6
  45. package/dist/laptop-background.d.ts +171 -0
  46. package/dist/laptop-background.js +550 -0
  47. package/dist/laptop-service.d.ts +111 -0
  48. package/dist/laptop-service.js +268 -0
  49. package/dist/lead-cli.js +2 -1
  50. package/dist/lead-doors.d.ts +6 -0
  51. package/dist/lead-doors.js +20 -2
  52. package/dist/live-records.d.ts +1 -1
  53. package/dist/live-records.js +3 -12
  54. package/dist/local-owner.d.ts +124 -0
  55. package/dist/local-owner.js +212 -0
  56. package/dist/login-secrets.d.ts +60 -0
  57. package/dist/login-secrets.js +195 -0
  58. package/dist/mcp-oauth.d.ts +4 -0
  59. package/dist/mcp-oauth.js +13 -7
  60. package/dist/model-choices.d.ts +43 -0
  61. package/dist/model-choices.js +80 -0
  62. package/dist/operate.d.ts +46 -1
  63. package/dist/operate.js +461 -40
  64. package/dist/person-proof.js +3 -0
  65. package/dist/project-knowledge.d.ts +7 -7
  66. package/dist/quick-start.d.ts +3 -1
  67. package/dist/quick-start.js +6 -1
  68. package/dist/release-cli.js +2 -1
  69. package/dist/runner-process.d.ts +9 -0
  70. package/dist/runner-process.js +18 -0
  71. package/dist/sample-project.d.ts +31 -0
  72. package/dist/sample-project.js +141 -0
  73. package/dist/scope.d.ts +24 -2
  74. package/dist/scope.js +74 -3
  75. package/dist/serve.js +129 -22
  76. package/dist/server/chat.js +10 -3
  77. package/dist/server/chrome.d.ts +10 -2
  78. package/dist/server/chrome.js +7 -5
  79. package/dist/server/console.css +10 -0
  80. package/dist/server/guards.d.ts +3 -0
  81. package/dist/server/guards.js +22 -2
  82. package/dist/server/http.d.ts +22 -0
  83. package/dist/server/http.js +58 -0
  84. package/dist/server/live.js +4 -14
  85. package/dist/server/mutation-key.d.ts +70 -0
  86. package/dist/server/mutation-key.js +142 -0
  87. package/dist/server/pages.js +10 -7
  88. package/dist/server/people-tokens.js +240 -20
  89. package/dist/server/remote.d.ts +2 -0
  90. package/dist/server/remote.js +19 -4
  91. package/dist/server/render-new-task.js +5 -1
  92. package/dist/server/render-pages.d.ts +2 -1
  93. package/dist/server/render-pages.js +1 -1
  94. package/dist/server/render-people.d.ts +36 -0
  95. package/dist/server/render-people.js +65 -1
  96. package/dist/server/render-projects.js +4 -2
  97. package/dist/server/render-tasks.d.ts +12 -0
  98. package/dist/server/render-tasks.js +12 -4
  99. package/dist/server/request-context.d.ts +6 -0
  100. package/dist/server/route-table.d.ts +16 -0
  101. package/dist/server/route-table.js +121 -0
  102. package/dist/server/runtime.d.ts +6 -4
  103. package/dist/server/session.d.ts +31 -1
  104. package/dist/server/session.js +12 -2
  105. package/dist/server/settings.js +63 -8
  106. package/dist/server/task-views.js +10 -1
  107. package/dist/server/tasks.d.ts +10 -0
  108. package/dist/server/tasks.js +59 -9
  109. package/dist/server/terminals.js +15 -2
  110. package/dist/server/welcome.d.ts +22 -16
  111. package/dist/server/welcome.js +150 -52
  112. package/dist/settings-groups-ui.d.ts +7 -1
  113. package/dist/settings-groups-ui.js +23 -2
  114. package/dist/settings-proofs.js +1 -0
  115. package/dist/setup-guide.d.ts +2 -2
  116. package/dist/store.d.ts +124 -1
  117. package/dist/store.js +245 -12
  118. package/dist/surface.d.ts +10 -0
  119. package/dist/surface.js +40 -1
  120. package/dist/task-text.d.ts +6 -0
  121. package/dist/task-text.js +17 -0
  122. package/dist/telegram-lead.js +2 -0
  123. package/dist/terminal-text.d.ts +11 -0
  124. package/dist/terminal-text.js +26 -0
  125. package/dist/toolroll-update.d.ts +8 -1
  126. package/dist/toolroll-update.js +151 -4
  127. package/dist/tui-contract.d.ts +118 -0
  128. package/dist/tui-contract.js +9 -0
  129. package/dist/tui-source.d.ts +14 -0
  130. package/dist/tui-source.js +182 -0
  131. package/dist/tui.js +129 -0
  132. package/dist/up-processes.d.ts +13 -1
  133. package/dist/up-processes.js +13 -3
  134. package/dist/welcome-facts.d.ts +18 -0
  135. package/dist/welcome-facts.js +79 -0
  136. package/package.json +4 -2
@@ -0,0 +1,70 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Session, Who } from "./session.js";
3
+ /** The request header a fetch carries the key in. */
4
+ export declare const KEY_HEADER = "x-toolroll-key";
5
+ /** The form field a server-rendered form carries it in (not `key`: forms already use that for idempotency and API keys). */
6
+ export declare const KEY_FIELD = "toolroll-key";
7
+ /** A terminal socket offers `toolroll` (the protocol the server selects) and `toolroll.key.<key>` (never echoed back). */
8
+ export declare const KEY_PROTOCOL = "toolroll";
9
+ export declare const KEY_PROTOCOL_PREFIX = "toolroll.key.";
10
+ /** Where the browser keeps it: `{ epoch, key }` in the console origin's localStorage. */
11
+ export declare const KEY_STORAGE = "toolroll.key";
12
+ /** How a person gets a fresh key: their password again (a full session), or the terminal (a local owner). */
13
+ export type Reconnect = {
14
+ kind: "password";
15
+ href: string;
16
+ } | {
17
+ kind: "command";
18
+ command: string;
19
+ };
20
+ export type KeyRefusal = {
21
+ status: 403 | 409;
22
+ message: string;
23
+ reconnect: Reconnect;
24
+ };
25
+ export declare const RESTARTED = "Toolroll restarted \u2014 reconnect to make changes.";
26
+ export declare const RESTARTED_LOCAL = "Toolroll restarted \u2014 run `toolroll open` to reconnect.";
27
+ export declare const NOT_CONNECTED = "This tab isn't connected to Toolroll \u2014 reconnect to make changes.";
28
+ export declare const NOT_CONNECTED_LOCAL = "This tab isn't connected to Toolroll \u2014 run `toolroll open` to reconnect.";
29
+ /** The page a request reads, without the workspace read's own parameters: where a reconnect comes back to. */
30
+ export declare function pagePathOf(url: URL): string;
31
+ export declare const keyHashOf: (key: string) => string;
32
+ /** The one key a request presents (header, form field or socket protocol), or null for none, a malformed one, or two
33
+ * different ones: two keys in one request is a smuggling attempt, never a preference. */
34
+ export declare function presentedKey(request: IncomingMessage, body: {
35
+ getAll(name: string): string[];
36
+ } | null): string | null;
37
+ /** Constant-time: the presented key's hash against the session's. */
38
+ export declare function keyMatches(presented: string, hash: string | null): boolean;
39
+ export type BrowserKeys = {
40
+ /** Whether mutations need the key now (a protected loopback install, with the key turned on for this console). */
41
+ required(): boolean;
42
+ /** The console's one browser origin (http://127.0.0.1:<port>) while required, else null. */
43
+ canonicalHost(): string | null;
44
+ /** The one mutation check: null to proceed, else the refusal (with how to reconnect). Bearer callers pass. */
45
+ check(who: Who, request: IncomingMessage, body: {
46
+ getAll(name: string): string[];
47
+ } | null): KeyRefusal | null;
48
+ /** A browser session whose key is from an earlier console start (or none at all) while the key is required. */
49
+ stale(who: Who, back?: string): Reconnect | null;
50
+ /** Gives a session being made (or reconnected) a fresh key: its hash and this start's epoch go on the session, the key
51
+ * comes back to deliver once. Null while not required, or off the canonical origin (that origin's storage would hold it). */
52
+ issue(session: Pick<Session, "keyEpoch" | "keyHash">, request: IncomingMessage): string | null;
53
+ /** The no-store page that puts the key in this origin's storage and moves on to `to` (a same-site path). */
54
+ deliver(response: ServerResponse, key: string, to: string, cookies?: readonly string[]): void;
55
+ };
56
+ export declare function createBrowserKeys(deps: {
57
+ /** The console turned the key on (a real console start; tests and the demo leave it off unless they ask). */
58
+ enabled: boolean;
59
+ epoch: string;
60
+ /** On a protected loopback install (bound to loopback only, no --allow-host, no public URL, no tailnet names), read
61
+ * per request: the console's one browser host (`127.0.0.1:<port>`, or `[::1]:<port>` for an IPv6-only bind). Else null. */
62
+ protectedHost: () => string | null;
63
+ }): BrowserKeys;
64
+ /**
65
+ * Every page that can post, while the key is required: fills the key into each same-origin POST form's data (the
66
+ * `formdata` event fires for a native submit, requestSubmit(), submit() and `new FormData(form)` alike) and offers
67
+ * `window.soKeyHeaders(headers)` to the page's own fetches. It reads nothing on the page and sends the key nowhere but
68
+ * this origin. Without a stored key it does nothing, and the server answers "reconnect".
69
+ */
70
+ export declare const KEY_FILL_SCRIPT: string;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The browser mutation key (easy-start C2, amendment A1): the second factor every browser mutation carries on a
3
+ * protected loopback install, beside the session's CSRF token.
4
+ *
5
+ * Why: cookies are not isolated by port (RFC 6265 §8.5). Any server on another port of this computer receives the
6
+ * session cookie and can read every page, CSRF token included. So on a console that only this computer can reach, a
7
+ * mutation also needs a key that lives only in the browser's storage for the console's own origin
8
+ * (http://127.0.0.1:<port>): 32 random bytes per session, of which the server keeps only a hash and the console start
9
+ * (key epoch) it was issued under. The key is delivered once, in a response that cannot be fetched again (a one-time
10
+ * link, a password sign-in, an identity-provider finish, a password reconnect), by a no-store page whose one nonce'd
11
+ * script writes it to localStorage and moves on. It is never rendered into a page or JSON.
12
+ *
13
+ * The key is bound to the console start (N3): a process that held the port while Toolroll was stopped could read the
14
+ * saved key, so every restart mints a new epoch and every older key reads as "Toolroll restarted — reconnect".
15
+ *
16
+ * A protected loopback install: the console is bound to a loopback address only, with no --allow-host, no public URL
17
+ * and no tailnet names. Everywhere else nothing here applies.
18
+ */
19
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
20
+ import { html, htmlString, scriptElement } from "../html.js";
21
+ import { HANDOFF_STYLE } from "./http.js";
22
+ /** The request header a fetch carries the key in. */
23
+ export const KEY_HEADER = "x-toolroll-key";
24
+ /** The form field a server-rendered form carries it in (not `key`: forms already use that for idempotency and API keys). */
25
+ export const KEY_FIELD = "toolroll-key";
26
+ /** A terminal socket offers `toolroll` (the protocol the server selects) and `toolroll.key.<key>` (never echoed back). */
27
+ export const KEY_PROTOCOL = "toolroll";
28
+ export const KEY_PROTOCOL_PREFIX = "toolroll.key.";
29
+ /** Where the browser keeps it: `{ epoch, key }` in the console origin's localStorage. */
30
+ export const KEY_STORAGE = "toolroll.key";
31
+ const KEY_SHAPE = /^[0-9a-f]{64}$/;
32
+ export const RESTARTED = "Toolroll restarted — reconnect to make changes.";
33
+ export const RESTARTED_LOCAL = "Toolroll restarted — run `toolroll open` to reconnect.";
34
+ export const NOT_CONNECTED = "This tab isn't connected to Toolroll — reconnect to make changes.";
35
+ export const NOT_CONNECTED_LOCAL = "This tab isn't connected to Toolroll — run `toolroll open` to reconnect.";
36
+ /** The page a request reads, without the workspace read's own parameters: where a reconnect comes back to. */
37
+ export function pagePathOf(url) {
38
+ const page = new URL(url.pathname + url.search, "http://standing-orders.local");
39
+ page.searchParams.delete("format");
40
+ page.searchParams.delete("request");
41
+ return page.pathname + page.search;
42
+ }
43
+ export const keyHashOf = (key) => createHash("sha256").update(key, "utf8").digest("hex");
44
+ /** The one key a request presents (header, form field or socket protocol), or null for none, a malformed one, or two
45
+ * different ones: two keys in one request is a smuggling attempt, never a preference. */
46
+ export function presentedKey(request, body) {
47
+ const found = [];
48
+ const header = request.headers[KEY_HEADER];
49
+ if (Array.isArray(header))
50
+ return null;
51
+ if (typeof header === "string" && header !== "")
52
+ found.push(header);
53
+ if (body !== null)
54
+ found.push(...body.getAll(KEY_FIELD).filter(one => one !== ""));
55
+ const protocols = request.headers["sec-websocket-protocol"];
56
+ if (typeof protocols === "string") {
57
+ for (const one of protocols.split(",").map(part => part.trim()))
58
+ if (one.startsWith(KEY_PROTOCOL_PREFIX))
59
+ found.push(one.slice(KEY_PROTOCOL_PREFIX.length));
60
+ }
61
+ // The same key by two roads (a form's field and its fetch's header) is one key; two different keys is none.
62
+ const distinct = [...new Set(found)];
63
+ if (distinct.length !== 1)
64
+ return null;
65
+ const key = distinct[0];
66
+ return KEY_SHAPE.test(key) ? key : null;
67
+ }
68
+ /** Constant-time: the presented key's hash against the session's. */
69
+ export function keyMatches(presented, hash) {
70
+ if (hash === null || !KEY_SHAPE.test(hash))
71
+ return false;
72
+ const a = Buffer.from(keyHashOf(presented), "hex"), b = Buffer.from(hash, "hex");
73
+ return a.length === b.length && timingSafeEqual(a, b);
74
+ }
75
+ export function createBrowserKeys(deps) {
76
+ const canonicalHost = () => deps.enabled ? deps.protectedHost() : null;
77
+ const required = () => canonicalHost() !== null;
78
+ // A refused post comes back to Home (its own address may answer only posts); a page's notice, to that page.
79
+ const reconnectOf = (who, back = "/") => who.authority === "full"
80
+ ? { kind: "password", href: back === "/" ? "/login/rekey" : `/login/rekey?return=${encodeURIComponent(back)}` }
81
+ : { kind: "command", command: "toolroll open" };
82
+ const stale = (who, back) => {
83
+ if (!required() || who.via !== "cookie")
84
+ return null;
85
+ const session = who.session;
86
+ return session.keyEpoch !== deps.epoch || session.keyHash === null ? reconnectOf(who, back) : null;
87
+ };
88
+ return {
89
+ required,
90
+ canonicalHost,
91
+ check(who, request, body) {
92
+ if (!required() || who.via !== "cookie")
93
+ return null;
94
+ const local = who.authority !== "full";
95
+ const reconnect = reconnectOf(who);
96
+ if (stale(who) !== null)
97
+ return { status: 409, message: local ? RESTARTED_LOCAL : RESTARTED, reconnect };
98
+ const presented = presentedKey(request, body);
99
+ if (presented === null || !keyMatches(presented, who.session.keyHash))
100
+ return { status: 403, message: local ? NOT_CONNECTED_LOCAL : NOT_CONNECTED, reconnect };
101
+ return null;
102
+ },
103
+ stale,
104
+ issue(session, request) {
105
+ const canonical = canonicalHost();
106
+ if (canonical === null || request.headers.host !== canonical)
107
+ return null;
108
+ const key = randomBytes(32).toString("hex");
109
+ session.keyHash = keyHashOf(key);
110
+ session.keyEpoch = deps.epoch;
111
+ return key;
112
+ },
113
+ deliver(response, key, to, cookies = []) {
114
+ const nonce = randomBytes(16).toString("base64");
115
+ const target = /^\/(?![/\\])/.test(to) ? to : "/";
116
+ // JSON inside a script: `<` escaped so nothing in it can close the element.
117
+ const data = JSON.stringify({ storage: KEY_STORAGE, value: JSON.stringify({ epoch: deps.epoch, key }), to: target }).replace(/</g, "\\u003c");
118
+ response.writeHead(200, {
119
+ "content-type": "text/html; charset=utf-8", "cache-control": "no-store", pragma: "no-cache", "referrer-policy": "no-referrer",
120
+ "x-content-type-options": "nosniff", "x-frame-options": "DENY",
121
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
122
+ ...(cookies.length === 0 ? {} : { "set-cookie": [...cookies] }),
123
+ });
124
+ const script = `(function(){var d=${data};try{localStorage.setItem(d.storage,d.value)}catch(e){}location.replace(d.to)})();`;
125
+ response.end(htmlString(html `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Toolroll</title>${HANDOFF_STYLE}<p>Opening Toolroll… <a href="${target}">Continue</a></p>${scriptElement(script, { nonce })}</html>`));
126
+ },
127
+ };
128
+ }
129
+ /**
130
+ * Every page that can post, while the key is required: fills the key into each same-origin POST form's data (the
131
+ * `formdata` event fires for a native submit, requestSubmit(), submit() and `new FormData(form)` alike) and offers
132
+ * `window.soKeyHeaders(headers)` to the page's own fetches. It reads nothing on the page and sends the key nowhere but
133
+ * this origin. Without a stored key it does nothing, and the server answers "reconnect".
134
+ */
135
+ export const KEY_FILL_SCRIPT = `(function(){if(window.soKeyHeaders)return;` +
136
+ `function k(){try{var v=JSON.parse(localStorage.getItem(${JSON.stringify(KEY_STORAGE)})||"null");return v&&typeof v.key==="string"&&/^[0-9a-f]{64}$/.test(v.key)?v.key:null}catch(e){return null}}` +
137
+ `window.soKeyHeaders=function(h){h=h||{};var x=k();if(x)h[${JSON.stringify(KEY_HEADER)}]=x;return h};` +
138
+ // The form's own method and action through HTMLFormElement's getters: a control named "method" or "action" (the
139
+ // skill form's <select name="method">) shadows f.method / f.action, which would skip the key or misread the target.
140
+ `var P=HTMLFormElement.prototype,G=function(n){return Object.getOwnPropertyDescriptor(P,n).get};var M=G("method"),A=G("action");` +
141
+ `document.addEventListener("formdata",function(e){var f=e.target,m,a;try{m=M.call(f);a=A.call(f)}catch(x){return}if(String(m).toLowerCase()!=="post")return;` +
142
+ `try{if(new URL(a,location.href).origin!==location.origin)return}catch(x){return}var x=k();if(x)e.formData.set(${JSON.stringify(KEY_FIELD)},x)},true);})();`;
@@ -29,7 +29,6 @@ import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
29
29
  import { ledgerBody } from "../ledger-view.js";
30
30
  import { agentRowOf, agentsListOf, agentFactsFor, sealedDiffOf } from "../agents-board.js";
31
31
  import { computersOf, phasesOf, systemOf } from "./agents-computers.js";
32
- import { markAgentSeen } from "../agent-board.js";
33
32
  import { agentsLiveOf } from "../agents-live.js";
34
33
  import { readChildWindow } from "../live-children-read.js";
35
34
  import { leadFiledLinks } from "./agents-live-filed.js";
@@ -192,7 +191,9 @@ export function createPagesHandlers(runtime) {
192
191
  // One scope for the page and the chrome around it: the project its address names (proved by the router), else all.
193
192
  const chrome = chromeFor(project, "none");
194
193
  const projects = chrome.projects ?? [];
195
- const view = homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat: chrome.chat === true, canAnswer: who.role === "approver" });
194
+ const view = { ...homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat: chrome.chat === true, canAnswer: who.role === "approver" }),
195
+ // Until the first result: the welcome's three steps stay one link away.
196
+ setup: firstRunStepsNow(now) !== null ? "/welcome" : null };
196
197
  return sendScreen(response, 200, screen("Home", html `<div data-kit-page><h1>Home</h1><p>${view.say}</p><p><a href="/work">Tasks</a></p></div>`, { chrome, workspace: { view } }));
197
198
  }
198
199
  // Search (UI revamp wave B): the ⌘K menu's "See all results". The places come from the page; the tasks here are the
@@ -450,9 +451,8 @@ export function createPagesHandlers(runtime) {
450
451
  const run = store.getRun(Number(asked));
451
452
  const facts = run === null || !runVisible(run) ? null : agentFactsFor(store, run.id, access, who.name);
452
453
  if (facts !== null) {
453
- // Opening a finished agent is viewing it: it leaves this person's unread Done.
454
- if (facts.outcome !== null && markAgentSeen(store, who.name, facts.runId, now))
455
- facts.seen = true;
454
+ // Opening a finished agent is viewing it: the shell says so with a keyed POST /agents/seen (easy-start N1: a page
455
+ // load never writes), which leaves this person's unread Done.
456
456
  const live = facts.outcome === null;
457
457
  const composer = live ? steerControlsFor(store, facts.taskId, facts.runId, now, who.role === "approver") : null;
458
458
  const sealed = live ? null : sealedDiffOf(store, evidenceRoot, facts.runId);
@@ -843,7 +843,10 @@ export function createPagesHandlers(runtime) {
843
843
  return projectsScreen(response, who, "naming where repositories live takes --project-root", 400, "/", "clone");
844
844
  // CONFIRM: password, then session liveness AGAIN via lookupSession
845
845
  // (finding 22/33) — the record is consumed from the RETURNED live
846
- // session, synchronously, before the first await.
846
+ // session, synchronously, before the first await. A full session types
847
+ // its password (or Passwordless stands in); a laptop's local owner has
848
+ // none: inside this row's add-project admission the prover's local-owner
849
+ // branch proves it (scope.ts proveAccountNow), whatever the field holds.
847
850
  const password = body.get("token") ?? "";
848
851
  if (!authenticateApprover(who, password).ok) {
849
852
  return projectsScreen(response, who, "cloning takes your password, typed again", 403, "/", "clone");
@@ -953,7 +956,7 @@ export function createPagesHandlers(runtime) {
953
956
  ? "Choose a projects folder once: toolroll up --project-root <dir>"
954
957
  : "choose which folder this server may use with --project-root <dir>",
955
958
  }
956
- : { enabled: true, roots: ceiling.roots, record: [...(who.session.onboard?.entries() ?? [])][0] ?? null };
959
+ : { enabled: true, roots: ceiling.roots, localOwner: who.authority !== "full", record: [...(who.session.onboard?.entries() ?? [])][0] ?? null };
957
960
  const peeks = {};
958
961
  for (const one of recent)
959
962
  peeks[one.path] = peekOf(one.path, now);
@@ -3,7 +3,7 @@ import { adapterPolicy } from "./route-policy.js";
3
3
  /** settings handlers, moved without changing their route bodies. */
4
4
  import { mintApiToken, TOKEN_DAYS } from "../api-tokens.js";
5
5
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
6
- import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
6
+ import { html, htmlString, joinHtml, postForm, reauth, replaceMarkup, scriptElement } from "../html.js";
7
7
  import { credentialsHtml, tokenShownHtml } from "../credentials-ui.js";
8
8
  import { projectName } from "../project.js";
9
9
  import { requestLimitsHtml, tokenLimitWords } from "../request-budget-ui.js";
@@ -22,10 +22,13 @@ import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcA
22
22
  import { personAuditHtml, personHref } from "../people-audit-ui.js";
23
23
  import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
24
24
  import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
25
- import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
26
- import { joinDeadPage, joinFormPage, loginPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
27
- import { liveInviteTerms } from "../store.js";
28
- import { SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
25
+ import { form, HANDOFF_STYLE, loginHref, loginReturn, page, passwordSetNext, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
26
+ import { joinDeadPage, joinFormPage, loginPage, passwordAlreadySetPage, passwordSetPage, reconnectPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
27
+ import { enrollOwnerPassword, liveInviteTerms, localOwnerBinding } from "../store.js";
28
+ import { KEY_FILL_SCRIPT } from "./mutation-key.js";
29
+ import { LOCAL_OWNER_LAPSED } from "../local-owner.js";
30
+ import { withFormToken } from "./request-context.js";
31
+ import { FULL_SESSION, SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
29
32
  import { setupCodeMatches, signupCookie, signupFromHere } from "./signup-gate.js";
30
33
  /** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
31
34
  * running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
@@ -42,7 +45,7 @@ export function sameVisitCheck(authTime, startedAt, now) {
42
45
  return Math.min(Math.max(at, startedAt), now);
43
46
  }
44
47
  export function createPeopleHandlers(runtime) {
45
- const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
48
+ const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords, browserKeys } = runtime;
46
49
  // v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
47
50
  async function sessionsPage(ctx) {
48
51
  const { url, who, response, project } = ctx;
@@ -279,11 +282,29 @@ export function createPeopleHandlers(runtime) {
279
282
  if (!passwordAllowed(held.account))
280
283
  return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
281
284
  const id = randomBytes(32).toString("hex");
282
- sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
283
- project: runtime.defaultProject, projectRevision: 1 });
284
- recordSignIn(held.account, "signed in", "one-time link");
285
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
286
- return redirect(response, "/chat");
285
+ const keyed = keyFor(request);
286
+ const base = { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
287
+ project: runtime.defaultProject, projectRevision: 1 };
288
+ if (held.kind === "local-owner") {
289
+ // Easy-start C4: a local owner's link becomes only a local-owner session, and only if local mode, read again in
290
+ // the same transaction as the session is saved, is still on at the link's generation for the same owner, and the
291
+ // owner's account generation hasn't moved (a password set since ended this road).
292
+ const made = store.transact(() => {
293
+ const now = localOwnerBinding(store);
294
+ if (now === null || now.account !== held.account || now.localGeneration !== held.localGeneration || now.accountGeneration !== held.accountGeneration)
295
+ return false;
296
+ sessions.set(id, { ...base, generation: now.accountGeneration, authority: "local-owner", localGeneration: now.localGeneration, ...keyed.fields });
297
+ return true;
298
+ });
299
+ if (!made)
300
+ return page(response, 410, loginPage("That sign-in link no longer works. Run toolroll again to open Toolroll.", "/chat", ssoOffer()));
301
+ recordSignIn(held.account, "signed in", "local link");
302
+ }
303
+ else {
304
+ sessions.set(id, { ...base, ...FULL_SESSION, ...keyed.fields });
305
+ recordSignIn(held.account, "signed in", "one-time link");
306
+ }
307
+ return signedInTo(response, keyed.key, "/chat", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
287
308
  }
288
309
  // v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
289
310
  // ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
@@ -374,11 +395,11 @@ export function createPeopleHandlers(runtime) {
374
395
  }
375
396
  const account = store.accountOf(signedIn.account);
376
397
  const id = randomBytes(32).toString("hex");
377
- sessions.set(id, { ...arrival(request), name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
398
+ const keyed = keyFor(request);
399
+ sessions.set(id, { ...arrival(request), ...FULL_SESSION, ...keyed.fields, name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
378
400
  project: runtime.defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
379
401
  recordSignIn(signedIn.account, "signed in", settings.label);
380
- response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
381
- return redirect(response, handoff.returnTo);
402
+ return signedInTo(response, keyed.key, handoff.returnTo, [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
382
403
  }
383
404
  async function signupSend(ctx) {
384
405
  const { request, response } = ctx;
@@ -428,8 +449,11 @@ export function createPeopleHandlers(runtime) {
428
449
  if (authenticated === null || !authenticated.ok)
429
450
  return page(response, 500, loginPage("The account was created, but signing in failed. Sign in with it now."));
430
451
  const id = randomBytes(32).toString("hex");
452
+ const keyed = keyFor(request);
431
453
  sessions.set(id, {
432
454
  ...arrival(request),
455
+ ...FULL_SESSION,
456
+ ...keyed.fields,
433
457
  name,
434
458
  csrf: randomBytes(32).toString("hex"),
435
459
  role: authenticated.role,
@@ -442,8 +466,7 @@ export function createPeopleHandlers(runtime) {
442
466
  // A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
443
467
  ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
444
468
  });
445
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
446
- return redirect(response, "/");
469
+ return signedInTo(response, keyed.key, "/", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
447
470
  }
448
471
  async function loginSend(ctx) {
449
472
  const { request, response } = ctx;
@@ -479,8 +502,11 @@ export function createPeopleHandlers(runtime) {
479
502
  }
480
503
  recordSignIn(name, "signed in", "browser");
481
504
  const id = randomBytes(32).toString("hex");
505
+ const keyed = keyFor(request);
482
506
  sessions.set(id, {
483
507
  ...arrival(request),
508
+ ...FULL_SESSION,
509
+ ...keyed.fields,
484
510
  name: name,
485
511
  csrf: randomBytes(32).toString("hex"),
486
512
  role: authenticated.role,
@@ -494,8 +520,7 @@ export function createPeopleHandlers(runtime) {
494
520
  // else (a phone, a proxy) it signs in and proves nothing more.
495
521
  ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
496
522
  });
497
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
498
- return redirect(response, returnTo);
523
+ return signedInTo(response, keyed.key, returnTo, [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
499
524
  }
500
525
  async function logout(ctx) {
501
526
  const { request, response } = ctx;
@@ -567,8 +592,11 @@ export function createPeopleHandlers(runtime) {
567
592
  }
568
593
  // The cookie mints only AFTER the commit — same shape as login.
569
594
  const id = randomBytes(32).toString("hex");
595
+ const keyed = keyFor(request);
570
596
  sessions.set(id, {
571
597
  ...arrival(request),
598
+ ...FULL_SESSION,
599
+ ...keyed.fields,
572
600
  name,
573
601
  csrf: randomBytes(32).toString("hex"),
574
602
  role: made.role,
@@ -579,8 +607,7 @@ export function createPeopleHandlers(runtime) {
579
607
  project: runtime.defaultProject,
580
608
  projectRevision: 1,
581
609
  });
582
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
583
- return redirect(response, "/");
610
+ return signedInTo(response, keyed.key, "/", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
584
611
  }
585
612
  /** The invite code in /join/:code (the route's pattern has already proven its shape). */
586
613
  function joinCodeOf(url) {
@@ -674,6 +701,195 @@ export function createPeopleHandlers(runtime) {
674
701
  return { ok: false, message: "Choose projects available on this instance." };
675
702
  return { ok: true, projects };
676
703
  }
704
+ /** Easy-start C2: a session's key fields when it is made: on a protected loopback install, a fresh key (only its hash
705
+ * and this start's epoch kept) to deliver once; else none. */
706
+ function keyFor(request) {
707
+ const fields = { keyEpoch: null, keyHash: null };
708
+ const key = browserKeys.issue(fields, request);
709
+ return { fields, key };
710
+ }
711
+ /** A sign-in's answer: its cookies, then on to `to` — through the key's one delivery when there is a key. */
712
+ function signedInTo(response, key, to, cookies) {
713
+ if (key !== null)
714
+ return browserKeys.deliver(response, key, to, cookies);
715
+ response.setHeader("Set-Cookie", cookies.length === 1 ? cookies[0] : [...cookies]);
716
+ return redirect(response, to);
717
+ }
718
+ // Easy-start C2: Reconnect. A browser session keeps reading after a restart, but its mutation key was bound to the
719
+ // console start that issued it. A full session gets a fresh key here by typing its password again (a cookie alone
720
+ // never does: a cookie holder on another port has the cookie); a local owner, by running `toolroll open`.
721
+ async function rekeyPage(ctx) {
722
+ const { url, who, response } = ctx;
723
+ const returnTo = loginReturn(url.searchParams.get("return"));
724
+ if (who?.via !== "cookie")
725
+ return redirect(response, loginHref(returnTo));
726
+ if (!browserKeys.required())
727
+ return redirect(response, returnTo);
728
+ return page(response, 200, reconnectPage(null, returnTo, howToReconnect(who)));
729
+ }
730
+ function howToReconnect(who) {
731
+ if (who.authority !== "full")
732
+ return { kind: "command" };
733
+ if (!passwordAllowed(who.name))
734
+ return { kind: "sso", label: ssoSettings()?.label ?? "your identity provider" };
735
+ return { kind: "password", name: who.name };
736
+ }
737
+ async function rekeySend(ctx) {
738
+ const { who, request, response } = ctx;
739
+ const body = readForm(await form(request), CONSOLE_FORMS.rekey);
740
+ const returnTo = loginReturn(body.get("return"));
741
+ if (who?.via !== "cookie")
742
+ return redirect(response, loginHref(returnTo));
743
+ if (!browserKeys.required())
744
+ return redirect(response, returnTo);
745
+ // Only this console's own page: a present Origin must name it (the password below is the proof either way).
746
+ const origin = request.headers.origin;
747
+ if (typeof origin === "string" && origin !== "null" && origin !== `http://${browserKeys.canonicalHost()}`) {
748
+ return page(response, 403, reconnectPage("Reconnect from Toolroll's own page.", returnTo, howToReconnect(who)));
749
+ }
750
+ const how = howToReconnect(who);
751
+ if (how.kind !== "password")
752
+ return page(response, 403, reconnectPage(null, returnTo, how));
753
+ const source = joinSourceOf(request), at = Date.now();
754
+ const wait = signInBudget.waitFor(source, at);
755
+ if (wait > 0) {
756
+ recordSignIn(who.name, "sign-in refused", "too many tries", "reconnect");
757
+ response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
758
+ return page(response, 429, reconnectPage(`Too many tries. Try again in ${minutesWords(wait)}.`, returnTo, how));
759
+ }
760
+ // Only a password typed now: never a session, Passwordless or a remembered password (an empty one simply fails).
761
+ const proved = authenticateAccount(store, who.name, body.get("token") ?? "");
762
+ if (!proved.ok) {
763
+ if (proved.reason === "locked") {
764
+ recordSignIn(who.name, "sign-in refused", "locked", "reconnect");
765
+ response.setHeader("Retry-After", String(Math.ceil(proved.retryAfterMs / 1000)));
766
+ return page(response, 429, reconnectPage(`Too many wrong passwords. Try again in ${minutesWords(proved.retryAfterMs)}.`, returnTo, how));
767
+ }
768
+ signInBudget.failed(source, at);
769
+ recordSignIn(who.name, "sign-in refused", "wrong password", "reconnect");
770
+ return page(response, 403, reconnectPage("That password isn't right.", returnTo, how));
771
+ }
772
+ // The same person under the same credential generation and standing this session signed in with; anything else
773
+ // signs in again instead.
774
+ if (proved.generation !== who.session.generation || proved.role !== who.session.role)
775
+ return redirect(response, loginHref(returnTo));
776
+ const key = browserKeys.issue(who.session, request);
777
+ if (key === null)
778
+ return page(response, 421, reconnectPage(`Open Toolroll at http://${browserKeys.canonicalHost()} to reconnect.`, returnTo, how));
779
+ sessions.persist(who.session);
780
+ recordSignIn(who.name, "reconnected", "browser");
781
+ return browserKeys.deliver(response, key, returnTo);
782
+ }
783
+ // Easy-start C4: /password/set, the one credential act a local owner's session may make: its first password, only
784
+ // while the account has none. The answer is a fresh full session (and a fresh browser key), and on to the act that
785
+ // was refused. Every other session, and an account that has a password, is told to sign in with it.
786
+ /** A /password/set page: no-store, and on a protected loopback install the key fill rides it (its form posts the key). */
787
+ function passwordSetAnswer(response, status, document) {
788
+ response.setHeader("Cache-Control", "no-store");
789
+ if (!browserKeys.required())
790
+ return page(response, status, document);
791
+ const nonce = randomBytes(16).toString("base64");
792
+ return page(response, status, replaceMarkup(document, /<\/body><\/html>$/, () => html `${scriptElement(KEY_FILL_SCRIPT, { nonce })}</body></html>`), nonce);
793
+ }
794
+ async function passwordSetForm(ctx) {
795
+ const { url, who, response } = ctx;
796
+ // Where to go once it is set: `next` (a refusal's link), or `return` (older links), checked either way.
797
+ const returnTo = passwordSetNext(url.searchParams.get("next") ?? url.searchParams.get("return"));
798
+ if (who === null || who === undefined)
799
+ return redirect(response, loginHref(returnTo));
800
+ if (who.via !== "cookie")
801
+ return respond(response, 403, "text/plain; charset=utf-8", "This address needs a browser sign-in.");
802
+ if (who.authority === "full" || store.accountOf(who.name)?.passwordSet !== false)
803
+ return passwordSetAnswer(response, 200, passwordAlreadySetPage("A password is already set", returnTo, who.authority === "full"));
804
+ return passwordSetAnswer(response, 200, withFormToken(who.session.csrf, () => passwordSetPage({ returnTo, problem: null })));
805
+ }
806
+ async function passwordSetSend(ctx) {
807
+ const { who, request, response } = ctx;
808
+ const media = String(request.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
809
+ if (media !== "application/x-www-form-urlencoded") {
810
+ request.resume();
811
+ return respond(response, 415, "text/plain; charset=utf-8", "forms only");
812
+ }
813
+ const body = readForm(await form(request), CONSOLE_FORMS.passwordSet);
814
+ const returnTo = passwordSetNext(body.get("next") ?? body.get("return"));
815
+ if (who === null || who === undefined)
816
+ return redirect(response, loginHref(returnTo));
817
+ if (who.via !== "cookie")
818
+ return respond(response, 403, "text/plain; charset=utf-8", "This address needs a browser sign-in.");
819
+ for (const field of ["csrf", "password", "again", "next", "return"]) {
820
+ if (body.getAll(field).length > 1)
821
+ return respond(response, 400, "text/plain; charset=utf-8", `duplicated ${field} field`);
822
+ }
823
+ const account = store.accountOf(who.name);
824
+ // A full session, or an account that has a password: only signing in with it.
825
+ if (who.authority === "full" || account === null || account.passwordSet)
826
+ return passwordSetAnswer(response, 409, passwordAlreadySetPage("A password is already set", returnTo, who.authority === "full"));
827
+ const again = (problem, status) => passwordSetAnswer(response, status, withFormToken(who.session.csrf, () => passwordSetPage({ returnTo, problem })));
828
+ // The console's own page only: a present Origin (or Referer) must name it; the session's CSRF token and (on a protected
829
+ // loopback install) its browser key are proved as for every other mutation.
830
+ const origin = request.headers.origin, referer = request.headers.referer;
831
+ const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
832
+ if (named !== null && !runtime.allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]))
833
+ return again({ field: null, text: "Set your password from Toolroll's own page." }, 403);
834
+ if (body.get("csrf") !== who.session.csrf)
835
+ return again({ field: null, text: "This form had expired, so it was reloaded. Enter the password again." }, 403);
836
+ const keyed = browserKeys.check(who, request, body.sent);
837
+ if (keyed !== null)
838
+ return again({ field: null, text: keyed.message }, keyed.status);
839
+ if (account.revokedAt !== null || account.generation !== who.session.generation || !sessions.savedNow(who.session) || who.session.localGeneration === null) {
840
+ return again({ field: null, text: LOCAL_OWNER_LAPSED }, 409);
841
+ }
842
+ // Rate-limited like signing in: the same per-address budget, and each refused try spends from it.
843
+ const source = joinSourceOf(request), at = Date.now();
844
+ const wait = signInBudget.waitFor(source, at);
845
+ if (wait > 0) {
846
+ response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
847
+ return again({ field: null, text: `Too many tries. Try again in ${minutesWords(wait)}.` }, 429);
848
+ }
849
+ const password = body.get("password") ?? "";
850
+ // The strength rule sign-up and joining use.
851
+ if (password.length < SIGNUP_PASSWORD_MIN) {
852
+ signInBudget.failed(source, at);
853
+ return again({ field: "password", text: `Use at least ${SIGNUP_PASSWORD_MIN} characters.` }, 400);
854
+ }
855
+ if ((body.get("again") ?? "") !== password) {
856
+ signInBudget.failed(source, at);
857
+ return again({ field: "again", text: "The two passwords don't match. Type them again." }, 400);
858
+ }
859
+ // Hashed before the transaction (scrypt is slow); enrollOwnerPassword then checks and writes in one IMMEDIATE
860
+ // transaction: still no password, local mode on at this session's generation, the account its active owner. It ends
861
+ // every session the account had (this one included).
862
+ const enrolled = enrollOwnerPassword(store, who.name, hashPassword(password), who.session.localGeneration, clock());
863
+ if (!enrolled.ok) {
864
+ if (enrolled.reason === "already-set")
865
+ return passwordSetAnswer(response, 409, passwordAlreadySetPage("A password was just set", returnTo));
866
+ return again({ field: null, text: LOCAL_OWNER_LAPSED }, 409);
867
+ }
868
+ const cookieId = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(request.headers.cookie ?? "")?.[1];
869
+ if (cookieId !== undefined)
870
+ sessions.delete(cookieId);
871
+ recordSignIn(who.name, "signed in", "browser", "first password set");
872
+ // A fresh full session, made only after the commit, with a fresh key delivered once (no-store) on a protected install.
873
+ const id = randomBytes(32).toString("hex");
874
+ const issued = keyFor(request);
875
+ sessions.set(id, {
876
+ ...arrival(request),
877
+ ...FULL_SESSION,
878
+ ...issued.fields,
879
+ name: who.name,
880
+ csrf: randomBytes(32).toString("hex"),
881
+ role: account.role,
882
+ generation: enrolled.generation,
883
+ createdAt: Date.now(),
884
+ sawBoardAt: null,
885
+ lastSeen: Date.now(),
886
+ project: who.session.project ?? runtime.defaultProject,
887
+ projectRevision: 1,
888
+ // A password typed on this computer starts this session's freshness window (cockpit B2), as signing in does.
889
+ ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
890
+ });
891
+ return signedInTo(response, issued.key, returnTo, [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
892
+ }
677
893
  const registrations = handlersOf("people", {
678
894
  "people.page": peoplePage,
679
895
  "settings.sessions": sessionsPage,
@@ -691,8 +907,12 @@ export function createPeopleHandlers(runtime) {
691
907
  "edge.signup": signupSend,
692
908
  "edge.login": loginSend,
693
909
  "edge.logout": logout,
910
+ "edge.rekey-page": rekeyPage,
911
+ "edge.rekey": rekeySend,
694
912
  "edge.join": joinPage,
695
913
  "edge.join-send": joinSend,
914
+ "edge.password-set-page": passwordSetForm,
915
+ "edge.password-set": passwordSetSend,
696
916
  });
697
917
  return { registrations, takeJoinAttempt, ssoCallback, readAccessForm };
698
918
  }
@@ -51,6 +51,8 @@ export interface RemoteRuntime {
51
51
  teamsSourceBudget: SourceAdmission;
52
52
  teamsTenantBudget: SourceAdmission;
53
53
  freshFactsOf: (request: IncomingMessage, who: Who | null) => FreshFacts | null;
54
+ /** Easy-start C2: the browser mutation key (server/mutation-key.ts). */
55
+ browserKeys: import("./mutation-key.js").BrowserKeys;
54
56
  }
55
57
  export declare function createRemoteHandlers(runtime: RemoteRuntime): {
56
58
  registrations: import("./handler-registry.js").Registration[];