diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..a41b378 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,106 @@ +// POST /api/auth/session (path สั้นของ POST /api/note/auth/session — โค้ดก้อนเดียวกัน +// copy มา 2026-11-05) — แลก credential ของลิงก์ e-Court เป็น access_token +// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่ +// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?, +// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก +// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL +// (8 ชม.) ให้ client เก็บ localStorage "note-auth" +// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง +// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ +import { NextResponse } from "next/server"; + +import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket"; +import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token"; +import { registerExternalFile } from "@/lib/external-import"; +import { parseSessionExchangeBody } from "@/lib/note-auth"; +import { NOTE_SESSION_TTL } from "@/lib/note-token"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } }); +} + +export async function POST(request: Request): Promise { + let raw: unknown; + try { + raw = await request.json(); + } catch { + raw = null; + } + const parsed = parseSessionExchangeBody(raw); + if (!parsed.ok) return json({ error: parsed.error }, 400); + + const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + if (secret === undefined || secret === "") { + console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า"); + return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500); + } + + let identity: { userid: string; username: string; fid: string }; + if (parsed.credential.kind === "jwt") { + const verified = verifyDocverLoginToken(parsed.credential.token, secret); + if (!verified.ok) { + return json( + { error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason }, + 401, + ); + } + identity = verified.claims; + } else { + // ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/ + // .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production + // (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว + // คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้ + // โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/ + // dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง) + if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") { + console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket"); + return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500); + } + const verified = verifyAddinTicket( + parsed.credential.ticketData, + parsed.credential.hmacHex, + addinTicketSecret(), + ); + if (!verified.ok) { + return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401); + } + identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid }; + } + + // N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential + // ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง + // เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน + // พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์) + let fileId: string | undefined; + let fileError: string | undefined; + if (parsed.fileUrl !== undefined) { + const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, { + trusted: true, + }); + if (result.ok) fileId = result.fileId; + else fileError = result.error; + } + + const access_token = issueDocverToken( + { userid: identity.userid, username: identity.username, fid: identity.fid, template: null }, + secret, + NOTE_SESSION_TTL, + "login", + ); + // audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม + // ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log) + const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); + console.log(`[sso] e-Court login ok: userid=${auditId}`); + return json({ + access_token, + token_type: "Bearer", + expires_in: NOTE_SESSION_TTL, + userid: identity.userid, + username: identity.username, + ...(fileId !== undefined ? { fileId } : {}), + ...(fileError !== undefined ? { fileError } : {}), + }); +} diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts new file mode 100644 index 0000000..5a9241f --- /dev/null +++ b/app/api/auth/token/route.ts @@ -0,0 +1,113 @@ +// PLAN-DOCVER.md §auth — POST /api/auth/token (path สั้นของ POST /api/documents/token +// — โค้ดก้อนเดียวกัน copy มา 2026-11-05) ออก access_token ของกลุ่ม docver +// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง): +// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY +// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง) +// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server +// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → +// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket +// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client ไม่ตรวจ HMAC +// เองแล้วตาม PLAN-SSO.md D13) · contract 2026-11-04: 400 = ฟอร์แม็ตพัง +// (รวม missing) / 401 = ลายเซ็นไม่ถูก / 403 = ลายเซ็นถูกแต่ ticket หมดอายุ +// (client ที่มี JWT เดิมของ fid นี้ใน session ใช้ต่อเอง — ไม่มีการออกซ้ำ +// จากหลักฐาน DB อีกต่อไป เลิก requirement ขั้น 7 ฉบับ 2026-10-28) +// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env +// DOCVER_TOKEN_TTL_SECONDS (default 604800 = 7 วัน — token อายุยาวแทนระบบ +// refresh/renew) แล้วนำไปแนบ Authorization: Bearer กับ endpoint อื่นของกลุ่ม +// (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] +// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว +import { docverError } from "@/lib/document-versioning"; +import { + addinTicketSecret, + parseTicketTokenBody, + ticketToDocverClaims, + verifyAddinTicketHmac, +} from "@/lib/addin-ticket"; +import { isTicketExpired } from "@/utils/docver-ticket"; +import { + issueDocverToken, + parseTokenRequestBody, + requireDocverApiKey, + type DocverTokenClaims, +} from "@/lib/docver-token"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด) กัน oracle · หมดอายุแยกเป็น 403 */ +const TICKET_DENIED = "ticket ไม่ถูกต้อง"; +/** 403 ของทางเข้า ticket — ลายเซ็นถูกแต่หมดอายุ (client มี JWT เดิมใน session ก็ใช้ต่อได้) */ +const TICKET_EXPIRED = "ticket ของ e-Court หมดอายุ"; + +export async function POST(request: Request) { + return request.headers.get("x-api-key") !== null + ? postWithApiKey(request) + : postWithTicket(request); +} + +/** ทางเข้า 1 (เดิม) — e-Court server-to-server */ +async function postWithApiKey(request: Request): Promise { + const apiKeyFail = requireDocverApiKey(request); + if (apiKeyFail !== null) return apiKeyFail; + + let raw: unknown; + try { + raw = await request.json(); + } catch { + return docverError("body ต้องเป็น JSON", 400); + } + const parsed = parseTokenRequestBody(raw); + if (!parsed.ok) return docverError(parsed.error, 400); + return issueToken(parsed.claims); +} + +/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */ +async function postWithTicket(request: Request): Promise { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return docverError("body ต้องเป็น JSON", 400); + } + const parsed = parseTicketTokenBody(raw); + if (!parsed.ok) { + // contract 2026-11-04: ฟอร์แม็ตพัง = 400 ทั้ง missing/invalid (เดิม missing เป็น 401) + return docverError(parsed.error, 400); + } + // ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้ + const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); + if (!verified.ok) return docverError(TICKET_DENIED, 401); + + // ลายเซ็นถูกแต่ ticket หมดอายุ → 403 เสมอ (contract 2026-11-04 — เลิก requirement + // ขั้น 7 ฉบับ 2026-10-28: ไม่ตรวจ hasVersionByUser อีกต่อไป เพราะ ticket ฝังอยู่ใน + // ไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไป + // เท่ากันคือปล่อยสิทธิ์ตามการครอบครองไฟล์ — ฝั่ง client ที่มี JWT ของ fid นี้ + // ใน session ที่ยังไม่หมด (7 วัน) ใช้ต่อเอง ไม่มี = ขอไฟล์ใหม่จาก e-Court) + if (isTicketExpired(verified.ticket.exp)) { + return docverError(TICKET_EXPIRED, 403); + } + return issueToken(ticketToDocverClaims(verified.ticket)); +} + +/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */ +async function issueToken(claims: DocverTokenClaims): Promise { + // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) + const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + if (secret === undefined || secret === "") { + return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); + } + const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); + // default 7 วัน (2026-11-04) — token อายุยาวแทนระบบ refresh/renew ของ add-in + const ttlSeconds = + Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800; + + const accessToken = issueDocverToken(claims, secret, ttlSeconds); + return new Response( + JSON.stringify({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ttlSeconds, + }), + { headers: { "content-type": "application/json; charset=utf-8" } }, + ); +} diff --git a/lib/openapi.ts b/lib/openapi.ts index 2c27077..c9ef256 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -1,6 +1,7 @@ // OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI // แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver -// (จัด version เอกสาร) +// (จัด version เอกสาร) + auth (ทางเข้า path สั้น /api/auth/* — alias ของ +// /api/documents/token กับ /api/note/auth/session เพิ่ม 2026-11-05) // route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้ // — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก) // @@ -25,7 +26,8 @@ export const openApiSpec = { version: "1.0.0", description: "API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " + - "API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)", + "API ภายนอกสำหรับ backend e-Court (N10), API จัด version เอกสาร (docver) " + + "และทางเข้า auth แบบ path สั้น (/api/auth/*)", }, servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }], tags: [ @@ -59,6 +61,15 @@ export const openApiSpec = { "server-to-server ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้ · " + "SSO: เส้น mint login-token สำหรับประกอบลิงก์เข้าสู่ระบบ (PLAN-SSO.md)", }, + { + name: "auth", + description: + "ทางเข้า auth แบบ **path สั้น** `/api/auth/*` (เพิ่ม 2026-11-05) — endpoint " + + "ก้อนเดียวกับของเดิมคนละ URL เลือกใช้ทางใดทางหนึ่งได้:\n" + + "- `POST /api/auth/token` = `POST /api/documents/token` — ออก access_token ของกลุ่ม documents (docver)\n" + + "- `POST /api/auth/session` = `POST /api/note/auth/session` — แลก credential ของลิงก์ e-Court " + + "เป็น session token ของแอป note", + }, ], paths: { "/api/template/test": { @@ -687,6 +698,181 @@ export const openApiSpec = { }, }, }, + "/api/auth/token": { + post: { + tags: ["auth"], + summary: "ขอ access_token ของกลุ่ม documents (docver) — path สั้นของ /api/documents/token", + description: + "ทางเข้า **path สั้น** ของ `POST /api/documents/token` — endpoint ก้อนเดียวกัน " + + "พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05)\n\n" + + "ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " + + "token ฝัง claims `{userid, username, fid, template}` อายุตาม env " + + "`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" + + "ระบบ refresh/renew ของ add-in)\n\n" + + "**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" + + "1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " + + "`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" + + "2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " + + "ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " + + "ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " + + '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' + + "(contract 2026-11-04: ฟอร์แม็ตพัง/ไม่ครบ = 400 · ลายเซ็นไม่ถูก = 401 · " + + "**ลายเซ็นถูกแต่ ticket หมดอายุ = 403** — add-in ที่มี access_token เดิมของ `fid` " + + "นี้ใน session ยังใช้ต่อได้ ไม่มี = ขอไฟล์ใหม่จาก e-Court)\n\n" + + "ได้ token แล้วแนบเป็น `Authorization: Bearer ` กับทุก request ถัดไป", + security: [{ ApiKeyAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + oneOf: [ + { $ref: "#/components/schemas/TokenRequest" }, + { $ref: "#/components/schemas/TicketTokenRequest" }, + ], + }, + examples: { + "server-to-server ด้วย x-api-key (ครบทุก claim)": { + value: { + userid: "123", + username: "Sorawit Bholsithi", + fid: "3", + template: "T12", + }, + }, + "server-to-server ขั้นต่ำ (template เป็นทางเลือก)": { + value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" }, + }, + "add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": { + value: { + ticket_data: + '{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}', + HmacSha256: + "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/TokenResponse" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 401: { + description: + "ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " + + "ทางเข้า ticket: ลายเซ็น HMAC ไม่ถูก (ข้อความเดียวกันทุกสาเหตุ)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 403: { + description: + "เฉพาะทางเข้า ticket: ลายเซ็นถูกแต่ ticket หมดอายุ — ใช้ access_token เดิมของ " + + "fid นี้ใน session ต่อ หรือขอไฟล์ใหม่จาก e-Court (contract 2026-11-04)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, + "/api/auth/session": { + post: { + tags: ["auth"], + summary: "แลก credential ของลิงก์ e-Court เป็น session token ของแอป note (SSO)", + description: + "ทางเข้า **path สั้น** ของ `POST /api/note/auth/session` — endpoint ก้อนเดียวกัน " + + "พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05) · flow SSO ของแอป note " + + "(PLAN-SSO.md §3.2 ฉบับ token): ลิงก์ e-Court พา browser มาหน้า `/note` พร้อม " + + "credential → client เรียกเส้นนี้แลกเป็น access_token เก็บ localStorage " + + '("note-auth") แล้วแนบ `Authorization: Bearer` กับทุก API/Server Action ของแอป\n\n' + + "- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/note/auth/token`) " + + "หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court stamp ใน custom " + + "properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" + + "- token ที่ได้เป็นชนิด `purpose=login` อายุ 8 ชม. (28800 วินาที) — ใช้ได้กับแอป " + + "note เท่านั้น เอาไปยิง `/api/documents/*` ของกลุ่ม docver ไม่ได้ (401)\n" + + "- ส่ง `fileUrl` มาด้วย = ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server ทันที " + + "(fid จาก credential ที่ verify แล้ว) — ล้มก็ยังได้ token พร้อม `fileError` " + + "ให้หน้าแรกแสดงสาเหตุ · `fileUrl` ต้องผ่าน allowlist ของ N8 " + + "(admincourt.go.th + env `EXTERNAL_FILE_HOSTS`) หรือเป็น path `/samples/…`\n" + + "- **ทาง ticket ใน production**: secret ต้องตั้งจริง (env `ADDIN_TICKET_SECRET` " + + '→ `SECRET`) — ค่า default `"super_secret"` ที่รู้กันทั้งระบบถูกปฏิเสธ (500)', + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/SessionExchangeRequest" }, + examples: { + "ทาง JWT (param t ของลิงก์ e-Court)": { + value: { + t: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwdXJwb3NlIjoibG9naW4iLCJ1c2VyaWQiOiJvb20iLCJ1c2VybmFtZSI6IuC4geC4m+C4tOC5iOC4quC4lOC5iCIsImZpZCI6IkYxMjMifQ.….….…", + fileUrl: "https://service.admincourt.go.th/uploads/F123.pdf", + typeId: "T12", + }, + }, + "ทาง ticket (คู่ค่าเดียวกับไฟล์ .docx ของ add-in)": { + value: { + ticket_data: + '{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}', + HmacSha256: + "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: + "session token ของแอป note (พร้อม fileId ถ้าลงทะเบียน fileUrl สำเร็จ)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/SessionResponse" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 401: { + description: + "credential หมดอายุหรือไม่ถูกต้อง — ทาง JWT แยก `reason: expired` (หมดอายุ) กับ " + + "`invalid` · ทาง ticket รวมเป็น `invalid` ทั้งลายเซ็นผิดและหมดอายุ", + content: { + "application/json": { + schema: { + allOf: [ + { $ref: "#/components/schemas/Error" }, + { + type: "object", + properties: { + reason: { + type: "string", + enum: ["expired", "invalid"], + }, + }, + }, + ], + }, + }, + }, + }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, "/api/documents/token": { post: { tags: ["documents"], @@ -1149,6 +1335,73 @@ export const openApiSpec = { }, }, }, + SessionExchangeRequest: { + type: "object", + description: + "credential ของลิงก์ e-Court ที่จะแลกเป็น session token — ส่งทางเดียว: `t` " + + "**หรือ** `ticket_data`+`HmacSha256` (สองชุดพร้อมกัน = 400 ตาม D12) · " + + "allowlist 5 field เป๊ะ — ส่ง field อื่น = 400", + properties: { + t: { + type: "string", + description: + "login-token (JWT purpose=login) จาก POST /api/note/auth/token — ≤ 8192 ตัวอักษร", + }, + ticket_data: { + type: "string", + description: + "JSON string ของ ticket ชุดเดียวกับที่ e-Court stamp ในไฟล์ .docx — " + + "≤ 2048 ตัวอักษร (ส่งตรง ๆ ไม่แก้ string — HMAC ผูกกับค่าดิบ)", + }, + HmacSha256: { + type: "string", + description: "hex 64 ตัว ของ HMAC-SHA256(secret, ticket_data ดิบ) — ≤ 128 ตัวอักษร", + }, + fileUrl: { + type: "string", + description: + "URL เอกสารบน e-Court — ต้องผ่าน allowlist ของ N8 (admincourt.go.th + env " + + "EXTERNAL_FILE_HOSTS) · ส่งมา = ลงทะเบียนเอกสารตอนแลก token (≤ 2048 ตัวอักษร)", + }, + typeId: { + type: "string", + description: "รหัสกลุ่มเอกสารของ e-Court (≤ 200 ตัวอักษร)", + }, + }, + }, + SessionResponse: { + type: "object", + description: + "session ของแอป note — client เก็บใน localStorage (\"note-auth\") แล้วแนบ " + + "Authorization: Bearer กับทุก API/Server Action ของแอป", + properties: { + access_token: { + type: "string", + description: "JWT purpose=login อายุ 8 ชม. — ใช้ได้กับแอป note เท่านั้น", + }, + token_type: { type: "string", example: "Bearer" }, + expires_in: { + type: "integer", + description: "อายุวินาที (NOTE_SESSION_TTL = 28800 = 8 ชม.)", + example: 28800, + }, + userid: { type: "string", description: "รหัสผู้ใช้จาก credential (u_userid ของ e-Court)" }, + username: { type: "string", description: "ชื่อแสดงผลจาก credential" }, + fileId: { + type: "string", + format: "uuid", + description: + "มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ — id ของเอกสารในระบบนี้ " + + "(ใช้เข้าหน้าอ่าน /note/annotate/)", + }, + fileError: { + type: "string", + description: + "มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) — token ยังออกให้ " + + "หน้าแรกแสดงสาเหตุ", + }, + }, + }, ExtractResponse: { type: "object", properties: { diff --git a/tests/lib/openapi.test.ts b/tests/lib/openapi.test.ts new file mode 100644 index 0000000..bd9610e --- /dev/null +++ b/tests/lib/openapi.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { openApiSpec } from "@/lib/openapi"; + +// spec ถูก inline เป็น JSON ลงหน้า Swagger UI ตรง ๆ (app/api/swagger/route.ts) — +// $ref ที่ชี้หาย (แก้ชื่อ schema ใน lib/openapi.ts แล้วลืมแก้อีกฝั่ง) จะไม่มี error +// ตอน build เพราะเป็นแค่ string กลาง object literal ที่ serialize ได้ปกติ — จับได้ที่นี่ก่อน +// เห็นเป็นช่องว่าง ๆ บนหน้า /api/swagger + +function collectRefs(node: unknown, out: string[] = []): string[] { + if (Array.isArray(node)) { + for (const item of node) collectRefs(item, out); + } else if (node !== null && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + if (key === "$ref" && typeof value === "string") out.push(value); + else collectRefs(value, out); + } + } + return out; +} + +/** แกะ JSON pointer (#/a/b/c) — null = ชี้ไปที่ไม่มีจริง */ +function resolveRef(spec: object, ref: string): unknown { + if (!ref.startsWith("#/")) return null; + let node: unknown = spec; + for (const part of ref.slice(2).split("/")) { + if (node === null || typeof node !== "object") return null; + const key = part.replace(/~1/g, "/").replace(/~0/g, "~"); + node = (node as Record)[key]; + if (node === undefined) return null; + } + return node; +} + +describe("openApiSpec", () => { + it("ทุก $ref ใน spec ชี้ถึงจุดที่มีจริง", () => { + const refs = collectRefs(openApiSpec); + expect(refs.length).toBeGreaterThan(0); + const broken = refs.filter((ref) => resolveRef(openApiSpec, ref) === null); + expect(broken).toEqual([]); + }); + + it("กลุ่ม auth: path สั้น /api/auth/* ครบทั้งคู่ และ path เดิมที่เป็นต้นทางยังอยู่", () => { + const paths = openApiSpec.paths as Record; + expect(paths["/api/auth/token"]).toBeDefined(); + expect(paths["/api/auth/session"]).toBeDefined(); + // path เดิม (ที่ /api/auth/* alias มา) ต้องยัง document อยู่ + expect(paths["/api/documents/token"]).toBeDefined(); + expect(paths["/api/note/auth/token"]).toBeDefined(); + }); +});