feat: add scoped document API and resumable batch uploader
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
totp,
|
||||
isLoginRedirect,
|
||||
} from "../src/lib/auth";
|
||||
import { createDocumentKey, revokeDocumentKey } from "../src/lib/document-keys";
|
||||
import { readDocument, saveDocument } from "../src/lib/docs";
|
||||
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||
|
||||
@@ -210,6 +211,7 @@ async function main() {
|
||||
assert.equal(raw.status, 200);
|
||||
assert.ok((await raw.text()).startsWith("# Endor"));
|
||||
if (process.env.DOCUMENT_SMOKE_TEST) {
|
||||
if (command === "exercise") await exerciseDocumentApi(request, headers);
|
||||
const sourceSlug = "ci-live-document";
|
||||
const previous = readDocument(sourceSlug);
|
||||
if (command === "exercise") {
|
||||
@@ -356,3 +358,42 @@ main().catch((error) => {
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function exerciseDocumentApi(request: (path: string, init?: RequestInit) => Promise<Response>, session: Record<string, string>) {
|
||||
const writer = createDocumentKey("isolated API smoke", "read-write", 1);
|
||||
const reader = createDocumentKey("isolated read smoke", "read", 1);
|
||||
const headers = { Authorization: "Bearer " + writer.token, "Content-Type": "application/json", "Idempotency-Key": "smoke-batch" };
|
||||
const readHeaders = { Authorization: "Bearer " + reader.token, "Content-Type": "application/json" };
|
||||
const make = (name: string) => ({ slug: `endor/${name}`, sourceSlug: name, title: "API smoke",
|
||||
category: "Endor", collection: "Endor 文档", group: "CI", description: "Isolated API smoke",
|
||||
draft: true, featured: false, content: "# API smoke", revision: 0 });
|
||||
const payload = { documents: [make("ci-api-first"), make("ci-api-second")] };
|
||||
try {
|
||||
assert.equal((await request("/api/documents")).status, 401);
|
||||
assert.equal((await request("/api/documents", { method: "POST", headers: { ...session, "Content-Type": "application/json" }, body: JSON.stringify(payload.documents[0]) })).status, 401);
|
||||
assert.equal((await request("/api/research/ai-companies", { headers })).status, 401, "Document keys cannot read research");
|
||||
assert.equal((await request("/api/search", { headers })).status, 401, "Document keys cannot authenticate other APIs");
|
||||
assert.equal((await request("/api/documents/batch", { method: "POST", headers: readHeaders, body: JSON.stringify(payload) })).status, 403);
|
||||
const saved = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal((await saved.json()).documents.length, 2);
|
||||
const replay = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
|
||||
assert.equal(replay.status, 200); assert.equal((await replay.json()).replayed, true);
|
||||
const current = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
|
||||
assert.equal(current.status, 200); assert.equal((await current.json()).revision, 1);
|
||||
const page = await request("/api/documents?limit=1", { headers: readHeaders });
|
||||
assert.equal(page.status, 200);
|
||||
const listing = await page.json();
|
||||
assert.equal(listing.documents.length, 1); assert.equal(listing.documents[0].content, undefined);
|
||||
const bad = { documents: [{ ...payload.documents[0], revision: 1, content: "Must roll back" }, payload.documents[1]] };
|
||||
const conflict = await request("/api/documents/batch", { method: "POST", headers: { ...headers, "Idempotency-Key": "smoke-conflict" }, body: JSON.stringify(bad) });
|
||||
assert.equal(conflict.status, 409);
|
||||
const unchanged = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
|
||||
assert.equal((await unchanged.json()).content, "# API smoke");
|
||||
const history = await request("/api/documents/item/endor/ci-api-first?history=1", { headers });
|
||||
assert.equal(history.status, 200); assert.equal((await history.json()).total, 1);
|
||||
revokeDocumentKey(writer.id);
|
||||
assert.equal((await request("/api/documents", { headers })).status, 401);
|
||||
} finally { revokeDocumentKey(writer.id); revokeDocumentKey(reader.id); }
|
||||
console.log("Document API smoke passed: scoped keys, batch rollback, pagination, history, replay and revocation; no secrets displayed");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
|
||||
import { resolve, dirname, sep } from "node:path";
|
||||
import { createDocumentKey, listDocumentKeys, revokeDocumentKey, type DocumentKeyScope } from "../src/lib/document-keys";
|
||||
import { openSiteDatabase } from "../src/lib/site-database";
|
||||
import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs";
|
||||
|
||||
process.umask(0o077);
|
||||
const [command, argument] = process.argv.slice(2);
|
||||
const [command, argument, ...extra] = process.argv.slice(2);
|
||||
try {
|
||||
switch (command) {
|
||||
case "key-create": {
|
||||
const authFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!authFile || !argument || !extra[0]) throw Error("Usage: key-create <name> <private-file> [read|read-write] [days]");
|
||||
const file = resolve(extra[0]);
|
||||
const directory = resolve(dirname(authFile));
|
||||
if (!file.startsWith(directory + sep)) throw Error("Key output must be inside the private authentication directory");
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
let created: { id: string } | undefined;
|
||||
try {
|
||||
const key = createDocumentKey(argument, (extra[1] ?? "read-write") as DocumentKeyScope, Number(extra[2] ?? 90));
|
||||
created = key;
|
||||
writeFileSync(fd, JSON.stringify(key) + "\n");
|
||||
console.log(JSON.stringify({ id: key.id, scope: key.scope, expiresAt: key.expiresAt, saved: file }));
|
||||
} catch (error) {
|
||||
if (created) revokeDocumentKey(created.id);
|
||||
unlinkSync(file);
|
||||
throw error;
|
||||
} finally { closeSync(fd); }
|
||||
break;
|
||||
}
|
||||
case "key-list": console.log(JSON.stringify(listDocumentKeys(), null, 2)); break;
|
||||
case "key-revoke":
|
||||
if (!argument) throw Error("Provide key id");
|
||||
revokeDocumentKey(argument); console.log("Document key revoked"); break;
|
||||
case "backup": {
|
||||
if (!argument) throw Error("Provide a private backup filename");
|
||||
const db = openSiteDatabase();
|
||||
@@ -36,7 +62,7 @@ try {
|
||||
{ flag: "wx", mode: 0o600 });
|
||||
console.log("Document export created; existing files are never overwritten"); break;
|
||||
}
|
||||
default: throw Error("Usage: docs backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
|
||||
default: throw Error("Usage: docs key-create <name> <private-file> [read|read-write] [days] | key-list | key-revoke <id> | backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : "Document operation failed");
|
||||
|
||||
@@ -6,9 +6,8 @@ import { spawnSync } from "node:child_process";
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-"));
|
||||
try {
|
||||
const file = join(dir, "docs.test.cjs");
|
||||
await build({ entryPoints: ["tests/docs.test.ts"], bundle: true,
|
||||
platform: "node", target: "node22", format: "cjs", outfile: file });
|
||||
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
|
||||
await build({ entryPoints: ["tests/docs.test.ts", "tests/documents-api.test.ts"], bundle: true,
|
||||
platform: "node", target: "node22", format: "cjs", outdir: dir, outExtension: { ".js": ".cjs" } });
|
||||
const result = spawnSync(process.execPath, ["--test", join(dir, "docs.test.cjs"), join(dir, "documents-api.test.cjs")], { stdio: "inherit" });
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
|
||||
+105
-15
@@ -9,6 +9,9 @@ export type DocLink = {
|
||||
export type Doc = DocLink & { content: string };
|
||||
export type DocInput = Omit<Doc, "revision" | "searchText">;
|
||||
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
|
||||
export class DocumentValidationError extends Error {}
|
||||
export class DocumentConflictError extends Error {}
|
||||
export type DocumentReceipt = Pick<Doc, "slug" | "sourceSlug" | "revision">;
|
||||
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
|
||||
function database() {
|
||||
@@ -25,6 +28,10 @@ function database() {
|
||||
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_write_requests (
|
||||
namespace TEXT NOT NULL, request_id TEXT NOT NULL, digest TEXT NOT NULL,
|
||||
response TEXT NOT NULL, PRIMARY KEY(namespace, request_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_imports (
|
||||
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -33,50 +40,51 @@ function database() {
|
||||
}
|
||||
|
||||
export function validateDocument(input: DocInput): void {
|
||||
if (!input || typeof input !== "object") throw Error("Invalid document");
|
||||
if (!input || typeof input !== "object") throw new DocumentValidationError("Invalid document");
|
||||
for (const key of ["slug", "sourceSlug"] as const)
|
||||
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`);
|
||||
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug");
|
||||
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`);
|
||||
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw new DocumentValidationError("Use a semantic public slug");
|
||||
for (const key of ["title", "category", "collection", "group"] as const)
|
||||
if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`);
|
||||
if (typeof input[key] !== "string" || !input[key].trim()) throw new DocumentValidationError(`Missing ${key}`);
|
||||
if (typeof input.content !== "string" || typeof input.description !== "string" ||
|
||||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields");
|
||||
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit");
|
||||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields");
|
||||
if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit");
|
||||
const images = [
|
||||
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
|
||||
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
|
||||
];
|
||||
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)))
|
||||
throw Error("Use compressed WebP images instead of original rasters");
|
||||
throw new DocumentValidationError("Use compressed WebP images instead of original rasters");
|
||||
}
|
||||
|
||||
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
|
||||
validateDocument(input);
|
||||
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict");
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
|
||||
const id = existing?.id ?? randomUUID();
|
||||
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
|
||||
title: input.title, group: input.group, collection: input.collection, description: input.description,
|
||||
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
|
||||
const payload = JSON.stringify(doc);
|
||||
// Aliases remain reserved after renaming; collision rolls back the whole write.
|
||||
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
|
||||
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
|
||||
// Reserve both canonical and historical names before updating any rows.
|
||||
for (const alias of new Set([doc.slug, doc.sourceSlug])) {
|
||||
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
|
||||
.get(alias) as { document_id: string } | undefined;
|
||||
if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`);
|
||||
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
|
||||
if (prior && prior.document_id !== id) throw new DocumentConflictError(`Document alias collision: ${alias}`);
|
||||
}
|
||||
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
|
||||
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
|
||||
for (const alias of new Set([doc.slug, doc.sourceSlug]))
|
||||
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
|
||||
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
|
||||
.run(id, doc.revision, payload, new Date().toISOString());
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw Error("Provide expected revision");
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
@@ -145,3 +153,85 @@ export function documentHistory(sourceSlug: string): Doc[] {
|
||||
.map((row) => JSON.parse(row.payload) as Doc);
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
// API batches commit entirely or not at all; receipts make lost-response retries safe.
|
||||
export function saveDocumentBatch(documents: Doc[], request?: { namespace: string; id: string }) {
|
||||
if (!Array.isArray(documents) || !documents.length || documents.length > 100)
|
||||
throw new DocumentValidationError("Provide 1 to 100 documents");
|
||||
const seen = new Set<string>();
|
||||
for (const doc of documents) {
|
||||
validateDocument(doc);
|
||||
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0)
|
||||
throw new DocumentValidationError("Provide expected revision");
|
||||
if (seen.has(doc.sourceSlug)) throw new DocumentValidationError("Duplicate sourceSlug in batch");
|
||||
seen.add(doc.sourceSlug);
|
||||
}
|
||||
if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id)))
|
||||
throw new DocumentValidationError("Invalid Idempotency-Key");
|
||||
const digest = createHash("sha256").update(JSON.stringify(documents)).digest("hex");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
if (request) {
|
||||
const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?")
|
||||
.get(request.namespace, request.id) as { digest: string; response: string } | undefined;
|
||||
if (prior) {
|
||||
if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content");
|
||||
db.exec("COMMIT");
|
||||
return { documents: JSON.parse(prior.response) as DocumentReceipt[], replayed: true };
|
||||
}
|
||||
}
|
||||
const receipts = documents.map((input) => {
|
||||
const { slug, sourceSlug, revision } = write(db, input, input.revision);
|
||||
return { slug, sourceSlug, revision };
|
||||
});
|
||||
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
|
||||
.run(request.namespace, request.id, digest, JSON.stringify(receipts));
|
||||
db.exec("COMMIT");
|
||||
return { documents: receipts, replayed: false };
|
||||
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentPage(limit = 50, offset = 0) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
|
||||
throw new DocumentValidationError("Invalid pagination");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const total = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
|
||||
const documents = (db.prepare("SELECT json_remove(payload,'$.content') AS payload FROM documents ORDER BY source_slug LIMIT ? OFFSET ?")
|
||||
.all(limit, offset) as { payload: string }[]).map((row) => JSON.parse(row.payload) as DocLink);
|
||||
db.exec("COMMIT");
|
||||
return { documents, total, limit, offset, nextOffset: offset + documents.length < total ? offset + documents.length : null };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentHistoryPage(sourceSlug: string, limit = 50, offset = 0) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
|
||||
throw new DocumentValidationError("Invalid pagination");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const total = (db.prepare(`SELECT COUNT(*) AS n FROM document_revisions r JOIN documents d
|
||||
ON d.id=r.document_id WHERE d.source_slug=?`).get(sourceSlug) as { n: number }).n;
|
||||
const revisions = (db.prepare(`SELECT json_remove(r.payload,'$.content') AS payload, r.created_at AS createdAt
|
||||
FROM document_revisions r JOIN documents d ON d.id=r.document_id
|
||||
WHERE d.source_slug=? ORDER BY r.revision DESC LIMIT ? OFFSET ?`)
|
||||
.all(sourceSlug, limit, offset) as { payload: string; createdAt: string }[])
|
||||
.map((row) => ({ ...JSON.parse(row.payload) as DocLink, createdAt: row.createdAt }));
|
||||
db.exec("COMMIT");
|
||||
return { revisions, total, limit, offset, nextOffset: offset + revisions.length < total ? offset + revisions.length : null };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentRevision(sourceSlug: string, revision: number): Doc | null {
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) throw new DocumentValidationError("Invalid revision");
|
||||
const db = database();
|
||||
try {
|
||||
const row = db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
|
||||
ON d.id=r.document_id WHERE d.source_slug=? AND r.revision=?`).get(sourceSlug, revision) as { payload: string } | undefined;
|
||||
return row ? JSON.parse(row.payload) as Doc : null;
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
|
||||
export type DocumentKeyScope = "read" | "read-write";
|
||||
export type DocumentKey = { id: string; name: string; scope: DocumentKeyScope;
|
||||
createdAt: number; expiresAt: number; revokedAt: number | null; lastUsedAt: number | null };
|
||||
function database() {
|
||||
const db = openSiteDatabase();
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS document_api_keys (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, digest TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL CHECK(scope IN ('read','read-write')), created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL, revoked_at INTEGER, last_used_at INTEGER
|
||||
);`);
|
||||
return db;
|
||||
}
|
||||
export function createDocumentKey(name: string, scope: DocumentKeyScope = "read-write", days = 90) {
|
||||
if (!name.trim() || name.length > 100 || !["read", "read-write"].includes(scope) ||
|
||||
!Number.isSafeInteger(days) || days < 1 || days > 365) throw Error("Invalid key name, scope or lifetime");
|
||||
const id = randomUUID();
|
||||
const token = "tdoc_" + randomBytes(32).toString("base64url");
|
||||
const createdAt = Date.now();
|
||||
const expiresAt = createdAt + days * 86400000;
|
||||
const db = database();
|
||||
try {
|
||||
db.prepare("INSERT INTO document_api_keys(id,name,digest,scope,created_at,expires_at) VALUES (?,?,?,?,?,?)")
|
||||
.run(id, name.trim(), createHash("sha256").update(token).digest("hex"), scope, createdAt, expiresAt);
|
||||
return { id, token, name: name.trim(), scope, createdAt, expiresAt };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function authenticateDocumentKey(token: string | undefined, now = Date.now()) {
|
||||
if (!token || !/^tdoc_[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
const db = database();
|
||||
try {
|
||||
const row = db.prepare("SELECT id,scope,last_used_at AS lastUsedAt FROM document_api_keys WHERE digest=? AND revoked_at IS NULL AND expires_at>?")
|
||||
.get(createHash("sha256").update(token).digest("hex"), now) as { id: string; scope: DocumentKeyScope; lastUsedAt: number | null } | undefined;
|
||||
if (!row) return null;
|
||||
if (row.lastUsedAt === null || row.lastUsedAt < now - 60000)
|
||||
db.prepare("UPDATE document_api_keys SET last_used_at=? WHERE id=? AND (last_used_at IS NULL OR last_used_at<?)")
|
||||
.run(now, row.id, now - 60000);
|
||||
return { id: row.id, scope: row.scope };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function listDocumentKeys(): DocumentKey[] {
|
||||
const db = database();
|
||||
try {
|
||||
return db.prepare(`SELECT id,name,scope,created_at AS createdAt,expires_at AS expiresAt,
|
||||
revoked_at AS revokedAt,last_used_at AS lastUsedAt FROM document_api_keys ORDER BY created_at,id`).all() as DocumentKey[];
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function revokeDocumentKey(id: string) {
|
||||
const db = database();
|
||||
try {
|
||||
const result = db.prepare("UPDATE document_api_keys SET revoked_at=COALESCE(revoked_at,?) WHERE id=?").run(Date.now(), id);
|
||||
if (!result.changes) throw Error("Document key not found");
|
||||
} finally { db.close(); }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticateDocumentKey } from "./document-keys";
|
||||
import { DocumentConflictError, DocumentValidationError, validateDocument, type Doc } from "./docs";
|
||||
|
||||
export function authorizeDocuments(request: NextApiRequest, response: NextApiResponse, write = false) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
const header = request.headers.authorization;
|
||||
const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : undefined;
|
||||
const key = authenticateDocumentKey(token);
|
||||
if (!key) {
|
||||
response.status(401).json({ error: "需要有效的文档 API Key", code: "unauthorized" });
|
||||
return null;
|
||||
}
|
||||
if (write && key.scope !== "read-write") {
|
||||
response.status(403).json({ error: "该 API Key 仅允许读取文档", code: "read_only" });
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
export function documentApiError(response: NextApiResponse, error: unknown) {
|
||||
if (error instanceof DocumentConflictError)
|
||||
return response.status(409).json({ error: error.message, code: "conflict" });
|
||||
if (error instanceof DocumentValidationError)
|
||||
return response.status(400).json({ error: error.message, code: "invalid_document" });
|
||||
return response.status(503).json({ error: "文档数据库暂时不可用", code: "unavailable" });
|
||||
}
|
||||
export function parseDocument(value: unknown): Doc {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected a document object");
|
||||
const row = value as Record<string, unknown>;
|
||||
const fields = ["slug", "sourceSlug", "title", "category", "collection", "group", "description", "draft", "featured", "content", "revision"];
|
||||
if (Object.keys(row).some((key) => !fields.includes(key))) throw new DocumentValidationError("Unknown document field");
|
||||
const doc = Object.fromEntries(fields.map((key) => [key, row[key]])) as Doc;
|
||||
validateDocument(doc);
|
||||
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0) throw new DocumentValidationError("Provide expected revision");
|
||||
return doc;
|
||||
}
|
||||
export function pagination(request: NextApiRequest) {
|
||||
const number = (value: string | string[] | undefined, fallback: number) => {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new DocumentValidationError("Invalid pagination");
|
||||
return Number(value);
|
||||
};
|
||||
return { limit: number(request.query.limit, 50), offset: number(request.query.offset, 0) };
|
||||
}
|
||||
export function idempotency(request: NextApiRequest, namespace: string) {
|
||||
const id = request.headers["idempotency-key"];
|
||||
if (id === undefined) return undefined;
|
||||
if (typeof id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new DocumentValidationError("Invalid Idempotency-Key");
|
||||
return { namespace, id };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { DocumentValidationError, saveDocumentBatch } from "@/lib/docs";
|
||||
import { authorizeDocuments, documentApiError, idempotency, parseDocument } from "@/lib/documents-api";
|
||||
|
||||
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
const key = authorizeDocuments(request, response, true);
|
||||
if (!key) return;
|
||||
if (request.method !== "POST") {
|
||||
response.setHeader("Allow", "POST");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const body = request.body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) ||
|
||||
Object.keys(body).length !== 1 || !Array.isArray(body.documents) ||
|
||||
!body.documents.length || body.documents.length > 100)
|
||||
throw new DocumentValidationError("Expected { documents: [...] } with 1 to 100 documents");
|
||||
const documents = body.documents.map((row: unknown) => parseDocument(row));
|
||||
return response.status(200).json(saveDocumentBatch(documents, idempotency(request, key.id)));
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { documentPage, saveDocumentBatch } from "@/lib/docs";
|
||||
import { authorizeDocuments, documentApiError, idempotency, pagination, parseDocument } from "@/lib/documents-api";
|
||||
|
||||
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
const key = authorizeDocuments(request, response, request.method === "POST");
|
||||
if (!key) return;
|
||||
if (request.method === "GET") {
|
||||
const { limit, offset } = pagination(request);
|
||||
return response.status(200).json(documentPage(limit, offset));
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
const result = saveDocumentBatch([parseDocument(request.body)], idempotency(request, key.id));
|
||||
return response.status(200).json({ document: result.documents[0], replayed: result.replayed });
|
||||
}
|
||||
response.setHeader("Allow", "GET, POST");
|
||||
return response.status(405).end();
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { DocumentValidationError, documentHistoryPage, documentRevision, readDocument } from "@/lib/docs";
|
||||
import { authorizeDocuments, documentApiError, pagination } from "@/lib/documents-api";
|
||||
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
if (!authorizeDocuments(request, response)) return;
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const slug = request.query.slug;
|
||||
if (!Array.isArray(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const doc = readDocument(slug.join("/"));
|
||||
if (!doc) return response.status(404).json({ error: "文档不存在", code: "not_found" });
|
||||
if (request.query.revision !== undefined) {
|
||||
if (request.query.history !== undefined || typeof request.query.revision !== "string" || !/^\d+$/.test(request.query.revision))
|
||||
throw new DocumentValidationError("Invalid revision query");
|
||||
const prior = documentRevision(doc.sourceSlug, Number(request.query.revision));
|
||||
if (!prior) return response.status(404).json({ error: "修订不存在", code: "not_found" });
|
||||
return response.status(200).json(prior);
|
||||
}
|
||||
if (request.query.history !== undefined) {
|
||||
if (request.query.history !== "1") throw new DocumentValidationError("Use history=1");
|
||||
const { limit, offset } = pagination(request);
|
||||
return response.status(200).json(documentHistoryPage(doc.sourceSlug, limit, offset));
|
||||
}
|
||||
return response.status(200).json(doc);
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
|
||||
import { authenticateDocumentKey } from "./lib/document-keys";
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const headers = {
|
||||
@@ -31,6 +32,14 @@ export function proxy(request: NextRequest) {
|
||||
? authorization.slice(7)
|
||||
: undefined;
|
||||
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
|
||||
// Document keys are scoped to the document API; they never authenticate other pages or APIs.
|
||||
if (bearer && (path === "/api/documents" || path.startsWith("/api/documents/"))) {
|
||||
try {
|
||||
if (authenticateDocumentKey(bearer)) return NextResponse.next({ headers });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "文档认证暂时不可用" }, { status: 503, headers });
|
||||
}
|
||||
}
|
||||
if (validSession(token, config)) {
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import documents from "../src/pages/api/documents/index";
|
||||
import batch from "../src/pages/api/documents/batch";
|
||||
import item from "../src/pages/api/documents/item/[...slug]";
|
||||
import { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys";
|
||||
import { allDocuments, documentHistory, readDocument, saveDocument, type Doc } from "../src/lib/docs";
|
||||
import { newAuthConfig, signSession } from "../src/lib/auth";
|
||||
|
||||
const input: Doc = { slug: "endor/example", sourceSlug: "00-example", title: "Example", category: "Endor",
|
||||
collection: "Endor 文档", group: "讨论", description: "Description", draft: true, featured: false,
|
||||
content: "# Example\n\nOriginal body", revision: 0 };
|
||||
type Options = { method?: string; body?: unknown; headers?: Record<string, string>;
|
||||
query?: Record<string, string | string[]> };
|
||||
function call(handler: (req: NextApiRequest, res: NextApiResponse) => unknown, options: Options = {}) {
|
||||
let status = 200;
|
||||
let body: unknown;
|
||||
const headers: Record<string, string> = {};
|
||||
const response = {
|
||||
status(value: number) { status = value; return this; },
|
||||
setHeader(name: string, value: string) { headers[name.toLowerCase()] = value; return this; },
|
||||
json(value: unknown) { body = value; return this; },
|
||||
end() { return this; },
|
||||
};
|
||||
handler({ method: options.method ?? "GET", body: options.body, headers: options.headers ?? {},
|
||||
query: options.query ?? {} } as unknown as NextApiRequest, response as unknown as NextApiResponse);
|
||||
return { status, body, headers };
|
||||
}
|
||||
function isolated(run: (context: { directory: string; headers: Record<string, string> }) => void) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tabularium-doc-api-"));
|
||||
const previous = process.env.AUTH_CONFIG_FILE;
|
||||
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
|
||||
writeFileSync(process.env.AUTH_CONFIG_FILE, JSON.stringify(newAuthConfig()), { mode: 0o600 });
|
||||
try {
|
||||
const key = createDocumentKey("test uploader");
|
||||
run({ directory, headers: { authorization: "Bearer " + key.token } });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
||||
else process.env.AUTH_CONFIG_FILE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("new API requires document keys, rejects browser cookies and broad web sessions", () => isolated(() => {
|
||||
assert.equal(call(documents).status, 401);
|
||||
assert.equal(call(documents, { headers: { authorization: "Bearer invalid" } }).status, 401);
|
||||
const config = JSON.parse(readFileSync(process.env.AUTH_CONFIG_FILE!, "utf8"));
|
||||
const token = signSession(config);
|
||||
assert.equal(call(documents, { headers: { authorization: "Bearer " + token } }).status, 401);
|
||||
assert.equal(call(documents, { headers: { cookie: "tabularium_session=" + token } }).status, 401);
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
}));
|
||||
|
||||
test("key scopes, revocation, expiry and secret-free listing are persistent", () => isolated(({ directory }) => {
|
||||
const key = createDocumentKey("reader", "read", 1);
|
||||
const headers = { authorization: "Bearer " + key.token };
|
||||
assert.equal(call(documents, { headers }).status, 200);
|
||||
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 403);
|
||||
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input] } }).status, 403);
|
||||
assert.ok(!JSON.stringify(listDocumentKeys()).includes(key.token));
|
||||
assert.ok(!readFileSync(join(directory, "login-state.sqlite")).includes(Buffer.from(key.token)));
|
||||
assert.equal(authenticateDocumentKey(key.token, key.expiresAt), null);
|
||||
revokeDocumentKey(key.id);
|
||||
assert.equal(call(documents, { headers }).status, 401);
|
||||
const revoked = listDocumentKeys().find((row) => row.id === key.id);
|
||||
assert.ok(revoked); assert.equal(typeof revoked.revokedAt, "number");
|
||||
}));
|
||||
|
||||
test("single save, aliases, metadata and historical content are visible without rebuilding", () => isolated(({ headers }) => {
|
||||
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 200);
|
||||
assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).revision, 1);
|
||||
const changed = { ...input, revision: 1, slug: "endor/renamed", content: "# Changed", collection: "新合集" };
|
||||
assert.equal(call(documents, { method: "POST", headers, body: changed }).status, 200);
|
||||
assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).slug, changed.slug);
|
||||
assert.equal((call(item, { headers, query: { slug: [input.sourceSlug], revision: "1" } }).body as Doc).content, input.content);
|
||||
const history = call(item, { headers, query: { slug: [input.sourceSlug], history: "1", limit: "1" } });
|
||||
const page = history.body as { revisions: Doc[]; total: number; nextOffset: number };
|
||||
assert.equal(page.total, 2);
|
||||
assert.equal(page.nextOffset, 1);
|
||||
assert.equal(page.revisions[0].revision, 2);
|
||||
assert.equal(page.revisions[0].content, undefined);
|
||||
assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "99" } }).status, 404);
|
||||
assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "0" } }).status, 400);
|
||||
}));
|
||||
|
||||
test("pagination returns metadata only with strict bounds", () => isolated(({ headers }) => {
|
||||
const second = { ...input, slug: "endor/second", sourceSlug: "01-second" };
|
||||
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, second] } }).status, 200);
|
||||
const result = call(documents, { headers, query: { limit: "1" } });
|
||||
const page = result.body as { documents: Doc[]; total: number; nextOffset: number };
|
||||
assert.equal(page.total, 2); assert.equal(page.nextOffset, 1);
|
||||
assert.equal(page.documents[0].content, undefined);
|
||||
assert.equal((call(documents, { headers, query: { limit: "1", offset: "1" } }).body as typeof page).documents[0].slug, second.slug);
|
||||
const invalidQueries: Record<string, string | string[]>[] = [
|
||||
{ limit: "101" }, { offset: "-1" }, { limit: ["1", "2"] }, { limit: "1e2" },
|
||||
];
|
||||
for (const query of invalidQueries) assert.equal(call(documents, { headers, query }).status, 400);
|
||||
assert.equal(result.headers["cache-control"], "private, no-store");
|
||||
}));
|
||||
|
||||
test("late batch conflict rolls back earlier rows and their revision history", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, slug: "endor/existing", sourceSlug: "existing" }, 0);
|
||||
const conflict = { ...input, slug: "endor/existing", sourceSlug: "existing", revision: 0 };
|
||||
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, conflict] } }).status, 409);
|
||||
assert.equal(readDocument(input.slug), null);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 0);
|
||||
assert.equal(documentHistory("existing").length, 1);
|
||||
assert.equal(allDocuments().length, 1);
|
||||
}));
|
||||
|
||||
test("idempotent retries return the original receipt without creating another revision", () => isolated(({ headers }) => {
|
||||
const keyed = { ...headers, "idempotency-key": "upload-1" };
|
||||
const first = call(documents, { method: "POST", headers: keyed, body: input });
|
||||
const second = call(documents, { method: "POST", headers: keyed, body: input });
|
||||
assert.equal(first.status, 200); assert.equal(second.status, 200);
|
||||
assert.equal((second.body as { replayed: boolean }).replayed, true);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 1);
|
||||
assert.equal(call(documents, { method: "POST", headers: keyed, body: { ...input, content: "Different" } }).status, 409);
|
||||
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 409);
|
||||
}));
|
||||
|
||||
test("invalid batch members, duplicate identifiers, raw images and malformed fields never write", () => isolated(({ headers }) => {
|
||||
for (const body of [{ documents: [] }, { documents: [input, input] },
|
||||
{ documents: [input], unexpected: true }, { documents: Array.from({ length: 101 }, () => input) }])
|
||||
assert.equal(call(batch, { method: "POST", headers, body }).status, 400);
|
||||
for (const body of [{ ...input, revision: undefined }, { ...input, typo: true },
|
||||
{ ...input, slug: "../private" }, { ...input, content: "" }, null])
|
||||
assert.equal(call(documents, { method: "POST", headers, body }).status, 400);
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
assert.equal(call(documents, { method: "DELETE", headers }).status, 405);
|
||||
assert.equal(call(item, { headers, query: { slug: ["missing"] } }).status, 404);
|
||||
}));
|
||||
Reference in New Issue
Block a user