toolroll 0.9.82 → 0.9.84
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 +41 -0
- package/README.md +4 -3
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +7 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +110 -0
- package/dist/folder-look.js +306 -0
- package/dist/guides.js +4 -3
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +53 -0
- package/dist/operate.js +277 -49
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-identity.d.ts +44 -0
- package/dist/project-identity.js +153 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +91 -17
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +5 -2
- package/dist/server/flows.js +13 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +9 -0
- package/dist/server/pages.js +102 -92
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-pages.d.ts +12 -16
- package/dist/server/render-pages.js +30 -69
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.js +7 -14
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +8 -1
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +211 -71
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/tasks.js +19 -5
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit's one script (docs/plans/settings-revamp.md §4), shipped as a page's functional script beside kit
|
|
3
|
+
* forms (settings-kit.ts). Without it every kit form works as today: the proof field shows inline and Save posts.
|
|
4
|
+
*
|
|
5
|
+
* With it, a kit form that asks for a proof:
|
|
6
|
+
* - moves its server-written proof field into the `<dialog>` inside the same form (the field never leaves the form);
|
|
7
|
+
* - opens the dialog on submit instead of posting, remembering the pressed button;
|
|
8
|
+
* - Confirm (its button, or Enter in the field) posts the form with that button (`requestSubmit(submitter)`, after the
|
|
9
|
+
* submit event that asked has finished), so its name=value and every hidden term go along unchanged; Cancel (or
|
|
10
|
+
* Escape) closes it and posts nothing, and focus returns to the button;
|
|
11
|
+
* - once a kit form posts, it is pending: further submits are dropped and its Save and Confirm are disabled (after the
|
|
12
|
+
* post has taken their values), until the page is shown again or the post was cancelled — never on a timer;
|
|
13
|
+
* - asks for nothing when the proof was left out (Passwordless) — there is no dialog then — and shows the
|
|
14
|
+
* identity-provider link when the sign-in swapped the field.
|
|
15
|
+
* Each section's Save bar names what changed. The script never reads or sets a field's value except to check that a
|
|
16
|
+
* required proof isn't empty; the server still proves everything.
|
|
17
|
+
*/
|
|
18
|
+
export const SETTINGS_KIT_SCRIPT = `(function(){
|
|
19
|
+
if(window.__soKit)return;window.__soKit=1;
|
|
20
|
+
var d=document;d.documentElement.classList.add("kit-js");
|
|
21
|
+
function label(el){var l=el.labels&&el.labels[0];if(!l&&el.closest)l=el.closest("fieldset")&&el.closest("fieldset").querySelector("legend");return l?(l.textContent||"").replace(/\\s+/g," ").trim():(el.name||"");}
|
|
22
|
+
function prep(f){if(f.__kit)return;f.__kit=1;
|
|
23
|
+
var bar=f.querySelector("[data-kit-state]");if(bar&&bar.textContent==="")bar.textContent=bar.getAttribute("data-idle")||"";
|
|
24
|
+
if(!f.hasAttribute("data-kit-step-up"))return;
|
|
25
|
+
var dl=f.querySelector("[data-kit-dialog]"),p=f.querySelector(":scope>[data-kit-proof]");
|
|
26
|
+
if(!dl||!p||typeof dl.showModal!=="function"){f.removeAttribute("data-kit-step-up");return;}
|
|
27
|
+
dl.querySelector("[data-kit-slot]").appendChild(p);
|
|
28
|
+
p.querySelectorAll("[required]").forEach(function(i){i.removeAttribute("required");i.setAttribute("data-kit-required","");});
|
|
29
|
+
dl.addEventListener("close",function(){if(f.__kitSent)return;var back=f.__kitBy||f.querySelector(".kit-save");if(back&&back.focus)back.focus();});}
|
|
30
|
+
function scan(root){if(root.querySelectorAll)root.querySelectorAll("form.kit-form").forEach(prep);}
|
|
31
|
+
scan(d);
|
|
32
|
+
if(typeof MutationObserver!=="undefined")new MutationObserver(function(list){list.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches("form.kit-form"))prep(n);scan(n);}});});}).observe(d.documentElement,{childList:true,subtree:true});
|
|
33
|
+
function asks(dl){var slot=dl.querySelector("[data-kit-slot]");return !!slot.querySelector("input:not([type=hidden]),a,.sso-step-up");}
|
|
34
|
+
function confirm(f){var dl=f.querySelector("[data-kit-dialog]"),miss=null;
|
|
35
|
+
dl.querySelectorAll("[data-kit-required]").forEach(function(i){if(!miss&&i.value==="")miss=i;});
|
|
36
|
+
if(miss){miss.setCustomValidity("Enter your password.");miss.reportValidity();miss.focus();return;}
|
|
37
|
+
var by=f.__kitBy&&f.__kitBy.form===f?f.__kitBy:null;f.__kitSent=1;dl.close();
|
|
38
|
+
setTimeout(function(){f.__kitGo=1;try{by?f.requestSubmit(by):f.requestSubmit();}finally{f.__kitGo=0;}},0);}
|
|
39
|
+
function buttons(f){return f.querySelectorAll(".kit-save,[data-kit-confirm]");}
|
|
40
|
+
function release(f){f.__kitPending=0;f.__kitSent=0;buttons(f).forEach(function(b){b.disabled=false;});}
|
|
41
|
+
function latch(f,e){f.__kitPending=1;setTimeout(function(){if(e.defaultPrevented){release(f);return;}
|
|
42
|
+
buttons(f).forEach(function(b){b.disabled=true;});},0);}
|
|
43
|
+
addEventListener("pageshow",function(){d.querySelectorAll("form.kit-form").forEach(release);});
|
|
44
|
+
d.addEventListener("submit",function(e){var f=e.target;if(!f||!f.classList||!f.classList.contains("kit-form"))return;prep(f);
|
|
45
|
+
if(f.__kitPending){e.preventDefault();e.stopImmediatePropagation();return;}
|
|
46
|
+
var dl=f.hasAttribute("data-kit-step-up")?f.querySelector("[data-kit-dialog]"):null;
|
|
47
|
+
if(f.__kitGo||!dl||!asks(dl)){latch(f,e);return;}
|
|
48
|
+
e.preventDefault();e.stopImmediatePropagation();
|
|
49
|
+
if(dl.open){confirm(f);return;}
|
|
50
|
+
f.__kitBy=e.submitter||null;f.__kitSent=0;dl.showModal();
|
|
51
|
+
var first=dl.querySelector("[data-kit-slot] input:not([type=hidden]),[data-kit-slot] a")||dl.querySelector("[data-kit-confirm]");if(first)first.focus();},true);
|
|
52
|
+
d.addEventListener("click",function(e){var b=e.target&&e.target.closest?e.target.closest("[data-kit-confirm],[data-kit-cancel]"):null;if(!b)return;
|
|
53
|
+
var dl=b.closest("[data-kit-dialog]"),f=dl&&dl.closest("form");if(!f)return;e.preventDefault();
|
|
54
|
+
if(b.hasAttribute("data-kit-cancel")){dl.close();return;}confirm(f);});
|
|
55
|
+
d.addEventListener("input",function(e){var t=e.target;if(t&&t.setCustomValidity&&t.hasAttribute&&t.hasAttribute("data-kit-required"))t.setCustomValidity("");},true);
|
|
56
|
+
function changed(e){var t=e.target,f=t&&t.form;if(!f||!f.classList.contains("kit-form")||t.closest("[data-kit-dialog]")||t.type==="hidden")return;prep(f);
|
|
57
|
+
var bar=f.querySelector("[data-kit-save-bar]"),state=f.querySelector("[data-kit-state]");if(!bar||!state)return;
|
|
58
|
+
f.__kitChanged=f.__kitChanged||[];var name=label(t);if(name&&f.__kitChanged.indexOf(name)<0)f.__kitChanged.push(name);
|
|
59
|
+
bar.setAttribute("data-changed","");state.textContent="Changed: "+f.__kitChanged.join(", ");}
|
|
60
|
+
d.addEventListener("input",changed);d.addEventListener("change",changed);
|
|
61
|
+
})();`;
|
|
62
|
+
/** The pages that wear the settings frame: one column, no Crew sheet, a breadcrumb under Settings. Here (not in
|
|
63
|
+
* settings-kit.ts) so the browser bundle reads it without the server's form contracts. */
|
|
64
|
+
export function isSettingsFrame(pathname) {
|
|
65
|
+
return pathname === "/settings" || pathname.startsWith("/settings/") || pathname === "/setup" || pathname.startsWith("/setup/")
|
|
66
|
+
|| pathname === "/people" || pathname === "/mode" || pathname === "/control" || pathname === "/spend";
|
|
67
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit (docs/plans/settings-revamp.md §4): the frame's breadcrumb, and the pieces every settings page is
|
|
3
|
+
* rebuilt from — a section, its own sticky Save, the step-up dialog drawn from the action's own proof contract
|
|
4
|
+
* (settings-proofs.ts), field errors, secret inputs and choice groups. Server-rendered HTML that works without
|
|
5
|
+
* JavaScript; settings-kit-script.ts only turns the inline proof field into a dialog.
|
|
6
|
+
*
|
|
7
|
+
* Rules the helpers keep (and settings-kit.test.ts pins):
|
|
8
|
+
* - the proof field is written into the form's own server HTML, named as its handler reads it, labelled, and
|
|
9
|
+
* `current-password`; so sensitivity (serve.ts sendScreen) and the identity-provider swap (ssoStepUps) still see it;
|
|
10
|
+
* - a step-up field renders through `reauth`, so Passwordless leaves it out exactly as today; a credential or typed
|
|
11
|
+
* password field always renders;
|
|
12
|
+
* - a CSRF-only action renders no proof field and no dialog;
|
|
13
|
+
* - nothing here ever sets `value` on a proof or secret field;
|
|
14
|
+
* - handlers never read any of this: it only draws.
|
|
15
|
+
*/
|
|
16
|
+
import { type Html, type HtmlValue } from "./html.js";
|
|
17
|
+
import { isSettingsFrame } from "./settings-kit-script.js";
|
|
18
|
+
import { type ProofRow } from "./settings-proofs.js";
|
|
19
|
+
export { isSettingsFrame };
|
|
20
|
+
/** One breadcrumb: a link, or (the last) the page itself. */
|
|
21
|
+
export type Crumb = {
|
|
22
|
+
label: string;
|
|
23
|
+
href: string | null;
|
|
24
|
+
};
|
|
25
|
+
/** The text of a page's first h1 (tags dropped, the common entities read), or null. */
|
|
26
|
+
export declare function headingText(page: string): string | null;
|
|
27
|
+
/** The breadcrumb for a settings-frame page: "Settings / <this page>", or nothing on the hub and outside the frame. */
|
|
28
|
+
export declare function settingsCrumbs(pathname: string, title: string, page: string): Crumb[] | undefined;
|
|
29
|
+
/** The project the sidebar's picker names: the session's saved selection while this person may still see it, else
|
|
30
|
+
* the page's own effective project, else none — checked against the visible project list, so a selection that
|
|
31
|
+
* outlived the ceiling is never sent to the page. */
|
|
32
|
+
export declare function openProjectFor(saved: string | null, effective: string | null | undefined, visible: readonly {
|
|
33
|
+
path: string;
|
|
34
|
+
}[]): string | null;
|
|
35
|
+
/** The breadcrumb as server HTML (the page before scripts, and without them). */
|
|
36
|
+
export declare function crumbsHtml(crumbs: readonly Crumb[]): Html;
|
|
37
|
+
/** The password-manager opt-outs every secret input carries (a service's key is not the person's sign-in). */
|
|
38
|
+
export declare const SECRET_OPT_OUTS: {
|
|
39
|
+
readonly "data-1p-ignore": true;
|
|
40
|
+
readonly "data-lpignore": "true";
|
|
41
|
+
readonly "data-bwignore": true;
|
|
42
|
+
readonly "data-form-type": "other";
|
|
43
|
+
};
|
|
44
|
+
/** A field's message under it: tied to the field with aria-describedby, announced, and the field focused. */
|
|
45
|
+
export declare function fieldError(id: string, message: string): Html;
|
|
46
|
+
export type TextField = {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
label: string;
|
|
50
|
+
type?: "text" | "number" | "url" | "email";
|
|
51
|
+
/** What the person typed (kept after a validation error). Never a secret: secrets use secretInput. */
|
|
52
|
+
value?: string | null;
|
|
53
|
+
hint?: string;
|
|
54
|
+
error?: string | null;
|
|
55
|
+
attrs?: Readonly<Record<string, string | number | boolean | null | undefined>>;
|
|
56
|
+
};
|
|
57
|
+
/** A labelled text field; with an error it is aria-invalid, names its message and takes focus. */
|
|
58
|
+
export declare function textField(field: TextField): Html;
|
|
59
|
+
/** A secret that is not the person's own password (an API key, a bot token, a client secret): masked, never given a
|
|
60
|
+
* value, kept out of password managers. The field name is the handler's own wire name. */
|
|
61
|
+
export declare function secretInput(field: {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
label: string;
|
|
65
|
+
hint?: string;
|
|
66
|
+
required?: boolean;
|
|
67
|
+
error?: string | null;
|
|
68
|
+
}): Html;
|
|
69
|
+
/** A choice group: a fieldset with its legend, each option's detail tied to it. Nothing saves until Save. */
|
|
70
|
+
export declare function choiceGroup(group: {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
legend: string;
|
|
74
|
+
checked: string | null;
|
|
75
|
+
options: readonly {
|
|
76
|
+
value: string;
|
|
77
|
+
label: string;
|
|
78
|
+
detail?: string;
|
|
79
|
+
}[];
|
|
80
|
+
error?: string | null;
|
|
81
|
+
}): Html;
|
|
82
|
+
/** Proofs that only a password typed now satisfies: no Passwordless session and no identity-provider re-check. */
|
|
83
|
+
export declare const typedOnly: (row: ProofRow) => boolean;
|
|
84
|
+
/** The proof field a row's handler reads, as today's forms write it; empty for a CSRF-only row. A step-up field goes
|
|
85
|
+
* through `reauth` (Passwordless leaves it out); a credential or typed field always shows. A typed-only field carries
|
|
86
|
+
* `data-so-typed`, so the identity-provider swap leaves it a real password field (keepTypedFields). Never a value. */
|
|
87
|
+
export declare function proofField(row: ProofRow, options?: {
|
|
88
|
+
label?: string;
|
|
89
|
+
}): Html;
|
|
90
|
+
/**
|
|
91
|
+
* The identity-provider swap (render-people.ts ssoStepUps) with typed-only proof fields kept: those handlers take only
|
|
92
|
+
* a password typed now, so the field must stay a real password input. The field is held out of the swap by its
|
|
93
|
+
* marker and put back as it was. Every other step-up field is swapped exactly as before.
|
|
94
|
+
*/
|
|
95
|
+
export declare function keepTypedFields<A extends unknown[]>(page: Html, swap: (page: Html, ...rest: A) => Html, ...rest: A): Html;
|
|
96
|
+
/** How the dialog explains a proof that Passwordless doesn't cover, in words; null where nothing needs saying. */
|
|
97
|
+
export declare function proofNote(row: ProofRow): string | null;
|
|
98
|
+
export type StepUpForm = {
|
|
99
|
+
/** The proof inventory row this form posts under. */
|
|
100
|
+
proof: string;
|
|
101
|
+
/** The exact route (one of the row's). */
|
|
102
|
+
action: string;
|
|
103
|
+
/** Unique on the page: the form, its dialog and its section hang from it. */
|
|
104
|
+
id: string;
|
|
105
|
+
/** The section's own fields. */
|
|
106
|
+
body: HtmlValue;
|
|
107
|
+
/** Hidden terms posted back unchanged (nonce, digest, fingerprint, the shown project…). */
|
|
108
|
+
hidden?: Readonly<Record<string, string | number | null | undefined>>;
|
|
109
|
+
/** The Save bar's words before anything changes, and its button. */
|
|
110
|
+
save?: {
|
|
111
|
+
label?: string;
|
|
112
|
+
idle?: string;
|
|
113
|
+
name?: string;
|
|
114
|
+
value?: string;
|
|
115
|
+
};
|
|
116
|
+
/** What the dialog says the person is agreeing to (the action's own words by default). */
|
|
117
|
+
confirm?: string;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* A section's form, posted to its existing route with its existing fields, and its sticky Save. When the row needs a
|
|
121
|
+
* proof, the proof field sits in the form's own HTML — inline (as today, which is how it works without JavaScript) and
|
|
122
|
+
* moved into a `<dialog>` inside the same form by settings-kit-script.ts, which opens it on submit, keeps the pressed
|
|
123
|
+
* button, and posts nothing on Cancel. The server still proves everything.
|
|
124
|
+
*/
|
|
125
|
+
export declare function stepUpForm(form: StepUpForm): Html;
|
|
126
|
+
/** A section: a heading, a one-line state, a problem the handler sent back (role=alert), then its form. */
|
|
127
|
+
export declare function kitSection(section: {
|
|
128
|
+
id: string;
|
|
129
|
+
title: string;
|
|
130
|
+
state?: HtmlValue;
|
|
131
|
+
problem?: string | null;
|
|
132
|
+
said?: string | null;
|
|
133
|
+
body: HtmlValue;
|
|
134
|
+
}): Html;
|
|
135
|
+
/** The kit's look: one 760px column, sections with no nested wells, the sticky Save, the dialog. The needs-you colour
|
|
136
|
+
* falls back to the warning token until S5 lands `--so-needs-you`. */
|
|
137
|
+
export declare const SETTINGS_KIT_CSS: string;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit (docs/plans/settings-revamp.md §4): the frame's breadcrumb, and the pieces every settings page is
|
|
3
|
+
* rebuilt from — a section, its own sticky Save, the step-up dialog drawn from the action's own proof contract
|
|
4
|
+
* (settings-proofs.ts), field errors, secret inputs and choice groups. Server-rendered HTML that works without
|
|
5
|
+
* JavaScript; settings-kit-script.ts only turns the inline proof field into a dialog.
|
|
6
|
+
*
|
|
7
|
+
* Rules the helpers keep (and settings-kit.test.ts pins):
|
|
8
|
+
* - the proof field is written into the form's own server HTML, named as its handler reads it, labelled, and
|
|
9
|
+
* `current-password`; so sensitivity (serve.ts sendScreen) and the identity-provider swap (ssoStepUps) still see it;
|
|
10
|
+
* - a step-up field renders through `reauth`, so Passwordless leaves it out exactly as today; a credential or typed
|
|
11
|
+
* password field always renders;
|
|
12
|
+
* - a CSRF-only action renders no proof field and no dialog;
|
|
13
|
+
* - nothing here ever sets `value` on a proof or secret field;
|
|
14
|
+
* - handlers never read any of this: it only draws.
|
|
15
|
+
*/
|
|
16
|
+
import { attributes, html, htmlString, joinHtml, postForm, reauth, replaceMarkup } from "./html.js";
|
|
17
|
+
import { isSettingsFrame } from "./settings-kit-script.js";
|
|
18
|
+
import { contractReads, PROOF_INVENTORY, proofRow } from "./settings-proofs.js";
|
|
19
|
+
export { isSettingsFrame };
|
|
20
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'", nbsp: " " };
|
|
21
|
+
/** The text of a page's first h1 (tags dropped, the common entities read), or null. */
|
|
22
|
+
export function headingText(page) {
|
|
23
|
+
const found = /<h1\b[^>]*>([\s\S]*?)<\/h1>/i.exec(page);
|
|
24
|
+
if (found === null)
|
|
25
|
+
return null;
|
|
26
|
+
const text = found[1].replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|#39|#x27|nbsp);/g, (_whole, name) => ENTITIES[name] ?? "").replace(/\s+/g, " ").trim();
|
|
27
|
+
return text === "" ? null : text.slice(0, 80);
|
|
28
|
+
}
|
|
29
|
+
/** The breadcrumb for a settings-frame page: "Settings / <this page>", or nothing on the hub and outside the frame. */
|
|
30
|
+
export function settingsCrumbs(pathname, title, page) {
|
|
31
|
+
if (!isSettingsFrame(pathname) || pathname === "/settings")
|
|
32
|
+
return undefined;
|
|
33
|
+
const name = headingText(page) ?? (title.charAt(0).toUpperCase() + title.slice(1));
|
|
34
|
+
return [{ label: "Settings", href: "/settings" }, { label: name, href: null }];
|
|
35
|
+
}
|
|
36
|
+
/** The project the sidebar's picker names: the session's saved selection while this person may still see it, else
|
|
37
|
+
* the page's own effective project, else none — checked against the visible project list, so a selection that
|
|
38
|
+
* outlived the ceiling is never sent to the page. */
|
|
39
|
+
export function openProjectFor(saved, effective, visible) {
|
|
40
|
+
return [saved, effective ?? null].find((path) => path !== null && visible.some(one => one.path === path)) ?? null;
|
|
41
|
+
}
|
|
42
|
+
/** The breadcrumb as server HTML (the page before scripts, and without them). */
|
|
43
|
+
export function crumbsHtml(crumbs) {
|
|
44
|
+
return html `<nav class="kit-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(one => one.href === null
|
|
45
|
+
? html `<li aria-current="page">${one.label}</li>`
|
|
46
|
+
: html `<li><a href="${one.href}">${one.label}</a></li>`)}</ol></nav>`;
|
|
47
|
+
}
|
|
48
|
+
// ---- fields --------------------------------------------------------------------------------------------------------
|
|
49
|
+
/** The password-manager opt-outs every secret input carries (a service's key is not the person's sign-in). */
|
|
50
|
+
export const SECRET_OPT_OUTS = { "data-1p-ignore": true, "data-lpignore": "true", "data-bwignore": true, "data-form-type": "other" };
|
|
51
|
+
const errorId = (id) => `${id}-error`;
|
|
52
|
+
/** A field's message under it: tied to the field with aria-describedby, announced, and the field focused. */
|
|
53
|
+
export function fieldError(id, message) {
|
|
54
|
+
return html `<p class="kit-field-error" id="${errorId(id)}" role="alert">${message}</p>`;
|
|
55
|
+
}
|
|
56
|
+
/** A labelled text field; with an error it is aria-invalid, names its message and takes focus. */
|
|
57
|
+
export function textField(field) {
|
|
58
|
+
const hint = field.hint === undefined ? null : `${field.id}-hint`;
|
|
59
|
+
const described = [hint, field.error ? errorId(field.id) : null].filter(Boolean).join(" ");
|
|
60
|
+
return html `<div class="kit-field"><label for="${field.id}">${field.label}</label>\
|
|
61
|
+
${field.hint === undefined ? "" : html `<p class="kit-hint" id="${hint}">${field.hint}</p>`}\
|
|
62
|
+
<input${attributes({ id: field.id, name: field.name, type: field.type ?? "text", value: field.value ?? null, ...(field.attrs ?? {}),
|
|
63
|
+
"aria-invalid": field.error ? "true" : null, "aria-describedby": described === "" ? null : described, autofocus: field.error ? true : null })}>\
|
|
64
|
+
${field.error ? fieldError(field.id, field.error) : ""}</div>`;
|
|
65
|
+
}
|
|
66
|
+
/** A secret that is not the person's own password (an API key, a bot token, a client secret): masked, never given a
|
|
67
|
+
* value, kept out of password managers. The field name is the handler's own wire name. */
|
|
68
|
+
export function secretInput(field) {
|
|
69
|
+
const described = [field.hint === undefined ? null : `${field.id}-hint`, field.error ? errorId(field.id) : null].filter(Boolean).join(" ");
|
|
70
|
+
return html `<div class="kit-field"><label for="${field.id}">${field.label}</label>\
|
|
71
|
+
${field.hint === undefined ? "" : html `<p class="kit-hint" id="${field.id}-hint">${field.hint}</p>`}\
|
|
72
|
+
<input${attributes({ id: field.id, name: field.name, type: "password", autocomplete: "off", spellcheck: "false", autocapitalize: "none", "data-so-secret": true, ...SECRET_OPT_OUTS,
|
|
73
|
+
required: field.required === true, "aria-invalid": field.error ? "true" : null, "aria-describedby": described === "" ? null : described, autofocus: field.error ? true : null })}>\
|
|
74
|
+
${field.error ? fieldError(field.id, field.error) : ""}</div>`;
|
|
75
|
+
}
|
|
76
|
+
/** A choice group: a fieldset with its legend, each option's detail tied to it. Nothing saves until Save. */
|
|
77
|
+
export function choiceGroup(group) {
|
|
78
|
+
return html `<fieldset class="kit-choices"${group.error ? html ` aria-describedby="${errorId(group.id)}"` : ""}><legend>${group.legend}</legend>\
|
|
79
|
+
${group.options.map((one, at) => {
|
|
80
|
+
const id = `${group.id}-${at}`;
|
|
81
|
+
return html `<label class="kit-choice" for="${id}"><input${attributes({ type: "radio", id, name: group.name, value: one.value, checked: one.value === group.checked, "aria-describedby": one.detail === undefined ? null : `${id}-detail` })}>\
|
|
82
|
+
<span>${one.label}</span>${one.detail === undefined ? "" : html `<span class="kit-choice-detail" id="${id}-detail">${one.detail}</span>`}</label>`;
|
|
83
|
+
})}${group.error ? fieldError(group.id, group.error) : ""}</fieldset>`;
|
|
84
|
+
}
|
|
85
|
+
// ---- proof ---------------------------------------------------------------------------------------------------------
|
|
86
|
+
/** Proofs that only a password typed now satisfies: no Passwordless session and no identity-provider re-check. */
|
|
87
|
+
export const typedOnly = (row) => row.proof === "typed-password" || row.proof === "typed-now-operator";
|
|
88
|
+
/** The proof field a row's handler reads, as today's forms write it; empty for a CSRF-only row. A step-up field goes
|
|
89
|
+
* through `reauth` (Passwordless leaves it out); a credential or typed field always shows. A typed-only field carries
|
|
90
|
+
* `data-so-typed`, so the identity-provider swap leaves it a real password field (keepTypedFields). Never a value. */
|
|
91
|
+
export function proofField(row, options = {}) {
|
|
92
|
+
if (row.field === null || row.proof === "none")
|
|
93
|
+
return html ``;
|
|
94
|
+
if (!contractReads(row, row.field))
|
|
95
|
+
throw new Error(`${row.id}: its form doesn't read ${row.field}`);
|
|
96
|
+
const label = options.label ?? "Your Toolroll password";
|
|
97
|
+
if (typedOnly(row))
|
|
98
|
+
return html `<label>${label}<input type="password" name="${row.field}" data-so-typed autocomplete="current-password" required></label>`;
|
|
99
|
+
const field = html `<label>${label}<input type="password" name="${row.field}" autocomplete="current-password" required></label>`;
|
|
100
|
+
return row.proof === "step-up" ? reauth(field) : field;
|
|
101
|
+
}
|
|
102
|
+
const TYPED_FIELD = /<input type="password" name="([a-z-]+)" data-so-typed autocomplete="current-password" required>/g;
|
|
103
|
+
const HELD_FIELD = /<input type="password" name="([a-z-]+)" data-so-typed data-so-typed-held required>/g;
|
|
104
|
+
/**
|
|
105
|
+
* The identity-provider swap (render-people.ts ssoStepUps) with typed-only proof fields kept: those handlers take only
|
|
106
|
+
* a password typed now, so the field must stay a real password input. The field is held out of the swap by its
|
|
107
|
+
* marker and put back as it was. Every other step-up field is swapped exactly as before.
|
|
108
|
+
*/
|
|
109
|
+
export function keepTypedFields(page, swap, ...rest) {
|
|
110
|
+
const held = replaceMarkup(page, TYPED_FIELD, (_whole, name) => html `<input type="password" name="${name}" data-so-typed data-so-typed-held required>`);
|
|
111
|
+
return replaceMarkup(swap(held, ...rest), HELD_FIELD, (_whole, name) => html `<input type="password" name="${name}" data-so-typed autocomplete="current-password" required>`);
|
|
112
|
+
}
|
|
113
|
+
/** The rows that share this row's route: one handler's variants. */
|
|
114
|
+
const variantsOf = (row) => PROOF_INVENTORY.filter(one => one !== row && one.routes.some(route => row.routes.includes(route)));
|
|
115
|
+
/** How the dialog explains a proof that Passwordless doesn't cover, in words; null where nothing needs saying. */
|
|
116
|
+
export function proofNote(row) {
|
|
117
|
+
if (row.proof === "credential")
|
|
118
|
+
return "This changes a way in, so it always takes your password.";
|
|
119
|
+
if (row.proof === "typed-password")
|
|
120
|
+
return "This always takes your password, typed.";
|
|
121
|
+
if (row.proof === "typed-now-operator")
|
|
122
|
+
return "Widening this takes an instance operator's password, typed now.";
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A section's form, posted to its existing route with its existing fields, and its sticky Save. When the row needs a
|
|
127
|
+
* proof, the proof field sits in the form's own HTML — inline (as today, which is how it works without JavaScript) and
|
|
128
|
+
* moved into a `<dialog>` inside the same form by settings-kit-script.ts, which opens it on submit, keeps the pressed
|
|
129
|
+
* button, and posts nothing on Cancel. The server still proves everything.
|
|
130
|
+
*/
|
|
131
|
+
export function stepUpForm(form) {
|
|
132
|
+
const row = proofRow(form.proof);
|
|
133
|
+
if (!row.routes.includes(form.action))
|
|
134
|
+
throw new Error(`${row.id} doesn't post to ${form.action}`);
|
|
135
|
+
const hidden = form.hidden ?? {};
|
|
136
|
+
for (const name of Object.keys(hidden))
|
|
137
|
+
if (row.field === name || row.secrets.includes(name))
|
|
138
|
+
throw new Error(`${row.id}: ${name} is never a hidden value`);
|
|
139
|
+
// The exact variant: its own selector must be in the hidden terms, and no other variant's may be.
|
|
140
|
+
if (row.select !== undefined && String(hidden[row.select.field] ?? "") !== row.select.value)
|
|
141
|
+
throw new Error(`${row.id} posts ${row.select.field}=${row.select.value}`);
|
|
142
|
+
for (const other of variantsOf(row))
|
|
143
|
+
if (other.select !== undefined && String(hidden[other.select.field] ?? "") === other.select.value)
|
|
144
|
+
throw new Error(`${form.action} with ${other.select.field}=${other.select.value} is ${other.id}, not ${row.id}`);
|
|
145
|
+
const proof = proofField(row);
|
|
146
|
+
const asks = htmlString(proof) !== "";
|
|
147
|
+
const note = proofNote(row);
|
|
148
|
+
const save = form.save ?? {};
|
|
149
|
+
const submit = html `<button${attributes({ type: "submit", class: "kit-save", name: save.name ?? null, value: save.value ?? null })}>${save.label ?? "Save"}</button>`;
|
|
150
|
+
const dialog = asks ? html `<dialog class="kit-dialog" id="${form.id}-dialog" aria-labelledby="${form.id}-dialog-title" data-kit-dialog>\
|
|
151
|
+
<h2 id="${form.id}-dialog-title">Confirm it's you</h2><p class="kit-dialog-terms">${form.confirm ?? row.action}${row.when === undefined ? "" : html ` <span class="kit-hint">(${row.when})</span>`}</p>\
|
|
152
|
+
${note === null ? "" : html `<p class="kit-hint">${note}</p>`}<div class="kit-dialog-slot" data-kit-slot></div>\
|
|
153
|
+
<div class="kit-dialog-actions"><button type="button" class="kit-cancel" data-kit-cancel>Cancel</button><button type="button" class="kit-confirm" data-kit-confirm>${save.label ?? "Save"}</button></div></dialog>` : "";
|
|
154
|
+
return postForm(form.action, joinHtml([
|
|
155
|
+
form.body,
|
|
156
|
+
asks ? html `<div class="kit-proof" data-kit-proof>${proof}</div>` : "",
|
|
157
|
+
html `<div class="kit-save-bar" data-kit-save-bar><span class="kit-save-state" aria-live="polite" data-kit-state data-idle="${save.idle ?? "No changes"}"></span>${submit}</div>`,
|
|
158
|
+
dialog,
|
|
159
|
+
]), { hidden, attrs: { id: form.id, class: "kit-form", "data-kit-form": row.id, "data-kit-step-up": asks ? "" : null } });
|
|
160
|
+
}
|
|
161
|
+
/** A section: a heading, a one-line state, a problem the handler sent back (role=alert), then its form. */
|
|
162
|
+
export function kitSection(section) {
|
|
163
|
+
return html `<section class="kit-section" id="${section.id}" aria-labelledby="${section.id}-title"><h2 id="${section.id}-title">${section.title}</h2>\
|
|
164
|
+
${section.state === undefined ? "" : html `<p class="kit-state">${section.state}</p>`}\
|
|
165
|
+
${section.problem ? html `<p class="kit-problem" role="alert">${section.problem}</p>` : ""}\
|
|
166
|
+
${section.said ? html `<p class="kit-said" role="status">${section.said}</p>` : ""}${section.body}</section>`;
|
|
167
|
+
}
|
|
168
|
+
/** The kit's look: one 760px column, sections with no nested wells, the sticky Save, the dialog. The needs-you colour
|
|
169
|
+
* falls back to the warning token until S5 lands `--so-needs-you`. */
|
|
170
|
+
export const SETTINGS_KIT_CSS = ":root{--so-settings-width:760px}" +
|
|
171
|
+
".kit-crumbs ol{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0;padding:0;list-style:none;font-size:14px;color:var(--so-muted)}" +
|
|
172
|
+
".kit-crumbs li+li::before{content:'/';margin-right:6px;color:var(--so-muted)}.kit-crumbs a{color:var(--so-muted);text-decoration:none}.kit-crumbs a:hover{color:var(--so-ink);text-decoration:underline}" +
|
|
173
|
+
".kit-crumbs [aria-current]{color:var(--so-ink);font-weight:600}" +
|
|
174
|
+
".kit-section{margin:0 0 32px}.kit-section>h2{margin:0 0 4px;font-size:16px}.kit-state{margin:0 0 12px;color:var(--so-muted)}" +
|
|
175
|
+
".kit-problem{margin:0 0 12px;padding:10px 12px;border:1px solid var(--so-danger);border-radius:8px;color:var(--so-danger)}.kit-said{margin:0 0 12px;color:var(--so-muted)}" +
|
|
176
|
+
".kit-field{margin:0 0 14px}.kit-hint{margin:2px 0 0;font-size:13px;color:var(--so-muted)}" +
|
|
177
|
+
".kit-field-error{margin:4px 0 0;font-size:13px;color:var(--so-danger)}.kit-field [aria-invalid=true]{border-color:var(--so-danger)}" +
|
|
178
|
+
".kit-choices{margin:0 0 14px;padding:0;border:0}.kit-choices legend{padding:0;margin:0 0 6px;font-weight:600}" +
|
|
179
|
+
".kit-choice{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:start;min-height:44px;padding:8px 0;margin:0}.kit-choice input{margin:3px 0 0}.kit-choice-detail{grid-column:2;font-size:13px;color:var(--so-muted)}" +
|
|
180
|
+
".kit-save-bar{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0 0;padding:10px 0;border-top:1px solid var(--so-line);background:var(--so-paper)}" +
|
|
181
|
+
".kit-save-state{color:var(--so-muted);font-size:14px}.kit-save-bar[data-changed] .kit-save-state{color:var(--so-ink)}" +
|
|
182
|
+
".kit-js .kit-form[data-kit-step-up]>.kit-proof{display:none}" +
|
|
183
|
+
".kit-dialog{max-width:min(440px,calc(100vw - 32px));padding:20px;border:1px solid var(--so-line);border-radius:12px;background:var(--so-paper);color:var(--so-ink)}" +
|
|
184
|
+
".kit-dialog::backdrop{background:rgb(0 0 0 / .35)}.kit-dialog h2{margin:0 0 6px;font-size:17px}.kit-dialog-slot .kit-proof{display:block}" +
|
|
185
|
+
".kit-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0}" +
|
|
186
|
+
"@media (max-width:900px){.kit-save-bar button,.kit-dialog-actions button,.kit-crumbs a{min-height:44px}.kit-crumbs a{display:inline-flex;align-items:center}}";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings proof inventory (docs/plans/settings-revamp.md §4): for every settings action, the proof its handler
|
|
3
|
+
* already asks for, as data. Only rendering code reads it (settings-kit.ts draws the step-up dialog from it); no
|
|
4
|
+
* handler does, and nothing here changes what a handler proves. `src/settings-kit.test.ts` pins every row against the
|
|
5
|
+
* form contracts (console-api.ts), the route table, and — for one row of each kind — the handler itself over HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Proof kinds, as the handlers implement them (scope.ts, server/guards.ts):
|
|
8
|
+
* - `none`: the session and the form token (CSRF) only.
|
|
9
|
+
* - `step-up`: `authenticateApprover` — a password typed now; an empty field stands in under Passwordless (the
|
|
10
|
+
* person's own live browser session) and for a one-use identity-provider re-check.
|
|
11
|
+
* - `credential`: `authenticateCredential` — a password typed now, or a one-use identity-provider re-check. Passwordless
|
|
12
|
+
* never covers it.
|
|
13
|
+
* - `typed-password`: the handler checks the typed password itself (`authenticateAccount`, or an empty field refused
|
|
14
|
+
* before the check). Neither Passwordless nor an identity-provider re-check stands in.
|
|
15
|
+
* - `typed-now-operator`: a password typed now by an instance operator (widening check access); not even a re-check.
|
|
16
|
+
*/
|
|
17
|
+
import { type ConsoleFormName } from "./contracts/console-api.js";
|
|
18
|
+
export type ProofKind = "none" | "step-up" | "credential" | "typed-password" | "typed-now-operator";
|
|
19
|
+
/** Who the route table admits (route-table.ts `role`); `handler` notes a narrower check the handler makes itself. */
|
|
20
|
+
export type ProofRole = "any" | "approver" | "operator";
|
|
21
|
+
export type ProofRow = {
|
|
22
|
+
/** Stable id the kit is called with. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** What the person is doing, in words. */
|
|
25
|
+
readonly action: string;
|
|
26
|
+
/** The exact POST paths. */
|
|
27
|
+
readonly routes: readonly string[];
|
|
28
|
+
/** The form contract its handler reads through. */
|
|
29
|
+
readonly form: ConsoleFormName;
|
|
30
|
+
/** The proof field's name, or null when none is read. */
|
|
31
|
+
readonly field: "password" | "token" | null;
|
|
32
|
+
readonly proof: ProofKind;
|
|
33
|
+
/** Only some submissions need the proof (the rest are `none`), in words. */
|
|
34
|
+
readonly when?: string;
|
|
35
|
+
/** Rows that share a route are variants of one handler, each with its own exact proof. A variant with `select`
|
|
36
|
+
* applies exactly when the form carries that hidden value (the kit refuses a form whose hidden terms pick another
|
|
37
|
+
* variant); a variant with `explicit` is named by the caller (a choice the server decides, like widening). */
|
|
38
|
+
readonly select?: {
|
|
39
|
+
readonly field: string;
|
|
40
|
+
readonly value: string;
|
|
41
|
+
};
|
|
42
|
+
readonly explicit?: true;
|
|
43
|
+
/** Under Passwordless, does an empty proof field pass? (`accepted` only for step-up.) */
|
|
44
|
+
readonly passwordless: "accepted" | "refused" | "n/a";
|
|
45
|
+
/** Does a one-use identity-provider re-check (empty field after "Confirm with …") pass? */
|
|
46
|
+
readonly sso: "accepted" | "refused" | "n/a";
|
|
47
|
+
readonly role: ProofRole;
|
|
48
|
+
/** A narrower standing the handler checks itself, in words. */
|
|
49
|
+
readonly handlerRole?: string;
|
|
50
|
+
/** Fields that hold a secret that is not the person's proof (never echoed, password managers kept out). */
|
|
51
|
+
readonly secrets: readonly string[];
|
|
52
|
+
/** Fields whose name reads like a credential but hold something else (an id, a bot token…). */
|
|
53
|
+
readonly notProof?: readonly string[];
|
|
54
|
+
/** Every term the handler requires besides the proof: hidden fields carried back unchanged (nonce, digest,
|
|
55
|
+
* fingerprint, the shown project, what a preview showed) and the fields that pick the target (repo, act). */
|
|
56
|
+
readonly terms: readonly string[];
|
|
57
|
+
/** Anything else the handler requires, in words. */
|
|
58
|
+
readonly note?: string;
|
|
59
|
+
};
|
|
60
|
+
export declare const PROOF_INVENTORY: readonly ProofRow[];
|
|
61
|
+
/** One row by id; throws on an unknown id (a rendering bug, never a request's input). */
|
|
62
|
+
export declare function proofRow(id: string): ProofRow;
|
|
63
|
+
/** Whether the field a row names is one its form contract reads (used by the kit's own guard and the tests). */
|
|
64
|
+
export declare function contractReads(row: ProofRow, field: string): boolean;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings proof inventory (docs/plans/settings-revamp.md §4): for every settings action, the proof its handler
|
|
3
|
+
* already asks for, as data. Only rendering code reads it (settings-kit.ts draws the step-up dialog from it); no
|
|
4
|
+
* handler does, and nothing here changes what a handler proves. `src/settings-kit.test.ts` pins every row against the
|
|
5
|
+
* form contracts (console-api.ts), the route table, and — for one row of each kind — the handler itself over HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Proof kinds, as the handlers implement them (scope.ts, server/guards.ts):
|
|
8
|
+
* - `none`: the session and the form token (CSRF) only.
|
|
9
|
+
* - `step-up`: `authenticateApprover` — a password typed now; an empty field stands in under Passwordless (the
|
|
10
|
+
* person's own live browser session) and for a one-use identity-provider re-check.
|
|
11
|
+
* - `credential`: `authenticateCredential` — a password typed now, or a one-use identity-provider re-check. Passwordless
|
|
12
|
+
* never covers it.
|
|
13
|
+
* - `typed-password`: the handler checks the typed password itself (`authenticateAccount`, or an empty field refused
|
|
14
|
+
* before the check). Neither Passwordless nor an identity-provider re-check stands in.
|
|
15
|
+
* - `typed-now-operator`: a password typed now by an instance operator (widening check access); not even a re-check.
|
|
16
|
+
*/
|
|
17
|
+
import { CONSOLE_FORMS } from "./contracts/console-api.js";
|
|
18
|
+
const ACCEPT = { passwordless: "accepted", sso: "accepted" };
|
|
19
|
+
const CREDENTIAL = { passwordless: "refused", sso: "accepted" };
|
|
20
|
+
const TYPED = { passwordless: "refused", sso: "refused" };
|
|
21
|
+
const NONE = { passwordless: "n/a", sso: "n/a" };
|
|
22
|
+
/** /mode/sign re-derives the digest from the posted terms, so every one rides back. */
|
|
23
|
+
const MODE_TERMS = ["nonce", "digest", "expiry", "name", "days", "publication", "auto-approve", "plan-auto", "chat-approve"];
|
|
24
|
+
const CHAT_APPS = [["slack", "Slack", "slack", ["app-token", "bot-token"]], ["teams", "Teams", "teams", ["secret"]], ["discord", "Discord", "discord", ["bot-token"]]];
|
|
25
|
+
export const PROOF_INVENTORY = [
|
|
26
|
+
// CSRF only.
|
|
27
|
+
{ id: "quality-default", action: "Set the default quality for new tasks", routes: ["/settings/quality-default"], form: "qualityDefault", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
28
|
+
{ id: "notifications", action: "Choose what reaches you", routes: ["/settings/notifications"], form: "notifications", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
29
|
+
{ id: "notifications-mute", action: "Mute a project", routes: ["/settings/notifications/mute"], form: "notificationsMute", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
30
|
+
{ id: "messaging", action: "Choose the chat app that receives alerts", routes: ["/settings/messaging"], form: "messaging", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
31
|
+
{ id: "telegram-digest", action: "Set the Telegram digest", routes: ["/settings/telegram-digest"], form: "telegramDigest", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
32
|
+
{ id: "provider-key", action: "Save or clear an AI provider key", routes: ["/settings/provider-key", "/settings/provider-key-clear"], form: "providerKey", field: null, proof: "none", ...NONE, role: "any", secrets: ["value"], terms: [] },
|
|
33
|
+
{ id: "telegram-token", action: "Save the Telegram bot token", routes: ["/settings/telegram-token"], form: "telegramToken", field: null, proof: "none", ...NONE, role: "any", secrets: ["token"], notProof: ["token"], terms: [] },
|
|
34
|
+
{ id: "email", action: "Set up or test email", routes: ["/settings/email", "/settings/email-test", "/settings/email-read-test"], form: "email", field: null, proof: "none", ...NONE, role: "any", secrets: ["password"], notProof: ["password"], terms: [], note: "`password` is the mail server's password; its wire name stays" },
|
|
35
|
+
{ id: "google", action: "Connect or disconnect Google", routes: ["/settings/google", "/settings/google/disconnect"], form: "google", field: null, proof: "none", ...NONE, role: "any", secrets: ["clientSecret"], terms: [] },
|
|
36
|
+
{ id: "appearance", action: "Change the theme or accent", routes: ["/settings/appearance"], form: "appearance", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
37
|
+
{ id: "chat-approval-confirm", action: "Review approving from chat", routes: ["/settings/chat-approval/confirm", "/settings/chat-approval/off"], form: "chatApproval", field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] },
|
|
38
|
+
...CHAT_APPS.map(([app, name, form]) => ({ id: `${app}-alerts`, action: `Send alerts to ${name}`, routes: [`/settings/${app}/alerts`], form, field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] })),
|
|
39
|
+
// Step-up: a password typed now, the Passwordless session, or a one-use re-check.
|
|
40
|
+
{ id: "permission-default", action: "Start new tasks with Full access", routes: ["/settings/permission-default"], form: "permissionDefault", field: "password", proof: "step-up", when: "only when widening to Full access", ...ACCEPT, role: "any", secrets: [], terms: [] },
|
|
41
|
+
{ id: "spend-budget", action: "Set a monthly limit", routes: ["/spend/budget"], form: "spendBudget", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
|
|
42
|
+
{ id: "models-agent", action: "Change which model does each job", routes: ["/settings/models/agent"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest"] },
|
|
43
|
+
{ id: "models-apply", action: "Apply a model preset or switch", routes: ["/settings/models/apply"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest", "change"], note: "plus the previewed preset, or switch and to" },
|
|
44
|
+
...[
|
|
45
|
+
["policy", "Change the policy", "policy", "operator"],
|
|
46
|
+
["approval", "Change approval rules", "approval", "operator"],
|
|
47
|
+
["request-limits", "Change request limits", "requestLimits", "operator"],
|
|
48
|
+
["retention", "Change retention", "retention", "operator"],
|
|
49
|
+
["backups", "Change backups", "backups", "operator"],
|
|
50
|
+
["data", "Download everything", "data", "operator"],
|
|
51
|
+
].map(([path, action, form, role]) => ({ id: path, action, routes: [`/settings/${path}`], form, field: "password", proof: "step-up", ...ACCEPT, role, secrets: [], terms: path === "approval" ? ["repo"] : [] })),
|
|
52
|
+
{ id: "pull-requests", action: "Change pull-request settings, or turn them off", routes: ["/settings/pull-requests"], form: "pullRequests", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "act"] },
|
|
53
|
+
{ id: "pull-requests-on", action: "Turn pull requests on", routes: ["/settings/pull-requests"], select: { field: "act", value: "on" }, form: "pullRequests", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "act", "github", "base"], note: "`github` and `base` are what the page showed; the handler checks again and refuses a difference" },
|
|
54
|
+
{ id: "checks", action: "Change checks", routes: ["/settings/checks"], form: "checks", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "act"] },
|
|
55
|
+
{ id: "monitoring", action: "Change monitoring", routes: ["/settings/monitoring"], form: "monitoring", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: ["header-value"], terms: [] },
|
|
56
|
+
{ id: "updates", action: "Update Toolroll", routes: ["/settings/updates", "/settings/updates/seen", "/settings/updates/cancel"], form: "updates", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
|
|
57
|
+
{ id: "storage-cleanup", action: "Choose when checkouts are removed", routes: ["/settings/storage"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
|
|
58
|
+
{ id: "storage-clean", action: "Clean up checkouts", routes: ["/settings/storage/clean"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["preview"], note: "`preview` is the digest of what the page showed; a changed list is refused" },
|
|
59
|
+
{ id: "storage-discard", action: "Discard a checkout", routes: ["/settings/storage/discard"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["path"] },
|
|
60
|
+
{ id: "project-live-view", action: "Show a project's commands in the live view", routes: ["/settings/project/live-view"], form: "projectLiveView", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce"] },
|
|
61
|
+
{ id: "project-delete", action: "Delete a project", routes: ["/settings/project/delete"], form: "projectDelete", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["repo", "step"], note: "the project's name typed too" },
|
|
62
|
+
{ id: "tools-connect", action: "Connect a tool", routes: ["/settings/tools/connect"], form: "toolsConnect", field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: ["repo", "shown"], note: "`shown` is the project the page showed (a post from another project is refused), not a digest" },
|
|
63
|
+
{ id: "tools-change", action: "Change a project's tools", routes: ["/settings/tools/change"], form: "toolsChange", field: "password", proof: "step-up", when: "only to add a tool (catalog, custom, import) or set a secret", ...ACCEPT, role: "approver", secrets: ["value"], terms: ["repo", "shown", "action"] },
|
|
64
|
+
...CHAT_APPS.map(([app, name, form]) => ({ id: `${app}-close`, action: `Unpair or disconnect ${name}`, routes: [`/settings/${app}/unpair`, `/settings/${app}/disconnect`], form, field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: [] })),
|
|
65
|
+
{ id: "telegram-unpair", action: "Unpair your phone from Telegram", routes: ["/settings/telegram/unpair"], form: "telegramPair", field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: [] },
|
|
66
|
+
{ id: "people-close", action: "Cancel an invite or remove a person", routes: ["/people/invite-revoke", "/people/revoke"], form: "peopleRevoke", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver", secrets: [], terms: [] },
|
|
67
|
+
{ id: "project-setup", action: "Approve project setup", routes: ["/control/setup-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project; models for a new installation need an instance operator", secrets: [], terms: ["repo", "nonce", "fingerprint", "provider", "model", "command", "seconds"], note: "the nonce is spent before the password is checked: after a refusal, review again; the reviewed inputs are previewed again and must match the fingerprint" },
|
|
68
|
+
{ id: "project-instructions", action: "Add the agent instructions", routes: ["/control/instructions-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce", "fingerprint"], note: "the nonce is spent before the password is checked" },
|
|
69
|
+
{ id: "mode-sign", action: "Sign an operating mode", routes: ["/mode/sign"], form: "mode", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
|
|
70
|
+
{ id: "mode-sign-chat-approve", action: "Sign an operating mode that lets your chat app approve", routes: ["/mode/sign"], select: { field: "chat-approve", value: "1" }, form: "mode", field: "token", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
|
|
71
|
+
// Credential acts: a password typed now or a one-use re-check; never Passwordless.
|
|
72
|
+
{ id: "passwordless-on", action: "Turn Passwordless on", routes: ["/settings/passwordless"], form: "passwordless", field: "password", proof: "credential", when: "only to turn it on (off needs nothing)", ...CREDENTIAL, role: "operator", secrets: [], terms: [] },
|
|
73
|
+
{ id: "sign-in", action: "Change sign-in (SSO)", routes: ["/settings/sign-in"], form: "signIn", field: "password", proof: "credential", when: "every action except Test", ...CREDENTIAL, role: "operator", secrets: ["client-secret"], terms: [] },
|
|
74
|
+
{ id: "release-approval", action: "Approve releases automatically", routes: ["/settings/release-approval"], form: "releaseApproval", field: "password", proof: "credential", when: "only to approve automatically (Ask me needs nothing)", ...CREDENTIAL, role: "approver", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
75
|
+
{ id: "project-network", action: "Change what builds may reach", routes: ["/settings/project/network"], form: "projectNetwork", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
76
|
+
{ id: "project-writes", action: "Change where builds may write", routes: ["/settings/project/writes"], form: "projectWrites", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
77
|
+
{ id: "project-sandbox", action: "Change a project's sandbox", routes: ["/settings/project/sandbox"], form: "projectSandbox", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
78
|
+
{ id: "project-check-access", action: "Narrow how far checks reach", routes: ["/settings/project/check-access"], form: "projectCheckAccess", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"], note: "for a form that can only narrow; one that offers a wider level uses project-check-access-widen" },
|
|
79
|
+
{ id: "project-check-access-widen", action: "Widen how far checks reach", routes: ["/settings/project/check-access"], explicit: true, form: "projectCheckAccess", field: "password", proof: "typed-now-operator", ...TYPED, role: "any", handlerRole: "an instance operator", secrets: [], terms: ["repo"], note: "the server decides whether a choice widens; a form offering any wider level asks for this proof" },
|
|
80
|
+
...CHAT_APPS.map(([app, name, form, secrets]) => ({ id: `${app}-open`, action: `Connect or pair ${name}`, routes: [`/settings/${app}/connect`, `/settings/${app}/pair`], form, field: "password", proof: "credential", ...CREDENTIAL, role: "approver", secrets, terms: [] })),
|
|
81
|
+
{ id: "sessions-token", action: "Make an API token", routes: ["/settings/sessions"], form: "sessions", field: "password", proof: "credential", when: "only to make a token (ending sessions and revoking tokens need nothing)", ...CREDENTIAL, role: "any", secrets: [], notProof: ["token"], terms: [], note: "`token` here names an API token to revoke" },
|
|
82
|
+
{ id: "people-invite", action: "Invite someone", routes: ["/people/invite"], form: "peopleInvite", field: "token", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver", secrets: [], terms: [] },
|
|
83
|
+
{ id: "people-projects", action: "Change a person's projects", routes: ["/people/projects"], form: "peopleProjects", field: "token", proof: "credential", ...CREDENTIAL, role: "operator", secrets: [], terms: ["name"] },
|
|
84
|
+
// A typed password, checked by the handler itself.
|
|
85
|
+
{ id: "telegram-pair", action: "Pair your phone with Telegram", routes: ["/settings/telegram/pair"], form: "telegramPair", field: "password", proof: "typed-password", ...TYPED, role: "approver", secrets: [], terms: [] },
|
|
86
|
+
{ id: "chat-approval-save", action: "Turn on approving from chat", routes: ["/settings/chat-approval/save"], form: "chatApproval", field: "token", proof: "typed-password", ...TYPED, role: "approver", secrets: [], terms: ["nonce", "digest", "scope", "full-access", "cap-usd"] },
|
|
87
|
+
{ id: "push-subscribe", action: "Get alerts on this device", routes: ["/push/subscribe"], form: "pushSubscribe", field: "token", proof: "typed-password", ...TYPED, role: "any", handlerRole: "an approver", secrets: [], terms: ["endpoint", "p256dh", "auth"] },
|
|
88
|
+
{ id: "lead-config", action: "Set up the lead's model and its own key", routes: ["/chat/config"], form: "chatConfig", field: "token", proof: "typed-password", ...TYPED, role: "any", handlerRole: "an approver", secrets: ["key"], terms: [] },
|
|
89
|
+
];
|
|
90
|
+
const BY_ID = new Map(PROOF_INVENTORY.map(row => [row.id, row]));
|
|
91
|
+
/** One row by id; throws on an unknown id (a rendering bug, never a request's input). */
|
|
92
|
+
export function proofRow(id) {
|
|
93
|
+
const row = BY_ID.get(id);
|
|
94
|
+
if (row === undefined)
|
|
95
|
+
throw new Error(`no proof inventory row ${id}`);
|
|
96
|
+
return row;
|
|
97
|
+
}
|
|
98
|
+
/** Whether the field a row names is one its form contract reads (used by the kit's own guard and the tests). */
|
|
99
|
+
export function contractReads(row, field) {
|
|
100
|
+
return CONSOLE_FORMS[row.form].fields.includes(field);
|
|
101
|
+
}
|
package/dist/setup-guide.js
CHANGED
|
@@ -15,8 +15,10 @@ export const ASSISTANTS = withAcp({
|
|
|
15
15
|
export function modelChoices(provider, configured, home = homedir()) {
|
|
16
16
|
// Aliases documented at https://code.claude.com/docs/en/model-config.
|
|
17
17
|
let choices = provider === "claude"
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Exact ids first: Sonnet 5.5 builds routine tasks under "Sized by job" (Settings → Models); the short names float.
|
|
19
|
+
? [{ value: "claude-sonnet-5-5", label: "Sonnet 5.5 — routine tasks" }, { value: "claude-opus-5-5", label: "Opus 5.5 — large or risky tasks" },
|
|
20
|
+
{ value: "claude-haiku-5-5", label: "Haiku 5.5 — small tasks" }, { value: "sonnet", label: "Sonnet (floating) — moves to the newest version" },
|
|
21
|
+
{ value: "opus", label: "Opus (floating) — moves to the newest version" }, { value: "haiku", label: "Haiku (floating) — moves to the newest version" }]
|
|
20
22
|
: [];
|
|
21
23
|
if (provider === "codex") {
|
|
22
24
|
// Use the local CLI's own catalog, including account-specific availability.
|