also looking at this
A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials
#15
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.
| @@ -116,7 +116,7 @@ if (user) { | ||
| 116 | 116 | try { |
| 117 | 117 | // Owned projects only — settings is the owner's admin surface. |
| 118 | 118 | __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', | |
| 120 | 120 | [Number(user.id)], |
| 121 | 121 | )) ?? [] |
| 122 | 122 | if (__projectRows.length === 0) |
| @@ -158,8 +158,8 @@ if (user) { | ||
| 158 | 158 | |
| 159 | 159 | // Template-facing projections. `ingest_key` is dropped here and nowhere else: |
| 160 | 160 | // the switcher needs a project's id, name and state, never its credential. |
| 161 | const projects = __projectRows.map(({ ingest_key: _ignored, ...p }) => p) | |
| 162 | const activeProject = __activeRow ? (({ ingest_key: _ignored, ...p }) => p)(__activeRow) : null | |
| 161 | const projects = __projectRows.map(({ ingest_key: _ignored, read_key: _secret, ...p }) => p) | |
| 162 | const activeProject = __activeRow ? (({ ingest_key: _ignored, read_key: _secret, ...p }) => p)(__activeRow) : null | |
| 163 | 163 | const projectId = activeProject?.id || null |
| 164 | 164 | |
| 165 | 165 | // 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 | ||
| 253 | 253 | // named value, so publishing it stays a decision rather than a side effect. |
| 254 | 254 | const keyFull = __activeRow ? String(__activeRow.ingest_key || '') : '' |
| 255 | 255 | const 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. | |
| 260 | const hasReadKey = !!(__activeRow && __activeRow.read_key) | |
| 261 | const readKeyUsed = __activeRow && __activeRow.read_key_last_used_at ? String(__activeRow.read_key_last_used_at) : '' | |
| 256 | 262 | |
| 257 | 263 | // Framework-specific install snippet, keyed off the project's platform. Key-only |
| 258 | 264 | // (Flare-style): the ingest key alone identifies the project — no project id, no DSN. |
| @@ -539,6 +545,8 @@ defineClientPayload({ | ||
| 539 | 545 | codeText: guide.code, |
| 540 | 546 | initialKeyFull: keyFull, |
| 541 | 547 | initialKeyMask: keyMask, |
| 548 | initialHasReadKey: hasReadKey, | |
| 549 | initialReadKeyUsed: readKeyUsed, | |
| 542 | 550 | }) |
| 543 | 551 | |
| 544 | 552 | useSeoMeta({ |
| @@ -828,6 +836,88 @@ async function rotateKey(): Promise<void> { | ||
| 828 | 836 | } |
| 829 | 837 | } |
| 830 | 838 | |
| 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. | |
| 844 | const hasReadKey = state(initialHasReadKey) | |
| 845 | const readKeyUsed = state(initialReadKeyUsed) | |
| 846 | const readKeyOnce = state('') | |
| 847 | const readKeyBusy = state(false) | |
| 848 | const readKeyNote = state('') | |
| 849 | const readKeyNoteOk = state(true) | |
| 850 | const copiedReadKey = state(false) | |
| 851 | ||
| 852 | /** "never", or the stamp as the browser's locale writes it. */ | |
| 853 | const 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 | ||
| 861 | function copyReadKey(): void { | |
| 862 | void copyText(readKeyOnce(), copiedReadKey) | |
| 863 | } | |
| 864 | ||
| 865 | async 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 | ||
| 895 | async 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 | ||
| 831 | 921 | // ---- members and invites ---- |
| 832 | 922 | const inviteEmail = state('') |
| 833 | 923 | const inviting = state(false) |
| @@ -1254,6 +1344,7 @@ onMount(() => { | ||
| 1254 | 1344 | <nav class="flex flex-wrap gap-2 items-center mb-8 text-sm"> |
| 1255 | 1345 | <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> |
| 1256 | 1346 | <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> | |
| 1257 | 1348 | <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> |
| 1258 | 1349 | <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> |
| 1259 | 1350 | <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(() => { | ||
| 1316 | 1407 | </div> |
| 1317 | 1408 | </section> |
| 1318 | 1409 | |
| 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 | ||
| 1319 | 1449 | {{-- ========================== REPOSITORY ========================== --}} |
| 1320 | 1450 | <section id="repository" class="mb-10 scroll-mt-[84px]"> |
| 1321 | 1451 | <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Repository</div> |