feat(docver): add access token endpoint and Bearer auth on all endpoints

- POST /api/documents/token issues JWT HS256 (claims userid/username/fid/template) after x-api-key check against new env DOCVER_API_KEY (fail-closed, timing-safe, uniform 401)
- all docver endpoints now require Authorization: Bearer, verified with env DOCVER_TOKEN_SECRET (TTL via DOCVER_TOKEN_TTL_SECONDS, default 3600); secret unset = 401 on verify / 500 on issue
- identity (userId/userName) + templateId extracted from token claims instead of request payload; removed from multipart form (legacy fields now 400)
- lib/docver-token.ts: hand-rolled JWT via node:crypto only — alg pinned to HS256 (anti alg-confusion), timing-safe compare with both sides hashed, exp enforced, identical 401 for all bearer failure modes
- openapi spec: /api/documents/token path + DocverBearer scheme; PLAN-DOCVER.md / CLAUDE.md / .env.example updated
- tests: new tests/lib/docver-token.test.ts + adjusted parseVersionUploadForm tests (258 pass, no DB env needed)
- package-lock.json: sync minio (^8.0.7) that was missing from the earlier docver commit

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-26 06:29:19 +07:00
parent 280cc2da40
commit 98b370c925
13 changed files with 932 additions and 131 deletions

View file

@ -23,16 +23,15 @@ const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ */
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
const FORM_FIELDS = [
"file",
"pdf",
"userId",
"userName",
"clientVersionId",
"fields",
"message",
"templateId",
"fileName",
] as const;
@ -160,11 +159,8 @@ export function parseFieldsJson(raw: string):
}
export type ParsedVersionUploadForm = {
userId: string;
userName: string;
clientVersionId: string | null;
message: string | null;
templateId: string | null;
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
fileName: string | null;
@ -172,7 +168,8 @@ export type ParsedVersionUploadForm = {
};
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
* size ก่อน buffer) — userId/userName บังคับเพราะระบบ stand-alone ไม่มี session */
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
export function parseVersionUploadForm(form: FormData):
| { ok: true; form: ParsedVersionUploadForm }
| DocverError {
@ -187,36 +184,12 @@ export function parseVersionUploadForm(form: FormData):
}
}
const userId = formString(form, "userId");
if (!userId.ok) return userId;
if (userId.value === null) {
return { ok: false, error: "ต้องระบุ userId (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
}
if (userId.value.length > 100) {
return { ok: false, error: "userId ยาวเกิน 100 ตัวอักษร" };
}
const userName = formString(form, "userName");
if (!userName.ok) return userName;
if (userName.value === null) {
return { ok: false, error: "ต้องระบุ userName (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
}
if (userName.value.length > 200) {
return { ok: false, error: "userName ยาวเกิน 200 ตัวอักษร" };
}
const clientVersionId = formString(form, "clientVersionId");
if (!clientVersionId.ok) return clientVersionId;
if (clientVersionId.value && clientVersionId.value.length > 100) {
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
}
const templateId = formString(form, "templateId");
if (!templateId.ok) return templateId;
if (templateId.value && templateId.value.length > 100) {
return { ok: false, error: "templateId ยาวเกิน 100 ตัวอักษร" };
}
const message = formString(form, "message");
if (!message.ok) return message;
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
@ -238,11 +211,8 @@ export function parseVersionUploadForm(form: FormData):
return {
ok: true,
form: {
userId: userId.value,
userName: userName.value,
clientVersionId: clientVersionId.value,
message: message.value,
templateId: templateId.value,
fileName: sanitizeDisplayFileName(rawFileName.value),
fields,
},