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.

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;
routes/analytics.tsmodified+88-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,47 @@ 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 const authHeader = request.headers?.get?.('authorization') ?? ''
830 const presented = request.bearerToken?.() ?? authHeader.replace(/^Bearer\s+/i, '')
831 if (!looksLikeReadKey(presented))
832 return requireSiteRole(request, siteId, 'viewer')
833
834 const row = (await pgq('SELECT id, read_key, read_key_last_used_at FROM sites WHERE id = ? LIMIT 1', [String(siteId)]))?.[0]
835 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
836 const verdict = authorizeRead(site, presented)
837 if (!verdict.ok)
838 return json({ error: verdict.error }, verdict.status)
839 if (site)
840 await recordReadKeyUse(site)
841 return null
842}
843
844/** Stamp the key as used, at most once a minute — see LAST_USED_RESOLUTION_MS. */
845async function recordReadKeyUse(site: { id: string, read_key_last_used_at?: string | null }): Promise<void> {
846 const now = new Date()
847 if (!shouldRecordUse(site.read_key_last_used_at, now))
848 return
849 // Never let the stamp fail a read: it aids revoking a key, it does not
850 // authorise one.
851 await pgq('UPDATE sites SET read_key_last_used_at = ? WHERE id = ?', [now.toISOString(), String(site.id)]).catch(() => {})
852}
853
812854async function requireSiteRole(request: HandlerRequest, siteId: string, required: SiteRole): Promise<Response | null> {
813855 const uid = authUserId(request)
814856 if (!uid)
@@ -1011,6 +1053,40 @@ route.post('/api/sites/{siteId}/members', async (request) => {
10111053 return json({ members: await listSiteMembers(siteId) })
10121054}).middleware('auth').skipCsrf()
10131055
1056route.options('/api/sites/{siteId}/read-key', () => new Response(null, { status: 204, headers: CORS }))
1057
1058// Issue (or replace) a site's read key. Admin-only.
1059//
1060// The credential a server presents to read this site's analytics without a
1061// person's account behind it (#67). Returned here and nowhere else: it is a
1062// secret, so it is shown once and afterwards the site only reports that a key
1063// exists and when it was last used. Losing it means issuing another, and the
1064// previous key stops working the moment that lands.
1065route.post('/api/sites/{siteId}/read-key', async (request) => {
1066 const siteId = request.params.siteId
1067 const denied = await requireSiteRole(request, siteId, 'admin')
1068 if (denied)
1069 return denied
1070
1071 const key = newReadKey()
1072 // A new key starts with no last-used stamp: keeping the old one would read
1073 // as "used before it existed".
1074 await pgq('UPDATE sites SET read_key = ?, read_key_last_used_at = NULL WHERE id = ?', [key, siteId])
1075 return json({ read_key: key })
1076}).middleware('auth').skipCsrf()
1077
1078// Revoke a site's read key. Admin-only. Anything still presenting it is
1079// refused on its next request.
1080route.delete('/api/sites/{siteId}/read-key', async (request) => {
1081 const siteId = request.params.siteId
1082 const denied = await requireSiteRole(request, siteId, 'admin')
1083 if (denied)
1084 return denied
1085
1086 await pgq('UPDATE sites SET read_key = NULL, read_key_last_used_at = NULL WHERE id = ?', [siteId])
1087 return json({ revoked: true })
1088}).middleware('auth').skipCsrf()
1089
10141090route.options('/api/sites/{siteId}/members/{userId}', () => new Response(null, { status: 204, headers: CORS }))
10151091
10161092route.delete('/api/sites/{siteId}/members/{userId}', async (request) => {
@@ -3559,7 +3635,8 @@ route.delete('/api/sites/{siteId}/visitors/{visitorId}', async (request) => {
35593635
35603636route.get('/api/sites/{siteId}/stats', async (request) => {
35613637 const siteId = request.params.siteId
3562 const denied = await requireSiteRole(request, siteId, 'viewer')
3638 // A viewer on this site, or a read key issued for it (#67).
3639 const denied = await requireSiteRead(request, siteId)
35633640 if (denied)
35643641 return denied
35653642 const { from, to } = window(request)
@@ -3583,11 +3660,12 @@ route.get('/api/sites/{siteId}/stats', async (request) => {
35833660 sessions: Number(row?.sessions ?? 0),
35843661 range: { from, to },
35853662 })
3586}).middleware('auth')
3663})
35873664
35883665route.get('/api/sites/{siteId}/timeseries', async (request) => {
35893666 const siteId = request.params.siteId
3590 const denied = await requireSiteRole(request, siteId, 'viewer')
3667 // A viewer on this site, or a read key issued for it (#67).
3668 const denied = await requireSiteRead(request, siteId)
35913669 if (denied)
35923670 return denied
35933671 const { from, to } = window(request)
@@ -3606,11 +3684,12 @@ route.get('/api/sites/{siteId}/timeseries', async (request) => {
36063684 if ('response' in result)
36073685 return result.response
36083686 return json({ series: result.rows })
3609}).middleware('auth')
3687})
36103688
36113689route.get('/api/sites/{siteId}/pages', async (request) => {
36123690 const siteId = request.params.siteId
3613 const denied = await requireSiteRole(request, siteId, 'viewer')
3691 // A viewer on this site, or a read key issued for it (#67).
3692 const denied = await requireSiteRead(request, siteId)
36143693 if (denied)
36153694 return denied
36163695 const { from, to } = window(request)
@@ -3629,11 +3708,12 @@ route.get('/api/sites/{siteId}/pages', async (request) => {
36293708 if ('response' in result)
36303709 return result.response
36313710 return json({ pages: result.rows })
3632}).middleware('auth')
3711})
36333712
36343713route.get('/api/sites/{siteId}/referrers', async (request) => {
36353714 const siteId = request.params.siteId
3636 const denied = await requireSiteRole(request, siteId, 'viewer')
3715 // A viewer on this site, or a read key issued for it (#67).
3716 const denied = await requireSiteRead(request, siteId)
36373717 if (denied)
36383718 return denied
36393719 const { from, to } = window(request)
@@ -3652,7 +3732,7 @@ route.get('/api/sites/{siteId}/referrers', async (request) => {
36523732 if ('response' in result)
36533733 return result.response
36543734 return json({ referrers: result.rows })
3655}).middleware('auth')
3735})
36563736
36573737// Owner-gated "top <dimension>" reports over page_views, so every dashboard
36583738// 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+110-0
Changes to tests/unit/read-key.test.ts
@@ -0,0 +1,110 @@
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 expect(authorizeRead({ ...site, read_key: null }, KEY).status).toBe(403)
75 })
76
77 test('refuses another project\'s key with 401', () => {
78 expect(authorizeRead(site, newReadKey())).toEqual({ ok: false, status: 401, error: 'Unauthorized' })
79 })
80
81 test('refuses a stored value that is not a well-formed key', () => {
82 // A column hand-edited to something short must not become a weak key.
83 expect(authorizeRead({ ...site, read_key: 'x' }, 'x').ok).toBe(false)
84 })
85})
86
87describe('shouldRecordUse', () => {
88 const now = new Date('2026-10-19T12:00:00.000Z')
89
90 test('records the first use', () => {
91 expect(shouldRecordUse(null, now)).toBe(true)
92 expect(shouldRecordUse(undefined, now)).toBe(true)
93 })
94
95 test('records again once the resolution has passed, and not before', () => {
96 const justBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS + 1).toISOString()
97 const atResolution = new Date(now.getTime() - LAST_USED_RESOLUTION_MS).toISOString()
98 expect(shouldRecordUse(justBefore, now)).toBe(false)
99 expect(shouldRecordUse(atResolution, now)).toBe(true)
100 })
101
102 test('records when the stored stamp cannot be read', () => {
103 // Better an extra write than a key that never looks used.
104 expect(shouldRecordUse('not a date', now)).toBe(true)
105 })
106
107 test('accepts a Date as well as a string', () => {
108 expect(shouldRecordUse(new Date(now.getTime() - 1000), now)).toBe(false)
109 })
110})