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
app/Models/Site.tsmodified+15-0
Changes to app/Models/Site.ts
@@ -37,6 +37,21 @@ export default defineModel({
3737 validation: { rule: schema.string().required().max(255) },
3838 },
3939
40 // Secret read key: a machine presents it as `Authorization: Bearer` to read
41 // this site's analytics without a person's credentials (#67). Not fillable
42 // and with no factory — issued on demand from site settings, revoked by
43 // setting it back to null. See app/Support/read-key.ts.
44 read_key: {
45 fillable: false,
46 hidden: true,
47 validation: { rule: schema.string() },
48 },
49
50 read_key_last_used_at: {
51 fillable: false,
52 validation: { rule: schema.string() },
53 },
54
4055 domains: {
4156 fillable: true,
4257 validation: { rule: schema.string().optional() },
app/Support/read-key.tsadded+102-0
Changes to app/Support/read-key.ts
@@ -0,0 +1,102 @@
1/**
2 * A site's read key: the credential a machine presents to read that site's
3 * analytics.
4 *
5 * There was no per-site credential to extend. The tracker is a public script
6 * beaconing to a site id, so unlike loghq and bughq this is a new credential
7 * rather than a second mode on an existing one — and unlike those, it has no
8 * public sibling to be confused with. It returns data, so it is a secret: 256
9 * bits, compared in constant time, and accepted only on the reads listed on
10 * the issue rather than on all forty-odd site routes.
11 *
12 * It authorises exactly one site. There is no account behind it, so it
13 * outlives the person who created it and carries none of their scope — which
14 * is the whole reason it exists (stacksjs/analyticshq#67): a Stacks dashboard
15 * reading a site server-to-server should not be holding somebody's session.
16 */
17
18/**
19 * `ahq_read_` then 256 bits of hex.
20 *
21 * The prefix is doing real work. It tells a read key apart from a user's
22 * bearer token before either is verified, so a read key is never handed to
23 * `Auth.getUserFromToken`; and it gives secret scanners something to match, so
24 * a key pasted into a repository can be found and revoked.
25 */
26const PREFIX = 'ahq_read_'
27const BODY = /^[0-9a-f]{64}$/
28
29export function newReadKey(): string {
30 const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
31 return PREFIX + Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
32}
33
34/** Whether a presented credential is shaped like a read key at all. */
35export function looksLikeReadKey(value: unknown): value is string {
36 return typeof value === 'string' && value.startsWith(PREFIX) && BODY.test(value.slice(PREFIX.length))
37}
38
39/**
40 * Equal, in time that does not depend on how much of the key is right.
41 *
42 * `===` on a string short-circuits at the first differing byte, which leaks
43 * the length of a correct prefix to anyone who can measure the response. That
44 * is a real attack on a secret an attacker can guess at repeatedly, and not
45 * one on the public ingest key, which is why only this side does it.
46 */
47export function sameKey(presented: string, stored: string): boolean {
48 if (presented.length !== stored.length)
49 return false
50 let diff = 0
51 for (let i = 0; i < presented.length; i++)
52 diff |= presented.charCodeAt(i) ^ stored.charCodeAt(i)
53 return diff === 0
54}
55
56export interface ReadKeySite {
57 id: string
58 read_key?: string | null
59}
60
61export type ReadAuthorization =
62 | { ok: true }
63 | { ok: false, status: 401 | 403 | 404, error: string }
64
65/**
66 * Whether this key may read this site.
67 *
68 * Site ids are public — they are embedded in the tracking snippet — so unlike
69 * loghq and bughq, telling 404 from 403 here leaks nothing, and the existing
70 * requireSiteRole already distinguishes them. The wording matches it so a
71 * refusal reads the same whichever credential was presented.
72 */
73export function authorizeRead(site: ReadKeySite | null | undefined, presented: unknown): ReadAuthorization {
74 if (!site)
75 return { ok: false, status: 404, error: 'Site not found' }
76 // No key issued is not the same as "any key will do": a site without one
77 // must refuse every presented key, including an empty one.
78 if (!looksLikeReadKey(site.read_key))
79 return { ok: false, status: 403, error: 'Forbidden' }
80 if (!looksLikeReadKey(presented) || !sameKey(presented, site.read_key))
81 return { ok: false, status: 401, error: 'Unauthorized' }
82 return { ok: true }
83}
84
85/**
86 * How often a key's last-used stamp is written.
87 *
88 * The stamp is there so somebody can see whether a key is still in use before
89 * revoking it, which a minute's resolution answers. Writing on every request
90 * would put an UPDATE in front of every read of a log stream that a dashboard
91 * polls.
92 */
93export const LAST_USED_RESOLUTION_MS = 60_000
94
95export function shouldRecordUse(lastUsedAt: string | Date | null | undefined, now: Date): boolean {
96 if (!lastUsedAt)
97 return true
98 const previous = lastUsedAt instanceof Date ? lastUsedAt.getTime() : Date.parse(String(lastUsedAt))
99 if (Number.isNaN(previous))
100 return true
101 return now.getTime() - previous >= LAST_USED_RESOLUTION_MS
102}
database/migrations/0000000060-add-site-read-key.sqladded+29-0
Changes to database/migrations/0000000060-add-site-read-key.sql
@@ -0,0 +1,29 @@
1-- A read-scoped site key, so a machine can read this site's analytics without
2-- holding a person's credentials (stacksjs/analyticshq#67).
3--
4-- NOTE ON STYLE: no semicolons in these comments. The migration runner splits
5-- the file on semicolons without first stripping comment lines.
6--
7-- WHY NOW
8--
9-- Every read here resolves a signed-in user, and the only issuable bearer
10-- belongs to a human. A Stacks dashboard reading a site server-to-server had
11-- nothing correct to hold, so selecting the hosted provider rendered empty.
12--
13-- Unlike loghq and bughq there was no per-site credential to extend. The
14-- tracker is a public script beaconing to a site id, so this is a new
15-- credential rather than a second mode on an old one.
16--
17-- This one returns data, so it is a secret. 256 bits, compared in constant
18-- time in app/Support/read-key.ts, and accepted only on the reads listed on
19-- that issue rather than on all forty-odd site routes.
20--
21-- Both nullable because a key is issued on demand and revoking sets it back to
22-- NULL, which is also why uniqueness is a partial index: every site without a
23-- key would otherwise collide.
24--
25-- read_key_last_used_at tells somebody whether a key is still in use before
26-- they revoke it. Written at most once a minute per key, not per request.
27ALTER TABLE "sites" ADD COLUMN IF NOT EXISTS "read_key" varchar(255);
28ALTER TABLE "sites" ADD COLUMN IF NOT EXISTS "read_key_last_used_at" varchar(255);
29CREATE UNIQUE INDEX IF NOT EXISTS "sites_read_key_unique" ON "sites" ("read_key") WHERE "read_key" IS NOT NULL;
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>
routes/analytics.tsmodified+90-8
Changes to routes/analytics.ts
@@ -20,6 +20,7 @@ import { formatCount, renderBadge, renderSparkline, sanitizeLabel } from '../app
2020import { ASSIGNABLE_ROLES, isAssignableRole, listReachableSites, listSiteMembers, resolveSiteRole, satisfies, siteExists, type SiteRole } from '../app/Analytics/access'
2121import { ALERT_CONDITIONS, ALERT_METRICS, isAlertCondition, isAlertMetric, isRelative } from '../app/Analytics/alerts'
2222import { planForSite } from '../app/Analytics/entitlements'
23import { authorizeRead, looksLikeReadKey, newReadKey, shouldRecordUse } from '../app/Support/read-key'
2324import { normalizeEnvironment } from '../app/Analytics/environment'
2425import { serializeEventProperties } from '../app/Analytics/event-properties'
2526import { expiryFrom, hashToken, inviteRefusal, looksLikeEmail, mintToken, normalizeEmail } from '../app/Analytics/invites'
@@ -809,6 +810,49 @@ function authUserId(request: HandlerRequest): string | null {
809810 * conceal and collapsing the two would only make real 404s undebuggable. The
810811 * secret is the data, and that is what the role check protects.
811812 */
813/**
814 * Gate a site-scoped read on a viewer role, or on a read key issued for the
815 * site (#67).
816 *
817 * Only the reads named on that issue call this — the stats summary, the time
818 * series, and the top pages and referrers. Every other `/api/sites/{siteId}/*`
819 * route keeps asking for a person, so a machine key reaches exactly the four
820 * endpoints it was issued for rather than all forty-odd by default. Adding one
821 * is calling this instead of requireSiteRole, deliberately one line per route.
822 *
823 * These four drop `.middleware('auth')`, which 401s before the handler and so
824 * would refuse a key before it could be read. Nothing is lost: that middleware
825 * was defence in depth behind requireSiteRole, which does the real check and
826 * still answers 401 when no credential resolves.
827 */
828async function requireSiteRead(request: HandlerRequest, siteId: string): Promise<Response | null> {
829 // Read off the header rather than a bearerToken() helper: HandlerRequest
830 // declares no such method here, and line 3272 reads the same header the same
831 // way, so this stays consistent with the rest of the file.
832 const presented = String(request.headers?.get?.('authorization') ?? '').replace(/^Bearer\s+/i, '')
833 if (!looksLikeReadKey(presented))
834 return requireSiteRole(request, siteId, 'viewer')
835
836 const row = (await pgq('SELECT id, read_key, read_key_last_used_at FROM sites WHERE id = ? LIMIT 1', [String(siteId)]))?.[0]
837 const site = row ? { id: String(row.id), read_key: row.read_key == null ? null : String(row.read_key), read_key_last_used_at: row.read_key_last_used_at == null ? null : String(row.read_key_last_used_at) } : null
838 const verdict = authorizeRead(site, presented)
839 if (!verdict.ok)
840 return json({ error: verdict.error }, verdict.status)
841 if (site)
842 await recordReadKeyUse(site)
843 return null
844}
845
846/** Stamp the key as used, at most once a minute — see LAST_USED_RESOLUTION_MS. */
847async function recordReadKeyUse(site: { id: string, read_key_last_used_at?: string | null }): Promise<void> {
848 const now = new Date()
849 if (!shouldRecordUse(site.read_key_last_used_at, now))
850 return
851 // Never let the stamp fail a read: it aids revoking a key, it does not
852 // authorise one.
853 await pgq('UPDATE sites SET read_key_last_used_at = ? WHERE id = ?', [now.toISOString(), String(site.id)]).catch(() => {})
854}
855
812856async function requireSiteRole(request: HandlerRequest, siteId: string, required: SiteRole): Promise<Response | null> {
813857 const uid = authUserId(request)
814858 if (!uid)
@@ -1011,6 +1055,40 @@ route.post('/api/sites/{siteId}/members', async (request) => {
10111055 return json({ members: await listSiteMembers(siteId) })
10121056}).middleware('auth').skipCsrf()
10131057
1058route.options('/api/sites/{siteId}/read-key', () => new Response(null, { status: 204, headers: CORS }))
1059
1060// Issue (or replace) a site's read key. Admin-only.
1061//
1062// The credential a server presents to read this site's analytics without a
1063// person's account behind it (#67). Returned here and nowhere else: it is a
1064// secret, so it is shown once and afterwards the site only reports that a key
1065// exists and when it was last used. Losing it means issuing another, and the
1066// previous key stops working the moment that lands.
1067route.post('/api/sites/{siteId}/read-key', async (request) => {
1068 const siteId = request.params.siteId
1069 const denied = await requireSiteRole(request, siteId, 'admin')
1070 if (denied)
1071 return denied
1072
1073 const key = newReadKey()
1074 // A new key starts with no last-used stamp: keeping the old one would read
1075 // as "used before it existed".
1076 await pgq('UPDATE sites SET read_key = ?, read_key_last_used_at = NULL WHERE id = ?', [key, siteId])
1077 return json({ read_key: key })
1078}).middleware('auth').skipCsrf()
1079
1080// Revoke a site's read key. Admin-only. Anything still presenting it is
1081// refused on its next request.
1082route.delete('/api/sites/{siteId}/read-key', async (request) => {
1083 const siteId = request.params.siteId
1084 const denied = await requireSiteRole(request, siteId, 'admin')
1085 if (denied)
1086 return denied
1087
1088 await pgq('UPDATE sites SET read_key = NULL, read_key_last_used_at = NULL WHERE id = ?', [siteId])
1089 return json({ revoked: true })
1090}).middleware('auth').skipCsrf()
1091
10141092route.options('/api/sites/{siteId}/members/{userId}', () => new Response(null, { status: 204, headers: CORS }))
10151093
10161094route.delete('/api/sites/{siteId}/members/{userId}', async (request) => {
@@ -3559,7 +3637,8 @@ route.delete('/api/sites/{siteId}/visitors/{visitorId}', async (request) => {
35593637
35603638route.get('/api/sites/{siteId}/stats', async (request) => {
35613639 const siteId = request.params.siteId
3562 const denied = await requireSiteRole(request, siteId, 'viewer')
3640 // A viewer on this site, or a read key issued for it (#67).
3641 const denied = await requireSiteRead(request, siteId)
35633642 if (denied)
35643643 return denied
35653644 const { from, to } = window(request)
@@ -3583,11 +3662,12 @@ route.get('/api/sites/{siteId}/stats', async (request) => {
35833662 sessions: Number(row?.sessions ?? 0),
35843663 range: { from, to },
35853664 })
3586}).middleware('auth')
3665})
35873666
35883667route.get('/api/sites/{siteId}/timeseries', async (request) => {
35893668 const siteId = request.params.siteId
3590 const denied = await requireSiteRole(request, siteId, 'viewer')
3669 // A viewer on this site, or a read key issued for it (#67).
3670 const denied = await requireSiteRead(request, siteId)
35913671 if (denied)
35923672 return denied
35933673 const { from, to } = window(request)
@@ -3606,11 +3686,12 @@ route.get('/api/sites/{siteId}/timeseries', async (request) => {
36063686 if ('response' in result)
36073687 return result.response
36083688 return json({ series: result.rows })
3609}).middleware('auth')
3689})
36103690
36113691route.get('/api/sites/{siteId}/pages', async (request) => {
36123692 const siteId = request.params.siteId
3613 const denied = await requireSiteRole(request, siteId, 'viewer')
3693 // A viewer on this site, or a read key issued for it (#67).
3694 const denied = await requireSiteRead(request, siteId)
36143695 if (denied)
36153696 return denied
36163697 const { from, to } = window(request)
@@ -3629,11 +3710,12 @@ route.get('/api/sites/{siteId}/pages', async (request) => {
36293710 if ('response' in result)
36303711 return result.response
36313712 return json({ pages: result.rows })
3632}).middleware('auth')
3713})
36333714
36343715route.get('/api/sites/{siteId}/referrers', async (request) => {
36353716 const siteId = request.params.siteId
3636 const denied = await requireSiteRole(request, siteId, 'viewer')
3717 // A viewer on this site, or a read key issued for it (#67).
3718 const denied = await requireSiteRead(request, siteId)
36373719 if (denied)
36383720 return denied
36393721 const { from, to } = window(request)
@@ -3652,7 +3734,7 @@ route.get('/api/sites/{siteId}/referrers', async (request) => {
36523734 if ('response' in result)
36533735 return result.response
36543736 return json({ referrers: result.rows })
3655}).middleware('auth')
3737})
36563738
36573739// Owner-gated "top <dimension>" reports over page_views, so every dashboard
36583740// breakdown has a documented JSON endpoint too (issue #15). `column` is always a
tests/unit/api-authz.test.tsmodified+54-2
Changes to tests/unit/api-authz.test.ts
@@ -22,11 +22,23 @@ function routeBlock(path: string): string {
2222 return end === -1 ? rest : rest.slice(0, end)
2323}
2424
25const READ_ENDPOINTS = [
25/**
26 * The four reads a machine key may reach (#67), and nothing else.
27 *
28 * These are gated by `requireSiteRead`, which takes a read key issued for the
29 * site OR falls back to the same viewer check as everything below. They drop
30 * `.middleware('auth')` because it 401s before the handler and would refuse a
31 * key before it could be read — the real check is in the gate, which is
32 * asserted separately.
33 */
34const KEY_READABLE = [
2635 '/api/sites/{siteId}/stats',
2736 '/api/sites/{siteId}/timeseries',
2837 '/api/sites/{siteId}/pages',
2938 '/api/sites/{siteId}/referrers',
39]
40
41const READ_ENDPOINTS = [
3042 '/api/sites/{siteId}/events',
3143 '/api/sites/{siteId}/entry-pages',
3244 '/api/sites/{siteId}/exit-pages',
@@ -45,6 +57,42 @@ const TOP_DIMENSIONS = [
4557 '/api/sites/{siteId}/utm/campaigns',
4658]
4759
60describe('guardrail: a read key reaches exactly the reads it was issued for', () => {
61 // The point of a site key is that it is narrower than a person. If it could
62 // reach every /api/sites/{siteId}/* route, it would be a worse version of the
63 // user token it replaces, so the set it reaches is pinned here rather than
64 // left to whoever edits a route next.
65 for (const path of KEY_READABLE) {
66 test(`GET ${path} is gated by requireSiteRead`, () => {
67 const block = routeBlock(path)
68 expect(block).not.toBe('')
69 expect(block).toContain('requireSiteRead(request, siteId)')
70 })
71 }
72
73 test('no other route is gated by requireSiteRead', () => {
74 const gated = analytics.split('requireSiteRead(request, siteId)').length - 1
75 // One call per allowlisted route, plus the definition's own signature line
76 // does not match this call shape.
77 expect(gated).toBe(KEY_READABLE.length)
78 })
79
80 test('requireSiteRead still enforces viewer for a person', () => {
81 // Without this the four routes above would have no role check at all for a
82 // signed-in caller: dropping .middleware('auth') on them is only safe
83 // because the fallback is the same gate the rest of the file uses.
84 const i = analytics.indexOf('async function requireSiteRead(')
85 expect(i).toBeGreaterThan(-1)
86 const end = analytics.indexOf('\nasync function requireSiteRole(', i)
87 expect(end).toBeGreaterThan(i)
88 const body = analytics.slice(i, end)
89 expect(body).toContain('requireSiteRole(request, siteId, \'viewer\')')
90 // And it only accepts something shaped like a key — anything else falls
91 // through to the person check rather than being trusted.
92 expect(body).toContain('looksLikeReadKey(presented)')
93 })
94})
95
4896describe('guardrail: site-scoped read endpoints require at least viewer', () => {
4997 // These were owner-gated until #19, which is why an invited colleague could not
5098 // read a single report. The requirement is now a RANK, and the rank has to be
@@ -160,7 +208,11 @@ describe('guardrail: no site-scoped endpoint is left ungated', () => {
160208 const rest = analytics.slice(i)
161209 const end = rest.indexOf('\nroute.')
162210 const block = end === -1 ? rest : rest.slice(0, end)
163 expect(block).toMatch(/requireSiteRole\(request, siteId, '(viewer|admin|owner)'\)|requireSiteOwner\(request, siteId\)/)
211 // requireSiteRead counts: it takes a read key issued for this site, and
212 // otherwise defers to requireSiteRole(..., 'viewer') — asserted above, so
213 // accepting it here does not widen what "gated" means. The set of routes
214 // allowed to use it is pinned above too.
215 expect(block).toMatch(/requireSiteRole\(request, siteId, '(viewer|admin|owner)'\)|requireSiteOwner\(request, siteId\)|requireSiteRead\(request, siteId\)/)
164216 })
165217 }
166218})
tests/unit/read-key.test.tsadded+115-0
Changes to tests/unit/read-key.test.ts
@@ -0,0 +1,115 @@
1import { describe, expect, test } from 'bun:test'
2import { authorizeRead, LAST_USED_RESOLUTION_MS, looksLikeReadKey, newReadKey, sameKey, shouldRecordUse } from '../../app/Support/read-key'
3
4const KEY = newReadKey()
5const site = { id: 'demo', read_key: KEY }
6
7describe('newReadKey', () => {
8 test('is prefixed and carries 256 bits of hex', () => {
9 expect(KEY.startsWith('ahq_read_')).toBe(true)
10 expect(KEY.slice('ahq_read_'.length)).toMatch(/^[0-9a-f]{64}$/)
11 })
12
13 test('does not repeat', () => {
14 const keys = new Set(Array.from({ length: 200 }, () => newReadKey()))
15 expect(keys.size).toBe(200)
16 })
17})
18
19describe('looksLikeReadKey', () => {
20 test('accepts a key it minted', () => {
21 expect(looksLikeReadKey(KEY)).toBe(true)
22 })
23
24 test('rejects anything else a bearer header might carry', () => {
25 // The prefix is what keeps a read key out of Auth.getUserFromToken, so a
26 // user's token and an ingest key must never pass for one.
27 for (const value of [
28 '',
29 null,
30 undefined,
31 'some_other_credential_shape_entirely',
32 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig',
33 `${'ahq_read_'}${'z'.repeat(64)}`,
34 `${'ahq_read_'}${'a'.repeat(63)}`,
35 `${'ahq_read_'}${'a'.repeat(65)}`,
36 KEY.toUpperCase(),
37 ])
38 expect(looksLikeReadKey(value)).toBe(false)
39 })
40})
41
42describe('sameKey', () => {
43 test('matches an identical key and nothing else', () => {
44 expect(sameKey(KEY, KEY)).toBe(true)
45 expect(sameKey(KEY, newReadKey())).toBe(false)
46 })
47
48 test('is false on a shared prefix, not just a differing first byte', () => {
49 const other = `${KEY.slice(0, -1)}${KEY.endsWith('a') ? 'b' : 'a'}`
50 expect(sameKey(other, KEY)).toBe(false)
51 })
52
53 test('does not throw on a length mismatch', () => {
54 expect(sameKey('short', KEY)).toBe(false)
55 expect(sameKey(KEY, 'short')).toBe(false)
56 })
57})
58
59describe('authorizeRead', () => {
60 test('allows the site its own key', () => {
61 expect(authorizeRead(site, KEY)).toEqual({ ok: true })
62 })
63
64 test('refuses an unknown site with 404', () => {
65 expect(authorizeRead(null, KEY)).toEqual({ ok: false, status: 404, error: 'Site not found' })
66 expect(authorizeRead(undefined, KEY)).toEqual({ ok: false, status: 404, error: 'Site not found' })
67 })
68
69 test('refuses a site with no key issued, whatever is presented', () => {
70 // Guards `null === null`: no key configured must never authorise, not even
71 // against a caller presenting nothing.
72 for (const presented of [KEY, null, undefined, ''])
73 expect(authorizeRead({ ...site, read_key: null }, presented).ok).toBe(false)
74 // Narrowed rather than reached into: the refusal shape only carries a
75 // status on the failing branch of the union.
76 const refused = authorizeRead({ ...site, read_key: null }, KEY)
77 expect(refused.ok).toBe(false)
78 if (!refused.ok)
79 expect(refused.status).toBe(403)
80 })
81
82 test('refuses another project\'s key with 401', () => {
83 expect(authorizeRead(site, newReadKey())).toEqual({ ok: false, status: 401, error: 'Unauthorized' })
84 })
85
86 test('refuses a stored value that is not a well-formed key', () => {
87 // A column hand-edited to something short must not become a weak key.
88 expect(authorizeRead({ ...site, read_key: 'x' }, 'x').ok).toBe(false)
89 })
90})
91
92describe('shouldRecordUse', () => {
93 const now = new Date('2026-10-19T12:00:00.000Z')
94
95 test('records the first use', () => {
96 expect(shouldRecordUse(null, now)).toBe(true)
97 expect(shouldRecordUse(undefined, now)).toBe(true)
98 })
99
100 test('records again once the resolution has passed, and not before', () => {
101 const justBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS + 1).toISOString()
102 const atResolution = new Date(now.getTime() - LAST_USED_RESOLUTION_MS).toISOString()
103 expect(shouldRecordUse(justBefore, now)).toBe(false)
104 expect(shouldRecordUse(atResolution, now)).toBe(true)
105 })
106
107 test('records when the stored stamp cannot be read', () => {
108 // Better an extra write than a key that never looks used.
109 expect(shouldRecordUse('not a date', now)).toBe(true)
110 })
111
112 test('accepts a Date as well as a string', () => {
113 expect(shouldRecordUse(new Date(now.getTime() - 1000), now)).toBe(false)
114 })
115})