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.

resources/views/settings.stxmodified+133-3
Changes to resources/views/settings.stx
@@ -116,7 +116,7 @@ if (user) {
116116 try {
117117 // Owned projects only — settings is the owner's admin surface.
118118 __projectRows = (await __db.unsafe(
119 'SELECT id, name, platform, ingest_key, is_active FROM projects WHERE owner_id = $1 ORDER BY created_at DESC NULLS LAST, id',
119 'SELECT id, name, platform, ingest_key, is_active, read_key, read_key_last_used_at FROM projects WHERE owner_id = $1 ORDER BY created_at DESC NULLS LAST, id',
120120 [Number(user.id)],
121121 )) ?? []
122122 if (__projectRows.length === 0)
@@ -158,8 +158,8 @@ if (user) {
158158
159159// Template-facing projections. `ingest_key` is dropped here and nowhere else:
160160// the switcher needs a project's id, name and state, never its credential.
161const projects = __projectRows.map(({ ingest_key: _ignored, ...p }) => p)
162const activeProject = __activeRow ? (({ ingest_key: _ignored, ...p }) => p)(__activeRow) : null
161const projects = __projectRows.map(({ ingest_key: _ignored, read_key: _secret, ...p }) => p)
162const activeProject = __activeRow ? (({ ingest_key: _ignored, read_key: _secret, ...p }) => p)(__activeRow) : null
163163const projectId = activeProject?.id || null
164164
165165// The connected repository, for the Repository section below. Loaded here
@@ -253,6 +253,12 @@ const inviteView = __inviteRows.map(i => ({ id: i.id, email: i.email, registered
253253// named value, so publishing it stays a decision rather than a side effect.
254254const keyFull = __activeRow ? String(__activeRow.ingest_key || '') : ''
255255const keyMask = maskKey(keyFull)
256// The read key itself never reaches the page: it is a secret, shown once when
257// it is issued and never again (#14). Only whether one exists, and when it was
258// last used, so somebody can tell a live key from a forgotten one before
259// revoking it.
260const hasReadKey = !!(__activeRow && __activeRow.read_key)
261const readKeyUsed = __activeRow && __activeRow.read_key_last_used_at ? String(__activeRow.read_key_last_used_at) : ''
256262
257263// Framework-specific install snippet, keyed off the project's platform. Key-only
258264// (Flare-style): the ingest key alone identifies the project — no project id, no DSN.
@@ -539,6 +545,8 @@ defineClientPayload({
539545 codeText: guide.code,
540546 initialKeyFull: keyFull,
541547 initialKeyMask: keyMask,
548 initialHasReadKey: hasReadKey,
549 initialReadKeyUsed: readKeyUsed,
542550})
543551
544552useSeoMeta({
@@ -828,6 +836,88 @@ async function rotateKey(): Promise<void> {
828836 }
829837}
830838
839// ---- read key ----
840// Issued on demand, shown once, and revocable (#14). The key is held in a
841// signal only for as long as the page that issued it is open: it is never
842// rendered from the server and nothing stores it, so closing the page is the
843// end of the only copy the browser ever had.
844const hasReadKey = state(initialHasReadKey)
845const readKeyUsed = state(initialReadKeyUsed)
846const readKeyOnce = state('')
847const readKeyBusy = state(false)
848const readKeyNote = state('')
849const readKeyNoteOk = state(true)
850const copiedReadKey = state(false)
851
852/** "never", or the stamp as the browser's locale writes it. */
853const readKeyUsedLabel = (): string => {
854 const at = readKeyUsed()
855 if (!at)
856 return 'never used'
857 const when = new Date(at)
858 return Number.isNaN(when.getTime()) ? 'used recently' : `last used ${when.toLocaleString()}`
859}
860
861function copyReadKey(): void {
862 void copyText(readKeyOnce(), copiedReadKey)
863}
864
865async function issueReadKey(): Promise<void> {
866 // Replacing one is destructive in the same way rotating the ingest key is,
867 // so it asks — but only when there is something to break.
868 if (hasReadKey()) {
869 const ask = window.stxConfirm
870 ? window.stxConfirm('The current read key stops working immediately, and anything still using it will get 404 until you update it. Continue?', { title: 'Replace read key?', confirmText: 'Replace key', cancelText: 'Cancel', type: 'warning' })
871 : Promise.resolve(true)
872 if (!(await Promise.resolve(ask)))
873 return
874 }
875 readKeyBusy.set(true)
876 try {
877 const res = await apiFetch(`${projUrl}/read-key`, { method: 'POST' })
878 const body = await readJson(res)
879 readKeyBusy.set(false)
880 if (!res.ok) {
881 note(readKeyNote, readKeyNoteOk, body.error || 'Could not issue a key.', false)
882 return
883 }
884 readKeyOnce.set(String(body.read_key || ''))
885 hasReadKey.set(true)
886 readKeyUsed.set('')
887 note(readKeyNote, readKeyNoteOk, 'Copy it now — this is the only time it is shown.', true)
888 }
889 catch {
890 readKeyBusy.set(false)
891 note(readKeyNote, readKeyNoteOk, 'Network error.', false)
892 }
893}
894
895async function revokeReadKey(): Promise<void> {
896 const ask = window.stxConfirm
897 ? window.stxConfirm('Anything using this key stops reading immediately. Continue?', { title: 'Revoke read key?', confirmText: 'Revoke', cancelText: 'Cancel', type: 'warning' })
898 : Promise.resolve(true)
899 if (!(await Promise.resolve(ask)))
900 return
901 readKeyBusy.set(true)
902 try {
903 const res = await apiFetch(`${projUrl}/read-key`, { method: 'DELETE' })
904 const body = await readJson(res)
905 readKeyBusy.set(false)
906 if (!res.ok) {
907 note(readKeyNote, readKeyNoteOk, body.error || 'Could not revoke.', false)
908 return
909 }
910 hasReadKey.set(false)
911 readKeyUsed.set('')
912 readKeyOnce.set('')
913 note(readKeyNote, readKeyNoteOk, 'Revoked.', true)
914 }
915 catch {
916 readKeyBusy.set(false)
917 note(readKeyNote, readKeyNoteOk, 'Network error.', false)
918 }
919}
920
831921// ---- members and invites ----
832922const inviteEmail = state('')
833923const inviting = state(false)
@@ -1254,6 +1344,7 @@ onMount(() => {
12541344 <nav class="flex flex-wrap gap-2 items-center mb-8 text-sm">
12551345 <a href="#install" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Install</a>
12561346 <a href="#keys" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Keys</a>
1347 <a href="#read-key" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Read key</a>
12571348 <a href="#repository" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Repository</a>
12581349 <a href="#members" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Members</a>
12591350 <a href="#alerts" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Alerts</a>
@@ -1316,6 +1407,45 @@ onMount(() => {
13161407 </div>
13171408 </section>
13181409
1410 {{-- ========================= READ KEY ========================= --}}
1411 <section id="read-key" class="mb-10 scroll-mt-[84px]">
1412 <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Read key</div>
1413 <div class="px-5 py-5 app-panel">
1414 <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.
1416 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.
1417 Send it as <span class="font-mono">Authorization: Bearer</span>.
1418 </p>
1419
1420 {{-- Just issued: the only time the key is ever shown. --}}
1421 <div :show="!!readKeyOnce()">
1422 <div class="flex flex-wrap gap-2 items-center">
1423 <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm tracking-[0.01em] truncate bg-canvas border border-line rounded-[10px]" :text="readKeyOnce()"></code>
1424 <button @click="copyReadKey" class="btn-quiet btn-sm" :text="copiedReadKey() ? 'Copied' : 'Copy'">Copy</button>
1425 </div>
1426 <p class="mt-3.5 leading-relaxed text-warn text-xs">
1427 Copy it now. It is not stored anywhere you can read it back, so leaving this page means issuing a new one.
1428 </p>
1429 </div>
1430
1431 {{-- Otherwise: whether one exists, and whether it is still in use. --}}
1432 <div :show="!readKeyOnce()">
1433 <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>
1435 <Button variant="ghost" @click="issueReadKey" :loading="readKeyBusy()" :label="hasReadKey() ? 'Replace' : 'Issue key'" className="btn-quiet btn-sm">Issue key</Button>
1436 <span :show="hasReadKey()">
1437 <Button variant="ghost" @click="revokeReadKey" :loading="readKeyBusy()" className="btn-danger btn-sm">Revoke</Button>
1438 </span>
1439 </div>
1440 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
1441 The last-used time is written at most once a minute, so a key in constant use shows a time up to a minute old.
1442 </p>
1443 </div>
1444
1445 <span class="mt-2 text-ok text-xs" :show="!!readKeyNote()" :class="readKeyNoteOk() ? 'mt-2 text-xs text-ok' : 'mt-2 text-xs text-accent'">{{ readKeyNote() }}</span>
1446 </div>
1447 </section>
1448
13191449 {{-- ========================== REPOSITORY ========================== --}}
13201450 <section id="repository" class="mb-10 scroll-mt-[84px]">
13211451 <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Repository</div>
tests/unit/button-vocabulary.test.tsmodified+7-2
Changes to tests/unit/button-vocabulary.test.ts
@@ -151,6 +151,11 @@ describe('settings button vocabulary', () => {
151151describe('settings Button adoption', () => {
152152 const SITES = [
153153 { paint: 'btn-danger btn-sm', name: 'Rotate', bindings: ['loading'] },
154 // The read key's pair (#14). Issue doubles as Replace once a key exists, so
155 // its label is reactive — through `:label`, never `:text`, which writes
156 // textContent and would erase the spinner this file exists to protect.
157 { paint: 'btn-quiet btn-sm', name: 'Issue key', bindings: ['loading', 'label'] },
158 { paint: 'btn-danger btn-sm', name: 'Revoke', bindings: ['loading'] },
154159 { paint: 'btn-cta btn-solid', name: 'Invite', bindings: ['loading'] },
155160 { paint: 'btn-cta btn-solid', name: 'Add channel', bindings: ['loading'] },
156161 { paint: 'flex-none whitespace-nowrap btn-cta btn-danger', name: '', bindings: ['loading'] },
@@ -167,7 +172,7 @@ describe('settings Button adoption', () => {
167172 // </Button>, swallowing half the file. Strip comments before matching markup.
168173 const calls = markup.match(/<Button\b[\s\S]*?<\/Button>/g) ?? []
169174
170 test('there are exactly five, and none of them re-states a paint', () => {
175 test('there are exactly as many as are registered, and none re-states a paint', () => {
171176 expect(calls).toHaveLength(SITES.length)
172177 for (const [i, call] of calls.entries()) {
173178 expect(/className="([^"]*)"/.exec(call)?.[1]).toBe(SITES[i].paint)
@@ -183,7 +188,7 @@ describe('settings Button adoption', () => {
183188 for (const [i, call] of calls.entries()) {
184189 const dependencies = new Set<string>()
185190 const html = await processComponents(call, {
186 __stx_client_signal_names: ['rotating', 'inviting', 'adding', 'archiving', 'modalBusy', 'modalReady', 'modalConfirmLabel'],
191 __stx_client_signal_names: ['rotating', 'inviting', 'adding', 'archiving', 'modalBusy', 'modalReady', 'modalConfirmLabel', 'readKeyBusy', 'hasReadKey'],
187192 __importedComponents: new Map([['button', resolve(ROOT, 'node_modules/@stacksjs/components/src/ui/button/Button.stx')]]),
188193 }, FILE, { componentsDir: resolve(ROOT, 'resources/components') }, dependencies)
189194