Skip to content

Commit faea0ec

Browse files
BrooooooklynPluso
andauthored
fix(platform): return operator session expiry as ISO UTC from confirmation GET (#383)
## Problem `POST /operator/v1/session` (elevation) serializes `expiresAt` with `toISOString()` → `2026-10-07T12:13:32.000Z`. `GET /operator/v1/session` (confirmation) passed the column value through raw: `operator_sessions.expires_at` is `timestamp without time zone`, and the string-mode column mapping returns SQL text `2026-10-07 12:13:32` — same instant, different text. The iOS admin gate validates ISO UTC and rejected the confirmation response (`admin-session-confirm incompatibleResponse`, status 200). ## Fix In the confirmation handler only: parse the timezone-less wall time explicitly as UTC (`T` separator, `+00:00` — no assumed bare suffix, no runtime-local guessing) and serialize with `toISOString()`. The response now carries the same instant and the same ISO text the elevation issued. The middleware context value is untouched and nothing else reads it, so blast radius is this one response. ## Test evidence (observed, not expected) New regression test round-trips the expiry through the real database conversion (PGlite) and asserts both responses carry byte-identical ISO text plus the full shape the app validates (apiVersion 1, same user, eight capability flags as booleans, hostedWorkerOperations a boolean). - Old code: 7 of 8 pass; the new test fails with the app's exact complaint — `expected '2026-10-07 12:13:32' to be '2026-10-07T12:13:32.000Z'` - With this patch: all 8 of 8 pass — elevation, drain maintenance, role/suspension enforcement, forgery rejection, dev-bypass rejection, and input validation all unaffected ``` vp test run --project platform-api-integration platform/packages/api/test/integration/operator-auth.test.ts ``` Co-authored-by: Pluso <pluso@airbuild.local>
1 parent 21babf9 commit faea0ec

2 files changed

Lines changed: 59 additions & 1 deletion

File tree

‎platform/packages/api/src/operator/auth.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,17 @@ operatorSessionRoutes.post('/session', async (context) => {
117117
export const authenticatedSessionRoutes = new Hono<ApiEnv>();
118118
authenticatedSessionRoutes.get('/session', (context) => {
119119
const user = context.get('user');
120+
// operator_sessions.expires_at is a timestamp without time zone whose wall
121+
// time is UTC by convention: the POST above inserts a UTC ISO string. The
122+
// string-mode column mapping returns it in SQL text form
123+
// ("YYYY-MM-DD HH:MM:SS.sss"), so parse it explicitly as UTC and serialize
124+
// with toISOString() — the response then carries the same instant and the
125+
// same ISO text the POST returned.
126+
const storedExpiry: string = context.get('operatorSession').expiresAt;
127+
const expiresAt = new Date(`${storedExpiry.replace(' ', 'T')}+00:00`).toISOString();
120128
return context.json({
121129
apiVersion: 1,
122-
expiresAt: context.get('operatorSession').expiresAt,
130+
expiresAt,
123131
user: { id: user.id, login: user.login, role: user.role },
124132
capabilities: getPlatformCapabilities(context.env),
125133
hostedWorkerOperations: !context.env.PLATFORM_INSTALLATION_ID,

‎platform/packages/api/test/integration/operator-auth.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,56 @@ describe('operator authentication', () => {
7171
).toBe(401);
7272
});
7373

74+
test('confirmation repeats the exact ISO UTC expiry the elevation issued', async () => {
75+
const admin = await createTestUser(
76+
env.db,
77+
'operator-expiry',
78+
'github',
79+
undefined,
80+
'free',
81+
'admin',
82+
);
83+
const created = await request('/session', admin.jwt, {});
84+
expect(created.status).toBe(200);
85+
const createdBody = (await created.json()) as { token: string; expiresAt: string };
86+
87+
const confirmed = await request('/session', createdBody.token);
88+
expect(confirmed.status).toBe(200);
89+
const confirmedBody = (await confirmed.json()) as {
90+
apiVersion: number;
91+
expiresAt: string;
92+
user: { id: string; login: string; role: string };
93+
capabilities: Record<string, unknown>;
94+
hostedWorkerOperations: unknown;
95+
};
96+
97+
// The expiry round-trips through Postgres as a timestamp without time zone,
98+
// and the string-mode column mapping reformats it as SQL text; the handler
99+
// must serialize it back to the ISO UTC text the admin app pins. Assert the
100+
// real conversion result, not a fixture.
101+
const isoUtc = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
102+
expect(createdBody.expiresAt).toMatch(isoUtc);
103+
expect(confirmedBody.expiresAt).toBe(createdBody.expiresAt);
104+
expect(Date.parse(confirmedBody.expiresAt)).toBe(Date.parse(createdBody.expiresAt));
105+
106+
// The remaining shape the admin app validates before accepting a session.
107+
expect(confirmedBody.apiVersion).toBe(1);
108+
expect(confirmedBody.user).toEqual({ id: admin.id, login: admin.login, role: 'admin' });
109+
for (const name of [
110+
'ai',
111+
'customDomains',
112+
'email',
113+
'githubActions',
114+
'githubOAuth',
115+
'googleOAuth',
116+
'managedBuilds',
117+
'sandbox',
118+
]) {
119+
expect(typeof confirmedBody.capabilities[name]).toBe('boolean');
120+
}
121+
expect(typeof confirmedBody.hostedWorkerOperations).toBe('boolean');
122+
});
123+
74124
test('keeps login, elevation, authenticated drain, session, and logout usable in drain maintenance', async () => {
75125
const admin = await createTestUser(
76126
env.db,

0 commit comments

Comments
 (0)