toolroll 0.9.88 → 0.9.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +16 -10
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent-board.d.ts +9 -0
- package/dist/agent-board.js +20 -0
- package/dist/agent-fence.js +20 -2
- package/dist/approval-authority.d.ts +7 -0
- package/dist/approval-authority.js +13 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +30 -30
- package/dist/browser-workspace.d.ts +13 -0
- package/dist/builder.js +5 -2
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +7 -1
- package/dist/chat-channel.js +11 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-task-actions.d.ts +1 -0
- package/dist/check-access.d.ts +3 -0
- package/dist/check-access.js +4 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +6 -2
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +23 -0
- package/dist/coding-ui.js +4 -2
- package/dist/connect-cli.js +2 -1
- package/dist/console-instance.d.ts +34 -1
- package/dist/console-instance.js +124 -15
- package/dist/contracts/cli.d.ts +72 -0
- package/dist/contracts/cli.js +13 -0
- package/dist/contracts/console-api.d.ts +9 -2
- package/dist/contracts/console-api.js +9 -2
- package/dist/desktop-host.js +8 -3
- package/dist/desktop-update.js +2 -1
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +22 -2
- package/dist/easy-start-args.d.ts +13 -0
- package/dist/easy-start-args.js +24 -0
- package/dist/easy-start.d.ts +77 -0
- package/dist/easy-start.js +321 -0
- package/dist/egress.js +5 -0
- package/dist/first-run.d.ts +77 -0
- package/dist/first-run.js +45 -0
- package/dist/flow-starters.js +3 -0
- package/dist/flow-triggers.js +4 -0
- package/dist/folder-gate.d.ts +32 -0
- package/dist/folder-gate.js +43 -0
- package/dist/guides.js +1 -1
- package/dist/invoke.js +11 -6
- package/dist/laptop-background.d.ts +171 -0
- package/dist/laptop-background.js +550 -0
- package/dist/laptop-service.d.ts +111 -0
- package/dist/laptop-service.js +268 -0
- package/dist/lead-cli.js +2 -1
- package/dist/lead-doors.d.ts +6 -0
- package/dist/lead-doors.js +20 -2
- package/dist/live-records.d.ts +1 -1
- package/dist/live-records.js +3 -12
- package/dist/local-owner.d.ts +124 -0
- package/dist/local-owner.js +212 -0
- package/dist/login-secrets.d.ts +60 -0
- package/dist/login-secrets.js +195 -0
- package/dist/mcp-oauth.d.ts +4 -0
- package/dist/mcp-oauth.js +13 -7
- package/dist/model-choices.d.ts +43 -0
- package/dist/model-choices.js +80 -0
- package/dist/operate.d.ts +46 -1
- package/dist/operate.js +461 -40
- package/dist/person-proof.js +3 -0
- package/dist/project-access.js +15 -1
- package/dist/project-admission.d.ts +6 -1
- package/dist/project-admission.js +44 -7
- package/dist/project-identity.d.ts +8 -0
- package/dist/project-identity.js +43 -0
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/project.d.ts +1 -0
- package/dist/project.js +6 -0
- package/dist/proposal.js +11 -1
- package/dist/quick-start.d.ts +3 -1
- package/dist/quick-start.js +6 -1
- package/dist/recipes.js +12 -1
- package/dist/release-cli.js +2 -1
- package/dist/runner-process.d.ts +9 -0
- package/dist/runner-process.js +18 -0
- package/dist/sample-project.d.ts +31 -0
- package/dist/sample-project.js +141 -0
- package/dist/scope.d.ts +24 -2
- package/dist/scope.js +74 -3
- package/dist/serve.js +157 -28
- package/dist/server/chat.js +10 -3
- package/dist/server/chrome.d.ts +10 -2
- package/dist/server/chrome.js +7 -5
- package/dist/server/console.css +10 -0
- package/dist/server/guards.d.ts +3 -0
- package/dist/server/guards.js +22 -2
- package/dist/server/http.d.ts +22 -0
- package/dist/server/http.js +58 -0
- package/dist/server/live.js +4 -14
- package/dist/server/mutation-key.d.ts +70 -0
- package/dist/server/mutation-key.js +142 -0
- package/dist/server/pages.js +12 -9
- package/dist/server/people-tokens.js +240 -20
- package/dist/server/remote.d.ts +2 -0
- package/dist/server/remote.js +19 -4
- package/dist/server/render-new-task.js +5 -1
- package/dist/server/render-pages.d.ts +2 -1
- package/dist/server/render-pages.js +1 -1
- package/dist/server/render-people.d.ts +36 -0
- package/dist/server/render-people.js +65 -1
- package/dist/server/render-projects.js +4 -2
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +16 -6
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.d.ts +16 -0
- package/dist/server/route-table.js +121 -0
- package/dist/server/runtime.d.ts +6 -4
- package/dist/server/session.d.ts +31 -1
- package/dist/server/session.js +12 -2
- package/dist/server/settings.js +63 -8
- package/dist/server/task-views.js +10 -1
- package/dist/server/tasks.d.ts +10 -0
- package/dist/server/tasks.js +59 -9
- package/dist/server/terminals.js +15 -2
- package/dist/server/welcome.d.ts +22 -16
- package/dist/server/welcome.js +150 -52
- package/dist/settings-groups-ui.d.ts +7 -1
- package/dist/settings-groups-ui.js +23 -2
- package/dist/settings-proofs.js +1 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/setup-guide.js +6 -0
- package/dist/store.d.ts +124 -1
- package/dist/store.js +245 -12
- package/dist/surface.d.ts +10 -0
- package/dist/surface.js +40 -1
- package/dist/task-text.d.ts +6 -0
- package/dist/task-text.js +17 -0
- package/dist/teardown-git.js +16 -1
- package/dist/telegram-lead.js +2 -0
- package/dist/terminal-text.d.ts +11 -0
- package/dist/terminal-text.js +26 -0
- package/dist/toolroll-update.d.ts +8 -1
- package/dist/toolroll-update.js +151 -4
- package/dist/tui-contract.d.ts +118 -0
- package/dist/tui-contract.js +9 -0
- package/dist/tui-source.d.ts +14 -0
- package/dist/tui-source.js +182 -0
- package/dist/tui.js +129 -0
- package/dist/up-processes.d.ts +13 -1
- package/dist/up-processes.js +13 -3
- package/dist/welcome-facts.d.ts +18 -0
- package/dist/welcome-facts.js +79 -0
- package/dist/youiyoux-intake.js +11 -1
- package/package.json +4 -2
|
@@ -3,7 +3,7 @@ import { adapterPolicy } from "./route-policy.js";
|
|
|
3
3
|
/** settings handlers, moved without changing their route bodies. */
|
|
4
4
|
import { mintApiToken, TOKEN_DAYS } from "../api-tokens.js";
|
|
5
5
|
import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
6
|
-
import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
|
|
6
|
+
import { html, htmlString, joinHtml, postForm, reauth, replaceMarkup, scriptElement } from "../html.js";
|
|
7
7
|
import { credentialsHtml, tokenShownHtml } from "../credentials-ui.js";
|
|
8
8
|
import { projectName } from "../project.js";
|
|
9
9
|
import { requestLimitsHtml, tokenLimitWords } from "../request-budget-ui.js";
|
|
@@ -22,10 +22,13 @@ import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcA
|
|
|
22
22
|
import { personAuditHtml, personHref } from "../people-audit-ui.js";
|
|
23
23
|
import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
|
|
24
24
|
import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
|
|
25
|
-
import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
|
|
26
|
-
import { joinDeadPage, joinFormPage, loginPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
|
|
27
|
-
import { liveInviteTerms } from "../store.js";
|
|
28
|
-
import {
|
|
25
|
+
import { form, HANDOFF_STYLE, loginHref, loginReturn, page, passwordSetNext, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
|
|
26
|
+
import { joinDeadPage, joinFormPage, loginPage, passwordAlreadySetPage, passwordSetPage, reconnectPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
|
|
27
|
+
import { enrollOwnerPassword, liveInviteTerms, localOwnerBinding } from "../store.js";
|
|
28
|
+
import { KEY_FILL_SCRIPT } from "./mutation-key.js";
|
|
29
|
+
import { LOCAL_OWNER_LAPSED } from "../local-owner.js";
|
|
30
|
+
import { withFormToken } from "./request-context.js";
|
|
31
|
+
import { FULL_SESSION, SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
|
|
29
32
|
import { setupCodeMatches, signupCookie, signupFromHere } from "./signup-gate.js";
|
|
30
33
|
/** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
|
|
31
34
|
* running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
|
|
@@ -42,7 +45,7 @@ export function sameVisitCheck(authTime, startedAt, now) {
|
|
|
42
45
|
return Math.min(Math.max(at, startedAt), now);
|
|
43
46
|
}
|
|
44
47
|
export function createPeopleHandlers(runtime) {
|
|
45
|
-
const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
|
|
48
|
+
const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords, browserKeys } = runtime;
|
|
46
49
|
// v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
|
|
47
50
|
async function sessionsPage(ctx) {
|
|
48
51
|
const { url, who, response, project } = ctx;
|
|
@@ -279,11 +282,29 @@ export function createPeopleHandlers(runtime) {
|
|
|
279
282
|
if (!passwordAllowed(held.account))
|
|
280
283
|
return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
|
|
281
284
|
const id = randomBytes(32).toString("hex");
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
const keyed = keyFor(request);
|
|
286
|
+
const base = { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
|
|
287
|
+
project: runtime.defaultProject, projectRevision: 1 };
|
|
288
|
+
if (held.kind === "local-owner") {
|
|
289
|
+
// Easy-start C4: a local owner's link becomes only a local-owner session, and only if local mode, read again in
|
|
290
|
+
// the same transaction as the session is saved, is still on at the link's generation for the same owner, and the
|
|
291
|
+
// owner's account generation hasn't moved (a password set since ended this road).
|
|
292
|
+
const made = store.transact(() => {
|
|
293
|
+
const now = localOwnerBinding(store);
|
|
294
|
+
if (now === null || now.account !== held.account || now.localGeneration !== held.localGeneration || now.accountGeneration !== held.accountGeneration)
|
|
295
|
+
return false;
|
|
296
|
+
sessions.set(id, { ...base, generation: now.accountGeneration, authority: "local-owner", localGeneration: now.localGeneration, ...keyed.fields });
|
|
297
|
+
return true;
|
|
298
|
+
});
|
|
299
|
+
if (!made)
|
|
300
|
+
return page(response, 410, loginPage("That sign-in link no longer works. Run toolroll again to open Toolroll.", "/chat", ssoOffer()));
|
|
301
|
+
recordSignIn(held.account, "signed in", "local link");
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
sessions.set(id, { ...base, ...FULL_SESSION, ...keyed.fields });
|
|
305
|
+
recordSignIn(held.account, "signed in", "one-time link");
|
|
306
|
+
}
|
|
307
|
+
return signedInTo(response, keyed.key, "/chat", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
287
308
|
}
|
|
288
309
|
// v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
|
|
289
310
|
// ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
|
|
@@ -374,11 +395,11 @@ export function createPeopleHandlers(runtime) {
|
|
|
374
395
|
}
|
|
375
396
|
const account = store.accountOf(signedIn.account);
|
|
376
397
|
const id = randomBytes(32).toString("hex");
|
|
377
|
-
|
|
398
|
+
const keyed = keyFor(request);
|
|
399
|
+
sessions.set(id, { ...arrival(request), ...FULL_SESSION, ...keyed.fields, name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
|
|
378
400
|
project: runtime.defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
|
|
379
401
|
recordSignIn(signedIn.account, "signed in", settings.label);
|
|
380
|
-
response.
|
|
381
|
-
return redirect(response, handoff.returnTo);
|
|
402
|
+
return signedInTo(response, keyed.key, handoff.returnTo, [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
382
403
|
}
|
|
383
404
|
async function signupSend(ctx) {
|
|
384
405
|
const { request, response } = ctx;
|
|
@@ -428,8 +449,11 @@ export function createPeopleHandlers(runtime) {
|
|
|
428
449
|
if (authenticated === null || !authenticated.ok)
|
|
429
450
|
return page(response, 500, loginPage("The account was created, but signing in failed. Sign in with it now."));
|
|
430
451
|
const id = randomBytes(32).toString("hex");
|
|
452
|
+
const keyed = keyFor(request);
|
|
431
453
|
sessions.set(id, {
|
|
432
454
|
...arrival(request),
|
|
455
|
+
...FULL_SESSION,
|
|
456
|
+
...keyed.fields,
|
|
433
457
|
name,
|
|
434
458
|
csrf: randomBytes(32).toString("hex"),
|
|
435
459
|
role: authenticated.role,
|
|
@@ -442,8 +466,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
442
466
|
// A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
|
|
443
467
|
...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
|
|
444
468
|
});
|
|
445
|
-
response.
|
|
446
|
-
return redirect(response, "/");
|
|
469
|
+
return signedInTo(response, keyed.key, "/", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
447
470
|
}
|
|
448
471
|
async function loginSend(ctx) {
|
|
449
472
|
const { request, response } = ctx;
|
|
@@ -479,8 +502,11 @@ export function createPeopleHandlers(runtime) {
|
|
|
479
502
|
}
|
|
480
503
|
recordSignIn(name, "signed in", "browser");
|
|
481
504
|
const id = randomBytes(32).toString("hex");
|
|
505
|
+
const keyed = keyFor(request);
|
|
482
506
|
sessions.set(id, {
|
|
483
507
|
...arrival(request),
|
|
508
|
+
...FULL_SESSION,
|
|
509
|
+
...keyed.fields,
|
|
484
510
|
name: name,
|
|
485
511
|
csrf: randomBytes(32).toString("hex"),
|
|
486
512
|
role: authenticated.role,
|
|
@@ -494,8 +520,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
494
520
|
// else (a phone, a proxy) it signs in and proves nothing more.
|
|
495
521
|
...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
|
|
496
522
|
});
|
|
497
|
-
response.
|
|
498
|
-
return redirect(response, returnTo);
|
|
523
|
+
return signedInTo(response, keyed.key, returnTo, [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
499
524
|
}
|
|
500
525
|
async function logout(ctx) {
|
|
501
526
|
const { request, response } = ctx;
|
|
@@ -567,8 +592,11 @@ export function createPeopleHandlers(runtime) {
|
|
|
567
592
|
}
|
|
568
593
|
// The cookie mints only AFTER the commit — same shape as login.
|
|
569
594
|
const id = randomBytes(32).toString("hex");
|
|
595
|
+
const keyed = keyFor(request);
|
|
570
596
|
sessions.set(id, {
|
|
571
597
|
...arrival(request),
|
|
598
|
+
...FULL_SESSION,
|
|
599
|
+
...keyed.fields,
|
|
572
600
|
name,
|
|
573
601
|
csrf: randomBytes(32).toString("hex"),
|
|
574
602
|
role: made.role,
|
|
@@ -579,8 +607,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
579
607
|
project: runtime.defaultProject,
|
|
580
608
|
projectRevision: 1,
|
|
581
609
|
});
|
|
582
|
-
response.
|
|
583
|
-
return redirect(response, "/");
|
|
610
|
+
return signedInTo(response, keyed.key, "/", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
584
611
|
}
|
|
585
612
|
/** The invite code in /join/:code (the route's pattern has already proven its shape). */
|
|
586
613
|
function joinCodeOf(url) {
|
|
@@ -674,6 +701,195 @@ export function createPeopleHandlers(runtime) {
|
|
|
674
701
|
return { ok: false, message: "Choose projects available on this instance." };
|
|
675
702
|
return { ok: true, projects };
|
|
676
703
|
}
|
|
704
|
+
/** Easy-start C2: a session's key fields when it is made: on a protected loopback install, a fresh key (only its hash
|
|
705
|
+
* and this start's epoch kept) to deliver once; else none. */
|
|
706
|
+
function keyFor(request) {
|
|
707
|
+
const fields = { keyEpoch: null, keyHash: null };
|
|
708
|
+
const key = browserKeys.issue(fields, request);
|
|
709
|
+
return { fields, key };
|
|
710
|
+
}
|
|
711
|
+
/** A sign-in's answer: its cookies, then on to `to` — through the key's one delivery when there is a key. */
|
|
712
|
+
function signedInTo(response, key, to, cookies) {
|
|
713
|
+
if (key !== null)
|
|
714
|
+
return browserKeys.deliver(response, key, to, cookies);
|
|
715
|
+
response.setHeader("Set-Cookie", cookies.length === 1 ? cookies[0] : [...cookies]);
|
|
716
|
+
return redirect(response, to);
|
|
717
|
+
}
|
|
718
|
+
// Easy-start C2: Reconnect. A browser session keeps reading after a restart, but its mutation key was bound to the
|
|
719
|
+
// console start that issued it. A full session gets a fresh key here by typing its password again (a cookie alone
|
|
720
|
+
// never does: a cookie holder on another port has the cookie); a local owner, by running `toolroll open`.
|
|
721
|
+
async function rekeyPage(ctx) {
|
|
722
|
+
const { url, who, response } = ctx;
|
|
723
|
+
const returnTo = loginReturn(url.searchParams.get("return"));
|
|
724
|
+
if (who?.via !== "cookie")
|
|
725
|
+
return redirect(response, loginHref(returnTo));
|
|
726
|
+
if (!browserKeys.required())
|
|
727
|
+
return redirect(response, returnTo);
|
|
728
|
+
return page(response, 200, reconnectPage(null, returnTo, howToReconnect(who)));
|
|
729
|
+
}
|
|
730
|
+
function howToReconnect(who) {
|
|
731
|
+
if (who.authority !== "full")
|
|
732
|
+
return { kind: "command" };
|
|
733
|
+
if (!passwordAllowed(who.name))
|
|
734
|
+
return { kind: "sso", label: ssoSettings()?.label ?? "your identity provider" };
|
|
735
|
+
return { kind: "password", name: who.name };
|
|
736
|
+
}
|
|
737
|
+
async function rekeySend(ctx) {
|
|
738
|
+
const { who, request, response } = ctx;
|
|
739
|
+
const body = readForm(await form(request), CONSOLE_FORMS.rekey);
|
|
740
|
+
const returnTo = loginReturn(body.get("return"));
|
|
741
|
+
if (who?.via !== "cookie")
|
|
742
|
+
return redirect(response, loginHref(returnTo));
|
|
743
|
+
if (!browserKeys.required())
|
|
744
|
+
return redirect(response, returnTo);
|
|
745
|
+
// Only this console's own page: a present Origin must name it (the password below is the proof either way).
|
|
746
|
+
const origin = request.headers.origin;
|
|
747
|
+
if (typeof origin === "string" && origin !== "null" && origin !== `http://${browserKeys.canonicalHost()}`) {
|
|
748
|
+
return page(response, 403, reconnectPage("Reconnect from Toolroll's own page.", returnTo, howToReconnect(who)));
|
|
749
|
+
}
|
|
750
|
+
const how = howToReconnect(who);
|
|
751
|
+
if (how.kind !== "password")
|
|
752
|
+
return page(response, 403, reconnectPage(null, returnTo, how));
|
|
753
|
+
const source = joinSourceOf(request), at = Date.now();
|
|
754
|
+
const wait = signInBudget.waitFor(source, at);
|
|
755
|
+
if (wait > 0) {
|
|
756
|
+
recordSignIn(who.name, "sign-in refused", "too many tries", "reconnect");
|
|
757
|
+
response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
|
|
758
|
+
return page(response, 429, reconnectPage(`Too many tries. Try again in ${minutesWords(wait)}.`, returnTo, how));
|
|
759
|
+
}
|
|
760
|
+
// Only a password typed now: never a session, Passwordless or a remembered password (an empty one simply fails).
|
|
761
|
+
const proved = authenticateAccount(store, who.name, body.get("token") ?? "");
|
|
762
|
+
if (!proved.ok) {
|
|
763
|
+
if (proved.reason === "locked") {
|
|
764
|
+
recordSignIn(who.name, "sign-in refused", "locked", "reconnect");
|
|
765
|
+
response.setHeader("Retry-After", String(Math.ceil(proved.retryAfterMs / 1000)));
|
|
766
|
+
return page(response, 429, reconnectPage(`Too many wrong passwords. Try again in ${minutesWords(proved.retryAfterMs)}.`, returnTo, how));
|
|
767
|
+
}
|
|
768
|
+
signInBudget.failed(source, at);
|
|
769
|
+
recordSignIn(who.name, "sign-in refused", "wrong password", "reconnect");
|
|
770
|
+
return page(response, 403, reconnectPage("That password isn't right.", returnTo, how));
|
|
771
|
+
}
|
|
772
|
+
// The same person under the same credential generation and standing this session signed in with; anything else
|
|
773
|
+
// signs in again instead.
|
|
774
|
+
if (proved.generation !== who.session.generation || proved.role !== who.session.role)
|
|
775
|
+
return redirect(response, loginHref(returnTo));
|
|
776
|
+
const key = browserKeys.issue(who.session, request);
|
|
777
|
+
if (key === null)
|
|
778
|
+
return page(response, 421, reconnectPage(`Open Toolroll at http://${browserKeys.canonicalHost()} to reconnect.`, returnTo, how));
|
|
779
|
+
sessions.persist(who.session);
|
|
780
|
+
recordSignIn(who.name, "reconnected", "browser");
|
|
781
|
+
return browserKeys.deliver(response, key, returnTo);
|
|
782
|
+
}
|
|
783
|
+
// Easy-start C4: /password/set, the one credential act a local owner's session may make: its first password, only
|
|
784
|
+
// while the account has none. The answer is a fresh full session (and a fresh browser key), and on to the act that
|
|
785
|
+
// was refused. Every other session, and an account that has a password, is told to sign in with it.
|
|
786
|
+
/** A /password/set page: no-store, and on a protected loopback install the key fill rides it (its form posts the key). */
|
|
787
|
+
function passwordSetAnswer(response, status, document) {
|
|
788
|
+
response.setHeader("Cache-Control", "no-store");
|
|
789
|
+
if (!browserKeys.required())
|
|
790
|
+
return page(response, status, document);
|
|
791
|
+
const nonce = randomBytes(16).toString("base64");
|
|
792
|
+
return page(response, status, replaceMarkup(document, /<\/body><\/html>$/, () => html `${scriptElement(KEY_FILL_SCRIPT, { nonce })}</body></html>`), nonce);
|
|
793
|
+
}
|
|
794
|
+
async function passwordSetForm(ctx) {
|
|
795
|
+
const { url, who, response } = ctx;
|
|
796
|
+
// Where to go once it is set: `next` (a refusal's link), or `return` (older links), checked either way.
|
|
797
|
+
const returnTo = passwordSetNext(url.searchParams.get("next") ?? url.searchParams.get("return"));
|
|
798
|
+
if (who === null || who === undefined)
|
|
799
|
+
return redirect(response, loginHref(returnTo));
|
|
800
|
+
if (who.via !== "cookie")
|
|
801
|
+
return respond(response, 403, "text/plain; charset=utf-8", "This address needs a browser sign-in.");
|
|
802
|
+
if (who.authority === "full" || store.accountOf(who.name)?.passwordSet !== false)
|
|
803
|
+
return passwordSetAnswer(response, 200, passwordAlreadySetPage("A password is already set", returnTo, who.authority === "full"));
|
|
804
|
+
return passwordSetAnswer(response, 200, withFormToken(who.session.csrf, () => passwordSetPage({ returnTo, problem: null })));
|
|
805
|
+
}
|
|
806
|
+
async function passwordSetSend(ctx) {
|
|
807
|
+
const { who, request, response } = ctx;
|
|
808
|
+
const media = String(request.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
|
|
809
|
+
if (media !== "application/x-www-form-urlencoded") {
|
|
810
|
+
request.resume();
|
|
811
|
+
return respond(response, 415, "text/plain; charset=utf-8", "forms only");
|
|
812
|
+
}
|
|
813
|
+
const body = readForm(await form(request), CONSOLE_FORMS.passwordSet);
|
|
814
|
+
const returnTo = passwordSetNext(body.get("next") ?? body.get("return"));
|
|
815
|
+
if (who === null || who === undefined)
|
|
816
|
+
return redirect(response, loginHref(returnTo));
|
|
817
|
+
if (who.via !== "cookie")
|
|
818
|
+
return respond(response, 403, "text/plain; charset=utf-8", "This address needs a browser sign-in.");
|
|
819
|
+
for (const field of ["csrf", "password", "again", "next", "return"]) {
|
|
820
|
+
if (body.getAll(field).length > 1)
|
|
821
|
+
return respond(response, 400, "text/plain; charset=utf-8", `duplicated ${field} field`);
|
|
822
|
+
}
|
|
823
|
+
const account = store.accountOf(who.name);
|
|
824
|
+
// A full session, or an account that has a password: only signing in with it.
|
|
825
|
+
if (who.authority === "full" || account === null || account.passwordSet)
|
|
826
|
+
return passwordSetAnswer(response, 409, passwordAlreadySetPage("A password is already set", returnTo, who.authority === "full"));
|
|
827
|
+
const again = (problem, status) => passwordSetAnswer(response, status, withFormToken(who.session.csrf, () => passwordSetPage({ returnTo, problem })));
|
|
828
|
+
// The console's own page only: a present Origin (or Referer) must name it; the session's CSRF token and (on a protected
|
|
829
|
+
// loopback install) its browser key are proved as for every other mutation.
|
|
830
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
831
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
832
|
+
if (named !== null && !runtime.allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]))
|
|
833
|
+
return again({ field: null, text: "Set your password from Toolroll's own page." }, 403);
|
|
834
|
+
if (body.get("csrf") !== who.session.csrf)
|
|
835
|
+
return again({ field: null, text: "This form had expired, so it was reloaded. Enter the password again." }, 403);
|
|
836
|
+
const keyed = browserKeys.check(who, request, body.sent);
|
|
837
|
+
if (keyed !== null)
|
|
838
|
+
return again({ field: null, text: keyed.message }, keyed.status);
|
|
839
|
+
if (account.revokedAt !== null || account.generation !== who.session.generation || !sessions.savedNow(who.session) || who.session.localGeneration === null) {
|
|
840
|
+
return again({ field: null, text: LOCAL_OWNER_LAPSED }, 409);
|
|
841
|
+
}
|
|
842
|
+
// Rate-limited like signing in: the same per-address budget, and each refused try spends from it.
|
|
843
|
+
const source = joinSourceOf(request), at = Date.now();
|
|
844
|
+
const wait = signInBudget.waitFor(source, at);
|
|
845
|
+
if (wait > 0) {
|
|
846
|
+
response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
|
|
847
|
+
return again({ field: null, text: `Too many tries. Try again in ${minutesWords(wait)}.` }, 429);
|
|
848
|
+
}
|
|
849
|
+
const password = body.get("password") ?? "";
|
|
850
|
+
// The strength rule sign-up and joining use.
|
|
851
|
+
if (password.length < SIGNUP_PASSWORD_MIN) {
|
|
852
|
+
signInBudget.failed(source, at);
|
|
853
|
+
return again({ field: "password", text: `Use at least ${SIGNUP_PASSWORD_MIN} characters.` }, 400);
|
|
854
|
+
}
|
|
855
|
+
if ((body.get("again") ?? "") !== password) {
|
|
856
|
+
signInBudget.failed(source, at);
|
|
857
|
+
return again({ field: "again", text: "The two passwords don't match. Type them again." }, 400);
|
|
858
|
+
}
|
|
859
|
+
// Hashed before the transaction (scrypt is slow); enrollOwnerPassword then checks and writes in one IMMEDIATE
|
|
860
|
+
// transaction: still no password, local mode on at this session's generation, the account its active owner. It ends
|
|
861
|
+
// every session the account had (this one included).
|
|
862
|
+
const enrolled = enrollOwnerPassword(store, who.name, hashPassword(password), who.session.localGeneration, clock());
|
|
863
|
+
if (!enrolled.ok) {
|
|
864
|
+
if (enrolled.reason === "already-set")
|
|
865
|
+
return passwordSetAnswer(response, 409, passwordAlreadySetPage("A password was just set", returnTo));
|
|
866
|
+
return again({ field: null, text: LOCAL_OWNER_LAPSED }, 409);
|
|
867
|
+
}
|
|
868
|
+
const cookieId = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(request.headers.cookie ?? "")?.[1];
|
|
869
|
+
if (cookieId !== undefined)
|
|
870
|
+
sessions.delete(cookieId);
|
|
871
|
+
recordSignIn(who.name, "signed in", "browser", "first password set");
|
|
872
|
+
// A fresh full session, made only after the commit, with a fresh key delivered once (no-store) on a protected install.
|
|
873
|
+
const id = randomBytes(32).toString("hex");
|
|
874
|
+
const issued = keyFor(request);
|
|
875
|
+
sessions.set(id, {
|
|
876
|
+
...arrival(request),
|
|
877
|
+
...FULL_SESSION,
|
|
878
|
+
...issued.fields,
|
|
879
|
+
name: who.name,
|
|
880
|
+
csrf: randomBytes(32).toString("hex"),
|
|
881
|
+
role: account.role,
|
|
882
|
+
generation: enrolled.generation,
|
|
883
|
+
createdAt: Date.now(),
|
|
884
|
+
sawBoardAt: null,
|
|
885
|
+
lastSeen: Date.now(),
|
|
886
|
+
project: who.session.project ?? runtime.defaultProject,
|
|
887
|
+
projectRevision: 1,
|
|
888
|
+
// A password typed on this computer starts this session's freshness window (cockpit B2), as signing in does.
|
|
889
|
+
...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
|
|
890
|
+
});
|
|
891
|
+
return signedInTo(response, issued.key, returnTo, [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
892
|
+
}
|
|
677
893
|
const registrations = handlersOf("people", {
|
|
678
894
|
"people.page": peoplePage,
|
|
679
895
|
"settings.sessions": sessionsPage,
|
|
@@ -691,8 +907,12 @@ export function createPeopleHandlers(runtime) {
|
|
|
691
907
|
"edge.signup": signupSend,
|
|
692
908
|
"edge.login": loginSend,
|
|
693
909
|
"edge.logout": logout,
|
|
910
|
+
"edge.rekey-page": rekeyPage,
|
|
911
|
+
"edge.rekey": rekeySend,
|
|
694
912
|
"edge.join": joinPage,
|
|
695
913
|
"edge.join-send": joinSend,
|
|
914
|
+
"edge.password-set-page": passwordSetForm,
|
|
915
|
+
"edge.password-set": passwordSetSend,
|
|
696
916
|
});
|
|
697
917
|
return { registrations, takeJoinAttempt, ssoCallback, readAccessForm };
|
|
698
918
|
}
|
package/dist/server/remote.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface RemoteRuntime {
|
|
|
51
51
|
teamsSourceBudget: SourceAdmission;
|
|
52
52
|
teamsTenantBudget: SourceAdmission;
|
|
53
53
|
freshFactsOf: (request: IncomingMessage, who: Who | null) => FreshFacts | null;
|
|
54
|
+
/** Easy-start C2: the browser mutation key (server/mutation-key.ts). */
|
|
55
|
+
browserKeys: import("./mutation-key.js").BrowserKeys;
|
|
54
56
|
}
|
|
55
57
|
export declare function createRemoteHandlers(runtime: RemoteRuntime): {
|
|
56
58
|
registrations: import("./handler-registry.js").Registration[];
|
package/dist/server/remote.js
CHANGED
|
@@ -38,6 +38,9 @@ export function createRemoteHandlers(runtime) {
|
|
|
38
38
|
const account = who === null ? null : store.accountOf(who.name);
|
|
39
39
|
if (!who || !account || account.revokedAt !== null)
|
|
40
40
|
return null;
|
|
41
|
+
// Easy-start C1: team conversations are a remote feature; a laptop's local owner (no password) never reaches them.
|
|
42
|
+
if (who.via === 'cookie' && who.authority !== 'full')
|
|
43
|
+
return null;
|
|
41
44
|
if (who.via === 'bearer')
|
|
42
45
|
passwordProvedAt.set(request, clock().getTime());
|
|
43
46
|
return { name: who.name, generation: account.generation, role: account.role };
|
|
@@ -71,7 +74,7 @@ export function createRemoteHandlers(runtime) {
|
|
|
71
74
|
return true;
|
|
72
75
|
}
|
|
73
76
|
const who = identify(request, false);
|
|
74
|
-
return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
|
|
77
|
+
return who?.name === actor.name && who.via === 'cookie' && who.authority === 'full' && who.session.generation === actor.generation;
|
|
75
78
|
},
|
|
76
79
|
authorizeMutation: (request, actor) => {
|
|
77
80
|
if (actor.principal !== undefined)
|
|
@@ -81,11 +84,15 @@ export function createRemoteHandlers(runtime) {
|
|
|
81
84
|
return false;
|
|
82
85
|
if (who.via === 'bearer')
|
|
83
86
|
return request.headers.origin === undefined;
|
|
87
|
+
if (who.authority !== 'full')
|
|
88
|
+
return false;
|
|
84
89
|
const origin = request.headers.origin, referer = request.headers.referer;
|
|
85
90
|
const named = typeof origin === 'string' && origin !== 'null' ? origin : typeof referer === 'string' ? referer : null;
|
|
86
91
|
if (named !== null && !allowedHost(named.replace(/^https?:\/\//, '').split('/')[0]))
|
|
87
92
|
return false;
|
|
88
|
-
|
|
93
|
+
// Easy-start C2: and, on a protected loopback install, the session's key (its header), which a cookie holder on
|
|
94
|
+
// another port can't read.
|
|
95
|
+
return request.headers['x-csrf-token'] === who.session.csrf && runtime.browserKeys.check(who, request, null) === null;
|
|
89
96
|
},
|
|
90
97
|
execute: async (actor, input) => {
|
|
91
98
|
const reply = await team.execute(actor, input);
|
|
@@ -112,7 +119,8 @@ export function createRemoteHandlers(runtime) {
|
|
|
112
119
|
requesterKey: joinSourceOf,
|
|
113
120
|
session: request => {
|
|
114
121
|
const who = identify(request, false);
|
|
115
|
-
|
|
122
|
+
// Easy-start C1: connecting an outside app is a credential act; a local owner's session is never one that consents.
|
|
123
|
+
if (who === null || who.via !== "cookie" || who.authority !== "full")
|
|
116
124
|
return null;
|
|
117
125
|
return { name: who.name, role: who.role, csrf: who.session.csrf, generation: who.session.generation,
|
|
118
126
|
sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider",
|
|
@@ -123,13 +131,20 @@ export function createRemoteHandlers(runtime) {
|
|
|
123
131
|
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
124
132
|
return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
|
|
125
133
|
},
|
|
134
|
+
// Easy-start C2: the consent's own post presents the session's key too (its form carries it, filled by the page's
|
|
135
|
+
// one nonce'd script while the key is required).
|
|
136
|
+
keyed: (request, form) => {
|
|
137
|
+
const who = identify(request, false);
|
|
138
|
+
return who !== null && who.via === "cookie" && runtime.browserKeys.check(who, request, form) === null;
|
|
139
|
+
},
|
|
140
|
+
keyFill: () => runtime.browserKeys.required(),
|
|
126
141
|
// The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
|
|
127
142
|
// grant (their role, their projects) is checked beside it.
|
|
128
143
|
// Proved on this request's live session at the moment of consent (signed in here still, same generation), through
|
|
129
144
|
// the one account step-up: a password typed now, or an explicit re-check (spent). Never a remembered password.
|
|
130
145
|
confirm: (request, session, typed) => {
|
|
131
146
|
const who = identify(request, false);
|
|
132
|
-
if (who === null || who.via !== "cookie" || who.name !== session.name || who.session.generation !== session.generation)
|
|
147
|
+
if (who === null || who.via !== "cookie" || who.authority !== "full" || who.name !== session.name || who.session.generation !== session.generation)
|
|
133
148
|
return false;
|
|
134
149
|
// Connecting an outside app is a credential act: never a session (Passwordless) or a remembered password.
|
|
135
150
|
return freshFacts.run(runtime.freshFactsOf(request, who), () => { const proved = proveAccountNow(store, who.name, typed, { credential: true }); return proved.ok && credentialProof(proved.method); });
|
|
@@ -8,6 +8,7 @@ import { html, joinHtml, postForm } from "../html.js";
|
|
|
8
8
|
import { projectName } from "../project.js";
|
|
9
9
|
import { screen } from "./chrome.js";
|
|
10
10
|
import { btn, composer, disclose, doc, docHeader, dock, field, ideas, input, note, phoneHeader, radios, select, sendButton, textarea, toggleChip } from "./kit.js";
|
|
11
|
+
import { TASK_TEXT_LIMITS } from "../task-text.js";
|
|
11
12
|
const FORM_ID = "nt-form";
|
|
12
13
|
const PLAN_ON = "The agent reads the code and asks you anything unclear, then writes the plan.";
|
|
13
14
|
const PLAN_OFF = "The agent writes the plan straight from your words. Faster for small, obvious changes.";
|
|
@@ -59,7 +60,10 @@ suggestions = []) {
|
|
|
59
60
|
value: text, rows: 4, maxRows: 10, before: bar, error: problem,
|
|
60
61
|
send: sendButton({ label: "File task" }),
|
|
61
62
|
note: html `<span class="k-on-note">${PLAN_ON}</span><span class="k-off-note">${PLAN_OFF}</span>`,
|
|
62
|
-
|
|
63
|
+
// The box holds what a title holds: a task's words are its title, and the planner reads nothing else of them when no
|
|
64
|
+
// goal is given, so longer words would be lost, not kept. Filing's whole-word cut (task-text.ts titleWithin) is a
|
|
65
|
+
// belt for other callers only.
|
|
66
|
+
textAttrs: { maxlength: TASK_TEXT_LIMITS.title, autofocus: true },
|
|
63
67
|
});
|
|
64
68
|
const options = html `<div class="k-form">\
|
|
65
69
|
${field({ id: "nt-quality", label: "Checks", control: select({ id: "nt-quality", name: "quality-mode", items: [["default", "Default checks"], ["strict", "Strict (release)"]], current: quality, wide: true }) })}\
|
|
@@ -341,7 +341,8 @@ export type OnboardCardState = {
|
|
|
341
341
|
why: string;
|
|
342
342
|
} | {
|
|
343
343
|
enabled: true;
|
|
344
|
-
roots: readonly string[];
|
|
344
|
+
roots: readonly string[]; /** Easy-start: the local owner's own session (no password to type; the prover's local-owner branch proves the clone). */
|
|
345
|
+
localOwner?: boolean;
|
|
345
346
|
record: [string, {
|
|
346
347
|
nameWithOwner: string;
|
|
347
348
|
rootIndex: number;
|
|
@@ -881,7 +881,7 @@ export function queueScript() {
|
|
|
881
881
|
`function problem(task,text){var card=region.querySelector('.queue-card[data-task="'+task.replace(/["\\\\]/g,"")+'"]');if(!card)return false;` +
|
|
882
882
|
`var old=card.querySelector(".queue-problem");if(old)old.remove();` +
|
|
883
883
|
`var row=document.createElement("p");row.className="problem queue-problem";row.setAttribute("role","alert");row.textContent=text;card.appendChild(row);return true;}` +
|
|
884
|
-
`fetch("/queue/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:post.toString(),redirect:"manual"})` +
|
|
884
|
+
`fetch("/queue/move",{method:"POST",headers:(window.soKeyHeaders||function(h){return h})({"Content-Type":"application/x-www-form-urlencoded"}),body:post.toString(),redirect:"manual"})` +
|
|
885
885
|
`.then(function(r){if(r.ok)return fetch("/queue?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(f){` +
|
|
886
886
|
`if(f.type==="opaqueredirect"||f.status===401||f.status===403){location.href="/login";return false;}return f.ok?f.text():null;});` +
|
|
887
887
|
`if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return false;}` +
|
|
@@ -27,10 +27,46 @@ export declare function ssoStepUps(page: Html, sso: {
|
|
|
27
27
|
label: string;
|
|
28
28
|
fresh: boolean;
|
|
29
29
|
}, returnTo: string): Html;
|
|
30
|
+
/**
|
|
31
|
+
* Easy start: for a local owner who has no password yet, every step-up password field (type=password with
|
|
32
|
+
* autocomplete="current-password", typed-only ones included) becomes "Set a password to do this." and the Set a
|
|
33
|
+
* password link, carrying this page on: there is no password to type. A new password, a secret or a key field is
|
|
34
|
+
* never one. The field's own label and hint go with it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function localOwnerStepUps(page: Html, setHref: string): Html;
|
|
30
37
|
export declare function loginPage(problem: string | null, returnTo?: string, sso?: {
|
|
31
38
|
label: string;
|
|
32
39
|
operatorsOnly: boolean;
|
|
33
40
|
} | null): Html;
|
|
41
|
+
/**
|
|
42
|
+
* Easy-start C2: reconnect this tab after a restart (or when it lost its key): the password again for a full session, an
|
|
43
|
+
* identity-provider sign-in for one that signs in there, or the terminal for a local owner (no password exists).
|
|
44
|
+
*/
|
|
45
|
+
export declare function reconnectPage(problem: string | null, returnTo: string, how: {
|
|
46
|
+
kind: "password";
|
|
47
|
+
name: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "sso";
|
|
50
|
+
label: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "command";
|
|
53
|
+
}): Html;
|
|
54
|
+
/** Which /password/set field a refusal is about (null: the page itself). */
|
|
55
|
+
export type PasswordSetProblem = {
|
|
56
|
+
field: "password" | "again" | null;
|
|
57
|
+
text: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Easy-start C4: the local owner's first password. Two fields and one button; the form carries the session's CSRF token
|
|
61
|
+
* (render it inside withFormToken) and, through the page's key fill, the browser key and the act that was refused, to continue there. Neither typed
|
|
62
|
+
* password ever comes back on a refusal.
|
|
63
|
+
*/
|
|
64
|
+
export declare function passwordSetPage(view: {
|
|
65
|
+
returnTo: string;
|
|
66
|
+
problem: PasswordSetProblem | null;
|
|
67
|
+
}): Html;
|
|
68
|
+
/** /password/set once a password exists (set earlier, or just now in another tab): sign in with it, and come back. */
|
|
69
|
+
export declare function passwordAlreadySetPage(title: string, returnTo: string, signedIn?: boolean): Html;
|
|
34
70
|
/**
|
|
35
71
|
* The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
|
|
36
72
|
* command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
|
|
@@ -54,6 +54,19 @@ export function ssoStepUps(page, sso, returnTo) {
|
|
|
54
54
|
const labelled = replaceMarkup(page, /<label>[^<]*<input\b([^>]*)>\s*<\/label>/g, (_whole, attributes) => stepUp(attributes) ? swap(attributes) : null);
|
|
55
55
|
return replaceMarkup(labelled, /<input\b([^>]*)>/g, (_whole, attributes) => stepUp(attributes) ? swap(attributes) : null);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Easy start: for a local owner who has no password yet, every step-up password field (type=password with
|
|
59
|
+
* autocomplete="current-password", typed-only ones included) becomes "Set a password to do this." and the Set a
|
|
60
|
+
* password link, carrying this page on: there is no password to type. A new password, a secret or a key field is
|
|
61
|
+
* never one. The field's own label and hint go with it.
|
|
62
|
+
*/
|
|
63
|
+
export function localOwnerStepUps(page, setHref) {
|
|
64
|
+
const stepUp = (attributes) => /\btype="password"/.test(attributes) && /\bautocomplete="current-password"/.test(attributes);
|
|
65
|
+
const instead = () => html `<p class="k-note" data-local-step-up>Set a password to do this. <a class="k-link" href="${setHref}">Set a password</a></p>`;
|
|
66
|
+
const fields = replaceMarkup(page, /<div class="k-field"><label class="k-label" for="[^"]*">[^<]*<\/label><input\b([^>]*)>(?:<p class="k-hint"[^>]*>[^<]*<\/p>)?(?:<span class="k-err"[^>]*>[^<]*<\/span>)?<\/div>/g, (_whole, attributes) => stepUp(attributes) ? instead() : null);
|
|
67
|
+
const labelled = replaceMarkup(fields, /<label\b[^>]*>[^<]*(?:<span\b[^>]*>[^<]*<\/span>)?[^<]*<input\b([^>]*)>\s*<\/label>/g, (_whole, attributes) => stepUp(attributes) ? instead() : null);
|
|
68
|
+
return replaceMarkup(labelled, /<input\b([^>]*)>/g, (_whole, attributes) => stepUp(attributes) ? instead() : null);
|
|
69
|
+
}
|
|
57
70
|
/** An expiry as a person reads it: "Thu 5 Nov, 10:42". */
|
|
58
71
|
function expiryWords(iso) {
|
|
59
72
|
const at = new Date(iso);
|
|
@@ -77,7 +90,58 @@ export function loginPage(problem, returnTo = "/", sso = null) {
|
|
|
77
90
|
]);
|
|
78
91
|
return shell("Sign in · Toolroll", outPage({
|
|
79
92
|
brand: BRAND_HTML, brandHref: "/login", title: "Sign in", body,
|
|
80
|
-
foot: html `${sso === null ? html `Forgot it? Your first login was shown when Toolroll started, and saved beside its database
|
|
93
|
+
foot: html `${sso === null ? html `Forgot it? Your first login was shown when Toolroll started, and saved beside its database in <code>.toolroll-secrets/up-login.txt</code> on the computer running it.<br>` : ""}No account? Ask whoever runs this Toolroll for an invite link.`,
|
|
94
|
+
}), { nav: false });
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Easy-start C2: reconnect this tab after a restart (or when it lost its key): the password again for a full session, an
|
|
98
|
+
* identity-provider sign-in for one that signs in there, or the terminal for a local owner (no password exists).
|
|
99
|
+
*/
|
|
100
|
+
export function reconnectPage(problem, returnTo, how) {
|
|
101
|
+
const back = returnTo === "/" ? null : returnTo;
|
|
102
|
+
const body = how.kind === "command"
|
|
103
|
+
? html `<p>Run <code>toolroll open</code> on this computer to reconnect.</p>`
|
|
104
|
+
: how.kind === "sso"
|
|
105
|
+
? btn(`Sign in with ${how.label}`, { variant: "primary", href: `/login/sso${back === null ? "" : `?return=${encodeURIComponent(back)}`}`, className: "k-wide" })
|
|
106
|
+
: postForm("/login/rekey", joinHtml([
|
|
107
|
+
outField({ id: "rekey-password", label: `Password for ${how.name}`, name: "token", type: "password", attrs: { autocomplete: "current-password", required: true, autofocus: true } }),
|
|
108
|
+
btn("Reconnect", { variant: "primary", type: "submit", className: "k-wide" }),
|
|
109
|
+
]), { signedOut: true, returnTo: back, attrs: { class: "k-form" } });
|
|
110
|
+
return shell("Reconnect · Toolroll", outPage({
|
|
111
|
+
brand: BRAND_HTML, brandHref: "/", title: "Reconnect",
|
|
112
|
+
body: joinHtml([problem === null ? "" : html `<div class="k-refusal">${notice(problem)}</div>`, body]),
|
|
113
|
+
foot: html `Toolroll restarted, so this tab needs a fresh connection before it can make changes.`,
|
|
114
|
+
}), { nav: false });
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Easy-start C4: the local owner's first password. Two fields and one button; the form carries the session's CSRF token
|
|
118
|
+
* (render it inside withFormToken) and, through the page's key fill, the browser key and the act that was refused, to continue there. Neither typed
|
|
119
|
+
* password ever comes back on a refusal.
|
|
120
|
+
*/
|
|
121
|
+
export function passwordSetPage(view) {
|
|
122
|
+
const problem = view.problem;
|
|
123
|
+
const error = (field) => problem !== null && problem.field === field ? problem.text : null;
|
|
124
|
+
const back = view.returnTo === "/" ? null : view.returnTo;
|
|
125
|
+
const form = postForm("/password/set", joinHtml([
|
|
126
|
+
outField({ id: "password-new", label: "Password", name: "password", type: "password", hint: `At least ${SIGNUP_PASSWORD_MIN} characters.`, error: error("password"),
|
|
127
|
+
attrs: { autocomplete: "new-password", required: true, minlength: SIGNUP_PASSWORD_MIN, ...(problem === null ? { autofocus: true } : {}) } }),
|
|
128
|
+
outField({ id: "password-again", label: "Type it again", name: "again", type: "password", error: error("again"),
|
|
129
|
+
attrs: { autocomplete: "new-password", required: true, minlength: SIGNUP_PASSWORD_MIN } }),
|
|
130
|
+
btn("Set password", { variant: "primary", type: "submit", className: "k-wide" }),
|
|
131
|
+
]), { hidden: { next: back }, attrs: { class: "k-form" } });
|
|
132
|
+
return shell("Set a password · Toolroll", outPage({
|
|
133
|
+
brand: BRAND_HTML, brandHref: "/", title: "Set a password",
|
|
134
|
+
sub: "Agents can work on their own, merge and release only after you set a password.",
|
|
135
|
+
body: joinHtml([problem !== null && problem.field === null ? html `<div class="k-refusal">${notice(problem.text)}</div>` : "", form,
|
|
136
|
+
html `<div class="k-acts">${btn("Not now", { variant: "ghost", href: view.returnTo, className: "k-wide" })}</div>`]),
|
|
137
|
+
}), { nav: false });
|
|
138
|
+
}
|
|
139
|
+
/** /password/set once a password exists (set earlier, or just now in another tab): sign in with it, and come back. */
|
|
140
|
+
export function passwordAlreadySetPage(title, returnTo, signedIn = false) {
|
|
141
|
+
return shell(`${title} · Toolroll`, outPage({
|
|
142
|
+
brand: BRAND_HTML, brandHref: "/", title, sub: signedIn ? "You're signed in with it." : "Sign in with it to continue.",
|
|
143
|
+
body: html `<div class="k-acts">${signedIn ? btn("Continue", { variant: "primary", href: returnTo, className: "k-wide" })
|
|
144
|
+
: btn("Sign in", { variant: "primary", href: returnTo === "/" ? "/login" : `/login?return=${encodeURIComponent(returnTo)}`, className: "k-wide" })}</div>`,
|
|
81
145
|
}), { nav: false });
|
|
82
146
|
}
|
|
83
147
|
/**
|
|
@@ -73,7 +73,9 @@ export function cloneReviewHtml(onboard, problem) {
|
|
|
73
73
|
return null;
|
|
74
74
|
const [nonce, record] = onboard.record;
|
|
75
75
|
const kind = "step-up";
|
|
76
|
-
|
|
76
|
+
// The local owner has no password to type: the clone is its own act (add-project), proved by the session at confirm.
|
|
77
|
+
const local = onboard.localOwner === true;
|
|
78
|
+
const factsNow = local ? { ...approvalFactsNow(), passwordless: true } : approvalFactsNow();
|
|
77
79
|
const size = record.diskUsageKib === null ? "Unknown" : `${Math.max(1, Math.round(record.diskUsageKib / 1024))} MiB`;
|
|
78
80
|
const bigOk = record.large
|
|
79
81
|
? html `<div class="k-checks"><label><input type="checkbox" name="big-ok" value="1" required>${record.nameWithOwner} is ${record.diskUsageKib === null ? "of unknown size" : size}. Clone it anyway</label></div>`
|
|
@@ -82,7 +84,7 @@ export function cloneReviewHtml(onboard, problem) {
|
|
|
82
84
|
status: html `<h2>Clone ${record.nameWithOwner}</h2>`,
|
|
83
85
|
text: html `Review before anything is downloaded. Clones into ${mono(shortPathOf(record.target))} and adds it as a project. Large-file (LFS) objects aren’t downloaded.`,
|
|
84
86
|
acts: approveForm({ action: "/projects/onboard-confirm", kind, facts: factsNow, label: "Clone and add", form: { hidden: { nonce } }, before: bigOk }),
|
|
85
|
-
terms: `${approvalTerms(kind, factsNow)} The review expires in 10 minutes.`,
|
|
87
|
+
terms: `${local ? "No password needed on this computer." : approvalTerms(kind, factsNow)} The review expires in 10 minutes.`,
|
|
86
88
|
err: problem === null ? null : refused(problem),
|
|
87
89
|
attrs: { "aria-label": `Clone ${record.nameWithOwner}` },
|
|
88
90
|
})}${facts([["Repository", mono(`github.com/${record.nameWithOwner}`)], ["Size", size], ["Into", mono(shortPathOf(record.target))]])}`;
|