mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Stop iterating on individual 400 errors and implement the full Codex Responses-API protocol once, from the source. Wrote a 713-line reference at docs/codex-protocol-spec.md from the official openai/codex Rust client; this commit is the TS implementation against that spec. Required pieces that were missing in v1.4.11: - `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id` claim of the OAuth id_token (also accepts the URI-namespaced variant under `https://api.openai.com/auth`). Without it: 401. - `originator`, `User-Agent`, `Accept: text/event-stream`, `session_id`/`session-id` (both spellings), `thread_id`/`thread-id`. - Body fields `reasoning: null` and `include: []` are required even when no reasoning is requested. - Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a test placeholder upstream). - Refresh endpoint takes a JSON body, not form-urlencoded. Storage codec rewrite: `codexAccessTokenEncrypted` now holds an encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead of just the raw access token, so the account id stays available across requests. v1.4.7-v1.4.11 records that lack the field cannot be revived — `decryptCodexCreds` returns null for those rows and `resolveCodexProvider` flips the connection to `expired`, prompting the user to re-link once. Removed the dead browser-flow routes (`/api/auth/codex/authorize` and `/callback`). They never worked on hosted domains because Hydra's redirect-URI allow-list for the public Codex client ID covers only localhost; device-code is the only viable path. CodexClient SSE parsing extended to handle `response.failed` and `response.incomplete` terminal events, and to read the actual routed model from the `OpenAI-Model` response header. Tests rebuilt: codex-oauth covers the full device-code flow with a synthesised id_token JWT, the JSON-body refresh, and the storage-codec round-trip. CodexClient tests carry an `accountId` through the constructor. All 720 tests pass; typecheck clean. Refs: docs/codex-protocol-spec.md §1-§6 for every claim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { pollDeviceCode, encryptCodexCreds } from "@/lib/ai/codex-oauth";
|
|
import { cookies } from "next/headers";
|
|
import { decrypt } from "@/lib/crypto";
|
|
import { prisma } from "@/lib/db";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
/**
|
|
* v1.4.7.1: Device-code flow polling endpoint.
|
|
*
|
|
* The client polls this every `intervalSeconds` (returned from
|
|
* `device-start`). While the user has not approved on chatgpt.com,
|
|
* we return `status: "pending"` and the client retries. Once Hydra
|
|
* issues an authorization code we exchange it server-side, swap the
|
|
* id_token for an OpenAI API key (RFC 8693), and persist both the
|
|
* key and the OAuth refresh token encrypted on the user record.
|
|
*
|
|
* The cookie that holds the `device_auth_id` + `user_code` lives 15
|
|
* minutes — same window Hydra gives the device code. After success
|
|
* we delete the cookie immediately so a refresh of the page does not
|
|
* try to re-poll a stale device id.
|
|
*/
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
|
|
const rl = await checkRateLimit(`codex-device-poll:${user.id}`, 60, 60_000);
|
|
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
|
|
|
const cookieStore = await cookies();
|
|
const cookie = cookieStore.get("codex_device")?.value;
|
|
if (!cookie) {
|
|
return apiError("No device-auth attempt in progress", 400);
|
|
}
|
|
|
|
let deviceAuthId: string;
|
|
let userCode: string;
|
|
try {
|
|
const decoded = JSON.parse(decrypt(cookie)) as {
|
|
deviceAuthId: string;
|
|
userCode: string;
|
|
};
|
|
deviceAuthId = decoded.deviceAuthId;
|
|
userCode = decoded.userCode;
|
|
} catch {
|
|
cookieStore.delete("codex_device");
|
|
return apiError("Invalid device-auth state — restart the flow", 400);
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await pollDeviceCode({ deviceAuthId, userCode });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "unknown";
|
|
annotate({ meta: { codex_device_poll_error: message } });
|
|
return apiError("Codex device-poll failed — please restart the flow", 502);
|
|
}
|
|
|
|
if (result.status === "pending") {
|
|
annotate({
|
|
action: { name: "codex.device.poll" },
|
|
meta: { pending: true },
|
|
});
|
|
return apiSuccess({ status: "pending" });
|
|
}
|
|
|
|
// status === "connected" → persist the full credential blob
|
|
// (access token + chatgpt_account_id + refresh token), encrypted.
|
|
// The account id is mandatory for the `ChatGPT-Account-ID` header
|
|
// on every Codex backend request.
|
|
const enc = encryptCodexCreds(result.creds);
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
codexAccessTokenEncrypted: enc.accessEncrypted,
|
|
codexRefreshTokenEncrypted: enc.refreshEncrypted,
|
|
codexTokenExpiresAt: result.creds.expiresAt,
|
|
codexConnectedAt: new Date(),
|
|
codexConnectionStatus: "connected",
|
|
insightsCachedAt: null,
|
|
insightsCachedText: null,
|
|
},
|
|
});
|
|
|
|
cookieStore.delete("codex_device");
|
|
|
|
await auditLog("codex.oauth.connected", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { method: "device_code" },
|
|
});
|
|
annotate({
|
|
action: { name: "codex.device.poll" },
|
|
meta: { connected: true },
|
|
});
|
|
|
|
return apiSuccess({ status: "connected" });
|
|
});
|