@zq-silk/yui 2.12.0 → 2.15.0
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/README.md +5 -0
- package/dist/config/configCatalog.js +1 -1
- package/dist/controlPlaneCli.js +1 -1
- package/dist/controller/runtime.js +2 -0
- package/dist/job/projectSync.js +3 -3
- package/dist/storage/sqliteSchema.js +12 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
- package/dist/web/assets/assetManifest.js +23 -1
- package/dist/web/assets/client/app/login.js +156 -0
- package/dist/web/assets/client/app/settings.js +4 -0
- package/dist/web/assets/client/i18n/en.js +64 -1
- package/dist/web/assets/client/i18n/zh-CN.js +64 -1
- package/dist/web/assets/client/index.js +6 -0
- package/dist/web/assets/client/lib/api.js +5 -0
- package/dist/web/assets/client/lib/session.js +21 -0
- package/dist/web/assets/client/views/dock/terminal.js +4 -1
- package/dist/web/assets/client/views/permissions.js +129 -0
- package/dist/web/assets/shared/icons.js +1 -0
- package/dist/web/assets/shared/manageNav.js +1 -0
- package/dist/web/assets/shell/login.js +56 -0
- package/dist/web/assets/shell/settingsContent.js +4 -0
- package/dist/web/assets/styles/index.js +2 -0
- package/dist/web/assets/styles/views/login.js +42 -0
- package/dist/web/assets/styles/views/settings.js +13 -0
- package/dist/web/webAccess.js +129 -9
- package/dist/web/webPermissions.js +97 -0
- package/dist/web/webServer.js +209 -15
- package/docs/authorization.md +35 -0
- package/docs/release-workflow.md +80 -0
- package/docs/release-workflow.zh-CN.md +60 -0
- package/docs/storage-baseline.md +7 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +77 -11
- package/docs/testing/verification-levels.zh-CN.md +63 -9
- package/i18n/README.zh-CN.md +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export const PERMISSIONS_SCRIPT = String.raw `
|
|
2
|
+
import { h } from "/assets/js/lib/dom.js";
|
|
3
|
+
import { requestJson } from "/assets/js/lib/api.js";
|
|
4
|
+
|
|
5
|
+
export function mountPermissions(root, t) {
|
|
6
|
+
let meta, generation = 0, timer;
|
|
7
|
+
let mode = "commands", role = "", category = "", q = "", cursor = "0", previous = [];
|
|
8
|
+
const label = r => r.name ? t("permissions.configured") + ": " + r.name : t("permissions.role." + r.id);
|
|
9
|
+
let status, results, prev, next, search, roleSelect, categorySelect;
|
|
10
|
+
function reset() { cursor = "0"; previous = []; load(); }
|
|
11
|
+
function controls() {
|
|
12
|
+
const modeSelect = h("select", { onchange: () => {
|
|
13
|
+
mode = modeSelect.value; roleSelect.disabled = categorySelect.disabled = mode !== "commands"; reset();
|
|
14
|
+
} }, ["commands", "capabilities"].map(id => h("option", { value: id }, t("permissions." + id))));
|
|
15
|
+
modeSelect.value = mode;
|
|
16
|
+
search = h("input", { type: "search", value: q, maxlength: 200, oninput: () => {
|
|
17
|
+
q = search.value; clearTimeout(timer);
|
|
18
|
+
// Invalidate any older result immediately, even during debounce.
|
|
19
|
+
generation++; prev.disabled = next.disabled = true;
|
|
20
|
+
timer = setTimeout(reset, 180);
|
|
21
|
+
} });
|
|
22
|
+
roleSelect = h("select", { onchange: () => { role = roleSelect.value; reset(); } },
|
|
23
|
+
h("option", { value: "" }, t("permissions.allRoles")),
|
|
24
|
+
meta.roles.map(r => h("option", { value: r.id }, label(r))));
|
|
25
|
+
roleSelect.value = role;
|
|
26
|
+
categorySelect = h("select", { onchange: () => { category = categorySelect.value; reset(); } },
|
|
27
|
+
h("option", { value: "" }, t("permissions.allCategories")),
|
|
28
|
+
meta.categories.map(c => h("option", { value: c }, c)));
|
|
29
|
+
categorySelect.value = category;
|
|
30
|
+
roleSelect.disabled = categorySelect.disabled = mode !== "commands";
|
|
31
|
+
status = h("p", { role: "status", "aria-live": "polite" });
|
|
32
|
+
results = h("div.permissions-results");
|
|
33
|
+
prev = h("button.btn", { type: "button", disabled: true, onclick: () => { cursor = previous.pop(); load(); } }, t("permissions.previous"));
|
|
34
|
+
next = h("button.btn", { type: "button", disabled: true }, t("permissions.next"));
|
|
35
|
+
const field = (key, input) => h("label.field", null, h("span", null, t("permissions." + key)), input);
|
|
36
|
+
root.replaceChildren(
|
|
37
|
+
h("div.card.settings-card.settings-stack", null,
|
|
38
|
+
h("p", null, t("permissions.notice")),
|
|
39
|
+
h("div.settings-grid", null, field("view", modeSelect), field("search", search),
|
|
40
|
+
field("role", roleSelect), field("category", categorySelect))),
|
|
41
|
+
status, results, h("div.settings-actions", null, prev, next),
|
|
42
|
+
h("details.settings-card", null, h("summary", null, t("permissions.legend")),
|
|
43
|
+
h("p", null, t("permissions.states")),
|
|
44
|
+
h("p", null, t("permissions.boundaries")),
|
|
45
|
+
h("p", null, t("permissions.sources") + ": " + meta.sources.join(" · "))));
|
|
46
|
+
}
|
|
47
|
+
function detailContent(data) {
|
|
48
|
+
return h("div.settings-stack", null,
|
|
49
|
+
h("p", null, t("permissions.unevaluated")),
|
|
50
|
+
h("p", null, t("permissions.boundaries")),
|
|
51
|
+
data.roles.map(r => h("div", null,
|
|
52
|
+
h("h3", null, label(meta.roles.find(item => item.id === r.role))),
|
|
53
|
+
h("p", null, t("permissions.reason." + (r.visible ? "visible" : "hidden"))),
|
|
54
|
+
h("p", null, t("permissions.boundary." + r.audience)),
|
|
55
|
+
r.usage.length ? h("pre", null, r.usage.join("\n")) : null)),
|
|
56
|
+
h("small", null, t("permissions.sources") + ": " + data.source));
|
|
57
|
+
}
|
|
58
|
+
function commandTable(page, version) {
|
|
59
|
+
const shown = role ? meta.roles.filter(r => r.id === role) : meta.roles.filter(r => !r.name);
|
|
60
|
+
const body = h("tbody");
|
|
61
|
+
page.items.forEach((item, index) => {
|
|
62
|
+
const detail = h("td", { colSpan: shown.length + 1 });
|
|
63
|
+
const detailRow = h("tr", { hidden: true, id: "permission-detail-" + version + "-" + index }, detail);
|
|
64
|
+
let loaded = false;
|
|
65
|
+
const button = h("button.permissions-command", { type: "button", "aria-expanded": "false",
|
|
66
|
+
"aria-controls": detailRow.id, onclick: async () => {
|
|
67
|
+
detailRow.hidden = !detailRow.hidden;
|
|
68
|
+
button.setAttribute("aria-expanded", String(!detailRow.hidden));
|
|
69
|
+
if (detailRow.hidden || loaded) return;
|
|
70
|
+
loaded = true; detail.textContent = t("permissions.loading");
|
|
71
|
+
try {
|
|
72
|
+
const params = new URLSearchParams({ id: item.id, ...(role ? { role } : {}) });
|
|
73
|
+
const data = await requestJson("/api/permissions/command?" + params);
|
|
74
|
+
if (version !== generation) return;
|
|
75
|
+
detail.replaceChildren(detailContent(data));
|
|
76
|
+
} catch (error) { loaded = false; detail.textContent = error.message; }
|
|
77
|
+
} }, item.id);
|
|
78
|
+
body.append(h("tr", null, h("th", { scope: "row" }, button, h("small", null, item.summary)),
|
|
79
|
+
item.roles.map(r => h("td", null, h("span", { class: r.visible ? "permissions-visible" : "permissions-hidden" },
|
|
80
|
+
t("permissions." + (r.visible ? "visible" : "hidden")))))), detailRow);
|
|
81
|
+
});
|
|
82
|
+
return h("div.permissions-scroll", { tabIndex: 0, role: "region", "aria-label": t("permissions.commands") },
|
|
83
|
+
h("table.permissions-table", null,
|
|
84
|
+
h("caption", null, t("permissions.tableHint")),
|
|
85
|
+
h("thead", null, h("tr", null, h("th", { scope: "col" }, t("permissions.command")),
|
|
86
|
+
shown.map(r => h("th", { scope: "col" }, label(r))))), body));
|
|
87
|
+
}
|
|
88
|
+
async function load() {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
const version = ++generation;
|
|
91
|
+
status.textContent = t("permissions.loading");
|
|
92
|
+
results.replaceChildren(); prev.disabled = next.disabled = true;
|
|
93
|
+
const params = new URLSearchParams({ q, cursor, ...(mode === "commands" ? { role, category } : {}) });
|
|
94
|
+
try {
|
|
95
|
+
const page = await requestJson("/api/permissions/" + mode + "?" + params);
|
|
96
|
+
if (version !== generation) return;
|
|
97
|
+
status.textContent = page.items.length ? (Number(cursor) + 1) + "–" + (Number(cursor) + page.items.length) + " / " + page.total : t("permissions.empty");
|
|
98
|
+
if (mode === "commands") results.append(commandTable(page, version));
|
|
99
|
+
else results.append(h("p", null, t("permissions.capabilityNotice")), ...page.items.map(item =>
|
|
100
|
+
h("details.settings-card", null, h("summary", null, item.name),
|
|
101
|
+
h("p", null, item.summary), h("p", null, t("permissions.unevaluated")),
|
|
102
|
+
h("p", null, t("permissions.effect") + ": " + item.effect),
|
|
103
|
+
h("p", null, t("permissions.requiredPermissions") + ": " + (item.requiredPermissions.join(", ") || "—")),
|
|
104
|
+
h("p", null, t("permissions.requiredInputs") + ": " + (item.requiredInputs.join(", ") || "—")),
|
|
105
|
+
h("p", null, t("permissions.inputs") + ": " + (item.inputs.join(", ") || "—")),
|
|
106
|
+
h("small", null, t("permissions.sources") + ": " + page.source + " → " + item.source))));
|
|
107
|
+
prev.disabled = previous.length === 0;
|
|
108
|
+
next.disabled = page.nextCursor === null;
|
|
109
|
+
next.onclick = () => { previous.push(cursor); cursor = page.nextCursor; load(); };
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (version !== generation) return;
|
|
112
|
+
status.textContent = error.message;
|
|
113
|
+
results.append(h("button.btn", { type: "button", onclick: load }, t("permissions.retry")));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function init() {
|
|
117
|
+
root.replaceChildren(h("p", { role: "status" }, t("permissions.loading")));
|
|
118
|
+
try {
|
|
119
|
+
meta = await requestJson("/api/permissions");
|
|
120
|
+
controls(); load();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
root.replaceChildren(h("p", { role: "status" }, error.message),
|
|
123
|
+
h("button.btn", { type: "button", onclick: init }, t("permissions.retry")));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
init();
|
|
127
|
+
return { translate() { if (meta) { controls(); load(); } } };
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
@@ -32,6 +32,7 @@ export const ICON_PATHS = Object.freeze({
|
|
|
32
32
|
dots: ["M5 12h.01", "M12 12h.01", "M19 12h.01"],
|
|
33
33
|
inbox: ["M4 13l2.5-8h11L20 13", "M4 13v6h16v-6", "M4 13h5l1 2h4l1-2h5"],
|
|
34
34
|
user: ["M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M4 20a8 8 0 0 1 16 0"],
|
|
35
|
+
eyeOff: ["M3 3l18 18", "M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3 3.9", "M6.6 6.6C3.9 8.3 2 12 2 12s3.6 7 10 7a9.6 9.6 0 0 0 5.4-1.6", "M9.9 9.9a3 3 0 0 0 4.2 4.2"],
|
|
35
36
|
eye: ["M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
|
|
36
37
|
plus: ["M12 5v14", "M5 12h14"],
|
|
37
38
|
play: ["M8 5v14l11-7z"],
|
|
@@ -8,6 +8,7 @@ export const MANAGE_SECTIONS = Object.freeze({
|
|
|
8
8
|
{ page: "settings", section: "preferences", key: "settings.section.preferences", label: "Preferences" },
|
|
9
9
|
{ page: "settings", section: "skills", key: "settings.section.skills", label: "Skills" },
|
|
10
10
|
{ page: "settings", section: "jobs", key: "settings.section.jobs", label: "Jobs & schedules" },
|
|
11
|
+
{ page: "settings", section: "permissions", key: "settings.section.permissions", label: "Command permissions" },
|
|
11
12
|
{ page: "settings", section: "server", key: "settings.section.server", label: "Agents & server" },
|
|
12
13
|
{ page: "models", section: "connection", key: "models.section.connection", label: "Model connection" }
|
|
13
14
|
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DEFAULT_DARK_THEME } from "../shared/themes.js";
|
|
2
|
+
import { iconSvg } from "../shared/icons.js";
|
|
3
|
+
// The only document served without a session. It carries no page token and
|
|
4
|
+
// no Task data; the client module posts the password as same-origin JSON and
|
|
5
|
+
// the server answers with a session cookie. CSP form-action 'none' means the
|
|
6
|
+
// form can never submit natively (so a password cannot end up in a URL).
|
|
7
|
+
const SHEETS = ["fonts", "tokens", "base", "ui/controls", "ui/forms", "views/login"];
|
|
8
|
+
export const LOGIN_HTML = `<!doctype html>
|
|
9
|
+
<html lang="en" data-theme="${DEFAULT_DARK_THEME.id}">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
13
|
+
<meta name="color-scheme" content="dark light">
|
|
14
|
+
<script src="/assets/theme.js"></script>
|
|
15
|
+
<link rel="icon" href="data:,">
|
|
16
|
+
<title>Sign in · Yui</title>
|
|
17
|
+
<link rel="preload" href="/assets/fonts/inter-500.woff2" as="font" type="font/woff2" crossorigin>
|
|
18
|
+
<link rel="preload" href="/assets/fonts/inter-600.woff2" as="font" type="font/woff2" crossorigin>
|
|
19
|
+
${SHEETS.map(name => ` <link rel="stylesheet" href="/assets/css/${name}.css">`).join("\n")}
|
|
20
|
+
</head>
|
|
21
|
+
<body class="login-body">
|
|
22
|
+
<main class="login-shell">
|
|
23
|
+
<div class="login-top">
|
|
24
|
+
<label class="login-locale"><span class="sr-only" data-i18n="settings.language">Language</span><select id="locale-select"><option value="en">English</option><option value="zh-CN">简体中文</option></select></label>
|
|
25
|
+
</div>
|
|
26
|
+
<section class="login-card" aria-labelledby="login-title">
|
|
27
|
+
<div class="login-brand">
|
|
28
|
+
<span class="brand-mark" aria-hidden="true">結</span>
|
|
29
|
+
<span class="brand-name">Yui</span>
|
|
30
|
+
</div>
|
|
31
|
+
<header class="login-heading">
|
|
32
|
+
<h1 id="login-title" data-i18n="login.heading">Sign in to Yui</h1>
|
|
33
|
+
<p id="login-subtitle" class="login-sub" data-i18n="login.subtitle">Enter the Web access password from this listener's private credential file.</p>
|
|
34
|
+
</header>
|
|
35
|
+
<p id="login-notice" class="login-notice" role="status" hidden></p>
|
|
36
|
+
<form id="login-form" class="login-form" method="post" novalidate aria-describedby="login-subtitle">
|
|
37
|
+
<input class="sr-only" type="text" name="username" value="yui" autocomplete="username" tabindex="-1" aria-hidden="true" readonly>
|
|
38
|
+
<div class="login-field">
|
|
39
|
+
<label class="login-label" for="login-password" data-i18n="login.password">Password</label>
|
|
40
|
+
<div class="login-password">
|
|
41
|
+
<input id="login-password" name="password" type="password" autocomplete="current-password" required maxlength="256" spellcheck="false" autocapitalize="off" autocorrect="off" aria-describedby="login-error" aria-invalid="false" autofocus>
|
|
42
|
+
<button id="login-reveal" class="icon-btn login-reveal" type="button" aria-controls="login-password" aria-pressed="false" aria-label="Show password" data-i18n-aria-label="login.show" title="Show password" data-i18n-title="login.show">${iconSvg("eye", "icon icon-show")}${iconSvg("eyeOff", "icon icon-hide")}</button>
|
|
43
|
+
</div>
|
|
44
|
+
<p id="login-error" class="login-error" role="alert" hidden></p>
|
|
45
|
+
</div>
|
|
46
|
+
<button id="login-submit" class="btn btn-primary login-submit" type="submit" disabled>
|
|
47
|
+
<span class="login-spinner" aria-hidden="true"></span><span id="login-submit-label" data-i18n="login.submit">Sign in</span>
|
|
48
|
+
</button>
|
|
49
|
+
</form>
|
|
50
|
+
<p id="login-transport" class="login-transport" hidden>${iconSvg("alert")}<span data-i18n="login.httpWarning">This connection uses plain HTTP and is not encrypted. Sign in only on a trusted network.</span></p>
|
|
51
|
+
<noscript><p class="login-error" data-i18n="login.noscript">JavaScript is required to sign in.</p></noscript>
|
|
52
|
+
</section>
|
|
53
|
+
</main>
|
|
54
|
+
<script type="module" src="/assets/js/app/login.js"></script>
|
|
55
|
+
</body>
|
|
56
|
+
</html>`;
|
|
@@ -30,4 +30,8 @@ export const SETTINGS_MAIN = `
|
|
|
30
30
|
<label class="field settings-search"><span data-i18n="settings.search">Find a setting</span><input id="settings-search" type="search" autocomplete="off"></label>
|
|
31
31
|
<p id="settings-status" role="status"></p>
|
|
32
32
|
<div id="settings-groups" class="settings-stack"></div>
|
|
33
|
+
</section>
|
|
34
|
+
<section class="manage-section" data-section="permissions" aria-labelledby="settings-permissions-title" hidden>
|
|
35
|
+
${manageHead("settings-permissions-title", "settings.section.permissions", "Command permissions", "permissions.intro", "Read-only discovery templates, not authorization for a specific call. No rules or Grants are changed.")}
|
|
36
|
+
<section id="permission-browser" class="settings-stack"></section>
|
|
33
37
|
</section>`;
|
|
@@ -19,6 +19,7 @@ import { MANAGE_STYLES } from "./views/manage.js";
|
|
|
19
19
|
import { SETTINGS_STYLES } from "./views/settings.js";
|
|
20
20
|
import { MODELS_STYLES } from "./views/models.js";
|
|
21
21
|
import { JOBS_STYLES } from "./views/jobs.js";
|
|
22
|
+
import { LOGIN_STYLES } from "./views/login.js";
|
|
22
23
|
// The stylesheet registry, in cascade order: foundations (fonts, tokens,
|
|
23
24
|
// element defaults), the workspace frame, shared UI primitives, then one
|
|
24
25
|
// sheet per view, rendered prose, and breakpoints last so they override
|
|
@@ -44,6 +45,7 @@ export const STYLESHEETS = Object.freeze([
|
|
|
44
45
|
{ name: "views/settings", body: SETTINGS_STYLES },
|
|
45
46
|
{ name: "views/models", body: MODELS_STYLES },
|
|
46
47
|
{ name: "views/jobs", body: JOBS_STYLES },
|
|
48
|
+
{ name: "views/login", body: LOGIN_STYLES },
|
|
47
49
|
{ name: "markdown", body: MARKDOWN_STYLES },
|
|
48
50
|
{ name: "layout/responsive", body: RESPONSIVE_STYLES }
|
|
49
51
|
]);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* LOGIN VIEW — the standalone sign-in document: one centred card on the
|
|
3
|
+
* canvas with the workbench brand, a password field (with reveal toggle),
|
|
4
|
+
* inline error/notice states and the plain-HTTP reminder. Colour comes only
|
|
5
|
+
* from theme tokens; the card collapses to the full width on narrow screens.
|
|
6
|
+
*/
|
|
7
|
+
export const LOGIN_STYLES = `
|
|
8
|
+
.login-body{height:auto;min-height:100vh;min-height:100dvh;overflow:auto}
|
|
9
|
+
.login-shell{position:relative;display:grid;place-items:center;min-height:100vh;min-height:100dvh;padding:72px 16px 48px;background:radial-gradient(120% 70% at 50% -10%,var(--accent-soft),transparent 60%),var(--canvas)}
|
|
10
|
+
.login-top{position:absolute;top:16px;right:16px}
|
|
11
|
+
.login-locale select{height:30px;width:auto;max-width:132px;padding:0 26px 0 9px;line-height:28px;font-size:var(--fs-xs);background-color:var(--surface)}
|
|
12
|
+
.login-card{display:grid;gap:20px;width:min(400px,100%);padding:32px 28px 28px;border:1px solid var(--line-2);border-radius:var(--r-lg);background:var(--surface);box-shadow:var(--shadow-2)}
|
|
13
|
+
.login-brand{display:flex;align-items:center;gap:10px}
|
|
14
|
+
.login-brand .brand-mark{display:grid;place-items:center;width:32px;height:32px;flex:none;border-radius:9px;background:var(--accent);color:var(--accent-ink);font-size:17px;font-weight:700;line-height:1}
|
|
15
|
+
.login-brand .brand-name{font-size:var(--fs-lg);font-weight:650;letter-spacing:-.02em}
|
|
16
|
+
.login-heading{display:grid;gap:6px}
|
|
17
|
+
.login-heading h1{font-size:var(--fs-2xl)}
|
|
18
|
+
.login-sub{color:var(--ink-3);font-size:var(--fs-sm);overflow-wrap:anywhere}
|
|
19
|
+
.login-notice{margin:0;padding:10px 12px;border:1px solid var(--line-2);border-left:3px solid var(--tone,var(--info));border-radius:var(--r-sm);background:var(--tone-soft,var(--info-soft));color:var(--ink);font-size:var(--fs-sm)}
|
|
20
|
+
.login-form{display:grid;gap:16px}
|
|
21
|
+
.login-field{display:grid;gap:6px;min-width:0}
|
|
22
|
+
.login-label{color:var(--ink-2);font-size:var(--fs-xs);font-weight:500}
|
|
23
|
+
.login-password{position:relative}
|
|
24
|
+
.login-password input{height:40px;padding-right:44px}
|
|
25
|
+
.login-password input[aria-invalid="true"]{border-color:var(--bad)}
|
|
26
|
+
.login-password input[aria-invalid="true"]:focus{box-shadow:0 0 0 3px var(--bad-soft)}
|
|
27
|
+
.login-reveal{position:absolute;top:4px;right:4px}
|
|
28
|
+
.login-reveal .icon-hide,.login-reveal[aria-pressed="true"] .icon-show{display:none}
|
|
29
|
+
.login-reveal[aria-pressed="true"] .icon-hide{display:block}
|
|
30
|
+
.login-error{margin:0;color:var(--bad);font-size:var(--fs-sm);overflow-wrap:anywhere}
|
|
31
|
+
.login-submit{width:100%;height:40px;font-size:var(--fs-md)}
|
|
32
|
+
.login-spinner{display:none;width:14px;height:14px;flex:none;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:login-spin .8s linear infinite}
|
|
33
|
+
.login-form[aria-busy="true"] .login-spinner{display:inline-block}
|
|
34
|
+
@keyframes login-spin{to{transform:rotate(360deg)}}
|
|
35
|
+
.login-transport{display:flex;align-items:flex-start;gap:8px;margin:0;padding-top:16px;border-top:1px solid var(--line);color:var(--ink-3);font-size:var(--fs-xs)}
|
|
36
|
+
.login-transport .icon{width:14px;height:14px;margin-top:2px;color:var(--warn)}
|
|
37
|
+
@media(max-width:480px){
|
|
38
|
+
.login-shell{place-items:start center;padding:64px 12px 32px}
|
|
39
|
+
.login-card{padding:24px 20px;border-radius:var(--r-md)}
|
|
40
|
+
.login-heading h1{font-size:var(--fs-xl)}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
@@ -33,6 +33,19 @@ export const SETTINGS_STYLES = `
|
|
|
33
33
|
.settings-search input{width:100%;max-width:520px}
|
|
34
34
|
#settings-status:empty,#browser-receipt:empty{display:none}
|
|
35
35
|
.mono-line{overflow-wrap:anywhere}
|
|
36
|
+
.permissions-results{display:grid;gap:12px;min-width:0}
|
|
37
|
+
.permissions-scroll{overflow:auto;border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface)}
|
|
38
|
+
.permissions-table{border-collapse:collapse;width:100%;font-size:var(--fs-xs)}
|
|
39
|
+
.permissions-table caption{text-align:left;padding:14px;color:var(--ink-2)}
|
|
40
|
+
.permissions-table th,.permissions-table td{text-align:left;vertical-align:top;padding:12px;border-bottom:1px solid var(--line)}
|
|
41
|
+
.permissions-table thead th{background:var(--surface-2);min-width:100px;font-weight:600}
|
|
42
|
+
.permissions-table th[scope=row]{min-width:240px;max-width:360px;font-weight:400}
|
|
43
|
+
.permissions-table small{display:block;color:var(--ink-3);margin-top:6px;overflow-wrap:anywhere}
|
|
44
|
+
.permissions-command{font-family:var(--font-mono);color:var(--accent);background:none;border:0;padding:4px 0;cursor:pointer;text-align:left;overflow-wrap:anywhere}
|
|
45
|
+
.permissions-command:hover{text-decoration:underline}
|
|
46
|
+
.permissions-table pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:var(--fs-xs);max-width:700px}
|
|
47
|
+
.permissions-visible{color:var(--ink);white-space:nowrap}
|
|
48
|
+
.permissions-hidden{color:var(--ink-3);white-space:nowrap}
|
|
36
49
|
@media(max-width:${BREAKPOINTS.small}px){
|
|
37
50
|
.settings-grid{grid-template-columns:minmax(0,1fr)}
|
|
38
51
|
.settings-card{padding:14px}
|
package/dist/web/webAccess.js
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
|
3
3
|
import { isIP } from "node:net";
|
|
4
4
|
import { networkInterfaces } from "node:os";
|
|
5
5
|
import { isAbsolute } from "node:path";
|
|
6
|
+
export const DEFAULT_WEB_SESSION_POLICY = Object.freeze({
|
|
7
|
+
idleMs: 8 * 60 * 60 * 1000,
|
|
8
|
+
absoluteMs: 24 * 60 * 60 * 1000,
|
|
9
|
+
maxSessions: 32,
|
|
10
|
+
failureWindowMs: 60 * 1000,
|
|
11
|
+
maxFailures: 5,
|
|
12
|
+
now: () => Date.now()
|
|
13
|
+
});
|
|
14
|
+
/** A post-login destination must stay a same-origin document path. Anything
|
|
15
|
+
* else (absolute/protocol-relative URLs, backslashes, API/auth/asset paths)
|
|
16
|
+
* falls back to the workbench root. */
|
|
17
|
+
export function safeReturnPath(candidate) {
|
|
18
|
+
if (typeof candidate !== "string" || candidate.length > 2048 || !candidate.startsWith("/")
|
|
19
|
+
|| candidate.startsWith("//") || /[\\\u0000-\u001f\u007f]/u.test(candidate))
|
|
20
|
+
return "/";
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(candidate, "http://return.invalid");
|
|
23
|
+
if (url.origin !== "http://return.invalid")
|
|
24
|
+
return "/";
|
|
25
|
+
if (/^\/(?:login|auth|api|assets|preview)(?:\/|$)/u.test(url.pathname))
|
|
26
|
+
return "/";
|
|
27
|
+
return url.pathname + url.search;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "/";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
6
33
|
export function isLoopbackAddress(host) {
|
|
7
34
|
return host === "localhost" || host === "::1" || /^127\./.test(host) && isIP(host) === 4;
|
|
8
35
|
}
|
|
@@ -39,7 +66,7 @@ function passwordDigest(path) {
|
|
|
39
66
|
const password = readFileSync(fd, "utf8").replace(/\r?\n$/, "");
|
|
40
67
|
if (!/^[\x21-\x7e]{32,128}$/.test(password))
|
|
41
68
|
throw new Error("invalid password");
|
|
42
|
-
return createHash("sha256").update(
|
|
69
|
+
return createHash("sha256").update(password).digest();
|
|
43
70
|
}
|
|
44
71
|
catch {
|
|
45
72
|
// Never include file contents, OS error text or a submitted credential.
|
|
@@ -50,7 +77,7 @@ function passwordDigest(path) {
|
|
|
50
77
|
closeSync(fd);
|
|
51
78
|
}
|
|
52
79
|
}
|
|
53
|
-
export function createWebAccess(options, interfaces = networkInterfaces()) {
|
|
80
|
+
export function createWebAccess(options, interfaces = networkInterfaces(), policy = DEFAULT_WEB_SESSION_POLICY) {
|
|
54
81
|
validateWebAccessOptions(options);
|
|
55
82
|
const lan = !isLoopbackAddress(options.host);
|
|
56
83
|
const digest = options.authFile === undefined ? undefined : passwordDigest(options.authFile);
|
|
@@ -65,6 +92,41 @@ export function createWebAccess(options, interfaces = networkInterfaces()) {
|
|
|
65
92
|
const allowed = new Set(!lan || wildcard ? [...addresses, "127.0.0.1", "::1", "localhost"] : addresses);
|
|
66
93
|
const urlHost = (host) => isIP(host) === 6 ? `[${host}]` : host;
|
|
67
94
|
const urls = addresses.map(host => `http://${urlHost(host)}:${options.port}`);
|
|
95
|
+
// Cookies are scoped by host, not port: include the port so two listeners on
|
|
96
|
+
// one machine cannot overwrite each other's session.
|
|
97
|
+
const cookieName = `yui_session_${options.port}`;
|
|
98
|
+
// Keyed by a hash of the opaque cookie value; the raw value is never stored.
|
|
99
|
+
const sessions = new Map();
|
|
100
|
+
const failures = new Map();
|
|
101
|
+
const sessionKey = (request) => {
|
|
102
|
+
const header = request.headers.cookie;
|
|
103
|
+
if (typeof header !== "string" || header.length > 8192)
|
|
104
|
+
return undefined;
|
|
105
|
+
for (const part of header.split(";")) {
|
|
106
|
+
const index = part.indexOf("=");
|
|
107
|
+
if (index > 0 && part.slice(0, index).trim() === cookieName) {
|
|
108
|
+
const value = part.slice(index + 1).trim();
|
|
109
|
+
return /^[A-Za-z0-9_-]{43}$/.test(value) ? createHash("sha256").update(value).digest("hex") : undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
};
|
|
114
|
+
const live = (key, touch) => {
|
|
115
|
+
if (key === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
const session = sessions.get(key);
|
|
118
|
+
if (!session)
|
|
119
|
+
return false;
|
|
120
|
+
const now = policy.now();
|
|
121
|
+
if (now - session.seen >= policy.idleMs || now - session.created >= policy.absoluteMs) {
|
|
122
|
+
sessions.delete(key);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (touch)
|
|
126
|
+
session.seen = now;
|
|
127
|
+
return true;
|
|
128
|
+
};
|
|
129
|
+
const cookie = (value, maxAgeSeconds) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
|
|
68
130
|
const warnings = lan ? [
|
|
69
131
|
"HTTP is not encrypted. Use only a trusted network; credentials and data are visible to network observers.",
|
|
70
132
|
...(wildcard ? ["Wildcard binding listens on all matching interfaces, including public ones. Yui does not open firewall ports or tunnels."] : []),
|
|
@@ -72,7 +134,8 @@ export function createWebAccess(options, interfaces = networkInterfaces()) {
|
|
|
72
134
|
] : [];
|
|
73
135
|
return {
|
|
74
136
|
lan,
|
|
75
|
-
authentication: digest ? "
|
|
137
|
+
authentication: digest ? "password" : "local",
|
|
138
|
+
passwordRequired: digest !== undefined,
|
|
76
139
|
urls,
|
|
77
140
|
warnings,
|
|
78
141
|
acceptsHost(request) {
|
|
@@ -98,12 +161,69 @@ export function createWebAccess(options, interfaces = networkInterfaces()) {
|
|
|
98
161
|
return request.headers["sec-fetch-site"] !== "cross-site"
|
|
99
162
|
&& (origin === undefined ? !requireOrigin : origin === `http://${request.headers.host}`);
|
|
100
163
|
},
|
|
164
|
+
/** Every protected request/handshake checks the listener's session table. */
|
|
101
165
|
authenticated(request) {
|
|
166
|
+
return !digest || live(sessionKey(request), true);
|
|
167
|
+
},
|
|
168
|
+
/** True when the request carries this listener's cookie name, valid or not. */
|
|
169
|
+
presentedSession(request) {
|
|
170
|
+
return typeof request.headers.cookie === "string"
|
|
171
|
+
&& request.headers.cookie.split(";").some(part => part.trim().startsWith(`${cookieName}=`));
|
|
172
|
+
},
|
|
173
|
+
/** Opaque identity for binding long-lived connections; not a credential. */
|
|
174
|
+
sessionOf(request) {
|
|
175
|
+
const key = sessionKey(request);
|
|
176
|
+
return live(key, false) ? key : undefined;
|
|
177
|
+
},
|
|
178
|
+
sessionActive(key) { return !digest || live(key, false); },
|
|
179
|
+
login(request, password) {
|
|
102
180
|
if (!digest)
|
|
103
|
-
return
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
181
|
+
return { ok: false, reason: "unconfigured" };
|
|
182
|
+
const now = policy.now();
|
|
183
|
+
const address = request.socket.remoteAddress ?? "unknown";
|
|
184
|
+
const recent = (failures.get(address) ?? []).filter(at => now - at < policy.failureWindowMs);
|
|
185
|
+
if (recent.length >= policy.maxFailures) {
|
|
186
|
+
failures.set(address, recent);
|
|
187
|
+
return { ok: false, reason: "throttled",
|
|
188
|
+
retryAfterSeconds: Math.max(1, Math.ceil((recent[0] + policy.failureWindowMs - now) / 1000)) };
|
|
189
|
+
}
|
|
190
|
+
const valid = typeof password === "string" && password.length <= 256
|
|
191
|
+
&& timingSafeEqual(createHash("sha256").update(password).digest(), digest);
|
|
192
|
+
if (!valid) {
|
|
193
|
+
recent.push(now);
|
|
194
|
+
failures.delete(address);
|
|
195
|
+
failures.set(address, recent);
|
|
196
|
+
// Bound memory: forget the oldest addresses first.
|
|
197
|
+
while (failures.size > 1024)
|
|
198
|
+
failures.delete(failures.keys().next().value);
|
|
199
|
+
return { ok: false, reason: "invalid" };
|
|
200
|
+
}
|
|
201
|
+
failures.delete(address);
|
|
202
|
+
for (const [key, session] of sessions) {
|
|
203
|
+
if (now - session.seen >= policy.idleMs || now - session.created >= policy.absoluteMs)
|
|
204
|
+
sessions.delete(key);
|
|
205
|
+
}
|
|
206
|
+
while (sessions.size >= policy.maxSessions) {
|
|
207
|
+
let oldest;
|
|
208
|
+
let seen = Infinity;
|
|
209
|
+
for (const [key, session] of sessions)
|
|
210
|
+
if (session.seen < seen) {
|
|
211
|
+
seen = session.seen;
|
|
212
|
+
oldest = key;
|
|
213
|
+
}
|
|
214
|
+
sessions.delete(oldest);
|
|
215
|
+
}
|
|
216
|
+
const value = randomBytes(32).toString("base64url");
|
|
217
|
+
sessions.set(createHash("sha256").update(value).digest("hex"), { created: now, seen: now });
|
|
218
|
+
return { ok: true, cookie: cookie(value, Math.floor(policy.absoluteMs / 1000)) };
|
|
219
|
+
},
|
|
220
|
+
/** Revokes the presented session (if any) and returns a clearing cookie. */
|
|
221
|
+
logout(request) {
|
|
222
|
+
const key = sessionKey(request);
|
|
223
|
+
if (key !== undefined)
|
|
224
|
+
sessions.delete(key);
|
|
225
|
+
return cookie("", 0);
|
|
226
|
+
},
|
|
227
|
+
clearCookie() { return cookie("", 0); }
|
|
108
228
|
};
|
|
109
229
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ROOT_COMMAND } from "../cli/commandCatalog.js";
|
|
2
|
+
import { discoveryCommandTree } from "../cli/commandDiscovery.js";
|
|
3
|
+
import { BUILTIN_CAPABILITIES } from "../kernel/builtinCapabilities.js";
|
|
4
|
+
const templates = [
|
|
5
|
+
{ id: "public", audience: "public" },
|
|
6
|
+
{ id: "operator", audience: "operator" },
|
|
7
|
+
{ id: "global", audience: "global" },
|
|
8
|
+
{ id: "leader", audience: "leader" },
|
|
9
|
+
{ id: "worker", audience: "assignment" },
|
|
10
|
+
{ id: "reviewer", audience: "assignment" },
|
|
11
|
+
{ id: "unbound", audience: "unbound" }
|
|
12
|
+
];
|
|
13
|
+
function executableNodes(node) {
|
|
14
|
+
return [...(node.kind !== "group" && node.discovery?.surface !== "runtime" ? [node] : []),
|
|
15
|
+
...node.children.flatMap(executableNodes)];
|
|
16
|
+
}
|
|
17
|
+
const commands = executableNodes(ROOT_COMMAND);
|
|
18
|
+
// The same pure projection as CLI help/completion. No fake Session, caller
|
|
19
|
+
// environment, authority issuance, registry invocation or domain assessment.
|
|
20
|
+
const projections = new Map(templates.map(role => [role.audience,
|
|
21
|
+
new Map(executableNodes(discoveryCommandTree({}, role.audience)).map(node => [node.path.join(" "), node]))]));
|
|
22
|
+
const pageSize = 30;
|
|
23
|
+
function searchText(value = "") {
|
|
24
|
+
if (value.length > 200)
|
|
25
|
+
throw new Error("Search is limited to 200 characters.");
|
|
26
|
+
return value.trim().toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
function page(items, cursor = "0") {
|
|
29
|
+
if (!/^(0|[1-9]\d*)$/.test(cursor) || !Number.isSafeInteger(Number(cursor)))
|
|
30
|
+
throw new Error("Invalid cursor.");
|
|
31
|
+
const start = Number(cursor);
|
|
32
|
+
return { items: items.slice(start, start + pageSize), total: items.length,
|
|
33
|
+
nextCursor: start + pageSize < items.length ? String(start + pageSize) : null };
|
|
34
|
+
}
|
|
35
|
+
/** Read-only metadata projection for the authenticated local Web user.
|
|
36
|
+
* Only configured GlobalRole names cross the Store boundary. Task contents,
|
|
37
|
+
* bindings, profiles, Sessions, Grants and plugin descriptors are not read.
|
|
38
|
+
* These templates deliberately have no AccessAssessment field. */
|
|
39
|
+
export function createWebPermissions(store) {
|
|
40
|
+
const roles = () => [...templates, ...store.listGlobalRoles()
|
|
41
|
+
.filter(role => role.name !== "operator").map(role => ({
|
|
42
|
+
id: `global:${role.name}`, name: role.name, audience: "global"
|
|
43
|
+
}))];
|
|
44
|
+
const selected = (id) => {
|
|
45
|
+
if (!id)
|
|
46
|
+
return templates;
|
|
47
|
+
const role = roles().find(role => role.id === id);
|
|
48
|
+
if (!role)
|
|
49
|
+
throw new Error("Unknown role template.");
|
|
50
|
+
return [role];
|
|
51
|
+
};
|
|
52
|
+
const cells = (id, selectedRoles) => selectedRoles.map(role => {
|
|
53
|
+
const projected = projections.get(role.audience).get(id);
|
|
54
|
+
return { role: role.id, visible: !!projected };
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
index() {
|
|
58
|
+
return { roles: roles(), categories: [...new Set(commands.map(node => node.path[1]))].sort(),
|
|
59
|
+
sources: ["cli/commandCatalog.ts", "cli/commandDiscovery.ts", "kernel/builtinCapabilities.ts", "docs/authorization.md"],
|
|
60
|
+
evaluation: "not-performed" };
|
|
61
|
+
},
|
|
62
|
+
commands(query = {}) {
|
|
63
|
+
const q = searchText(query.q);
|
|
64
|
+
const selectedRoles = selected(query.role);
|
|
65
|
+
return { ...page(commands.filter(node => (!query.category || node.path[1] === query.category)
|
|
66
|
+
&& `${node.path.join(" ")} ${node.summary}`.toLowerCase().includes(q))
|
|
67
|
+
.map(node => ({ id: node.path.join(" "), summary: node.summary, category: node.path[1],
|
|
68
|
+
roles: cells(node.path.join(" "), selectedRoles) })), query.cursor),
|
|
69
|
+
evaluation: "not-performed" };
|
|
70
|
+
},
|
|
71
|
+
command(id, role) {
|
|
72
|
+
const node = commands.find(node => node.path.join(" ") === id);
|
|
73
|
+
if (!node)
|
|
74
|
+
throw new Error("Unknown command template.");
|
|
75
|
+
return { id, summary: node.summary, evaluation: "not-performed",
|
|
76
|
+
source: "cli/commandCatalog.ts → cli/commandDiscovery.ts",
|
|
77
|
+
roles: selected(role).map(role => {
|
|
78
|
+
const projected = projections.get(role.audience).get(id);
|
|
79
|
+
return { role: role.id, audience: role.audience, visible: !!projected,
|
|
80
|
+
// Use the projected usage/options, including option audience gates.
|
|
81
|
+
usage: projected?.usage ?? [], options: projected?.options ?? [] };
|
|
82
|
+
}) };
|
|
83
|
+
},
|
|
84
|
+
capabilities(query = {}) {
|
|
85
|
+
const q = searchText(query.q);
|
|
86
|
+
return { ...page(BUILTIN_CAPABILITIES
|
|
87
|
+
.filter(entry => `${entry.name} ${entry.summary} ${entry.requiredPermissions.join(" ")}`.toLowerCase().includes(q))
|
|
88
|
+
.map(entry => ({
|
|
89
|
+
name: entry.name, summary: entry.summary, effect: entry.effect, source: entry.source,
|
|
90
|
+
requiredPermissions: entry.requiredPermissions,
|
|
91
|
+
requiredInputs: entry.inputSchema.required ?? [],
|
|
92
|
+
inputs: Object.keys(entry.inputSchema.properties ?? {})
|
|
93
|
+
})), query.cursor), evaluation: "not-performed",
|
|
94
|
+
source: "kernel/builtinCapabilities.ts (registry built-in descriptors)" };
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|