Protect Tabularium with persistent authenticator-code login
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s

This commit is contained in:
2026-10-27 21:48:16 +08:00
parent 0a4057ac2a
commit 9fcfbdb694
31 changed files with 1549 additions and 60 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
FROM node:22-bookworm-slim
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000 AUTH_CONFIG_FILE=/auth/config.json AUTH_COOKIE_SECURE=false
WORKDIR /app
# Context is the verified standalone artifact, never the source checkout.
COPY --chown=node:node . .
RUN mkdir -p /auth && chown node:node /auth && chmod 700 /auth
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=6 \
+32 -3
View File
@@ -13,7 +13,7 @@ uv sync --project tools/figures --locked
pnpm dev
```
访问 http://127.0.0.1:3000 。站点无外部字体或 CDN 资源。
访问 http://127.0.0.1:3000 ,先使用验证器动态码登录。首次本地使用需按下文初始化私有认证配置;缺少配置时正文关闭访问。站点无外部字体或 CDN 资源。
```text
apps/docs/ Next.js Pages Router + React + TypeScript + Tailwind CSS
@@ -47,14 +47,43 @@ main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署
拓扑检查:`uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v`。容器检查流程会运行同一检查。
## 动态码登录与二维码
与 PROMPT 的参考流程一致:共享验证器的6位TOTP动态码,每30秒更新;成功登录的HttpOnly、SameSite=Lax会话有效7天。此方案是动态码单因素登录,不是密码加动态码的双因素组合。
正文、SVG、文档数据与API都经过认证,仅登录入口、登录所需代码资源和最小健康检查公开。20次/5分钟的共享尝试上限与已用时间步持久保存在SQLite中,跨进程和重启继续生效。缺失或损坏配置时拒绝访问,不自动降为公开站点。
生产认证文件位于服务器 `/opt/endorphin-tabularium-docs/private-auth`,目录0700、文件0600,只挂载进本项目容器。部署只初始化一次,后续更新保留同一验证器密钥与会话签名密钥。生产镜像、Git、CI日志和网页不包含密钥。
管理员通过SSH管理:
```sh
sudo /opt/endorphin-tabularium-docs/auth qr
sudo /opt/endorphin-tabularium-docs/auth verify
```
`qr`只允许交互终端显示;`qr-png`用于通过SSH将PNG直接重定向到私有本地文件,不在CI中运行。二维码等同登录凭据,不放入本仓库或网页。只有明确执行 `auth rotate --confirm` 才更换密钥并使旧二维码、会话失效;重新显示或下载二维码不会轮换。
本地先构建CLI,再初始化独立的本地测试配置:
```sh
pnpm build
AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json node apps/docs/.next/standalone/apps/docs/auth-cli.cjs init
AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=false pnpm dev
```
当前服务器入口为HTTP,与参考项目一致使用 `AUTH_COOKIE_SECURE=false`;配置HTTPS后应在独立production.env设置 `AUTH_COOKIE_SECURE=true`。HTTP本身不加密动态码和会话传输。
`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。
## CI/CD 与缓存
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做实际路由与资源冒烟检查。
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。
3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立;额外保留 Next.js SWC 下载缓存。
4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。
4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过动态码认证、重放/限流、重启持久化、健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。
5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。
根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。
+6 -2
View File
@@ -4,13 +4,15 @@
"private": true,
"scripts": {
"dev": "NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
"build": "NEXT_TELEMETRY_DISABLED=1 next build",
"build": "NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
"lint": "eslint . --max-warnings=0",
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit"
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
"test:auth": "node scripts/test-auth.mjs"
},
"dependencies": {
"next": "16.4.3",
"qrcode": "1.5.4",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-markdown": "10.1.0",
@@ -20,8 +22,10 @@
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/typography": "0.5.20",
"@types/node": "22.20.5",
"@types/qrcode": "1.5.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"esbuild": "0.25.12",
"eslint": "^9.39.5",
"eslint-config-next": "16.4.3",
"postcss": "8.5.31",
+256
View File
@@ -0,0 +1,256 @@
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 { 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();
const routes = Object.keys(
JSON.parse(
readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"),
).routes,
) as string[];
for (const path of [
"/",
"/docs/00-endor-concepts-draft",
"/figures/endor-architecture.svg",
`/_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"])
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),
};
let home = "";
for (const route of routes) {
const response = await request(route, { headers });
assert.equal(response.status, 200, route);
if (route === "/") home = await response.text();
}
assert.ok(home.includes("TABULARIUM"));
const assets = new Set(
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
m[1].replaceAll("&amp;", "&"),
),
);
assert.ok(assets.size);
for (const path of assets)
assert.equal((await request(path, { headers })).status, 200, path);
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;
});
+9
View File
@@ -0,0 +1,9 @@
import { build } from "esbuild";
await build({
entryPoints: ["scripts/auth-cli.ts"],
bundle: true,
platform: "node",
target: "node22",
format: "cjs",
outfile: ".next/standalone/apps/docs/auth-cli.cjs",
});
+23
View File
@@ -0,0 +1,23 @@
import { build } from "esbuild";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-auth-tests-"));
try {
const file = join(dir, "auth.test.cjs");
await build({
entryPoints: ["tests/auth.test.ts"],
bundle: true,
platform: "node",
target: "node22",
format: "cjs",
outfile: file,
});
const result = spawnSync(process.execPath, ["--test", file], {
stdio: "inherit",
});
process.exitCode = result.status ?? 1;
} finally {
await rm(dir, { recursive: true, force: true });
}
+17 -1
View File
@@ -52,7 +52,7 @@ export function Navigation({
key={d.slug}
>
{d.title}
{d.slug.includes("draft") && (
{sectionFor(d.slug).label.includes("草案") && (
<span className="tiny-dot" aria-label="草案" />
)}
</Link>
@@ -131,6 +131,22 @@ export function Shell({
<span>搜索文档…</span>
<kbd>⌘ K</kbd>
</button>
<button
className="logout-button"
aria-label="退出登录"
onClick={async () => {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
// Drop cached private document data when ending the session.
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
window.location.assign("/login");
}
}}
>
退出
</button>
</header>
<div className="mobile-bar">
<button
+72
View File
@@ -0,0 +1,72 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { authId, matchingStep, type AuthConfig } from "./auth";
function database() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
chmodSync(file, 0o600);
db.exec(`PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS login_state (
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0, last_step INTEGER NOT NULL DEFAULT -1
);`);
return db;
}
// SQLite's write transaction serializes attempts across workers and restarts.
export function consumeCode(
config: AuthConfig,
code: string,
now = Date.now(),
): "ok" | "invalid" | "limited" {
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
const id = authId(config);
db.prepare(
"INSERT OR IGNORE INTO login_state(id,window_start) VALUES (?,?)",
).run(id, now);
const state = db
.prepare("SELECT * FROM login_state WHERE id=?")
.get(id) as {
window_start: number;
attempts: number;
last_step: number;
};
const expired = now - state.window_start >= 300000;
const attempts = expired ? 0 : state.attempts;
let result: "ok" | "invalid" | "limited" = "limited";
if (attempts < 20) {
db.prepare(
"UPDATE login_state SET attempts=?,window_start=? WHERE id=?",
).run(attempts + 1, expired ? now : state.window_start, id);
const step = matchingStep(config.secret, code, now);
result = step !== null && step > state.last_step ? "ok" : "invalid";
if (result === "ok")
db.prepare("UPDATE login_state SET last_step=? WHERE id=?").run(
step!,
id,
);
}
db.exec("COMMIT");
return result;
} catch (error) {
try {
db.exec("ROLLBACK");
} catch {
/* Transaction may not have started. */
}
throw error;
} finally {
db.close();
}
}
export function initializeStore() {
const db = database();
db.close();
}
+169
View File
@@ -0,0 +1,169 @@
import {
createHash,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { readFileSync } from "node:fs";
export const SESSION_COOKIE = "tabularium_session";
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
export type AuthConfig = { secret: string; jwtKey: string };
export function authConfig(): AuthConfig | null {
const path = process.env.AUTH_CONFIG_FILE;
if (!path) return null;
let value: AuthConfig;
try {
value = JSON.parse(readFileSync(path, "utf8")) as AuthConfig;
if (
!/^[A-Z2-7]{32}$/.test(value.secret) ||
!/^[a-f0-9]{64}$/.test(value.jwtKey)
)
throw new Error();
} catch {
throw new Error("Invalid authentication configuration");
}
return value;
}
export function base32(bytes: Buffer) {
let bits = 0,
value = 0,
output = "";
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
output += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[(value >>> bits) & 31];
}
}
if (bits)
output += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[(value << (5 - bits)) & 31];
return output;
}
export function newAuthConfig(): AuthConfig {
return {
secret: base32(randomBytes(20)),
jwtKey: randomBytes(32).toString("hex"),
};
}
export function authId(config: AuthConfig) {
return createHash("sha256").update(config.jwtKey).digest("hex");
}
export function totp(secret: string, step: number, digits = 6) {
let bits = 0,
value = 0;
const bytes: number[] = [];
for (const char of secret) {
const index = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(char);
if (index < 0) throw Error("Invalid TOTP key");
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((value >>> bits) & 255);
}
}
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(step));
const hmac = createHmac("sha1", Buffer.from(bytes)).update(counter).digest();
return String(
(hmac.readUInt32BE(hmac[19] & 15) & 0x7fffffff) % 10 ** digits,
).padStart(digits, "0");
}
export function matchingStep(secret: string, code: string, now = Date.now()) {
if (!/^\d{6}$/.test(code)) return null;
const current = Math.floor(now / 30000);
for (const step of [current, current - 1, current + 1])
if (timingSafeEqual(Buffer.from(totp(secret, step)), Buffer.from(code)))
return step;
return null;
}
export function signSession(
config: AuthConfig,
now = Math.floor(Date.now() / 1000),
lifetime = SESSION_SECONDS,
) {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
).toString("base64url");
const payload = Buffer.from(
JSON.stringify({
iss: "tabularium",
aud: "tabularium-web",
sub: "shared",
iat: now,
exp: now + lifetime,
jti: randomBytes(16).toString("hex"),
}),
).toString("base64url");
const content = header + "." + payload;
return (
content +
"." +
createHmac("sha256", Buffer.from(config.jwtKey, "hex"))
.update(content)
.digest("base64url")
);
}
export function validSession(
token: string | undefined,
config: AuthConfig,
now = Math.floor(Date.now() / 1000),
) {
try {
if (!token || token.length > 2048) return false;
const parts = token.split(".");
if (parts.length !== 3) return false;
const [header, payload, signature] = parts;
const h = JSON.parse(Buffer.from(header, "base64url").toString());
if (h.alg !== "HS256" || h.typ !== "JWT") return false;
const expected = createHmac("sha256", Buffer.from(config.jwtKey, "hex"))
.update(header + "." + payload)
.digest();
const actual = Buffer.from(signature, "base64url");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
return false;
const p = JSON.parse(Buffer.from(payload, "base64url").toString());
return (
p.iss === "tabularium" &&
p.aud === "tabularium-web" &&
p.sub === "shared" &&
Number.isInteger(p.exp) &&
Number.isInteger(p.iat) &&
p.iat <= now + 30 &&
p.exp > now &&
p.exp > p.iat &&
p.exp - p.iat <= SESSION_SECONDS
);
} catch {
return false;
}
}
export function sessionCookie(token: string, clear = false) {
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_SECONDS}${process.env.AUTH_COOKIE_SECURE === "false" ? "" : "; Secure"}`;
}
export function safeReturn(value: unknown) {
return typeof value === "string" &&
value.startsWith("/") &&
!value.startsWith("//") &&
!/[\\\r\n]/.test(value) &&
!value.startsWith("/login")
? value
: "/";
}
export function isLoginRedirect(value: string | null, base: string) {
if (!value) return false;
try {
const target = new URL(value, base);
return (
target.origin === new URL(base).origin &&
(target.pathname === "/login" ||
/^\/_next\/data\/[^/]+\/login\.json$/.test(target.pathname))
);
} catch {
return false;
}
}
+2 -2
View File
@@ -8,9 +8,9 @@ export function sectionFor(slug: string): Section {
if (slug.includes("endor-concepts"))
return {
group: "架构设计",
label: "概念草案",
label: "系统架构",
symbol: "◇",
description: "从全球入口、区域运行时到对象数据,理解 Endor 的概念拓扑。",
description: "从全球入口、区域运行时到对象数据,了解 Endor 的系统架构。",
};
if (slug.includes("mounting"))
return {
+42
View File
@@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { authConfig, sessionCookie, signSession } from "@/lib/auth";
import { consumeCode } from "@/lib/auth-store";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).end();
}
try {
const origin = req.headers.origin;
if (
!origin ||
new URL(origin).host !== req.headers.host ||
req.headers["sec-fetch-site"] === "cross-site"
)
return res.status(403).json({ error: "请求来源不受信任" });
const config = authConfig();
if (!config)
return res
.status(503)
.json({ error: "尚未配置动态码登录,请联系管理员" });
const code = typeof req.body?.code === "string" ? req.body.code.trim() : "";
const result = await consumeCode(config, code);
if (result === "limited") {
res.setHeader("Retry-After", "300");
return res.status(429).json({ error: "尝试次数过多,请五分钟后再试" });
}
if (result !== "ok")
return res
.status(401)
.json({ error: "动态码不正确或已使用,请等待下一组动态码" });
res.setHeader("Set-Cookie", sessionCookie(signSession(config)));
return res.status(200).json({ ok: true });
} catch {
return res.status(503).json({ error: "登录服务暂时不可用,请稍后重试" });
}
}
export const config = { api: { bodyParser: { sizeLimit: "1kb" } } };
+21
View File
@@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { sessionCookie } from "@/lib/auth";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).end();
}
try {
if (
!req.headers.origin ||
new URL(req.headers.origin).host !== req.headers.host ||
req.headers["sec-fetch-site"] === "cross-site"
)
return res.status(403).end();
} catch {
return res.status(403).end();
}
res.setHeader("Set-Cookie", sessionCookie("", true));
return res.status(200).json({ ok: true });
}
+3 -3
View File
@@ -95,7 +95,7 @@ export default function DocPage({
</div>
<div className="article-meta">
<span className="eyebrow">{section.group}</span>
{doc.slug.includes("draft") && (
{section.label.includes("草案") && (
<span className="draft-badge">讨论草案</span>
)}
</div>
@@ -183,7 +183,7 @@ export default function DocPage({
blockquote: ({ children }) => (
<blockquote
className={
doc.slug.includes("draft")
section.label.includes("草案")
? "callout callout-draft"
: "callout"
}
@@ -239,7 +239,7 @@ export default function DocPage({
<div className="toc-note">
<span className="status-dot" /> {section.label}
<p>
{doc.slug.includes("draft")
{section.label.includes("草案")
? "这里记录正在讨论的设计,尚不代表现有实现。"
: "每一次理解,都成为下一步的起点。"}
</p>
+1 -1
View File
@@ -119,7 +119,7 @@ export default function Home({
<p className="card-description">{section.description}</p>
<div className="card-bottom">
<span>{section.label}</span>
{doc.slug.includes("draft") && (
{section.label.includes("草案") && (
<span className="draft-badge">草案</span>
)}
</div>
+85
View File
@@ -0,0 +1,85 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { useState, type FormEvent } from "react";
import { Brand } from "@/components/Shell";
export default function Login() {
const router = useRouter();
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const result = await response.json();
if (!response.ok) {
setError(result.error || "登录没有完成");
setCode("");
return;
}
const next = router.query.next;
window.location.assign(
typeof next === "string" &&
next.startsWith("/") &&
!next.startsWith("//") &&
!/[\\\r\n]/.test(next) &&
!next.startsWith("/login")
? next
: "/",
);
} catch {
setError("暂时无法连接,请稍后重试");
} finally {
setBusy(false);
}
}
return (
<>
<Head>
<title>动态码登录 · Tabularium</title>
<meta name="robots" content="noindex,nofollow" />
</Head>
<main className="auth-page">
<div className="auth-brand">
<Brand />
</div>
<form className="auth-card" onSubmit={submit}>
<p className="eyebrow">PRIVATE KNOWLEDGE ARCHIVE</p>
<h1>输入动态码</h1>
<p className="auth-description">
打开验证器,输入当前的 6 位动态码。
</p>
<label htmlFor="auth-code">动态码</label>
<input
id="auth-code"
autoFocus
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
required
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
placeholder="000000"
aria-describedby="auth-error"
/>
<div className="auth-error" id="auth-error" role="alert">
{error}
</div>
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? "正在验证…" : "进入文档"}
</button>
<p className="auth-footnote">尚未绑定验证器?请联系管理员。</p>
</form>
</main>
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const headers = {
"Cache-Control": "private, no-store",
"Referrer-Policy": "same-origin",
};
if (
path === "/login" ||
path === "/api/health" ||
path === "/api/auth/login" ||
path === "/api/auth/logout" ||
path.startsWith("/_next/static/")
) {
return NextResponse.next({ headers });
}
let config;
try {
config = authConfig();
} catch {
/* Missing or invalid files fail closed. */
}
if (!config)
return NextResponse.json(
{ error: "登录服务尚未配置,请联系管理员" },
{ status: 503, headers },
);
const authorization = request.headers.get("authorization");
const bearer = authorization?.startsWith("Bearer ")
? authorization.slice(7)
: undefined;
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
if (validSession(token, config)) {
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
try {
if (
!request.headers.get("origin") ||
new URL(request.headers.get("origin")!).host !==
request.headers.get("host") ||
request.headers.get("sec-fetch-site") === "cross-site"
)
throw new Error();
} catch {
return NextResponse.json(
{ error: "请求来源不受信任" },
{ status: 403, headers },
);
}
}
return NextResponse.next({ headers });
}
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
return NextResponse.json(
{ error: "请先使用动态码登录" },
{ status: 401, headers },
);
}
const login = request.nextUrl.clone();
login.pathname = "/login";
login.search = "";
login.searchParams.set("next", path + request.nextUrl.search);
return NextResponse.redirect(login, { headers });
}
+85
View File
@@ -1447,3 +1447,88 @@ button {
max-height: none;
}
}
.auth-page {
min-height: 100dvh;
display: grid;
place-content: center;
padding: 32px 20px;
background: #fafcf9;
}
.auth-brand {
margin: 0 auto 30px;
}
.auth-card {
width: min(390px, calc(100vw - 40px));
padding: 36px;
border: 1px solid var(--line);
border-radius: 12px;
background: white;
box-shadow: 0 15px 45px #34523c06;
}
.auth-card h1 {
font-size: 26px;
font-weight: 550;
letter-spacing: -0.8px;
margin: 22px 0 12px;
}
.auth-description {
font-size: 13px;
color: #78867a;
line-height: 1.8;
margin-bottom: 25px;
}
.auth-card label {
display: block;
font-size: 12px;
color: #617566;
margin-bottom: 10px;
}
.auth-card input {
width: 100%;
padding: 13px;
border: 1px solid #d8e2d7;
border-radius: 6px;
font:
24px ui-monospace,
monospace;
letter-spacing: 10px;
text-align: center;
}
.auth-card input::placeholder {
color: #bdc8bb;
}
.auth-error {
font-size: 12px;
color: #a64e3c;
min-height: 38px;
padding: 8px 0;
line-height: 1.6;
}
.auth-card button[type="submit"] {
display: block;
width: 100%;
padding: 12px;
background: #2c7057;
border-radius: 6px;
color: white;
font-size: 13px;
}
.auth-card button:disabled {
opacity: 0.5;
cursor: default;
}
.auth-footnote {
font-size: 11px;
color: #92a08f;
margin-top: 20px;
text-align: center;
}
.logout-button {
font-size: 11px;
color: #788575;
padding: 6px 0;
}
.logout-button:hover {
color: var(--green);
}
+84
View File
@@ -0,0 +1,84 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
authConfig,
base32,
matchingStep,
newAuthConfig,
safeReturn,
sessionCookie,
signSession,
totp,
validSession,
} from "../src/lib/auth";
import { consumeCode } from "../src/lib/auth-store";
test("RFC6238 SHA1 vectors, format and narrow time window", () => {
const secret = base32(Buffer.from("12345678901234567890"));
for (const [time, expected] of [
[59, "94287082"],
[1111111109, "07081804"],
[2000000000, "69279037"],
] as const) {
assert.equal(totp(secret, Math.floor(time / 30), 8), expected);
}
assert.equal(matchingStep(secret, "287082", 59000), 1);
assert.equal(matchingStep(secret, "123", 59000), null);
assert.equal(matchingStep(secret, totp(secret, 10), 1000000), null);
});
test("signed sessions reject tampering, expiration, future issuance and excessive lifetime", () => {
const config = newAuthConfig();
const token = signSession(config, 100);
assert.equal(validSession(token, config, 101), true);
assert.equal(validSession(token + "x", config, 101), false);
assert.equal(validSession(token, newAuthConfig(), 101), false);
assert.equal(validSession(signSession(config, 100, 1), config, 101), false);
assert.equal(validSession(signSession(config, 200), config, 100), false);
assert.equal(
validSession(signSession(config, 100, 604801), config, 101),
false,
);
assert.equal(validSession("a.b.c", config), false);
assert.equal(safeReturn("//untrusted.example"), "/");
assert.equal(safeReturn("/\\untrusted.example"), "/");
assert.equal(safeReturn("/docs/example"), "/docs/example");
assert.match(sessionCookie("test"), /HttpOnly; SameSite=Lax/);
assert.match(sessionCookie("", true), /Max-Age=0/);
});
test("durable replay protection and rate limit survive reopened SQLite connections", () => {
const directory = mkdtempSync(join(tmpdir(), "tabularium-auth-state-"));
const previous = process.env.AUTH_CONFIG_FILE;
try {
const config = newAuthConfig();
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
writeFileSync(process.env.AUTH_CONFIG_FILE, JSON.stringify(config), {
mode: 0o600,
});
assert.deepEqual(authConfig(), config);
const now = 2000000000000;
const code = totp(config.secret, Math.floor(now / 30000));
assert.equal(consumeCode(config, code, now), "ok");
assert.equal(consumeCode(config, code, now), "invalid");
for (let i = 0; i < 18; i++)
assert.equal(consumeCode(config, "invalid", now), "invalid");
assert.equal(consumeCode(config, code, now), "limited");
const later = now + 300001;
assert.equal(
consumeCode(
config,
totp(config.secret, Math.floor(later / 30000)),
later,
),
"ok",
);
writeFileSync(process.env.AUTH_CONFIG_FILE, "{}");
assert.throws(() => authConfig());
} finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(directory, { recursive: true, force: true });
}
});
+2 -2
View File
@@ -1,8 +1,8 @@
# 理解 Endor:从全球入口到对象数据
# Endor 系统架构
Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。PostgreSQL、Aerarium 和 S3 各自维护独立的分片群,在区域内保持高可用,并由统一备份系统追踪所有区域、所有存储类型的分片与恢复材料。
> **概念设计草案**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
> **架构说明**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
## 架构总览
+23
View File
@@ -0,0 +1,23 @@
# 动态码认证与标题修订验收 · 2026-10-27
参考用户指定聊天中的PROMPT流程:6位/30秒TOTP、共享验证器、7天HttpOnly/SameSite=Lax会话、20次/5分钟限流、动态码时间步防重放、SSH管理员二维码工具。Tabularium生成独立密钥,不使用PROMPT密钥。
Tabularium保持现有Pages Router和部署架构,使用项目独立SQLite文件保存尝试与已用时间步,不引入外部数据库。密钥和状态在本项目私有挂载目录持久保存,正常升级只初始化缺失配置,绝不自动轮换。未配置或配置损坏时正文拒绝访问。
## 本地验证
- 三个认证单元测试通过:RFC6238官方SHA1测试向量、窗口与格式;会话篡改/过期/未来时间/过长寿命;SQLite重新打开后的重放与限流。
- `auth-cli exercise`通过:匿名HTML、文档JSON、SVG重定向登录;未知API返回401;伪造middleware头不能绕过;登录页不包含密钥;有效会话可读全部文档与资源;伪造会话无效;并发同码只有一次200,其余401;跨站403;退出清Cookie;连续尝试429。
- 实际浏览器在本地独立测试配置下输入动态码成功,按next返回原文档。未在工具输出中打印密钥或动态码。
- 架构文章标题为“Endor 系统架构”;该文主标题上方讨论草案徽标数量为0,正文保留架构说明以区分设计与已实现内容。
- lint、类型检查、认证测试、生产构建及现有四个CI/部署脚本单元测试通过。本机继续使用此前可用的webpack构建,再打包服务器CLI;CI生产构建配置继续使用Next.js默认构建。
## 交付与安全边界
- `login.png`:不含动态码的登录入口截图。
- `architecture.png`:登录后新标题与去除徽标的页面截图。
- 绑定二维码保存于仓库外的私有目录,绝不放入此验证目录或公开网页。
- 当前公网HTTP与参考项目一致,HTTP不加密动态码/会话。HTTPS启用后应将AUTH_COOKIE_SECURE设为true。
- 该方案是验证器动态码单因素登录,不声称是密码加动态码的双因素组合。
依据:[RFC6238](https://www.rfc-editor.org/rfc/rfc6238.html)、[Next.js Proxy](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+5
View File
@@ -3,6 +3,11 @@ services:
docs:
image: ${TABULARIUM_IMAGE:?verified image required}
init: true
environment:
AUTH_CONFIG_FILE: /auth/config.json
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
volumes:
- /opt/endorphin-tabularium-docs/private-auth:/auth
ports:
- "${TABULARIUM_BIND:-0.0.0.0}:${TABULARIUM_PORT:-18135}:3000"
restart: unless-stopped
+2 -1
View File
@@ -13,6 +13,7 @@
"lint": "pnpm --filter @endor/docs run lint",
"typecheck": "pnpm --filter @endor/docs run typecheck",
"figures": "uv run --locked --project tools/figures endor-figures",
"check": "pnpm run lint && pnpm run typecheck && pnpm run figures && pnpm run build"
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth"
}
}
+470
View File
@@ -13,6 +13,9 @@ importers:
next:
specifier: 16.4.3
version: 16.4.3(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
qrcode:
specifier: 1.5.4
version: 1.5.4
react:
specifier: 19.3.1
version: 19.3.1
@@ -35,12 +38,18 @@ importers:
'@types/node':
specifier: 22.20.5
version: 22.20.5
'@types/qrcode':
specifier: 1.5.6
version: 1.5.6
'@types/react':
specifier: 19.3.0
version: 19.3.0
'@types/react-dom':
specifier: 19.3.0
version: 19.3.0(@types/react@19.3.0)
esbuild:
specifier: 0.25.12
version: 0.25.12
eslint:
specifier: ^9.39.5
version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
@@ -142,6 +151,162 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -598,6 +763,9 @@ packages:
'@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.3.0':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
peerDependencies:
@@ -807,6 +975,10 @@ packages:
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -916,6 +1088,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001819:
resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==}
@@ -941,6 +1117,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1001,6 +1180,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -1026,6 +1209,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -1037,6 +1223,9 @@ packages:
electron-to-chromium@1.5.464:
resolution: {integrity: sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1080,6 +1269,11 @@ packages:
resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
engines: {node: '>= 0.4'}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1252,6 +1446,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1285,6 +1483,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1448,6 +1650,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@@ -1651,6 +1857,10 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1913,14 +2123,26 @@ packages:
resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1950,6 +2172,10 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1980,6 +2206,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2021,6 +2252,13 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2064,6 +2302,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2123,6 +2364,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@@ -2149,6 +2394,10 @@ packages:
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2307,6 +2556,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.24:
resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==}
engines: {node: '>= 0.4'}
@@ -2320,9 +2572,24 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2464,6 +2731,84 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
@@ -2824,6 +3169,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.20.5
'@types/react-dom@19.3.0(@types/react@19.3.0)':
dependencies:
'@types/react': 19.3.0
@@ -3012,6 +3361,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -3147,6 +3498,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001819: {}
ccount@2.0.1: {}
@@ -3166,6 +3519,12 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -3220,6 +3579,8 @@ snapshots:
optionalDependencies:
supports-color: 7.2.0
decamelize@1.2.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -3246,6 +3607,8 @@ snapshots:
dependencies:
dequal: 2.0.3
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -3258,6 +3621,8 @@ snapshots:
electron-to-chromium@1.5.464: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
enhanced-resolve@5.26.0:
@@ -3376,6 +3741,35 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -3621,6 +4015,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -3657,6 +4056,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3842,6 +4243,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@@ -4014,6 +4417,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -4507,14 +4914,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -4541,6 +4958,8 @@ snapshots:
picomatch@4.0.7: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@6.0.10:
@@ -4572,6 +4991,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
react-dom@19.3.1(react@19.3.1):
@@ -4655,6 +5080,10 @@ snapshots:
mdast-util-to-markdown: 2.2.0
unified: 11.0.5
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -4699,6 +5128,8 @@ snapshots:
semver@7.8.5: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -4800,6 +5231,12 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.9
@@ -4856,6 +5293,10 @@ snapshots:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@@ -5082,6 +5523,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.24:
dependencies:
available-typed-arrays: 1.0.7
@@ -5098,8 +5541,35 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@4.0.3: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.6.7):
+1
View File
@@ -2,4 +2,5 @@ packages:
- apps/*
saveExact: true
allowBuilds:
esbuild: true
unrs-resolver: true
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
cd /opt/endorphin-tabularium-docs/current
command=${1:-qr}
compose() {
docker compose -p endorphin-tabularium-docs --env-file /etc/endorphin-tabularium-docs/production.env --env-file release.env -f compose.yml "$@"
}
case "$command" in
qr|token) compose exec docs node apps/docs/auth-cli.cjs "$command" ;;
qr-png|verify) compose exec -T docs node apps/docs/auth-cli.cjs "$command" ;;
rotate)
[ "${2:-}" = "--confirm" ] || { echo 'Use rotate --confirm to invalidate authenticators and sessions' >&2; exit 1; }
compose exec -T docs node apps/docs/auth-cli.cjs rotate --confirm ;;
*) echo 'Usage: auth qr | qr-png | token | verify | rotate --confirm' >&2; exit 1 ;;
esac
+2
View File
@@ -1,9 +1,11 @@
#!/bin/sh
set -eu
export CI=true
pnpm install --frozen-lockfile --store-dir /pnpm/store
uv sync --project tools/figures --locked
pnpm run lint
pnpm run typecheck
pnpm --filter @endor/docs run test:auth
cp -R apps/docs/public/figures /tmp/expected-figures
pnpm run figures
uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v
+14 -6
View File
@@ -35,22 +35,30 @@ def fingerprint() -> str:
def smoke_image(image: str, name: str) -> None:
from smoke import verify
run("docker", "run", "-d", "--name", name, "-p", "127.0.0.1::3000", image)
run("docker", "run", "-d", "--name", name, "--mount", "type=volume,dst=/auth", "-p", "127.0.0.1::3000", image)
try:
port = run("docker", "port", name, "3000/tcp", capture=True).rsplit(":", 1)[1]
run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "init")
for _ in range(60):
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
if state.get("Health", {}).get("Status") == "healthy":
verify(f"http://127.0.0.1:{port}")
return
run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "exercise")
run("docker", "restart", name)
# Counter/replay state is on the same durable mount after restart.
run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "state-check")
for _ in range(30):
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
if state.get("Health", {}).get("Status") == "healthy":
run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "verify")
return
time.sleep(1)
raise RuntimeError("Authenticated image failed restart verification")
if not state["Running"]:
break
time.sleep(1)
run("docker", "logs", "--tail", "80", name)
raise RuntimeError("Production image did not become healthy")
finally:
run("docker", "rm", "-f", name)
run("docker", "rm", "-fv", name)
def main() -> None:
+13 -2
View File
@@ -9,7 +9,6 @@ import socket
import subprocess
import urllib.request
from ci import APP, ROOT, fingerprint, run
from smoke import verify
DEPLOY_ROOT = Path("/opt/endorphin-tabularium-docs")
CONFIG = Path("/etc/endorphin-tabularium-docs/production.env")
@@ -101,6 +100,16 @@ def main() -> None:
if not 1024 <= port <= 65535:
raise ValueError("Invalid port")
check_port(port)
auth_dir = DEPLOY_ROOT / "private-auth"
auth_dir.mkdir(mode=0o700, exist_ok=True)
auth_dir.chmod(0o700)
os.chown(auth_dir, 1000, 1000)
# Initialize once; normal updates must preserve the authenticator key.
run("docker", "run", "--rm", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/auth-cli.cjs", "init")
shutil.copyfile(ROOT / "scripts/auth-admin.sh", DEPLOY_ROOT / "auth")
(DEPLOY_ROOT / "auth").chmod(0o750)
current = DEPLOY_ROOT / "current"
previous = current.resolve() if current.is_symlink() else None
release = DEPLOY_ROOT / "releases" / sha
@@ -109,7 +118,9 @@ def main() -> None:
(release / "release.env").write_text(f"TABULARIUM_IMAGE={actual}\n")
try:
compose(release, "up", "-d", "--no-build", "--wait", "--wait-timeout", "120")
verify(f"http://127.0.0.1:{port}")
container = run("docker", "ps", "-q", "--filter", f"label=com.docker.compose.project={APP}",
"--filter", "label=com.docker.compose.service=docs", capture=True)
run("docker", "exec", container, "node", "apps/docs/auth-cli.cjs", "verify")
except Exception:
if previous is not None:
print("Deployment failed; restoring previous verified release", flush=True)
+28 -36
View File
@@ -1,48 +1,40 @@
"""Exercise actual production routes and assets without third-party dependencies."""
from html import unescape
"""Check the public authentication boundary; auth-cli verify checks private content."""
import json
from pathlib import Path
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def verify(base: str) -> None:
def get(path: str) -> tuple[bytes, str]:
with urllib.request.urlopen(base.rstrip("/") + path, timeout=15) as response:
if response.status != 200:
raise RuntimeError(f"Unexpected status for {path}: {response.status}")
return response.read(), response.headers.get("Content-Type", "")
opener = urllib.request.build_opener(NoRedirect())
base = base.rstrip("/")
health, _ = get("/api/health")
assert json.loads(health) == {"status": "ok"}
home, _ = get("/")
assert b"TABULARIUM" in home
for file in sorted((ROOT / "content/docs").glob("*.md")):
page, _ = get(f"/docs/{file.stem}")
title = re.search(r"^# (.+)$", file.read_text(), re.M)
assert title is not None
text = unescape(re.sub(r"<[^>]+>", "", page.decode()))
assert title.group(1) in text, file.name
assets = set(re.findall(r'(?:src|href)="(/_next/static/[^" ]+)"', home.decode()))
assert assets, "No generated JavaScript/CSS assets found"
for asset in assets:
get(unescape(asset))
for file in sorted((ROOT / "apps/docs/public/figures").glob("*.svg")):
data, kind = get(f"/figures/{file.name}")
assert "image/svg+xml" in kind
assert ET.fromstring(data).tag.endswith("svg")
try:
get("/docs/this-document-does-not-exist")
except urllib.error.HTTPError as error:
assert error.code == 404
else:
raise RuntimeError("Unknown document did not return 404")
print(f"Smoke passed: {base} (health, documents, JS/CSS, SVG, 404)", flush=True)
def get(path):
try:
response = opener.open(base + path, timeout=15)
except urllib.error.HTTPError as error:
response = error
with response:
return response.code, response.headers, response.read()
status, _, health = get("/api/health")
assert status == 200 and json.loads(health) == {"status": "ok"}
status, _, login = get("/login")
assert status == 200 and "输入动态码" in login.decode()
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg"):
status, headers, _ = get(path)
assert status == 307, path
target = urllib.parse.urlparse(urllib.parse.urljoin(base, headers["Location"]))
assert target.netloc == urllib.parse.urlparse(base).netloc and target.path == "/login"
assert "no-store" in headers.get("Cache-Control", "")
assert get("/api/unknown")[0] == 401
print(f"Public auth boundary passed: {base} (login, health, protected pages/SVG/API)", flush=True)
if __name__ == "__main__":