400 lines
18 KiB
TypeScript
400 lines
18 KiB
TypeScript
import {
|
|
mkdirSync,
|
|
existsSync,
|
|
writeFileSync,
|
|
renameSync,
|
|
chmodSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
} from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import assert from "node:assert/strict";
|
|
import QRCode from "qrcode";
|
|
import {
|
|
authConfig,
|
|
newAuthConfig,
|
|
signSession,
|
|
totp,
|
|
isLoginRedirect,
|
|
} from "../src/lib/auth";
|
|
import { createDocumentKey, revokeDocumentKey } from "../src/lib/document-keys";
|
|
import { readDocument, saveDocument } from "../src/lib/docs";
|
|
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
|
|
|
async function main() {
|
|
process.umask(0o077);
|
|
const command = process.argv[2];
|
|
const file = process.env.AUTH_CONFIG_FILE;
|
|
if (!file)
|
|
throw Error("Set AUTH_CONFIG_FILE before using the authentication tool");
|
|
if (command === "init" || command === "rotate") {
|
|
if (command === "rotate" && !process.argv.includes("--confirm"))
|
|
throw Error(
|
|
"Rotation requires --confirm; all previous authenticators and sessions will stop working",
|
|
);
|
|
if (command === "init" && existsSync(file)) {
|
|
authConfig();
|
|
initializeStore();
|
|
console.log("Existing authentication key preserved");
|
|
return;
|
|
}
|
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
chmodSync(dirname(file), 0o700);
|
|
const temporary = file + "." + process.pid + ".tmp";
|
|
writeFileSync(temporary, JSON.stringify(newAuthConfig()) + "\n", {
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
});
|
|
renameSync(temporary, file);
|
|
initializeStore();
|
|
console.log(
|
|
"Private authentication configuration initialized; no key displayed",
|
|
);
|
|
return;
|
|
}
|
|
const config = authConfig();
|
|
if (!config) throw Error("Authentication is not configured");
|
|
if (command === "state-check") {
|
|
assert.equal(consumeCode(config, "invalid"), "limited");
|
|
console.log("Durable attempt limit survives restart");
|
|
return;
|
|
}
|
|
if (command === "qr" || command === "qr-png") {
|
|
if (process.env.CI)
|
|
throw Error("Never display or export authentication QR codes in CI");
|
|
const uri =
|
|
"otpauth://totp/Tabularium:shared?" +
|
|
new URLSearchParams({
|
|
secret: config.secret,
|
|
issuer: "Tabularium",
|
|
algorithm: "SHA1",
|
|
digits: "6",
|
|
period: "30",
|
|
});
|
|
if (command === "qr-png") {
|
|
process.stdout.write(
|
|
await QRCode.toBuffer(uri, {
|
|
width: 660,
|
|
margin: 4,
|
|
errorCorrectionLevel: "M",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (!process.stdout.isTTY)
|
|
throw Error(
|
|
"Use an interactive SSH terminal for qr, or redirect qr-png to a private local PNG",
|
|
);
|
|
console.log(
|
|
await QRCode.toString(uri, {
|
|
type: "terminal",
|
|
small: true,
|
|
errorCorrectionLevel: "M",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (command === "token") {
|
|
if (!process.stdout.isTTY)
|
|
throw Error(
|
|
"Tokens can only be displayed in an interactive SSH terminal",
|
|
);
|
|
console.log(signSession(config, undefined, 3600));
|
|
return;
|
|
}
|
|
const base = process.env.AUTH_CHECK_BASE_URL || "http://127.0.0.1:3000";
|
|
if (command === "run") {
|
|
const script = process.argv[3];
|
|
if (!script) throw Error("Provide a script path");
|
|
const original = globalThis.fetch;
|
|
const token = signSession(config, undefined, 600);
|
|
globalThis.fetch = (input, init) => {
|
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
if (url.origin === new URL(base).origin) {
|
|
const headers = new Headers(
|
|
init?.headers ||
|
|
(input instanceof Request ? input.headers : undefined),
|
|
);
|
|
headers.set("Authorization", "Bearer " + token);
|
|
return original(input, { ...init, headers });
|
|
}
|
|
return original(input, init);
|
|
};
|
|
await import(pathToFileURL(resolve(script)).href);
|
|
return;
|
|
}
|
|
if (command !== "verify" && command !== "exercise")
|
|
throw Error(
|
|
"Usage: init | qr | qr-png | rotate --confirm | token | run <script> | verify | exercise",
|
|
);
|
|
const request = (path: string, init: RequestInit = {}) =>
|
|
fetch(base + path, { ...init, redirect: "manual" });
|
|
const buildId = readFileSync(
|
|
join(__dirname, ".next/BUILD_ID"),
|
|
"utf8",
|
|
).trim();
|
|
for (const path of [
|
|
"/",
|
|
"/docs/00-endor-concepts-draft",
|
|
"/figures/endor-architecture.svg",
|
|
"/figures/endor-review-gate.webp",
|
|
"/figures/endor-review-desk.webp",
|
|
"/docs/pretext/01-chapter",
|
|
"/materials/pretext/verify.cjs",
|
|
"/materials/pretext/results.chrome156.json",
|
|
`/_next/data/${buildId}/index.json`,
|
|
`/_next/data/${buildId}/docs/00-endor-concepts-draft.json`,
|
|
]) {
|
|
const response = await request(path);
|
|
assert.equal(response.status, 307, path);
|
|
assert.ok(
|
|
isLoginRedirect(
|
|
response.headers.get("location") ||
|
|
response.headers.get("x-nextjs-redirect"),
|
|
base,
|
|
),
|
|
path,
|
|
);
|
|
}
|
|
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/documents/source?slug=endor/endor-purpose-draft", "/api/research/ai-companies"])
|
|
assert.equal((await request(path)).status, 401, path);
|
|
assert.equal(
|
|
(
|
|
await request("/api/private", {
|
|
headers: { "x-middleware-subrequest": "proxy:proxy:proxy:proxy:proxy" },
|
|
})
|
|
).status,
|
|
401,
|
|
);
|
|
const login = await request("/login");
|
|
assert.equal(login.status, 200);
|
|
const loginHtml = await login.text();
|
|
assert.ok(loginHtml.includes("输入动态码"));
|
|
assert.ok(!loginHtml.includes(config.secret));
|
|
assert.deepEqual(await (await request("/api/health")).json(), {
|
|
status: "ok",
|
|
});
|
|
const headers = {
|
|
Authorization: "Bearer " + signSession(config, undefined, 600),
|
|
};
|
|
const researchResponse = await request("/api/research/ai-companies", { headers });
|
|
assert.equal(researchResponse.status, 200, "authenticated research API");
|
|
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
|
|
const research = await researchResponse.json();
|
|
assert.equal(research.companies.length, 1159);
|
|
assert.equal(research.categories.length, 11);
|
|
assert.equal(research.evidence.length, 3874);
|
|
assert.equal(research.observations.length, 453);
|
|
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
|
|
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
|
|
assert.equal(researchPage.status, 200);
|
|
const researchHtml = await researchPage.text();
|
|
assert.ok(researchHtml.includes('data-category="其他研究"'));
|
|
assert.ok(researchHtml.includes("AI 公司研究"));
|
|
assert.ok(researchHtml.includes("正在读取研究数据库"));
|
|
const searchResponse = await request("/api/search", { headers });
|
|
assert.equal(searchResponse.status, 200);
|
|
const searchIndex = await searchResponse.json();
|
|
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "source-studies/pretext/measurement-cache" && doc.searchText.includes("getSegmentMetrics")));
|
|
for (const doc of searchIndex as { slug: string; sourceSlug: string }[]) {
|
|
assert.ok(!doc.slug.split("/").some((part) => /^\d+-/.test(part)), doc.slug);
|
|
const legacy = await request(`/docs/${doc.sourceSlug}?from=legacy`, { headers });
|
|
assert.equal(legacy.status, 308, doc.sourceSlug);
|
|
const destination = new URL(legacy.headers.get("location")!, base);
|
|
assert.equal(destination.pathname, `/docs/${doc.slug}`);
|
|
assert.equal(destination.searchParams.get("from"), "legacy");
|
|
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
|
|
}
|
|
const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)];
|
|
const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers });
|
|
assert.equal(raw.status, 200);
|
|
assert.ok((await raw.text()).startsWith("# Endor"));
|
|
if (process.env.DOCUMENT_SMOKE_TEST) {
|
|
if (command === "exercise") await exerciseDocumentApi(request, headers);
|
|
const sourceSlug = "ci-live-document";
|
|
const previous = readDocument(sourceSlug);
|
|
if (command === "exercise") {
|
|
const first = saveDocument({ slug: "endor/ci-live-document", sourceSlug,
|
|
title: "Runtime document", category: "Endor", collection: "Endor 文档", group: "CI",
|
|
description: "Isolated smoke test", draft: true, featured: false,
|
|
content: "# Runtime document\n\nInitial body" }, previous?.revision ?? 0);
|
|
assert.equal((await request(`/docs/${first.slug}`, { headers })).status, 200);
|
|
saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision);
|
|
}
|
|
const stored = readDocument(sourceSlug);
|
|
assert.ok(stored, "Smoke document must exist after creation or restart");
|
|
assert.ok(stored.content.includes("Updated without rebuilding"), "DB content survives restart");
|
|
const live = await request(`/docs/${stored.slug}`, { headers });
|
|
assert.equal(live.status, 200);
|
|
assert.ok((await live.text()).includes("Updated without rebuilding"));
|
|
const alias = await request("/docs/endor/ci-live-document?live=1", { headers });
|
|
assert.equal(alias.status, 308);
|
|
assert.ok(alias.headers.get("location")?.includes("ci-live-document-renamed?live=1"));
|
|
const updatedIndex = await (await request("/api/search", { headers })).json();
|
|
assert.ok(updatedIndex.some((doc: { searchText: string }) => doc.searchText.includes("Updated without rebuilding")));
|
|
}
|
|
let home = "";
|
|
let purpose = "";
|
|
for (const route of routes) {
|
|
const response = await request(route, { headers });
|
|
assert.equal(response.status, 200, route);
|
|
const html = await response.text();
|
|
if (route === "/") home = html;
|
|
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
|
|
if (route.startsWith("/docs/")) {
|
|
assert.ok(html.includes('aria-label="文档分类"'), route);
|
|
assert.ok(html.includes('data-category="语言与运行时"'), route);
|
|
assert.ok(html.includes('class="category-panel" hidden=""'), route);
|
|
assert.ok(!html.includes('class="collection-picker"'), route);
|
|
assert.ok(!html.includes("<select"), route);
|
|
assert.ok(!html.includes('class="nav-back"'), route);
|
|
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
|
|
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
|
|
for (const id of ids) assert.ok(html.includes(`href="#${id}"`), `TOC anchor: ${route}#${id}`);
|
|
}
|
|
}
|
|
assert.ok(home.includes("资料库"));
|
|
assert.ok(home.includes("/docs/source-studies/pretext/overview"));
|
|
// Inline preview and zoom dialog must both use the compressed browser asset.
|
|
assert.equal(
|
|
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-gate\.webp"/g)].length,
|
|
2,
|
|
"The comic preview and lightbox must use WebP",
|
|
);
|
|
assert.ok(purpose.includes('aria-label="上一张图片"'));
|
|
assert.ok(purpose.includes('aria-label="下一张图片"'));
|
|
assert.ok(purpose.includes('aria-label="显示第 1 张图片" aria-current="true"'));
|
|
assert.ok(purpose.includes('aria-label="显示第 2 张图片"'));
|
|
assert.equal(
|
|
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-desk\.webp"/g)].length,
|
|
0,
|
|
"The second slide should not download until selected",
|
|
);
|
|
for (const name of ["endor-review-gate", "endor-review-desk"]) {
|
|
assert.ok(!purpose.includes(`/figures/${name}.png`));
|
|
const comic = await request(`/figures/${name}.webp`, { headers });
|
|
assert.equal(comic.status, 200);
|
|
assert.match(comic.headers.get("content-type") || "", /image\/webp/);
|
|
assert.deepEqual(
|
|
Buffer.from(await comic.arrayBuffer()),
|
|
readFileSync(join(__dirname, `public/figures/${name}.webp`)),
|
|
);
|
|
assert.equal((await request(`/figures/${name}.png`, { headers })).status, 404);
|
|
}
|
|
const assets = new Set(
|
|
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
|
|
m[1].replaceAll("&", "&"),
|
|
),
|
|
);
|
|
assert.ok(assets.size);
|
|
for (const path of assets)
|
|
assert.equal((await request(path, { headers })).status, 200, path);
|
|
for (const name of ["verify.cjs", "results.chrome156.json"]) {
|
|
const response = await request("/materials/pretext/" + name, { headers });
|
|
assert.equal(response.status, 200, name);
|
|
assert.ok((await response.text()).length > 100);
|
|
}
|
|
for (const name of readdirSync(join(__dirname, "public/figures"))) {
|
|
if (!name.endsWith(".svg")) continue;
|
|
const response = await request("/figures/" + name, { headers });
|
|
assert.equal(response.status, 200, name);
|
|
assert.match(response.headers.get("content-type") || "", /image\/svg\+xml/);
|
|
}
|
|
assert.equal(
|
|
(await request("/docs/this-document-does-not-exist", { headers })).status,
|
|
404,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await request("/", {
|
|
headers: { Authorization: headers.Authorization + "x" },
|
|
})
|
|
).status,
|
|
307,
|
|
);
|
|
if (command === "exercise") {
|
|
const origin = new URL(base).origin;
|
|
const postCode = (code: string, site = origin) =>
|
|
request("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Origin: site },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
assert.equal(
|
|
(await postCode("000000", "https://untrusted.example")).status,
|
|
403,
|
|
);
|
|
const code = totp(config.secret, Math.floor(Date.now() / 30000));
|
|
const results = await Promise.all([postCode(code), postCode(code)]);
|
|
assert.deepEqual(results.map((r) => r.status).sort(), [200, 401]);
|
|
const cookie = results
|
|
.find((r) => r.status === 200)!
|
|
.headers.get("set-cookie")!;
|
|
assert.match(cookie, /HttpOnly/);
|
|
assert.match(cookie, /SameSite=Lax/);
|
|
assert.match(cookie, /Max-Age=604800/);
|
|
const session = cookie.split(";")[0];
|
|
assert.equal(
|
|
(await request("/", { headers: { Cookie: session } })).status,
|
|
200,
|
|
);
|
|
const logout = await request("/api/auth/logout", {
|
|
method: "POST",
|
|
headers: { Cookie: session, Origin: origin },
|
|
});
|
|
assert.equal(logout.status, 200);
|
|
assert.match(logout.headers.get("set-cookie")!, /Max-Age=0/);
|
|
for (let i = 0; i < 21; i++) await postCode("invalid");
|
|
assert.equal((await postCode(code)).status, 429);
|
|
}
|
|
console.log(
|
|
`Authentication ${command} passed: protected routes/data/images, signed sessions, all documents, assets, SVG and 404; no secrets displayed`,
|
|
);
|
|
}
|
|
main().catch((error) => {
|
|
console.error(
|
|
error instanceof Error ? error.message : "Authentication command failed",
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
|
|
async function exerciseDocumentApi(request: (path: string, init?: RequestInit) => Promise<Response>, session: Record<string, string>) {
|
|
const writer = createDocumentKey("isolated API smoke", "read-write", 1);
|
|
const reader = createDocumentKey("isolated read smoke", "read", 1);
|
|
const headers = { Authorization: "Bearer " + writer.token, "Content-Type": "application/json", "Idempotency-Key": "smoke-batch" };
|
|
const readHeaders = { Authorization: "Bearer " + reader.token, "Content-Type": "application/json" };
|
|
const make = (name: string) => ({ slug: `endor/${name}`, sourceSlug: name, title: "API smoke",
|
|
category: "Endor", collection: "Endor 文档", group: "CI", description: "Isolated API smoke",
|
|
draft: true, featured: false, content: "# API smoke", revision: 0 });
|
|
const payload = { documents: [make("ci-api-first"), make("ci-api-second")] };
|
|
try {
|
|
assert.equal((await request("/api/documents")).status, 401);
|
|
assert.equal((await request("/api/documents", { method: "POST", headers: { ...session, "Content-Type": "application/json" }, body: JSON.stringify(payload.documents[0]) })).status, 401);
|
|
assert.equal((await request("/api/research/ai-companies", { headers })).status, 401, "Document keys cannot read research");
|
|
assert.equal((await request("/api/search", { headers })).status, 401, "Document keys cannot authenticate other APIs");
|
|
assert.equal((await request("/api/documents/batch", { method: "POST", headers: readHeaders, body: JSON.stringify(payload) })).status, 403);
|
|
const saved = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
|
|
assert.equal(saved.status, 200);
|
|
assert.equal((await saved.json()).documents.length, 2);
|
|
const replay = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
|
|
assert.equal(replay.status, 200); assert.equal((await replay.json()).replayed, true);
|
|
const current = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
|
|
assert.equal(current.status, 200); assert.equal((await current.json()).revision, 1);
|
|
const page = await request("/api/documents?limit=1", { headers: readHeaders });
|
|
assert.equal(page.status, 200);
|
|
const listing = await page.json();
|
|
assert.equal(listing.documents.length, 1); assert.equal(listing.documents[0].content, undefined);
|
|
const bad = { documents: [{ ...payload.documents[0], revision: 1, content: "Must roll back" }, payload.documents[1]] };
|
|
const conflict = await request("/api/documents/batch", { method: "POST", headers: { ...headers, "Idempotency-Key": "smoke-conflict" }, body: JSON.stringify(bad) });
|
|
assert.equal(conflict.status, 409);
|
|
const unchanged = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
|
|
assert.equal((await unchanged.json()).content, "# API smoke");
|
|
const history = await request("/api/documents/item/endor/ci-api-first?history=1", { headers });
|
|
assert.equal(history.status, 200); assert.equal((await history.json()).total, 1);
|
|
revokeDocumentKey(writer.id);
|
|
assert.equal((await request("/api/documents", { headers })).status, 401);
|
|
} finally { revokeDocumentKey(writer.id); revokeDocumentKey(reader.id); }
|
|
console.log("Document API smoke passed: scoped keys, batch rollback, pagination, history, replay and revocation; no secrets displayed");
|
|
}
|