Protect Tabularium with persistent authenticator-code login
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s
This commit is contained in:
+2
-1
@@ -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 \
|
||||
|
||||
@@ -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 缓存或卷。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("&", "&"),
|
||||
),
|
||||
);
|
||||
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;
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" } } };
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
# 理解 Endor:从全球入口到对象数据
|
||||
# Endor 系统架构
|
||||
|
||||
Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。PostgreSQL、Aerarium 和 S3 各自维护独立的分片群,在区域内保持高可用,并由统一备份系统追踪所有区域、所有存储类型的分片与恢复材料。
|
||||
|
||||
> **概念设计草案**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
|
||||
> **架构说明**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
|
||||
|
||||
## 架构总览
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+470
@@ -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):
|
||||
|
||||
@@ -2,4 +2,5 @@ packages:
|
||||
- apps/*
|
||||
saveExact: true
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
unrs-resolver: true
|
||||
|
||||
Executable
+15
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user