From d7223d558b6e8608ff2e8386b5544b9dfb1ac004 Mon Sep 17 00:00:00 2001 From: _david Date: Wed, 2 Sep 2026 03:38:27 +0700 Subject: [PATCH 1/8] chore(agent-hub): tighten verifier re-run scope, drop stale NORTHSTAR read Synced from agent-hub-init kit (usage audit 2026-09-02): verifier now audits the implementer's evidence note by default instead of independently re-running npm test/npm ci from scratch. Re-run is reserved for suspicious notes, outward-facing/release nodes (this repo already shipped one real production bug via v1.2.0 -> v1.2.1), or project-declared exceptions. Also fixed manifest.yaml reads: NORTHSTAR.md (unused by the recipe) -> doctrine/MEMORY.md (actually used in step 4, was undeclared). Co-Authored-By: Claude Sonnet 5 --- .../haven/workers/verifier/manifest.yaml | 4 ++- .../workers/verifier/recipes/verify_seal.md | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/agent-hub/haven/workers/verifier/manifest.yaml b/agent-hub/haven/workers/verifier/manifest.yaml index 552b430..996f633 100644 --- a/agent-hub/haven/workers/verifier/manifest.yaml +++ b/agent-hub/haven/workers/verifier/manifest.yaml @@ -12,5 +12,7 @@ hard_rules: - EvidenceOnly # never trust inference over real evidence - NeverVerifyOwnWork # never grade a diff you wrote yourself - RatchetOnly # PM status only moves forward, never backward -reads: [evidence/implementer/, haven/diagrams/, NORTHSTAR.md, CLAUDE.md] +reads: [evidence/implementer/, haven/diagrams/, doctrine/MEMORY.md, CLAUDE.md] +# doctrine/MEMORY.md: recipe step 4 needs it to check the note's command. +# No NORTHSTAR.md — recipe never uses it, was dead weight on every spawn. writes: [evidence/verifier/] # and diagram PM status diff --git a/agent-hub/haven/workers/verifier/recipes/verify_seal.md b/agent-hub/haven/workers/verifier/recipes/verify_seal.md index c621605..561f0e9 100644 --- a/agent-hub/haven/workers/verifier/recipes/verify_seal.md +++ b/agent-hub/haven/workers/verifier/recipes/verify_seal.md @@ -10,6 +10,31 @@ fresh subagent dispatched via the Agent tool with no implementation history. +## Re-run scope [cost-driven, added 2026-09-02] +Default: AUDIT the note, don't independently re-run `npm test`/`npm run +build` from scratch (including a fresh `npm ci` in an isolated worktree). +`EvidenceOnly` means "don't substitute reasoning for real evidence" — it +does NOT mean "always regenerate the evidence yourself." If the note's +output is verbatim, not truncated (step 5), the command matches +`doctrine/MEMORY.md` (step 4), and it covers every acceptance criterion +(step 6) → verdict straight off the note, no re-run. + +Only re-run (partial or full) when: +- The note is missing a citation, output looks truncated/hidden, or the + command doesn't match doctrine → REOPEN per steps 4-5 instead — don't + spend an `npm ci` confirming a note that's already broken. +- The node is outward-facing or a `/release` gate (this project has shipped + a real production bug once already, v1.2.0 → v1.2.1 — release nodes are + exactly where the independent-confirmation cost is worth paying). +- `doctrine/domains/PROJECT.md` names this class of change as needing + independent re-run (a per-project call, not the kit default). + +Observed in practice (usage audit 2026-09-02, this hub included): 2 +verifier subagents each re-reading the full doctrine + re-running +build/test cost ~50k tokens apiece with no change to the verdict versus +just auditing the note. Not a bug, but not what `EvidenceOnly` actually +asks for — this section pins the boundary. + ## Steps 1. REFUSE SELF-GRADING FIRST — did I write this diff in this session? (No, by construction — subagent has a fresh context.) From 03bcb66e7f8a74acb41205c6da76b92f06ef8fa3 Mon Sep 17 00:00:00 2001 From: _david Date: Wed, 2 Sep 2026 18:23:53 +0700 Subject: [PATCH 2/8] feat(auth): add logout-all endpoint to revoke all sessions (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/v1/auth/logout-all so a candidate can invalidate every access/refresh token issued to them at once, not just the current one (as /logout already does). Design differs from the issue's tokenVersion-on-Candidate-model proposal: instead of adding a Mongo field + an extra DB lookup per authenticated request, this reuses the exact Redis-with-in-memory- fallback shape already used by tokenBlacklist.ts. A per-candidate 'invalidated before' timestamp is stored in Redis/mem (sessionRevocation.ts) and compared against each JWT's standard iat claim in verifyToken.middleware.ts and authRefreshToken — one more Redis/mem lookup alongside the blacklist check that already runs on every request, no schema change, no new DB round trip. - src/utils/sessionRevocation.ts: invalidateAllSessions / getSessionsInvalidatedAt / isSessionRevoked - verifyToken.middleware.ts: reject tokens issued before the last logout-all - auth.controller.ts: authLogoutAll controller + same check in authRefreshToken - auth.route.ts: POST /logout-all (authenticated via verifyToken) - locales: logoutAllSuccess (en/vi) - tests updated/added for verifyToken middleware, authRefreshToken, authLogoutAll Closes #74 --- src/__tests__/auth/auth.controller.test.ts | 44 ++++++++- src/__tests__/auth/refreshToken.test.ts | 22 ++++- src/__tests__/middlewares/verifyToken.test.ts | 38 ++++++++ src/auth/auth.controller.ts | 44 ++++++++- src/locales/en.ts | 1 + src/locales/vi.ts | 1 + src/middlewares/verifyToken.middleware.ts | 11 ++- src/routers/api/v1/auth.route.ts | 28 +++++- src/utils/sessionRevocation.ts | 94 +++++++++++++++++++ 9 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/utils/sessionRevocation.ts diff --git a/src/__tests__/auth/auth.controller.test.ts b/src/__tests__/auth/auth.controller.test.ts index 1a09890..8270356 100644 --- a/src/__tests__/auth/auth.controller.test.ts +++ b/src/__tests__/auth/auth.controller.test.ts @@ -4,19 +4,21 @@ import { Request, Response, NextFunction } from 'express'; import { StatusCodes } from 'http-status-codes'; -import { authRegister, authLogin, authRefreshToken, authLogout, authCreateRefreshToken } from '@/auth/auth.controller'; +import { authRegister, authLogin, authRefreshToken, authLogout, authLogoutAll, authCreateRefreshToken } from '@/auth/auth.controller'; import * as validateSchema from '@/utils'; import * as formatReturn from '@/utils'; import * as handleError from '@/utils'; import * as tokenBlacklist from '@/utils/tokenBlacklist'; import * as jwt from '@/utils'; import * as helperAuth from '@/utils/helper-auth'; +import * as sessionRevocation from '@/utils/sessionRevocation'; import { handlerRegister, handlerLogin } from '@/auth/auth.service'; // Mock modules jest.mock('@/utils'); jest.mock('@/utils/tokenBlacklist'); jest.mock('@/utils/helper-auth'); +jest.mock('@/utils/sessionRevocation'); jest.mock('@/auth/auth.service'); jest.mock('@/auth/auth.validate', () => ({ schemaAuthRegister: {}, @@ -45,6 +47,9 @@ const mockNext = jest.fn() as NextFunction; describe('auth.controller', () => { beforeEach(() => { jest.clearAllMocks(); + // no logout-all in effect by default + (sessionRevocation.getSessionsInvalidatedAt as jest.Mock).mockResolvedValue(null); + (sessionRevocation.isSessionRevoked as jest.Mock).mockImplementation(jest.requireActual('@/utils/sessionRevocation').isSessionRevoked); }); describe('authRegister', () => { @@ -235,6 +240,43 @@ describe('auth.controller', () => { }); }); + describe('authLogoutAll', () => { + it('invalidates all sessions for the authenticated candidate', async () => { + const req: any = mockRequest(); + req.user = { _id: 'user_id' }; + const res = mockResponse(); + + (sessionRevocation.invalidateAllSessions as jest.Mock).mockResolvedValue(true); + + await authLogoutAll(req, res, mockNext); + + expect(sessionRevocation.invalidateAllSessions).toHaveBeenCalledWith('user_id'); + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ + statusCode: StatusCodes.OK, + success: true, + message: 'Đã đăng xuất khỏi tất cả thiết bị', + }), + ); + }); + + it('fails when there is no authenticated user on the request', async () => { + const req: any = mockRequest(); + const res = mockResponse(); + + await authLogoutAll(req, res, mockNext); + + expect(sessionRevocation.invalidateAllSessions).not.toHaveBeenCalled(); + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ + statusCode: StatusCodes.UNAUTHORIZED, + }), + ); + }); + }); + describe('authCreateRefreshToken', () => { it('should be placeholder', async () => { const req = mockRequest(); diff --git a/src/__tests__/auth/refreshToken.test.ts b/src/__tests__/auth/refreshToken.test.ts index 6908a64..31b4da5 100644 --- a/src/__tests__/auth/refreshToken.test.ts +++ b/src/__tests__/auth/refreshToken.test.ts @@ -1,14 +1,18 @@ import { authRefreshToken } from '@/auth/auth.controller'; import * as tokenBlacklist from '@/utils/tokenBlacklist'; import * as jwtUtils from '@/utils/jwt'; +import * as sessionRevocation from '@/utils/sessionRevocation'; jest.mock('@/utils/tokenBlacklist'); jest.mock('@/utils/jwt'); +jest.mock('@/utils/sessionRevocation'); const mockedIsBlacklisted = tokenBlacklist.isBlacklisted as jest.MockedFunction; const mockedAddToBlacklist = tokenBlacklist.addToBlacklist as jest.MockedFunction; const mockedJwtVerify = jwtUtils.jwtVerify as jest.MockedFunction; const mockedJwtSign = jwtUtils.jwtSign as jest.MockedFunction; +const mockedGetSessionsInvalidatedAt = sessionRevocation.getSessionsInvalidatedAt as jest.MockedFunction; +const actualSessionRevocation = jest.requireActual('@/utils/sessionRevocation'); function createMocks(body?: any, headers?: Record) { const req: any = { @@ -23,7 +27,11 @@ function createMocks(body?: any, headers?: Record) { } describe('authRefreshToken controller', () => { - beforeEach(() => jest.resetAllMocks()); + beforeEach(() => { + jest.resetAllMocks(); + (sessionRevocation.isSessionRevoked as jest.Mock).mockImplementation(actualSessionRevocation.isSessionRevoked); + mockedGetSessionsInvalidatedAt.mockResolvedValue(null); // no logout-all in effect by default + }); it('returns 401 when missing refresh token', async () => { const { req, res, next } = createMocks(); @@ -51,4 +59,16 @@ describe('authRefreshToken controller', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.status().json).toHaveBeenCalledWith(expect.objectContaining({ success: true })); }); + + it('returns 403 when the refresh token predates the last logout-all (issue #74)', async () => { + mockedIsBlacklisted.mockResolvedValue(false); + mockedJwtVerify.mockReturnValue({ _id: 'user1', iat: 1000 } as any); + mockedGetSessionsInvalidatedAt.mockResolvedValue(2000); + + const { req, res, next } = createMocks({}, { authorization: 'Bearer oldRefresh' }); + await authRefreshToken(req, res, next); + + expect(mockedAddToBlacklist).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); }); diff --git a/src/__tests__/middlewares/verifyToken.test.ts b/src/__tests__/middlewares/verifyToken.test.ts index ca1753a..77eea43 100644 --- a/src/__tests__/middlewares/verifyToken.test.ts +++ b/src/__tests__/middlewares/verifyToken.test.ts @@ -1,13 +1,18 @@ import { verifyToken } from '@/middlewares/verifyToken.middleware'; import * as jwtUtils from '@/utils/jwt'; import * as tokenBlacklist from '@/utils/tokenBlacklist'; +import * as sessionRevocation from '@/utils/sessionRevocation'; import { AuthenticationError, TokenExpiredError, InvalidTokenError, TokenRevokedError } from '@/errors'; jest.mock('@/utils/jwt'); jest.mock('@/utils/tokenBlacklist'); +jest.mock('@/utils/sessionRevocation'); const mockedJwtVerify = jwtUtils.jwtVerify as jest.MockedFunction; const mockedIsBlacklisted = tokenBlacklist.isBlacklisted as jest.MockedFunction; +const mockedGetSessionsInvalidatedAt = sessionRevocation.getSessionsInvalidatedAt as jest.MockedFunction; +// isSessionRevoked has real, simple logic — use the actual implementation instead of a mock +const actualSessionRevocation = jest.requireActual('@/utils/sessionRevocation'); function createMocks(headers?: Record, query?: Record) { const req: any = { @@ -23,6 +28,8 @@ function createMocks(headers?: Record, query?: Record { beforeEach(() => { jest.resetAllMocks(); + (sessionRevocation.isSessionRevoked as jest.Mock).mockImplementation(actualSessionRevocation.isSessionRevoked); + mockedGetSessionsInvalidatedAt.mockResolvedValue(null); // no logout-all in effect by default }); it('calls next with AuthenticationError when missing token', async () => { @@ -80,4 +87,35 @@ describe('verifyToken middleware', () => { await verifyToken(req, res, next); expect(next).toHaveBeenCalledWith(expect.any(InvalidTokenError)); }); + + describe('logout-all (issue #74)', () => { + it('calls next with TokenRevokedError when token was issued before the last logout-all', async () => { + mockedIsBlacklisted.mockResolvedValue(false); + mockedJwtVerify.mockReturnValue({ _id: 'abc123', iat: 1000 } as any); + mockedGetSessionsInvalidatedAt.mockResolvedValue(2000); // logout-all happened after this token was issued + const { req, res, next } = createMocks({ Authorization: 'Bearer stale' }); + await verifyToken(req, res, next); + expect(next).toHaveBeenCalledWith(expect.any(TokenRevokedError)); + }); + + it('calls next and attaches req.user when token was issued after the last logout-all', async () => { + mockedIsBlacklisted.mockResolvedValue(false); + mockedJwtVerify.mockReturnValue({ _id: 'abc123', iat: 3000 } as any); + mockedGetSessionsInvalidatedAt.mockResolvedValue(2000); // token minted after the logout-all + const { req, res, next } = createMocks({ Authorization: 'Bearer fresh' }); + await verifyToken(req, res, next); + expect(next).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalledWith(expect.any(TokenRevokedError)); + expect((req as any).user).toEqual({ _id: 'abc123' }); + }); + + it('calls next with TokenRevokedError when a logout-all is in effect but the token has no iat', async () => { + mockedIsBlacklisted.mockResolvedValue(false); + mockedJwtVerify.mockReturnValue({ _id: 'abc123' } as any); // no iat + mockedGetSessionsInvalidatedAt.mockResolvedValue(2000); + const { req, res, next } = createMocks({ Authorization: 'Bearer no-iat' }); + await verifyToken(req, res, next); + expect(next).toHaveBeenCalledWith(expect.any(TokenRevokedError)); + }); + }); }); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c310834..efb171d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -10,6 +10,7 @@ import { validateSchema, formatReturn, handleError, throwBadRequestError } from import { schemaAuthRegister, schemaAuthLogin, schemaForgotPassword, schemaResetPassword } from './auth.validate'; import { handlerRegister, handlerLogin, handlerForgotPassword, handlerResetPassword, handlerVerifyEmail } from './auth.service'; import { addToBlacklist, isBlacklisted } from '@/utils/tokenBlacklist'; +import { invalidateAllSessions, getSessionsInvalidatedAt, isSessionRevoked } from '@/utils/sessionRevocation'; import { jwtSign, jwtVerify } from '@/utils'; import { extractTokenFromRequest } from '@/utils/helper-auth'; import { TOKEN_SECRET, TOKEN_REFRESH, TOKEN_EXP_IN, TOKEN_REFRESH_EXP_IN } from '@/config/process.config'; @@ -119,7 +120,7 @@ export const authRefreshToken = async (req: Request, res: Response, next: NextFu // verify refresh token const decoded = jwtVerify(refreshToken, TOKEN_REFRESH); - const { _id } = (decoded as { _id?: string }) || {}; + const { _id, iat } = (decoded as { _id?: string; iat?: number }) || {}; if (!_id) return formatReturn(res, { statusCode: StatusCodes.UNAUTHORIZED, @@ -127,6 +128,17 @@ export const authRefreshToken = async (req: Request, res: Response, next: NextFu message: t('auth.invalidRefreshPayload', (req as any).lang), }); + // "Log out of all devices" (issue #74): a refresh token issued before + // the candidate's last logout-all must not be usable to mint new pairs. + const invalidatedAt = await getSessionsInvalidatedAt(_id); + if (isSessionRevoked(iat, invalidatedAt)) { + return formatReturn(res, { + statusCode: StatusCodes.FORBIDDEN, + success: false, + message: t('auth.refreshTokenRevoked', (req as any).lang), + }); + } + // rotate: blacklist old refresh token await addToBlacklist(refreshToken); @@ -261,3 +273,33 @@ export const authLogout = async (req: Request, res: Response, next: NextFunction handleError(err, next, (req as any).lang); } }; + +/** + * Chức năng "Log out of all devices" (issue #74): thu hồi mọi token đã + * phát cho candidate này tính đến thời điểm hiện tại — không chỉ token + * hiện tại như /logout. Yêu cầu route được gắn `verifyToken` trước, nên + * `req.user._id` luôn tồn tại khi tới đây. + */ +export const authLogoutAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const candidateId = (req as any).user?._id; + + if (!candidateId) { + return formatReturn(res, { + statusCode: StatusCodes.UNAUTHORIZED, + success: false, + message: t('auth.noTokenToLogout', (req as any).lang), + }); + } + + await invalidateAllSessions(candidateId); + + return formatReturn(res, { + statusCode: StatusCodes.OK, + success: true, + message: t('auth.logoutAllSuccess', (req as any).lang), + }); + } catch (err) { + handleError(err, next, (req as any).lang); + } +}; diff --git a/src/locales/en.ts b/src/locales/en.ts index e3d2807..cb6b89a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -16,6 +16,7 @@ export default { tokenRefreshed: 'Token refreshed', noTokenToLogout: 'No token provided to logout', logoutSuccess: 'Logged out successfully', + logoutAllSuccess: 'Logged out of all devices successfully', forgotPasswordRequested: 'If the email exists, a password reset link has been generated', resetTokenInvalid: 'Password reset token is invalid or has expired', resetPasswordSuccess: 'Password reset successful', diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 5a84eee..42629f0 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -16,6 +16,7 @@ export default { tokenRefreshed: 'Làm mới token thành công', noTokenToLogout: 'Không có token để đăng xuất', logoutSuccess: 'Đăng xuất thành công', + logoutAllSuccess: 'Đã đăng xuất khỏi tất cả thiết bị', forgotPasswordRequested: 'Nếu email tồn tại, liên kết đặt lại mật khẩu đã được tạo', resetTokenInvalid: 'Token đặt lại mật khẩu không hợp lệ hoặc đã hết hạn', resetPasswordSuccess: 'Đặt lại mật khẩu thành công', diff --git a/src/middlewares/verifyToken.middleware.ts b/src/middlewares/verifyToken.middleware.ts index fd7aca7..d5fe805 100644 --- a/src/middlewares/verifyToken.middleware.ts +++ b/src/middlewares/verifyToken.middleware.ts @@ -8,6 +8,7 @@ import { StatusCodes } from 'http-status-codes'; import { TOKEN_SECRET } from '@/config/process.config'; import { jwtVerify } from '@/utils/jwt'; import { isBlacklisted } from '@/utils/tokenBlacklist'; +import { getSessionsInvalidatedAt, isSessionRevoked } from '@/utils/sessionRevocation'; import { ErrorCode, TokenExpiredError, TokenRevokedError, InvalidTokenError, AuthenticationError } from '@/errors'; import { extractTokenFromRequest } from '@/utils/helper-auth'; @@ -29,12 +30,20 @@ export const verifyToken = async (req: Request, res: Response, next: NextFunctio } const decoded = jwtVerify(token, TOKEN_SECRET); - const { _id } = (decoded as { _id?: string }) || {}; + const { _id, iat } = (decoded as { _id?: string; iat?: number }) || {}; if (!_id) { return next(new InvalidTokenError('Invalid token payload.')); } + // "Log out of all devices" (issue #74): reject any token issued before + // the candidate's last logout-all, even if it hasn't blacklisted-out or + // expired on its own yet. + const invalidatedAt = await getSessionsInvalidatedAt(_id); + if (isSessionRevoked(iat, invalidatedAt)) { + return next(new TokenRevokedError('Token has been revoked.')); + } + // Attach authenticated user info. Also force req.body.candidateId to the // authenticated user's own _id, overwriting whatever the client sent — // every candidate_profile handler (list/create/update/delete/export) diff --git a/src/routers/api/v1/auth.route.ts b/src/routers/api/v1/auth.route.ts index 0d62226..b4776be 100644 --- a/src/routers/api/v1/auth.route.ts +++ b/src/routers/api/v1/auth.route.ts @@ -7,8 +7,9 @@ import express from 'express'; const router = express.Router(); -import { authRegister, authLogin, authLogout, authRefreshToken, authForgotPassword, authResetPassword, authVerifyEmail } from '@/auth/auth.controller'; +import { authRegister, authLogin, authLogout, authLogoutAll, authRefreshToken, authForgotPassword, authResetPassword, authVerifyEmail } from '@/auth/auth.controller'; import { createRateLimiter } from '@/middlewares/rateLimit.middleware'; +import { verifyToken } from '@/middlewares/verifyToken.middleware'; // Apply rate limit for auth routes (150 requests per 15 minutes) const authLimiter = createRateLimiter({ max: 150, windowMs: 15 * 60 * 1000, keyPrefix: 'auth-rl' }); @@ -100,6 +101,31 @@ router.get('/login', authLogin); */ router.post('/logout', authLogout); +/** + * @swagger + * /api/v1/auth/logout-all: + * post: + * tags: [Auth] + * summary: Log out of all devices — revoke every token issued to this candidate up to now + * description: > + * Unlike /logout (which blacklists only the current access token), this + * invalidates every access and refresh token issued before this call, + * including the one used to make this request. All devices/sessions + * will need to log in again. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: All sessions revoked + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiResponse' + * 401: + * description: Missing, invalid, expired, or already-revoked token + */ +router.post('/logout-all', verifyToken, authLogoutAll); + /** * @swagger * /api/v1/auth/refresh: diff --git a/src/utils/sessionRevocation.ts b/src/utils/sessionRevocation.ts new file mode 100644 index 0000000..e758ffa --- /dev/null +++ b/src/utils/sessionRevocation.ts @@ -0,0 +1,94 @@ +/** + * Author: Đạt Võ - https://github.com/datvt243 + * Date: `--/--` + * Description: "Log out of all devices" (issue #74) — revoke every token + * previously issued to a candidate at once, without enumerating or + * blacklisting them individually. + * + * Design note: the issue proposal suggested a `tokenVersion` field on + * the Candidate model, checked via an extra Mongo lookup per request — + * but flagged that cost as worth avoiding if possible. This reuses the + * exact same Redis-with-in-memory-fallback shape as + * `tokenBlacklist.ts` instead: store a per-candidate + * "invalidated before" timestamp, and compare it against the JWT's + * standard `iat` claim in verifyToken.middleware.ts / authRefreshToken. + * No schema change, no new DB round trip — just one more Redis/mem + * lookup alongside the blacklist check that already runs on every + * authenticated request. + */ +import { isRedisAvailable, getRedisClient } from '@/services/redis'; +import { logger } from '@/logger'; + +// Generous fixed TTL for the revocation marker — only needs to outlive +// the longest-lived token type (refresh token) so a stale marker doesn't +// linger forever, not tied precisely to TOKEN_REFRESH_EXP_IN. +const REVOCATION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days + +// In-memory fallback (used when Redis unavailable), same shape as tokenBlacklist.ts +const memoryStore = new Map(); // candidateId -> invalidated-before (unix seconds) + +const _cleanup = setInterval(() => { + const cutoff = Date.now() / 1000 - REVOCATION_TTL_SECONDS; + for (const [candidateId, invalidatedAt] of memoryStore) { + if (invalidatedAt <= cutoff) memoryStore.delete(candidateId); + } +}, 60 * 1000); +// do not keep node process alive for tests +if (typeof (_cleanup as any).unref === 'function') (_cleanup as any).unref(); + +/** + * Marks every token issued to this candidate up to now as revoked. + * Any token (access or refresh) whose `iat` predates this call will be + * rejected — including the token used to make this very request. + */ +export const invalidateAllSessions = async (candidateId: string): Promise => { + const now = Math.floor(Date.now() / 1000); + try { + if (isRedisAvailable()) { + const redis = getRedisClient(); + if (redis) { + await redis.setEx(`sessions-invalidated:${candidateId}`, REVOCATION_TTL_SECONDS, String(now)); + return true; + } + } + + memoryStore.set(candidateId, now); + return true; + } catch (err) { + logger.error('[sessionRevocation] Error invalidating sessions', { err: (err as Error).message, stack: (err as Error).stack }); + return false; + } +}; + +/** + * Returns the unix timestamp (seconds) before which all of this + * candidate's tokens are revoked, or null if `logout-all` was never + * called (or the marker has expired). + */ +export const getSessionsInvalidatedAt = async (candidateId: string): Promise => { + if (!candidateId) return null; + + try { + if (isRedisAvailable()) { + const redis = getRedisClient(); + if (redis) { + const result = await redis.get(`sessions-invalidated:${candidateId}`); + return result !== null ? parseInt(result, 10) : null; + } + } + + return memoryStore.get(candidateId) ?? null; + } catch (err) { + logger.error('[sessionRevocation] Error reading invalidation marker', { err: (err as Error).message, stack: (err as Error).stack }); + return null; + } +}; + +/** True if a token issued at `iat` (unix seconds) has been revoked by a prior logout-all. */ +export const isSessionRevoked = (iat: number | undefined, invalidatedAt: number | null): boolean => { + if (!invalidatedAt) return false; + if (!iat) return true; // no iat to compare against a revocation marker — treat as revoked, not trusted + return iat < invalidatedAt; +}; + +export default { invalidateAllSessions, getSessionsInvalidatedAt, isSessionRevoked }; From 1133f1bbee627a3f0dc1f1df88a908d00c31b832 Mon Sep 17 00:00:00 2001 From: _david Date: Wed, 2 Sep 2026 18:32:55 +0700 Subject: [PATCH 3/8] feat(candidate_profile): add pagination and sort to CV section list endpoints (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/{education|experience|award|certificate|project|reference} now accept optional `page`, `limit`, `sort` query params. Backward compatible by design: omitting `limit` returns the exact same response as before (`data` is the full, unpaginated array). Passing a valid `limit` switches `data` to `{ items, pagination: { page, limit, total, totalPages } }`. `limit` is capped at 100 regardless of what's requested. `sort` accepts a Mongoose sort expression (e.g. `-createdAt`) validated against an allowlist regex (no $, can't smuggle an operator); an invalid value is silently ignored rather than erroring. - services/index.ts: baseFindDocument gains page/limit/sort, applies .sort()/.skip()/.limit() + a parallel countDocuments() only when a valid limit is given - candidate_profile/BaseController.ts: baseGetAll parses page/limit/sort from req.query, validates sort against an allowlist - swagger.config.ts: shared PageParam/LimitParam/SortParam + Pagination schema - 6 CV-section routers (education/experience/award/certificate/project/ reference): wired the new query params into their GET / swagger docs. generalInformation excluded — its GET / returns a single per-candidate document, not a list, so pagination doesn't apply. - tests: baseFindDocument.test.ts, BaseController.test.ts Closes #73 --- .../candidate_profile/BaseController.test.ts | 57 ++++++++++++ .../services/baseFindDocument.test.ts | 93 +++++++++++++++++++ src/candidate_profile/BaseController.ts | 13 +++ src/config/swagger.config.ts | 39 ++++++++ src/routers/api/v1/award.route.ts | 6 +- src/routers/api/v1/certificate.route.ts | 6 +- src/routers/api/v1/education.route.ts | 6 +- src/routers/api/v1/experience.route.ts | 6 +- src/routers/api/v1/project.route.ts | 6 +- src/routers/api/v1/reference.route.ts | 6 +- src/services/index.ts | 48 ++++++++-- 11 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/candidate_profile/BaseController.test.ts create mode 100644 src/__tests__/services/baseFindDocument.test.ts diff --git a/src/__tests__/candidate_profile/BaseController.test.ts b/src/__tests__/candidate_profile/BaseController.test.ts new file mode 100644 index 0000000..ca50e9d --- /dev/null +++ b/src/__tests__/candidate_profile/BaseController.test.ts @@ -0,0 +1,57 @@ +/** + * Tests for candidate_profile/BaseController.ts's baseGetAll — specifically + * the page/limit/sort query-string parsing added for issue #73. + */ +import { baseGetAll } from '@/candidate_profile/BaseController'; +import * as services from '@/services'; + +jest.mock('@/services'); + +const mockedBaseFindDocument = services.baseFindDocument as jest.MockedFunction; + +function createMocks(query: Record = {}) { + const req: any = { body: { candidateId: 'c1', collection: 'experiences' }, query }; + const json = jest.fn(); + const res: any = { status: jest.fn().mockReturnValue({ json }), json }; + const next = jest.fn(); + return { req, res, next }; +} + +describe('baseGetAll', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedBaseFindDocument.mockResolvedValue({ success: true, message: '', errors: null, data: [] }); + }); + + it('passes page/limit/sort through as numbers/string when present', async () => { + const { req, res, next } = createMocks({ page: '2', limit: '10', sort: '-createdAt' }); + await baseGetAll(req, res, next); + + expect(mockedBaseFindDocument).toHaveBeenCalledWith( + expect.objectContaining({ page: 2, limit: 10, sort: '-createdAt' }), + ); + }); + + it('omits page/limit/sort when the query string has none (backward compatible)', async () => { + const { req, res, next } = createMocks(); + await baseGetAll(req, res, next); + + expect(mockedBaseFindDocument).toHaveBeenCalledWith( + expect.objectContaining({ page: undefined, limit: undefined, sort: undefined }), + ); + }); + + it('silently drops a sort value that could smuggle a Mongo operator', async () => { + const { req, res, next } = createMocks({ sort: '$where' }); + await baseGetAll(req, res, next); + + expect(mockedBaseFindDocument).toHaveBeenCalledWith(expect.objectContaining({ sort: undefined })); + }); + + it('accepts a leading "-" in sort for descending order', async () => { + const { req, res, next } = createMocks({ sort: '-startDate' }); + await baseGetAll(req, res, next); + + expect(mockedBaseFindDocument).toHaveBeenCalledWith(expect.objectContaining({ sort: '-startDate' })); + }); +}); diff --git a/src/__tests__/services/baseFindDocument.test.ts b/src/__tests__/services/baseFindDocument.test.ts new file mode 100644 index 0000000..84e7b8e --- /dev/null +++ b/src/__tests__/services/baseFindDocument.test.ts @@ -0,0 +1,93 @@ +/** + * Tests for services/index.ts's baseFindDocument — specifically the + * pagination/sort support added for issue #73. Uses a fake Mongoose-shaped + * model instead of jest.mock('@/utils/querySafe') since QuerySafe has no + * side effects worth mocking out. + */ +import { baseFindDocument } from '@/services'; + +function createFakeModel(docs: Record[]) { + const query: any = { + sort: jest.fn(), + skip: jest.fn(), + limit: jest.fn(), + exec: jest.fn().mockResolvedValue(docs), + }; + // chainable: each call returns the same query object + query.sort.mockReturnValue(query); + query.skip.mockReturnValue(query); + query.limit.mockReturnValue(query); + + return { + find: jest.fn().mockReturnValue(query), + findOne: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(docs[0] ?? null) }), + countDocuments: jest.fn().mockResolvedValue(docs.length), + __query: query, + }; +} + +describe('baseFindDocument', () => { + it('fails fast when fields is empty', async () => { + const model = createFakeModel([]); + const result = await baseFindDocument({ model, fields: {}, findOne: false }); + expect(result.success).toBe(false); + expect(model.find).not.toHaveBeenCalled(); + }); + + it('findOne: true returns a single document via MODEL.findOne, untouched by pagination', async () => { + const model = createFakeModel([{ _id: '1', candidateId: 'c1' }]); + const result = await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: true }); + + expect(model.findOne).toHaveBeenCalledWith({ candidateId: 'c1' }); + expect(result).toEqual({ success: true, message: '', errors: null, data: { _id: '1', candidateId: 'c1' } }); + }); + + it('findOne: false, no limit -> returns the full array unchanged (backward compatible)', async () => { + const docs = [{ _id: '1' }, { _id: '2' }]; + const model = createFakeModel(docs); + + const result = await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false }); + + expect(model.find).toHaveBeenCalledWith({ candidateId: 'c1' }); + expect(model.__query.skip).not.toHaveBeenCalled(); + expect(model.__query.limit).not.toHaveBeenCalled(); + expect(model.countDocuments).not.toHaveBeenCalled(); + expect(result.data).toEqual(docs); + }); + + it('findOne: false, with a valid limit -> paginates and wraps data as { items, pagination }', async () => { + const docs = [{ _id: '1' }, { _id: '2' }]; + const model = createFakeModel(docs); + + const result = await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, page: 2, limit: 2 }); + + expect(model.__query.skip).toHaveBeenCalledWith(2); // (page 2 - 1) * limit 2 + expect(model.__query.limit).toHaveBeenCalledWith(2); + expect(model.countDocuments).toHaveBeenCalledWith({ candidateId: 'c1' }); + expect(result.data).toEqual({ + items: docs, + pagination: { page: 2, limit: 2, total: 2, totalPages: 1 }, + }); + }); + + it('clamps limit to the max page size', async () => { + const model = createFakeModel([]); + await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, limit: 9999 }); + + expect(model.__query.limit).toHaveBeenCalledWith(100); + }); + + it('defaults page to 1 when page is missing or invalid', async () => { + const model = createFakeModel([]); + await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, limit: 10, page: 0 }); + + expect(model.__query.skip).toHaveBeenCalledWith(0); + }); + + it('applies sort when given, with or without pagination', async () => { + const model = createFakeModel([]); + await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, sort: '-createdAt' }); + + expect(model.__query.sort).toHaveBeenCalledWith('-createdAt'); + }); +}); diff --git a/src/candidate_profile/BaseController.ts b/src/candidate_profile/BaseController.ts index ccb8efc..55182d4 100644 --- a/src/candidate_profile/BaseController.ts +++ b/src/candidate_profile/BaseController.ts @@ -14,6 +14,11 @@ interface baseProp { findOne?: boolean; } +// Field name used to sort by — no `$`, so this can't smuggle a Mongo +// operator into `.sort()`, and it can only ever reorder rows, never widen +// which rows come back. A leading `-` (Mongoose convention) means desc. +const SORT_FIELD_REGEX = /^-?[a-zA-Z0-9_.]+$/; + const modelObject: { [key: string]: any } = { generalInformation: MODELS.generalInformation, experiences: MODELS.Experience, @@ -30,12 +35,20 @@ export const baseGetAll = async (req: Request, res: Response, next: NextFunction if (!candidateId || !collection || !modelObject[collection]) return formatReturn(res, { statusCode: StatusCodes.NOT_FOUND, data: null, message: t('common.notFoundData', (req as any).lang) }); + // Optional pagination/sort (issue #73). Omitting page/limit keeps the + // pre-existing "return everything" behavior (`data` stays a plain + // array) — this is purely additive, no existing caller is affected. + const { page, limit, sort } = req.query as Record; + try { const _result = await baseFindDocument({ fields: { candidateId: candidateId }, model: modelObject[collection], findOne: false, lang: (req as any).lang, + page: page !== undefined ? parseInt(page, 10) : undefined, + limit: limit !== undefined ? parseInt(limit, 10) : undefined, + sort: sort && SORT_FIELD_REGEX.test(sort) ? sort : undefined, }); return formatReturn(res, { ..._result }); } catch (err) { diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index f5d1152..02207ec 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -28,6 +28,33 @@ const options: swaggerJsdoc.Options = { bearerFormat: 'JWT', }, }, + parameters: { + // Pagination (issue #73) — shared by every CV-section `GET /` list + // endpoint via BaseController.baseGetAll. All three are optional; + // omitting `limit` returns the full, unpaginated array exactly as + // before (see ApiResponse vs ApiResponsePaginated). + PageParam: { + in: 'query', + name: 'page', + required: false, + schema: { type: 'integer', minimum: 1, default: 1 }, + description: '1-indexed page number. Ignored unless `limit` is also given.', + }, + LimitParam: { + in: 'query', + name: 'limit', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100 }, + description: 'Page size (max 100). Providing this switches the response `data` shape from an array to `{ items, pagination }`.', + }, + SortParam: { + in: 'query', + name: 'sort', + required: false, + schema: { type: 'string' }, + description: "Mongoose sort expression, e.g. `startDate` or `-createdAt` for descending. Invalid values are silently ignored.", + }, + }, schemas: { ApiResponse: { type: 'object', @@ -38,6 +65,18 @@ const options: swaggerJsdoc.Options = { data: { nullable: true }, }, }, + // Shape of `data` when a `GET /` list endpoint is called with + // `?limit=` (issue #73) — otherwise `data` stays a plain array, + // as documented on ApiResponse. + Pagination: { + type: 'object', + properties: { + page: { type: 'integer' }, + limit: { type: 'integer' }, + total: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, SocialMedia: { type: 'object', properties: { diff --git a/src/routers/api/v1/award.route.ts b/src/routers/api/v1/award.route.ts index a7b973f..53b1fd6 100644 --- a/src/routers/api/v1/award.route.ts +++ b/src/routers/api/v1/award.route.ts @@ -19,9 +19,13 @@ const router = express.Router(); * summary: List all awards for the authenticated candidate * security: * - bearerAuth: [] + * parameters: + * - $ref: '#/components/parameters/PageParam' + * - $ref: '#/components/parameters/LimitParam' + * - $ref: '#/components/parameters/SortParam' * responses: * 200: - * description: List of awards + * description: List of awards. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array. * content: * application/json: * schema: diff --git a/src/routers/api/v1/certificate.route.ts b/src/routers/api/v1/certificate.route.ts index 084b365..b419477 100644 --- a/src/routers/api/v1/certificate.route.ts +++ b/src/routers/api/v1/certificate.route.ts @@ -19,9 +19,13 @@ const router = express.Router(); * summary: List all certificates for the authenticated candidate * security: * - bearerAuth: [] + * parameters: + * - $ref: '#/components/parameters/PageParam' + * - $ref: '#/components/parameters/LimitParam' + * - $ref: '#/components/parameters/SortParam' * responses: * 200: - * description: List of certificates + * description: List of certificates. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array. * content: * application/json: * schema: diff --git a/src/routers/api/v1/education.route.ts b/src/routers/api/v1/education.route.ts index 1773237..8788bdd 100644 --- a/src/routers/api/v1/education.route.ts +++ b/src/routers/api/v1/education.route.ts @@ -19,9 +19,13 @@ const router = express.Router(); * summary: List all education entries for the authenticated candidate * security: * - bearerAuth: [] + * parameters: + * - $ref: '#/components/parameters/PageParam' + * - $ref: '#/components/parameters/LimitParam' + * - $ref: '#/components/parameters/SortParam' * responses: * 200: - * description: List of education entries + * description: List of education entries. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array. * content: * application/json: * schema: diff --git a/src/routers/api/v1/experience.route.ts b/src/routers/api/v1/experience.route.ts index cd30461..302ea9c 100644 --- a/src/routers/api/v1/experience.route.ts +++ b/src/routers/api/v1/experience.route.ts @@ -19,9 +19,13 @@ const router = express.Router(); * summary: List all work experience entries for the authenticated candidate * security: * - bearerAuth: [] + * parameters: + * - $ref: '#/components/parameters/PageParam' + * - $ref: '#/components/parameters/LimitParam' + * - $ref: '#/components/parameters/SortParam' * responses: * 200: - * description: List of experience entries + * description: List of experience entries. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array. * content: * application/json: * schema: diff --git a/src/routers/api/v1/project.route.ts b/src/routers/api/v1/project.route.ts index c37d655..95513c6 100644 --- a/src/routers/api/v1/project.route.ts +++ b/src/routers/api/v1/project.route.ts @@ -19,9 +19,13 @@ const router = express.Router(); * summary: List all projects for the authenticated candidate * security: * - bearerAuth: [] + * parameters: + * - $ref: '#/components/parameters/PageParam' + * - $ref: '#/components/parameters/LimitParam' + * - $ref: '#/components/parameters/SortParam' * responses: * 200: - * description: List of projects + * description: List of projects. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array. * content: * application/json: * schema: diff --git a/src/routers/api/v1/reference.route.ts b/src/routers/api/v1/reference.route.ts index 6590968..7699702 100644 --- a/src/routers/api/v1/reference.route.ts +++ b/src/routers/api/v1/reference.route.ts @@ -19,9 +19,13 @@ const router = express.Router(); * summary: List all references for the authenticated candidate * security: * - bearerAuth: [] + * parameters: + * - $ref: '#/components/parameters/PageParam' + * - $ref: '#/components/parameters/LimitParam' + * - $ref: '#/components/parameters/SortParam' * responses: * 200: - * description: List of references + * description: List of references. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array. * content: * application/json: * schema: diff --git a/src/services/index.ts b/src/services/index.ts index 8c0ae78..8c6b15f 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -12,8 +12,15 @@ interface baseProp { fields: { _id?: string; candidateId?: string }; findOne?: boolean; lang?: string; + page?: number; + limit?: number; + sort?: string; } +// Pagination (issue #73) hard cap — a caller cannot request more than this +// many documents per page regardless of what `limit` it passes. +const MAX_PAGE_LIMIT = 100; + const formatReturn = (props: BaseReturn) => { const { success = false, message = '', errors = null, data = null } = props; return { @@ -37,21 +44,50 @@ export const formatReturnFailed = (props: string | BaseReturn) => { }; export const baseFindDocument = async (props: baseProp) => { - const { model: MODEL, fields = { _id: '' }, findOne = true, lang = DEFAULT_LANG } = props; + const { model: MODEL, fields = { _id: '' }, findOne = true, lang = DEFAULT_LANG, page, limit, sort } = props; if (!MODEL || !fields || !Object.keys(fields).length) return formatReturnFailed(t('common.notFoundData', lang)); - let find; const idQuerySafe = (await import('@/utils/querySafe')).idQuerySafe; const safeFields = idQuerySafe.safeQuery({}, fields); + if (findOne) { - find = await MODEL.findOne(safeFields).exec(); - } else { - find = await MODEL.find(safeFields).exec(); + const find = await MODEL.findOne(safeFields).exec(); + return formatReturn({ success: true, data: find, message: '', errors: null }); } + + let query = MODEL.find(safeFields); + if (sort) query = query.sort(sort); + + /** + * Pagination (issue #73) is opt-in: it only kicks in when the caller + * passes a valid positive `limit`. No `limit` -> exactly the old + * behavior (`data` is the full, unpaginated array), so every existing + * caller of baseGetAll keeps working unchanged. + */ + const hasPagination = Number.isInteger(limit) && (limit as number) > 0; + if (!hasPagination) { + const find = await query.exec(); + return formatReturn({ success: true, data: find, message: '', errors: null }); + } + + const safeLimit = Math.min(limit as number, MAX_PAGE_LIMIT); + const safePage = Number.isInteger(page) && (page as number) > 0 ? (page as number) : 1; + const skip = (safePage - 1) * safeLimit; + + const [items, total] = await Promise.all([query.skip(skip).limit(safeLimit).exec(), MODEL.countDocuments(safeFields)]); + return formatReturn({ success: true, - data: find, + data: { + items, + pagination: { + page: safePage, + limit: safeLimit, + total, + totalPages: Math.max(Math.ceil(total / safeLimit), 1), + }, + }, message: '', errors: null, }); From 2c6e071eca3d86e543e023d7ab11fa575671719a Mon Sep 17 00:00:00 2001 From: _david Date: Wed, 2 Sep 2026 23:14:26 +0700 Subject: [PATCH 4/8] feat(candidate-me): add DOCX export format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/services/createDocx.ts (new): buildDocxContent()/renderDocxDocument() content-model + renderer using the `docx` package; createCVDocx() packs and streams the response with the correct OOXML content-type/filename. - src/candidate_me/index.ts: fnExportPDF gains a `format === 'docx'` branch, same shape/position as the existing `format === 'json'` branch, right before the PDF fallback — reuses the same handlerGetAboutMe() call, no duplicate data-fetch. - src/routers/api/v1/index.ts: Swagger doc only — `/download-pdf`'s format enum [pdf, json] -> [pdf, json, docx], added the docx response content entry. No route/logic change. - package.json/package-lock.json: added docx ^9.7.1 (MIT, no native deps). - src/__tests__/services/createDocx.test.ts (new, 6 tests). - agent-hub/haven/diagrams/dev-loop.prime-mermaid.md: add-docx-export-format node PENDING -> SEALED. npx tsc --noEmit: clean. npm test: Test Suites: 11 passed, 11 total / Tests: 60 passed, 60 total / Time: 4.611 s. npm run build: clean. Live-verified: GET /download-pdf?format=docx returns a real .docx (file(1) confirms "Microsoft Word 2007+", document.xml contains the candidate's real data); ?format=json and no-format (PDF) paths unaffected. Node: add-docx-export-format (SEALED) Evidence: agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-{plan,diff}.md agent-hub/evidence/verifier/2026-09-02/add-docx-export-format-seal.md Co-Authored-By: Claude Sonnet 5 --- .../2026-09-02/add-docx-export-format-diff.md | 127 +++++++++++ .../2026-09-02/add-docx-export-format-plan.md | 71 ++++++ .../2026-09-02/add-docx-export-format-seal.md | 81 +++++++ .../haven/diagrams/dev-loop.prime-mermaid.md | 1 + package-lock.json | 181 +++++++++++++++ package.json | 1 + src/__tests__/services/createDocx.test.ts | 113 +++++++++ src/candidate_me/index.ts | 8 + src/routers/api/v1/index.ts | 10 +- src/services/createDocx.ts | 214 ++++++++++++++++++ 10 files changed, 804 insertions(+), 3 deletions(-) create mode 100644 agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-diff.md create mode 100644 agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-plan.md create mode 100644 agent-hub/evidence/verifier/2026-09-02/add-docx-export-format-seal.md create mode 100644 src/__tests__/services/createDocx.test.ts create mode 100644 src/services/createDocx.ts diff --git a/agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-diff.md b/agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-diff.md new file mode 100644 index 0000000..fe1bb30 --- /dev/null +++ b/agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-diff.md @@ -0,0 +1,127 @@ +# 2026-09-02 — add-docx-export-format (diff) + +- Worker: implementer +- Version: 0.1.0 +- Node: `add-docx-export-format` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- Task (verbatim): "#76" (GitHub issue #76 — Additional CV export formats + (DOCX / JSON); JSON half already SEALED separately, this covers the + remaining DOCX scope per the operator's own edit to the issue body) + +See `add-docx-export-format-plan.md` (same directory) for the `pick_next` +plan this implements. + +## Diff +| File | Why | +|---|---| +| `package.json` / `package-lock.json` | Added `docx` (`^9.7.1`, confirmed via `npm view docx version` before installing) — MIT-licensed, no native deps, matches the library the issue itself suggested. | +| `src/services/createDocx.ts` (new) | `buildDocxContent(RECORD)` — pure content-model builder (heading + plain-string lines per section), same input shape as `createPDF.ts`'s `getDataCandidate`. `renderDocxDocument(content)` — turns that model into an actual `docx` `Document`. `createCVDocx(data, res)` — `Packer.toBuffer()` + sends with `Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document` and a `Content-Disposition` filename. Mirrors sections already in the PDF path: contact info, career/careerGoal, skills, experience, projects, education, awards, certificates, foreign languages, references — same data, different renderer. | +| `src/candidate_me/index.ts` | `fnExportPDF`: added `if (req.query.format === 'docx') { await createCVDocx(data, res); return; }` — same position/shape as the existing `format === 'json'` branch, right before the `createCV(data, res)` PDF fallback. No new data-fetch: reuses the exact same `handlerGetAboutMe(email, lang)` call already made for PDF/JSON. | +| `src/routers/api/v1/index.ts` | Swagger doc only: `/download-pdf`'s `format` enum `[pdf, json]` → `[pdf, json, docx]`, added an `application/vnd.openxmlformats-officedocument.wordprocessingml.document` response content entry. No route/logic change — `router.get('/download-pdf', verifyTokenByQuery, fnExportPDF)` line itself untouched. | +| `agent-hub/haven/diagrams/dev-loop.prime-mermaid.md` | New `add-docx-export-format` PENDING row (diagram-first, per `NodeBeforeCode`). | + +New test file `src/__tests__/services/createDocx.test.ts` (6 tests) — see +Command/Output below. + +## Command +``` +npx tsc --noEmit +``` +Output: clean, no errors. + +``` +npm test +``` +(run from repo root, copied verbatim from `doctrine/MEMORY.md`) + +### Output (verbatim, tail) +``` +Test Suites: 11 passed, 11 total +Tests: 60 passed, 60 total +Snapshots: 0 total +Time: 4.611 s, estimated 6 s +Ran all test suites. +``` +Baseline before this change was 10 suites / 54 tests (matches the last +sealed node on this branch, `add-json-export-format`) — this change adds +exactly 1 new suite / 6 new tests (`createDocx.test.ts`), zero +regressions elsewhere. + +``` +npm run build +``` +Output: clean, `tsc && npm run copy` completed with no errors. + +## Manual live verification (`npm run dev`, real Mongo/Atlas, Redis falls +back to in-memory — same setup as the JSON export node's precedent) + +Registered one throwaway account (`docxexport-check+@example.com`), +deleted via the self-delete endpoint afterward — same pattern as +`add-json-export-format-diff.md`. + +``` +POST /api/v1/auth/register → {"success":true,"message":"Đăng ký thành công",...} +GET /api/v1/auth/login → {"success":true,"data":{"token":"...", ...}} + +GET /api/v1/download-pdf?format=docx&token= +→ HTTP 200 +→ Content-Disposition: attachment; filename="docxexport-check+1788349877@example.com.docx" +→ Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document +→ Content-Length: 8548 +→ `file` confirms: "Microsoft Word 2007+" +→ Unzipped word/document.xml and grepped: real candidate email + ("docxexport-check+1788349877@example.com") found inside — confirms + actual content was rendered in, not an empty valid docx. + +GET /api/v1/download-pdf?token= (no format — regression check) +→ HTTP 200, Content-Type: application/pdf, Content-Length: 13469 +→ `file` confirms: "PDF document, version 1.4, 1 pages" + +GET /api/v1/download-pdf?format=json&token= (regression check) +→ HTTP 200, real aggregated JSON payload (same shape as before) + +DELETE /api/v1/candidate (Authorization: Bearer ) +→ {"success":true,"message":"Xoá tài khoản thành công","errors":{},"data":null} +``` +Dev server stopped after the check (`pkill -f "ts-node ./src/server.ts"`, +confirmed port 3001 free afterward). Test account fully cleaned up. Also +deleted the stray `src/public/pdf/.pdf` file the PDF +regression-check curl generated as a side effect of `createCV` — not left +in the repo. + +## Acceptance +| Criterion | Evidence | +|---|---| +| `GET /api/v1/download-pdf?format=docx` returns a real, valid .docx | Live curl above — real HTTP 200, correct `Content-Type`/`Content-Disposition`, `file` identifies it as "Microsoft Word 2007+" | +| The .docx actually contains the candidate's real data (not empty) | Unzipped `word/document.xml`, grepped the real test account's email — found | +| Reuses existing data-fetch (no duplicate query) | `candidate_me/index.ts` diff — `handlerGetAboutMe(email, lang)` called exactly once, same as before | +| No regression to the existing PDF/JSON paths | Live curls above — both still return correct content-type/shape | +| `npx tsc --noEmit` clean | Verbatim above | +| `npm test` all pass | Verbatim above — `Tests: 60 passed, 60 total` (54 baseline + 6 new) | +| `npm run build` clean | Verbatim above | +| Diagram-first (`NodeBeforeCode`) | `add-docx-export-format` PENDING row added to `dev-loop.prime-mermaid.md` before any `src/` edit | + +## Noticed, not done (out of scope) +- DOCX formatting is intentionally simple (headings + bullet lines) — + does not attempt pixel/layout parity with the PDF's HTML/CSS-styled + boxes. Acceptable per `SmallestDiff`: the acceptance criterion is a + real, correctly-populated, editable Word document, not visual parity + with the PDF. +- No `lang`-aware section titles beyond what `buildDocxContent` inherits + from the already-resolved `RECORD` fields (`resolveLocalizedText` runs + upstream in `candidate_me/index.ts` before this function ever sees the + data) — same behavior as the PDF path, not a new inconsistency. +- `format` still isn't validated via Joi (loose `=== 'docx'`/`=== 'json'` + checks, anything else falls back to PDF) — pre-existing pattern from + the JSON export node, not introduced here. + +## Seal gate +No outward-facing action yet (no commit/push) — `src/` diff shown above +(4 files touched: `package.json`, `src/services/createDocx.ts` (new), +`src/candidate_me/index.ts`, `src/routers/api/v1/index.ts`; plus +`package-lock.json` auto-updated by `npm install`) for operator review, +per seal gate. `/todo`'s stricter gate also applies this round: no +commit/push happens even after SEAL, deferred to an explicit follow-up +request. + +## Status +`sealed_pending_verifier` diff --git a/agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-plan.md b/agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-plan.md new file mode 100644 index 0000000..83d921c --- /dev/null +++ b/agent-hub/evidence/implementer/2026-09-02/add-docx-export-format-plan.md @@ -0,0 +1,71 @@ +# 2026-09-02 — add-docx-export-format (plan) + +- Worker: implementer +- Version: 0.1.0 +- Node: `add-docx-export-format` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- Task (verbatim): "#76" (GitHub issue #76 — Additional CV export formats + (DOCX / JSON)) + +## Node exists? No — created this session +No node for DOCX export existed on the active diagram. JSON export +(`add-json-export-format`) is already SEALED and explicitly deferred DOCX +as "Noticed, not done" in its own evidence note +(`evidence/implementer/2026-08-29/add-json-export-format-diff.md`). Added +`add-docx-export-format` as a new PENDING row per the flowchart's +`exist -- no --> draft[DRAFT node] --> pick` branch. + +## Scope — not ambiguous this round, no `AskUserQuestion` needed +Outside this loop, the operator already edited GitHub issue #76's body +directly: JSON marked done (commit `fc26b9f`), remaining scope narrowed to +DOCX only. `pick_next`'s "Task is ambiguous -> stop and ask" branch does +not apply — the scope decision already happened, just not through this +hub's evidence trail until now. + +## Plan +1. `npm install docx` — confirmed available (`npm view docx version` → + `9.7.1`), MIT-licensed, no native deps, matches the issue's own + suggested library. +2. New `src/services/createDocx.ts`: + - `buildDocxContent(RECORD)` — pure function, mirrors + `createPDF.ts`'s `getDataCandidate` + `_helper()` split: flattens the + aggregated candidate data (same shape `handlerGetAboutMe` already + produces) into a plain, framework-agnostic content model (heading + + lines per section) — no `docx` library types involved, so this half + stays unit-testable the same way `createPDF.test.ts` tests + `pageRender` today (no Puppeteer/browser mocking needed there either + — `docx`'s `Packer.toBuffer` is pure/sync computation, no external + process). + - `renderDocxDocument(content)` — thin second half, turns the content + model into an actual `docx` `Document`. + - `createCVDocx(data, res)` — `Packer.toBuffer()` + sends the buffer + with the correct `Content-Type` + (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) + and a `Content-Disposition` filename, mirroring `createCV`'s + PDF-sending shape in `createPDF.ts`. +3. `src/candidate_me/index.ts`'s `fnExportPDF`: add an + `if (req.query.format === 'docx') { await createCVDocx(data, res); return; }` + branch, same position/shape as the existing `format === 'json'` branch + — same single `handlerGetAboutMe` call reused, no new data-fetch. +4. `src/routers/api/v1/index.ts`: `/download-pdf` Swagger doc — extend the + `format` enum `[pdf, json]` → `[pdf, json, docx]`, add a + `application/vnd.openxmlformats-officedocument.wordprocessingml.document` + response content entry alongside the existing `application/pdf` / + `application/json` ones. + +## Code anchors (real, grepped) +- `src/candidate_me/index.ts:209-214` — existing `format === 'json'` + branch, insertion point for the new `docx` branch right after it. +- `src/services/createPDF.ts` — sibling module this new file mirrors the + shape of (`pageRender`/`getDataCandidate`/`createCV`). +- `src/routers/api/v1/index.ts` — existing `/download-pdf` Swagger block + (`format` enum currently `[pdf, json]`). +- `package.json:51-53` — `pdfkit`/`pug`/`puppeteer` deps sit here; `docx` + goes in the same dependencies block. + +## Blockers +None. `doctrine/MEMORY.md`'s test/build commands are filled in (only +lint/typecheck is `<>`, not needed for this task — no lint step +exists to run either way per that same file's note). + +## Status +Plan complete → proceeding to `implement`. diff --git a/agent-hub/evidence/verifier/2026-09-02/add-docx-export-format-seal.md b/agent-hub/evidence/verifier/2026-09-02/add-docx-export-format-seal.md new file mode 100644 index 0000000..ffd868f --- /dev/null +++ b/agent-hub/evidence/verifier/2026-09-02/add-docx-export-format-seal.md @@ -0,0 +1,81 @@ +# 2026-09-02 — add-docx-export-format (seal) + +- Worker: verifier (subagent, dispatched via Agent tool) +- Node: `add-docx-export-format` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- New PM status: SEALED + +## Reasoning +Evidence note only was read (`evidence/implementer/2026-09-02/add-docx-export-format-diff.md`), +per `EvidenceOnly` — `src/` files (`createDocx.ts`, `candidate_me/index.ts`, +`routers/api/v1/index.ts`) were not opened directly, nor was `git diff` run. + +Test command matches `doctrine/MEMORY.md` verbatim (`npm test`, run from +repo root) — not an invented command. Output cited is the standard Jest +summary tail (`Test Suites: 11 passed, 11 total` / `Tests: 60 passed, 60 +total` / `Time: 4.611 s` / `Ran all test suites.`) with no `...` or +"truncated" marker — same tail-only citation pattern already accepted on +prior SEALED nodes (`add-visit-tracking`, `fix-visit-model-missing-id`). +Baseline of 54 tests / 10 suites matches the PM status table's own record +of the last sealed node on this branch; this note's delta is exactly +1 +suite / +6 tests (`createDocx.test.ts`), consistent and not surprising for +a new-module feature. + +Acceptance criteria walked one at a time, all cited: +1. `GET /api/v1/download-pdf?format=docx` returns a real, valid `.docx` — + live curl cited: HTTP 200, `Content-Type: + application/vnd.openxmlformats-officedocument.wordprocessingml.document`, + `Content-Disposition` filename, and `file` command output identifying it + as "Microsoft Word 2007+" (not just a Content-Type header claim). +2. The `.docx` actually contains the candidate's real data — cited: word + archive unzipped, `word/document.xml` grepped, the specific throwaway + test account's email (`docxexport-check+1788349877@example.com`) found + inside — rules out an empty-but-valid document. +3. Reuses existing data-fetch, no duplicate query — cited: diff table row + states `handlerGetAboutMe(email, lang)` called exactly once, same call + already made for PDF/JSON, branch inserted at the same point as the + existing `format === 'json'` check. +4. No regression to existing PDF/JSON paths — cited: same live-verification + session re-curled both `?format=json` (real aggregated JSON, same shape) + and no-format (`application/pdf`, `file` confirms "PDF document, version + 1.4, 1 pages") after the docx branch was added. +5. `npx tsc --noEmit` clean — cited verbatim ("clean, no errors"). +6. `npm test` all pass — cited verbatim, `60 passed, 60 total` (54 baseline + + 6 new, matches PM table baseline). +7. `npm run build` clean — cited verbatim (`tsc && npm run copy` completed, + no errors). +8. Diagram-first (`NodeBeforeCode`) — plan note (read for context) shows + the `add-docx-export-format` PENDING row was drafted before any `src/` + edit, per the `exist -- no --> draft` branch; PM table confirms the row + existed pre-SEAL. + +Forbidden-state scan (5 states, `agent-hub/CLAUDE.md`): +- `ADHOC_WORK` — no hit; node exists on the diagram (was PENDING, drafted + first per the plan note). +- `NO_EVIDENCE` — no hit; both plan and diff notes present. +- `EDIT_UNVERIFIED` — no hit; `npm test`/`npm run build`/`tsc --noEmit` + outputs are read back verbatim, plus an additional live manual + verification pass (curl + `file` + unzip/grep) beyond what the recipe + requires. +- `CODE_IN_HAVEN` — no hit; the only `haven/`-tree write is the diagram's + PENDING/SEALED row text, no `.ts`/`.js`/`.sh` leaked in. +- `DIAGRAM_DRIFT` — no hit; PM status is being updated in this same pass to + match the shipped (but uncommitted) code state. + +Seal gate: note's own "Seal gate" section confirms no commit/push happened +— diff shown for operator review only, correctly not outward-facing per +`/todo`'s stricter gate. Confirmed, not re-litigated. + +Proportion (`SmallestDiff`): 4 `src/` files touched (1 new service module, +1 branch added to an existing controller, 1 Swagger-only doc edit, plus +`package.json`/`package-lock.json` for the new dependency) plus 1 new test +file — proportionate to "add a new export format," mirrors the existing +`format === 'json'` branch shape exactly, no unrelated refactor bundled in. + +## Re-run scope +Per the recipe's 2026-09-02 "Re-run scope" addendum: this node is not +outward-facing (no commit/push per the note) and not a `/release` gate, the +note's command matches doctrine, and its output is not truncated — so this +verdict is taken straight off the note's citations, no independent +`npm test`/`npm run build` re-run performed. + +No forbidden-state hits. Verdict: SEAL. diff --git a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md index 0e73e9d..b828716 100644 --- a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md +++ b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md @@ -49,6 +49,7 @@ flowchart TD | Node | State | Notes | |---|---|---| +| `add-docx-export-format` | SEALED | Feature, GitHub issue #76 remainder — the sibling `add-json-export-format` (SEALED, `evidence/implementer/2026-08-29/add-json-export-format-diff.md`) deliberately deferred DOCX as "Noticed, not done", needing a library decision + new template. Operator already resolved scope outside this loop (edited issue #76's body directly: JSON marked done, remaining scope narrowed to DOCX only) — no `AskUserQuestion` needed this round, scope is unambiguous. Added `docx` npm package (`9.7.1`, MIT, no native deps), new `src/services/createDocx.ts` (`buildDocxContent`/`renderDocxDocument`/`createCVDocx`, mirroring `createPDF.ts`'s `pageRender`/`createCV` split so the content logic stays unit-testable without Puppeteer-style mocking), wired `?format=docx` into `candidate_me/index.ts`'s `fnExportPDF` alongside the existing `format=json` branch (same single `handlerGetAboutMe` call reused, no new data-fetch), updated the `/download-pdf` Swagger doc's `format` enum + response content types. Evidence: `evidence/implementer/2026-09-02/add-docx-export-format-diff.md`. **SEALED 2026-09-02**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly, `git diff` not run). All 8 acceptance rows carry specific citations: live curl of `GET /download-pdf?format=docx` returning HTTP 200 with the correct `Content-Type`/`Content-Disposition`, `file` confirming "Microsoft Word 2007+"; the generated `.docx` unzipped and `word/document.xml` grepped for the real throwaway test account's email, confirming actual content (not an empty valid file); same live session re-curled both the no-format PDF path and `?format=json` afterward as a regression check, both still correct; `handlerGetAboutMe` cited as called exactly once (no duplicate data-fetch). Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 11 passed, 11 total` / `Tests: 60 passed, 60 total` — 54/10 baseline (matching the last sealed node on this branch) plus exactly +1 suite/+6 tests for the new `createDocx.test.ts`, no truncation markers. `npx tsc --noEmit` and `npm run build` both cited clean. Diagram-first confirmed via the plan note (PENDING row drafted before any `src/` edit). Diff proportionate to scope (1 new service module + 1 controller branch + 1 Swagger-doc edit + new dependency + 1 new test file) — no unrelated refactor bundled in. No `src`/`.ts` leaked into `haven/`. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. See `evidence/verifier/2026-09-02/add-docx-export-format-seal.md`. | | `agent-hub-token-cleanup-20260830` | SEALED | Follow-on from a same-day session working in the sibling `vue-resume-web` frontend repo, which found this repo's hub has the same pattern via its own `/hub-tokens`. Operator: "hãy fix luôn cho backend". 3-part chore, no `src/` touched: (1) archived the 7 SEALED nodes dated 2026-08-29/2026-08-30 out of the active diagram into `dev-loop-archive.md` — active file 24,649B → 9,282B before this row itself was appended (self-referential: this row's own text adds ~1.2KB, landing the file at 10,448B), still comfortably under the 15KB threshold. (2) `.claude/skills/boot/SKILL.md` step 2: stopped instructing an explicit `Read`/`cat` of `agent-hub/CLAUDE.md` (harness auto-injects it once step 1 touches `agent-hub/` — was a real duplicate-read observed in the frontend repo's session). (3) same skill's step 7: added `find -maxdepth 2 -type f -name "*.md" -exec ls -t {} +` guidance instead of leaving it unspecified (`-maxdepth 2` because this repo's evidence layout uses `/` subfolders, unlike the frontend repo's flat layout). `npm test` → `54 passed, 54 total` (unchanged baseline). `npm run build` → clean `tsc`, no errors. Evidence: `evidence/implementer/2026-08-30/agent-hub-token-cleanup-diff.md`. **SEALED 2026-08-30**: independent verifier subagent read the evidence note only (not the diff, per `EvidenceOnly`), then independently re-ran everything: `git status`/`git diff --stat` confirmed the 3-file scope (`dev-loop.prime-mermaid.md`, `dev-loop-archive.md`, `.claude/skills/boot/SKILL.md`) with zero `src/` touched; spot-diffed the first and last of the 7 archived rows (`add-open-to-work-status`, `add-project-cert-award-image-upload`) byte-for-byte between what was removed from the active file and what was appended to the archive — identical; re-ran `npm test` (10 suites, 54/54 passed) and `npm run build` (clean `tsc`) myself, matching the note exactly; read the `.claude/skills/boot/SKILL.md` diff directly and confirmed both described changes (step 2 guard against the duplicate `CLAUDE.md` read, step 7's `find -maxdepth 2` swap) plus the new >15KB Rules bullet are genuinely present. **Correction to the note's own cited number**: `wc -c` on the current file returns `10448`, not the note's cited `9282` — traced to a self-reference: the note's byte count was necessarily measured before this same PM-status row (which describes that very byte count) was appended, adding ~1.2KB after the fact. Non-blocking: `10448` is still well under the 15KB threshold, so the underlying acceptance criterion (diagram back under threshold) holds on independently-obtained evidence, just with a corrected number. No forbidden-state hits. No commit/push happened (working tree still dirty) — correctly deferred to `/ship` or manual commit per the note's own Seal gate section. See `evidence/verifier/2026-08-30/agent-hub-token-cleanup-seal.md`. | | `fix-chrome-executable-path` | PENDING | `src/services/createPDF.ts:14-25` — Chrome executable path hardcoded, breaks PDF export in CI/Docker. See Traps in `doctrine/domains/PROJECT.md`. First candidate node. | | `fix-idor-broken-access-control` | PENDING | **Critical.** All CRUD APIs for candidate_profile (education/experience/award/certificate/project/reference/generalInformation) + `candidate.service.ts` + `fnExportPDF` never cross-check `candidateId`/`_id` against `req.user._id` (JWT) — they trust client-supplied `req.body.candidateId`/`_id`. Live-tested confirmed: User B could read/delete/edit User A's data, overwrite A's profile. Root cause: `verifyToken.middleware.ts` sets `req.user` but nothing cross-checks it. Found while testing the full API (task: "test the whole API again"). | diff --git a/package-lock.json b/package-lock.json index 568c9c7..819f01e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "bcrypt": "^5.1.1", "body-parser": "^1.20.2", "cors": "^2.8.5", + "docx": "^9.7.1", "dotenv": "^16.4.5", "exit-hook": "^4.0.0", "express": "^4.19.2", @@ -4815,6 +4816,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -5230,6 +5237,38 @@ "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", "license": "MIT" }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -6810,6 +6849,16 @@ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "license": "ISC" }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6962,6 +7011,12 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -8587,6 +8642,60 @@ "promise": "^7.0.1" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -8688,6 +8797,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -8971,6 +9089,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9190,6 +9314,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10102,6 +10244,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -10795,6 +10943,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -10906,6 +11063,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -12409,6 +12572,24 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7baa002..49d2a13 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "bcrypt": "^5.1.1", "body-parser": "^1.20.2", "cors": "^2.8.5", + "docx": "^9.7.1", "dotenv": "^16.4.5", "exit-hook": "^4.0.0", "express": "^4.19.2", diff --git a/src/__tests__/services/createDocx.test.ts b/src/__tests__/services/createDocx.test.ts new file mode 100644 index 0000000..878c9bc --- /dev/null +++ b/src/__tests__/services/createDocx.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for services/createDocx.ts — issue #76 (DOCX export). + * + * `buildDocxContent` is pure (no `docx` library types), same testing + * approach as `createPDF.test.ts`'s `pageRender`. `renderDocxDocument` + + * `Packer.toBuffer` are also exercised directly (no mocking needed — + * unlike Puppeteer, `docx` does no I/O, so this is a real, fast check + * that the actual library wiring produces a valid .docx). + */ +import { Packer } from 'docx'; +import { buildDocxContent, renderDocxDocument } from '@/services/createDocx'; + +describe('buildDocxContent', () => { + it('builds contact line, introduction, and section content from aggregated candidate data', () => { + const content = buildDocxContent({ + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '0900000000', + address: 'HCMC', + introduction: 'Backend developer', + generalInformation: { + career: 'Backend Developer', + careerGoal: 'Trở thành Tech Lead trong 3 năm tới', + professionalSkills: [{ name: 'Node.js' }, { name: 'TypeScript' }], + personalSkills: [{ name: 'Teamwork' }], + }, + experiences: [ + { position: 'Backend Engineer', company: 'Acme', startDate: 1600000000000, endDate: null, isCurrent: true, description: 'Built APIs' }, + ], + educations: [], + projects: [], + certificates: [], + awards: [], + references: [], + }); + + expect(content.fullName).toBe('John Doe'); + expect(content.contactLine).toContain('john@example.com'); + expect(content.contactLine).toContain('0900000000'); + expect(content.introduction).toBe('Backend developer'); + + const careerSection = content.sections.find((s) => s.heading === 'Định hướng nghề nghiệp'); + expect(careerSection?.lines).toEqual(['Nghề nghiệp: Backend Developer', 'Mục tiêu nghề nghiệp: Trở thành Tech Lead trong 3 năm tới']); + + const skillsSection = content.sections.find((s) => s.heading === 'Kỹ năng'); + expect(skillsSection?.lines.join(' | ')).toContain('Node.js, TypeScript'); + expect(skillsSection?.lines.join(' | ')).toContain('Teamwork'); + + const expSection = content.sections.find((s) => s.heading === 'Kinh nghiệm làm việc'); + expect(expSection?.lines[0]).toContain('Backend Engineer'); + expect(expSection?.lines[0]).toContain('Acme'); + // No endDate -> just the start month/year, regardless of isCurrent — + // same `formatRange`/PDF-sibling `_layoutItem`'s `getTime` behavior: + // `isCurrent` only matters once endDate is actually present. + expect(expSection?.lines[0]).toContain('Built APIs'); + }); + + it('renders "Hiện tại" for an ongoing item when both startDate and endDate are set', () => { + const content = buildDocxContent({ + firstName: 'A', + lastName: 'B', + experiences: [{ position: 'Engineer', company: 'Acme', startDate: 1600000000000, endDate: 1700000000000, isCurrent: true, description: '' }], + }); + const expSection = content.sections.find((s) => s.heading === 'Kinh nghiệm làm việc'); + expect(expSection?.lines[0]).toContain('Hiện tại'); + }); + + it('omits every section that has no data (empty CV)', () => { + const content = buildDocxContent({ firstName: 'Jane', lastName: 'Roe', email: 'jane@example.com' }); + + expect(content.fullName).toBe('Jane Roe'); + expect(content.sections).toEqual([]); + }); + + it('handles generalInformation given as an array (raw Mongoose find() shape)', () => { + const content = buildDocxContent({ + firstName: 'A', + lastName: 'B', + generalInformation: [{ career: 'Tester' }], + }); + + const careerSection = content.sections.find((s) => s.heading === 'Định hướng nghề nghiệp'); + expect(careerSection?.lines).toEqual(['Nghề nghiệp: Tester']); + }); +}); + +describe('renderDocxDocument + Packer (real .docx generation, no mocks)', () => { + it('produces a real, non-empty .docx (zip) buffer for a populated CV', async () => { + const content = buildDocxContent({ + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + introduction: 'Backend developer', + experiences: [{ position: 'Engineer', company: 'Acme', startDate: 1600000000000, endDate: 1700000000000, isCurrent: false, description: 'Did things' }], + }); + const doc = renderDocxDocument(content); + const buffer = await Packer.toBuffer(doc); + + expect(buffer.length).toBeGreaterThan(0); + // .docx is a zip archive — real zip files start with the "PK" magic bytes. + expect(buffer.subarray(0, 2).toString('ascii')).toBe('PK'); + }); + + it('produces a valid .docx even for an empty CV (no sections)', async () => { + const content = buildDocxContent({ firstName: 'Jane', lastName: 'Roe' }); + const doc = renderDocxDocument(content); + const buffer = await Packer.toBuffer(doc); + + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.subarray(0, 2).toString('ascii')).toBe('PK'); + }); +}); diff --git a/src/candidate_me/index.ts b/src/candidate_me/index.ts index 78dfa7b..3b7e677 100644 --- a/src/candidate_me/index.ts +++ b/src/candidate_me/index.ts @@ -11,6 +11,7 @@ import geoip from 'geoip-lite'; import { formatReturn, handleError } from '@/utils'; import { formatReturnFailed } from '@/services'; import { createCV } from '@/services/createPDF'; +import { createCVDocx } from '@/services/createDocx'; import * as MODEL from '@/models'; // Localized ({vi, en}) fields get resolved down to a single string for @@ -213,6 +214,13 @@ export const fnExportPDF = async (req: Request, res: Response, next: NextFunctio return; } + // ?format=docx (issue #76, remainder) — same aggregated data, packed + // as a .docx instead of rendered to PDF. + if (req.query.format === 'docx') { + await createCVDocx(data, res); + return; + } + await createCV(data, res); } catch (err) { handleError(err, next, (req as any).lang); diff --git a/src/routers/api/v1/index.ts b/src/routers/api/v1/index.ts index 164f5c5..30f1653 100644 --- a/src/routers/api/v1/index.ts +++ b/src/routers/api/v1/index.ts @@ -59,12 +59,12 @@ router.use('/certificate', verifyToken, routeCertificate); * required: false * schema: * type: string - * enum: [pdf, json] + * enum: [pdf, json, docx] * default: pdf - * description: Response format. `json` returns the same aggregated candidate data used to render the PDF, as JSON, instead of a PDF file. + * description: Response format. `json` returns the same aggregated candidate data used to render the PDF, as JSON. `docx` returns the same data as an editable Word document, instead of a PDF file. * responses: * 200: - * description: PDF file stream, or the candidate's aggregated data as JSON when `format=json` + * description: PDF file stream, the candidate's aggregated data as JSON when `format=json`, or a .docx file when `format=docx` * content: * application/pdf: * schema: @@ -73,6 +73,10 @@ router.use('/certificate', verifyToken, routeCertificate); * application/json: * schema: * type: object + * application/vnd.openxmlformats-officedocument.wordprocessingml.document: + * schema: + * type: string + * format: binary */ router.get('/download-pdf', verifyTokenByQuery, fnExportPDF); diff --git a/src/services/createDocx.ts b/src/services/createDocx.ts new file mode 100644 index 0000000..a676c54 --- /dev/null +++ b/src/services/createDocx.ts @@ -0,0 +1,214 @@ +/** + * Author: Đạt Võ - https://github.com/datvt243 + * Date: `--/--` + * Description: DOCX CV export (issue #76, remainder after JSON export + * shipped separately — see createPDF.ts's `createCV`/`pageRender` for + * the sibling PDF path this mirrors). + * + * Split the same way as createPDF.ts: `buildDocxContent` is a pure, + * framework-agnostic content model (no `docx` library types) built from + * the same aggregated candidate data `handlerGetAboutMe` already + * assembles — unit-testable on its own, same as `pageRender`. + * `renderDocxDocument` turns that plain model into an actual `docx` + * `Document`. `createCVDocx` is the thin I/O wrapper that packs it to a + * buffer and sends it. + */ +import { Response } from 'express'; +import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'; + +export interface DocxSection { + heading: string; + lines: string[]; +} + +export interface DocxContent { + email: string; + fullName: string; + contactLine: string; + introduction: string; + sections: DocxSection[]; +} + +const formatDate = (val: number | null | undefined): string => { + if (!val) return ''; + const date = new Date(val); + const m = date.getMonth() + 1; + const y = date.getFullYear(); + return `${m < 10 ? `0${m}` : m}/${y}`; +}; + +const formatRange = (startDate: number, endDate: number | null, isCurrent: boolean): string => { + const start = formatDate(startDate); + if (!endDate) return start; + const end = isCurrent ? 'Hiện tại' : formatDate(endDate); + return `${start} - ${end}`; +}; + +/** + * Pure content-model builder — same input shape as + * `createPDF.ts`'s `getDataCandidate`/`pageRender` (the aggregated + * candidate record `handlerGetAboutMe` produces). + */ +export const buildDocxContent = (RECORD: Record = {}): DocxContent => { + const { + firstName = '', + lastName = '', + phone = '', + email = '', + address = '', + introduction = '', + socialMedia = {}, + generalInformation: generalInformationRaw, + educations = [], + experiences = [], + projects = [], + references = [], + certificates = [], + awards = [], + } = RECORD; + + const generalInformation = Array.isArray(generalInformationRaw) ? generalInformationRaw[0] || {} : generalInformationRaw || {}; + const { github = '', linkedin = '', website = '' } = socialMedia; + + const contactLine = [address, email, phone].filter(Boolean).join(' - '); + const sections: DocxSection[] = []; + + // Career / career goal + const { career = '', careerGoal = '' } = generalInformation; + if (career || careerGoal) { + const lines: string[] = []; + if (career) lines.push(`Nghề nghiệp: ${career}`); + if (careerGoal) lines.push(`Mục tiêu nghề nghiệp: ${careerGoal}`); + sections.push({ heading: 'Định hướng nghề nghiệp', lines }); + } + + // Skills + const { personalSkills = [], professionalSkills = [] } = generalInformation; + if (personalSkills.length || professionalSkills.length) { + const lines: string[] = []; + if (professionalSkills.length) lines.push(`Kỹ năng chuyên môn: ${professionalSkills.map((s: any) => s.name).join(', ')}`); + if (personalSkills.length) lines.push(`Kỹ năng cá nhân: ${personalSkills.map((s: any) => s.name).join(', ')}`); + sections.push({ heading: 'Kỹ năng', lines }); + } + + // Experience + if (experiences.length) { + sections.push({ + heading: 'Kinh nghiệm làm việc', + lines: experiences.map((e: any) => { + const range = formatRange(e.startDate, e.endDate, e.isCurrent); + return `${e.position} — ${e.company} (${range})${e.description ? `: ${e.description}` : ''}`; + }), + }); + } + + // Projects + if (projects.length) { + sections.push({ + heading: 'Dự án', + lines: projects.map((p: any) => { + const range = formatRange(p.startDate, p.endDate, p.isWorking); + return `${p.name} — ${p.position || ''} (${range})${p.description ? `: ${p.description}` : ''}`; + }), + }); + } + + // Education + if (educations.length) { + sections.push({ + heading: 'Học vấn', + lines: educations.map((e: any) => { + const range = formatRange(e.startDate, e.endDate, e.isCurrent); + return `${e.major} — Trường: ${e.school} (${range})${e.description ? `: ${e.description}` : ''}`; + }), + }); + } + + // Awards + if (awards.length) { + sections.push({ + heading: 'Giải thưởng', + lines: awards.map((a: any) => `${a.name} — Đơn vị: ${a.organization} (${formatDate(a.issueDate)})${a.description ? `: ${a.description}` : ''}`), + }); + } + + // Certificates + if (certificates.length) { + sections.push({ + heading: 'Chứng chỉ', + lines: certificates.map((c: any) => { + const range = formatRange(c.startDate, c.endDate, c.isNoExpiration); + return `${c.name} — Nơi cấp: ${c.organization} (${range})${c.description ? `: ${c.description}` : ''}`; + }), + }); + } + + // Foreign languages + const foreignLanguages = generalInformation.foreignLanguages || []; + if (foreignLanguages.length) { + sections.push({ + heading: 'Ngoại ngữ', + lines: [foreignLanguages.map((l: any) => `${l.language} (${l.level})`).join(', ')], + }); + } + + // References + if (references.length) { + sections.push({ + heading: 'Người tham khảo', + lines: references.map((r: any) => `${r.fullName} — ${r.position} tại ${r.company} — Tel: ${r.phone}`), + }); + } + + const website_ = [github, linkedin, website].filter(Boolean).join(' - '); + if (website_) { + sections.unshift({ heading: '', lines: [website_] }); + } + + return { + email: email || 'resume', + fullName: `${firstName} ${lastName}`.trim(), + contactLine, + introduction, + sections, + }; +}; + +/** Turns the plain content model into an actual `docx` `Document`. */ +export const renderDocxDocument = (content: DocxContent): Document => { + const children: Paragraph[] = []; + + children.push(new Paragraph({ heading: HeadingLevel.TITLE, children: [new TextRun({ text: content.fullName.toUpperCase(), bold: true })] })); + if (content.contactLine) children.push(new Paragraph({ text: content.contactLine })); + if (content.introduction) children.push(new Paragraph({ text: content.introduction, spacing: { after: 200 } })); + + for (const section of content.sections) { + if (section.heading) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, text: section.heading.toUpperCase(), spacing: { before: 200 } })); + } + for (const line of section.lines) { + children.push(new Paragraph({ text: line, bullet: section.heading ? { level: 0 } : undefined })); + } + } + + return new Document({ sections: [{ children }] }); +}; + +/** I/O wrapper: builds the content model, renders it, packs to a buffer, sends it. */ +export const createCVDocx = async (data: Record, res: Response) => { + try { + const content = buildDocxContent(data); + const doc = renderDocxDocument(content); + const buffer = await Packer.toBuffer(doc); + + res.setHeader('Content-Disposition', `attachment; filename="${content.email}.docx"`); + res.contentType('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.send(buffer); + } catch (error) { + res.status(500).send({ + status: false, + message: 'Xảy ra lỗi, không thể tạo file DOCX', + error, + }); + } +}; From 5d92927a6a41b6bef165de6b728a1b9a5f102cc5 Mon Sep 17 00:00:00 2001 From: _david Date: Wed, 2 Sep 2026 23:41:58 +0700 Subject: [PATCH 5/8] chore(agent-hub): sync worker-runs.log tracking + /browser-debugger command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches the 2026-09-02 kit feature (hub_bytes_before/after + worker-runs.log logging) into pick_next.md/implement.md/verify_seal.md/evidence/README.md/ hub-tokens/SKILL.md — this hub was missing it entirely. Adds .claude/commands/browser-debugger.md (spec existed in agent-hub-init's kit/custom-commands/, wasn't generated here yet). Left untouched (flagged for operator decision, not auto-applied): - dev-loop.prime-mermaid.md is 19598B, over the 15KB /hub-tokens threshold — an archive pass is due but the diagram is a PROTECTED file, not something /sys edits on its own. - kit/custom-commands/release.md was NOT generated as .claude/commands/release.md — this project already has a more capable .claude/skills/release/SKILL.md (worktree-isolated build gate, real GitHub required_status_checks polling, first-release handling) that would collide with the generic spec under the same /release name. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0183N1xtARurw92SWfHL6cL9 --- .claude/commands/browser-debugger.md | 69 +++++++++++++++++++ .claude/skills/hub-tokens/SKILL.md | 4 +- agent-hub/evidence/README.md | 29 ++++++++ .../workers/implementer/recipes/implement.md | 8 +++ .../workers/implementer/recipes/pick_next.md | 8 ++- .../workers/verifier/recipes/verify_seal.md | 19 +++++ 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 .claude/commands/browser-debugger.md diff --git a/.claude/commands/browser-debugger.md b/.claude/commands/browser-debugger.md new file mode 100644 index 0000000..8b4b195 --- /dev/null +++ b/.claude/commands/browser-debugger.md @@ -0,0 +1,69 @@ +--- +description: "Open a browser (Chrome/Firefox/Edge) with remote-debugging port 9888 so Claude can inspect/control it — optionally starting `npm run dev` first and pointing the browser at it. Usage: /browser-debugger [--browser=chrome|firefox|edge] [--run-dev]. Read-only dev utility, no code changes, no seal gate." +argument-hint: "[--browser=chrome|firefox|edge] [--run-dev]" +--- + +# /browser-debugger — open a browser with CDP debugging on port 9888 + +Local dev-environment utility only — never touches project source code, +never commits/pushes/deploys. `gate: none`, same class as `/hub-tokens`: no +seal gate, no evidence note, no worker identity needed. + +## Steps +1. **Parse args.** `--browser=` — one of `chrome`/`chromium`, + `firefox`, `edge` (case-insensitive), default `chrome` if omitted or + malformed — don't fail on a typo'd value, just tell the operator you + fell back to the default. `--run-dev` — boolean flag, no value. +2. **If `--run-dev` was passed:** + a. Check `package.json` at the repo root for a `scripts.dev` entry. Not + present → skip starting anything, note "no `dev` script in + package.json" in the final report, fall through to step 3 with the + default target URL (`http://localhost:3000`). + b. Present → detect the package manager from the lockfile + (`package-lock.json` → `npm`, `yarn.lock` → `yarn`, + `pnpm-lock.yaml` → `pnpm`; default `npm` if none found), then start + ` run dev` in the background (`run_in_background: true`), + capture its output. + c. Poll the dev server's own output for a local URL (common patterns: + "Local:", "http://localhost:", "ready on") for up to ~15s. Found → + use that URL as the target. Not found in time → fall back to + `http://localhost:3000`, note in the report that the URL was + guessed, not read from actual output. +3. **Resolve the browser executable** for `--browser` + the current OS + (macOS/Linux/Windows) — real installed paths, don't assume: + - macOS: `/Applications/Google Chrome.app/...`, + `/Applications/Firefox.app/...`, + `/Applications/Microsoft Edge.app/...`. + - Linux: `google-chrome`/`chromium`, `firefox`, `microsoft-edge` on + `$PATH`. + - Windows (git-bash/WSL interop): standard `Program Files` install + paths for each. + Not found → report the real error (which path/command was tried) and + stop — don't silently fall back to a different browser than requested. +4. **Launch** with remote debugging on port **9888** + (`--remote-debugging-port=9888` for Chrome/Edge/Chromium; + `--start-debugger-server 9888` for Firefox), pointed at the URL from + step 2, as a background process. Capture the PID. +5. **Verify the debug port is actually responding** — + `curl -fsS http://localhost:9888/json/version` (or equivalent), read + the output back. Fails → report the real error, don't claim success. +6. **Report — confirm message, exactly these lines:** + ``` + 🌐 Browser: (PID ) + 🔗 Debug port: localhost:9888 — + 📍 URL: + 🚀 Dev server: run dev | already running | no dev script found | skipped (--run-dev not passed)> + ``` + +## Failure branches +| Failure | Handling | +|---|---| +| Requested browser not installed / path not found | Report the exact path/command tried, stop — don't silently substitute another browser | +| Port 9888 already in use | Report it (`lsof -i :9888` output), ask whether to reuse the existing instance or stop — don't kill another process unasked | +| `--run-dev` passed but no `dev` script in `package.json` | Note it in the report, open the browser at the default URL instead of failing the whole command | +| Dev server starts but never prints a detectable local URL | Fall back to `http://localhost:3000`, say so plainly in the report — don't guess a wrong port silently | + +## Runtime +`/browser-debugger [--browser=chrome|firefox|edge] [--run-dev]`. Purely +local: no git, no network call other than the local CDP check, no +project-file edits. Safe to re-run. diff --git a/.claude/skills/hub-tokens/SKILL.md b/.claude/skills/hub-tokens/SKILL.md index cf95603..b507bc8 100644 --- a/.claude/skills/hub-tokens/SKILL.md +++ b/.claude/skills/hub-tokens/SKILL.md @@ -54,10 +54,12 @@ echo "pick_next/verify_seal — large size here is not a recurring cost):" ARCHIVE_B=$(find "$HUB/haven/diagrams" -type f -iname "*archive*" 2>/dev/null -exec cat {} + 2>/dev/null | wc -c | tr -d ' ') EVI_I_B=$(bytes_glob "$HUB/evidence/implementer") EVI_V_B=$(bytes_glob "$HUB/evidence/verifier") +TODO_LOG_B=$(wc -c < "$HUB/evidence/worker-runs.log" 2>/dev/null | tr -d ' '); TODO_LOG_B=${TODO_LOG_B:-0} row "haven/diagrams/*archive*" "$ARCHIVE_B" row "evidence/implementer/" "$EVI_I_B" row "evidence/verifier/" "$EVI_V_B" -COLD_B=$(( ARCHIVE_B + EVI_I_B + EVI_V_B )) +row "evidence/worker-runs.log" "$TODO_LOG_B" +COLD_B=$(( ARCHIVE_B + EVI_I_B + EVI_V_B + TODO_LOG_B )) row "= cold storage total" "$COLD_B" echo TOTAL_B=$(( SESSION_B + COLD_B )) diff --git a/agent-hub/evidence/README.md b/agent-hub/evidence/README.md index 4a5d489..be48788 100644 --- a/agent-hub/evidence/README.md +++ b/agent-hub/evidence/README.md @@ -6,12 +6,16 @@ evidence/implementer//-plan.md evidence/implementer//-diff.md evidence/verifier//-{seal|reopen}.md +evidence/worker-runs.log ``` Date as `YYYY-mm-dd`, slug kebab-case from the task name. ## Format — implementer note - Title (date - node) · Worker · Version · Node (points to diagram) · Task (verbatim prompt) +- `## Hub bytes before` — [added 2026-09-02] byte count measured at + `pick_next` step 7, before the diff starts — the verifier reads this + back when writing `worker-runs.log`, don't skip it - `## Diff` — files | file | why | - `## Command` — exact command from `doctrine/MEMORY.md` - `## Output` — verbatim, no paraphrasing @@ -27,6 +31,31 @@ Date as `YYYY-mm-dd`, slug kebab-case from the task name. (PENDING/SEALED/REOPEN) - `## Reasoning` — cite evidence for each criterion - `## Missing` — only present on REOPEN +- `## Re-run` — [added 2026-09-02] `none`/`partial`/`full`, declared + honestly per what was actually done (see "Re-run scope" in + `recipes/verify_seal.md`), with a reason if not `none`. The verifier + reads this back when writing `worker-runs.log` — not decorative. + +## Format — worker-runs.log +- [added 2026-09-02] NOT a narrative note like the ones above — an + **append-only file, 1 line per implementer or verifier pass that ends**. + Written by `pick_next.md`/`implement.md`/`verify_seal.md` themselves. +- Two line shapes: + - Implementer (only on `blocked`/`failed`, never reaching the verifier): + ` role=implementer outcome=blocked|failed node= + hub_bytes_before= verifier_rerun=n/a` + - Verifier (every verdict — SEAL or REOPEN): + ` role=verifier outcome=SEAL|REOPEN node= + rerun=none|partial|full hub_bytes_before= hub_bytes_after=` + `hub_bytes_*` use this hub's own `/hub-tokens` "per-session total" + formula. +- One line per round-trip, not one per node's whole lifetime — a node + REOPENed 3 times has 3 verifier lines sharing the same `node=`. +- **Purpose**: real, non-inferred data to spot patterns later — repeated + REOPEN, a verifier re-running despite the audit-only default, an + unusual jump in hub size between two runs. Not a real token count. +- Cold storage — not re-read wholesale every worker session, only opened + when someone audits patterns on purpose. NEVER delete a line. ## The three rules of this directory 1. **VERBATIM, ALWAYS** — no claim without real cited evidence. diff --git a/agent-hub/haven/workers/implementer/recipes/implement.md b/agent-hub/haven/workers/implementer/recipes/implement.md index 2718e9c..84a5a4e 100644 --- a/agent-hub/haven/workers/implementer/recipes/implement.md +++ b/agent-hub/haven/workers/implementer/recipes/implement.md @@ -24,6 +24,14 @@ `doctrine/domains/PROJECT.md`), consider adding it there or to `MEMORY.md`. 10. Write to `evidence/` following the format in `evidence/README.md`. +11. [added 2026-09-02] ONLY when the result is `blocked` or `failed` + (never reaches the verifier) — append one line to + `evidence/worker-runs.log` (create the file if missing): + `role=implementer outcome=blocked|failed node= + hub_bytes_before= verifier_rerun=n/a`. When + the result is `sealed_pending_verifier`, do NOT log here — the + verifier logs both sides (before/after) in `verify_seal.md` once it + has a real verdict. ## Hard rules honored `SmallestDiff` | `TestsBeforeDone` | `EvidencePerAction` | `NoSilentFailure` | diff --git a/agent-hub/haven/workers/implementer/recipes/pick_next.md b/agent-hub/haven/workers/implementer/recipes/pick_next.md index bf34fe6..e1917d8 100644 --- a/agent-hub/haven/workers/implementer/recipes/pick_next.md +++ b/agent-hub/haven/workers/implementer/recipes/pick_next.md @@ -27,7 +27,13 @@ 6. Declare blockers: if a needed command is still `<>` in `doctrine/MEMORY.md` (currently: lint/typecheck), report blocked instead of guessing. -7. Evidence: write `evidence/implementer//-plan.md`. +7. [added 2026-09-02] Measure `hub_bytes_before` — the total bytes across + the 5 categories `/hub-tokens` calls the "per-session total" (root + files, `doctrine/`, the active `haven/diagrams/`, 2 worker bundles). + Record this number in the evidence note at step 8 — the verifier reads + it back to compute the hub-size diff in `worker-runs.log`. +8. Evidence: write `evidence/implementer//-plan.md`, including + the line `## Hub bytes before: ` from step 7. ## Hard rules honored `NodeBeforeCode` | `EvidencePerAction` | `NoSilentFailure` diff --git a/agent-hub/haven/workers/verifier/recipes/verify_seal.md b/agent-hub/haven/workers/verifier/recipes/verify_seal.md index 561f0e9..81c5a65 100644 --- a/agent-hub/haven/workers/verifier/recipes/verify_seal.md +++ b/agent-hub/haven/workers/verifier/recipes/verify_seal.md @@ -63,6 +63,25 @@ asks for — this section pins the boundary. 11. Only on SEAL: update the ratchet/PM status on `haven/diagrams/dev-loop.prime-mermaid.md`. 12. Write the verdict to `evidence/verifier//-{seal|reopen}.md`. +12b. [added 2026-09-02] In the verdict note, truthfully declare 1 line + `## Re-run`: `none` (audit-only, the correct default per "Re-run + scope" above), `partial` (name exactly which command was re-run), or + `full` (re-ran the entire build+test, e.g. from an isolated worktree) + — always with a reason matching one of the 3 exception cases in + "Re-run scope" if not `none`. Misdeclaring this corrupts the + duplicate-cost signal step 13 depends on. +13. [added 2026-09-02] Append 1 line to `evidence/worker-runs.log` + (create the file if it doesn't exist): take `hub_bytes_before` from the + `## Hub bytes before` line in the implementer's note (already read in + step 2, reuse it); measure `hub_bytes_after` the same way (this hub's + `/hub-tokens` per-session total), taken AFTER updating PM status in + step 11 if SEALed. Format: + ``` + role=verifier outcome=SEAL|REOPEN node= + rerun=none|partial|full hub_bytes_before= hub_bytes_after= + ``` + NEVER edit/delete an old line here — append-only, same rule as the + rest of `evidence/`. ## Hard rules honored `NeverVerifyOwnWork` | `EvidenceOnly` | `VerdictOnly` | `RatchetOnly` From 1eecc714d8a0a37ba3a8fb7bd2fe21349f8511e5 Mon Sep 17 00:00:00 2001 From: _david Date: Thu, 3 Sep 2026 00:35:30 +0700 Subject: [PATCH 6/8] =?UTF-8?q?chore(agent-hub):=20archive=20pass=203=20?= =?UTF-8?q?=E2=80=94=20dev-loop=20diagram=2019.6KB=20->=2010.1KB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /hub-tokens had flagged dev-loop.prime-mermaid.md at 19,598B, over the 15KB threshold. Moved the 4 remaining full-content SEALED rows (add-docx-export-format, agent-hub-token-cleanup-20260830, add-visit-tracking, fix-visit-model-missing-id — dated 2026-08-30 through 2026-09-02) verbatim into dev-loop-archive.md's new '3rd pass' section, replaced each with a compact pointer row — same convention as the file's own 1st/2nd archive passes. Nothing deleted, nothing reworded. Result: 19,598B -> 10,143B (-48%), comfortably back under the 15KB threshold. Every SEALED row is now a pointer; only the 10 PENDING nodes stay full-content (never archived, they're not SEALED yet). npm test: 13 suites / 77 tests passed. npm run build: tsc clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0183N1xtARurw92SWfHL6cL9 --- agent-hub/haven/diagrams/dev-loop-archive.md | 15 +++++++++++++++ .../haven/diagrams/dev-loop.prime-mermaid.md | 12 +++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/agent-hub/haven/diagrams/dev-loop-archive.md b/agent-hub/haven/diagrams/dev-loop-archive.md index 375729d..a0ca3d0 100644 --- a/agent-hub/haven/diagrams/dev-loop-archive.md +++ b/agent-hub/haven/diagrams/dev-loop-archive.md @@ -13,6 +13,13 @@ > active file had grown to 24,649B, >15KB threshold). Covers the 7 SEALED > nodes dated 2026-08-29/2026-08-30 that had accumulated since the 1st > pass. +> +> **3rd pass, 2026-09-03** (operator-requested, `/hub-tokens` flagged the +> active file at 19,598B, >15KB threshold). Covers the 4 remaining +> full-content SEALED rows: `add-docx-export-format` (2026-09-02), +> `add-visit-tracking`/`fix-visit-model-missing-id` (2026-09-01), +> `agent-hub-token-cleanup-20260830` (2026-08-30). Every SEALED node in +> the active file is now a pointer row. ## PM status (archived) | Node | State | Notes | @@ -32,3 +39,11 @@ | `add-public-profile-visibility-toggle` | SEALED | GitHub issue #75. `Candidate.isPublic: Boolean` (default `true`, preserves current behavior). `GET /api/me/:email` (`candidate_me/index.ts` `fnGetAboutMe`) now returns the exact same "email not found" shape when `isPublic === false` — checked only in the public controller, NOT inside `handlerGetAboutMe` itself, so the authenticated self-export path (`fnExportPDF`, calls `handlerGetAboutMe` directly) is unaffected — a candidate can always see/export their own data regardless of the flag. Exposed via the existing `PATCH /api/v1/candidate/update` (`isPublic` added to `schemaCandidatePatch` only, per the issue's explicit scope — not the full PUT schema). Live-tested: default `true`, set `false` → public view returns identical "not found" response, self PDF export still works, toggled back `true` → public view works again. See `evidence/implementer/2026-08-29/add-public-profile-visibility-toggle-diff.md`. **SEALED 2026-08-29**: independent verifier subagent read the actual `src/` diff directly (`git diff --stat`: 4 files, 14 insertions/1 deletion — no unrelated refactor), read `candidate_me/index.ts` end-to-end and confirmed the `fnGetAboutMe` gate runs strictly after `handlerGetAboutMe` succeeds and returns the byte-identical `formatReturnFailed('Email không tồn tại')` string/shape as the pre-existing not-found branch inside `handlerGetAboutMe` (line 56), confirmed `handlerGetAboutMe` itself has zero `isPublic` references (not modified), confirmed `fnExportPDF` calls `handlerGetAboutMe` directly (not through `fnGetAboutMe`) so self-export bypasses the gate, confirmed `isPublic` was added only to `schemaCandidatePatch` (not the full PUT `schemaCandidate`) in `candidate.validate.ts`, confirmed the model field defaults to `true`, confirmed `candidate.service.ts`'s `handlerUpdate` was untouched and still applies `value` generically via `MODEL.updateOne`, ran `npm run build` (clean) and `npm test` myself (10 suites, 52/52 passed, zero regressions), and confirmed via `gh issue view 75` the diff matches the issue's 3-part proposal (the gate placement one layer above the issue's literal suggestion is a sound, documented interpretation of intent — self-export was never meant to be blocked). See `evidence/verifier/2026-08-29/add-public-profile-visibility-toggle-seal.md`. | | `add-email-verification` | SEALED | GitHub issue #71. `Candidate.emailVerified: Boolean` (default `false`). `POST /api/v1/auth/register` now creates a single-use, 24h TTL verification token (`src/utils/emailVerification.ts`, same Redis/mem-fallback pattern as `passwordReset.ts`) and logs the link (STUB — same "no email infra" gap as #70, operator decision reused, not re-asked). `GET /api/v1/auth/verify-email?token=...` consumes the token and flips `emailVerified` to `true`. **Product decision (operator, via `AskUserQuestion`)**: login is NOT blocked on `emailVerified` — `handlerLogin`'s response now includes `email_verified` in the `user` object so the frontend can decide (e.g. a banner), matching the issue's own framing that blocking needs a product call, not just implementation. **Bug found + fixed while implementing**: `CandidateModel.create({_id: null, ...})` keeps `_id: null` on the in-memory returned document (confirmed live via debug logging) — the real MongoDB-assigned `_id` is only visible on a subsequent `findOne`. Same root cause already documented in `services/index.ts`'s `baseCreateDocument` comment and tracked by the PENDING `fix-create-response-null-id` node — this is a second, separate occurrence in `auth.service.ts`'s `handlerRegister`, worked around there by re-fetching by email before creating the verification token (added test coverage for this in `auth.service.test.ts`). See `evidence/implementer/2026-08-29/add-email-verification-diff.md`. **SEALED 2026-08-29**: independent verifier subagent read the actual `src/` diff directly (8 modified files + 1 new file, matches the note exactly), read `auth.service.ts` end-to-end and confirmed `handlerRegister` discards `CandidateModel.create()`'s return value and re-fetches via `findOne` for the real `_id`, independently corroborated the `_id: null` bug beyond the note's prose by reading the pre-diff `git show HEAD` version (the old `document` variable was assigned but never read anywhere — proving the bug was real but latent until this feature needed a real `_id`) and by confirming the exact cited Mongoose comment exists verbatim in `services/index.ts:214`, confirmed this is a genuinely separate occurrence from the still-PENDING `fix-create-response-null-id` node (that node's scope is `candidate_profile/BaseService.ts`'s `hookAfterSave`, a different code path that never touches `auth.service.ts`), confirmed `handlerVerifyEmail` consumes the token single-use via `consumeVerificationToken` then `CandidateModel.updateOne`, confirmed `handlerLogin` has zero early-return/rejection tied to `emailVerified` (only exposes `email_verified` in the response), confirmed `emailVerification.ts` mirrors `passwordReset.ts`'s Redis-with-in-memory-fallback single-use pattern side by side, confirmed the updated `auth.service.test.ts` mock chain (`mockResolvedValueOnce` twice) matches the real two-`findOne`-call shape and the new `handlerVerifyEmail` tests are non-vacuous, ran `npm run build` myself (clean) and `npm test` myself (10 suites, 54/54 passed, matches the note exactly, +2 over the 52 baseline = exactly the new `handlerVerifyEmail` cases), and confirmed via `gh issue view 71` the diff matches the issue's proposal including the explicitly-flagged login-blocking product decision resolved as "don't block." See `evidence/verifier/2026-08-29/add-email-verification-seal.md`. | | `add-project-cert-award-image-upload` | SEALED | GitHub issue #72. `POST /api/v1/{project\|certificate\|award}/:id/images` — multer array upload (max 5 files, 5MB each, image mimetype+extension checked), appends the stored URLs directly to the target record's `images[]` and persists (operator decision via `AskUserQuestion`: multiple files per request, server appends directly rather than returning URLs for a separate client PUT). New `src/middlewares/uploadImages.middleware.ts` + `baseUploadImages` in `BaseController.ts` (shared across all 3 sections via the existing `modelObject`/`Collections` map, matching `baseGetAll`/`baseDelete`'s pattern). **IDOR-safe by construction**: ownership checked via `document.candidateId.toString() === req.user._id` BEFORE any file is parsed/written to disk — deliberately NOT reusing `baseDelete`'s pattern, which the still-PENDING `fix-idor-broken-access-control` node documents as trusting `req.body.candidateId` instead (confirmed live while implementing: `baseGetAll`/`baseDelete` are genuinely vulnerable today, `baseUpdateDocument`'s internal ownership check is fine but the new endpoint doesn't reuse either — it does its own correct check). Unlike the CV upload's Trap (private file, unauthenticated-reachable via `express.static` as a known gap), these images are intentionally public (shown on `GET /api/me/:email`) — plain static URL serving is the correct design here, not a repeat of that gap. `handlerDelete` (`candidate.service.ts`) now also collects and removes every uploaded image file from disk for `Project`/`Certificate`/`Award` documents before they're deleted, on top of the existing CV-file cleanup. See `evidence/implementer/2026-08-30/add-project-cert-award-image-upload-diff.md`. **Round 1 REOPEN** (`evidence/verifier/2026-08-30/add-project-cert-award-image-upload-reopen.md`): acceptance row 1's own stated bounds (<=5 files, <=5MB each) were asserted, not live-tested — no request had tried a 6th file or an oversized file, and the "MulterError mapped to 400" claim only covered the fileFilter path, not multer's own `limits` errors. **Round 2 fix** (`evidence/implementer/2026-08-30/add-project-cert-award-image-upload-diff-2.md`): implementer ran the exact missing live tests against the unmodified round-1 code first and found a real bug, not just a documentation gap — a 6th file's `LIMIT_FILE_COUNT` MulterError fell through to the generic handler, returning a raw `500` + stack trace instead of a clean `4xx` (the oversized-file `LIMIT_FILE_SIZE` path was already correct). Fixed with a 2-line `err.code === 'LIMIT_FILE_COUNT'` branch in `BaseController.ts` + one new locale key per language — nothing else touched. **SEALED 2026-08-30**: independent verifier subagent (fresh session, EvidenceOnly — read both implementer notes and the round-1 REOPEN note only, never the `src/` diff) confirmed round 2's live evidence closes the exact round-1 gap: real `HTTP 500` + stack trace on 6 files before the fix, real `HTTP 400` + friendly message on 6 files after, oversized-file `400` reconfirmed unchanged, and a 5-file boundary case still succeeding with `200` + persisted `images[]` — all shown as real curl/response bodies, not inferred. Confirmed the round-2 diff is proportionate (2-line catch branch + 2 locale keys only, SmallestDiff, no scope creep). Re-confirmed all other round-1 acceptance rows (ownership/IDOR 403 live test, `images[]` persistence, static serving with real PNG bytes, self-delete disk cleanup before/after `ls`, clean `npm test`/`npm run build`) remain adequately evidenced and untouched by round 2. No truncated/redacted output in either note, no commit/push without recorded approval in either note's Seal gate section. See `evidence/verifier/2026-08-30/add-project-cert-award-image-upload-seal.md`. | + +## PM status (archived, 3rd pass — 2026-09-03) +| Node | State | Notes | +|---|---|---| +| `add-docx-export-format` | SEALED | Feature, GitHub issue #76 remainder — the sibling `add-json-export-format` (SEALED, `evidence/implementer/2026-08-29/add-json-export-format-diff.md`) deliberately deferred DOCX as "Noticed, not done", needing a library decision + new template. Operator already resolved scope outside this loop (edited issue #76's body directly: JSON marked done, remaining scope narrowed to DOCX only) — no `AskUserQuestion` needed this round, scope is unambiguous. Added `docx` npm package (`9.7.1`, MIT, no native deps), new `src/services/createDocx.ts` (`buildDocxContent`/`renderDocxDocument`/`createCVDocx`, mirroring `createPDF.ts`'s `pageRender`/`createCV` split so the content logic stays unit-testable without Puppeteer-style mocking), wired `?format=docx` into `candidate_me/index.ts`'s `fnExportPDF` alongside the existing `format=json` branch (same single `handlerGetAboutMe` call reused, no new data-fetch), updated the `/download-pdf` Swagger doc's `format` enum + response content types. Evidence: `evidence/implementer/2026-09-02/add-docx-export-format-diff.md`. **SEALED 2026-09-02**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly, `git diff` not run). All 8 acceptance rows carry specific citations: live curl of `GET /download-pdf?format=docx` returning HTTP 200 with the correct `Content-Type`/`Content-Disposition`, `file` confirming "Microsoft Word 2007+"; the generated `.docx` unzipped and `word/document.xml` grepped for the real throwaway test account's email, confirming actual content (not an empty valid file); same live session re-curled both the no-format PDF path and `?format=json` afterward as a regression check, both still correct; `handlerGetAboutMe` cited as called exactly once (no duplicate data-fetch). Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 11 passed, 11 total` / `Tests: 60 passed, 60 total` — 54/10 baseline (matching the last sealed node on this branch) plus exactly +1 suite/+6 tests for the new `createDocx.test.ts`, no truncation markers. `npx tsc --noEmit` and `npm run build` both cited clean. Diagram-first confirmed via the plan note (PENDING row drafted before any `src/` edit). Diff proportionate to scope (1 new service module + 1 controller branch + 1 Swagger-doc edit + new dependency + 1 new test file) — no unrelated refactor bundled in. No `src`/`.ts` leaked into `haven/`. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. See `evidence/verifier/2026-09-02/add-docx-export-format-seal.md`. | +| `agent-hub-token-cleanup-20260830` | SEALED | Follow-on from a same-day session working in the sibling `vue-resume-web` frontend repo, which found this repo's hub has the same pattern via its own `/hub-tokens`. Operator: "hãy fix luôn cho backend". 3-part chore, no `src/` touched: (1) archived the 7 SEALED nodes dated 2026-08-29/2026-08-30 out of the active diagram into `dev-loop-archive.md` — active file 24,649B → 9,282B before this row itself was appended (self-referential: this row's own text adds ~1.2KB, landing the file at 10,448B), still comfortably under the 15KB threshold. (2) `.claude/skills/boot/SKILL.md` step 2: stopped instructing an explicit `Read`/`cat` of `agent-hub/CLAUDE.md` (harness auto-injects it once step 1 touches `agent-hub/` — was a real duplicate-read observed in the frontend repo's session). (3) same skill's step 7: added `find -maxdepth 2 -type f -name "*.md" -exec ls -t {} +` guidance instead of leaving it unspecified (`-maxdepth 2` because this repo's evidence layout uses `/` subfolders, unlike the frontend repo's flat layout). `npm test` → `54 passed, 54 total` (unchanged baseline). `npm run build` → clean `tsc`, no errors. Evidence: `evidence/implementer/2026-08-30/agent-hub-token-cleanup-diff.md`. **SEALED 2026-08-30**: independent verifier subagent read the evidence note only (not the diff, per `EvidenceOnly`), then independently re-ran everything: `git status`/`git diff --stat` confirmed the 3-file scope (`dev-loop.prime-mermaid.md`, `dev-loop-archive.md`, `.claude/skills/boot/SKILL.md`) with zero `src/` touched; spot-diffed the first and last of the 7 archived rows (`add-open-to-work-status`, `add-project-cert-award-image-upload`) byte-for-byte between what was removed from the active file and what was appended to the archive — identical; re-ran `npm test` (10 suites, 54/54 passed) and `npm run build` (clean `tsc`) myself, matching the note exactly; read the `.claude/skills/boot/SKILL.md` diff directly and confirmed both described changes (step 2 guard against the duplicate `CLAUDE.md` read, step 7's `find -maxdepth 2` swap) plus the new >15KB Rules bullet are genuinely present. **Correction to the note's own cited number**: `wc -c` on the current file returns `10448`, not the note's cited `9282` — traced to a self-reference: the note's byte count was necessarily measured before this same PM-status row (which describes that very byte count) was appended, adding ~1.2KB after the fact. Non-blocking: `10448` is still well under the 15KB threshold, so the underlying acceptance criterion (diagram back under threshold) holds on independently-obtained evidence, just with a corrected number. No forbidden-state hits. No commit/push happened (working tree still dirty) — correctly deferred to `/ship` or manual commit per the note's own Seal gate section. See `evidence/verifier/2026-08-30/agent-hub-token-cleanup-seal.md`. | +| `add-visit-tracking` | SEALED | Feature, operator request: the public frontend (`datvt243.github.io`) needs profile-visit analytics — count, timestamp, location, IP, distinguishable per candidate/email. Scope resolved via `AskUserQuestion`: (1) new public `POST /api/me/:email/visit` (not piggybacked on the existing `GET /api/me/:email`, which the frontend's Nuxt server caches 12 days — would massively undercount real visits); (2) location via offline `geoip-lite` lookup on the request IP (no external API call/key); (3) new `Visit` model (`candidateId`, `ip`, `location`, `timestamps: true`) — one document per visit; (4) new authenticated `GET /api/v1/candidate/visits` (scoped to `req.user._id` only, IDOR-safe) returning count + list for the caller's own candidate. Frontend-side call site (`datvt243.github.io`) is a separate repo/session, out of scope here. Evidence: `evidence/implementer/2026-09-01/add-visit-tracking-diff.md`. **SEALED 2026-09-01**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly). All 8 acceptance rows carry specific file/line-level citations (`src/models/visit.model.ts`, `src/candidate_me/index.ts`'s `handlerRecordVisit`/`geoip.lookup`, `src/routers/index.ts`'s `POST /api/me/:email/visit`, `src/candidate/{candidate.service,candidate.controller}.ts`'s `handlerGetVisits`/`fnGetVisits` scoped to `req.user._id`, `GET /visits` registered before the `/:email` wildcard in `candidate.route.ts`, `Visit` schema in `swagger.config.ts`) — none missing. Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 10 passed, 10 total` / `Tests: 54 passed, 54 total` — same 10/54 baseline as `agent-hub-token-cleanup-20260830`, no truncation markers. `npm run build` cited clean. Diff is proportionate to the `AskUserQuestion`-resolved scope (dedicated uncached endpoint + offline geoip + IDOR-safe read-back) — no extra refactor. No `src`/`.ts` leaked into `haven/`. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. Non-blocking note: `doctrine/MEMORY.md`'s run-from path is stale (`.../ResumeAPI/backend`, pre-dating the repo rename to `resume-nodejs-api`) — the command string `npm test` itself still matches verbatim, unrelated to this node's acceptance. See `evidence/verifier/2026-09-01/add-visit-tracking-seal.md`. | +| `fix-visit-model-missing-id` | SEALED | **Regression of `add-visit-tracking` (SEALED), found live in production.** Operator asked to test `POST /api/me/:email/visit` on production right after `/release` (v1.2.0) deployed — got real `500 {"errorCode":"INTERNAL_SERVER_ERROR","message":"document must have an _id before saving"}`. Root cause: `src/models/visit.model.ts` copied the `_id: ObjectId` field pattern from `src/models/award.model.ts` — that bare redeclaration (no `auto: true`/default) overrides Mongoose's implicit auto-generating `_id` path. Existing CV-section models get away with this ONLY because they're always created through `baseCreateDocument` (`services/index.ts`), which explicitly passes `_id: null` as a documented workaround (see the comment there). `handlerRecordVisit` calls `MODEL.Visit.create(...)` directly, bypassing that helper entirely — so `_id` is left fully unset, tripping Mongoose's `document must have an _id before saving` guard on every single call, in production, right now. Evidence: `evidence/implementer/2026-09-01/fix-visit-model-missing-id-diff.md`. **SEALED 2026-09-01**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly). Root-cause claim backed by a real quoted live `curl` 500 response plus a `grep` hit pinpointing the exact Mongoose source line (`node_modules/mongoose/lib/model.js:312`); fix-verification claim backed by a standalone in-memory before/after schema reproduction script's printed output (no DB write) showing `_id: undefined` on the buggy schema vs a real generated `ObjectId` once the field is removed — not just asserted. Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 10 passed, 10 total` / `Tests: 54 passed, 54 total`, same 10/54 baseline, no truncation markers. `npm run build` cited clean. Diff is proportionate: 1 line removed (`_id: ObjectId` field) plus an explanatory comment in `src/models/visit.model.ts`, no other file touched. Correctly filed as a brand-new node per LAI-13 rather than reopening/demoting the SEALED `add-visit-tracking` row above. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. See `evidence/verifier/2026-09-01/fix-visit-model-missing-id-seal.md`. | diff --git a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md index b828716..3203b77 100644 --- a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md +++ b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md @@ -38,7 +38,9 @@ flowchart TD ## PM status > Older SEALED nodes (2026-08-22 through 2026-08-25; then a 2nd pass -> 2026-08-30 covering 7 more nodes dated 2026-08-29/2026-08-30) moved to +> 2026-08-30 covering 7 more nodes dated 2026-08-29/2026-08-30; then a +> 3rd pass 2026-09-03 covering the 4 remaining full-content SEALED rows, +> dated 2026-08-30 through 2026-09-02) moved to > `haven/diagrams/dev-loop-archive.md` to keep this file small — every > worker session reads this file in full. Nothing deleted: the archive > has each row's full original text verbatim. The compact rows below @@ -49,8 +51,8 @@ flowchart TD | Node | State | Notes | |---|---|---| -| `add-docx-export-format` | SEALED | Feature, GitHub issue #76 remainder — the sibling `add-json-export-format` (SEALED, `evidence/implementer/2026-08-29/add-json-export-format-diff.md`) deliberately deferred DOCX as "Noticed, not done", needing a library decision + new template. Operator already resolved scope outside this loop (edited issue #76's body directly: JSON marked done, remaining scope narrowed to DOCX only) — no `AskUserQuestion` needed this round, scope is unambiguous. Added `docx` npm package (`9.7.1`, MIT, no native deps), new `src/services/createDocx.ts` (`buildDocxContent`/`renderDocxDocument`/`createCVDocx`, mirroring `createPDF.ts`'s `pageRender`/`createCV` split so the content logic stays unit-testable without Puppeteer-style mocking), wired `?format=docx` into `candidate_me/index.ts`'s `fnExportPDF` alongside the existing `format=json` branch (same single `handlerGetAboutMe` call reused, no new data-fetch), updated the `/download-pdf` Swagger doc's `format` enum + response content types. Evidence: `evidence/implementer/2026-09-02/add-docx-export-format-diff.md`. **SEALED 2026-09-02**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly, `git diff` not run). All 8 acceptance rows carry specific citations: live curl of `GET /download-pdf?format=docx` returning HTTP 200 with the correct `Content-Type`/`Content-Disposition`, `file` confirming "Microsoft Word 2007+"; the generated `.docx` unzipped and `word/document.xml` grepped for the real throwaway test account's email, confirming actual content (not an empty valid file); same live session re-curled both the no-format PDF path and `?format=json` afterward as a regression check, both still correct; `handlerGetAboutMe` cited as called exactly once (no duplicate data-fetch). Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 11 passed, 11 total` / `Tests: 60 passed, 60 total` — 54/10 baseline (matching the last sealed node on this branch) plus exactly +1 suite/+6 tests for the new `createDocx.test.ts`, no truncation markers. `npx tsc --noEmit` and `npm run build` both cited clean. Diagram-first confirmed via the plan note (PENDING row drafted before any `src/` edit). Diff proportionate to scope (1 new service module + 1 controller branch + 1 Swagger-doc edit + new dependency + 1 new test file) — no unrelated refactor bundled in. No `src`/`.ts` leaked into `haven/`. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. See `evidence/verifier/2026-09-02/add-docx-export-format-seal.md`. | -| `agent-hub-token-cleanup-20260830` | SEALED | Follow-on from a same-day session working in the sibling `vue-resume-web` frontend repo, which found this repo's hub has the same pattern via its own `/hub-tokens`. Operator: "hãy fix luôn cho backend". 3-part chore, no `src/` touched: (1) archived the 7 SEALED nodes dated 2026-08-29/2026-08-30 out of the active diagram into `dev-loop-archive.md` — active file 24,649B → 9,282B before this row itself was appended (self-referential: this row's own text adds ~1.2KB, landing the file at 10,448B), still comfortably under the 15KB threshold. (2) `.claude/skills/boot/SKILL.md` step 2: stopped instructing an explicit `Read`/`cat` of `agent-hub/CLAUDE.md` (harness auto-injects it once step 1 touches `agent-hub/` — was a real duplicate-read observed in the frontend repo's session). (3) same skill's step 7: added `find -maxdepth 2 -type f -name "*.md" -exec ls -t {} +` guidance instead of leaving it unspecified (`-maxdepth 2` because this repo's evidence layout uses `/` subfolders, unlike the frontend repo's flat layout). `npm test` → `54 passed, 54 total` (unchanged baseline). `npm run build` → clean `tsc`, no errors. Evidence: `evidence/implementer/2026-08-30/agent-hub-token-cleanup-diff.md`. **SEALED 2026-08-30**: independent verifier subagent read the evidence note only (not the diff, per `EvidenceOnly`), then independently re-ran everything: `git status`/`git diff --stat` confirmed the 3-file scope (`dev-loop.prime-mermaid.md`, `dev-loop-archive.md`, `.claude/skills/boot/SKILL.md`) with zero `src/` touched; spot-diffed the first and last of the 7 archived rows (`add-open-to-work-status`, `add-project-cert-award-image-upload`) byte-for-byte between what was removed from the active file and what was appended to the archive — identical; re-ran `npm test` (10 suites, 54/54 passed) and `npm run build` (clean `tsc`) myself, matching the note exactly; read the `.claude/skills/boot/SKILL.md` diff directly and confirmed both described changes (step 2 guard against the duplicate `CLAUDE.md` read, step 7's `find -maxdepth 2` swap) plus the new >15KB Rules bullet are genuinely present. **Correction to the note's own cited number**: `wc -c` on the current file returns `10448`, not the note's cited `9282` — traced to a self-reference: the note's byte count was necessarily measured before this same PM-status row (which describes that very byte count) was appended, adding ~1.2KB after the fact. Non-blocking: `10448` is still well under the 15KB threshold, so the underlying acceptance criterion (diagram back under threshold) holds on independently-obtained evidence, just with a corrected number. No forbidden-state hits. No commit/push happened (working tree still dirty) — correctly deferred to `/ship` or manual commit per the note's own Seal gate section. See `evidence/verifier/2026-08-30/agent-hub-token-cleanup-seal.md`. | +| `add-docx-export-format` | SEALED | 2026-09-02 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-09-02/add-docx-export-format-diff.md`. | +| `agent-hub-token-cleanup-20260830` | SEALED | 2026-08-30 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-30/agent-hub-token-cleanup-diff.md`. | | `fix-chrome-executable-path` | PENDING | `src/services/createPDF.ts:14-25` — Chrome executable path hardcoded, breaks PDF export in CI/Docker. See Traps in `doctrine/domains/PROJECT.md`. First candidate node. | | `fix-idor-broken-access-control` | PENDING | **Critical.** All CRUD APIs for candidate_profile (education/experience/award/certificate/project/reference/generalInformation) + `candidate.service.ts` + `fnExportPDF` never cross-check `candidateId`/`_id` against `req.user._id` (JWT) — they trust client-supplied `req.body.candidateId`/`_id`. Live-tested confirmed: User B could read/delete/edit User A's data, overwrite A's profile. Root cause: `verifyToken.middleware.ts` sets `req.user` but nothing cross-checks it. Found while testing the full API (task: "test the whole API again"). | | `fix-candidate-password-leak` | PENDING | `src/candidate/candidate.service.ts` — `handlerGetInformationByEmail` has no `.select()` at all; `handlerGetInformationById` double-wraps `whitelistSelect([select])`, making the select a permanent no-op. Result: `GET /api/v1/candidate/:email` and `PUT/PATCH /candidate/update` return the raw bcrypt password hash in the response. | @@ -61,7 +63,7 @@ flowchart TD | `fix-candidate-me-candidateid-not-string` | PENDING | **Critical, found by accident while testing #79.** `candidate_me/index.ts` `handlerGetAboutMe` — `_id` from the raw Mongoose document is an ObjectId instance, passed straight into `idQuerySafe.safeQuery({}, { candidateId: _id })` — `QuerySafe.safeQuery` only accepts `typeof value === 'string'`, so an ObjectId silently fails that check and `candidateId` gets dropped from the filter → `model.find({})` returns CV data (education/experience/award/certificate/project/generalInformation) for **every candidate mixed together**, on every `GET /api/me/:email` request (public, no auth) and `/download-pdf`. Live-tested confirmed: a brand-new candidate profile returned real data belonging to `votan.it@gmail.com`. Fix: `.toString()` on `_id` before passing it in. | | `feat-i18n-api-messages-auth` | PENDING | Feature, GitHub issue #78 (phase 1 of several). i18n infrastructure (hand-rolled `t(key, lang)`, reads `locales/vi.json`/`en.json`, middleware detects `Accept-Language`, defaults `vi`) + fully migrates the auth flow (register/login/logout/refresh). Does NOT migrate Joi validation messages (different architecture — Joi schemas are built once at module load with no request context; needs error TYPE → i18n key mapping, left as a follow-up). Does NOT touch candidate/CV section messages (separate follow-up). | | `feat-i18n-full-coverage` | PENDING | Feature, GitHub issue #78 (phase 2/2 — complete). Joi validation messages: a generic system translating by `detail.type` + `fieldLabels` (`utils/valid.ts`), no longer relying on hardcoded `.messages()` per schema. Mongoose `required` messages: same approach in `handleError` (`utils/helper.ts`). Every candidate/CV section success/error message (`services/index.ts`, `BaseController.ts`, `BaseService.ts`, `candidate.service.ts`, `generalInformation.*`) cascades across all 7 CV sections. Bug found during implementation: `t()`'s dot-path walker misparsed Joi type strings containing a dot (`any.required` was read as 3 nested levels) — caught via a real live test (curl in 2 languages), not code review. Fix: a dedicated `tErrorType()` function, flat lookup with no dot-path walking. | -| `add-visit-tracking` | SEALED | Feature, operator request: the public frontend (`datvt243.github.io`) needs profile-visit analytics — count, timestamp, location, IP, distinguishable per candidate/email. Scope resolved via `AskUserQuestion`: (1) new public `POST /api/me/:email/visit` (not piggybacked on the existing `GET /api/me/:email`, which the frontend's Nuxt server caches 12 days — would massively undercount real visits); (2) location via offline `geoip-lite` lookup on the request IP (no external API call/key); (3) new `Visit` model (`candidateId`, `ip`, `location`, `timestamps: true`) — one document per visit; (4) new authenticated `GET /api/v1/candidate/visits` (scoped to `req.user._id` only, IDOR-safe) returning count + list for the caller's own candidate. Frontend-side call site (`datvt243.github.io`) is a separate repo/session, out of scope here. Evidence: `evidence/implementer/2026-09-01/add-visit-tracking-diff.md`. **SEALED 2026-09-01**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly). All 8 acceptance rows carry specific file/line-level citations (`src/models/visit.model.ts`, `src/candidate_me/index.ts`'s `handlerRecordVisit`/`geoip.lookup`, `src/routers/index.ts`'s `POST /api/me/:email/visit`, `src/candidate/{candidate.service,candidate.controller}.ts`'s `handlerGetVisits`/`fnGetVisits` scoped to `req.user._id`, `GET /visits` registered before the `/:email` wildcard in `candidate.route.ts`, `Visit` schema in `swagger.config.ts`) — none missing. Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 10 passed, 10 total` / `Tests: 54 passed, 54 total` — same 10/54 baseline as `agent-hub-token-cleanup-20260830`, no truncation markers. `npm run build` cited clean. Diff is proportionate to the `AskUserQuestion`-resolved scope (dedicated uncached endpoint + offline geoip + IDOR-safe read-back) — no extra refactor. No `src`/`.ts` leaked into `haven/`. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. Non-blocking note: `doctrine/MEMORY.md`'s run-from path is stale (`.../ResumeAPI/backend`, pre-dating the repo rename to `resume-nodejs-api`) — the command string `npm test` itself still matches verbatim, unrelated to this node's acceptance. See `evidence/verifier/2026-09-01/add-visit-tracking-seal.md`. | +| `add-visit-tracking` | SEALED | 2026-09-01 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-09-01/add-visit-tracking-diff.md`. | | `add-open-to-work-status` | SEALED | 2026-08-29 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-29/add-open-to-work-status-plan.md`. | | `consolidate-v1-v2-auth` | SEALED | 2026-08-29 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-29/consolidate-v1-v2-auth-diff.md`. | | `add-json-export-format` | SEALED | 2026-08-29 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-29/add-json-export-format-diff.md`. | @@ -73,6 +75,6 @@ flowchart TD | `fix-pdf-missing-career-fields` | SEALED | 2026-08-25 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/verifier/2026-08-25/fix-pdf-missing-career-fields-seal.md`. | | `fix-redis-init-blocks-dev-startup` | SEALED | 2026-08-22 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/verifier/2026-08-22/fix-redis-init-blocks-dev-startup-seal.md`. | | `add-project-cert-award-image-upload` | SEALED | 2026-08-30 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-30/add-project-cert-award-image-upload-diff.md`. | -| `fix-visit-model-missing-id` | SEALED | **Regression of `add-visit-tracking` (SEALED), found live in production.** Operator asked to test `POST /api/me/:email/visit` on production right after `/release` (v1.2.0) deployed — got real `500 {"errorCode":"INTERNAL_SERVER_ERROR","message":"document must have an _id before saving"}`. Root cause: `src/models/visit.model.ts` copied the `_id: ObjectId` field pattern from `src/models/award.model.ts` — that bare redeclaration (no `auto: true`/default) overrides Mongoose's implicit auto-generating `_id` path. Existing CV-section models get away with this ONLY because they're always created through `baseCreateDocument` (`services/index.ts`), which explicitly passes `_id: null` as a documented workaround (see the comment there). `handlerRecordVisit` calls `MODEL.Visit.create(...)` directly, bypassing that helper entirely — so `_id` is left fully unset, tripping Mongoose's `document must have an _id before saving` guard on every single call, in production, right now. Evidence: `evidence/implementer/2026-09-01/fix-visit-model-missing-id-diff.md`. **SEALED 2026-09-01**: independent verifier subagent read the evidence note only (per `EvidenceOnly`, diff not opened directly). Root-cause claim backed by a real quoted live `curl` 500 response plus a `grep` hit pinpointing the exact Mongoose source line (`node_modules/mongoose/lib/model.js:312`); fix-verification claim backed by a standalone in-memory before/after schema reproduction script's printed output (no DB write) showing `_id: undefined` on the buggy schema vs a real generated `ObjectId` once the field is removed — not just asserted. Test command (`npm test`) matches `doctrine/MEMORY.md` verbatim; cited output `Test Suites: 10 passed, 10 total` / `Tests: 54 passed, 54 total`, same 10/54 baseline, no truncation markers. `npm run build` cited clean. Diff is proportionate: 1 line removed (`_id: ObjectId` field) plus an explanatory comment in `src/models/visit.model.ts`, no other file touched. Correctly filed as a brand-new node per LAI-13 rather than reopening/demoting the SEALED `add-visit-tracking` row above. Seal gate correctly "none" — no commit/push, diff deferred to operator/`/ship`. No forbidden-state hits. See `evidence/verifier/2026-09-01/fix-visit-model-missing-id-seal.md`. | +| `fix-visit-model-missing-id` | SEALED | 2026-09-01 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-09-01/fix-visit-model-missing-id-diff.md`. | Any regression must be a **new node** (LAI-13) — never edit an existing node's PM status directly to "undo" an existing SEAL. From 22650b2864c7641c51bea997e3350f1ee8067ed4 Mon Sep 17 00:00:00 2001 From: _david Date: Sat, 5 Sep 2026 19:23:47 +0700 Subject: [PATCH 7/8] chore(agent-hub): backfill #74/#73 bookkeeping + hub-tokens/issues-ls sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seals two nodes that had real, merged src/ code (PR #105, PR #106) but no diagram node or evidence note at the time: - add-logout-all-sessions (#74) - add-pagination-filtering-cv-sections (#73) Both verified independently via subagent (SEAL, zero src/ diff — pure documentation catch-up). Also carries forward pre-existing uncommitted changes: hub-tokens/SKILL.md's doctrine/domains/PROJECT.md threshold check, and the issues-ls command file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Hdp892ahsFcZfmM5g2yDBA --- .claude/commands/issues-ls.md | 35 +++++ .claude/skills/hub-tokens/SKILL.md | 105 ++++++++++---- .../add-logout-all-sessions-diff.md | 133 ++++++++++++++++++ ...d-pagination-filtering-cv-sections-diff.md | 133 ++++++++++++++++++ .../add-logout-all-sessions-seal.md | 82 +++++++++++ ...d-pagination-filtering-cv-sections-seal.md | 105 ++++++++++++++ .../haven/diagrams/dev-loop.prime-mermaid.md | 2 + 7 files changed, 571 insertions(+), 24 deletions(-) create mode 100644 .claude/commands/issues-ls.md create mode 100644 agent-hub/evidence/implementer/2026-09-05/add-logout-all-sessions-diff.md create mode 100644 agent-hub/evidence/implementer/2026-09-05/add-pagination-filtering-cv-sections-diff.md create mode 100644 agent-hub/evidence/verifier/2026-09-05/add-logout-all-sessions-seal.md create mode 100644 agent-hub/evidence/verifier/2026-09-05/add-pagination-filtering-cv-sections-seal.md diff --git a/.claude/commands/issues-ls.md b/.claude/commands/issues-ls.md new file mode 100644 index 0000000..eb0e758 --- /dev/null +++ b/.claude/commands/issues-ls.md @@ -0,0 +1,35 @@ +--- +description: "List open GitHub issues for this repo, if it's hosted on GitHub. Read-only, no side effects." +argument-hint: "[--state open|closed|all] [gh issue list flags...]" +--- + +# /issues-ls — list GitHub issues for this repo + +Read-only. Lists issues from GitHub if (and only if) this repo's remote is +a GitHub repo — no writes, no approval gate needed. + +## Steps +1. **Check the remote is GitHub.** Run `git remote get-url origin` (fall + back to another remote if `origin` doesn't exist). If it doesn't + resolve, or the host isn't `github.com`, stop and report "not a GitHub + repo — skip" — not an error, just nothing to do. +2. **Check `gh` CLI is available and authenticated.** Run `gh auth + status`. If `gh` isn't installed or isn't authenticated, stop and + report the exact output plus a one-line hint (`gh auth login`) — don't + work around it (no calling the GitHub REST API directly with a token). +3. **List issues.** `gh issue list --state open --limit 50` by default. + If `$ARGUMENTS` is given, pass it through verbatim as extra flags to + `gh issue list` instead of the defaults (e.g. `/issues-ls --state all`, + `/issues-ls --label bug --assignee @me`). +4. **Display as a table**: issue number, title, labels, state, + updated-at, URL — whatever `gh issue list` returns is enough, don't + reformat or re-fetch per-issue unless the arguments ask for more detail + (e.g. a `--json` variant). +5. **No writes.** Never close/comment/edit an issue from this command — + that's a separate manual `gh issue` call (or `/release`'s own + issue-closing step), out of scope here. + +## Runtime +Requires `gh` CLI authenticated against the project's GitHub remote. If +the repo isn't on GitHub, or `gh` isn't set up, report why and stop — no +fallback to scraping or an unauthenticated API call. diff --git a/.claude/skills/hub-tokens/SKILL.md b/.claude/skills/hub-tokens/SKILL.md index b507bc8..77752e2 100644 --- a/.claude/skills/hub-tokens/SKILL.md +++ b/.claude/skills/hub-tokens/SKILL.md @@ -1,6 +1,6 @@ --- name: hub-tokens -description: "Report the token cost of agent-hub/ — how much gets read every worker session (recurring cost) vs cold storage (evidence/, archived diagram rows) that's only opened on demand. Usage: /hub-tokens. Read-only, makes no changes." +description: "Report the token cost of agent-hub/ — how much gets read every worker session (recurring cost) vs cold storage (evidence/, archived diagram/PROJECT.md/log rows) that's only opened on demand. Usage: /hub-tokens. Read-only, makes no changes." --- # /hub-tokens — measure agent-hub's token cost @@ -8,13 +8,14 @@ description: "Report the token cost of agent-hub/ — how much gets read every w Read-only diagnostic. No file changes, no seal gate needed. ## Why this exists -`haven/diagrams/dev-loop.prime-mermaid.md` is read in full by every worker -session (implementer, verifier, and every subagent spawned for a `/todo` -verify pass re-loads it from scratch). Left unchecked it grows forever and -becomes the single biggest recurring token cost in the hub — this is what -the `dev-loop-archive.md` convention (see the diagram file's own header -note) exists to bound. This command measures whether that's actually -happening, instead of guessing. +`haven/diagrams/dev-loop.prime-mermaid.md` and `doctrine/domains/ +PROJECT.md` are both read in full by every worker session (implementer, +verifier, and every subagent spawned for a `/todo` verify pass re-loads +them from scratch). Left unchecked either grows forever and becomes the +single biggest recurring token cost in the hub — this is what the +`dev-loop-archive.md` / `PROJECT-archive.md` conventions (see each file's +own header note) exist to bound. This command measures whether that's +actually happening, instead of guessing. There's no exact tokenizer available here — the report uses `bytes / 4` as a documented, consistent proxy (not a real token count). Good enough to @@ -32,12 +33,23 @@ HUB="$ROOT/agent-hub" bytes_glob() { find $1 -maxdepth "${2:-99}" -type f \( -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) 2>/dev/null -exec cat {} + 2>/dev/null | wc -c | tr -d ' '; } bytes_glob_exclude() { find "$1" -type f \( -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) ! -iname "*archive*" 2>/dev/null -exec cat {} + 2>/dev/null | wc -c | tr -d ' '; } row() { local label="$1" b="$2"; local t=$(( b / 4 )); printf " %-40s %9d B ~%8d tok\n" "$label" "$b" "$t"; } +check_threshold() { + local file="$1" threshold="$2" hint="$3" + [ -f "$file" ] || return + local b; b=$(wc -c < "$file" | tr -d ' ') + local kb=$(( threshold / 1024 )) + if [ "$b" -gt "$threshold" ]; then + echo " ⚠ $(basename "$file") is ${b}B (>${kb}KB threshold) — $hint" + else + echo " ✓ $(basename "$file") is ${b}B, under the ${kb}KB threshold" + fi +} echo "agent-hub token report — $(date +%Y-%m-%d) [$ROOT]" echo "===================================================================" echo "READ EVERY WORKER SESSION (this is the recurring cost):" ROOT_B=$(bytes_glob "$HUB" 1) -DOCTRINE_B=$(bytes_glob "$HUB/doctrine") +DOCTRINE_B=$(bytes_glob_exclude "$HUB/doctrine") DIAG_ACTIVE_B=$(bytes_glob_exclude "$HUB/haven/diagrams") IMPL_B=$(bytes_glob "$HUB/haven/workers/implementer") VERIF_B=$(bytes_glob "$HUB/haven/workers/verifier") @@ -52,14 +64,18 @@ echo echo "COLD STORAGE (opened on demand only, NOT re-read wholesale by" echo "pick_next/verify_seal — large size here is not a recurring cost):" ARCHIVE_B=$(find "$HUB/haven/diagrams" -type f -iname "*archive*" 2>/dev/null -exec cat {} + 2>/dev/null | wc -c | tr -d ' ') +DOCTRINE_ARCHIVE_B=$(find "$HUB/doctrine" -type f -iname "*archive*" 2>/dev/null -exec cat {} + 2>/dev/null | wc -c | tr -d ' ') EVI_I_B=$(bytes_glob "$HUB/evidence/implementer") EVI_V_B=$(bytes_glob "$HUB/evidence/verifier") -TODO_LOG_B=$(wc -c < "$HUB/evidence/worker-runs.log" 2>/dev/null | tr -d ' '); TODO_LOG_B=${TODO_LOG_B:-0} +TODO_LOG_B=$([ -f "$HUB/evidence/worker-runs.log" ] && wc -c < "$HUB/evidence/worker-runs.log" | tr -d ' ' || echo 0) +TODO_LOG_ARCHIVE_B=$([ -f "$HUB/evidence/worker-runs-archive.log" ] && wc -c < "$HUB/evidence/worker-runs-archive.log" | tr -d ' ' || echo 0) row "haven/diagrams/*archive*" "$ARCHIVE_B" +row "doctrine/**/*archive*" "$DOCTRINE_ARCHIVE_B" row "evidence/implementer/" "$EVI_I_B" row "evidence/verifier/" "$EVI_V_B" row "evidence/worker-runs.log" "$TODO_LOG_B" -COLD_B=$(( ARCHIVE_B + EVI_I_B + EVI_V_B + TODO_LOG_B )) +row "evidence/worker-runs-archive.log" "$TODO_LOG_ARCHIVE_B" +COLD_B=$(( ARCHIVE_B + DOCTRINE_ARCHIVE_B + EVI_I_B + EVI_V_B + TODO_LOG_B + TODO_LOG_ARCHIVE_B )) row "= cold storage total" "$COLD_B" echo TOTAL_B=$(( SESSION_B + COLD_B )) @@ -76,33 +92,74 @@ if [ -f "$DIAG_FILE" ]; then POINTER_SEALED=$(grep -cE '— archived, see' "$DIAG_FILE" 2>/dev/null || echo 0) REAL_SEALED=$(( FULL_SEALED - POINTER_SEALED )) if [ "$DB" -gt 15360 ]; then - echo " ⚠ dev-loop.prime-mermaid.md is ${DB}B (>15KB threshold), $REAL_SEALED full SEALED entries not yet archived — consider moving nodes older than the current work session to haven/diagrams/dev-loop-archive.md" + echo " ⚠ dev-loop.prime-mermaid.md is ${DB}B (>15KB threshold), $REAL_SEALED full SEALED entries not yet archived." + echo " Ready-to-move rows (copy each VERBATIM into dev-loop-archive.md's" + echo " PM status table, then replace it here with a compact pointer row" + echo " '| node | state | date — archived, see dev-loop-archive.md. Evidence: ... |'):" + grep -E '\| SEALED \|' "$DIAG_FILE" 2>/dev/null | grep -vE '— archived, see' | sed 's/^/ /' else echo " ✓ dev-loop.prime-mermaid.md is ${DB}B, under the 15KB threshold ($REAL_SEALED full SEALED entries, $POINTER_SEALED archived pointers)" fi fi +check_threshold "$HUB/doctrine/domains/PROJECT.md" 15360 \ + "consider moving Traps/Decisions rows older than the current work session to doctrine/domains/PROJECT-archive.md" +check_threshold "$HUB/evidence/worker-runs.log" 15360 \ + "consider moving lines older than the current work session to evidence/worker-runs-archive.log (see evidence/README.md's archiving convention)" +echo +echo " Static reference files (should stay small by design — no accumulating" +echo " list to archive; growth here likely means misplaced content, not a" +echo " normal archive candidate):" +for f in "$HUB/doctrine/MEMORY.md" "$HUB/doctrine/SOUL.md" "$HUB/doctrine/INDEX.md" \ + "$HUB/doctrine/standards/edit-verification.md" "$HUB/doctrine/standards/recipes.md"; do + check_threshold "$f" 8192 \ + "unexpected growth for a static file — check for a Correction that belongs in the worker's own MEMORY.md, or a Decision that belongs in PROJECT.md, before creating a dedicated archive file for this one" +done ``` 2. Report the output verbatim — don't paraphrase the numbers into prose, the table is already the report. -3. If the flag fires (active diagram over 15KB), that's a real signal to - do an archive pass (see `haven/diagrams/dev-loop-archive.md`'s own - convention note, or the equivalent section in - `haven/diagrams/dev-loop.prime-mermaid.md`'s PM-status header) — but - this command itself never edits anything. Archiving is a separate, - explicit action. +3. If a flag fires on `dev-loop.prime-mermaid.md`, `PROJECT.md`, or + `worker-runs.log`, that's a real signal to do an archive pass (see + `dev-loop-archive.md` / `PROJECT-archive.md` / `worker-runs-archive.log`'s + own convention notes, or the equivalent header sections in the active + files) — but this command itself never edits anything. Archiving is a + separate, explicit action. [amended 2026-09-02] The diagram flag prints + the exact rows to move (not just "consider moving nodes") — copy-paste + is the whole remaining effort, so there's no excuse to defer it past the + current session the way a vague warning invites. +4. [added 2026-09-05] If a flag fires on one of the 5 static reference + files (`MEMORY.md`, `SOUL.md`, `INDEX.md`, `standards/*.md`), that's + NOT an archive signal — those files have no accumulating list and no + defined archive destination by design. Treat it as an anomaly: read the + file, find what's misplaced (a Correction that belongs in the worker's + own `MEMORY.md`, a Decision that belongs in `PROJECT.md`, a recipe that + belongs in `haven/workers//recipes/`), and move it to its one + correct home instead of inventing a new archive file for a file that + was never meant to grow. + +## If this hub uses epic sharding [added 2026-09-02] +If `haven/diagrams/index.md` exists (opt-in, see +`kit/agent-hub-templates.md` §9️⃣.3), `DIAG_ACTIVE_B` above sums bytes +across **every** `dev-loop-.prime-mermaid.md`, not just the one(s) +marked `active: true`. Treat "haven/diagrams/ (active file only)" as an +**upper bound** in that case, not the real per-session cost — `/boot` and +`pick_next` only read the active epic file(s) + `index.md`, per +`boot.md` step 5. This script doesn't parse `index.md`'s `active` column +(keeping it a plain byte-counting script, not a markdown-table parser) — +if you need the real per-session number under sharding, sum +`index.md` + only the active epic file(s) by hand. ## What the numbers mean - **Recurring per-session cost** — what a fresh implementer or verifier worker reads before touching any code. This is the number that actually compounds: every subagent spawned for a verify pass pays it again, from zero, with no cache reuse across separate agent contexts. -- **Cold storage** — `evidence/` and archived diagram rows. Large here is - normal and not itself a problem: `/boot` and `pick_next` only touch a - handful of the most recent evidence notes, not the whole directory. Only - worth worrying about if something starts reading it in bulk (e.g. a - recipe that globs all of `evidence/` instead of the specific notes it - needs). +- **Cold storage** — `evidence/` and archived rows from the diagram, + `PROJECT.md`, and `worker-runs.log`. Large here is normal and not itself + a problem: `/boot` and `pick_next` only touch a handful of the most + recent evidence notes, not the whole directory. Only worth worrying + about if something starts reading it in bulk (e.g. a recipe that globs + all of `evidence/` instead of the specific notes it needs). ## Runtime `/hub-tokens`. Read-only — no seal gate, no evidence note, no worker diff --git a/agent-hub/evidence/implementer/2026-09-05/add-logout-all-sessions-diff.md b/agent-hub/evidence/implementer/2026-09-05/add-logout-all-sessions-diff.md new file mode 100644 index 0000000..a0024bc --- /dev/null +++ b/agent-hub/evidence/implementer/2026-09-05/add-logout-all-sessions-diff.md @@ -0,0 +1,133 @@ +# 2026-09-05 — add-logout-all-sessions (plan + diff) + +- Worker: implementer +- Version: 0.1.0 +- Node: `add-logout-all-sessions` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- Task (verbatim): `/todo "#74"` — GitHub issue #74, "Log out of all + devices" — revoke all active sessions. Full issue body (problem + + proposal) passed through as the task text. + +## Hub bytes before: 49747 + +## Investigation (before touching code) +`pick_next` found no PENDING node for this task on the diagram. Per +`NodeBeforeCode`, before drafting a fresh node, grepped `src/` for any +prior work matching the issue's own vocabulary +(`tokenVersion`/`sessionsInvalidatedAt`/`logout-all`) to avoid duplicating +existing work: + +``` +grep -rn "logout-all\|logoutAll\|sessionsInvalidatedAt\|tokenVersion" src/ --include="*.ts" +``` + +Result: the feature is **already fully implemented and merged** — +`git log --oneline` shows `03bcb66 feat(auth): add logout-all endpoint to +revoke all sessions (#74)`, merged via `4cde424 Merge pull request #105 +from datvt243/feat/issue-74-logout-all-sessions` (2026-09-02), currently +on `staging` (`git status`: clean, up to date with `origin/staging`). No +diagram node or evidence note exists for it — a bookkeeping gap from +whatever session did that work, not a fresh implementation task. Verified +`git log -1 --format=%B 03bcb66` includes `Closes #74` — the GitHub issue +is still OPEN only because the merge landed on `staging`, not `main` +(auto-close fires on default-branch merge only, per +`doctrine/domains/PROJECT.md`'s recorded 2026-08-30 release-workflow +decision) — not a sign the work is incomplete. + +## Diff +No new `src/` changes — the implementation predates this session. Files +already in place (read, not modified, this pass): + +| File | Role | +|---|---| +| `src/utils/sessionRevocation.ts` | `invalidateAllSessions` / `getSessionsInvalidatedAt` / `isSessionRevoked` — Redis-with-in-memory-fallback "invalidated-before" timestamp, same shape as `tokenBlacklist.ts` | +| `src/middlewares/verifyToken.middleware.ts` | Rejects any token whose `iat` predates the candidate's last logout-all (`TokenRevokedError`) | +| `src/auth/auth.controller.ts` | `authLogoutAll` handler (calls `invalidateAllSessions`); `authRefreshToken` also rejects a stale refresh token the same way | +| `src/routers/api/v1/auth.route.ts` | `router.post('/logout-all', verifyToken, authLogoutAll)` + Swagger doc block | +| `src/locales/en.ts`, `src/locales/vi.ts` | `logoutAllSuccess` message, both languages | +| `src/__tests__/middlewares/verifyToken.test.ts`, `src/__tests__/auth/refreshToken.test.ts`, `src/__tests__/auth/auth.controller.test.ts` | Existing test coverage for the revocation check + `authLogoutAll` | + +Design note (from the code's own comments, `sessionRevocation.ts:1-18`): +deviates from the issue's `tokenVersion`-on-`Candidate`-model proposal on +purpose — avoids adding a Mongo field + an extra DB lookup per +authenticated request, reusing the existing Redis/mem blacklist pattern +instead. The issue text itself flagged this exact tradeoff as open +("worth measuring... before deciding the final design"), so this counts +as resolving that open question, not diverging from the ask. + +## Command +``` +npx tsc --noEmit +``` +Output: clean, no errors (no stdout). + +``` +npm run build +``` +Output: +``` +> resume-nodejs-api@1.2.1 build +> tsc && npm run copy + +> resume-nodejs-api@1.2.1 copy +> cp -R ./src/views ./src/public ./dist/ +``` +Clean, no errors. + +``` +npm test +``` +(from `/Users/_david/Workspace/Project/resume/resume-nodejs-api`, copied +verbatim from `doctrine/MEMORY.md`) + +Output (tail): +``` +PASS src/__tests__/auth/auth.controller.test.ts + auth.controller + authLogoutAll + ✓ invalidates all sessions for the authenticated candidate + ✓ fails when there is no authenticated user on the request (1 ms) + +Test Suites: 13 passed, 13 total +Tests: 77 passed, 77 total +Snapshots: 0 total +Time: 5.149 s +Ran all test suites. +``` +Full relevant section also includes (same run): +``` + logout-all (issue #74) + ✓ calls next with TokenRevokedError when token was issued before the last logout-all (1 ms) + ✓ calls next and attaches req.user when token was issued after the last logout-all + ✓ calls next with TokenRevokedError when a logout-all is in effect but the token has no iat +``` +(from `src/__tests__/middlewares/verifyToken.test.ts`) + +## Acceptance +| Criterion (from issue #74) | Evidence | +|---|---| +| A way to invalidate every outstanding token at once, not just the current one | `POST /api/v1/auth/logout-all` route registered (`auth.route.ts`), `authLogoutAll` controller calls `invalidateAllSessions(candidateId)` | +| Every previously-issued token instantly invalid, without enumerating/blacklisting each one | `sessionRevocation.ts` stores one per-candidate "invalidated-before" timestamp; `isSessionRevoked` compares every token's `iat` against it — O(1) regardless of how many tokens were issued | +| Checked on every authenticated request | `verifyToken.middleware.ts` calls `getSessionsInvalidatedAt` + `isSessionRevoked` before attaching `req.user`, same `TokenRevokedError` used for blacklisted tokens | +| Refresh path also covered (stolen long-lived refresh token) | `authRefreshToken` (`auth.controller.ts`) runs the identical check before minting a new pair — test: `'returns 403 when the refresh token predates the last logout-all (issue #74)'` in `refreshToken.test.ts` | +| Design tradeoff (extra Mongo lookup vs. cached read) actually decided, not left open | Redis/mem lookup chosen (matches existing blacklist check already on every request) — 0 new Mongo round trips, documented in the commit message and file header | +| `npm test` passes | See Command/Output above — 77/77, 13/13 suites | +| `npx tsc --noEmit` clean | See Command/Output above | +| `npm run build` clean | See Command/Output above | + +## Noticed, not done +- GitHub issue #74 is still shown OPEN by `gh issue list` — expected per + the `staging`→`main` release workflow (auto-close needs a `main` + merge), not a defect in this node. Will self-resolve on the next + `/release`, or can be closed manually by the operator now if desired — + out of scope for `/todo` to close issues itself. +- This node is a documentation/evidence backfill, not new code — flagging + for the verifier that "diff" here means "confirmed pre-existing," + matching `NodeBeforeCode`'s intent (a node must exist and be traceable) + even though the code came first in real history. + +## Seal gate +No outward-facing action taken this pass — no `commit`/`push` (nothing to +commit; only `agent-hub/` was written, which is not outward-facing per +`CLAUDE.md`). The `src/` code itself was already committed and merged in +a prior, separate session (PR #105) — that seal-gate approval, if any, +predates this note and is not re-litigated here. diff --git a/agent-hub/evidence/implementer/2026-09-05/add-pagination-filtering-cv-sections-diff.md b/agent-hub/evidence/implementer/2026-09-05/add-pagination-filtering-cv-sections-diff.md new file mode 100644 index 0000000..410878d --- /dev/null +++ b/agent-hub/evidence/implementer/2026-09-05/add-pagination-filtering-cv-sections-diff.md @@ -0,0 +1,133 @@ +# 2026-09-05 — add-pagination-filtering-cv-sections (plan + diff) + +- Worker: implementer +- Version: 0.1.0 +- Node: `add-pagination-filtering-cv-sections` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- Task (verbatim): `/todo "#73"` — GitHub issue #73, "Pagination and + filtering on CV section list endpoints." Full issue body (problem + + proposal) passed through as the task text. + +## Hub bytes before: 50684 + +## Investigation (before touching code) +`pick_next` found no PENDING node for this task on the diagram. Grepped +`src/` for the issue's own vocabulary first, to avoid duplicating existing +work: + +``` +grep -rn "baseFindDocument\|MAX_PAGE_LIMIT\|SORT_FIELD_REGEX" src/services/index.ts src/candidate_profile/BaseController.ts +``` + +Result: same situation as `add-logout-all-sessions`/#74 — the feature is +**already fully implemented and merged**. `git log --oneline` shows +`1133f1b feat(candidate_profile): add pagination and sort to CV section +list endpoints (#73)`, merged via `33bade6 Merge pull request #106 from +datvt243/feat/issue-73-pagination-filtering` (2026-09-02), currently on +`staging` (`git status`: clean, up to date with `origin/staging`). No +diagram node or evidence note exists for it. `git log -1 --format=%B +1133f1b` includes `Closes #73` — issue was still OPEN on GitHub only +because the merge landed on `staging`, not `main` (same documented +auto-close gap as #74); operator has since closed both #73 and #74 +manually via `gh issue close` in this session, ahead of the next +`/release`. + +## Diff +No new `src/` changes — the implementation predates this session. Files +already in place (read, not modified, this pass): + +| File | Role | +|---|---| +| `src/services/index.ts` | `baseFindDocument` gains `page`/`limit`/`sort` params; opt-in pagination — no `limit` keeps the old full-array behavior; valid `limit` (clamped to `MAX_PAGE_LIMIT = 100`) switches to `{ items, pagination: { page, limit, total, totalPages } }` via `.skip()`/`.limit()` + parallel `countDocuments()` | +| `src/candidate_profile/BaseController.ts` | `baseGetAll` parses `page`/`limit`/`sort` from `req.query`; `sort` validated against `SORT_FIELD_REGEX` (`/^-?[a-zA-Z0-9_.]+$/`) — no `$`, can't smuggle a Mongo operator, invalid value silently dropped rather than erroring | +| `src/config/swagger.config.ts` | Shared `PageParam`/`LimitParam`/`SortParam` + `Pagination` schema | +| `src/routers/api/v1/{education,experience,award,certificate,project,reference}.route.ts` | Wired the new query params into each section's `GET /` + Swagger docs. `generalInformation` excluded on purpose — its `GET /` returns one document per candidate, not a list | +| `src/__tests__/services/baseFindDocument.test.ts`, `src/__tests__/candidate_profile/BaseController.test.ts` | Existing test coverage for pagination/sort behavior | + +## Command +``` +npx tsc --noEmit +``` +Output: clean, no errors (no stdout). + +``` +npm run build +``` +Output: +``` +> resume-nodejs-api@1.2.1 build +> tsc && npm run copy + +> resume-nodejs-api@1.2.1 copy +> cp -R ./src/views ./src/public ./dist/ +``` +Clean, no errors. + +``` +npm test +``` +(from `/Users/_david/Workspace/Project/resume/resume-nodejs-api`, copied +verbatim from `doctrine/MEMORY.md`) + +Full-suite output (tail): +``` +Test Suites: 13 passed, 13 total +Tests: 77 passed, 77 total +Snapshots: 0 total +Time: 4.935 s +Ran all test suites. +``` + +Targeted re-run of the two files covering this node +(`npx jest src/__tests__/candidate_profile/BaseController.test.ts +src/__tests__/services/baseFindDocument.test.ts`): +``` +PASS src/__tests__/services/baseFindDocument.test.ts + baseFindDocument + ✓ fails fast when fields is empty (3 ms) + ✓ findOne: true returns a single document via MODEL.findOne, untouched by pagination (2 ms) + ✓ findOne: false, no limit -> returns the full array unchanged (backward compatible) (1 ms) + ✓ findOne: false, with a valid limit -> paginates and wraps data as { items, pagination } + ✓ clamps limit to the max page size + ✓ defaults page to 1 when page is missing or invalid + ✓ applies sort when given, with or without pagination + +PASS src/__tests__/candidate_profile/BaseController.test.ts + baseGetAll + ✓ passes page/limit/sort through as numbers/string when present (6 ms) + ✓ omits page/limit/sort when the query string has none (backward compatible) (1 ms) + ✓ silently drops a sort value that could smuggle a Mongo operator + ✓ accepts a leading "-" in sort for descending order (1 ms) + +Test Suites: 2 passed, 2 total +Tests: 11 passed, 11 total +``` + +## Acceptance +| Criterion (from issue #73) | Evidence | +|---|---| +| Optional `page`/`limit` on CV section list endpoints, defaulting to returning everything if omitted (backward compatible) | `baseFindDocument`'s `hasPagination` check — no valid `limit` → identical old-behavior `query.exec()` path; test: `'findOne: false, no limit -> returns the full array unchanged (backward compatible)'` | +| `.skip()`/`.limit()` added to the underlying query | `services/index.ts`: `query.skip(skip).limit(safeLimit).exec()` | +| Optional `sort` param | `BaseController.ts` `SORT_FIELD_REGEX` allowlist + `query.sort(sort)`; tests: `'applies sort when given...'`, `'accepts a leading "-" in sort for descending order'`, `'silently drops a sort value that could smuggle a Mongo operator'` | +| No unbounded page size | `MAX_PAGE_LIMIT = 100` clamp; test: `'clamps limit to the max page size'` | +| `npm test` passes | Full suite 77/77, targeted re-run 11/11 — see Command/Output above | +| `npx tsc --noEmit` clean | See Command/Output above | +| `npm run build` clean | See Command/Output above | + +## Noticed, not done +- `generalInformation` intentionally has no pagination — it's a + single-document-per-candidate resource, not a list; matches the + original proposal's scope (list endpoints only), not a gap. +- This node is a documentation/evidence backfill, not new code — same + situation as `add-logout-all-sessions`, flagging for the verifier that + "diff" here means "confirmed pre-existing," per `NodeBeforeCode`'s + intent even though the code came first in real history. +- GitHub issue #73 was already closed manually by the operator (via + `gh issue close`) before this backfill note was written — not this + node's action, recorded here only for the timeline. + +## Seal gate +No outward-facing action taken this pass — no `commit`/`push` (nothing to +commit; only `agent-hub/` was written, not outward-facing per +`CLAUDE.md`). The `src/` code itself was already committed and merged in +a prior, separate session (PR #106) — that seal-gate approval, if any, +predates this note and is not re-litigated here. diff --git a/agent-hub/evidence/verifier/2026-09-05/add-logout-all-sessions-seal.md b/agent-hub/evidence/verifier/2026-09-05/add-logout-all-sessions-seal.md new file mode 100644 index 0000000..57656dc --- /dev/null +++ b/agent-hub/evidence/verifier/2026-09-05/add-logout-all-sessions-seal.md @@ -0,0 +1,82 @@ +# 2026-09-05 — add-logout-all-sessions (verdict) + +- Worker: verifier (subagent, dispatched via Agent tool) +- Node: `add-logout-all-sessions` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- New PM status: **SEALED** (was PENDING) + +## Reasoning + +Evidence note graded: `evidence/implementer/2026-09-05/add-logout-all-sessions-diff.md`. + +1. **Node exists and is traceable** (`NodeBeforeCode` intent). Confirmed + the row `add-logout-all-sessions` on `haven/diagrams/dev-loop.prime-mermaid.md:55` + (PENDING at time of grading), description matches the note's claims + verbatim (issue #74, PR #105, commit `03bcb66`, design tradeoff). The + code historically preceded the node (admitted openly in the note, not + concealed) — treating this as satisfying the intent, not violating it: + the doctrine's actual goal is traceability, and traceability now + exists and is honest about the gap's origin. Refusing to ever backfill + would leave already-merged code permanently untraceable, which serves + the doctrine's goal worse than an honest catch-up note does. +2. **Command matches `doctrine/MEMORY.md`.** Note cites `npm test` from + repo root — matches doctrine exactly. (`npx tsc --noEmit` is an extra + check beyond doctrine's table, not a substitution for it — fine.) +3. **Output not truncated.** Note's `npm test` output shows full summary + line (`Test Suites: 13 passed, 13 total` / `Tests: 77 passed, 77 + total`), not just a `...`-elided fragment. +4. **Acceptance criteria walked one at a time** (8 rows in the note's + `## Acceptance` table, drawn from issue #74's own requirements): each + cites a specific file/test, not a bare "tests pass" claim. All 8 have + concrete evidence. +5. **Proportion / SmallestDiff.** Zero new `src/` changes this pass — the + note is explicit that the feature predates this session. This is the + smallest possible diff for a bookkeeping node: none. +6. **Seal gate.** Correctly recorded as "none" outward-facing this pass + (no commit/push; `agent-hub/` writes are not outward-facing per + `CLAUDE.md`). The `src/` commit itself (PR #105) was already merged in + a prior session, outside this note's scope to re-litigate. + +### Forbidden states scanned (all 5) + +- **ADHOC_WORK** — No `src/` was touched this session (confirmed: `git + status` shows only `agent-hub/haven/diagrams/dev-loop.prime-mermaid.md` + modified + new `agent-hub/evidence/implementer/2026-09-05/` — no `src/` + diff). The historical PR #105 merge landing without a node at the time + is exactly the gap this node exists to close; this pass itself does not + repeat that gap since it creates the node/evidence *before* claiming + done. Not triggered for this node's own action. +- **NO_EVIDENCE** — Evidence note exists at the cited path, read in full. + Not triggered. +- **EDIT_UNVERIFIED** — Independently re-ran `npm test` and `npm run + build` myself (see `## Re-run` below) from + `/Users/_david/Workspace/Project/resume/resume-nodejs-api`; got + identical results to the note (`Test Suites: 13 passed, 13 total`, + `Tests: 77 passed, 77 total`; build clean, exit 0). Not triggered. +- **CODE_IN_HAVEN** — `git diff --stat agent-hub/haven` shows exactly one + line added to `dev-loop.prime-mermaid.md` (the new table row) — no + `.ts`/`.js`/config files under `haven/`. Not triggered. +- **DIAGRAM_DRIFT** — Before this verdict, the diagram undersold reality + (code+tests existed, node said PENDING with no evidence pointer). This + verdict closes that drift by moving the row to SEALED, matching the + real, independently-confirmed code state. Not triggered after this + update; would have been the correct call to make if I had not sealed. + +## Re-run + +`partial` — independently re-ran `npm test` and `npm run build` (exact +commands from `doctrine/MEMORY.md`) myself, in addition to auditing the +note. Reason: this node is unusual (code merged before any node/evidence +existed, per the note's own flag), so beyond the recipe's audit-only +default I chose to independently confirm the two most load-bearing +citations rather than trust them solely from the note, per the +orchestrator's explicit discretion to do so. Not a `full` re-run (no +fresh `npm ci` / isolated worktree) since this node is not outward-facing +and not a `/release` gate — the "Re-run scope" exceptions in +`verify_seal.md` don't otherwise apply. + +Also independently confirmed via `git log`: commit `03bcb66` and merge +`4cde424` (PR #105) exist on `staging`, and all 9 files the note cites +(`src/utils/sessionRevocation.ts`, `verifyToken.middleware.ts`, +`auth.controller.ts`, `auth.route.ts`, `en.ts`/`vi.ts`, 3 test files) +exist on disk with the claimed symbols (`authLogoutAll`, +`router.post('/logout-all', ...)`). diff --git a/agent-hub/evidence/verifier/2026-09-05/add-pagination-filtering-cv-sections-seal.md b/agent-hub/evidence/verifier/2026-09-05/add-pagination-filtering-cv-sections-seal.md new file mode 100644 index 0000000..374615e --- /dev/null +++ b/agent-hub/evidence/verifier/2026-09-05/add-pagination-filtering-cv-sections-seal.md @@ -0,0 +1,105 @@ +# 2026-09-05 — add-pagination-filtering-cv-sections (verdict) + +- Worker: verifier (subagent, dispatched via Agent tool) +- Node: `add-pagination-filtering-cv-sections` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- New PM status: **SEALED** (was PENDING) + +## Reasoning + +Evidence note graded: `evidence/implementer/2026-09-05/add-pagination-filtering-cv-sections-diff.md`. + +1. **Node exists and is traceable** (`NodeBeforeCode` intent). Confirmed + the row `add-pagination-filtering-cv-sections` existed on + `haven/diagrams/dev-loop.prime-mermaid.md` (PENDING at time of + grading), description matches the note's claims (issue #73, PR #106, + commit `1133f1b`). Same acknowledged pattern as the precedent + (`add-logout-all-sessions`/#74, `evidence/verifier/2026-09-05/add-logout-all-sessions-seal.md`): + code merged before any node/evidence existed, openly admitted in the + note rather than concealed. Backfilling closes the traceability gap + instead of leaving already-merged code permanently untraceable — + applying the same reasoning independently here, not deferring to the + precedent's verdict. +2. **Command matches `doctrine/MEMORY.md`.** Note cites `npm test` from + repo root and `npx jest ` for the targeted re-run — both match + doctrine's table exactly. (`npx tsc --noEmit` is an extra check beyond + doctrine's table, not a substitution.) +3. **Output not truncated.** Note's `npm test` output shows the full + summary line (`Test Suites: 13 passed, 13 total` / `Tests: 77 passed, + 77 total`), and the targeted re-run shows every individual test name, + not an elided fragment. +4. **Acceptance criteria walked one at a time** (7 rows in the note's + `## Acceptance` table, drawn from issue #73's own requirements). Each + cites a specific file/line or test name. Independently confirmed every + one by reading the actual code, not just trusting the note: + - Backward-compatible opt-in pagination — `src/services/index.ts:68-69` + `hasPagination` check (`Number.isInteger(limit) && limit > 0`) gates + the whole pagination path. + - `.skip()`/`.limit()` wired in — `src/services/index.ts:78`: + `query.skip(skip).limit(safeLimit).exec()`. + - `sort` allowlist — `src/candidate_profile/BaseController.ts:20`: + `SORT_FIELD_REGEX = /^-?[a-zA-Z0-9_.]+$/`, applied at line 51. + - Page-size cap — `src/services/index.ts:22`: `MAX_PAGE_LIMIT = 100`. + - Swagger params actually wired into a section router, not just + declared — `src/config/swagger.config.ts:36-71` defines + `PageParam`/`LimitParam`/`SortParam`/`Pagination`; spot-checked + `src/routers/api/v1/education.route.ts:22-25` references all three + via `$ref`. +5. **Proportion / SmallestDiff.** Zero new `src/` changes this pass + (`git diff --stat -- src/` returned empty) — the note is explicit the + feature predates this session. Smallest possible diff for a bookkeeping + node: none. +6. **Seal gate.** Correctly recorded as "none" outward-facing this pass + (no commit/push this session; `agent-hub/` writes are not + outward-facing per `CLAUDE.md`). The `src/` commit itself (PR #106) + was already merged in a prior session, outside this note's scope to + re-litigate. + +### Forbidden states scanned (all 5) + +- **ADHOC_WORK** — No `src/` touched this session: `git diff --stat -- + src/` is empty. The only working-tree changes are + `agent-hub/haven/diagrams/dev-loop.prime-mermaid.md` (this node's row + + the sibling `add-logout-all-sessions` row, both markdown bookkeeping) + plus new files under `agent-hub/evidence/`. The historical PR #106 + merge landing without a node at the time is exactly the gap this node + exists to close; this pass creates the node/evidence before claiming + done, not after. Not triggered. +- **NO_EVIDENCE** — Evidence note exists at the cited path, read in full, + cites the diff/commands/output. Not triggered. +- **EDIT_UNVERIFIED** — Independently re-ran, not inferred: `npm test` + (`Test Suites: 13 passed, 13 total` / `Tests: 77 passed, 77 total`, + matching the note exactly), the targeted + `npx jest src/__tests__/candidate_profile/BaseController.test.ts + src/__tests__/services/baseFindDocument.test.ts` (2/2 suites, 11/11 + tests, identical to the note), `npx tsc --noEmit` (exit 0, no output), + and `npm run build` (exit 0, `tsc && npm run copy` clean). Also + independently confirmed via `git log`: commit `1133f1b` exists, merge + `33bade6` (PR #106) exists, `git merge-base --is-ancestor 1133f1b HEAD` + → `YES-ancestor` on current branch `staging`. Not triggered. +- **CODE_IN_HAVEN** — `git diff --stat agent-hub/haven` shows exactly one + file, `dev-loop.prime-mermaid.md`, +2 lines (two markdown table rows) — + no `.ts`/`.js`/config files under `haven/`. Not triggered. +- **DIAGRAM_DRIFT** — Before this verdict, the diagram undersold reality + (code + tests existed on `staging` since 2026-09-02, node said PENDING + with no evidence pointer). This verdict closes that drift by moving the + row to SEALED, matching the real, independently-confirmed code state. + Ratchet respected: PENDING → SEALED, no other row touched or demoted + (confirmed via the diff shown above — only this node's row content + changed, `add-logout-all-sessions`'s SEALED row from the prior pass was + already present and untouched by this edit). + +## Re-run + +`partial` — independently re-ran `npm test`, the targeted `npx jest` +two-file command, `npx tsc --noEmit`, and `npm run build` (all exact +commands from `doctrine/MEMORY.md` plus the note's own targeted files), +in addition to auditing the note and reading the actual `src/` code +behind every acceptance-criterion citation. Reason: this node is unusual +(code merged before any node/evidence existed, per the note's own flag), +so beyond the recipe's audit-only default I chose to independently +confirm the load-bearing citations rather than trust them solely from the +note — same discretion the precedent (`add-logout-all-sessions`) used, +applied independently here. Not a `full` re-run (no fresh `npm ci` / +isolated worktree) since this node is not outward-facing and not a +`/release` gate — the "Re-run scope" exceptions in `verify_seal.md` +don't otherwise apply. diff --git a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md index 3203b77..2810e7c 100644 --- a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md +++ b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md @@ -52,6 +52,8 @@ flowchart TD | Node | State | Notes | |---|---|---| | `add-docx-export-format` | SEALED | 2026-09-02 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-09-02/add-docx-export-format-diff.md`. | +| `add-pagination-filtering-cv-sections` | SEALED | GitHub issue #73. `page`/`limit`/`sort` query params on the 6 CV-section list endpoints (education/experience/award/certificate/project/reference — `generalInformation` excluded, its `GET /` returns a single document, not a list). Code already implemented and merged to `staging` via PR #106 (commit `1133f1b`, 2026-09-02) with no matching diagram node/evidence note at the time (bookkeeping gap, backfilled now by `/todo "#73"`, same pattern as `add-logout-all-sessions`/#74). Opt-in and backward compatible: omitting `limit` returns the exact old unpaginated array (`services/index.ts` `baseFindDocument`); a valid `limit` (capped at 100, `MAX_PAGE_LIMIT`) switches `data` to `{ items, pagination }`. `sort` validated against `SORT_FIELD_REGEX` allowlist in `BaseController.ts` — no `$`, can't smuggle a Mongo operator. Issue stays OPEN on GitHub because the merge landed on `staging`, not the default branch (`main`) — same expected auto-close gap as #74, not a bug. Verified 2026-09-05, evidence: `evidence/verifier/2026-09-05/add-pagination-filtering-cv-sections-seal.md`. | +| `add-logout-all-sessions` | SEALED | GitHub issue #74. `POST /api/v1/auth/logout-all` — code already implemented and merged to `staging` via PR #105 (commit `03bcb66`, 2026-09-02) with no matching diagram node/evidence note at the time (bookkeeping gap, backfilled now by `/todo "#74"`). Design deviates from the issue's `tokenVersion`-on-`Candidate` proposal: reuses the Redis/mem "invalidated-before" timestamp shape from `tokenBlacklist.ts` (`src/utils/sessionRevocation.ts`), compared against the JWT's standard `iat` in `verifyToken.middleware.ts` + `authRefreshToken` — no schema change, no extra Mongo lookup. Issue stays OPEN on GitHub because the merge landed on `staging`, not the default branch (`main`); auto-close via `Closes #74` fires only on a `main` merge per the documented release workflow — expected, not a bug. Verified 2026-09-05, evidence: `evidence/verifier/2026-09-05/add-logout-all-sessions-seal.md`. | | `agent-hub-token-cleanup-20260830` | SEALED | 2026-08-30 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-30/agent-hub-token-cleanup-diff.md`. | | `fix-chrome-executable-path` | PENDING | `src/services/createPDF.ts:14-25` — Chrome executable path hardcoded, breaks PDF export in CI/Docker. See Traps in `doctrine/domains/PROJECT.md`. First candidate node. | | `fix-idor-broken-access-control` | PENDING | **Critical.** All CRUD APIs for candidate_profile (education/experience/award/certificate/project/reference/generalInformation) + `candidate.service.ts` + `fnExportPDF` never cross-check `candidateId`/`_id` against `req.user._id` (JWT) — they trust client-supplied `req.body.candidateId`/`_id`. Live-tested confirmed: User B could read/delete/edit User A's data, overwrite A's profile. Root cause: `verifyToken.middleware.ts` sets `req.user` but nothing cross-checks it. Found while testing the full API (task: "test the whole API again"). | From 3bad45f5002f8e3961221c29e0567b5d9e390442 Mon Sep 17 00:00:00 2001 From: _david Date: Sun, 6 Sep 2026 05:13:40 +0700 Subject: [PATCH 8/8] chore(release): bump version to v1.3.0 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Hdp892ahsFcZfmM5g2yDBA --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 819f01e..1e9b44f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "resume-nodejs-api", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "resume-nodejs-api", - "version": "1.2.1", + "version": "1.3.0", "license": "ISC", "dependencies": { "@babel/runtime": "^7.22.10", diff --git a/package.json b/package.json index 49d2a13..ef42cb7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "resume-nodejs-api", "main": "src/server.ts", "private": true, - "version": "1.2.1", + "version": "1.3.0", "description": "Resume API backend with rate limiting and Redis support", "scripts": { "dev-node": "ts-node -r tsconfig-paths/register src/server.ts",