feat: explicit hosting administrators and persistent self-registration policy

This commit is contained in:
2026-10-16 07:26:21 +09:00
parent 7042ec7d50
commit af008f802d
51 changed files with 6342 additions and 85 deletions
@@ -24,6 +24,7 @@ interface Fixture {
workspace_id: string;
owner_token: string;
second_token: string;
foreign_token: string;
worker_entry: DriveEntry;
}
function assert(value: unknown, message: string): asserts value {
@@ -164,14 +165,14 @@ try {
"revoke must be durably visible",
);
}
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.foreign_token);
try {
await listDriveGrants(ws);
throw new Error("member must not manage grants");
throw new Error("foreign Account must not manage grants");
} catch (error) {
assert(
error instanceof DriveGrantError && error.outcome === "not_committed",
"member grant read must be a real authorized rejection",
"foreign Account grant read must be a real authorized rejection",
);
}
} finally {
@@ -318,7 +319,7 @@ const secondRead = await second.readText(ws, updated.entry);
equal(
secondRead.entry.last_mutation_id,
updated.last_mutation_id,
"second actor must observe same last_mutation_id",
"second owner client must observe same last_mutation_id",
);
const drafts = ["# actor A draft\n", "# actor B draft\n"];
const race = await Promise.allSettled(
@@ -335,7 +336,7 @@ const race = await Promise.allSettled(
equal(
race.filter((result) => result.status === "fulfilled").length,
1,
"same-last_mutation_id two-actor CAS must have exactly one winner",
"same-last_mutation_id two-client CAS must have exactly one winner",
);
const loser = race.find((result) => result.status === "rejected");
assert(
@@ -348,7 +349,7 @@ const latest = await owner.readText(ws, updated.entry);
equal(
latest.text,
drafts[winnerIndex],
"durable content must be the winning actor's draft",
"durable content must be the winning client's draft",
);
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
// retention is deliberately verified by the separate browser fixture.
@@ -0,0 +1,5 @@
# Hosting UIのブラウザ検証
production UIと合成API応答の組合せを確認する。Setupだけはbrowser API境界も`setup-projection.ts`で置換する。Backend認可・実token・暗号署名・native WebAuthnを証明する検証ではない。実sessionやsecretは持ち込まず、request headers/bodyも記録しない。
共有buildは読取り専用とし、画像・診断・一時領域は`target/web-ux`へ分離する。実行入口と境界の理由は各test/capture sourceに置く。自動検査の成功は、画像とreview contextの目視判定を代替しない。
@@ -0,0 +1,113 @@
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const output = `${root}/target/web-ux/hosting`;
async function sha(bytes: Uint8Array<ArrayBuffer>) {
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
value.toString(16).padStart(2, "0")
).join("");
}
const results = [];
for (const theme of ["light", "dark"]) {
for (const prefix of ["before", "after", "navigation-before", "navigation-after"]) {
const run = `${prefix}-final-${theme}`;
const context: ReviewContext = JSON.parse(
await Deno.readTextFile(`${output}/${run}/review-context.json`),
);
assertEquals(context.diagnostics, []);
assert(!context.diagnosticSummary.truncated);
let screenshots = 0, expectedFailureCaptures = 0;
for (const capture of context.captures) {
assertEquals(capture.document.status, 200);
assert(!capture.errorSummary.truncated);
const expectedFailure = ["admin-load-error", "admin-save-error", "account-error"].includes(
capture.route.id,
);
if (expectedFailure) {
expectedFailureCaptures++;
// Non-OK bodies are cancelled by production client; ERR_ABORTED is the
// same deliberately failed response, not an unrelated network failure.
assertEquals(capture.errors.length, 3);
assertEquals(
capture.errors.filter((error) => error.status === 503).length,
1,
);
assertEquals(
capture.errors.filter((error) =>
error.kind === "console" && error.message.includes("503")
).length,
1,
);
assertEquals(
capture.errors.filter((error) =>
error.kind === "request" && error.message === "net::ERR_ABORTED"
).length,
1,
);
} else assertEquals(capture.errors, []);
assert(capture.snapshot !== null);
const snapshot = await Deno.readTextFile(`${output}/${run}/${capture.snapshot.bundlePath}`);
if (["ordinary", "anonymous"].includes(capture.route.id)) {
assert(!snapshot.includes('button "Edit registration policy"'));
assert(!snapshot.includes('link "Hosting settings"'));
}
if (capture.route.id === "ordinary-account-off") {
assert(snapshot.includes('button "Add passkey to alice"'));
assert(!snapshot.includes('link "Hosting settings"'));
}
for (const screenshot of capture.screenshots) {
assertEquals(
await sha(await Deno.readFile(`${output}/${run}/${screenshot.bundlePath}`)),
screenshot.sha256,
);
screenshots++;
}
}
assertEquals(expectedFailureCaptures, prefix === "after" ? 9 : 0);
results.push({
run,
captures: context.captures.length,
screenshots,
expectedFailureCaptures,
allDocuments200: true,
unexpectedErrors: 0,
imageHashesVerified: true,
diagnosticTruncation: false,
});
}
}
async function inventory(directory: string): Promise<{ path: string; sha256: string }[]> {
const files = [];
for await (const entry of Deno.readDir(directory)) {
const path = `${directory}/${entry.name}`;
if (entry.isDirectory) files.push(...await inventory(path));
else if (entry.isFile) {
files.push({
path: path.slice(root.length + 1),
sha256: await sha(await Deno.readFile(path)),
});
}
}
return files.sort((a, b) => a.path.localeCompare(b.path));
}
const baselineAssets = await inventory(`${root}/target/web-ux/baseline-source/web/workspace/build`);
const currentAssets = await inventory(`${root}/web/workspace/build`);
await Deno.writeTextFile(
`${output}/evidence-audit.json`,
JSON.stringify(
{
boundary:
"Actual production static shells; synthetic HTTP only. No Backend authorization/persistence/WebAuthn evidence.",
baselineSource: "git archive 7042ec7d50ca8b03b8034c33e9792f4b6ac9d48d web/workspace",
currentSource:
"Dirty shared worktree at same HEAD; exact served build bytes identified by inventory. Parent owns source/build provenance and final visual verdict.",
results,
baselineAssets,
currentAssets,
},
null,
2,
),
);
console.log(JSON.stringify(results, null, 2));
@@ -0,0 +1,142 @@
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const output = `${root}/target/web-ux/hosting`;
const synthetic = `ui-projection-${crypto.randomUUID()}`;
// 合成値はこの子processのmemory/envに限定する。argv、URL、保存証拠へ出さない。
await Deno.mkdir(`${root}/target/web-ux/tmp`, { recursive: true });
const audit = [];
const includeExisting = Deno.args.includes("--existing");
const runLabel = Deno.args.find((arg) => arg.startsWith("--run-label="))?.slice(12) ??
String(Date.now());
assert(/^[a-z0-9-]+$/.test(runLabel), "run label must be a safe path component");
for (const theme of ["light", "dark"]) {
for (
const kind of includeExisting ? ["setup", "settings"] : ["setup"]
) {
const run = `${kind === "setup" ? "setup" : "after-setup"}-${runLabel}-${theme}`;
const child = await new Deno.Command(Deno.execPath(), {
cwd: root,
env: { TMPDIR: `${root}/target/web-ux/tmp`, WEB_UX_SYNTHETIC_SETUP_INPUT: synthetic },
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-env",
"--allow-net",
"--allow-read",
"--allow-write=target/web-ux",
"--allow-run",
"--allow-sys",
"tools/web-ux/cli.ts",
"capture",
"--scenario",
`tools/web-ux/scenarios/hosting-${kind}-${theme}.json`,
"--output",
"target/web-ux/hosting",
"--run-id",
run,
],
stdout: "inherit",
stderr: "inherit",
}).output();
assertEquals(child.code, 2); // 両scenarioとも意図した拒否・障害表示を含む。
const context: ReviewContext = JSON.parse(
await Deno.readTextFile(`${output}/${run}/review-context.json`),
);
assertEquals(context.diagnostics, []);
assertEquals(context.status, "completed-with-errors");
assertEquals(context.captures.length, kind === "setup" ? 18 : 54);
let images = 0;
for (const capture of context.captures) {
assertEquals(capture.document.status, 200);
assertEquals(capture.errorSummary.truncated, false);
const rejected = capture.route.id === "setup-reject";
const expected = rejected ||
["admin-load-error", "admin-save-error", "account-error"].includes(capture.route.id);
if (expected) {
assert(capture.errors.every((error) =>
error.status === (rejected ? 403 : 503) ||
(error.kind === "console" && error.message.includes(String(rejected ? 403 : 503))) ||
(error.kind === "request" && error.message === "net::ERR_ABORTED")
));
assert(capture.errors.some((error) => error.status === (rejected ? 403 : 503)));
} else assertEquals(capture.errors, []);
for (const image of capture.screenshots) {
assertEquals(
await sha(await Deno.readFile(`${output}/${run}/${image.bundlePath}`)),
image.sha256,
);
images++;
}
}
let scannedTextFiles = 0;
for await (const path of files(`${output}/${run}`)) {
if (/\.(json|md|html|log)$/.test(path)) {
assert(
!(await Deno.readTextFile(path)).includes(synthetic),
"synthetic input must not appear in artifacts",
);
scannedTextFiles++;
}
}
audit.push({
run,
source: context.source,
captures: context.captures.length,
images,
unexpectedErrors: 0,
allDocuments200: true,
imageHashesVerified: true,
syntheticInputAbsent: true,
scannedTextFiles,
});
}
}
async function sha(bytes: Uint8Array<ArrayBuffer>) {
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
value.toString(16).padStart(2, "0")
).join("");
}
async function* files(dir: string): AsyncGenerator<string> {
for await (const entry of Deno.readDir(dir)) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory) yield* files(path);
else if (entry.isFile) yield path;
}
}
async function inventory(dir: string) {
const result = [];
for await (const path of files(dir)) {
result.push({
path: path.slice(root.length + 1),
sha256: await sha(await Deno.readFile(path)),
});
}
return result.sort((a, b) => a.path.localeCompare(b.path));
}
const diff = await new Deno.Command("git", {
cwd: root,
args: ["diff", "--binary", "HEAD", "--", "web/workspace/src", "web/workspace/deno.json"],
stdout: "piped",
stderr: "null",
}).output();
assert(diff.success);
await Deno.writeTextFile(
`${output}/setup-evidence-audit.json`,
JSON.stringify(
{
boundary:
"Production UI modules/styles unchanged. Setup-only bootstrap replaces navigator.credentials create/get; synthetic API responses. Not native WebAuthn, real token proof, authorization or persistence evidence. No request header/body logging.",
audit,
currentTrackedWebDiffSha256: await sha(diff.stdout),
currentSourceFiles: await inventory(`${root}/web/workspace/src`),
currentBuildFiles: await inventory(`${root}/web/workspace/build`),
fixtureFiles: await inventory(import.meta.dirname!),
},
null,
2,
),
);
console.log("Capture diagnostics, image hashes and synthetic-input artifact scan PASS.");
@@ -0,0 +1,111 @@
// UI projection fixture ONLY: actual production assets, synthetic API boundary.
// No real sessions/accounts/DB or Backend authorization claims. Cookie is a
// public fixture selector, not a credential; it is never saved as auth state.
import { extname, join, resolve } from "@std/path";
import { setupProjectionResponse } from "./setup-projection.ts";
const [port, build] = Deno.args;
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>");
const buildRoot = resolve(build);
const mime: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".png": "image/png",
};
const policies = new Map<string, boolean>();
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
let setupMode: string | null = null;
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
const selected = url.searchParams.get("fixture");
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected;
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on";
if (selected) policies.delete(mode);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
if (setup) return setup;
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
actor: anonymous ? null : {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
},
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous });
}
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") });
if (url.pathname === "/api/auth/registration-policy") {
if (mode === "account-error") {
return new Response("synthetic availability failure", { status: 503 });
}
if (mode === "account-loading") return new Promise<Response>(() => {});
return Response.json(policy());
}
if (url.pathname === "/api/hosting/settings") {
if (anonymous || ordinary) {
return new Response("synthetic denied", { status: anonymous ? 401 : 403 });
}
if (mode === "admin-load-error") {
return new Response("synthetic settings failure", { status: 503 });
}
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) {
return new Promise<Response>(() => {});
}
if (request.method === "PUT") {
if (mode === "admin-save-error") {
return new Response("synthetic unconfirmed save", { status: 503 });
}
const data = await request.json();
if (typeof data.self_registration_enabled !== "boolean") {
return new Response("invalid", { status: 400 });
}
policies.set(mode, data.self_registration_enabled);
}
return Response.json(policy());
}
if (url.pathname.startsWith("/api/")) {
return new Response("unexpected fixture API", { status: 404 });
}
const path = join(buildRoot, decodeURIComponent(url.pathname));
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 });
let bytes: Uint8Array;
let extension = extname(path);
try {
bytes = await Deno.readFile(path);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) {
throw error;
}
bytes = await Deno.readFile(join(buildRoot, "index.html"));
extension = ".html";
}
// productionのmarkup/style/moduleを保ち、Setupだけbrowser API境界を代替する。
// このbootstrapはnavigator.credentialsのみを置換し、formや表示を操作しない。
if (extension === ".html" && mode.startsWith("setup-")) {
bytes = new TextEncoder().encode(
new TextDecoder().decode(bytes).replace(
"<head>",
'<head><script src="/__ui_fixture/webauthn.js"></script>',
),
);
}
const headers = new Headers({
"content-type": mime[extension] ?? "application/octet-stream",
"cache-control": "no-store",
});
if (selected) headers.set("set-cookie", `ui-fixture=${selected}; Path=/; SameSite=Strict`);
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
});
@@ -0,0 +1,59 @@
import { resolve } from "@std/path";
import type { Scenario } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
for (const theme of ["light", "dark"]) {
const scenario: Scenario = JSON.parse(
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
);
scenario.id = `hosting-baseline-${theme}`;
scenario.title = "Before: tracked HEAD Account and global navigation; no hosting route existed";
scenario.routes = [
{
id: "account-on",
label: "Anonymous Account before",
path: "/account?fixture=account-on",
goal: "Compare existing Account registration/login before policy gating.",
dataState: "Synthetic anonymous session; unchanged baseline production shell.",
ready: { kind: "selector", selector: 'button:has-text("Register new account"):enabled' },
capturePoints: [{ id: "initial", label: "Before anonymous Account" }],
},
{
id: "admin-account",
label: "Signed-in Account before",
path: "/account?fixture=admin-account",
goal: "Compare Account and global navigation before admin settings link.",
dataState: "Synthetic signed-in session; no hosting route/link before.",
ready: { kind: "selector", selector: 'button:has-text("Add passkey to alice")' },
capturePoints: [{ id: "initial", label: "Before signed-in Account" }],
},
];
scenario.processes![0].cwd = root;
scenario.processes![0].args![scenario.processes![0].args!.length - 1] =
"target/web-ux/baseline-source/web/workspace/build";
for (const route of scenario.routes) route.capturePoints[0].fullPage = true;
await Deno.writeTextFile(
`${root}/target/web-ux/baseline-${theme}.json`,
JSON.stringify(scenario, null, 2),
);
for (const version of ["before", "after"]) {
const nav = structuredClone(scenario);
nav.id = `hosting-navigation-${version}-${theme}`;
nav.title = `${version} mobile global navigation on production shell`;
nav.viewports = nav.viewports.filter((item) => item.width === 390);
for (const route of nav.routes) {
route.capturePoints = [{
id: "navigation",
label: "Mobile global links",
interaction: [{ action: "click", selector: 'button[aria-label="Show sidebar"]' }],
ready: { kind: "selector", selector: 'nav[aria-label="Global pages"]' },
}];
}
if (version === "after") {
nav.processes![0].args![nav.processes![0].args!.length - 1] = "web/workspace/build";
}
await Deno.writeTextFile(
`${root}/target/web-ux/navigation-${version}-${theme}.json`,
JSON.stringify(nav, null, 2),
);
}
}
@@ -0,0 +1,175 @@
import { resolve } from "@std/path";
import type { RouteScenario, Scenario } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const selector = (selector: string) => ({ kind: "selector" as const, selector });
const click = (selector: string) => ({ action: "click" as const, selector });
const press = (selector: string, key: string) => ({ action: "press" as const, selector, key });
const route = (
id: string,
path: string,
ready: string,
dataState: string,
points = [{ id: "initial", label: "Initial state" }],
): RouteScenario => ({
id,
label: id,
path: `${path}?fixture=${id}`,
goal: "Verify production shell UI projection, not server authorization.",
dataState,
ready: selector(ready),
capturePoints: points,
});
const routes: RouteScenario[] = [
...["on", "off"].map((policy) =>
route(
`admin-${policy}`,
"/hosting/settings",
'button:has-text("Edit registration policy")',
`Synthetic admin; policy ${policy}.`,
[
{ id: "initial", label: "Loaded hosting-wide policy" },
{
id: "edit",
label: "Keyboard edit focus",
interaction: [press('button:has-text("Edit registration policy")', "Enter")],
ready: selector('input[type="checkbox"]:focus'),
},
{
id: "saved",
label: "Confirmed save",
interaction: [
press('input[type="checkbox"]', "Space"),
click('button:has-text("Save settings")'),
],
ready: selector('[role="status"]:has-text("Hosting settings saved.")'),
},
] as RouteScenario["capturePoints"],
)
),
route(
"ordinary",
"/hosting/settings",
'p:has-text("Hosting administrator access is required.")',
"Synthetic authenticated ordinary user, no admin controls.",
),
route(
"anonymous",
"/hosting/settings",
'a:has-text("Open Account")',
"Synthetic anonymous visitor; sign-in guidance, no controls.",
),
route(
"admin-loading",
"/hosting/settings",
'[role="status"]:has-text("Loading hosting settings")',
"Settings GET intentionally held pending; shell remains usable.",
),
route(
"admin-load-error",
"/hosting/settings",
'button:has-text("Retry loading settings")',
"Intentional HTTP503; expected console resource error and visible alert.",
),
...["saving", "save-error"].map((state) =>
route(
`admin-${state}`,
"/hosting/settings",
'button:has-text("Edit registration policy")',
state === "saving"
? "PUT held pending; last loaded policy retained, edit controls disabled."
: "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
[
{
id: state,
label: state,
interaction: [
click('button:has-text("Edit registration policy")'),
press('input[type="checkbox"]', "Space"),
click('button:has-text("Save settings")'),
],
ready: selector(state === "saving" ? 'button:has-text("Saving…")' : '[role="alert"]'),
},
] as RouteScenario["capturePoints"],
)
),
route(
"account-on",
"/account",
'button:has-text("Register new account"):enabled',
"Anonymous policy on; registration and login available.",
),
route(
"account-off",
"/account",
'p:has-text("New account registration is disabled.")',
"Anonymous policy off; no registration action, login available.",
),
route(
"admin-account",
"/account",
'button:has-text("Add passkey to alice")',
"Authenticated admin; own passkey addition and global settings link.",
),
route(
"ordinary-account-off",
"/account",
'button:has-text("Add passkey to alice")',
"Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
),
route(
"account-error",
"/account",
'button:has-text("Retry registration availability")',
"Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
),
route(
"account-loading",
"/account",
'[role="status"]:has-text("Checking registration availability")',
"Policy request held pending; login remains available after session loads.",
),
];
for (const account of routes.filter((item) => item.path.startsWith("/account"))) {
account.capturePoints[0].fullPage = true;
}
for (const theme of ["light", "dark"] as const) {
const scenario: Scenario = {
schemaVersion: 1,
id: `hosting-settings-${theme}`,
title: "Hosting settings: synthetic UI conditions on production shell",
baseUrl: "http://127.0.0.1:15378",
colorScheme: theme,
reducedMotion: "reduce",
personas: [{
id: "fixture",
label: "Synthetic API projection; no real authentication",
auth: { kind: "anonymous" },
}],
viewports: [{ label: "desktop", width: 1440, height: 900 }, {
label: "narrow",
width: 768,
height: 900,
}, { label: "mobile", width: 390, height: 844 }],
routes,
processes: [{
id: "hosting-ui-fixture",
command: "deno",
cwd: "../../..",
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15378",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15378/health",
}],
};
await Deno.writeTextFile(
`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`,
JSON.stringify(scenario, null, 2) + "\n",
);
}
@@ -0,0 +1,88 @@
import { resolve } from "@std/path";
import type { Interaction, Scenario } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const submit: Interaction[] = [{
action: "fill",
selector: "#host-setup-token",
value: "${WEB_UX_SYNTHETIC_SETUP_INPUT}",
}, { action: "click", selector: 'button:has-text("Add administrator passkey")' }];
for (const theme of ["light", "dark"]) {
const scenario: Scenario = JSON.parse(
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
);
scenario.id = `hosting-setup-${theme}`;
scenario.title =
"Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only";
scenario.redact = { text: ["${WEB_UX_SYNTHETIC_SETUP_INPUT}"] };
scenario.routes = [{
id: "setup-flow",
label: "Setup enrollment and login UI",
path: "/hosting/setup?fixture=setup-flow",
goal:
"Separate host-token enrollment from explicit passkey login and verified settings access.",
dataState:
"Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
capturePoints: [
{ id: "initial", label: "Empty password field; enrollment disabled", fullPage: true },
{
id: "host-help",
label: "Trusted-host instructions expanded",
interaction: [{ action: "press", selector: "summary", key: "Enter" }],
ready: { kind: "selector", selector: "details[open]" },
fullPage: true,
},
{
id: "masked-entry",
label: "Synthetic input password-masked; action enabled",
interaction: [{ action: "press", selector: "summary", key: "Enter" }, submit[0]],
ready: {
kind: "selector",
selector: 'button:has-text("Add administrator passkey"):enabled',
},
fullPage: true,
},
{
id: "enrolled",
label: "Enrollment is not login; explicit login handoff",
interaction: [submit[1]],
ready: {
kind: "selector",
selector: 'button:has-text("Sign in with new passkey"):enabled',
},
fullPage: true,
},
{
id: "verified",
label: "Matching actor/session/access projected verified; settings link",
interaction: [{
action: "press",
selector: 'button:has-text("Sign in with new passkey")',
key: "Enter",
}],
ready: { kind: "selector", selector: 'a:has-text("Open hosting settings")' },
fullPage: true,
},
],
}, {
id: "setup-reject",
label: "Rejected setup enrollment",
path: "/hosting/setup?fixture=setup-reject",
goal:
"Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
dataState:
"Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
capturePoints: [{
id: "rejected",
label: "Rejected attempt; token cleared",
interaction: submit,
ready: { kind: "selector", selector: '[role="alert"]' },
fullPage: true,
}],
}];
await Deno.writeTextFile(
`${root}/tools/web-ux/scenarios/hosting-setup-${theme}.json`,
JSON.stringify(scenario, null, 2) + "\n",
);
}
@@ -0,0 +1,250 @@
/// <reference lib="dom" />
// Production-shell browser behavior with synthetic HTTP projections only.
// Run against prebuilt assets; never builds or uses a real session/backend.
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/interactions`;
async function noOverflow(page: Page) {
assertEquals(
await page.evaluate(() => ({
page: document.documentElement.scrollWidth > innerWidth,
main: [...document.querySelectorAll("main")].some((main) =>
main.scrollWidth > main.clientWidth
),
})),
{ page: false, main: false },
);
}
Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown save outcome on production shell", async () => {
const processes = await startOwnedProcesses(
[{
id: "hosting-interactions",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-net",
"--allow-read",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15379",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15379/health",
readyTimeoutMs: 10000,
}],
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
evidence,
[],
);
const browser = await chromium.launch();
const results: unknown[] = [];
try {
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
const consoleErrors: string[] = [];
const httpErrors: string[] = [];
const failedRequests: { url: string; error: string }[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
}
});
page.on(
"requestfailed",
(request) =>
failedRequests.push({
url: request.url(),
error: request.failure()?.errorText ?? "unknown",
}),
);
const settingsRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/hosting/settings") {
settingsRequests.push(request.method());
}
});
await page.goto("http://127.0.0.1:15379/account?fixture=admin-on");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
if (
await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible()
) await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
const nav = page.getByRole("navigation", { name: "Global pages" });
await nav.getByRole("link", { name: "Hosting settings", exact: true }).waitFor();
await nav.getByRole("link", { name: "Account", exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Device Login");
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Hosting settings");
assertEquals(
await page.locator(":focus").evaluate((element) =>
getComputedStyle(element).outlineStyle !== "none"
),
true,
);
await page.keyboard.press("Enter");
const edit = page.getByRole("button", { name: "Edit registration policy" });
await edit.waitFor();
assertEquals(
await page.locator('a[href="/hosting/settings"]').getAttribute("aria-current"),
"page",
);
if (
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
width < 768
) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
await noOverflow(page);
await edit.focus();
await page.keyboard.press("Enter");
const checkbox = page.getByRole("checkbox", { name: "Allow new account registration" });
assertEquals(
await checkbox.evaluate((element) => element === document.activeElement),
true,
);
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.keyboard.press("Enter");
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Save settings");
await page.keyboard.press("Enter");
await page.getByRole("status").filter({ hasText: "Hosting settings saved." }).waitFor();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.getByText("Disabled", { exact: true }).waitFor();
assertEquals(settingsRequests.filter((method) => method === "PUT").length, 1);
await noOverflow(page);
for (const mode of ["ordinary", "anonymous"]) {
settingsRequests.length = 0;
await page.goto(`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`);
await page.getByText(
mode === "ordinary"
? "Hosting administrator access is required."
: "Sign in with a hosting administrator account to view these settings.",
{ exact: true },
).waitFor();
assertEquals(await edit.count(), 0);
assertEquals(await checkbox.count(), 0);
assertEquals(
await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(),
0,
);
assertEquals(settingsRequests, []);
await noOverflow(page);
}
await page.goto("http://127.0.0.1:15379/account?fixture=account-off");
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
).waitFor();
assertEquals(await page.getByRole("button", { name: "Register new account" }).count(), 0);
assertEquals(
await page.getByRole("button", { name: "Log in with passkey" }).isEnabled(),
true,
);
await noOverflow(page);
await page.goto("http://127.0.0.1:15379/account?fixture=ordinary-account-off");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
assertEquals(
await page.getByRole("button", { name: "Add passkey to alice" }).isEnabled(),
true,
);
await page.goto("http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error");
await edit.click();
await checkbox.press("Space");
await page.getByRole("button", { name: "Save settings", exact: true }).click();
await page.getByRole("alert").waitFor();
assertEquals(await checkbox.isChecked(), false);
assertEquals(await checkbox.isDisabled(), true);
assertEquals(
await page.getByRole("button", { name: "Save settings", exact: true }).isDisabled(),
true,
);
await noOverflow(page);
await page.getByRole("button", { name: "Reload settings", exact: true }).click();
await edit.waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
assertEquals(await edit.isEnabled(), true);
for (
const [mode, path, ready] of [
[
"admin-load-error",
"/hosting/settings",
'button:has-text("Retry loading settings")',
],
["account-error", "/account", 'button:has-text("Retry registration availability")'],
[
"admin-loading",
"/hosting/settings",
'[role="status"]:has-text("Loading hosting settings")',
],
[
"account-loading",
"/account",
'[role="status"]:has-text("Checking registration availability")',
],
]
) {
await page.goto(`http://127.0.0.1:15379${path}?fixture=${mode}`);
await page.locator(ready).waitFor();
await noOverflow(page);
}
// Navigation may also cancel the shell's concurrent identity/access
// reads. Only an explicit browser cancellation of these reads is expected.
assert(
failedRequests.every(({ url, error }) =>
error === "net::ERR_ABORTED" && [
"/api/hosting/settings",
"/api/auth/registration-policy",
"/api/auth/whoami",
"/api/hosting/access",
].some((path) => url.endsWith(path))
),
JSON.stringify(failedRequests),
);
assertEquals(httpErrors, [
"503 /api/hosting/settings",
"503 /api/hosting/settings",
"503 /api/auth/registration-policy",
]);
assertEquals(consoleErrors.length, 3);
assert(consoleErrors.every((message) => message.includes("503")));
assertEquals(errors, []);
results.push({
theme,
width,
keyboard: "pass",
overflow: "none",
deniedControls: "absent",
save: "confirmed",
unconfirmedSave: "blocked until reload",
realAuthorizationTested: false,
});
} finally {
await context.close();
}
}
}
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});
@@ -0,0 +1,64 @@
// Setupの表示条件だけを代替する。Bearerやrequest bodyは読まず、記録もしない。
// WebAuthnは固定の非暗号credentialを返すため、本物の認証・署名検証ではない。
export const setupWebAuthnProjection = `
(() => {
const bytes = () => new Uint8Array([117, 105]).buffer;
const core = () => ({ id: 'ui-projection-only', rawId: bytes(), type: 'public-key', getClientExtensionResults: () => ({}) });
Object.defineProperty(navigator.credentials, 'create', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), attestationObject: bytes(), getTransports: () => ['internal'] } }) });
Object.defineProperty(navigator.credentials, 'get', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), authenticatorData: bytes(), signature: bytes(), userHandle: null } }) });
})();
`;
const user = {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
};
const stages = new Map<string, "enrolled" | "login">();
export function setupProjectionResponse(
path: string,
mode: string,
reset: boolean,
): Response | null {
if (!mode.startsWith("setup-")) return null;
if (reset) stages.delete(mode);
if (path === "/__ui_fixture/webauthn.js") {
return new Response(setupWebAuthnProjection, {
headers: { "content-type": "text/javascript" },
});
}
if (path === "/api/auth/whoami") {
return Response.json({
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: stages.get(mode) === "login" });
}
if (path === "/api/auth/passkeys/addition/options") {
if (mode === "setup-reject") {
return new Response("synthetic enrollment rejection", { status: 403 });
}
return Response.json({
challenge_id: "fixture-add",
public_key: {
challenge: "dWk",
rp: { name: "UI projection" },
user: { id: "dWk", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
});
}
if (path === "/api/auth/passkeys/addition/complete") {
stages.set(mode, "enrolled");
return Response.json({ user });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } });
}
if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login");
return Response.json({ user });
}
return null;
}
@@ -0,0 +1,184 @@
/// <reference lib="dom" />
// UI projectionのみ。実APIのBearer認可・WebAuthn・cookie発行は検証しない。
// requestを観測する場合もmethod/pathだけとし、headers/bodyは取得・保存しない。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/setup-interactions`;
async function noOverflow(page: Page) {
assertEquals(
await page.evaluate(() => ({
page: document.documentElement.scrollWidth > innerWidth,
main: [...document.querySelectorAll("main")].some((main) =>
main.scrollWidth > main.clientWidth
),
})),
{ page: false, main: false },
);
}
Deno.test("setup UI separates masked host-token enrollment, rejected attempts, explicit login and verified settings navigation", async () => {
const processes = await startOwnedProcesses(
[{
id: "setup-interactions",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-net",
"--allow-read",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15380",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15380/health",
readyTimeoutMs: 10000,
}],
`${root}/tools/web-ux/scenarios/hosting-setup-light.json`,
evidence,
[],
);
const browser = await chromium.launch();
const results = [];
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const pageErrors: string[] = [],
consoleErrors: string[] = [],
httpErrors: string[] = [],
requestFailures: string[] = [];
const methodsAndPaths: string[] = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
}
});
page.on(
"requestfailed",
(request) => requestFailures.push(new URL(request.url()).pathname),
);
page.on(
"request",
(request) =>
methodsAndPaths.push(`${request.method()} ${new URL(request.url()).pathname}`),
);
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-flow");
const input = page.locator("#host-setup-token");
await input.waitFor();
const enroll = page.getByRole("button", {
name: "Add administrator passkey",
exact: true,
});
assertEquals(await input.getAttribute("type"), "password");
assertEquals(await enroll.isDisabled(), true);
assert(
!methodsAndPaths.some((entry) => entry.startsWith("POST ")),
);
assertEquals(
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
0,
);
await noOverflow(page);
await page.locator("summary").focus();
await page.keyboard.press("Enter");
await page.locator("details[open]").waitFor();
await noOverflow(page);
await page.keyboard.press("Enter");
const synthetic = `ui-projection-${crypto.randomUUID()}`;
await input.fill(synthetic);
assertEquals(await enroll.isEnabled(), true);
await input.focus();
await page.keyboard.press("Tab");
assertEquals(
await enroll.evaluate((element) => element === document.activeElement),
true,
);
assertEquals(
await enroll.evaluate((element) => getComputedStyle(element).outlineStyle !== "none"),
true,
);
await page.keyboard.press("Enter");
const login = page.getByRole("button", { name: "Sign in with new passkey", exact: true });
await login.waitFor();
await page.getByRole("status").filter({ hasText: "Enrollment is not a passkey login." })
.waitFor();
assertEquals(await input.count(), 0);
assertEquals(
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
0,
);
assertEquals(
methodsAndPaths.filter((entry) => entry.startsWith("POST ")),
[
"POST /api/auth/passkeys/addition/options",
"POST /api/auth/passkeys/addition/complete",
],
);
await noOverflow(page);
await login.focus();
await page.keyboard.press("Enter");
const settings = page.getByRole("link", { name: "Open hosting settings", exact: true });
await settings.waitFor();
await page.getByRole("status").filter({
hasText: "Passkey login and hosting administrator access verified.",
}).waitFor();
await noOverflow(page);
await settings.focus();
await page.keyboard.press("Enter");
await page.getByRole("button", { name: "Edit registration policy", exact: true })
.waitFor();
await noOverflow(page);
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-reject");
await input.fill(synthetic);
await enroll.click();
const error = page.getByRole("alert");
await error.waitFor();
assert(!(await input.inputValue()));
assertEquals(await enroll.isDisabled(), true);
assert(!(await error.innerText()).includes(synthetic));
assertEquals(await settings.count(), 0);
await noOverflow(page);
assertEquals(pageErrors, []);
assertEquals(httpErrors, ["403 /api/auth/passkeys/addition/options"]);
assertEquals(consoleErrors.length, 1);
assert(consoleErrors[0].includes("403"));
assert(requestFailures.every((path) => path === "/api/auth/passkeys/addition/options"));
results.push({
width,
theme: colorScheme,
emptyDisabled: true,
passwordMasked: true,
keyboard: "pass",
tokenClearedAfterRejectedAttempt: true,
enrollmentNotLogin: true,
explicitLoginBeforeSettings: true,
overflow: "none",
unexpectedErrors: 0,
requestHeadersOrBodiesRecorded: false,
realAuthenticationTested: false,
});
} finally {
await context.close();
}
}
}
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});