ReviewOS

also looking at this

loghqorg/loghq

A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials

#15
Merged glennmichael123 wants to merge feat/read-scoped-project-key into main
9 files +537 -17

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Support/request-auth.tsmodified+15-3
Changes to app/Support/request-auth.ts
@@ -80,16 +80,28 @@ export async function projectReadAccess(request: any, projectId: string, ownsPro
8080 const presented = bearerFrom(request)
8181
8282 if (looksLikeReadKey(presented)) {
83 const row = (await db.unsafe(
83 // db.unsafe answers an untyped row, so the columns are narrowed here rather
84 // than asserted: authorizeRead decides who may read a project and should be
85 // reading values it can see the shape of.
86 const found = (await db.unsafe(
8487 'SELECT id, read_key, read_key_last_used_at, is_active FROM projects WHERE id = $1 LIMIT 1',
8588 [String(projectId)],
86 ))?.[0] ?? null
89 ))?.[0]
90 const row = found
91 ? {
92 id: String(found.id),
93 read_key: found.read_key == null ? null : String(found.read_key),
94 read_key_last_used_at: found.read_key_last_used_at == null ? null : String(found.read_key_last_used_at),
95 is_active: found.is_active as boolean | number | null,
96 }
97 : null
8798 const verdict = authorizeRead(row, presented)
8899 // A key that is valid for some other project must not reveal that this one
89100 // exists, so its refusal is the same 404 an unknown id gets.
90101 if (!verdict.ok)
91102 return verdict.status === 401 ? { ok: false, status: 404, error: 'not found' } : verdict
92 await recordReadKeyUse(row)
103 if (row)
104 await recordReadKeyUse(row)
93105 return { ok: true, as: 'key' }
94106 }
95107
resources/views/settings.stxmodified+3-3
Changes to resources/views/settings.stx
@@ -884,7 +884,7 @@ async function issueReadKey(): Promise<void> {
884884 readKeyOnce.set(String(body.read_key || ''))
885885 hasReadKey.set(true)
886886 readKeyUsed.set('')
887 note(readKeyNote, readKeyNoteOk, 'Copy it now — this is the only time it is shown.', true)
887 note(readKeyNote, readKeyNoteOk, 'Copy it now. This is the only time it is shown.', true)
888888 }
889889 catch {
890890 readKeyBusy.set(false)
@@ -1412,7 +1412,7 @@ onMount(() => {
14121412 <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Read key</div>
14131413 <div class="px-5 py-5 app-panel">
14141414 <p class="mb-4 text-sm text-subtle">
1415 Lets a server read this project's logs without signing in as you — a Stacks dashboard, or anything else reading the API machine-to-machine.
1415 Lets a server read this project's logs without signing in as you: a Stacks dashboard, or anything else reading the API machine-to-machine.
14161416 Unlike the API key above this one <em>is</em> a secret: it reads your logs, it belongs to no account, and it keeps working after you leave.
14171417 Send it as <span class="font-mono">Authorization: Bearer</span>.
14181418 </p>
@@ -1431,7 +1431,7 @@ onMount(() => {
14311431 {{-- Otherwise: whether one exists, and whether it is still in use. --}}
14321432 <div :show="!readKeyOnce()">
14331433 <div class="flex flex-wrap gap-2 items-center">
1434 <span class="flex-1 min-w-[220px] text-sm" :class="hasReadKey() ? 'text-ink' : 'text-subtle'" :text="hasReadKey() ? `A read key is active — ${readKeyUsedLabel()}.` : 'No read key. Issue one to let a server read this project.'"></span>
1434 <span class="flex-1 min-w-[220px] text-sm" :class="hasReadKey() ? 'text-ink' : 'text-subtle'" :text="hasReadKey() ? `A read key is active, ${readKeyUsedLabel()}.` : 'No read key. Issue one to let a server read this project.'"></span>
14351435 <Button variant="ghost" @click="issueReadKey" :loading="readKeyBusy()" :label="hasReadKey() ? 'Replace' : 'Issue key'" className="btn-quiet btn-sm">Issue key</Button>
14361436 <span :show="hasReadKey()">
14371437 <Button variant="ghost" @click="revokeReadKey" :loading="readKeyBusy()" className="btn-danger btn-sm">Revoke</Button>