add /api/auth short-path endpoints (token/session) + swagger group "auth"

- POST /api/auth/token = alias of /api/documents/token (docver access_token,
  both x-api-key and ticket entrances)
- POST /api/auth/session = alias of /api/note/auth/session (exchange e-Court
  link credential for note login token, 8h TTL)
- lib/openapi.ts: new "auth" tag + both paths + SessionExchangeRequest /
  SessionResponse schemas (session endpoint had never been documented)
- tests/lib/openapi.test.ts: guard all $refs resolve + auth paths exist

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-11-05 13:06:45 +07:00
parent ccd257c825
commit 545c9d5703
4 changed files with 525 additions and 2 deletions

View file

@ -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<Response> {
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 } : {}),
});
}

113
app/api/auth/token/route.ts Normal file
View file

@ -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<Response> {
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<Response> {
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<Response> {
// ความผิด 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" } },
);
}

View file

@ -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 <token>` กับทุก 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/<fileId>)",
},
fileError: {
type: "string",
description:
"มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) — token ยังออกให้ " +
"หน้าแรกแสดงสาเหตุ",
},
},
},
ExtractResponse: {
type: "object",
properties: {

51
tests/lib/openapi.test.ts Normal file
View file

@ -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<string, unknown>)[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<string, unknown>;
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();
});
});