feat: activate provisional users with scoped passkey enrollment
This commit is contained in:
@@ -6,8 +6,9 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
||||
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
|
||||
@@ -7,6 +7,12 @@ export type HostingSettings = { self_registration_enabled: boolean, };
|
||||
|
||||
export type HostingAccess = { is_admin: boolean, };
|
||||
|
||||
export type ProvisionalAccountRequest = { handle: string, };
|
||||
|
||||
export type ActivationCodeResponse = { user: AuthenticatedUser, code: string, expires_at: string, };
|
||||
|
||||
export type ActivationOptionsRequest = { handle: string, code: string, browser_origin?: string | null, };
|
||||
|
||||
export type ActorAuthMethod = "browser_session" | "api_token";
|
||||
|
||||
export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
import { activateWithPasskey } from "./activation.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
function equal(a: unknown, b: unknown) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
|
||||
}
|
||||
async function rejects(run: Promise<unknown>) {
|
||||
try {
|
||||
await run;
|
||||
} catch (error) {
|
||||
equal((error as Error).message, "Activation could not be confirmed.");
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
async function boundary(
|
||||
fetcher: typeof fetch,
|
||||
create: (options: CredentialCreationOptions) => Promise<Credential | null>,
|
||||
run: () => Promise<void>,
|
||||
) {
|
||||
const saved = ["fetch", "window", "navigator"].map((key) =>
|
||||
[key, Object.getOwnPropertyDescriptor(globalThis, key)] as const
|
||||
);
|
||||
Object.defineProperties(globalThis, {
|
||||
fetch: { configurable: true, value: fetcher },
|
||||
window: {
|
||||
configurable: true,
|
||||
value: {
|
||||
isSecureContext: true,
|
||||
location: { origin: "https://example.test" },
|
||||
},
|
||||
},
|
||||
navigator: { configurable: true, value: { credentials: { create } } },
|
||||
});
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, descriptor] of saved) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// HTTP/WebAuthn境界の代替。本物のServerによるcode検証やcredential保存の試験ではない。
|
||||
Deno.test("activation uses only dedicated endpoints and confirms normalized target after completion", async () => {
|
||||
const requests: { path: string; init: RequestInit }[] = [];
|
||||
const signal = new AbortController().signal;
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
);
|
||||
}, async (options) => {
|
||||
equal(options.signal === signal, true);
|
||||
return credentialFixture();
|
||||
}, async () => {
|
||||
equal(
|
||||
await activateWithPasskey(" ALIce ", "synthetic-input-only", signal),
|
||||
authUser,
|
||||
);
|
||||
equal(requests.map((r) => r.path), [
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
equal(init.credentials, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.cache, "no-store");
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
equal(new Headers(init.headers).has("authorization"), false);
|
||||
}
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
handle: "alice",
|
||||
code: "synthetic-input-only",
|
||||
browser_origin: "https://example.test",
|
||||
});
|
||||
equal(Object.keys(JSON.parse(String(requests[1].init.body))).sort(), [
|
||||
"challenge_id",
|
||||
"credential",
|
||||
]);
|
||||
equal(
|
||||
String(requests[1].init.body).includes("synthetic-input-only"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
Deno.test("activation cancellation and abort never send complete or return active", async () => {
|
||||
for (const mode of ["cancel", "abort", "null"] as const) {
|
||||
let calls = 0;
|
||||
const controller = new AbortController();
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
return Response.json(registrationOptions);
|
||||
}, async () => {
|
||||
if (mode === "cancel") throw new Error("synthetic-input-only");
|
||||
if (mode === "abort") controller.abort();
|
||||
return mode === "null" ? null : credentialFixture();
|
||||
}, async () => {
|
||||
await rejects(
|
||||
activateWithPasskey("alice", "synthetic-input-only", controller.signal),
|
||||
);
|
||||
equal(calls, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("activation sanitizes HTTP transport malformed and mismatched completion without retry", async () => {
|
||||
for (const mode of ["http", "throw", "malformed", "mismatch"] as const) {
|
||||
let calls = 0;
|
||||
await boundary(
|
||||
async (input) => {
|
||||
calls++;
|
||||
if (mode === "throw") throw new Error("synthetic-input-only");
|
||||
if (mode === "http") {
|
||||
return new Response("synthetic-input-only", { status: 403 });
|
||||
}
|
||||
if (mode === "malformed") {
|
||||
return Response.json({ unexpected: "synthetic-input-only" });
|
||||
}
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: { ...authUser, handle: "other" } },
|
||||
);
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(calls, mode === "mismatch" ? 2 : 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Deno.test("activation invalid IDs and oversized code are rejected before network", async () => {
|
||||
let calls = 0;
|
||||
await boundary(
|
||||
async () => {
|
||||
calls++;
|
||||
return Response.json({});
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
async () => {
|
||||
for (const handle of ["", "a/b", "A", "i̇", "a".repeat(65)]) {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
handle,
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
}
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"x".repeat(4097),
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(calls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
ActivationOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
parseAuthUserResponse,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
} from "./model.ts";
|
||||
|
||||
// 一時codeは通常auth clientや本人の鍵追加APIへ渡さない。options取得はsessionではない。
|
||||
// 再開はID+codeの再入力から行い、ceremony/codeをURL・storageへ保存しない。
|
||||
// Cookieが存在しても対象はServerがcodeから決め、complete成功時だけ新sessionを受け取る。
|
||||
export async function activateWithPasskey(
|
||||
handle: string,
|
||||
code: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
if (window.isSecureContext !== true || !code || code.length > 4096) {
|
||||
throw new Error("Activation unavailable");
|
||||
}
|
||||
const request = async (
|
||||
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
|
||||
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error("Activation rejected");
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
};
|
||||
const options = await request("/api/auth/activation/options", {
|
||||
handle,
|
||||
code,
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
code = "";
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
signal.throwIfAborted();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey,
|
||||
signal,
|
||||
});
|
||||
if (!credential) throw new Error("Registration cancelled");
|
||||
signal.throwIfAborted();
|
||||
const result = parseAuthUserResponse(
|
||||
await request("/api/auth/activation/complete", {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
}),
|
||||
).user;
|
||||
signal.throwIfAborted();
|
||||
if (result.handle !== handle) throw new Error("Activation target mismatch");
|
||||
return result;
|
||||
} catch {
|
||||
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
|
||||
throw new Error("Activation could not be confirmed.");
|
||||
} finally {
|
||||
code = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
import {
|
||||
issueActivationCode,
|
||||
normalizeActivationHandle,
|
||||
} from "./hosting-accounts.ts";
|
||||
import { HostingRequestError } from "./hosting.ts";
|
||||
import { authUser } from "./api.test-fixtures.ts";
|
||||
function equal(a: unknown, b: unknown) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
|
||||
}
|
||||
async function boundary(fetcher: typeof fetch, run: () => Promise<void>) {
|
||||
const saved = globalThis.fetch;
|
||||
globalThis.fetch = fetcher;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
globalThis.fetch = saved;
|
||||
}
|
||||
}
|
||||
const response = () => ({
|
||||
user: authUser,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
Deno.test("hosting issuance normalizes ASCII handle and selects create or explicit reissue using cookie only", async () => {
|
||||
for (const reissue of [false, true]) {
|
||||
let calls = 0;
|
||||
await boundary(async (input, requestInit) => {
|
||||
const init = requestInit as RequestInit;
|
||||
calls++;
|
||||
equal(
|
||||
input,
|
||||
reissue
|
||||
? "/api/hosting/accounts/activation-code"
|
||||
: "/api/hosting/accounts",
|
||||
);
|
||||
equal(JSON.parse(String(init?.body)), { handle: "alice" });
|
||||
equal(init?.method, "POST");
|
||||
equal(init?.credentials, "same-origin");
|
||||
equal(init?.redirect, "error");
|
||||
equal(init?.cache, "no-store");
|
||||
equal(init?.referrerPolicy, "no-referrer");
|
||||
equal(new Headers(init?.headers).has("authorization"), false);
|
||||
return Response.json(response());
|
||||
}, async () => {
|
||||
const issued = await issueActivationCode(
|
||||
" ALIce ",
|
||||
reissue,
|
||||
new AbortController().signal,
|
||||
);
|
||||
equal(issued.user, authUser);
|
||||
equal(calls, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("hosting issuance rejects malformed mismatched secret-bearing responses and transport diagnostics", async () => {
|
||||
for (
|
||||
const mode of ["throw", "malformed", "mismatch", "extra", "expiry"] as const
|
||||
) {
|
||||
await boundary(async () => {
|
||||
if (mode === "throw") throw new Error("synthetic-output-only");
|
||||
const value = response();
|
||||
return Response.json(
|
||||
mode === "malformed"
|
||||
? { code: value.code }
|
||||
: mode === "extra"
|
||||
? { ...value, extra: true }
|
||||
: mode === "expiry"
|
||||
? { ...value, expires_at: "never" }
|
||||
: { ...value, user: { ...authUser, handle: "other" } },
|
||||
);
|
||||
}, async () => {
|
||||
try {
|
||||
await issueActivationCode("alice", false, new AbortController().signal);
|
||||
} catch (error) {
|
||||
equal(
|
||||
(error as Error).message,
|
||||
"Code issuance could not be confirmed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("hosting issuance exposes only HTTP status for denial and never retries", async () => {
|
||||
for (const status of [401, 403, 409, 503]) {
|
||||
let calls = 0;
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
return new Response("synthetic-output-only", { status });
|
||||
}, async () => {
|
||||
try {
|
||||
await issueActivationCode("alice", true, new AbortController().signal);
|
||||
} catch (error) {
|
||||
equal(error instanceof HostingRequestError, true);
|
||||
equal((error as HostingRequestError).status, status);
|
||||
equal(
|
||||
(error as Error).message.includes("synthetic-output-only"),
|
||||
false,
|
||||
);
|
||||
equal(calls, 1);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("hosting ID normalization rejects Unicode and invalid length instead of changing account identity", async () => {
|
||||
equal(normalizeActivationHandle(" AbC-_09 "), "abc-_09");
|
||||
for (const value of ["", "a".repeat(65), "A", "a.b", "a b", "İ"]) {
|
||||
let rejected = false;
|
||||
try {
|
||||
normalizeActivationHandle(value);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
equal(rejected, true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { HostingRequestError } from "./hosting.ts";
|
||||
import { parseAuthUserResponse } from "./model.ts";
|
||||
|
||||
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
|
||||
export function normalizeActivationHandle(value: string): string {
|
||||
const handle = value.trim().replace(
|
||||
/[A-Z]/g,
|
||||
(letter) => letter.toLowerCase(),
|
||||
);
|
||||
if (!/^[a-z0-9_-]{1,64}$/.test(handle)) {
|
||||
throw new Error("Invalid account ID.");
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
// 管理者は新規IDを作成して、発行結果のcodeと/activateの場所を本人へ別々に渡す。
|
||||
// 再発行は仮accountにだけ行い、旧code/ceremonyは失効する。応答喪失時は自動再送せず、
|
||||
// 本人のPasskey login確認後に必要なら明示再発行する。codeは発行結果のmemoryだけで扱う。
|
||||
export async function issueActivationCode(
|
||||
handle: string,
|
||||
reissue: boolean,
|
||||
signal: AbortSignal,
|
||||
): Promise<ActivationCodeResponse> {
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
const response = await fetch(
|
||||
reissue
|
||||
? "/api/hosting/accounts/activation-code"
|
||||
: "/api/hosting/accounts",
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ handle }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new HostingRequestError(response.status);
|
||||
}
|
||||
const value = await readBoundedAuthResponseJson(response);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).sort().join(",") !== "code,expires_at,user" ||
|
||||
typeof record.code !== "string" || !record.code ||
|
||||
record.code.length > 4096 ||
|
||||
typeof record.expires_at !== "string" ||
|
||||
!Number.isFinite(Date.parse(record.expires_at))
|
||||
) throw new Error();
|
||||
const user = parseAuthUserResponse({ user: record.user }).user;
|
||||
if (user.handle !== handle) throw new Error();
|
||||
signal.throwIfAborted();
|
||||
return { user, code: record.code, expires_at: record.expires_at };
|
||||
} catch (error) {
|
||||
if (error instanceof HostingRequestError) throw error;
|
||||
// HTTP本文、JSON/transport元例外には秘密が含まれ得る。
|
||||
throw new Error("Code issuance could not be confirmed.");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts';
|
||||
|
||||
let { children, data }: LayoutProps = $props();
|
||||
// Hostingと初回登録はWorkspaceに依存しない。login全体の分離はここで扱わない。
|
||||
const standalone = $derived(page.url.pathname === '/activate' || page.url.pathname.startsWith('/hosting/'));
|
||||
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||
@@ -55,7 +57,7 @@
|
||||
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
|
||||
|
||||
$effect(() => {
|
||||
const error = catalog.data?.workspaceCatalogError;
|
||||
const error = !standalone && catalog.data?.workspaceCatalogError;
|
||||
if (error) {
|
||||
pushWorkspaceAlert('error', error, {
|
||||
id: 'workspace-catalog-load',
|
||||
@@ -95,6 +97,11 @@
|
||||
|
||||
<WorkspaceAlerts />
|
||||
|
||||
{#if standalone}
|
||||
<main class="standalone-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
{:else}
|
||||
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
|
||||
<SidebarFrame
|
||||
mode={sidebarMode}
|
||||
@@ -139,8 +146,17 @@
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.standalone-main {
|
||||
width: min(100%, 48rem);
|
||||
min-width: 0;
|
||||
min-height: 100dvh;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { Load } from "@sveltejs/kit";
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load = ({ fetch }: Parameters<Load>[0]) => ({
|
||||
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({
|
||||
deferredCatalog: settle((async () => {
|
||||
// 初回有効化とhosting管理はWorkspace未作成でも利用する。
|
||||
if (url.pathname === "/activate" || url.pathname.startsWith("/hosting/")) {
|
||||
return { accessibleWorkspaces: [], workspaceCatalogError: null };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
accessibleWorkspaces: await listWorkspaces(fetch),
|
||||
@@ -14,7 +18,9 @@ export const load = ({ fetch }: Parameters<Load>[0]) => ({
|
||||
} catch (error) {
|
||||
return {
|
||||
accessibleWorkspaces: [],
|
||||
workspaceCatalogError: error instanceof Error ? error.message : "Unable to load Workspaces",
|
||||
workspaceCatalogError: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load Workspaces",
|
||||
};
|
||||
}
|
||||
})()),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
|
||||
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
|
||||
let handle = $state('');
|
||||
let code = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let user = $state<AuthUser | null>(null);
|
||||
let controller: AbortController | null = null;
|
||||
let generation = 0;
|
||||
// 本人はID+codeから開始し、WebAuthnとcomplete応答が揃うまで通常利用へ進ませない。
|
||||
// cancel/reloadはsecretを破棄して再入力。通信断ではactiveを推定しない。
|
||||
async function activate() {
|
||||
if (busy || user || !code) return;
|
||||
try { handle = normalizeActivationHandle(handle); }
|
||||
catch { error = 'Check the account ID supplied by your administrator.'; return; }
|
||||
const current = ++generation;
|
||||
controller = new AbortController(); busy = true; error = '';
|
||||
const pending = activateWithPasskey(handle, code, controller.signal);
|
||||
code = '';
|
||||
try {
|
||||
const confirmed = await pending;
|
||||
if (current !== generation) return;
|
||||
user = confirmed;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (current !== generation) return;
|
||||
error = 'Registration completion could not be confirmed. If you completed the Passkey prompt, check Passkey login from Account first. Otherwise re-enter the ID and code to restart. Ask your administrator to reissue if the code expired, was locked or was replaced.';
|
||||
} finally { if (current === generation) { code = ''; busy = false; controller = null; } }
|
||||
}
|
||||
function cancel() {
|
||||
++generation; controller?.abort(); controller = null; code = ''; busy = false;
|
||||
error = 'Registration stopped; completion is not confirmed. If you completed the Passkey prompt, check Passkey login from Account first. Otherwise re-enter the ID and code to restart.';
|
||||
}
|
||||
onMount(() => {
|
||||
const clear = () => { ++generation; controller?.abort(); code = ''; busy = false; };
|
||||
window.addEventListener('pagehide', clear);
|
||||
return () => window.removeEventListener('pagehide', clear);
|
||||
});
|
||||
onDestroy(() => { ++generation; controller?.abort(); code = ''; });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Activate account · Yoi</title></svelte:head>
|
||||
<div class="activation" data-sveltekit-preload-data="off">
|
||||
<h1>Activate account</h1>
|
||||
{#if user}
|
||||
<p role="status">Passkey registration and activation confirmed for {user.handle}. You are signed in.</p>
|
||||
<p>Use your Passkey for future logins. The activation code can no longer be used.</p>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else}
|
||||
<p>Enter the ID and one-time code provided by your hosting administrator. You must register a Passkey before this account can be used.</p>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void activate(); }} autocomplete="off">
|
||||
<label for="activation-handle">Account ID (handle)</label>
|
||||
<input id="activation-handle" bind:value={handle} required maxlength="128" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} />
|
||||
<label for="activation-code">Activation code</label>
|
||||
<input id="activation-code" type="password" bind:value={code} required maxlength="4096" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} aria-describedby="code-help" />
|
||||
<p id="code-help" class="muted">Use only this Server’s trusted HTTPS origin (or localhost). The code expires after 15 minutes or five incorrect attempts. It is not a password or an administrator setup token.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" disabled={busy || !handle.trim() || !code}>{busy ? 'Registering Passkey…' : 'Register Passkey and activate'}</button>
|
||||
{#if busy}<button type="button" onclick={cancel}>Cancel registration</button>{/if}
|
||||
</div>
|
||||
</form>
|
||||
{#if busy}<p role="status">Passkey registration pending. Account activation has not been confirmed.</p>{/if}
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<a href="/account">Already registered or completion uncertain? Check Passkey login from Account</a>
|
||||
<details><summary>Restart or get a new code</summary><p>Allow the browser’s Passkey prompt and save a Passkey. Only a confirmed completion signs you in; checking the code alone gives no normal session, Workspace access or device approval. After cancellation or reload, enter the ID and code again. A correct code is not consumed by starting the prompt. If a request lost its response, try Passkey login before restarting. If no Passkey works, ask your administrator to reissue for the pending ID; reissue invalidates the old code and prompt. This works even when self-registration is disabled.</p></details>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.activation, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.activation { max-width: 42rem; }
|
||||
h1, p { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
input { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; }
|
||||
a:hover, a:active { color: var(--text); }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Activate from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
const create = vi.fn(async () => {
|
||||
if (mode === "pending") return await new Promise<Credential>(() => {});
|
||||
if (mode === "cancel") throw new Error("private diagnostic");
|
||||
return credentialFixture();
|
||||
});
|
||||
vi.stubGlobal("navigator", { credentials: { create } });
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input) === "/api/auth/activation/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (String(input) === "/api/auth/activation/complete") {
|
||||
if (mode === "unknown") throw new Error("private diagnostic");
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create };
|
||||
}
|
||||
async function submit() {
|
||||
await fireEvent.input(screen.getByLabelText("Account ID (handle)"), {
|
||||
target: { value: " ALICE " },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText("Activation code"), {
|
||||
target: { value: "synthetic-input-only" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Register Passkey and activate" }),
|
||||
);
|
||||
}
|
||||
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
|
||||
const { fetcher } = boundary("success");
|
||||
render(Activate);
|
||||
await submit();
|
||||
await screen.findByText(/registration and activation confirmed/);
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
]);
|
||||
expect(screen.queryByLabelText("Activation code")).toBeNull();
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
|
||||
"alice",
|
||||
);
|
||||
});
|
||||
test("pending ceremony never exposes activation or workspace actions and explicit cancel clears the input", async () => {
|
||||
const { fetcher } = boundary("pending");
|
||||
render(Activate);
|
||||
await submit();
|
||||
await screen.findByText(/activation has not been confirmed/);
|
||||
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Cancel registration" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(fetcher.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
(screen.getByLabelText("Activation code") as HTMLInputElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
for (const mode of ["cancel", "unknown"] as const) {
|
||||
test(`${mode} does not claim active or echo diagnostics and offers login-first recovery`, async () => {
|
||||
boundary(mode);
|
||||
render(Activate);
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain("Passkey login")
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"private diagnostic",
|
||||
);
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { ActivationCodeResponse } from '#lib/generated/auth-api.ts';
|
||||
import { loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import { HostingRequestError, loadHostingAccess, SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
|
||||
import { issueActivationCode, normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||
|
||||
let viewState = $state<'loading' | 'anonymous' | 'denied' | 'error' | 'ready'>('loading');
|
||||
let handle = $state('');
|
||||
let operation = $state<'create' | 'reissue'>('create');
|
||||
let issued = $state<ActivationCodeResponse | null>(null);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let generation = 0;
|
||||
let controller = new AbortController();
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function clearIssued() { issued = null; clearTimeout(expiryTimer); }
|
||||
async function load() {
|
||||
const current = ++generation;
|
||||
controller.abort(); controller = new AbortController();
|
||||
clearIssued(); busy = false; viewState = 'loading'; error = '';
|
||||
try {
|
||||
const { actor } = await loadWhoami();
|
||||
if (current !== generation) return;
|
||||
if (!actor) { viewState = 'anonymous'; return; }
|
||||
const access = await loadHostingAccess();
|
||||
if (current !== generation) return;
|
||||
viewState = access.is_admin ? 'ready' : 'denied';
|
||||
} catch { if (current === generation) viewState = 'error'; }
|
||||
}
|
||||
async function issue() {
|
||||
if (busy || viewState !== 'ready') return;
|
||||
clearIssued(); error = '';
|
||||
try { handle = normalizeActivationHandle(handle); }
|
||||
catch { error = 'Use 1–64 ASCII letters, numbers, hyphens or underscores for the account ID.'; return; }
|
||||
const current = generation;
|
||||
busy = true;
|
||||
try {
|
||||
// 表示済みaccessを永続権限と見なさず、操作直前にもlive確認する。
|
||||
const access = await loadHostingAccess();
|
||||
if (current !== generation) return;
|
||||
if (!access.is_admin) { viewState = 'denied'; return; }
|
||||
const result = await issueActivationCode(handle, operation === 'reissue', controller.signal);
|
||||
if (current !== generation) return;
|
||||
issued = result;
|
||||
expiryTimer = setTimeout(clearIssued, Math.max(0, Math.min(900000, Date.parse(result.expires_at) - Date.now())));
|
||||
} catch (err) {
|
||||
if (current !== generation) return;
|
||||
if (err instanceof HostingRequestError && (err.status === 401 || err.status === 403)) {
|
||||
viewState = err.status === 401 ? 'anonymous' : 'denied';
|
||||
} else {
|
||||
error = 'Code issuance could not be confirmed. Do not repeat account creation automatically. If the recipient completed registration, check Passkey login first. Otherwise verify the ID and explicitly reissue a code for the pending account. Active accounts cannot be reissued.';
|
||||
}
|
||||
} finally { if (current === generation) busy = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
void load();
|
||||
const refresh = () => { void load(); };
|
||||
const clearPage = () => { ++generation; controller.abort(); clearIssued(); busy = false; };
|
||||
const restorePage = (event: PageTransitionEvent) => { if (event.persisted) void load(); };
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.addEventListener('pagehide', clearPage);
|
||||
window.addEventListener('pageshow', restorePage);
|
||||
return () => { window.removeEventListener(SESSION_CHANGED_EVENT, refresh); window.removeEventListener('pagehide', clearPage); window.removeEventListener('pageshow', restorePage); };
|
||||
});
|
||||
onDestroy(() => { ++generation; controller.abort(); clearIssued(); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosting accounts · Yoi</title></svelte:head>
|
||||
<div class="accounts" data-sveltekit-preload-data="off">
|
||||
<h1>Hosting accounts</h1>
|
||||
{#if viewState === 'loading'}
|
||||
<p role="status">Checking hosting access…</p>
|
||||
{:else if viewState === 'anonymous'}
|
||||
<p>Sign in with a hosting administrator account to issue activation codes.</p>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else if viewState === 'denied'}
|
||||
<p>Hosting administrator access is required.</p>
|
||||
{:else if viewState === 'error'}
|
||||
<p role="alert" class="error">Hosting access could not be checked.</p>
|
||||
<button type="button" onclick={load}>Retry access check</button>
|
||||
{:else}
|
||||
<p>Create a pending, ordinary user with an ID only. No email or existing Passkey is needed, even when self-registration is disabled.</p>
|
||||
{#if issued}
|
||||
<section aria-labelledby="issued-heading">
|
||||
<h2 id="issued-heading">Activation code issued</h2>
|
||||
<dl><dt>Account ID (handle)</dt><dd>{issued.user.handle}</dd><dt>Expires</dt><dd>{new Date(issued.expires_at).toLocaleString()}</dd><dt>One-time code</dt><dd class="secret">{issued.code}</dd></dl>
|
||||
<p>This code is shown only here and disappears when hidden, expired or you leave. Privately give it and the ID to the recipient. Do not put it in a URL, screenshot, log or support message.</p>
|
||||
<p>Ask the recipient to open <a href="/activate">/activate</a> on this Server and register a Passkey within 15 minutes. The pending account is not active until registration completes.</p>
|
||||
<button type="button" onclick={clearIssued}>Hide code</button>
|
||||
</section>
|
||||
{:else}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void issue(); }} autocomplete="off">
|
||||
<label for="account-operation">Operation</label>
|
||||
<select id="account-operation" bind:value={operation} disabled={busy}><option value="create">Create account and issue code</option><option value="reissue">Reissue for a pending account</option></select>
|
||||
<label for="account-handle">Account ID (handle)</label>
|
||||
<input id="account-handle" bind:value={handle} required maxlength="128" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} aria-describedby="handle-help" />
|
||||
<p id="handle-help" class="muted">1–64 ASCII letters, numbers, hyphens or underscores. Surrounding spaces are removed and letters become lowercase.</p>
|
||||
{#if operation === 'reissue'}<p>Reissuing invalidates the old code and any in-progress registration. It cannot replace a Passkey or recover an active account.</p>{/if}
|
||||
<button class="primary" type="submit" disabled={busy || !handle.trim()}>{busy ? 'Issuing code…' : operation === 'create' ? 'Create account and issue code' : 'Reissue activation code'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<details><summary>Issuance and recovery steps</summary><p>Create a new ID, deliver the code privately, then ask the recipient to register at /activate. Codes expire after 15 minutes or five incorrect attempts. Options alone do not consume a correct code. After a cancelled prompt or reload, re-enter ID and code. If expired, locked or lost, choose reissue for the same pending ID and deliver only the new code. Ask the recipient to check Passkey login first if completion was uncertain. After activation use Passkey login, not the code.</p></details>
|
||||
<a href="/hosting/settings">Hosting settings</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.accounts, section, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.accounts { max-width: 44rem; }
|
||||
h1, h2, p, dl, dd { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
h2 { font-size: var(--font-size-body); line-height: var(--line-height-body); }
|
||||
dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2) var(--space-4); }
|
||||
dd { overflow-wrap: anywhere; font-family: var(--font-mono); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
input, select { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; }
|
||||
a:hover, a:active { color: var(--text); }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Accounts from "./+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(
|
||||
mode: "admin" | "anonymous" | "ordinary" | "revoked" | "unknown" | "loading",
|
||||
) {
|
||||
let accesses = 0;
|
||||
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (input === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...authUser, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (input === "/api/hosting/access") {
|
||||
accesses++;
|
||||
if (mode === "loading") return await new Promise<Response>(() => {});
|
||||
return Response.json({
|
||||
is_admin: mode !== "ordinary" && !(mode === "revoked" && accesses > 1),
|
||||
});
|
||||
}
|
||||
if (
|
||||
init?.method === "POST" &&
|
||||
String(input).startsWith("/api/hosting/accounts")
|
||||
) {
|
||||
if (mode === "unknown") throw new Error("private diagnostic");
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
async function issue() {
|
||||
await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), {
|
||||
target: { value: " ALICE " },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create account and issue code" }),
|
||||
);
|
||||
}
|
||||
for (const mode of ["anonymous", "ordinary", "loading"] as const) {
|
||||
test(`${mode} hides account mutations before current administrator access is confirmed`, async () => {
|
||||
const fetcher = boundary(mode);
|
||||
render(Accounts);
|
||||
await screen.findByText(
|
||||
mode === "anonymous"
|
||||
? /Sign in with a hosting administrator/
|
||||
: mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
: "Checking hosting access…",
|
||||
);
|
||||
expect(screen.queryByLabelText("Account ID (handle)")).toBeNull();
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === "POST")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
test("creation shows issuance only in page memory and hide clears the code", async () => {
|
||||
const fetcher = boundary("admin");
|
||||
render(Accounts);
|
||||
await issue();
|
||||
await screen.findByRole("heading", { name: "Activation code issued" });
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === "POST")!;
|
||||
expect(JSON.parse(String(post[1]?.body))).toEqual({ handle: "alice" });
|
||||
expect(post[0]).toBe("/api/hosting/accounts");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Hide code" }));
|
||||
expect(screen.queryByText("synthetic-output-only")).toBeNull();
|
||||
expect(screen.getByLabelText("Account ID (handle)")).toBeTruthy();
|
||||
// 実際のselect操作と再発行はproduction-shell browser suiteが確認する。
|
||||
});
|
||||
test("access revoked after page load prevents issuance", async () => {
|
||||
const fetcher = boundary("revoked");
|
||||
render(Accounts);
|
||||
await issue();
|
||||
await screen.findByText("Hosting administrator access is required.");
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === "POST")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("lost issuance response never retries or echoes private diagnostics", async () => {
|
||||
const fetcher = boundary("unknown");
|
||||
render(Accounts);
|
||||
await issue();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"could not be confirmed",
|
||||
)
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"private diagnostic",
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "Activation code issued" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([, init]) => init?.method === "POST").length,
|
||||
).toBe(1);
|
||||
});
|
||||
@@ -128,6 +128,7 @@
|
||||
<p class="error" role="alert">{error}</p>
|
||||
<button type="button" onclick={load}>Retry loading settings</button>
|
||||
{:else if settings}
|
||||
<a href="/hosting/accounts">Create accounts or reissue activation codes</a>
|
||||
<section aria-labelledby="registration-heading">
|
||||
<h2 id="registration-heading">Self-registration</h2>
|
||||
<p class="muted">Applies to this hosting, across all Workspaces.</p>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Locator } from "playwright";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
// 本番shellとclientを接続する合成HTTP/WebAuthn試験。Server認証/atomic保存のE2Eではない。
|
||||
Deno.test("activation standalone shell supports issuance, reissue, denial, restart and confirmed-only activation", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "activation-browser",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/activation-browser/fixture-server.ts",
|
||||
"15381",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15381/health",
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
|
||||
`${root}/target/web-ux/activation-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
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 requested: string[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/")) {
|
||||
requested.push(
|
||||
`${request.method()} ${new URL(request.url()).pathname}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", () => errors.push("pageerror"));
|
||||
const go = async (path: string, mode: string) => {
|
||||
await page.goto(`http://127.0.0.1:15381${path}?fixture=${mode}`);
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
false,
|
||||
);
|
||||
const linkStyle = (link: Locator) =>
|
||||
link.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const probe = document.createElement("span");
|
||||
probe.hidden = true;
|
||||
element.append(probe);
|
||||
const token = (name: string) => {
|
||||
probe.style.color = `var(${name})`;
|
||||
return getComputedStyle(probe).color;
|
||||
};
|
||||
const result = {
|
||||
color: style.color,
|
||||
outline: style.outlineColor,
|
||||
width: style.outlineWidth,
|
||||
kind: style.outlineStyle,
|
||||
underline: style.textDecorationLine,
|
||||
strong: token("--text-strong"),
|
||||
text: token("--text"),
|
||||
accent: token("--accent"),
|
||||
};
|
||||
probe.remove();
|
||||
return result;
|
||||
});
|
||||
const checkLink = async (link: Locator) => {
|
||||
await page.mouse.move(0, 0);
|
||||
const normal = await linkStyle(link);
|
||||
assertEquals(normal.color, normal.strong);
|
||||
assert(normal.underline.includes("underline"));
|
||||
await link.hover();
|
||||
const hovered = await linkStyle(link);
|
||||
assertEquals(hovered.color, hovered.text);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await link.focus();
|
||||
const focused = await linkStyle(link);
|
||||
assertEquals(focused.kind, "solid");
|
||||
assertEquals(focused.width, "2px");
|
||||
assertEquals(focused.outline, focused.accent);
|
||||
};
|
||||
await go("/hosting/settings", "admin");
|
||||
await page.getByRole("link", {
|
||||
name: "Create accounts or reissue activation codes",
|
||||
}).click();
|
||||
await page.getByLabel("Account ID (handle)").waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", { name: "Hosting settings", exact: true }),
|
||||
);
|
||||
const handle = page.getByLabel("Account ID (handle)");
|
||||
await handle.fill(" ALICE ");
|
||||
const issue = page.getByRole("button", {
|
||||
name: "Create account and issue code",
|
||||
});
|
||||
await handle.press("Tab");
|
||||
assert(
|
||||
await issue.evaluate((el) =>
|
||||
el === document.activeElement &&
|
||||
getComputedStyle(el).outlineStyle !== "none"
|
||||
),
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("heading", { name: "Activation code issued" })
|
||||
.waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", { name: "/activate", exact: true }),
|
||||
);
|
||||
assertEquals(await page.locator(".secret").count(), 1);
|
||||
await noOverflow();
|
||||
await page.getByRole("button", { name: "Hide code" }).click();
|
||||
assertEquals(await page.locator(".secret").count(), 0);
|
||||
await page.getByLabel("Operation").selectOption("reissue");
|
||||
await page.getByRole("button", { name: "Reissue activation code" })
|
||||
.click();
|
||||
await page.getByRole("heading", { name: "Activation code issued" })
|
||||
.waitFor();
|
||||
assert(
|
||||
requested.includes("POST /api/hosting/accounts/activation-code"),
|
||||
);
|
||||
for (const mode of ["anonymous", "ordinary"]) {
|
||||
const before = requested.filter((path) =>
|
||||
path.startsWith("POST ")
|
||||
).length;
|
||||
await go("/hosting/accounts", mode);
|
||||
await page.getByText(
|
||||
mode === "anonymous"
|
||||
? "Sign in with a hosting administrator account to issue activation codes."
|
||||
: "Hosting administrator access is required.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)").count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
requested.filter((path) => path.startsWith("POST ")).length,
|
||||
before,
|
||||
);
|
||||
await noOverflow();
|
||||
}
|
||||
const fillActivation = async () => {
|
||||
await page.getByLabel("Account ID (handle)").fill("alice");
|
||||
await page.getByLabel("Activation code", { exact: true }).fill(
|
||||
"synthetic-input-only",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
}).click();
|
||||
};
|
||||
await go("/activate", "activation-pending");
|
||||
await page.getByLabel("Activation code", { exact: true }).waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", {
|
||||
name:
|
||||
"Already registered or completion uncertain? Check Passkey login from Account",
|
||||
}),
|
||||
);
|
||||
await fillActivation();
|
||||
await page.getByText(
|
||||
"Passkey registration pending. Account activation has not been confirmed.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Activation code", { exact: true })
|
||||
.inputValue(),
|
||||
"",
|
||||
);
|
||||
assertEquals(
|
||||
requested.filter((path) =>
|
||||
path === "POST /api/auth/activation/complete"
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: "Cancel registration" })
|
||||
.click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await page.getByText(/You are signed in/).count(), 0);
|
||||
await noOverflow();
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Activation code", { exact: true })
|
||||
.inputValue(),
|
||||
"",
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"activation-cancel",
|
||||
"activation-unknown",
|
||||
"activation-reject",
|
||||
]
|
||||
) {
|
||||
await go("/activate", mode);
|
||||
await fillActivation();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await page.getByText(/You are signed in/).count(), 0);
|
||||
await noOverflow();
|
||||
}
|
||||
await go("/activate", "activation-success");
|
||||
await fillActivation();
|
||||
await page.getByText(
|
||||
"Passkey registration and activation confirmed for alice. You are signed in.",
|
||||
).waitFor();
|
||||
assertEquals(await page.locator(".app-shell").count(), 0);
|
||||
await noOverflow();
|
||||
assertEquals(
|
||||
requested.some((path) => path.includes("/api/workspaces")),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
requested.some((path) =>
|
||||
path.includes("/addition/") || path.includes("/registration/") ||
|
||||
path.includes("/device")
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
[...Object.values(localStorage), ...Object.values(sessionStorage)]
|
||||
.some((value) => value.includes("synthetic-"))
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(new URL(page.url()).searchParams.has("code"), false);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// 合成HTTPとWebAuthn境界だけを使い、本番build/shellをshared web-uxで採取する。
|
||||
// ServerE2Eとは別物。secret fieldは撮影前に隠し、snapshot/診断もredactする。
|
||||
import { resolve } from "@std/path";
|
||||
import { capture } from "../../../../tools/web-ux/src/capture.ts";
|
||||
import type {
|
||||
Interaction,
|
||||
RouteScenario,
|
||||
Scenario,
|
||||
} from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
Deno.env.set("WEB_UX_SYNTHETIC_ACTIVATION_INPUT", "synthetic-input-only");
|
||||
const ready = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string): Interaction => ({
|
||||
action: "click",
|
||||
selector,
|
||||
});
|
||||
const fill = (selector: string, value: string): Interaction => ({
|
||||
action: "fill",
|
||||
selector,
|
||||
value,
|
||||
});
|
||||
const activation = [
|
||||
fill("#activation-handle", "alice"),
|
||||
fill("#activation-code", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"),
|
||||
click('button:has-text("Register Passkey and activate")'),
|
||||
];
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
selector: string,
|
||||
capturePoints: RouteScenario["capturePoints"] = [{
|
||||
id: "initial",
|
||||
label: id,
|
||||
fullPage: true,
|
||||
}],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path,
|
||||
goal: "Check production shell and state/copy; not Server authorization.",
|
||||
dataState: `Synthetic ${id}. No real code, account, credential or session.`,
|
||||
ready: ready(selector),
|
||||
capturePoints,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
route(
|
||||
"settings",
|
||||
"/hosting/settings?fixture=admin",
|
||||
'a[href="/hosting/accounts"]',
|
||||
),
|
||||
route("admin", "/hosting/accounts?fixture=admin", "#account-handle:enabled", [
|
||||
{ id: "initial", label: "ID-only creation", fullPage: true },
|
||||
{
|
||||
id: "link-focus",
|
||||
label: "Hosting settings link keyboard focus",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Tab" }],
|
||||
ready: ready('a[href="/hosting/settings"]:focus-visible'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
label: "Issued; code redacted",
|
||||
interaction: [
|
||||
fill("#account-handle", "alice"),
|
||||
click('button:has-text("Create account and issue code")'),
|
||||
],
|
||||
ready: ready("#issued-heading"),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "reissue",
|
||||
label: "Explicit reissue and invalidation warning",
|
||||
interaction: [click('button:has-text("Hide code")'), {
|
||||
action: "press",
|
||||
selector: "#account-operation",
|
||||
key: "ArrowDown",
|
||||
}],
|
||||
ready: ready('button:has-text("Reissue activation code")'),
|
||||
fullPage: true,
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"anonymous",
|
||||
"/hosting/accounts?fixture=anonymous",
|
||||
'a[href="/account"]',
|
||||
),
|
||||
route(
|
||||
"ordinary",
|
||||
"/hosting/accounts?fixture=ordinary",
|
||||
'p:has-text("Hosting administrator access is required.")',
|
||||
),
|
||||
route("loading", "/hosting/accounts?fixture=loading", '[role="status"]'),
|
||||
route(
|
||||
"access-error",
|
||||
"/hosting/accounts?fixture=access-error",
|
||||
'[role="alert"]',
|
||||
),
|
||||
route(
|
||||
"issue-error",
|
||||
"/hosting/accounts?fixture=issue-error",
|
||||
"#account-handle:enabled",
|
||||
[{
|
||||
id: "unconfirmed",
|
||||
label: "Intentional HTTP409; unconfirmed issuance",
|
||||
interaction: [
|
||||
fill("#account-handle", "alice"),
|
||||
click('button:has-text("Create account and issue code")'),
|
||||
],
|
||||
ready: ready('[role="alert"]'),
|
||||
fullPage: true,
|
||||
}],
|
||||
),
|
||||
route(
|
||||
"activation-pending",
|
||||
"/activate?fixture=activation-pending",
|
||||
"#activation-code:enabled",
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID and code before mandatory Passkey",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "link-focus",
|
||||
label: "Account recovery link keyboard focus",
|
||||
interaction: [{
|
||||
action: "press",
|
||||
selector: "summary",
|
||||
key: "Shift+Tab",
|
||||
}],
|
||||
ready: ready('a[href="/account"]:focus-visible'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "pending",
|
||||
label: "Pending prompt, no activation claim",
|
||||
interaction: activation,
|
||||
ready: ready('button:has-text("Cancel registration")'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Cancelled; code cleared and restart guidance",
|
||||
interaction: [click('button:has-text("Cancel registration")')],
|
||||
ready: ready('[role="alert"]'),
|
||||
fullPage: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
...[
|
||||
"activation-cancel",
|
||||
"activation-unknown",
|
||||
"activation-reject",
|
||||
"activation-success",
|
||||
].map((mode) =>
|
||||
route(mode, `/activate?fixture=${mode}`, "#activation-code:enabled", [{
|
||||
id: "result",
|
||||
label: mode,
|
||||
interaction: activation,
|
||||
ready: ready(
|
||||
mode === "activation-success" ? '[role="status"]' : '[role="alert"]',
|
||||
),
|
||||
fullPage: true,
|
||||
}])
|
||||
),
|
||||
];
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `activation-${colorScheme}-${width}`,
|
||||
title: "Activation synthetic projection on production build",
|
||||
baseUrl: "http://127.0.0.1:15382",
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic HTTP/WebAuthn only",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
redact: {
|
||||
selectors: [".secret"],
|
||||
text: ["synthetic-output-only", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"],
|
||||
},
|
||||
processes: [{
|
||||
id: "activation-projection",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/activation-browser/fixture-server.ts",
|
||||
"15382",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15382/health",
|
||||
}],
|
||||
};
|
||||
const scenarioPath =
|
||||
`${root}/target/web-ux/activation-${colorScheme}-${width}.json`;
|
||||
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
|
||||
const result = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: `${root}/target/web-ux`,
|
||||
runId: `activation-after-v3-${colorScheme}-${width}`,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
colorScheme,
|
||||
width,
|
||||
status: result.status,
|
||||
captures: result.captures.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 本番buildを配信するUI投影fixture。実際のcode検証、認証、DB、cookie発行ではない。
|
||||
// request本文/credential/secretを記録しない。fixture cookieは公開状態名だけ。
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("Expected port and build root");
|
||||
const root = 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 user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
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");
|
||||
const mode = selected ??
|
||||
request.headers.get("cookie")?.match(/activation-fixture=([\w-]+)/)?.[1] ??
|
||||
"admin";
|
||||
const path = url.pathname;
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
const source = mode === "activation-pending"
|
||||
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>new Promise(()=>{})});`
|
||||
: mode === "activation-cancel"
|
||||
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>{throw new DOMException('Cancelled','NotAllowedError')}});`
|
||||
: setupWebAuthnProjection;
|
||||
return new Response(source, {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/workspaces") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...user, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
if (mode === "loading") return await new Promise<Response>(() => {});
|
||||
if (mode === "access-error") return new Response(null, { status: 503 });
|
||||
return Response.json({
|
||||
is_admin: mode !== "ordinary" && mode !== "anonymous",
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/settings") {
|
||||
return Response.json({ self_registration_enabled: false });
|
||||
}
|
||||
if (
|
||||
path === "/api/hosting/accounts" ||
|
||||
path === "/api/hosting/accounts/activation-code"
|
||||
) {
|
||||
if (mode === "issue-error") return new Response(null, { status: 409 });
|
||||
// codeは暗号素材でない固定の投影値。captureは.secretを隠しsnapshotからも除外する。
|
||||
return Response.json({
|
||||
user,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
if (mode === "activation-reject") {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return Response.json({
|
||||
challenge_id: "ui-projection-only",
|
||||
public_key: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI fixture" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
if (mode === "activation-unknown") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
||||
const file = join(root, decodeURIComponent(path));
|
||||
if (!file.startsWith(root + "/")) return new Response(null, { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(file);
|
||||
try {
|
||||
bytes = await Deno.readFile(file);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Deno.errors.NotFound) &&
|
||||
!(error instanceof Deno.errors.IsADirectory)
|
||||
) throw error;
|
||||
bytes = await Deno.readFile(join(root, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
if (extension === ".html") {
|
||||
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",
|
||||
`activation-fixture=${selected}; Path=/; SameSite=Strict`,
|
||||
);
|
||||
}
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -101,10 +101,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
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",
|
||||
);
|
||||
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
|
||||
// Workspace未作成でも管理できることと整合させる。
|
||||
assertEquals(await nav.count(), 0);
|
||||
await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor();
|
||||
if (
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
|
||||
width < 768
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { ssr } from "../src/routes/+layout.ts";
|
||||
import { load, ssr } from "../src/routes/+layout.ts";
|
||||
|
||||
Deno.test("activation and hosting shells never require Workspace catalog authority", async () => {
|
||||
for (
|
||||
const path of [
|
||||
"/activate",
|
||||
"/hosting/accounts",
|
||||
"/hosting/settings",
|
||||
"/hosting/setup",
|
||||
]
|
||||
) {
|
||||
let calls = 0;
|
||||
const event = {
|
||||
url: new URL(path, "https://workspace.test"),
|
||||
fetch: () => {
|
||||
calls++;
|
||||
throw new Error("Workspace API must not be called");
|
||||
},
|
||||
} as unknown as Parameters<typeof load>[0];
|
||||
assertEquals(await load(event).deferredCatalog, {
|
||||
ok: true,
|
||||
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
||||
});
|
||||
assertEquals(calls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace frontend serves deep links through the client SPA", () => {
|
||||
assertEquals(ssr, false);
|
||||
|
||||
Reference in New Issue
Block a user