From 848282d31468fd3b26bdb8481c763d10fc857621 Mon Sep 17 00:00:00 2001 From: Pluso Date: Wed, 7 Oct 2026 14:05:22 +0000 Subject: [PATCH] fix(platform): return operator session expiry as ISO UTC from confirmation GET operator_sessions.expires_at is timestamp without time zone; the string-mode column mapping returns SQL text ('2026-10-07 12:13:32') while the elevation POST serializes with toISOString(). The iOS admin gate validates ISO UTC and rejected the confirmation response (admin-session-confirm incompatibleResponse, status 200). Parse the timezone-less wall time explicitly as UTC and serialize with toISOString() so confirmation returns the same instant and the same text the elevation issued. Adds a regression test that round-trips the expiry through the real database conversion and asserts both responses carry byte-identical ISO text plus the full shape the app validates. Observed: fails on the old code ('2026-10-07 12:13:32' vs '2026-10-07T12:13:32.000Z'), passes 8/8 with the fix. --- platform/packages/api/src/operator/auth.ts | 10 +++- .../test/integration/operator-auth.test.ts | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/platform/packages/api/src/operator/auth.ts b/platform/packages/api/src/operator/auth.ts index fdf0bd22e..2cdc84498 100644 --- a/platform/packages/api/src/operator/auth.ts +++ b/platform/packages/api/src/operator/auth.ts @@ -117,9 +117,17 @@ operatorSessionRoutes.post('/session', async (context) => { export const authenticatedSessionRoutes = new Hono(); authenticatedSessionRoutes.get('/session', (context) => { const user = context.get('user'); + // operator_sessions.expires_at is a timestamp without time zone whose wall + // time is UTC by convention: the POST above inserts a UTC ISO string. The + // string-mode column mapping returns it in SQL text form + // ("YYYY-MM-DD HH:MM:SS.sss"), so parse it explicitly as UTC and serialize + // with toISOString() — the response then carries the same instant and the + // same ISO text the POST returned. + const storedExpiry: string = context.get('operatorSession').expiresAt; + const expiresAt = new Date(`${storedExpiry.replace(' ', 'T')}+00:00`).toISOString(); return context.json({ apiVersion: 1, - expiresAt: context.get('operatorSession').expiresAt, + expiresAt, user: { id: user.id, login: user.login, role: user.role }, capabilities: getPlatformCapabilities(context.env), hostedWorkerOperations: !context.env.PLATFORM_INSTALLATION_ID, diff --git a/platform/packages/api/test/integration/operator-auth.test.ts b/platform/packages/api/test/integration/operator-auth.test.ts index 44b1e82a0..8453ca650 100644 --- a/platform/packages/api/test/integration/operator-auth.test.ts +++ b/platform/packages/api/test/integration/operator-auth.test.ts @@ -71,6 +71,56 @@ describe('operator authentication', () => { ).toBe(401); }); + test('confirmation repeats the exact ISO UTC expiry the elevation issued', async () => { + const admin = await createTestUser( + env.db, + 'operator-expiry', + 'github', + undefined, + 'free', + 'admin', + ); + const created = await request('/session', admin.jwt, {}); + expect(created.status).toBe(200); + const createdBody = (await created.json()) as { token: string; expiresAt: string }; + + const confirmed = await request('/session', createdBody.token); + expect(confirmed.status).toBe(200); + const confirmedBody = (await confirmed.json()) as { + apiVersion: number; + expiresAt: string; + user: { id: string; login: string; role: string }; + capabilities: Record; + hostedWorkerOperations: unknown; + }; + + // The expiry round-trips through Postgres as a timestamp without time zone, + // and the string-mode column mapping reformats it as SQL text; the handler + // must serialize it back to the ISO UTC text the admin app pins. Assert the + // real conversion result, not a fixture. + const isoUtc = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + expect(createdBody.expiresAt).toMatch(isoUtc); + expect(confirmedBody.expiresAt).toBe(createdBody.expiresAt); + expect(Date.parse(confirmedBody.expiresAt)).toBe(Date.parse(createdBody.expiresAt)); + + // The remaining shape the admin app validates before accepting a session. + expect(confirmedBody.apiVersion).toBe(1); + expect(confirmedBody.user).toEqual({ id: admin.id, login: admin.login, role: 'admin' }); + for (const name of [ + 'ai', + 'customDomains', + 'email', + 'githubActions', + 'githubOAuth', + 'googleOAuth', + 'managedBuilds', + 'sandbox', + ]) { + expect(typeof confirmedBody.capabilities[name]).toBe('boolean'); + } + expect(typeof confirmedBody.hostedWorkerOperations).toBe('boolean'); + }); + test('keeps login, elevation, authenticated drain, session, and logout usable in drain maintenance', async () => { const admin = await createTestUser( env.db,