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
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.
| @@ -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; | |
| @@ -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,47 @@ 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 | 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. */ | |
| 845 | async 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 | ||
| 812 | 854 | async function requireSiteRole(request: HandlerRequest, siteId: string, required: SiteRole): Promise<Response | null> { |
| 813 | 855 | const uid = authUserId(request) |
| 814 | 856 | if (!uid) |
| @@ -1011,6 +1053,40 @@ route.post('/api/sites/{siteId}/members', async (request) => { | ||
| 1011 | 1053 | return json({ members: await listSiteMembers(siteId) }) |
| 1012 | 1054 | }).middleware('auth').skipCsrf() |
| 1013 | 1055 | |
| 1056 | route.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. | |
| 1065 | route.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. | |
| 1080 | route.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 | ||
| 1014 | 1090 | route.options('/api/sites/{siteId}/members/{userId}', () => new Response(null, { status: 204, headers: CORS })) |
| 1015 | 1091 | |
| 1016 | 1092 | route.delete('/api/sites/{siteId}/members/{userId}', async (request) => { |
| @@ -3559,7 +3635,8 @@ route.delete('/api/sites/{siteId}/visitors/{visitorId}', async (request) => { | ||
| 3559 | 3635 | |
| 3560 | 3636 | route.get('/api/sites/{siteId}/stats', async (request) => { |
| 3561 | 3637 | 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) | |
| 3563 | 3640 | if (denied) |
| 3564 | 3641 | return denied |
| 3565 | 3642 | const { from, to } = window(request) |
| @@ -3583,11 +3660,12 @@ route.get('/api/sites/{siteId}/stats', async (request) => { | ||
| 3583 | 3660 | sessions: Number(row?.sessions ?? 0), |
| 3584 | 3661 | range: { from, to }, |
| 3585 | 3662 | }) |
| 3586 | }).middleware('auth') | |
| 3663 | }) | |
| 3587 | 3664 | |
| 3588 | 3665 | route.get('/api/sites/{siteId}/timeseries', async (request) => { |
| 3589 | 3666 | 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) | |
| 3591 | 3669 | if (denied) |
| 3592 | 3670 | return denied |
| 3593 | 3671 | const { from, to } = window(request) |
| @@ -3606,11 +3684,12 @@ route.get('/api/sites/{siteId}/timeseries', async (request) => { | ||
| 3606 | 3684 | if ('response' in result) |
| 3607 | 3685 | return result.response |
| 3608 | 3686 | return json({ series: result.rows }) |
| 3609 | }).middleware('auth') | |
| 3687 | }) | |
| 3610 | 3688 | |
| 3611 | 3689 | route.get('/api/sites/{siteId}/pages', async (request) => { |
| 3612 | 3690 | 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) | |
| 3614 | 3693 | if (denied) |
| 3615 | 3694 | return denied |
| 3616 | 3695 | const { from, to } = window(request) |
| @@ -3629,11 +3708,12 @@ route.get('/api/sites/{siteId}/pages', async (request) => { | ||
| 3629 | 3708 | if ('response' in result) |
| 3630 | 3709 | return result.response |
| 3631 | 3710 | return json({ pages: result.rows }) |
| 3632 | }).middleware('auth') | |
| 3711 | }) | |
| 3633 | 3712 | |
| 3634 | 3713 | route.get('/api/sites/{siteId}/referrers', async (request) => { |
| 3635 | 3714 | 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) | |
| 3637 | 3717 | if (denied) |
| 3638 | 3718 | return denied |
| 3639 | 3719 | const { from, to } = window(request) |
| @@ -3652,7 +3732,7 @@ route.get('/api/sites/{siteId}/referrers', async (request) => { | ||
| 3652 | 3732 | if ('response' in result) |
| 3653 | 3733 | return result.response |
| 3654 | 3734 | return json({ referrers: result.rows }) |
| 3655 | }).middleware('auth') | |
| 3735 | }) | |
| 3656 | 3736 | |
| 3657 | 3737 | // Owner-gated "top <dimension>" reports over page_views, so every dashboard |
| 3658 | 3738 | // 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,110 @@ | ||
| 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 | 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 | ||
| 87 | describe('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 | }) | |