also looking at this
A read-scoped site key, so a Stacks dashboard can read analytics without a person's credentials
#68
7 files
+550
-11
| @@ -37,6 +37,21 @@ export default defineModel({ | ||
| 37 | 37 | validation: { rule: schema.string().required().max(255) }, |
| 38 | 38 | }, |
| 39 | 39 | |
| 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 | ||
| 40 | 55 | domains: { |
| 41 | 56 | fillable: true, |
| 42 | 57 | validation: { rule: schema.string().optional() }, |
| @@ -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 | */ | |
| 26 | const PREFIX = 'ahq_read_' | |
| 27 | const BODY = /^[0-9a-f]{64}$/ | |
| 28 | ||
| 29 | export 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. */ | |
| 35 | export 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 | */ | |
| 47 | export 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 | ||
| 56 | export interface ReadKeySite { | |
| 57 | id: string | |
| 58 | read_key?: string | null | |
| 59 | } | |
| 60 | ||
| 61 | export 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 | */ | |
| 73 | export 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 | */ | |
| 93 | export const LAST_USED_RESOLUTION_MS = 60_000 | |
| 94 | ||
| 95 | export 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 | } | |
| @@ -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. | |
| 27 | ALTER TABLE "sites" ADD COLUMN IF NOT EXISTS "read_key" varchar(255); | |
| 28 | ALTER TABLE "sites" ADD COLUMN IF NOT EXISTS "read_key_last_used_at" varchar(255); | |
| 29 | CREATE UNIQUE INDEX IF NOT EXISTS "sites_read_key_unique" ON "sites" ("read_key") WHERE "read_key" IS NOT NULL; | |
| @@ -373,6 +373,8 @@ let shareMode = false | ||
| 373 | 373 | // The owner's existing share token for the active site ('' if none), so the owner |
| 374 | 374 | // UI can show/copy/revoke the link. Bridged into the client block. |
| 375 | 375 | let __shareToken = '' |
| 376 | let __hasReadKey = false | |
| 377 | let __readKeyUsed = '' | |
| 376 | 378 | |
| 377 | 379 | // Raw rows, read into the shapes in types/dashboard.d.ts as each query returns. |
| 378 | 380 | // `db.unsafe` answers Record<string, unknown>, and Postgres hands COUNT(*) back as |
| @@ -498,6 +500,16 @@ async function loadData(): Promise<void> { | ||
| 498 | 500 | if (!shareMode) |
| 499 | 501 | __shareToken = text((await readSettings(siteId)).share_token) || '' |
| 500 | 502 | |
| 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 | ||
| 501 | 513 | // Team: who can already reach this site, and who has been invited but has not |
| 502 | 514 | // accepted yet. Resolved server-side and rendered with @foreach so the panel |
| 503 | 515 | // says something before the bundle runs — an x-for over a reactive list is the |
| @@ -1382,12 +1394,16 @@ const exportPayload = { | ||
| 1382 | 1394 | // UI so it can display the existing share link. The underlying server binding |
| 1383 | 1395 | // stays private and only this exact scalar crosses the bridge. |
| 1384 | 1396 | const 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. | |
| 1399 | const readKeyPresent = __hasReadKey | |
| 1400 | const readKeyUsedAt = __readKeyUsed | |
| 1385 | 1401 | // regionGeo and cityGeo seed the two geo toggles. They were missing from this |
| 1386 | 1402 | // list for regions, and a declared payload bridges ONLY what it names, so |
| 1387 | 1403 | // `typeof regionGeo` was 'undefined' in the client block and the box always |
| 1388 | 1404 | // loaded unticked on a site that had regions on -- one click then sent `true` |
| 1389 | 1405 | // again rather than turning it off. |
| 1390 | defineClientPayload({ activeSiteId, chartTz, cityGeo, countryTotals, exportPayload, installOptions, liveNow, livePlaces, liveWhere, range, regionGeo, savedTz, shareCapability, visitorTimelines }) | |
| 1406 | defineClientPayload({ activeSiteId, chartTz, cityGeo, countryTotals, exportPayload, installOptions, liveNow, livePlaces, liveWhere, range, readKeyPresent, readKeyUsedAt, regionGeo, savedTz, shareCapability, visitorTimelines }) | |
| 1391 | 1407 | |
| 1392 | 1408 | useSeoMeta({ |
| 1393 | 1409 | title: "Dashboard - analyticshq", |
| @@ -1412,6 +1428,96 @@ const isPro = state(false) | ||
| 1412 | 1428 | // declared further down and reached by hoisting, which is the trade for having one |
| 1413 | 1429 | // place that decides what a share URL looks like. |
| 1414 | 1430 | const 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. | |
| 1437 | const hasReadKey = state(readKeyPresent) | |
| 1438 | const readKeyUsed = state(readKeyUsedAt) | |
| 1439 | const readKeyOnce = state('') | |
| 1440 | const readKeyBusy = state(false) | |
| 1441 | const readKeyCopied = state(false) | |
| 1442 | ||
| 1443 | function 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 | ||
| 1455 | function 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 | ||
| 1465 | async 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 | ||
| 1494 | async 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 | } | |
| 1415 | 1521 | const shareBusy = state(false) |
| 1416 | 1522 | const shareCopied = state(false) |
| 1417 | 1523 | // Live "current visitors": seeded from the server render, then polled so the |
| @@ -3775,6 +3881,44 @@ onMount(() => { | ||
| 3775 | 3881 | </div> |
| 3776 | 3882 | @endif |
| 3777 | 3883 | |
| 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 | ||
| 3778 | 3922 | <footer class="mt-12 text-center text-text-3 text-xs"> |
| 3779 | 3923 | Privacy-first, cookieless analytics on Stacks and PostgreSQL. <span class="mono">analyticshq.org</span> |
| 3780 | 3924 | </footer> |
| @@ -20,6 +20,7 @@ import { formatCount, renderBadge, renderSparkline, sanitizeLabel } from '../app | ||
| 20 | 20 | import { ASSIGNABLE_ROLES, isAssignableRole, listReachableSites, listSiteMembers, resolveSiteRole, satisfies, siteExists, type SiteRole } from '../app/Analytics/access' |
| 21 | 21 | import { ALERT_CONDITIONS, ALERT_METRICS, isAlertCondition, isAlertMetric, isRelative } from '../app/Analytics/alerts' |
| 22 | 22 | import { planForSite } from '../app/Analytics/entitlements' |
| 23 | import { authorizeRead, looksLikeReadKey, newReadKey, shouldRecordUse } from '../app/Support/read-key' | |
| 23 | 24 | import { normalizeEnvironment } from '../app/Analytics/environment' |
| 24 | 25 | import { serializeEventProperties } from '../app/Analytics/event-properties' |
| 25 | 26 | import { expiryFrom, hashToken, inviteRefusal, looksLikeEmail, mintToken, normalizeEmail } from '../app/Analytics/invites' |
| @@ -809,6 +810,49 @@ function authUserId(request: HandlerRequest): string | null { | ||
| 809 | 810 | * conceal and collapsing the two would only make real 404s undebuggable. The |
| 810 | 811 | * secret is the data, and that is what the role check protects. |
| 811 | 812 | */ |
| 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 | */ | |
| 828 | async 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. */ | |
| 847 | async 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 | ||
| 812 | 856 | async function requireSiteRole(request: HandlerRequest, siteId: string, required: SiteRole): Promise<Response | null> { |
| 813 | 857 | const uid = authUserId(request) |
| 814 | 858 | if (!uid) |
| @@ -1011,6 +1055,40 @@ route.post('/api/sites/{siteId}/members', async (request) => { | ||
| 1011 | 1055 | return json({ members: await listSiteMembers(siteId) }) |
| 1012 | 1056 | }).middleware('auth').skipCsrf() |
| 1013 | 1057 | |
| 1058 | route.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. | |
| 1067 | route.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. | |
| 1082 | route.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 | ||
| 1014 | 1092 | route.options('/api/sites/{siteId}/members/{userId}', () => new Response(null, { status: 204, headers: CORS })) |
| 1015 | 1093 | |
| 1016 | 1094 | route.delete('/api/sites/{siteId}/members/{userId}', async (request) => { |
| @@ -3559,7 +3637,8 @@ route.delete('/api/sites/{siteId}/visitors/{visitorId}', async (request) => { | ||
| 3559 | 3637 | |
| 3560 | 3638 | route.get('/api/sites/{siteId}/stats', async (request) => { |
| 3561 | 3639 | 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) | |
| 3563 | 3642 | if (denied) |
| 3564 | 3643 | return denied |
| 3565 | 3644 | const { from, to } = window(request) |
| @@ -3583,11 +3662,12 @@ route.get('/api/sites/{siteId}/stats', async (request) => { | ||
| 3583 | 3662 | sessions: Number(row?.sessions ?? 0), |
| 3584 | 3663 | range: { from, to }, |
| 3585 | 3664 | }) |
| 3586 | }).middleware('auth') | |
| 3665 | }) | |
| 3587 | 3666 | |
| 3588 | 3667 | route.get('/api/sites/{siteId}/timeseries', async (request) => { |
| 3589 | 3668 | 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) | |
| 3591 | 3671 | if (denied) |
| 3592 | 3672 | return denied |
| 3593 | 3673 | const { from, to } = window(request) |
| @@ -3606,11 +3686,12 @@ route.get('/api/sites/{siteId}/timeseries', async (request) => { | ||
| 3606 | 3686 | if ('response' in result) |
| 3607 | 3687 | return result.response |
| 3608 | 3688 | return json({ series: result.rows }) |
| 3609 | }).middleware('auth') | |
| 3689 | }) | |
| 3610 | 3690 | |
| 3611 | 3691 | route.get('/api/sites/{siteId}/pages', async (request) => { |
| 3612 | 3692 | 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) | |
| 3614 | 3695 | if (denied) |
| 3615 | 3696 | return denied |
| 3616 | 3697 | const { from, to } = window(request) |
| @@ -3629,11 +3710,12 @@ route.get('/api/sites/{siteId}/pages', async (request) => { | ||
| 3629 | 3710 | if ('response' in result) |
| 3630 | 3711 | return result.response |
| 3631 | 3712 | return json({ pages: result.rows }) |
| 3632 | }).middleware('auth') | |
| 3713 | }) | |
| 3633 | 3714 | |
| 3634 | 3715 | route.get('/api/sites/{siteId}/referrers', async (request) => { |
| 3635 | 3716 | 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) | |
| 3637 | 3719 | if (denied) |
| 3638 | 3720 | return denied |
| 3639 | 3721 | const { from, to } = window(request) |
| @@ -3652,7 +3734,7 @@ route.get('/api/sites/{siteId}/referrers', async (request) => { | ||
| 3652 | 3734 | if ('response' in result) |
| 3653 | 3735 | return result.response |
| 3654 | 3736 | return json({ referrers: result.rows }) |
| 3655 | }).middleware('auth') | |
| 3737 | }) | |
| 3656 | 3738 | |
| 3657 | 3739 | // Owner-gated "top <dimension>" reports over page_views, so every dashboard |
| 3658 | 3740 | // breakdown has a documented JSON endpoint too (issue #15). `column` is always a |
| @@ -22,11 +22,23 @@ function routeBlock(path: string): string { | ||
| 22 | 22 | return end === -1 ? rest : rest.slice(0, end) |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | const 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 | */ | |
| 34 | const KEY_READABLE = [ | |
| 26 | 35 | '/api/sites/{siteId}/stats', |
| 27 | 36 | '/api/sites/{siteId}/timeseries', |
| 28 | 37 | '/api/sites/{siteId}/pages', |
| 29 | 38 | '/api/sites/{siteId}/referrers', |
| 39 | ] | |
| 40 | ||
| 41 | const READ_ENDPOINTS = [ | |
| 30 | 42 | '/api/sites/{siteId}/events', |
| 31 | 43 | '/api/sites/{siteId}/entry-pages', |
| 32 | 44 | '/api/sites/{siteId}/exit-pages', |
| @@ -45,6 +57,42 @@ const TOP_DIMENSIONS = [ | ||
| 45 | 57 | '/api/sites/{siteId}/utm/campaigns', |
| 46 | 58 | ] |
| 47 | 59 | |
| 60 | describe('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 | ||
| 48 | 96 | describe('guardrail: site-scoped read endpoints require at least viewer', () => { |
| 49 | 97 | // These were owner-gated until #19, which is why an invited colleague could not |
| 50 | 98 | // 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', () => { | ||
| 160 | 208 | const rest = analytics.slice(i) |
| 161 | 209 | const end = rest.indexOf('\nroute.') |
| 162 | 210 | 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\)/) | |
| 164 | 216 | }) |
| 165 | 217 | } |
| 166 | 218 | }) |
| @@ -0,0 +1,115 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { authorizeRead, LAST_USED_RESOLUTION_MS, looksLikeReadKey, newReadKey, sameKey, shouldRecordUse } from '../../app/Support/read-key' | |
| 3 | ||
| 4 | const KEY = newReadKey() | |
| 5 | const site = { id: 'demo', read_key: KEY } | |
| 6 | ||
| 7 | describe('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 | ||
| 19 | describe('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 | ||
| 42 | describe('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 | ||
| 59 | describe('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 | ||
| 92 | describe('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 | }) | |