toolroll 0.9.49 → 0.9.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.js +6 -11
- package/dist/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +8 -23
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +9 -2
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +434 -23789
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +693 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +200 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.js +3 -0
- package/dist/sign-in-guard.d.ts +41 -16
- package/dist/sign-in-guard.js +147 -24
- package/dist/source-key.d.ts +2 -0
- package/dist/source-key.js +49 -0
- package/dist/team-http.d.ts +8 -1
- package/dist/team-http.js +9 -2
- package/dist/teams.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { adapterPolicy } from "./route-policy.js";
|
|
2
|
+
/** settings handlers, moved without changing their route bodies. */
|
|
3
|
+
import { mintApiToken, TOKEN_DAYS } from "../api-tokens.js";
|
|
4
|
+
import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
5
|
+
import { hiddenFields } from "../control-ui.js";
|
|
6
|
+
import { credentialsHtml, tokenShownHtml } from "../credentials-ui.js";
|
|
7
|
+
import { projectName } from "../project.js";
|
|
8
|
+
import { requestLimitsHtml, tokenLimitWords } from "../request-budget-ui.js";
|
|
9
|
+
import { authenticateAccount } from "../scope.js";
|
|
10
|
+
import { SSO_CALLBACK } from "../sso-settings.js";
|
|
11
|
+
import { escape, personChip, redirect, refuse, requestContext, respond, screen } from "./shared.js";
|
|
12
|
+
/** people handlers, moved without changing their route bodies. */
|
|
13
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import {} from "node:http";
|
|
15
|
+
import {} from "../contracts/console-api.js";
|
|
16
|
+
import { listCoordinators } from "../coordinator.js";
|
|
17
|
+
import { logEvent } from "../log.js";
|
|
18
|
+
import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcAuthorizeUrl, verifyIdToken } from "../oidc.js";
|
|
19
|
+
import { personAuditHtml, personHref } from "../people-audit-ui.js";
|
|
20
|
+
import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
|
|
21
|
+
import { hashPassword } from "../scope.js";
|
|
22
|
+
import { form, HANDOFF_STYLE, joinDeadPage, joinFormPage, loginHref, loginPage, loginReturn, page, SESSION_COOKIE, SIGN_IN_COOKIE, SIGN_IN_LINK_PATH, signInSpent, signupPage, startedHere } from "./shared.js";
|
|
23
|
+
export function createPeopleHandlers(runtime) {
|
|
24
|
+
const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
|
|
25
|
+
async function get(ctx) {
|
|
26
|
+
const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
|
|
27
|
+
// v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
|
|
28
|
+
if (url.pathname === "/settings/sessions") {
|
|
29
|
+
if (who.via !== "cookie")
|
|
30
|
+
return refuse(response, who, 403, "Sign in in a browser to see your sessions.", "/settings");
|
|
31
|
+
const everyone = url.searchParams.get("everyone") === "1" && store.isInstanceOperator(who.name);
|
|
32
|
+
const here = sessions.hashOf(who.session);
|
|
33
|
+
const view = { who: who.name, everyone, canSeeEveryone: store.isInstanceOperator(who.name), now: Date.now(),
|
|
34
|
+
sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name),
|
|
35
|
+
limits: { note: tokenLimitWords.bind(null, store.handle), section: store.isInstanceOperator(who.name) ? requestLimitsHtml(store.handle, store.apiTokens(null), who.session.csrf, Date.now()) : "" } };
|
|
36
|
+
return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions & tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
37
|
+
}
|
|
38
|
+
if (url.pathname === "/people") {
|
|
39
|
+
// People → a person: their API tokens and remote activity (people-audit-ui.ts). An approver may open anyone, within
|
|
40
|
+
// their projects; anyone else only themselves. Another person reads exactly like nobody.
|
|
41
|
+
const person = url.searchParams.get("person");
|
|
42
|
+
if (person !== null) {
|
|
43
|
+
const token = (url.searchParams.get("token-name") ?? "").slice(0, 60) || null, before = url.searchParams.get("before");
|
|
44
|
+
const cursor = before === null ? null : auditCursorOf(before);
|
|
45
|
+
if (before !== null && cursor === null)
|
|
46
|
+
return refuse(response, who, 400, "Invalid activity cursor.", "/people");
|
|
47
|
+
const page = who.role === "approver" || person === who.name
|
|
48
|
+
? remoteAudit(store, { self: who.name, everyone: who.role === "approver", repos: admissionList(), unplaced: store.isInstanceOperator(who.name) }, { person, token, source: null, since: null }, { before: cursor })
|
|
49
|
+
: null;
|
|
50
|
+
if (page === null || !page.ok)
|
|
51
|
+
return refuse(response, who, 404, "No such person.", "/people");
|
|
52
|
+
return sendScreen(response, 200, screen("people", personAuditHtml({ name: person, tokens: store.apiTokens(person), actions: page.actions, token, nextCursor: page.nextCursor, now }), { chrome: chromeFor(project, "people") }));
|
|
53
|
+
}
|
|
54
|
+
// Approvers see everyone (D7's ceiling: every fact already passed
|
|
55
|
+
// the process admission); a viewer sees exactly themselves (U3).
|
|
56
|
+
if (restricted()) {
|
|
57
|
+
const projects = admissionList() ?? [];
|
|
58
|
+
return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p><p><a href="${escape(personHref(who.name))}">Your remote activity</a></p>`, { chrome: chromeFor(project, "people") }));
|
|
59
|
+
}
|
|
60
|
+
const approverView = store.isInstanceOperator(who.name);
|
|
61
|
+
const accounts = store.accountFacts().filter(one => approverView || one.name === who.name);
|
|
62
|
+
const lastSeenOf = (name) => {
|
|
63
|
+
let seen = null;
|
|
64
|
+
for (const session of sessions.values()) {
|
|
65
|
+
if (session.name === name && (seen === null || session.lastSeen > seen))
|
|
66
|
+
seen = session.lastSeen;
|
|
67
|
+
}
|
|
68
|
+
return seen;
|
|
69
|
+
};
|
|
70
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
71
|
+
const projectChoices = consoleProjects();
|
|
72
|
+
const accessFields = (selected) => `<label>Project access<select name="access"><option value="selected"${selected !== null ? " selected" : ""}>Selected projects</option><option value="all"${selected === null ? " selected" : ""}>All projects</option></select></label>` +
|
|
73
|
+
`<p class="meta">Operators with all-project access also manage the instance.</p><fieldset><legend>Projects</legend>${projectChoices.length === 0 ? `<p class="meta">Add a project before granting selected access.</p>` : projectChoices.map(repo => `<label class="row"><input type="checkbox" name="projects" value="${escape(repo)}"${selected?.includes(repo) ? " checked" : ""}>${escape(projectName(repo))}</label>`).join("")}</fieldset>`;
|
|
74
|
+
const cards = accounts.map(one => {
|
|
75
|
+
const seen = lastSeenOf(one.name);
|
|
76
|
+
const attended = store.openAttendedOf(one.name);
|
|
77
|
+
const acts = store.recentActsOf(one.name);
|
|
78
|
+
const standing = one.revokedAt !== null
|
|
79
|
+
? `revoked ${escape(one.revokedAt.slice(0, 16).replace("T", " "))} by ${personChip(one.revokedBy ?? "?")}`
|
|
80
|
+
: one.role === "approver"
|
|
81
|
+
? "approves"
|
|
82
|
+
: "watches";
|
|
83
|
+
return [
|
|
84
|
+
`<div class="card">`,
|
|
85
|
+
`<h2 style="margin-top:0">${personChip(one.name)} <span class="meta">${standing}</span></h2>`,
|
|
86
|
+
`<p class="meta">${one.projects === null ? "All projects" : one.projects.length === 0 ? "No project access" : one.projects.map(repo => escape(projectName(repo))).join(", ")}</p>`,
|
|
87
|
+
approverView && one.revokedAt === null ? `<details><summary>Edit project access</summary><form method="post" action="/people/projects">${hiddenFields({ csrf, name: one.name })}${accessFields(one.projects)}<label>Your password<input type="password" name="token" autocomplete="current-password" required></label><p class="meta">Changing access signs this person out and ends their derived sessions and modes. Previously approved work stays recorded.</p><button type="submit">Save project access</button></form></details>` : "",
|
|
88
|
+
`<p class="meta">${seen === null ? "not signed in right now" : `signed in \u2014 active ${escape(new Date(seen).toISOString().slice(11, 16))} UTC`} \u00b7 joined ${escape(one.addedAt.slice(0, 10))}</p>`,
|
|
89
|
+
`<p><a href="${escape(personHref(one.name))}">Tokens and remote activity</a></p>`,
|
|
90
|
+
attended.length === 0
|
|
91
|
+
? ""
|
|
92
|
+
: `<p class="row">watching now: ${attended.map(session => `<a href="/t/${escape(session.taskId)}">${escape(session.taskId)}</a>`).join(", ")}</p>`,
|
|
93
|
+
acts.length === 0
|
|
94
|
+
? `<p class="meta">No recorded acts yet</p>`
|
|
95
|
+
: acts.map(act => `<p class="row meta">${escape(act.kind)} ${escape(act.subject)} \u00b7 ${escape(act.at.slice(0, 16).replace("T", " "))}</p>`).join("\n"),
|
|
96
|
+
approverView && one.revokedAt === null && one.name !== who.name
|
|
97
|
+
? `<form method="post" action="/people/revoke" class="row">` +
|
|
98
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="name" value="${escape(one.name)}">` +
|
|
99
|
+
`<label>Your password<input type="password" name="token" autocomplete="current-password"></label>` +
|
|
100
|
+
`<button type="submit">Remove ${escape(one.name)}'s sign-in</button>` +
|
|
101
|
+
`<span class="meta">ends their access and everything it signed \u2014 history stays</span></form>`
|
|
102
|
+
: "",
|
|
103
|
+
`</div>`,
|
|
104
|
+
].join("\n");
|
|
105
|
+
});
|
|
106
|
+
const invites = approverView ? store.openInvites(now) : [];
|
|
107
|
+
const inviteRows = invites.map(one => `<p class="row">${escape(one.role)} invite · ${one.projects === null ? "all projects" : one.projects.map(repo => escape(projectName(repo))).join(", ")} \u00b7 from ${personChip(one.mintedBy)} \u00b7 expires ${escape(one.expiresAt.slice(0, 16).replace("T", " "))}${one.attempts > 0 ? ` \u00b7 ${one.attempts} failed attempt${one.attempts === 1 ? "" : "s"}` : ""}` +
|
|
108
|
+
` <form method="post" action="/people/invite-revoke" style="display:inline">` +
|
|
109
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="id" value="${one.id}">` +
|
|
110
|
+
`<label>Password <input type="password" name="token" autocomplete="current-password" style="width:8rem"></label>` +
|
|
111
|
+
`<button type="submit">Cancel it</button></form></p>`);
|
|
112
|
+
const inviteCard = !approverView
|
|
113
|
+
? ""
|
|
114
|
+
: [
|
|
115
|
+
`<div class="card">`,
|
|
116
|
+
`<h2 style="margin-top:0">Invite someone</h2>`,
|
|
117
|
+
inviteRows.length === 0 ? `<p class="meta">No open invites</p>` : inviteRows.join("\n"),
|
|
118
|
+
`<form method="post" action="/people/invite" class="row">`,
|
|
119
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}">`,
|
|
120
|
+
`<label>They can<select name="role"><option value="viewer">Viewer — read work</option><option value="approver">Operator — create, manage and approve work</option></select></label>`,
|
|
121
|
+
accessFields(project === null ? [] : [project]),
|
|
122
|
+
`<label>Your password<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
123
|
+
`<button type="submit">Make an invite link</button>`,
|
|
124
|
+
`<span class="meta">single-use, expires in 72 hours</span>`,
|
|
125
|
+
`</form>`,
|
|
126
|
+
`</div>`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
// Coordinators (MCP spec v6): the machine principals beside the
|
|
129
|
+
// human ones — name, immutable fingerprint, what they may file
|
|
130
|
+
// into, their rate, and the revoke road. Approver eyes only.
|
|
131
|
+
const coordinatorCard = !approverView
|
|
132
|
+
? ""
|
|
133
|
+
: (() => {
|
|
134
|
+
const rows = listCoordinators(store);
|
|
135
|
+
if (rows.length === 0)
|
|
136
|
+
return "";
|
|
137
|
+
return [
|
|
138
|
+
`<div class="card">`,
|
|
139
|
+
`<h2 style="margin-top:0">Agent filers</h2>`,
|
|
140
|
+
`<p class="meta">Agents that can propose tasks. You approve what runs.</p>`,
|
|
141
|
+
...rows.map(one => `<p class="row">` +
|
|
142
|
+
`<span class="mono">${escape(`${one.name}#${one.cid.slice(0, 4)}`)}</span> ` +
|
|
143
|
+
(one.revokedAt !== null
|
|
144
|
+
? `<span class="meta">revoked ${escape(one.revokedAt.slice(0, 10))}</span>`
|
|
145
|
+
: `<span class="meta">${one.perHour}/hour · files into ${one.repos.map(repo => escape(repo)).join(", ")} · ${one.expiresAt === null ? "no expiry (made before expiry)" : `expires ${escape(one.expiresAt.slice(0, 10))}`} · last filed ${one.lastFiledAt === null ? "never" : escape(one.lastFiledAt.slice(0, 16).replace("T", " "))}</span>`) +
|
|
146
|
+
`</p>`),
|
|
147
|
+
`<details class="settings-more"><summary>Revoke an agent filer</summary><p class="meta">Run <code>toolroll coordinator revoke <cid> --as you</code> on this computer.</p></details>`,
|
|
148
|
+
`</div>`,
|
|
149
|
+
].join("\n");
|
|
150
|
+
})();
|
|
151
|
+
return sendScreen(response, 200, screen("people", [`<h1>People</h1>`, ...cards, coordinatorCard, inviteCard].join("\n"), {
|
|
152
|
+
chrome: chromeFor(project, "people"),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
156
|
+
}
|
|
157
|
+
async function post(ctx) {
|
|
158
|
+
const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
|
|
159
|
+
if (url.pathname === "/settings/sessions") {
|
|
160
|
+
const body = readForm(posted, CONSOLE_FORMS.sessions);
|
|
161
|
+
if (who.via !== "cookie")
|
|
162
|
+
return refuse(response, who, 403, "Sign in in a browser to manage sessions and tokens.", "/settings");
|
|
163
|
+
const operator = store.isInstanceOperator(who.name);
|
|
164
|
+
const back = (key, words) => redirect(response, `/settings/sessions?${body.get("everyone") === "1" && operator ? "everyone=1&" : ""}${key}=${encodeURIComponent(words)}`);
|
|
165
|
+
const action = body.get("action") ?? "";
|
|
166
|
+
if (action === "end-session") {
|
|
167
|
+
const target = store.webSessions(null).find(one => one.idHash === (body.get("session") ?? ""));
|
|
168
|
+
if (target === undefined)
|
|
169
|
+
return back("problem", "That session has already ended.");
|
|
170
|
+
if (target.account !== who.name && !operator)
|
|
171
|
+
return refuse(response, who, 403, "You can end only your own sessions.", "/settings/sessions");
|
|
172
|
+
sessions.endByHash(target.idHash);
|
|
173
|
+
recordSignIn(target.account, "signed out", target.account === who.name ? "by you elsewhere" : `by ${who.name}`);
|
|
174
|
+
return back("said", "Signed out there.");
|
|
175
|
+
}
|
|
176
|
+
if (action === "end-others") {
|
|
177
|
+
const here = sessions.hashOf(who.session);
|
|
178
|
+
const others = store.webSessions(who.name).filter(one => one.idHash !== here);
|
|
179
|
+
for (const one of others)
|
|
180
|
+
sessions.endByHash(one.idHash);
|
|
181
|
+
if (others.length > 0)
|
|
182
|
+
recordSignIn(who.name, "signed out", `everywhere else (${others.length})`);
|
|
183
|
+
return back("said", others.length === 0 ? "You aren't signed in anywhere else." : `Signed out of ${others.length} other session${others.length === 1 ? "" : "s"}.`);
|
|
184
|
+
}
|
|
185
|
+
if (action === "revoke-token") {
|
|
186
|
+
const token = store.apiTokens(null).find(one => one.id === (body.get("token") ?? ""));
|
|
187
|
+
if (token === undefined || token.revokedAt !== null)
|
|
188
|
+
return back("problem", "That token was already revoked.");
|
|
189
|
+
if (token.account !== who.name && !operator)
|
|
190
|
+
return refuse(response, who, 403, "You can revoke only your own tokens.", "/settings/sessions");
|
|
191
|
+
store.revokeApiToken(token.id, who.name, now);
|
|
192
|
+
return back("said", `Revoked ${token.name}. Anything using it stops working now.`);
|
|
193
|
+
}
|
|
194
|
+
if (action === "create-token") {
|
|
195
|
+
const name = (body.get("name") ?? "").trim().replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
196
|
+
const access = body.get("access") === "act" ? "act" : "read";
|
|
197
|
+
const days = Number(body.get("days"));
|
|
198
|
+
if (name === "" || name.length > 60)
|
|
199
|
+
return back("problem", "Name the token, in 60 characters or fewer.");
|
|
200
|
+
if (!TOKEN_DAYS.includes(days))
|
|
201
|
+
return back("problem", "Choose when it expires.");
|
|
202
|
+
if (access === "act" && who.role !== "approver")
|
|
203
|
+
return back("problem", "A viewer's token can only read.");
|
|
204
|
+
// A token is a credential: made with the person's password (or their identity provider's check from moments ago), counted once.
|
|
205
|
+
const typed = body.get("password") ?? "";
|
|
206
|
+
const confirmed = who.role === "approver" ? authenticateApprover(who, typed).ok
|
|
207
|
+
: typed === "" ? requestContext.getStore()?.sso?.fresh === true : authenticateAccount(store, who.name, typed).ok;
|
|
208
|
+
if (!confirmed)
|
|
209
|
+
return back("problem", "Enter your Toolroll password to make a token.");
|
|
210
|
+
const minted = mintApiToken();
|
|
211
|
+
const expiresAt = new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
212
|
+
store.createApiToken({ id: minted.id, account: who.name, name, secretHash: minted.hash, access, expiresAt, by: who.name }, now);
|
|
213
|
+
// The token, once, on a page with no script.
|
|
214
|
+
return sendScreen(response, 200, screen("API token", tokenShownHtml(name, minted.token, access, expiresAt), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
|
|
215
|
+
}
|
|
216
|
+
return back("problem", "Choose an action.");
|
|
217
|
+
}
|
|
218
|
+
if (url.pathname === "/people/projects") {
|
|
219
|
+
const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
|
|
220
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
221
|
+
return refuse(response, who, 403, "An instance operator manages project access.", "/people");
|
|
222
|
+
if (!authenticateApprover(who, body.get("token") ?? "", null).ok)
|
|
223
|
+
return refuse(response, who, 403, "Saving access requires your password.", "/people");
|
|
224
|
+
const access = readAccessForm(body);
|
|
225
|
+
if (!access.ok)
|
|
226
|
+
return refuse(response, who, 400, access.message, "/people");
|
|
227
|
+
const changed = store.setAccountProjects((body.get("name") ?? "").trim(), access.projects, who.name, now);
|
|
228
|
+
if (!changed.ok)
|
|
229
|
+
return refuse(response, who, 409, changed.reason === "last-instance-operator" ? "Keep at least one instance operator with all-project access." : "That account cannot be changed.", "/people");
|
|
230
|
+
bustBadge();
|
|
231
|
+
return redirect(response, "/people?said=Project%20access%20saved");
|
|
232
|
+
}
|
|
233
|
+
if (url.pathname === "/people/invite") {
|
|
234
|
+
const body = readForm(posted, CONSOLE_FORMS.peopleInvite);
|
|
235
|
+
if (who.via !== "cookie")
|
|
236
|
+
return refuse(response, who, 403, "inviting is a browser surface");
|
|
237
|
+
const token = body.get("token") ?? "";
|
|
238
|
+
// RAISING authority is a password ceremony (the doctrine): adding a
|
|
239
|
+
// person to the instance is exactly that.
|
|
240
|
+
if (!authenticateApprover(who, token).ok) {
|
|
241
|
+
return refuse(response, who, 403, "making an invite takes your password, typed again", "/people");
|
|
242
|
+
}
|
|
243
|
+
const role = body.get("role") === "approver" ? "approver" : "viewer";
|
|
244
|
+
const access = readAccessForm(body);
|
|
245
|
+
if (!access.ok)
|
|
246
|
+
return refuse(response, who, 400, access.message, "/people");
|
|
247
|
+
if (access.projects !== null && access.projects.length === 0)
|
|
248
|
+
return refuse(response, who, 400, "Select at least one project for this invitation.", "/people");
|
|
249
|
+
const minted = store.mintInvite(role, who.name, now, undefined, access.projects);
|
|
250
|
+
const origin = options.publicUrl !== undefined ? options.publicUrl.replace(/\/$/, "") : `http://${request.headers.host ?? "this-console"}`;
|
|
251
|
+
const linkScreen = screen("people", [
|
|
252
|
+
`<h1>Invite link</h1>`,
|
|
253
|
+
`<div class="card">`,
|
|
254
|
+
`<p>Shown once, so copy it now.</p>`,
|
|
255
|
+
`<p class="mono secret-value">${escape(`${origin}/join/${minted.token}`)}</p>`,
|
|
256
|
+
`<p class="meta">Send it to one person. It works once, lets them ${role === "approver" ? "approve and act" : "read work"} in ${access.projects === null ? "all projects" : access.projects.map(repo => escape(projectName(repo))).join(", ")}, and expires ${escape(minted.expiresAt.slice(0, 16).replace("T", " "))} UTC. Cancel it any time on People.</p>`,
|
|
257
|
+
`</div>`,
|
|
258
|
+
`<p class="meta"><a href="/people">Back to People</a></p>`,
|
|
259
|
+
].join("\n"),
|
|
260
|
+
// A one-time secret on screen: no script of any kind rides along.
|
|
261
|
+
{ chrome: chromeFor(projectOf(who, request) ?? null, "people"), forceSensitive: true });
|
|
262
|
+
return sendScreen(response, 200, linkScreen);
|
|
263
|
+
}
|
|
264
|
+
if (url.pathname === "/people/invite-revoke") {
|
|
265
|
+
const body = readForm(posted, CONSOLE_FORMS.peopleInviteRevoke);
|
|
266
|
+
if (who.via !== "cookie")
|
|
267
|
+
return refuse(response, who, 403, "inviting is a browser surface");
|
|
268
|
+
const token = body.get("token") ?? "";
|
|
269
|
+
if (!authenticateApprover(who, token).ok) {
|
|
270
|
+
return refuse(response, who, 403, "cancelling an invite takes your password, typed again", "/people");
|
|
271
|
+
}
|
|
272
|
+
const id = Number(body.get("id") ?? "");
|
|
273
|
+
const revoked = Number.isInteger(id) && id > 0 && store.revokeInvite(id, now);
|
|
274
|
+
return redirect(response, `/people?said=${encodeURIComponent(revoked ? "the invite is cancelled — its link is dead" : "that invite was already gone")}`);
|
|
275
|
+
}
|
|
276
|
+
if (url.pathname === "/people/revoke") {
|
|
277
|
+
const body = readForm(posted, CONSOLE_FORMS.peopleRevoke);
|
|
278
|
+
if (who.via !== "cookie")
|
|
279
|
+
return refuse(response, who, 403, "removing a person is a browser surface");
|
|
280
|
+
const token = body.get("token") ?? "";
|
|
281
|
+
if (!authenticateApprover(who, token).ok) {
|
|
282
|
+
return refuse(response, who, 403, "removing a person takes your password, typed again", "/people");
|
|
283
|
+
}
|
|
284
|
+
const name = (body.get("name") ?? "").trim();
|
|
285
|
+
const severed = store.revokeAccount(name, who.name, now);
|
|
286
|
+
if (!severed.ok) {
|
|
287
|
+
const words = severed.reason === "last-approver"
|
|
288
|
+
? "that is the last account that can approve — add another approver first"
|
|
289
|
+
: severed.reason === "already-revoked"
|
|
290
|
+
? `${name} is already removed`
|
|
291
|
+
: `no account \u0060${name}\u0060`;
|
|
292
|
+
return refuse(response, who, severed.reason === "last-approver" ? 409 : 404, words, "/people");
|
|
293
|
+
}
|
|
294
|
+
return redirect(response, `/people?said=${encodeURIComponent(`${name} can no longer sign in — their sessions, invites, and signed modes ended with them; history stays`)}`);
|
|
295
|
+
}
|
|
296
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
297
|
+
}
|
|
298
|
+
async function edge(ctx) {
|
|
299
|
+
const { url, who, request, response, method } = ctx;
|
|
300
|
+
if (url.pathname === "/login" && method === "GET") {
|
|
301
|
+
if (options.setupCode !== undefined && store.listApprovers().length === 0) {
|
|
302
|
+
return page(response, 200, signupPage(null, runtime.setupAttemptsLeft));
|
|
303
|
+
}
|
|
304
|
+
return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
|
|
305
|
+
}
|
|
306
|
+
// The one-time link `up` opens (onboarding): it signs the browser on this computer in and lands on Chat. Spent on
|
|
307
|
+
// the first visit whatever happens; the ledger notes the sign-in, never the link.
|
|
308
|
+
if (url.pathname.startsWith(SIGN_IN_LINK_PATH) && method === "GET") {
|
|
309
|
+
const code = url.pathname.slice(SIGN_IN_LINK_PATH.length);
|
|
310
|
+
const held = /^[A-Za-z0-9_-]{43}$/.test(code) ? signInLinks.get(linkKey(code)) : undefined;
|
|
311
|
+
if (held !== undefined)
|
|
312
|
+
signInLinks.delete(linkKey(code));
|
|
313
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
314
|
+
if (!fromThisComputer(request)) {
|
|
315
|
+
recordSignIn(held?.account ?? "unknown account", "sign-in refused", "one-time link", "opened from another device");
|
|
316
|
+
return page(response, 403, loginPage("That sign-in link works only in a browser on the computer running Toolroll. Sign in with your password.", "/chat", ssoOffer()));
|
|
317
|
+
}
|
|
318
|
+
if (held === undefined || held.expires < Date.now()) {
|
|
319
|
+
return page(response, 410, loginPage("That sign-in link has been used or has expired. Sign in with your password, or run toolroll up again.", "/chat", ssoOffer()));
|
|
320
|
+
}
|
|
321
|
+
const account = store.accountOf(held.account);
|
|
322
|
+
if (account === null || account.role !== "approver" || account.revokedAt !== null)
|
|
323
|
+
return page(response, 403, loginPage("That account can't sign in here any more.", "/chat", ssoOffer()));
|
|
324
|
+
if (!passwordAllowed(held.account))
|
|
325
|
+
return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
|
|
326
|
+
const id = randomBytes(32).toString("hex");
|
|
327
|
+
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(),
|
|
328
|
+
project: defaultProject, projectRevision: 1 });
|
|
329
|
+
recordSignIn(held.account, "signed in", "one-time link");
|
|
330
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
331
|
+
return redirect(response, "/chat");
|
|
332
|
+
}
|
|
333
|
+
// v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
|
|
334
|
+
// ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
|
|
335
|
+
if (url.pathname === "/login/sso" && method === "GET") {
|
|
336
|
+
const settings = ssoSettings();
|
|
337
|
+
const intent = url.searchParams.get("link") === "1" ? "link" : url.searchParams.get("reauth") === "1" ? "reauth" : "sign-in";
|
|
338
|
+
const returnTo = loginReturn(url.searchParams.get("return"));
|
|
339
|
+
if (settings === null)
|
|
340
|
+
return page(response, 404, loginPage("Sign-in with an identity provider isn't on.", returnTo));
|
|
341
|
+
if (intent !== "sign-in" && who?.via !== "cookie")
|
|
342
|
+
return redirect(response, loginHref(returnTo));
|
|
343
|
+
const origin = consoleOrigin(request.headers.host);
|
|
344
|
+
if (origin === null)
|
|
345
|
+
return page(response, 400, loginPage("Sign in from this computer (localhost) or from your https address.", returnTo, ssoOffer()));
|
|
346
|
+
const found = await providerFor(settings.issuer);
|
|
347
|
+
if (!found.ok)
|
|
348
|
+
return page(response, 502, loginPage(`${settings.label} isn't answering: ${found.said}`, returnTo, ssoOffer()));
|
|
349
|
+
const visit = newOidcVisit(), redirectUri = `${origin}${SSO_CALLBACK}`;
|
|
350
|
+
for (const [key, one] of ssoVisits)
|
|
351
|
+
if (one.expires < Date.now())
|
|
352
|
+
ssoVisits.delete(key);
|
|
353
|
+
// Bounded: anyone may start a sign-in, so the oldest waiting ones give way.
|
|
354
|
+
while (ssoVisits.size >= 1000)
|
|
355
|
+
ssoVisits.delete(ssoVisits.keys().next().value);
|
|
356
|
+
ssoVisits.set(visit.state, { visit, provider: found.provider, redirect: redirectUri, intent, returnTo, expires: Date.now() + 15 * 60_000 });
|
|
357
|
+
response.writeHead(302, { location: oidcAuthorizeUrl(found.provider, settings, visit, redirectUri, intent === "reauth"), "cache-control": "no-store", "referrer-policy": "no-referrer",
|
|
358
|
+
"set-cookie": `${SIGN_IN_COOKIE}=${visit.state}; Path=${SSO_CALLBACK}; Max-Age=900; HttpOnly; SameSite=Lax${origin.startsWith("https:") ? "; Secure" : ""}` });
|
|
359
|
+
response.end();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (url.pathname === SSO_CALLBACK && method === "GET")
|
|
363
|
+
return ssoCallback(request, response, url);
|
|
364
|
+
if (url.pathname === "/login/sso/finish" && method === "GET") {
|
|
365
|
+
const key = url.searchParams.get("h") ?? "";
|
|
366
|
+
if (!startedHere(request, key))
|
|
367
|
+
return page(response, 400, loginPage("That sign-in was started in another browser. Sign in again from this one.", "/", ssoOffer()));
|
|
368
|
+
const handoff = ssoHandoffs.get(key);
|
|
369
|
+
ssoHandoffs.delete(key);
|
|
370
|
+
response.setHeader("Set-Cookie", signInSpent("/login/sso/finish"));
|
|
371
|
+
const settings = ssoSettings();
|
|
372
|
+
if (handoff === undefined || handoff.expires < Date.now() || settings === null)
|
|
373
|
+
return page(response, 400, loginPage("That sign-in expired. Try again.", "/", ssoOffer()));
|
|
374
|
+
const { claims } = handoff;
|
|
375
|
+
const identity = { issuer: handoff.issuer, subject: claims.sub, email: claims.email, label: settings.label };
|
|
376
|
+
if (handoff.intent === "link" || handoff.intent === "reauth") {
|
|
377
|
+
if (who?.via !== "cookie")
|
|
378
|
+
return redirect(response, loginHref(handoff.returnTo));
|
|
379
|
+
if (handoff.intent === "link") {
|
|
380
|
+
const linked = store.linkSsoIdentity(who.name, identity, clock());
|
|
381
|
+
if (!linked.ok)
|
|
382
|
+
return redirect(response, `/settings/sign-in?problem=${encodeURIComponent(`That ${settings.label} account signs in as someone else here.`)}`);
|
|
383
|
+
who.session.sso = { at: Date.now() };
|
|
384
|
+
sessions.persist(who.session);
|
|
385
|
+
return redirect(response, `/settings/sign-in?said=${encodeURIComponent(`This account signs in with ${settings.label} now.`)}`);
|
|
386
|
+
}
|
|
387
|
+
// A step-up: the provider checked the same person again.
|
|
388
|
+
if (store.ssoAccount(identity.issuer, identity.subject) !== who.name)
|
|
389
|
+
return page(response, 403, loginPage(`That ${settings.label} account isn't the one signed in here.`, handoff.returnTo, ssoOffer()));
|
|
390
|
+
who.session.sso = { at: Date.now() };
|
|
391
|
+
sessions.persist(who.session);
|
|
392
|
+
recordSignIn(who.name, "confirmed", settings.label);
|
|
393
|
+
return redirect(response, handoff.returnTo);
|
|
394
|
+
}
|
|
395
|
+
const rule = accessFromGroups(claims.groups, settings.rules);
|
|
396
|
+
const signedIn = store.signInWithSso(identity, rule === null ? null : { role: rule.role === "operator" ? "approver" : "viewer", projects: rule.projects === "all" ? null : rule.projects }, () => accountNameFor(claims, name => store.accountOf(name) !== null), clock());
|
|
397
|
+
if (!signedIn.ok) {
|
|
398
|
+
recordSignIn(store.ssoAccount(identity.issuer, identity.subject) ?? "unknown account", "sign-in refused", signedIn.reason === "no-group" ? "no matching group" : signedIn.reason);
|
|
399
|
+
return page(response, 403, loginPage(signedIn.reason === "no-group" ? `Your ${settings.label} account isn't in a group that may use Toolroll. Ask whoever runs it.`
|
|
400
|
+
: signedIn.reason === "revoked" ? "This account was removed. Ask whoever runs Toolroll." : "That change would leave no one who can run this installation.", "/", ssoOffer()));
|
|
401
|
+
}
|
|
402
|
+
const account = store.accountOf(signedIn.account);
|
|
403
|
+
const id = randomBytes(32).toString("hex");
|
|
404
|
+
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(),
|
|
405
|
+
project: defaultProject, projectRevision: 1, sso: { at: Date.now() } });
|
|
406
|
+
recordSignIn(signedIn.account, "signed in", settings.label);
|
|
407
|
+
response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
408
|
+
return redirect(response, handoff.returnTo);
|
|
409
|
+
}
|
|
410
|
+
if (url.pathname === "/signup" && method === "POST") {
|
|
411
|
+
// Only while the table is empty, only with the printed code, only
|
|
412
|
+
// five tries: the bootstrap door underneath is atomic, so two first
|
|
413
|
+
// visitors serialize to one owner.
|
|
414
|
+
if (options.setupCode === undefined || store.listApprovers().length > 0) {
|
|
415
|
+
return page(response, 409, loginPage("an account already exists — sign in"));
|
|
416
|
+
}
|
|
417
|
+
if (runtime.setupAttemptsLeft <= 0) {
|
|
418
|
+
return page(response, 403, signupPage("too many wrong codes — restart the server to get a fresh code", 0));
|
|
419
|
+
}
|
|
420
|
+
const body = readForm(await form(request), CONSOLE_FORMS.signup);
|
|
421
|
+
const code = (body.get("code") ?? "").trim();
|
|
422
|
+
const name = (body.get("name") ?? "").trim();
|
|
423
|
+
const password = body.get("password") ?? "";
|
|
424
|
+
const given = Buffer.from(code.padEnd(64, " "), "utf8");
|
|
425
|
+
const wanted = Buffer.from(options.setupCode.padEnd(64, " "), "utf8");
|
|
426
|
+
if (code === "" || !timingSafeEqual(given, wanted)) {
|
|
427
|
+
runtime.setupAttemptsLeft -= 1;
|
|
428
|
+
return page(response, 403, signupPage("that is not the setup code the server printed", runtime.setupAttemptsLeft));
|
|
429
|
+
}
|
|
430
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
|
|
431
|
+
return page(response, 400, signupPage("a username is letters, digits, dots, dashes — up to 64", runtime.setupAttemptsLeft));
|
|
432
|
+
}
|
|
433
|
+
if (password.length < 8) {
|
|
434
|
+
return page(response, 400, signupPage("a password is at least 8 characters", runtime.setupAttemptsLeft));
|
|
435
|
+
}
|
|
436
|
+
const made = store.bootstrapApproverIfNone(name, hashPassword(password), clock());
|
|
437
|
+
if (!made.ok)
|
|
438
|
+
return page(response, 409, loginPage("an account already exists — sign in"));
|
|
439
|
+
const authenticated = authenticateAccount(store, name, password);
|
|
440
|
+
if (authenticated === null || !authenticated.ok)
|
|
441
|
+
return page(response, 500, loginPage("the account was created but could not sign in — try again"));
|
|
442
|
+
const id = randomBytes(32).toString("hex");
|
|
443
|
+
sessions.set(id, {
|
|
444
|
+
...arrival(request),
|
|
445
|
+
name,
|
|
446
|
+
csrf: randomBytes(32).toString("hex"),
|
|
447
|
+
role: authenticated.role,
|
|
448
|
+
generation: authenticated.generation,
|
|
449
|
+
createdAt: Date.now(),
|
|
450
|
+
sawBoardAt: null,
|
|
451
|
+
lastSeen: Date.now(),
|
|
452
|
+
project: defaultProject,
|
|
453
|
+
projectRevision: 1,
|
|
454
|
+
});
|
|
455
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
456
|
+
return redirect(response, "/");
|
|
457
|
+
}
|
|
458
|
+
if (url.pathname === "/login" && method === "POST") {
|
|
459
|
+
const body = readForm(await form(request), CONSOLE_FORMS.login);
|
|
460
|
+
const name = body.get("name");
|
|
461
|
+
const token = body.get("token");
|
|
462
|
+
// The destination a deep link asked for rides the form as a same-site
|
|
463
|
+
// path, re-checked here on both roads: a failed sign-in keeps it, a
|
|
464
|
+
// successful one lands on it. Nothing about the sign-in itself changes.
|
|
465
|
+
const returnTo = loginReturn(body.get("return"));
|
|
466
|
+
const source = joinSourceOf(request), at = Date.now();
|
|
467
|
+
const wait = signInBudget.waitFor(source, at);
|
|
468
|
+
if (wait > 0) {
|
|
469
|
+
recordSignIn(signInActor(name), "sign-in refused", "too many tries", "from one address");
|
|
470
|
+
response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
|
|
471
|
+
return page(response, 429, loginPage(`Too many sign-in attempts. Try again in ${minutesWords(wait)}.`, returnTo));
|
|
472
|
+
}
|
|
473
|
+
const authenticated = name !== null && token !== null ? authenticateAccount(store, name, token) : null;
|
|
474
|
+
if (authenticated === null || !authenticated.ok) {
|
|
475
|
+
if (authenticated?.reason === "locked") {
|
|
476
|
+
const locked = authenticated.retryAfterMs;
|
|
477
|
+
recordSignIn(signInActor(name), "sign-in refused", "locked");
|
|
478
|
+
response.setHeader("Retry-After", String(Math.ceil(locked / 1000)));
|
|
479
|
+
return page(response, 429, loginPage(`Too many wrong passwords. Try again in ${minutesWords(locked)}.`, returnTo));
|
|
480
|
+
}
|
|
481
|
+
signInBudget.failed(source, at);
|
|
482
|
+
recordSignIn(signInActor(name), "sign-in refused", "wrong password");
|
|
483
|
+
return page(response, 403, loginPage("wrong username or password", returnTo, ssoOffer()));
|
|
484
|
+
}
|
|
485
|
+
if (!passwordAllowed(name)) {
|
|
486
|
+
recordSignIn(name, "sign-in refused", "provider only");
|
|
487
|
+
return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, returnTo, ssoOffer()));
|
|
488
|
+
}
|
|
489
|
+
recordSignIn(name, "signed in", "browser");
|
|
490
|
+
const id = randomBytes(32).toString("hex");
|
|
491
|
+
sessions.set(id, {
|
|
492
|
+
...arrival(request),
|
|
493
|
+
name: name,
|
|
494
|
+
csrf: randomBytes(32).toString("hex"),
|
|
495
|
+
role: authenticated.role,
|
|
496
|
+
generation: authenticated.generation,
|
|
497
|
+
createdAt: Date.now(),
|
|
498
|
+
sawBoardAt: null,
|
|
499
|
+
lastSeen: Date.now(),
|
|
500
|
+
project: defaultProject,
|
|
501
|
+
projectRevision: 1,
|
|
502
|
+
});
|
|
503
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
504
|
+
return redirect(response, returnTo);
|
|
505
|
+
}
|
|
506
|
+
if (url.pathname === "/logout" && method === "POST") {
|
|
507
|
+
const cookies = request.headers.cookie ?? "";
|
|
508
|
+
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
|
|
509
|
+
if (match !== null) {
|
|
510
|
+
const leaving = sessions.get(match[1]);
|
|
511
|
+
if (leaving !== undefined)
|
|
512
|
+
recordSignIn(leaving.name, "signed out", "browser");
|
|
513
|
+
sessions.delete(match[1]);
|
|
514
|
+
}
|
|
515
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${cookieSecure}`);
|
|
516
|
+
return redirect(response, "/login");
|
|
517
|
+
}
|
|
518
|
+
// THE JOIN ROAD (v29, D6/E3): a single-use invite is the only door
|
|
519
|
+
// that creates an account from the outside. Cookie-free, script-free,
|
|
520
|
+
// and every dead token shape — unknown, expired, revoked, consumed,
|
|
521
|
+
// attempts spent — answers with ONE indistinguishable page. GET spends
|
|
522
|
+
// nothing; a POST spends one metered attempt BEFORE the KDF runs.
|
|
523
|
+
const joinToken = /^\/join\/([A-Za-z0-9_-]{16,64})$/.exec(url.pathname)?.[1];
|
|
524
|
+
if (joinToken !== undefined && method === "GET") {
|
|
525
|
+
return page(response, 200, store.inviteIsLive(joinToken, clock()) ? joinFormPage(joinToken, null, "") : joinDeadPage());
|
|
526
|
+
}
|
|
527
|
+
if (joinToken !== undefined && method === "POST") {
|
|
528
|
+
// EXACT media type (Codex people round 2, finding 5): parameters may
|
|
529
|
+
// follow a semicolon; a merely prefix-shaped type is not a form.
|
|
530
|
+
const media = String(request.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
|
|
531
|
+
if (media !== "application/x-www-form-urlencoded") {
|
|
532
|
+
return respond(response, 415, "text/plain; charset=utf-8", "forms only");
|
|
533
|
+
}
|
|
534
|
+
let body;
|
|
535
|
+
try {
|
|
536
|
+
body = readForm(await form(request), CONSOLE_FORMS.join);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return respond(response, 413, "text/plain; charset=utf-8", "body too large");
|
|
540
|
+
}
|
|
541
|
+
for (const field of ["name", "password"]) {
|
|
542
|
+
if (body.getAll(field).length > 1) {
|
|
543
|
+
return respond(response, 400, "text/plain; charset=utf-8", `duplicated ${field} field`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!takeJoinAttempt(joinSourceOf(request))) {
|
|
547
|
+
return respond(response, 429, "text/plain; charset=utf-8", "too many sign-up attempts right now — try again in a few minutes");
|
|
548
|
+
}
|
|
549
|
+
const admitted = store.admitInviteAttempt(joinToken, clock());
|
|
550
|
+
if (admitted === null)
|
|
551
|
+
return page(response, 200, joinDeadPage());
|
|
552
|
+
const name = (body.get("name") ?? "").trim();
|
|
553
|
+
const password = body.get("password") ?? "";
|
|
554
|
+
// Possession of a LIVE token earns real words (D6's disclosure
|
|
555
|
+
// ruling) — the attempt is already spent either way (E3).
|
|
556
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(name)) {
|
|
557
|
+
return page(response, 400, joinFormPage(joinToken, "names are 1\u201340 characters \u2014 letters, digits, dots, dashes, underscores", name));
|
|
558
|
+
}
|
|
559
|
+
if (password.length < 8) {
|
|
560
|
+
return page(response, 400, joinFormPage(joinToken, "the password needs at least 8 characters", name));
|
|
561
|
+
}
|
|
562
|
+
const made = store.consumeInviteAndCreateAccount({ tokenValue: joinToken, name, credentialHash: hashPassword(password) }, clock());
|
|
563
|
+
if (!made.ok) {
|
|
564
|
+
if (made.reason === "name-taken") {
|
|
565
|
+
return page(response, 400, joinFormPage(joinToken, "that name is taken \u2014 pick another", name));
|
|
566
|
+
}
|
|
567
|
+
return page(response, 200, joinDeadPage());
|
|
568
|
+
}
|
|
569
|
+
// The cookie mints only AFTER the commit — same shape as login.
|
|
570
|
+
const id = randomBytes(32).toString("hex");
|
|
571
|
+
sessions.set(id, {
|
|
572
|
+
...arrival(request),
|
|
573
|
+
name,
|
|
574
|
+
csrf: randomBytes(32).toString("hex"),
|
|
575
|
+
role: made.role,
|
|
576
|
+
generation: 1,
|
|
577
|
+
createdAt: Date.now(),
|
|
578
|
+
sawBoardAt: null,
|
|
579
|
+
lastSeen: Date.now(),
|
|
580
|
+
project: defaultProject,
|
|
581
|
+
projectRevision: 1,
|
|
582
|
+
});
|
|
583
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
584
|
+
return redirect(response, "/");
|
|
585
|
+
}
|
|
586
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
587
|
+
}
|
|
588
|
+
function takeJoinAttempt(source) {
|
|
589
|
+
const at = Date.now();
|
|
590
|
+
const globalRefill = Math.floor((at - runtime.joinGlobal.refilledAt) / 20_000);
|
|
591
|
+
if (globalRefill > 0)
|
|
592
|
+
runtime.joinGlobal = { tokens: Math.min(30, runtime.joinGlobal.tokens + globalRefill), refilledAt: at };
|
|
593
|
+
if (joinBySource.size > 1000) {
|
|
594
|
+
const oldest = joinBySource.keys().next().value;
|
|
595
|
+
if (oldest !== undefined)
|
|
596
|
+
joinBySource.delete(oldest);
|
|
597
|
+
}
|
|
598
|
+
const bucket = joinBySource.get(source) ?? { tokens: 10, refilledAt: at };
|
|
599
|
+
const refill = Math.floor((at - bucket.refilledAt) / 60_000);
|
|
600
|
+
const tokens = refill > 0 ? Math.min(10, bucket.tokens + refill) : bucket.tokens;
|
|
601
|
+
if (tokens <= 0 || runtime.joinGlobal.tokens <= 0) {
|
|
602
|
+
joinBySource.set(source, { tokens, refilledAt: refill > 0 ? at : bucket.refilledAt });
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
runtime.joinGlobal.tokens -= 1;
|
|
606
|
+
joinBySource.set(source, { tokens: tokens - 1, refilledAt: refill > 0 ? at : bucket.refilledAt });
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Back from a service's sign-in: the code becomes the tool's tokens,
|
|
611
|
+
* kept in its secrets file, and a page sends the person back to Tools (or
|
|
612
|
+
* to the kit the Connect came from, whose teammate may then use the tool).
|
|
613
|
+
*/
|
|
614
|
+
/** v100: back from the identity provider. Proves the person, then hands them to /login/sso/finish on this site. */
|
|
615
|
+
async function ssoCallback(request, response, url) {
|
|
616
|
+
const back = (words) => page(response, 400, loginPage(words, "/", ssoOffer()));
|
|
617
|
+
const state = url.searchParams.get("state") ?? "";
|
|
618
|
+
if (!startedHere(request, state))
|
|
619
|
+
return back("That sign-in was started in another browser. Sign in again from this one.");
|
|
620
|
+
const held = ssoVisits.get(state);
|
|
621
|
+
ssoVisits.delete(state);
|
|
622
|
+
const settings = ssoSettings();
|
|
623
|
+
if (held === undefined || held.expires < Date.now() || settings === null)
|
|
624
|
+
return back("That sign-in expired. Try again.");
|
|
625
|
+
if (url.searchParams.has("error"))
|
|
626
|
+
return back(url.searchParams.get("error") === "access_denied" ? `${settings.label} didn't let you in.` : `${settings.label} didn't finish the sign-in.`);
|
|
627
|
+
const code = url.searchParams.get("code") ?? "";
|
|
628
|
+
if (!/^[\x21-\x7e]{4,4096}$/.test(code))
|
|
629
|
+
return back(`${settings.label} didn't send a sign-in code.`);
|
|
630
|
+
const fetcher = options.ssoFetch ?? fetch;
|
|
631
|
+
const token = await exchangeOidcCode(held.provider, settings, code, held.visit, held.redirect, fetcher);
|
|
632
|
+
if (!token.ok)
|
|
633
|
+
return back(token.said);
|
|
634
|
+
if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
|
|
635
|
+
return page(response, 403, loginPage("This sign-in is not allowed."));
|
|
636
|
+
const verified = await verifyIdToken(token.idToken, held.provider, settings, held.visit.nonce, clock(), fetcher);
|
|
637
|
+
if (!verified.ok) {
|
|
638
|
+
logEvent("warn", "sso.refused", { reason: verified.said });
|
|
639
|
+
return back(verified.said);
|
|
640
|
+
}
|
|
641
|
+
// A step-up must be a check made just now, not a remembered one.
|
|
642
|
+
if (held.intent === "reauth" && verified.claims.authTime !== null && Date.now() / 1000 - verified.claims.authTime > 300)
|
|
643
|
+
return back(`${settings.label} didn't check you again. Try once more.`);
|
|
644
|
+
const key = randomBytes(24).toString("base64url");
|
|
645
|
+
for (const [one, value] of ssoHandoffs)
|
|
646
|
+
if (value.expires < Date.now())
|
|
647
|
+
ssoHandoffs.delete(one);
|
|
648
|
+
while (ssoHandoffs.size >= 1000)
|
|
649
|
+
ssoHandoffs.delete(ssoHandoffs.keys().next().value);
|
|
650
|
+
ssoHandoffs.set(key, { claims: verified.claims, issuer: held.provider.issuer, intent: held.intent, returnTo: held.returnTo, expires: Date.now() + 60_000 });
|
|
651
|
+
const to = `/login/sso/finish?h=${key}`;
|
|
652
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
|
|
653
|
+
// The hand-off key is this browser's alone, like the visit's state: its cookie rides to /login/sso/finish.
|
|
654
|
+
"set-cookie": [signInSpent(SSO_CALLBACK), `${SIGN_IN_COOKIE}=${key}; Path=/login/sso/finish; Max-Age=60; HttpOnly; SameSite=Lax${held.redirect.startsWith("https:") ? "; Secure" : ""}`],
|
|
655
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" });
|
|
656
|
+
response.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${escape(to)}"><title>Toolroll</title>${HANDOFF_STYLE}<p>Signing you in… <a href="${escape(to)}">Continue</a></p>`);
|
|
657
|
+
}
|
|
658
|
+
function readAccessForm(body) {
|
|
659
|
+
if (body.getAll("access").length > 1)
|
|
660
|
+
return { ok: false, message: "Choose one access setting." };
|
|
661
|
+
const mode = body.get("access");
|
|
662
|
+
// Older clients omitted this field and explicitly used instance roles.
|
|
663
|
+
if ((mode === null && body.getAll("projects").length === 0) || mode === "all")
|
|
664
|
+
return { ok: true, projects: null };
|
|
665
|
+
if (mode !== "selected")
|
|
666
|
+
return { ok: false, message: "Choose selected projects or all projects." };
|
|
667
|
+
const allowed = new Set([...managedRepos(), ...store.knownRepos()].filter(visible));
|
|
668
|
+
const projects = [...new Set(body.getAll("projects"))];
|
|
669
|
+
if (projects.length > 100 || projects.some(repo => !allowed.has(repo)))
|
|
670
|
+
return { ok: false, message: "Choose projects available on this instance." };
|
|
671
|
+
return { ok: true, projects };
|
|
672
|
+
}
|
|
673
|
+
const registrations = [
|
|
674
|
+
{ id: "settings.sessions-send", domain: "people", stage: "console", method: "POST", handle: post },
|
|
675
|
+
{ id: "settings.sessions", domain: "people", stage: "console", method: "GET", handle: get },
|
|
676
|
+
{ id: "edge.login-page", domain: "people", stage: "edge", method: "GET", handle: edge },
|
|
677
|
+
{ id: "edge.login-link", domain: "people", stage: "edge", method: "GET", handle: edge },
|
|
678
|
+
{ id: "edge.sso-start", domain: "people", stage: "edge", method: "GET", handle: edge },
|
|
679
|
+
{ id: "edge.sso-callback", domain: "people", stage: "edge", method: "GET", handle: edge },
|
|
680
|
+
{ id: "edge.sso-finish", domain: "people", stage: "edge", method: "GET", handle: edge },
|
|
681
|
+
{ id: "edge.signup", domain: "people", stage: "edge", method: "POST", handle: edge },
|
|
682
|
+
{ id: "edge.login", domain: "people", stage: "edge", method: "POST", handle: edge },
|
|
683
|
+
{ id: "edge.logout", domain: "people", stage: "edge", method: "POST", handle: edge },
|
|
684
|
+
{ id: "edge.join", domain: "people", stage: "edge", method: "GET", handle: edge },
|
|
685
|
+
{ id: "edge.join-send", domain: "people", stage: "edge", method: "POST", handle: edge },
|
|
686
|
+
{ id: "people.page", domain: "people", stage: "console", method: "GET", handle: get },
|
|
687
|
+
{ id: "people.projects", domain: "people", stage: "console", method: "POST", handle: post },
|
|
688
|
+
{ id: "people.invite", domain: "people", stage: "console", method: "POST", handle: post },
|
|
689
|
+
{ id: "people.invite-revoke", domain: "people", stage: "console", method: "POST", handle: post },
|
|
690
|
+
{ id: "people.revoke", domain: "people", stage: "console", method: "POST", handle: post },
|
|
691
|
+
];
|
|
692
|
+
return { registrations, get, post, edge, takeJoinAttempt, ssoCallback, readAccessForm };
|
|
693
|
+
}
|