From 332014a913eb92bf948e31a52287013a8edf8d05 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:04:42 -0700 Subject: [PATCH 1/3] fix: reject nonportable chat timestamps (TASK-479) Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/controllers/messageController.test.js | 12 ++++++++++++ backend/__tests__/unit/models/Message.test.js | 18 ++++++++++++++++++ backend/controllers/messageController.ts | 11 +++++++++++ backend/models/Message.ts | 9 ++++++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/controllers/messageController.test.js b/backend/__tests__/unit/controllers/messageController.test.js index eaafe8393a..df707702b4 100644 --- a/backend/__tests__/unit/controllers/messageController.test.js +++ b/backend/__tests__/unit/controllers/messageController.test.js @@ -70,6 +70,18 @@ describe('messageController', () => { expect(res.status).toHaveBeenCalledWith(400); }); + it.each(['+078915-01-01T00:00:00.000Z', '0000-01-01T00:00:00Z', 'garbage', ['2026-01-01', '2026-01-02']])( + 'rejects invalid pagination timestamp %j without querying either message store', async (before) => { + PGMessage.findByPodId.mockClear(); + MongoMessage.find.mockClear(); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await messageController.getMessages({ params: { podId: 'p1' }, query: { before }, user: { id: 'u1' } }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(PGMessage.findByPodId).not.toHaveBeenCalled(); + expect(MongoMessage.find).not.toHaveBeenCalled(); + }, + ); + it('returns 404 if pod not found', async () => { Pod.findById.mockResolvedValue(null); const req = { params: { podId: 'p1' }, query: {}, user: { id: 'u1' } }; diff --git a/backend/__tests__/unit/models/Message.test.js b/backend/__tests__/unit/models/Message.test.js index 0bc02ef248..0d1af03c10 100644 --- a/backend/__tests__/unit/models/Message.test.js +++ b/backend/__tests__/unit/models/Message.test.js @@ -71,6 +71,24 @@ describe('Message Model Tests', () => { expect(savedMessage.updatedAt).toBeDefined(); }); + it.each(['+078915-01-01T00:00:00.000Z', '0000-12-31T23:59:59.999Z'])( + 'rejects nonportable createdAt %s before persistence', async (createdAt) => { + await expect(Message.create({ podId: testPod._id, userId: testUser._id, + content: 'Must not poison pagination', createdAt })).rejects.toMatchObject({ + name: 'ValidationError', errors: { createdAt: expect.anything() }, + }); + expect(await Message.countDocuments({ podId: testPod._id })).toBe(0); + }, + ); + + it('accepts four-digit-year timestamp boundaries without changing them', async () => { + for (const createdAt of ['0001-01-01T00:00:00.000Z', '9999-12-31T23:59:59.999Z']) { + const message = new Message({ podId: testPod._id, userId: testUser._id, content: 'Boundary', createdAt }); + await expect(message.validate()).resolves.toBeUndefined(); + expect(message.createdAt.toISOString()).toBe(createdAt); + } + }); + it('should not save a message without required fields', async () => { // Test missing podId const messageWithoutPodId = new Message({ diff --git a/backend/controllers/messageController.ts b/backend/controllers/messageController.ts index 56230608ef..83efbc34c9 100644 --- a/backend/controllers/messageController.ts +++ b/backend/controllers/messageController.ts @@ -86,6 +86,17 @@ exports.getMessages = async (req: AuthRequest, res: Response): Promise => try { const { podId } = req.params; const { before } = req.query as { before?: string }; + // Legacy Mongo rows can still supply an extended-year cursor. Refuse it + // before PG errors and the whole request falls back to Mongo. + if (before !== undefined) { + const cursor = typeof before === 'string' && before.trim() ? new Date(before) : null; + if (!cursor || !Number.isFinite(cursor.getTime()) + || cursor.getUTCFullYear() < 1 || cursor.getUTCFullYear() > 9999) { + res.status(400).json({ msg: 'before must be a valid timestamp with a year from 0001 to 9999' }); + return; + } + } + // Clamp the page size [1, 200]. Unbounded `limit` let a caller ask for // millions of rows → OOM (both the PG and Mongo read paths below). Mirrors // the postController.getPosts clamp pattern. diff --git a/backend/models/Message.ts b/backend/models/Message.ts index 9af8e046aa..2c66dda65d 100644 --- a/backend/models/Message.ts +++ b/backend/models/Message.ts @@ -53,7 +53,14 @@ const MessageSchema = new Schema({ authorKind: { type: String, enum: ['kernel', null], default: null }, payload: { type: Schema.Types.Mixed }, metadata: { type: Schema.Types.Mixed }, - createdAt: { type: Date, default: Date.now }, + // Client pagination and the PG timestamp parser require four-digit years. + // JS/Mongo accept extended ISO years (e.g. +078915), which PG rejects when + // a client sends the stored value back as its pagination cursor. + createdAt: { + type: Date, default: Date.now, required: true, + min: new Date('0001-01-01T00:00:00.000Z'), + max: new Date('9999-12-31T23:59:59.999Z'), + }, updatedAt: { type: Date, default: Date.now }, // New Mongo-only writes start pending. The post-save hook stamps a server // commit marker after the insert is acknowledged; a crash before that stamp From 16c0150bcacf849b83469ae47d4e1c8be7f9546d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:16:30 -0700 Subject: [PATCH 2/3] test: round-trip accepted chat date boundaries through Mongo Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- backend/__tests__/unit/models/Message.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/models/Message.test.js b/backend/__tests__/unit/models/Message.test.js index 0d1af03c10..c70cdc376b 100644 --- a/backend/__tests__/unit/models/Message.test.js +++ b/backend/__tests__/unit/models/Message.test.js @@ -84,8 +84,13 @@ describe('Message Model Tests', () => { it('accepts four-digit-year timestamp boundaries without changing them', async () => { for (const createdAt of ['0001-01-01T00:00:00.000Z', '9999-12-31T23:59:59.999Z']) { const message = new Message({ podId: testPod._id, userId: testUser._id, content: 'Boundary', createdAt }); - await expect(message.validate()).resolves.toBeUndefined(); - expect(message.createdAt.toISOString()).toBe(createdAt); + await message.save(); + const reloaded = await Message.findById(message._id).lean(); + expect(reloaded.createdAt.toISOString()).toBe(createdAt); + expect(String(reloaded.podId)).toBe(String(testPod._id)); + expect(String(reloaded.userId)).toBe(String(testUser._id)); + expect(reloaded.content).toBe('Boundary'); + expect(reloaded.messageType).toBe('text'); } }); From 750281c1b4f414e4384cef3a10874cfa84fd5a54 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:21:09 -0700 Subject: [PATCH 3/3] fix: validate chat cursor calendar before PG query Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- .../controllers/messageController.test.js | 17 ++++++++++++++- backend/controllers/messageController.ts | 21 +++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/controllers/messageController.test.js b/backend/__tests__/unit/controllers/messageController.test.js index df707702b4..d17a8fd63c 100644 --- a/backend/__tests__/unit/controllers/messageController.test.js +++ b/backend/__tests__/unit/controllers/messageController.test.js @@ -70,7 +70,7 @@ describe('messageController', () => { expect(res.status).toHaveBeenCalledWith(400); }); - it.each(['+078915-01-01T00:00:00.000Z', '0000-01-01T00:00:00Z', 'garbage', ['2026-01-01', '2026-01-02']])( + it.each(['2019-02-30', '+002026-01-01T00:00:00.000Z', '1900-02-29', '2026-04-31', '+078915-01-01T00:00:00.000Z', '0000-01-01T00:00:00Z', 'garbage', ['2026-01-01', '2026-01-02']])( 'rejects invalid pagination timestamp %j without querying either message store', async (before) => { PGMessage.findByPodId.mockClear(); MongoMessage.find.mockClear(); @@ -82,6 +82,21 @@ describe('messageController', () => { }, ); + it.each([ + ['2024-02-29', '2024-02-29T00:00:00.000Z'], + ['2026-10-24T12:30:15.123Z', '2026-10-24T12:30:15.123Z'], + ['2026-10-24T12:30:15+02:00', '2026-10-24T10:30:15.000Z'], + ])('keeps a valid calendar cursor %s on the PG path', async (before, canonical) => { + Pod.findById.mockResolvedValue({ members: ['u1'] }); + PGMessage.findByPodId.mockResolvedValue([]); + MongoMessage.find.mockClear(); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await messageController.getMessages({ params: { podId: 'p1' }, query: { before }, user: { id: 'u1' } }, res); + expect(PGMessage.findByPodId).toHaveBeenLastCalledWith('p1', 50, canonical); + expect(MongoMessage.find).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith([]); + }); + it('returns 404 if pod not found', async () => { Pod.findById.mockResolvedValue(null); const req = { params: { podId: 'p1' }, query: {}, user: { id: 'u1' } }; diff --git a/backend/controllers/messageController.ts b/backend/controllers/messageController.ts index 83efbc34c9..397f174e0d 100644 --- a/backend/controllers/messageController.ts +++ b/backend/controllers/messageController.ts @@ -85,16 +85,29 @@ const normalizeMongo = (m: Record): NormalizedMessage => { exports.getMessages = async (req: AuthRequest, res: Response): Promise => { try { const { podId } = req.params; - const { before } = req.query as { before?: string }; + let { before } = req.query as { before?: string }; // Legacy Mongo rows can still supply an extended-year cursor. Refuse it // before PG errors and the whole request falls back to Mongo. if (before !== undefined) { - const cursor = typeof before === 'string' && before.trim() ? new Date(before) : null; - if (!cursor || !Number.isFinite(cursor.getTime()) + // Date.parse normalizes February 30 and accepts six-digit signed + // years that PG rejects. Validate calendar fields before parsing. + const match = typeof before === 'string' + ? /^(\d{4})-(\d{2})-(\d{2})(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d))?$/.exec(before) + : null; + const year = Number(match?.[1]); + const month = Number(match?.[2]); + const day = Number(match?.[3]); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const cursor = match ? new Date(before) : null; + if (!cursor || !Number.isFinite(cursor.getTime()) || year < 1 + || month < 1 || month > 12 || day < 1 || day > days[month - 1] || cursor.getUTCFullYear() < 1 || cursor.getUTCFullYear() > 9999) { - res.status(400).json({ msg: 'before must be a valid timestamp with a year from 0001 to 9999' }); + res.status(400).json({ msg: 'before must be a valid ISO date or timestamp with a year from 0001 to 9999' }); return; } + // Send one portable representation to PG, including for offset inputs. + before = cursor.toISOString(); } // Clamp the page size [1, 200]. Unbounded `limit` let a caller ask for