/** * HTTP transport for the entity API. * * This is the module that replaces `store.js`. It exposes the same * `createEntity(name)` factory with the same six methods and the same * signatures, so `base44Client.js` swaps one import and nothing above it * changes — not a hook, not a page, not a component. * * Everything here is a faithful translation of what `store.js` did locally into * what `docs/api-contract.md` specifies over the wire. Where the two could * differ, the local behaviour wins, because the callers were written against * it: * * - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare * object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is * unwrapped here and never seen above. * - A failure throws an `Error` whose `message` is the server's message, * because `store.js` threw and several callers depend on the throw * (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks). * - The default `sort` and `limit` on every method are the ones `store.js` * declared, so a call site that passes neither still gets what it always * got. * * The one thing that is genuinely new is the failure mode. A local store could * not be unreachable; an API can, and "Failed to fetch" names neither the * problem nor the fix. `request` turns that into a message that says which URL * did not answer. */ /** * Where the API lives. * * A same-origin path, not a host. The browser asks its own origin for * `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see * `vite.config.js`), and in production the same path is served by the same * origin as the app (see `nginx.conf`). * * This is a requirement of the session cookie rather than a preference. The * cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a * cross-site request — so a page on `localhost:5173` fetching * `http://127.0.0.1:8080` would authenticate once at login and then be a * stranger on every request after it. * * VITE_API_BASE_URL can still point somewhere else, and `credentials` below is * set so that it works, but the cross-origin path needs CORS credentials * configured on the server and is not the supported arrangement. */ const DEFAULT_BASE_URL = '/api/v1'; export const API_BASE_URL = String( import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL ).replace(/\/+$/, ''); /** * Shout if the API has been pointed at another origin. * * This exists because the failure it catches is silent and misleading. Set * `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a * preflight, the server answers it `204`, and then the real request is never * dispatched at all — because `credentials: 'include'` obliges the browser to * require `Access-Control-Allow-Credentials: true` on that preflight, and the * API does not send it. The server log shows an OPTIONS and nothing else; the * page shows a request that never completes. Nothing names the cause. * * Even if CORS were opened up, the session cookie is `SameSite=Lax` and would * not be sent on a cross-site request, so login would appear to succeed once * and then every subsequent request would arrive as a stranger. * * Development only: `import.meta.env.DEV` is statically replaced at build time, * so this whole block is dropped from the production bundle. */ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) { const sameOrigin = typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin); if (!sameOrigin) { console.error( `[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` + `from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` + 'The session cookie will not work: the login POST is blocked at the CORS ' + 'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' + 'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' + 'go through the Vite proxy.' ); } } /** * Entity name → the contract's resource path (§1: kebab-case plural, mass nouns * singular). * * Declared rather than derived. A rule that turns `AIInterview` into * `ai-interviews` and `Staff` into `staff` and `UserActivity` into * `user-activity` is three special cases wearing a trench coat, and a wrong * guess here is a 404 at runtime instead of a mistake anyone can see. */ const RESOURCE_PATHS = { JobPosting: 'job-postings', JobApplication: 'job-applications', AIInterview: 'ai-interviews', Staff: 'staff', WorkerProfile: 'worker-profiles', Course: 'courses', Badge: 'badges', LearningPath: 'learning-paths', Certification: 'certifications', RoleCategory: 'role-categories', UserActivity: 'user-activity', Evidence: 'evidence', User: 'users', Assignment: 'assignments', ShiftRecord: 'shift-records', /* Authored agents and skills. These are the registry the BACKEND runs from: an agent saved here is one Owliver can be asked to run, which is the whole difference between this and the preferences blob these used to live in. */ AgentDefinition: 'agent-definitions', SkillDefinition: 'skill-definitions', }; /* ── Request ────────────────────────────────────────────────────────────── */ /** * Builds a query string with the contract's filter encoding (§6). * * An array value becomes a repeated parameter — `?status=applied&status=hired` * — which the server reads as `col = ANY(...)`, matching `store.js`'s * `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared * `got === undefined` against real values and matched nothing, and sending the * string "undefined" would be a filter on a value no column holds. */ function queryString(params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value === undefined) continue; if (Array.isArray(value)) { for (const item of value) { if (item !== undefined) search.append(key, String(item)); } continue; } search.append(key, String(value)); } const encoded = search.toString(); return encoded ? `?${encoded}` : ''; } /** * The error a non-2xx becomes. * * `message` is the server's message verbatim, because §5.1 makes it * load-bearing: `store.js` threw `" not found"` and the API * reproduces that string exactly. The code, HTTP status and per-field details * ride along as properties — new information a local store never had, and * additive, so nothing that only reads `.message` notices. */ function apiError(status, payload) { const body = payload?.error; const error = new Error(body?.message || `Request failed with status ${status}`); error.name = 'KrowApiError'; error.status = status; error.code = body?.code || 'internal'; error.details = body?.details || {}; return error; } /** * One request, unwrapped. * * Returns `payload.data`, so every caller above works in bare records exactly * as it did against the local store. `meta` is deliberately dropped: nothing * reads it (§4.2), and surfacing it would mean changing what the six methods * return, which is the one thing Phase 2D must not do. */ async function request(method, path, { query, body } = {}) { const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`; let response; try { response = await fetch(url, { method, // The session cookie is HttpOnly: this code cannot read it, attach it by // hand, or store it. `credentials` is the only lever there is, and // without it `fetch` omits cookies on cross-origin requests entirely. // Same-origin — the supported arrangement — would send them anyway; // saying so explicitly means the one line that makes authentication work // is visible rather than implied. credentials: 'include', headers: body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch (cause) { // A transport failure, not an API response: no status, no envelope. The // browser's own message for this is "Failed to fetch", which says nothing // about which server or why, and it is nearly always the same cause — the // API is not running. const error = new Error( `Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` + `127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` + `(${method} ${path})` ); error.name = 'KrowApiError'; error.status = 0; error.code = 'unreachable'; error.details = {}; error.cause = cause; throw error; } const text = await response.text(); let payload = null; if (text) { try { payload = JSON.parse(text); } catch { payload = null; } } if (!response.ok) throw apiError(response.status, payload); if (payload === null) { const error = new Error(`${method} ${path} returned no JSON body`); error.name = 'KrowApiError'; error.status = response.status; error.code = 'internal'; error.details = {}; throw error; } return payload.data; } /** The request helper, for the `auth` surface in `base44Client.js`. */ export { request }; /** * True when an error is the API saying "you are not signed in". * * A 401 is an ordinary, expected answer here — it is what every request gets * before the first login and after a session expires — so callers need to tell * it apart from a real failure rather than treating both as "something broke". */ export function isUnauthenticated(error) { return Boolean(error) && (error.status === 401 || error.code === 'unauthorized'); } /* ── Entity API ─────────────────────────────────────────────────────────── */ /** * Builds the client surface for one entity. * * Signature-compatible with `store.js`'s `createEntity`, defaults included. The * defaults matter more than they look: `store.js` declared * `list(sort = '-created_date', limit = 100)`, and several call sites rely on * them rather than passing their own. */ export function createEntity(name) { const path = RESOURCE_PATHS[name]; if (!path) throw new Error(`No API resource path is declared for entity ${name}`); const base = `/${path}`; return { entityName: name, /** * `sort` is always sent, even when empty. `?sort=` is not the same as * omitting it: the contract reads an explicit empty value as "no ordering", * which is what `applySort` did with a falsy sort, while omitting it would * apply the endpoint's default. */ async list(sort = '-created_date', limit = 100) { return request('GET', base, { query: { sort, limit } }); }, async filter(query = {}, sort = '-created_date', limit = 100) { // Spread first so a field genuinely named `sort`, `limit` or `offset` // could never shadow the reserved parameters (§1 records that no column // collides with them today; this keeps that true if one ever does). return request('GET', base, { query: { ...query, sort, limit } }); }, async get(id) { return request('GET', `${base}/${encodeURIComponent(id)}`); }, async create(data) { return request('POST', base, { body: data }); }, async update(id, data) { return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data }); }, async delete(id) { return request('DELETE', `${base}/${encodeURIComponent(id)}`); }, /** * Sequential creates, exactly as `store.js` did it. * * Not a batch endpoint and not `Promise.all`: the contract has no bulk * write (§12.1), and doing them one at a time keeps the failure behaviour * identical — the first rejection stops the run and the records before it * are already written. */ async bulkCreate(records = []) { const created = []; for (const record of records) created.push(await this.create(record)); return created; }, }; }