feat: add GET /api/documents/latest returning latest version file with stamped ticket
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s

Server-to-server endpoint for the e-Court backend (x-api-key against
DOCVER_API_KEY or EXTERNAL_API_KEY):
- type=pdf returns the latest version's PDF bytes (404 when absent)
- type=docx stamps fresh ticket custom properties (exp + HmacSha256 via
  addinTicketSecret(), Ed25519 when PRIVATE_KEY_HEX is set) into an
  in-memory copy before returning — MinIO object is never modified
- docId comes from fid inside the ticket_data query param, which must
  include fid/uid/uname so the stamped file stays redeemable by the
  add-in at /api/documents/token (buildStampedTicketData drops null
  values because parseDocverTicket rejects tid:null)
- adds parseTicketDataParam, parseLatestTypeParam, requireDocumentApiKey,
  findLatestVersionForDoc, getVersionObjectBytes + unit tests (320 pass)
- syncs PLAN-DOCVER.md, OpenAPI spec and .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-31 11:09:42 +07:00
parent 5bc8d9a817
commit 0b6ee8cc84
12 changed files with 806 additions and 9 deletions

View file

@ -2,11 +2,19 @@
// (2026-10-30): คุมว่าก้อน {cc, bookmark, properties, verify} แกะได้ครบและ verify ทำงาน
// ตาม secret ที่ผู้เรียกส่ง · ไฟล์นี้ตั้งใจไม่แตะ module ที่ลาก @/db หรือ minio มาด้วย
// (pattern document-versioning.test.ts — รันได้ไม่ต้องตั้ง DATABASE_URL/MINIO_*)
import { createHmac } from "node:crypto";
import { createHmac, generateKeyPairSync } from "node:crypto";
import { describe, expect, it } from "vitest";
import PizzaZip from "pizzip";
import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
import { verifyAddinTicketHmac } from "@/lib/addin-ticket";
import { verifyEd25519 } from "@/lib/lib-utils";
import {
buildStampedTicketData,
extractDocumentSnapshot,
getCustomDocProps,
stampTicketProps,
} from "@/lib/lib-tmpl";
import { parseDocverTicket } from "@/utils/docver-ticket";
const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
@ -61,6 +69,31 @@ function stampedDocx(): Buffer {
});
}
// setCustomDocProps (ทาง stampTicketProps) บังคับให้ zip มี _rels/.rels และ
// [Content_Types].xml ก่อน (ensureCustomRelationship/ensureCustomContentType)
const RELS_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
`</Relationships>`;
const CONTENT_TYPES_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
`</Types>`;
/** docx จิ๋วที่ stamp ได้ — ครบส่วนที่ setCustomDocProps บังคับ */
function stampableDocx(extra: Record<string, string> = {}): Buffer {
return buildDocx({
"word/document.xml": documentXml(),
"_rels/.rels": RELS_XML,
"[Content_Types].xml": CONTENT_TYPES_XML,
...extra,
});
}
describe("extractDocumentSnapshot", () => {
it("แกะครบทั้งก้อน {cc, bookmark, properties, verify} เหมือน /api/template/extract", () => {
const snapshot = extractDocumentSnapshot(stampedDocx(), { secret: "super_secret" });
@ -99,3 +132,133 @@ describe("extractDocumentSnapshot", () => {
expect(() => extractDocumentSnapshot(Buffer.from("PK\x03\x04-not-a-zip"))).toThrow();
});
});
// buildStampedTicketData/stampTicketProps — ฝั่ง stamp ของ GET /api/documents/latest
// (recipe เดียวกับ applyTemplate แต่ ticket มาจาก query param และห้ามกลืน error)
describe("buildStampedTicketData", () => {
it("เติม exp = floor(now/1000)+ttl เรียง key ตามตัวอักษร — nowMs กำหนดได้ให้ deterministic", () => {
const nowMs = 1_793_000_000_000;
const result = buildStampedTicketData({ fid: "F1", uid: "u1", uname: "น" }, 300, nowMs);
expect(result).toBe(
JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "น" }),
);
// ลำดับ key ขาเข้าไม่มีผล — เรียงเป็น exp,fid,tid,uid,uname เสมอ (tid มีก็เข้าที่)
const shuffled = buildStampedTicketData(
{ uname: "น", uid: "u1", tid: "T12", fid: "F1" },
300,
nowMs,
);
expect(Object.keys(JSON.parse(shuffled))).toEqual(["exp", "fid", "tid", "uid", "uname"]);
});
it("ผลลัพธ์ redeem ได้จริง — parseDocverTicket (ฝั่ง add-in) แกะผ่าน", () => {
const ticketData = buildStampedTicketData(
{ fid: "F1", uid: "u1", uname: "ทดสอบ" },
300,
Date.now(),
);
expect(parseDocverTicket(ticketData).ok).toBe(true);
});
it("ค่า null/undefined ถูกตัดออก — parseTicketDataParam ใช้ null แทน tid ที่ไม่มี แต่ parseDocverTicket ไม่ยอม tid:null", () => {
const result = buildStampedTicketData(
{ fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null },
300,
1_793_000_000_000,
);
expect(result).toBe(
JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "ทดสอบ" }),
);
expect(parseDocverTicket(result).ok).toBe(true);
});
});
describe("stampTicketProps", () => {
const SECRET = "super_secret";
/** seed 32 byte (hex) + public key 32 byte (hex) จาก node:crypto — เข้ารูปที่
* signEd25519/verifyEd25519 ของ lib-utils รับ */
function ed25519KeyPair(): { seedHex: string; publicHex: string } {
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const privDer = privateKey.export({ type: "pkcs8", format: "der" }) as Buffer;
const pubDer = publicKey.export({ type: "spki", format: "der" }) as Buffer;
return {
seedHex: privDer.subarray(privDer.length - 32).toString("hex"),
publicHex: pubDer.subarray(pubDer.length - 32).toString("hex"),
};
}
it("stamp ticket_data+HmacSha256 ลงไฟล์เปล่า · ไม่ให้ private key = ไม่มี Ed25519", () => {
const ticketData = buildStampedTicketData(
{ fid: "F1", uid: "u1", uname: "ทดสอบ" },
300,
Date.now(),
);
const bare = stampableDocx();
const result = stampTicketProps(bare, ticketData, { secret: SECRET });
expect(result.ed25519).toBeNull();
expect(result.hmacSha256).toBe(
createHmac("sha256", SECRET).update(ticketData, "utf8").digest("hex"),
);
const props = getCustomDocProps(result.buffer);
expect(props.ticket_data).toBe(ticketData);
expect(props.HmacSha256).toBe(result.hmacSha256);
expect(props.Ed25519).toBeUndefined();
// non-destructive — buffer ต้นฉบับไม่ถูกแตะ (route ห้ามเขียนกลับ MinIO)
expect(bare.equals(stampableDocx())).toBe(true);
});
it("ให้ private key = มี Ed25519 และ verify ผ่านด้วย public key คู่กัน", () => {
const { seedHex, publicHex } = ed25519KeyPair();
const ticketData = buildStampedTicketData(
{ fid: "F1", uid: "u1", uname: "ทดสอบ" },
300,
Date.now(),
);
const result = stampTicketProps(stampableDocx(), ticketData, {
secret: SECRET,
privateKeyHex: seedHex,
});
expect(result.ed25519).not.toBeNull();
expect(verifyEd25519(ticketData, result.ed25519!, publicHex)).toBe(true);
expect(getCustomDocProps(result.buffer).Ed25519).toBe(result.ed25519);
});
it("chain จบถึง end: verifyAddinTicketHmac (ทางเข้า /api/documents/token) ผ่าน", () => {
const ticketData = buildStampedTicketData(
{ fid: "F1", uid: "u1", uname: "ทดสอบ" },
300,
Date.now(),
);
const result = stampTicketProps(stampableDocx(), ticketData, {
secret: SECRET,
});
const verified = verifyAddinTicketHmac(ticketData, result.hmacSha256, SECRET);
expect(verified.ok).toBe(true);
// secret ผิด = ไม่ผ่าน (HMAC ผูกกับ secret ของ add-in จริง)
expect(verifyAddinTicketHmac(ticketData, result.hmacSha256, "wrong").ok).toBe(false);
});
it("stamp ทับไฟล์ที่มี ticket เดิม = ค่าถูกแทนทั้งก้อน (exp เก่าหาย)", () => {
const fresh = buildStampedTicketData(
{ fid: "F2", uid: "u2", uname: "ใหม่" },
300,
Date.now(),
);
const result = stampTicketProps(
stampableDocx({ "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC) }),
fresh,
{ secret: SECRET },
);
const props = getCustomDocProps(result.buffer);
expect(props.ticket_data).toBe(fresh);
expect(String(props.ticket_data)).not.toContain("2000000000"); // exp เก่าของ TICKET_DATA
});
it("ไฟล์เสีย = throw (route ตอบ 500 — ห้ามกลืนแบบ applyTemplate)", () => {
expect(() =>
stampTicketProps(Buffer.from("not-a-zip"), "{}", { secret: SECRET }),
).toThrow();
});
});