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

@ -112,11 +112,11 @@ describe("parseFieldsJson", () => {
});
describe("parseVersionUploadForm", () => {
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
function baseForm(): FormData {
const form = new FormData();
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
form.append("userId", "u0001");
form.append("userName", "สมชาย ใจดี");
return form;
}
@ -124,17 +124,13 @@ describe("parseVersionUploadForm", () => {
const form = baseForm();
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
form.append("message", " ");
form.append("templateId", "tmpl-main");
form.append("fields", '{"blackno":"1"}');
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
const result = parseVersionUploadForm(form);
ok(result);
expect(result.form).toEqual({
userId: "u0001",
userName: "สมชาย ใจดี",
clientVersionId: "11111111-1111-4111-8111-111111111111",
message: null,
templateId: "tmpl-main",
fileName: "คำสั่งเรียก.docx",
fields: { blackno: "1" },
});
@ -148,22 +144,22 @@ describe("parseVersionUploadForm", () => {
if (!result.ok) expect(result.error).toContain("oops");
});
it("ตกเมื่อขาด userId หรือ userName (stand-alone บอกผู้อัปโหลดเอง)", () => {
const noUser = baseForm();
noUser.delete("userId");
expect(parseVersionUploadForm(noUser).ok).toBe(false);
const noName = baseForm();
noName.delete("userName");
expect(parseVersionUploadForm(noName).ok).toBe(false);
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
for (const legacy of ["userId", "userName", "templateId"]) {
const form = baseForm();
form.append(legacy, "x");
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain(legacy);
}
});
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
const form = baseForm();
form.delete("userId");
form.append("userId", new File(["x"], "u.txt"));
form.append("message", new File(["x"], "m.txt"));
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("userId");
if (!result.ok) expect(result.error).toContain("message");
});
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {