// api.js — every backend call lives here. // // All write requests pass {credentials: 'include'} implicitly because // the dev proxy and the production deploy serve the API from the same // origin as the frontend. If you split origins later, change here. async function jsonOrThrow(res) { if (!res.ok) { let detail = '' try { const body = await res.json() detail = body.detail || JSON.stringify(body) } catch { detail = await res.text() } const error = new Error(detail || `HTTP ${res.status}`) error.status = res.status throw error } return res.json() } export async function getMe() { const res = await fetch('/api/auth/me') return jsonOrThrow(res) } // ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ─────────────── // // The route-change hook (useLastState) debounce-posts the user's current // route + a small bag of *light* view state here for authenticated users. // The next sign-in reads `last_route` off `/api/auth/me` and redirects. // Privacy: `state` carries ephemeral view state ONLY — never draft-buffer // contents (see SPEC §6.2). Best-effort: callers ignore failures (an // offline/401 POST must never disrupt navigation). export async function putLastState(route, state) { const body = { route } if (state != null) body.state = state const res = await fetch('/api/me/last-state', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) return jsonOrThrow(res) } // ── v0.7.0: email + one-time-code sign-in (§6.2) ───────────────────────── // // The legacy /auth/login → /auth/callback OAuth flow remains during the // migration — the new UI just no longer points at it primarily. These // two helpers drive the Login.jsx surface. export async function requestOtc(email, { turnstileToken } = {}) { // v0.12.0 / roadmap item #10: when the Turnstile widget has produced // a token, send it alongside the email so the backend can siteverify // before the OTC dispatch. The backend treats a missing token as // either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false) // or hard-fail (verification required) — the frontend stays // uninvolved in the policy. const body = { email } if (turnstileToken) body.turnstile_token = turnstileToken const res = await fetch('/auth/otc/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) return jsonOrThrow(res) } export async function verifyOtc(email, code, { trustDevice = false } = {}) { // v0.11.0 — `trustDevice` is the "trust this device for 30 days" // checkbox on the Login.jsx OTC step. When true, the server mints // a fresh device-trust row and sets the long-lived cookie; on // subsequent visits, the cookie skips the OTC roundtrip via // `startDeviceTrust()`. const res = await fetch('/auth/otc/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, code, trust_device: !!trustDevice }), }) return jsonOrThrow(res) } // ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ───────────────── // // On the first OTC sign-in, the user lands in `permission_state='pending'` // and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface // then prompts for first/last/why and POSTs them here. After this lands, // the user sees the /beta-pending page until an admin grants access. export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) { const res = await fetch('/api/auth/me/beta-request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ first_name, last_name, beta_request_reason }), }) return jsonOrThrow(res) } // ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ───────── // // After a successful OTC sign-in, a contributor may set a passcode and // use email + passcode for subsequent sign-ins. OTC remains the // forgot-passcode fallback — 5 consecutive verify failures locks the // passcode path for 15 minutes (HTTP 423); the OTC path is unaffected. export async function checkPasscode(email) { // Anonymous endpoint. Returns `{has_passcode: boolean}` so the // Login.jsx flow can decide whether to render a passcode input or // fall back to OTC. We URL-encode the email so addresses with '+' // round-trip cleanly. const params = new URLSearchParams({ email }) const res = await fetch(`/auth/passcode/check?${params}`) return jsonOrThrow(res) } export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) { // v0.11.0 — same trust-device opt-in as `verifyOtc`. const res = await fetch('/auth/passcode/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }), }) return jsonOrThrow(res) } // ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ───────────── // // On a returning visit with a valid device-trust cookie, `startDeviceTrust` // re-establishes the session without an OTC / passcode roundtrip. The // cookie is HttpOnly so the client cannot read it; the call is a pure POST // that the browser attaches the cookie to automatically. // // `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the // /settings/devices revoke-device UI. The signed-in user is the implicit // subject; the cookie carries the session. export async function startDeviceTrust() { const res = await fetch('/auth/device-trust/start', { method: 'POST' }) return jsonOrThrow(res) } export async function listMyDevices() { return jsonOrThrow(await fetch('/api/auth/me/devices')) } export async function revokeMyDevice(deviceId) { return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' })) } export async function revokeAllMyDevices() { return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' })) } export async function setPasscode(passcode) { // Requires an active session — the server returns 401 if not signed // in. The signed-in user is the implicit subject; the body carries // only the new passcode. const res = await fetch('/auth/passcode/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode }), }) return jsonOrThrow(res) } export async function clearPasscode() { const res = await fetch('/auth/passcode', { method: 'DELETE' }) return jsonOrThrow(res) } // ── §22.9/§22.10 (M3): runtime deployment + per-project config ──────────── // // Replaces the build-time VITE_APP_NAME. `getDeployment` is fetched once on // boot (DeploymentProvider): { name, tagline, default_project_id, projects[] }. // `getProject` drives ProjectLayout's per-project chrome + theme. export async function getDeployment() { return jsonOrThrow(await fetch('/api/deployment')) } export async function getProject(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) } // §22 S5: create-project (global Owner). The backend provisions a Gitea content // repo, commits the project to projects.yaml, re-mirrors the registry, and // returns the new project { id, name, type, visibility }. export async function createProject({ projectId, name, type, visibility, contentRepo }) { const res = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: projectId, name, type, visibility: visibility || null, content_repo: contentRepo || null, }), }) return jsonOrThrow(res) } // §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a // collectionId, read the collection-scoped routes; with only a projectId, the // project default-collection compat path; with neither, the unscoped path. // §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] } // (each non-empty array becomes repeated query params, OR within the field); // `opts.malformed` toggles the malformed-only filter. Returns the full payload // { items, facets } — callers read both. With no selections it behaves as the // pre-SLICE-3 list (and a no-fields collection returns facets: {}). Facets are // served only on the project/collection-scoped paths, not the unscoped one. export async function listRFCs(projectId, collectionId, opts = {}) { const { selections = {}, malformed = false } = opts const qs = new URLSearchParams() for (const [field, values] of Object.entries(selections)) { for (const v of values) qs.append(field, v) } if (malformed) qs.set('malformed', 'true') const query = qs.toString() let base if (projectId && collectionId) { base = `/api/projects/${projectId}/collections/${collectionId}/rfcs` } else { base = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs' } const url = query ? `${base}?${query}` : base return jsonOrThrow(await fetch(url)) } export async function getRFC(projectId, slug, collectionId) { // Back-compat: getRFC(slug) (one arg) still hits the unscoped default path. if (slug === undefined) { return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`)) } if (collectionId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`)) } return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`)) } // §22 S2: the collections of a project (for the /p// directory). The // response also carries a `viewer` block (§22 S4 capability flags: // can_create_collection, can_invite, role) driving the role-aware directory. export async function listCollections(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`)) } // §22 S4: one collection's settings + the viewer's collection-level // capabilities (viewer.can_contribute / can_invite / role) — drives the // propose-first empty state and the collection invite control. export async function getCollection(projectId, collectionId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}`)) } // §22 S4 (C.2): the scope-role membership surface. An Owner grants // {owner, contributor} at the project, or at one collection (collectionId set), // to an existing account by email; the backend writes the membership row and // §15-notifies the grantee. export async function listScopeMembers(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}/members`)) } export async function grantScopeMember(projectId, { email, role, collectionId }) { const res = await fetch(`/api/projects/${projectId}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, role, collection_id: collectionId || null }), }) return jsonOrThrow(res) } export async function revokeScopeMember(projectId, userId, collectionId) { const qs = collectionId ? `?collection_id=${encodeURIComponent(collectionId)}` : '' const res = await fetch(`/api/projects/${projectId}/members/${userId}${qs}`, { method: 'DELETE', }) return jsonOrThrow(res) } // §22 S2: create-collection (deployment owner/admin). The backend commits a // .collection.yaml and re-mirrors the registry, returning the new collection. export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) { const res = await fetch(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ collection_id: collectionId, type, name: name || null, visibility: visibility || null, initial_state: initialState || null, }), }) return jsonOrThrow(res) } export async function listProposals(projectId) { const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals' return jsonOrThrow(await fetch(url)) } export async function getProposal(prNumber) { return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`)) } // §22.4 (Plan B write): propose into a specific project when projectId is // given; else the default-project compat path. export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) { const url = (projectId && collectionId) ? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose` : (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose') const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // #26: proposed_use_case is optional; send null when blank so the // backend treats it as "left blank". body: JSON.stringify({ title, slug, pitch, tags: tags || [], proposed_use_case: proposedUseCase || null, }), }) return jsonOrThrow(res) } // Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal. // Returns a (possibly empty) array of { tag, confidence }. Deliberately // forgiving — any non-OK response (rate limit, transient error, no key // configured server-side) resolves to [] so the modal just shows nothing // rather than surfacing an error for what is a best-effort assist. export async function suggestTags({ title, pitch, useCase }) { try { const res = await fetch('/api/rfcs/suggest-tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title || '', pitch: pitch || '', use_case: useCase || '', }), }) if (!res.ok) return [] const data = await res.json() return Array.isArray(data.suggestions) ? data.suggestions : [] } catch { return [] } } // Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC. // `contributionTarget` feeds the contribute form (RFC title, owner // display, the viewer's eligibility); `requestContribution` submits the // ask; accept/decline are the owner's inbox actions. export async function contributionTarget(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`)) } export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) { const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ matched_term: matchedTerm, who_i_am: whoIAm, why, use_case: useCase || null, }), }) return jsonOrThrow(res) } export async function acceptContributionRequest(slug, requestId) { return jsonOrThrow(await fetch( `/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' }, )) } export async function declineContributionRequest(slug, requestId) { return jsonOrThrow(await fetch( `/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' }, )) } // §22.8: request-to-join a scope + the cross-collection inbox. // `joinTarget` feeds the request modal (scope name, the viewer's eligibility); // `requestJoin` submits the ask (desired role + optional message); accept/decline // are the scope Owner's inbox actions (accept writes the membership row). export async function joinTarget(scopeType, scopeId) { return jsonOrThrow(await fetch( `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-target`, )) } export async function requestJoin(scopeType, scopeId, { role, message }) { const res = await fetch( `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, message: message || null }), }, ) return jsonOrThrow(res) } export async function acceptJoinRequest(scopeType, scopeId, requestId, role) { return jsonOrThrow(await fetch( `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/accept`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: role || null }), }, )) } export async function declineJoinRequest(scopeType, scopeId, requestId) { return jsonOrThrow(await fetch( `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/decline`, { method: 'POST' }, )) } export async function mergeProposal(prNumber) { const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' }) return jsonOrThrow(res) } export async function declineProposal(prNumber, comment) { const res = await fetch(`/api/proposals/${prNumber}/decline`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment }), }) return jsonOrThrow(res) } export async function withdrawProposal(prNumber) { const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' }) return jsonOrThrow(res) } // ── Slice 2: active-RFC view (§8) ───────────────────────────────────────── export async function listModels(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/models`)) } export async function getRFCMain(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`)) } export async function getBranch(slug, branch) { return jsonOrThrow(await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}` )) } export async function promoteToBranch(slug, body = {}) { const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) return jsonOrThrow(res) } export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proposed, was_edited_before_accept: !!wasEdited, force_apply_stale: !!forceApplyStale, }), }, ) return jsonOrThrow(res) } export async function declineChange(slug, branch, changeId) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`, { method: 'POST' }, ) return jsonOrThrow(res) } export async function reaskChange(slug, branch, changeId) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`, { method: 'POST' }, ) return jsonOrThrow(res) } export async function manualFlush(slug, branch, { newContent, paragraphCount }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }), }, ) return jsonOrThrow(res) } export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ read_public: readPublic, contribute_mode: contributeMode, }), }, ) return jsonOrThrow(res) } export async function createThread(slug, branch, body) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ) return jsonOrThrow(res) } export async function listThreads(slug, branch) { return jsonOrThrow(await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`, )) } export async function getThreadMessages(slug, branch, threadId) { return jsonOrThrow(await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`, )) } export async function postThreadMessage(slug, branch, threadId, { text, quote }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, quote }), }, ) return jsonOrThrow(res) } export async function resolveThread(slug, branch, threadId) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`, { method: 'POST' }, ) return jsonOrThrow(res) } // ── v0.16.0: owner-only invite for per-RFC PR or PR-less discussion ────── // // roadmap item #12 / §6 / §10. The RFC's owner invites specific emails // to one of two per-RFC roles ('contributor' or 'discussant'); the // invitee accepts via the email-encoded token after signing in. The // platform-level grant remains the admin's decision (per item #6 / // v0.8.0) — these endpoints control per-RFC membership only. export async function listRFCInvitations(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/invitations`)) } export async function createRFCInvitation(slug, { inviteeEmail, roleInRFC }) { const res = await fetch(`/api/rfcs/${slug}/invitations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ invitee_email: inviteeEmail, role_in_rfc: roleInRFC }), }) return jsonOrThrow(res) } export async function revokeRFCInvitation(slug, invitationId) { const res = await fetch(`/api/rfcs/${slug}/invitations/${invitationId}/revoke`, { method: 'POST', }) return jsonOrThrow(res) } export async function previewInvitation(token) { const params = new URLSearchParams({ token }) return jsonOrThrow(await fetch(`/api/invitations/accept?${params}`)) } export async function acceptInvitation(token) { const res = await fetch('/api/invitations/accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }) return jsonOrThrow(res) } // ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ──────────────────────── // // The substrate is `threads.branch_name IS NULL` — the same threads // table the branch chat uses, with a null branch the schema already // supported. Contribution still requires a PR (api_prs / openPR), so // these endpoints are read+write for discussion only. export async function listDiscussionThreads(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`)) } export async function createDiscussionThread(slug, { label = null, message = null } = {}) { const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, message }), }) return jsonOrThrow(res) } export async function getDiscussionThreadMessages(slug, threadId) { return jsonOrThrow(await fetch( `/api/rfcs/${slug}/discussion/threads/${threadId}/messages`, )) } export async function postDiscussionMessage(slug, threadId, { text, quote = null }) { const res = await fetch( `/api/rfcs/${slug}/discussion/threads/${threadId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, quote }), }, ) return jsonOrThrow(res) } export async function resolveDiscussionThread(slug, threadId) { const res = await fetch( `/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`, { method: 'POST' }, ) return jsonOrThrow(res) } // ── Slice 4: super-draft body editing (§9.5) ───────────────────────────── export async function startEditBranch(slug, body = {}) { const res = await fetch(`/api/rfcs/${slug}/start-edit-branch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) return jsonOrThrow(res) } export async function editMetadata(slug, { title, tags, prDescription }) { const res = await fetch(`/api/rfcs/${slug}/metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? null, tags: tags ?? null, pr_description: prDescription ?? null, }), }) return jsonOrThrow(res) } // ── Slice 5: §13 graduation + §13.1 claim ──────────────────────────────── export async function claimOwnership(slug) { const res = await fetch(`/api/rfcs/${slug}/claim`, { method: 'POST' }) return jsonOrThrow(res) } export async function listBlockingPRs(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`)) } // §13.7 retire (soft delete). RFC owners + site owners may retire; only // site owners may un-retire. The backend gates both regardless of UI. export async function retireRFC(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/retire`, { method: 'POST' })) } export async function unretireRFC(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/unretire`, { method: 'POST' })) } // §13.7: the site-owner-only list of retired entries (for the admin // "Retired" surface, where un-retire lives). export async function listRetiredRFCs() { return jsonOrThrow(await fetch('/api/admin/retired-rfcs')) } export async function graduateCheck(slug, { id }) { // Meta-only topology (§13.2): two fields — integer id + owners. No // repo name to validate. const params = new URLSearchParams() if (id != null) params.set('id', id) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`)) } export async function startGraduation(slug, { rfcId, owners }) { const res = await fetch(`/api/rfcs/${slug}/graduate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rfc_id: rfcId, owners }), }) return jsonOrThrow(res) } // Open an EventSource on the §13.3 progress stream. Returns the // EventSource so the caller can close() on dialog dismiss. Calls // onUpdate with the parsed state payload for every event. export function openGraduationProgress(slug, { onUpdate, onDone, onError }) { const es = new EventSource(`/api/rfcs/${slug}/graduate/progress`) const handle = (e) => { try { const payload = JSON.parse(e.data) onUpdate?.(payload, e.type) if (e.type === 'done' && payload?.finished) onDone?.(payload) } catch (err) { onError?.(err) } } for (const name of ['snapshot', 'step', 'rollback_step', 'completed', 'rolled_back', 'done']) { es.addEventListener(name, handle) } es.onerror = (e) => { onError?.(e); es.close() } return es } // ── Slice 3: the §10 PR flow ───────────────────────────────────────────── export async function draftPRText(slug, branch) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/pr-draft`, { method: 'POST' }, ) return jsonOrThrow(res) } export async function openPR(slug, branch, { title, description, proposedUseCase }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // #26: proposed_use_case is optional; null when blank. body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }), }, ) return jsonOrThrow(res) } export async function getPR(slug, prNumber) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/prs/${prNumber}`)) } export async function advancePRSeen(slug, prNumber, { lastSeenCommitSha, lastSeenMessageId }) { const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/seen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ last_seen_commit_sha: lastSeenCommitSha, last_seen_message_id: lastSeenMessageId, }), }) return jsonOrThrow(res) } export async function postPRReview(slug, prNumber, { text, anchorPayload, quote }) { const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, anchor_payload: anchorPayload || {}, quote: quote || null }), }) return jsonOrThrow(res) } export async function mergePR(slug, prNumber) { const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/merge`, { method: 'POST' }) return jsonOrThrow(res) } export async function withdrawPR(slug, prNumber) { const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/withdraw`, { method: 'POST' }) return jsonOrThrow(res) } export async function editPRDescription(slug, prNumber, { title, description }) { const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/description`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description }), }) return jsonOrThrow(res) } export async function startResolutionBranch(slug, prNumber) { const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/resolution-branch`, { method: 'POST', }) return jsonOrThrow(res) } // Stream a chat turn into a per-branch thread. Calls onChunk for each // text fragment, onChanges when the trailing `changes` event arrives, // and onDone at the terminal DONE marker. Returns the response headers // (so the caller can pull X-Assistant-Message-Id without re-streaming). export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, quote, model }), }, ) if (!res.ok) { const detail = await res.text() throw new Error(`Chat failed: ${detail || res.status}`) } const assistantId = res.headers.get('X-Assistant-Message-Id') const userMsgId = res.headers.get('X-User-Message-Id') const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let currentEvent = null while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n\n') buffer = parts.pop() for (const part of parts) { const lines = part.split('\n') let dataLine = null let event = null for (const line of lines) { if (line.startsWith('event: ')) event = line.slice(7).trim() if (line.startsWith('data: ')) dataLine = line.slice(6).trim() } if (dataLine === null) continue if (event === 'changes') { try { onChanges?.(JSON.parse(dataLine)) } catch {} continue } if (dataLine === 'DONE') { onDone?.(); break } try { const text = new TextDecoder().decode( Uint8Array.from(atob(dataLine), c => c.charCodeAt(0)) ) onChunk?.(text) } catch { // partial chunk } } } onDone?.() return { assistantId, userMsgId } } // --------------------------------------------------------------------------- // §15 / Slice 6: notifications surface // --------------------------------------------------------------------------- export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) { const params = new URLSearchParams() if (unread) params.set('unread', '1') if (rfcSlug) params.set('rfc_slug', rfcSlug) if (category) params.set('category', category) if (actorUserId) params.set('actor_user_id', actorUserId) if (bundled) params.set('bundled', '1') const qs = params.toString() return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`)) } export async function markNotificationRead(id) { return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' })) } export async function markNotificationsReadByFilter(filter) { return jsonOrThrow(await fetch('/api/notifications/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(filter || {}), })) } export async function listWatches() { return jsonOrThrow(await fetch('/api/watches')) } export async function setWatch(slug, state) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state }), })) } export async function getNotificationPreferences() { return jsonOrThrow(await fetch('/api/users/me/notification-preferences')) } export async function setNotificationPreferences(prefs) { return jsonOrThrow(await fetch('/api/users/me/notification-preferences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(prefs), })) } export async function getQuietHours() { return jsonOrThrow(await fetch('/api/users/me/quiet-hours')) } export async function setQuietHours({ start, end, timezone } = {}) { return jsonOrThrow(await fetch('/api/users/me/quiet-hours', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }), })) } // v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5). export async function getCookieConsent() { return jsonOrThrow(await fetch('/api/users/me/cookie-consent')) } export async function setCookieConsent({ analytics, other } = {}) { return jsonOrThrow(await fetch('/api/users/me/cookie-consent', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ essential: true, analytics: !!analytics, other: !!other, }), })) } export async function muteUser(userId) { return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' })) } export async function unmuteUser(userId) { return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' })) } export async function advanceChatSeen(slug, branch, lastSeenMessageId) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }), })) } // --------------------------------------------------------------------------- // §14.2 / Slice 7: PHILOSOPHY.md surface // --------------------------------------------------------------------------- export async function getPhilosophy() { return jsonOrThrow(await fetch('/api/philosophy')) } export async function getDocs() { return jsonOrThrow(await fetch('/api/docs')) } // --------------------------------------------------------------------------- // v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface // --------------------------------------------------------------------------- // // The framework mediates reads against the public // `wiggleverse/ohm-session-history` gitea repo so the rendered // `/docs/sessions/*` surface inherits the same chrome as // `/docs/user-guide`. Three text-bearing endpoints return markdown // (Content-Type: text/markdown) and the manifest returns JSON. We // wrap each into a small helper. // // 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex` // throws an Error with `.status === 404` so the UI can render its own // empty-state. 502 (gitea unreachable) throws `.status === 502` so // the UI can offer a retry button. export async function getSessionsManifest() { // Manifest 404 is mapped server-side to HTTP 200 + `{}` so this // helper never throws on the empty-state path. return jsonOrThrow(await fetch('/api/docs/sessions/manifest')) } async function _textOrThrow(res) { if (!res.ok) { let detail = '' try { const body = await res.json() detail = body.detail || JSON.stringify(body) } catch { detail = await res.text() } const error = new Error(detail || `HTTP ${res.status}`) error.status = res.status throw error } return res.text() } export async function getSessionsAbout() { return _textOrThrow(await fetch('/api/docs/sessions/about')) } export async function getSessionTranscript(nnnn, filename) { return _textOrThrow(await fetch( `/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}` )) } export async function getSessionIndex(nnnn) { return jsonOrThrow(await fetch( `/api/docs/sessions/${encodeURIComponent(nnnn)}/index` )) } // --------------------------------------------------------------------------- // v0.20.0 — /api/docs/specs/* surface // --------------------------------------------------------------------------- // // Sibling of the docs-sessions helpers above. The framework mediates // reads against the configured spec URLs (default: rfc-app's own // SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the // `/docs/specs/*` route inherits the same chrome as `/docs/user-guide` // and `/docs/sessions/*`. The manifest endpoint always returns 200 + // {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to // the framework default at parse time on the backend. // // 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`, // matching the docs-sessions helper convention. export async function getSpecsManifest() { return jsonOrThrow(await fetch('/api/docs/specs/manifest')) } export async function getSpec(name) { return _textOrThrow(await fetch( `/api/docs/specs/${encodeURIComponent(name)}` )) } // --------------------------------------------------------------------------- // Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute // typeahead). // --------------------------------------------------------------------------- export async function listAdminUsers() { return jsonOrThrow(await fetch('/api/admin/users')) } export async function setUserRole(userId, role) { return jsonOrThrow(await fetch(`/api/admin/users/${userId}/role`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role }), })) } export async function setUserMute(userId, muted) { return jsonOrThrow(await fetch(`/api/admin/users/${userId}/mute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ muted }), })) } // v0.9.0 — roadmap item #7. Flip a user's permission_state between // 'pending', 'granted', and 'revoked'. The Users tab on the admin // page wires Grant / Revoke buttons against this endpoint; the // returned `changed` flag is false when the requested state already // matched the row. export async function setUserPermission(userId, state) { return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state }), })) } export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) { const params = new URLSearchParams() if (actionKind) params.set('action_kind', actionKind) if (actorUserId != null) params.set('actor_user_id', actorUserId) if (rfcSlug) params.set('rfc_slug', rfcSlug) if (beforeId != null) params.set('before_id', beforeId) if (limit != null) params.set('limit', limit) const qs = params.toString() return jsonOrThrow(await fetch(`/api/admin/audit${qs ? `?${qs}` : ''}`)) } export async function listPermissionEvents({ beforeId, limit } = {}) { const params = new URLSearchParams() if (beforeId != null) params.set('before_id', beforeId) if (limit != null) params.set('limit', limit) const qs = params.toString() return jsonOrThrow(await fetch(`/api/admin/permission-events${qs ? `?${qs}` : ''}`)) } export async function listGraduationQueue() { return jsonOrThrow(await fetch('/api/admin/graduation-queue')) } export async function listAllowlist() { return jsonOrThrow(await fetch('/api/admin/allowlist')) } export async function addAllowlistEmail(email, note) { return jsonOrThrow(await fetch('/api/admin/allowlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, note: note || null }), })) } export async function removeAllowlistEmail(email) { return jsonOrThrow(await fetch(`/api/admin/allowlist/${encodeURIComponent(email)}`, { method: 'DELETE', })) } // v0.17.0 — roadmap item #16. Admin-create user + invite email with // optional custom message. The frontend modal on /admin/users wires // these two helpers; the claim helper drives the /invites/claim page // that the invitee lands on when they click the email link. // // `createUserInvite` returns `{ ok, invite_id, invited_user_id, email, // role }`. The 409 path (duplicate email) and 422 path (self-invite, // owner-grant-by-non-owner, malformed input) surface as thrown errors // via `jsonOrThrow` so the modal can render the server's message. // // `listUserInvites` returns the active-invites list for the admin's // "I sent these but they haven't been claimed yet" view. Active means // not claimed and not expired; once the invitee clicks through, the // row clears here and the user-listing's `pending_invite` badge // vanishes alongside. export async function createUserInvite({ email, first_name, last_name, role, custom_message }) { return jsonOrThrow(await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, first_name: first_name || '', last_name: last_name || '', role, custom_message: custom_message || '', }), })) } export async function listUserInvites() { return jsonOrThrow(await fetch('/api/admin/users/invites')) } // Claim an admin-issued invite token. Anonymous endpoint — the invitee // is not yet signed in; this call establishes the session on success. // `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true, // the server mints a fresh device-trust row + sets the long-lived // cookie so the invitee skips OTC on their next visit. export async function claimInvite(token, { trustDevice = false } = {}) { return jsonOrThrow(await fetch('/api/invites/claim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, trust_device: !!trustDevice }), })) } export async function searchUsers(q) { const params = new URLSearchParams() if (q) params.set('q', q) return jsonOrThrow(await fetch(`/api/users/search?${params}`)) } // SSE subscription helper. Returns a close() function. The handler // surface mirrors §15.3: a snapshot event on open, then per-notification // `notification` events, plus `read` events when another tab marks a row. export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) { const source = new EventSource('/api/notifications/stream') source.addEventListener('snapshot', e => { try { onSnapshot?.(JSON.parse(e.data)) } catch {} }) source.addEventListener('notification', e => { try { onNotification?.(JSON.parse(e.data)) } catch {} }) source.addEventListener('read', e => { try { onRead?.(JSON.parse(e.data)) } catch {} }) source.onerror = err => { onError?.(err) } return () => source.close() }