fix: harden Web UX capture profiles

This commit is contained in:
2026-09-01 23:00:28 +09:00
parent 4a4a01b730
commit c4a3f4ba1e
11 changed files with 247 additions and 35 deletions
-1
View File
@@ -6,4 +6,3 @@
*.local*
.env
.web-ux/
artifacts/web-ux/
+25 -12
View File
@@ -57,7 +57,9 @@ export WORKSPACE_ID='<workspace-id>'
Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with
mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies
or credentials.
or credentials. Each profile has a sidecar binding it to the exact persona and base URL origin with
a 12-hour default expiry. Capture fails explicitly when metadata is missing, the origin differs, or
the profile has expired; it never silently reuses or refreshes that state.
For an interactive Passkey/browser login:
@@ -78,7 +80,17 @@ without putting its value on the command line:
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner \
--import-state /private/path/owner-state.json
--import-state /private/path/owner-state.json \
--expires-in-hours 8
```
Delete both the profile and its metadata when it is no longer needed:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner \
--delete
```
Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a
@@ -93,7 +105,7 @@ Capture a stable multi-persona bundle:
```sh
deno task web-ux capture \
--scenario scenarios/workspace-control-plane.json \
--output ../../artifacts/web-ux \
--output ../../target/web-ux \
--run-id before-change
```
@@ -102,7 +114,7 @@ Use filters for a bounded feedback loop:
```sh
deno task web-ux capture \
--scenario scenarios/workspace-control-plane.json \
--output ../../artifacts/web-ux \
--output ../../target/web-ux \
--run-id ticket-list-after \
--personas owner,non-owner \
--routes tickets \
@@ -127,9 +139,9 @@ the new evidence. Playwright success alone is not visual acceptance.
```sh
deno task web-ux compare \
--before ../../artifacts/web-ux/before-change/review-context.json \
--after ../../artifacts/web-ux/after-change/review-context.json \
--output ../../artifacts/web-ux/before-vs-after
--before ../../target/web-ux/before-change/review-context.json \
--after ../../target/web-ux/after-change/review-context.json \
--output ../../target/web-ux/before-vs-after
```
`comparison.html` and `comparison.png` show before, after, and pixel diff side by side.
@@ -150,13 +162,14 @@ that it did not start.
Old complete review bundles can be removed without touching auth state or arbitrary directories:
```sh
deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 --dry-run
deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
```
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and
`artifacts/web-ux/` are ignored by Git. Keep a bundle outside Git or publish it through the approved
immutable artifact channel when durable review evidence is required.
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository
`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted.
Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable
review evidence is required.
## Adding a scenario
@@ -1,5 +1,6 @@
import { assertEquals, assertRejects } from "@std/assert";
import { join } from "@std/path";
import { writeAuthMetadata } from "../src/auth_state.ts";
import { capture } from "../src/capture.ts";
async function freePort(): Promise<number> {
@@ -17,8 +18,9 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
const authDirectory = join(directory, "auth");
await Deno.mkdir(authDirectory);
for (const persona of ["owner", "non-owner"]) {
const storageState = join(authDirectory, `${persona}.json`);
await Deno.writeTextFile(
join(authDirectory, `${persona}.json`),
storageState,
JSON.stringify({
cookies: [{
name: "persona",
@@ -33,6 +35,7 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
origins: [],
}),
);
await writeAuthMetadata(storageState, persona, baseUrl, 1);
}
const scenarioPath = join(directory, "scenario.json");
await Deno.writeTextFile(
+19 -7
View File
@@ -1,13 +1,17 @@
#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys
import { dirname, fromFileUrl, resolve } from "@std/path";
import { authenticate, cleanup } from "./src/lifecycle.ts";
import { capture, describeCapture } from "./src/capture.ts";
import { compare } from "./src/compare.ts";
const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux");
const HELP = `Web UX inspection workbench
Usage:
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--headless]
deno task web-ux capture --scenario <file> --output <directory> [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--expires-in-hours <hours>] [--headless]
deno task web-ux auth --scenario <file> --persona <id> --delete
deno task web-ux capture --scenario <file> [--output <directory>] [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
deno task web-ux compare --before <review-context.json> --after <review-context.json> --output <directory> [--threshold <0..1>]
deno task web-ux cleanup --output <directory> [--keep <count>] [--older-than-days <days>] [--dry-run]
@@ -81,18 +85,26 @@ export async function main(rawArgs: string[]): Promise<number> {
return 0;
}
if (args.command === "auth") {
rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [
"headless",
]);
rejectUnknown(
args,
["scenario", "persona", "base-url", "import-state", "timeout-ms", "expires-in-hours"],
["headless", "delete"],
);
const deleting = args.flags.has("delete");
if (deleting && optional(args, "import-state")) {
throw new Error("--delete cannot be combined with --import-state");
}
const path = await authenticate({
scenarioPath: required(args, "scenario"),
personaId: required(args, "persona"),
baseUrl: optional(args, "base-url"),
importState: optional(args, "import-state"),
timeoutMs: integer(args, "timeout-ms"),
expiresInHours: integer(args, "expires-in-hours"),
delete: deleting,
headless: args.flags.has("headless"),
});
console.log(`auth state saved: ${path}`);
console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`);
return 0;
}
if (args.command === "capture") {
@@ -105,7 +117,7 @@ export async function main(rawArgs: string[]): Promise<number> {
"routes",
"viewports",
], ["headed"]);
const outputDirectory = required(args, "output");
const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT;
const manifest = await capture({
scenarioPath: required(args, "scenario"),
outputDirectory,
@@ -23,6 +23,15 @@
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
"capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }]
},
{
"id": "account",
"label": "Account entry",
"path": "/account",
"goal": "Understand current authentication state and the available account action without Workspace-private content.",
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
"capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }]
}
]
}
@@ -57,6 +57,15 @@
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }]
},
{
"id": "settings",
"label": "Workspace settings",
"path": "/w/${WORKSPACE_ID}/settings",
"goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.",
"dataState": "Current permission projection for the selected Workspace.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }]
},
{
"id": "repositories",
"label": "Repository settings",
@@ -65,6 +74,17 @@
"dataState": "Workspace repository catalog projected through current permissions.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }]
},
{
"id": "repository-access",
"label": "Repository access",
"path": "/w/${WORKSPACE_ID}/settings/repository-access",
"goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.",
"dataState": "Configured repository access bindings projected without credential bytes.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [
{ "id": "initial", "label": "Repository access settings", "fullPage": true }
]
}
]
}
+87
View File
@@ -0,0 +1,87 @@
import { writePrivateJson } from "./artifacts.ts";
export type AuthStateMetadata = {
schemaVersion: 1;
personaId: string;
baseOrigin: string;
createdAt: string;
expiresAt: string;
};
export function authMetadataPath(storageStatePath: string): string {
return `${storageStatePath}.meta.json`;
}
function baseOrigin(baseUrl: string): string {
return new URL(baseUrl).origin;
}
export async function writeAuthMetadata(
storageStatePath: string,
personaId: string,
baseUrl: string,
expiresInHours: number,
): Promise<void> {
if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) {
throw new Error("auth state expiry must be a positive number of hours");
}
const createdAt = new Date();
const metadata: AuthStateMetadata = {
schemaVersion: 1,
personaId,
baseOrigin: baseOrigin(baseUrl),
createdAt: createdAt.toISOString(),
expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(),
};
await writePrivateJson(authMetadataPath(storageStatePath), metadata);
}
export async function validateAuthState(
storageStatePath: string,
personaId: string,
baseUrl: string,
now = new Date(),
): Promise<void> {
await Deno.stat(storageStatePath);
let parsed: unknown;
try {
parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath)));
} catch (error) {
throw new Error(
`auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`auth state metadata is invalid for ${personaId}`);
}
const metadata = parsed as Partial<AuthStateMetadata>;
if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) {
throw new Error(`auth state metadata does not match persona ${personaId}`);
}
if (metadata.baseOrigin !== baseOrigin(baseUrl)) {
throw new Error(
`auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${
baseOrigin(baseUrl)
}`,
);
}
const expiresAt = Date.parse(metadata.expiresAt ?? "");
if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`);
if (expiresAt <= now.getTime()) {
throw new Error(
`auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`,
);
}
}
export async function deleteAuthState(storageStatePath: string): Promise<void> {
for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) {
try {
await Deno.remove(path);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { basename, dirname, join, relative, resolve } from "@std/path";
import { type Browser, chromium, type Page, type Response } from "playwright";
import { validateAuthState } from "./auth_state.ts";
import {
assertBundleIsSecretFree,
bounded,
@@ -348,7 +349,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
const storageState = persona.auth.kind === "storage-state"
? resolveScenarioPath(scenarioPath, persona.auth.path)
: undefined;
if (storageState) await Deno.stat(storageState);
if (storageState) await validateAuthState(storageState, persona.id, baseUrl);
for (const viewport of viewports) {
const context = await browser.newContext({
storageState,
+21 -11
View File
@@ -1,6 +1,7 @@
import { dirname, resolve } from "@std/path";
import { chromium } from "playwright";
import { ensurePrivateDirectory, writePrivateJson } from "./artifacts.ts";
import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts";
import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts";
import {
interpolateEnvironment,
loadScenario,
@@ -14,6 +15,8 @@ export type AuthOptions = {
baseUrl?: string;
importState?: string;
timeoutMs?: number;
expiresInHours?: number;
delete?: boolean;
headless?: boolean;
};
@@ -37,22 +40,28 @@ export async function authenticate(options: AuthOptions): Promise<string> {
throw new Error(`persona ${persona.id} is anonymous and has no auth state`);
}
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
await ensurePrivateDirectory(dirname(outputPath));
if (options.importState) {
const imported = validateStorageState(
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
);
await writePrivateJson(outputPath, imported);
if (options.delete) {
await deleteAuthState(outputPath);
return outputPath;
}
if (!persona.login) {
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
}
await ensurePrivateDirectory(dirname(outputPath));
const baseUrl = validateBaseUrl(
interpolateEnvironment(
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
),
);
const expiresInHours = options.expiresInHours ?? 12;
if (options.importState) {
const imported = validateStorageState(
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
);
await writePrivateJson(outputPath, imported);
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
return outputPath;
}
if (!persona.login) {
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
}
const browser = await chromium.launch({ headless: options.headless ?? false });
try {
const context = await browser.newContext();
@@ -67,7 +76,8 @@ export async function authenticate(options: AuthOptions): Promise<string> {
});
}
await context.storageState({ path: outputPath });
if (Deno.build.os !== "windows") await Deno.chmod(outputPath, 0o600);
await makePrivate(outputPath);
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
return outputPath;
} finally {
await browser.close();
+4 -2
View File
@@ -38,8 +38,10 @@ async function waitForReady(url: string, timeoutMs: number): Promise<void> {
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
if (response.status < 500) return;
lastError = `HTTP ${response.status}`;
const status = response.status;
await response.body?.cancel();
if (status < 500) return;
lastError = `HTTP ${status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
+56
View File
@@ -0,0 +1,56 @@
import { assertRejects } from "@std/assert";
import { join } from "@std/path";
import {
authMetadataPath,
deleteAuthState,
validateAuthState,
writeAuthMetadata,
} from "../src/auth_state.ts";
Deno.test("auth state is bound to persona, base origin, and expiry", async () => {
const directory = await Deno.makeTempDir();
try {
const state = join(directory, "owner.json");
await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}');
await writeAuthMetadata(state, "owner", "https://example.test/path", 1);
await validateAuthState(state, "owner", "https://example.test/other");
await assertRejects(
() => validateAuthState(state, "non-owner", "https://example.test"),
Error,
"does not match persona",
);
await assertRejects(
() => validateAuthState(state, "owner", "https://other.test"),
Error,
"belongs to https://example.test",
);
await assertRejects(
() =>
validateAuthState(
state,
"owner",
"https://example.test",
new Date(Date.now() + 2 * 60 * 60 * 1000),
),
Error,
"auth state expired",
);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("auth state deletion removes state and metadata idempotently", async () => {
const directory = await Deno.makeTempDir();
try {
const state = join(directory, "owner.json");
await Deno.writeTextFile(state, "{}");
await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1);
await deleteAuthState(state);
await deleteAuthState(state);
await assertRejects(() => Deno.stat(state), Deno.errors.NotFound);
await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound);
} finally {
await Deno.remove(directory, { recursive: true });
}
});