742 lines
24 KiB
JavaScript
742 lines
24 KiB
JavaScript
// 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.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) {
|
|
const res = await fetch('/auth/otc/request', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
})
|
|
return jsonOrThrow(res)
|
|
}
|
|
|
|
export async function verifyOtc(email, code) {
|
|
const res = await fetch('/auth/otc/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, code }),
|
|
})
|
|
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) {
|
|
const res = await fetch('/auth/passcode/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, passcode }),
|
|
})
|
|
return jsonOrThrow(res)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
export async function listRFCs() {
|
|
return jsonOrThrow(await fetch('/api/rfcs'))
|
|
}
|
|
|
|
export async function getRFC(slug) {
|
|
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
|
|
}
|
|
|
|
export async function listProposals() {
|
|
return jsonOrThrow(await fetch('/api/proposals'))
|
|
}
|
|
|
|
export async function getProposal(prNumber) {
|
|
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
|
}
|
|
|
|
export async function proposeRFC({ title, slug, pitch, tags }) {
|
|
const res = await fetch('/api/rfcs/propose', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
|
|
})
|
|
return jsonOrThrow(res)
|
|
}
|
|
|
|
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.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`))
|
|
}
|
|
|
|
export async function graduateCheck(slug, { id, repo }) {
|
|
const params = new URLSearchParams()
|
|
if (id != null) params.set('id', id)
|
|
if (repo != null) params.set('repo', repo)
|
|
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
|
|
}
|
|
|
|
export async function startGraduation(slug, { rfcId, repoName, owners }) {
|
|
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, 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 }) {
|
|
const res = await fetch(
|
|
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, description }),
|
|
},
|
|
)
|
|
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'))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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',
|
|
}))
|
|
}
|
|
|
|
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()
|
|
}
|
|
|