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