ReviewOS

also looking at this

stacks/analyticshq

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

#68
Merged glennmichael123 wants to merge feat/read-scoped-site-key into main
7 files +550 -11

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/dashboard.stxmodified+145-1
Changes to resources/views/dashboard.stx
@@ -373,6 +373,8 @@ let shareMode = false
373373// The owner's existing share token for the active site ('' if none), so the owner
374374// UI can show/copy/revoke the link. Bridged into the client block.
375375let __shareToken = ''
376let __hasReadKey = false
377let __readKeyUsed = ''
376378
377379// Raw rows, read into the shapes in types/dashboard.d.ts as each query returns.
378380// `db.unsafe` answers Record<string, unknown>, and Postgres hands COUNT(*) back as
@@ -498,6 +500,16 @@ async function loadData(): Promise<void> {
498500 if (!shareMode)
499501 __shareToken = text((await readSettings(siteId)).share_token) || ''
500502
503 // The read key's presence and last use, never the key (#67). It is a secret,
504 // shown once when it is issued and never again, so the page is only ever told
505 // whether one exists — enough to tell a live key from a forgotten one before
506 // revoking it.
507 if (!shareMode) {
508 const keyRow = (await pgq(`SELECT read_key, read_key_last_used_at FROM sites WHERE id = ? LIMIT 1`, [String(siteId)]))?.[0]
509 __hasReadKey = !!(keyRow && keyRow.read_key)
510 __readKeyUsed = keyRow && keyRow.read_key_last_used_at ? String(keyRow.read_key_last_used_at) : ''
511 }
512
501513 // Team: who can already reach this site, and who has been invited but has not
502514 // accepted yet. Resolved server-side and rendered with @foreach so the panel
503515 // says something before the bundle runs — an x-for over a reactive list is the
@@ -1382,12 +1394,16 @@ const exportPayload = {
13821394// UI so it can display the existing share link. The underlying server binding
13831395// stays private and only this exact scalar crosses the bridge.
13841396const shareCapability = __shareToken
1397// Presence and last use only. The key itself is never bridged, because it is
1398// never on the server side of this page either — see the read of it above.
1399const readKeyPresent = __hasReadKey
1400const readKeyUsedAt = __readKeyUsed
13851401// regionGeo and cityGeo seed the two geo toggles. They were missing from this
13861402// list for regions, and a declared payload bridges ONLY what it names, so
13871403// `typeof regionGeo` was 'undefined' in the client block and the box always
13881404// loaded unticked on a site that had regions on -- one click then sent `true`
13891405// again rather than turning it off.
1390defineClientPayload({ activeSiteId, chartTz, cityGeo, countryTotals, exportPayload, installOptions, liveNow, livePlaces, liveWhere, range, regionGeo, savedTz, shareCapability, visitorTimelines })
1406defineClientPayload({ activeSiteId, chartTz, cityGeo, countryTotals, exportPayload, installOptions, liveNow, livePlaces, liveWhere, range, readKeyPresent, readKeyUsedAt, regionGeo, savedTz, shareCapability, visitorTimelines })
13911407
13921408useSeoMeta({
13931409 title: "Dashboard - analyticshq",
@@ -1412,6 +1428,96 @@ const isPro = state(false)
14121428// declared further down and reached by hoisting, which is the trade for having one
14131429// place that decides what a share URL looks like.
14141430const shareUrl = state(shareCapability ? shareLinkFor(shareCapability) : '')
1431
1432// --- Read key ---------------------------------------------------------------
1433// Issued on demand, shown once, revocable (#67). The key lives in a signal only
1434// for as long as the page that issued it is open: nothing renders it from the
1435// server and nothing stores it, so leaving the page is the end of the only copy
1436// the browser ever had.
1437const hasReadKey = state(readKeyPresent)
1438const readKeyUsed = state(readKeyUsedAt)
1439const readKeyOnce = state('')
1440const readKeyBusy = state(false)
1441const readKeyCopied = state(false)
1442
1443function readKeyStatus(): string {
1444 if (!hasReadKey())
1445 return 'No read key. Issue one to let a server read this site.'
1446 const at = readKeyUsed()
1447 if (!at)
1448 return 'A read key is active — never used.'
1449 const when = new Date(at)
1450 return Number.isNaN(when.getTime())
1451 ? 'A read key is active — used recently.'
1452 : `A read key is active — last used ${when.toLocaleString()}.`
1453}
1454
1455function copyReadKey() {
1456 const value = readKeyOnce()
1457 if (!value || !navigator.clipboard?.writeText)
1458 return
1459 void navigator.clipboard.writeText(value).then(() => {
1460 readKeyCopied.set(true)
1461 setTimeout(() => readKeyCopied.set(false), 1600)
1462 }, () => {})
1463}
1464
1465async function issueReadKey() {
1466 if (readKeyBusy())
1467 return
1468 // Replacing is destructive in the same way revoking is, so it asks — but only
1469 // when there is something to break.
1470 if (hasReadKey() && !(await stxConfirm('Replace the read key? Anything using the current one stops reading immediately.')))
1471 return
1472 readKeyBusy.set(true)
1473 let failed = false
1474 try {
1475 const res = await fetch(`/api/sites/${encodeURIComponent(activeSiteId)}/read-key`, { method: 'POST', credentials: 'same-origin' })
1476 const d: { read_key?: string } = await res.json().catch(() => ({}))
1477 if (res.ok && d.read_key) {
1478 readKeyOnce.set(d.read_key)
1479 hasReadKey.set(true)
1480 readKeyUsed.set('')
1481 }
1482 else {
1483 failed = true
1484 }
1485 }
1486 catch {
1487 failed = true
1488 }
1489 readKeyBusy.set(false)
1490 if (failed)
1491 await stxAlert('Could not issue a read key. Please try again.')
1492}
1493
1494async function revokeReadKey() {
1495 if (readKeyBusy())
1496 return
1497 if (!(await stxConfirm('Revoke the read key? Anything using it stops reading immediately.')))
1498 return
1499 readKeyBusy.set(true)
1500 let failed = false
1501 try {
1502 const res = await fetch(`/api/sites/${encodeURIComponent(activeSiteId)}/read-key`, { method: 'DELETE', credentials: 'same-origin' })
1503 if (res.ok) {
1504 hasReadKey.set(false)
1505 readKeyUsed.set('')
1506 readKeyOnce.set('')
1507 }
1508 else {
1509 failed = true
1510 }
1511 }
1512 catch {
1513 failed = true
1514 }
1515 readKeyBusy.set(false)
1516 // Worth surfacing rather than swallowing, for the same reason a failed share
1517 // revoke is: the old key is still live, and clearing nothing looks like success.
1518 if (failed)
1519 await stxAlert('Could not revoke the read key. It may still be active. Please try again.')
1520}
14151521const shareBusy = state(false)
14161522const shareCopied = state(false)
14171523// Live "current visitors": seeded from the server render, then polled so the
@@ -3775,6 +3881,44 @@ onMount(() => {
37753881 </div>
37763882 @endif
37773883
3884 {{-- Read key: the credential a server presents to read this site's reports
3885 without signing in as anybody (#67). Sits beside the share link because
3886 both hand reading to someone who is not you — but a share link is a URL
3887 for a person's browser, and this is a secret for a server, which is the
3888 distinction the copy has to carry.
3889
3890 Same :show discipline as the share panel above, and for the reason
3891 spelled out there: a client-signal conditional nested inside a server
3892 one breaks stx's directive matching (§7.3), so :show is the binding,
3893 and the SSR state rides in the static style (§7.1).
3894
3895 Written without naming those directives literally — prose that spells
3896 them has made a matcher start inside a comment before. --}}
3897 @if (ownsActive)
3898 <div class="p-5 panel site-settings-panel">
3899 <div class="flex flex-wrap gap-3 items-center justify-between">
3900 <div>
3901 <div class="font-semibold text-sm">Read key</div>
3902 <div class="mt-0.5 text-text-3 text-xs">Lets a server read this site's reports machine-to-machine. Unlike a share link this is a secret: it belongs to no account and keeps working after you leave.</div>
3903 </div>
3904
3905 {{-- Just issued: the only time the key is ever shown. --}}
3906 <div class="flex flex-wrap gap-2 items-center" :show="readKeyOnce()" style="display:none">
3907 <input readonly :value="readKeyOnce()" aria-label="Read key" class="text-xs mono" style="min-width:220px;max-width:340px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text-2)">
3908 <button type="button" @click="copyReadKey" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ readKeyCopied() ? 'Copied' : 'Copy' }}</button>
3909 </div>
3910
3911 {{-- Otherwise: whether one exists, and whether it is still in use. --}}
3912 <div class="flex flex-wrap gap-2 items-center" :show="!readKeyOnce()" style="display:none">
3913 <span class="text-text-3 text-xs" style="max-width:340px">{{ readKeyStatus() }}</span>
3914 <button type="button" @click="issueReadKey" :disabled="readKeyBusy()" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ readKeyBusy() ? 'Working…' : (hasReadKey() ? 'Replace' : 'Issue key') }}</button>
3915 <button type="button" @click="revokeReadKey" :disabled="readKeyBusy()" :show="hasReadKey()" style="display:none;padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-3);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">Revoke</button>
3916 </div>
3917 </div>
3918 <div class="mt-2 text-text-3" style="font-size:11px">Shown once when issued. 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.</div>
3919 </div>
3920 @endif
3921
37783922 <footer class="mt-12 text-center text-text-3 text-xs">
37793923 Privacy-first, cookieless analytics on Stacks and PostgreSQL. <span class="mono">analyticshq.org</span>
37803924 </footer>