Skip to content

Commit e14287a

Browse files
ryan-williamsclaude
andcommitted
GET /log returns each row's client (ua, ipHash, referer), and logs where Cloudflare places it (city/region/asOrg)
`d1AuditQuery().recent()` now also selects `ua`, `ip_hash` and `referer`, which were already recorded. `requestMeta` reads `request.cf.{city, region, asOrganization}` into three new nullable columns (`migrations/0002_access_log_location.sql`, additive). IPs stay HMAC'd and are never stored raw. Spec from disk-tree m3, moved to `specs/done/`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DyJyzSYpRxJuL6x6NyjpG3
1 parent 8feb662 commit e14287a

8 files changed

Lines changed: 124 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Peer deps are all optional and only needed for what you use: `@cloudflare/worker
6161

6262
## Migrations
6363

64-
`migrations/0001_init.sql` is the whole schema. A fresh database applies it (`wrangler d1 migrations apply` with `migrations_dir` pointed at the package's `migrations/`, as the demo does, or its content copied into your own sequence alongside your app's tables).
64+
`migrations/0001_init.sql` is the baseline schema, and `0002_access_log_location.sql` adds the log's location columns. A fresh database applies it (`wrangler d1 migrations apply` with `migrations_dir` pointed at the package's `migrations/`, as the demo does, or its content copied into your own sequence alongside your app's tables).
6565

6666
**Pre-1.0, migrations are squashed**: a schema change lands as a new numbered file, and once every known database has applied it, the directory is collapsed back into one baseline rather than kept as history. To move an existing database across a squash, bring it to the previous head (apply the pre-squash files from the SHA you're on), then mark the new files applied without running them:
6767

@@ -157,7 +157,7 @@ A verified address that policy rejects redirects with `?denied=<email>` rather t
157157

158158
`gate.update(id, patch)` changes a link's terms after the fact — expiry, cap, TTL, memo — so extending a deadline doesn't mean minting and re-sending a second link. (`sessionTtlS` is baked into the cookie at redeem, so it only affects future redemptions.)
159159

160-
**The access log** is one store for auth-lifecycle events and (optionally) views, so "who viewed what" joins to `grants` natively. Lifecycle events always log; `view` events are deduped per (session, path, hour) by a partial unique index, and are **off by default** — turn them on alongside the "access is logged" disclosure copy, not silently. Client IPs are never stored, only `HMAC(ip, secret)`.
160+
**The access log** is one store for auth-lifecycle events and (optionally) views, so "who viewed what" joins to `grants` natively. Lifecycle events always log; `view` events are deduped per (session, path, hour) by a partial unique index, and are **off by default** — turn them on alongside the "access is logged" disclosure copy, not silently. Client IPs are never stored, only `HMAC(ip, secret)`. In their place each row keeps where Cloudflare places the client (`request.cf` city, region, and network operator, e.g. "Comcast"), alongside the country, user agent and referer; `GET <basePath>/log` returns all of them, so an admin can tell whether a redeem came from the person the link was sent to.
161161

162162
**Magic links / passwordless sign-up.** `anyEmailPolicy` auto-approves any address, mints a grant bound to it, and hands it to `notify` — which becomes a real sign-in flow once `notify` can send mail:
163163

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Where a client is, from Cloudflare's `request.cf`, in place of its address
2+
-- (which stays an HMAC in `ip_hash`). Additive: no table rebuild, so a
3+
-- consumer's prod D1 takes it in place.
4+
ALTER TABLE access_log ADD COLUMN city TEXT;
5+
ALTER TABLE access_log ADD COLUMN region TEXT;
6+
ALTER TABLE access_log ADD COLUMN as_org TEXT; -- network operator (`cf.asOrganization`)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,11 @@ Ryan's call: keep IPs hashed, never stored raw, and log the location and network
2727
## Consumer
2828

2929
disk-tree's `GrantLog` already renders `ua` / `ip_hash` when present (hidden columns otherwise); it will add `city`/`region`/`as_org` beside `country`. Once this ships as a dist build, disk-tree bumps `@open-athena/auth` in `site/package.json` and applies the new migration to its D1.
30+
31+
## As built (auth, 2026-10-07)
32+
33+
- `StoredEvent` (from `d1AuditQuery().recent()`, so `GET /log`) gains `ua`, `ipHash`, `referer`, `city`, `region`, `asOrg`, all nullable.
34+
- `migrations/0002_access_log_location.sql`: three `ALTER TABLE access_log ADD COLUMN`s (`city`, `region`, `as_org`). Additive, so a prod D1 takes it in place. It stays a separate file until the next pre-1.0 squash.
35+
- `requestMeta` reads `request.cf.{city, region, asOrganization}` (null off Cloudflare). It already feeds every logged event, so redeem, deny, view, request and sign-in rows all carry them. `access_log_daily` rollups are unchanged and still keyed by country.
36+
- Tests: `routes.test.ts` "returns each row's client…" (a request with a `cf` object lands all three fields; one without leaves them null), and `schema.test.mjs` pins the new columns and file.
37+
- disk-tree: bump `@open-athena/auth` to the dist build of this commit, then `wrangler d1 migrations apply` (or apply `0002` by hand if its D1 copies the package's SQL into its own sequence).

‎src/adapters/d1.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,8 @@ export function d1AuditQuery(db: D1Database): AuditQuery {
431431
}
432432
const { results } = await db
433433
.prepare(
434-
`SELECT l.id, l.ts, l.event, l.grant_id, l.session_sub, l.path, l.reason, l.country
434+
`SELECT l.id, l.ts, l.event, l.grant_id, l.session_sub, l.path, l.reason, l.country,
435+
l.city, l.region, l.as_org, l.ua, l.ip_hash, l.referer
435436
FROM access_log l ${join}
436437
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
437438
ORDER BY l.id DESC LIMIT ?`,
@@ -446,6 +447,12 @@ export function d1AuditQuery(db: D1Database): AuditQuery {
446447
path: string | null
447448
reason: string | null
448449
country: string | null
450+
city: string | null
451+
region: string | null
452+
as_org: string | null
453+
ua: string | null
454+
ip_hash: string | null
455+
referer: string | null
449456
}>()
450457
return results.map(r => ({
451458
id: r.id,
@@ -456,6 +463,12 @@ export function d1AuditQuery(db: D1Database): AuditQuery {
456463
path: r.path,
457464
reason: r.reason,
458465
country: r.country,
466+
city: r.city,
467+
region: r.region,
468+
asOrg: r.as_org,
469+
ua: r.ua,
470+
ipHash: r.ip_hash,
471+
referer: r.referer,
459472
}))
460473
},
461474
}
@@ -481,8 +494,8 @@ export function d1AuditSink(db: D1Database): AuditSink {
481494
const bucket = dedupe ? Math.floor(e.ts / 3600) : null
482495
await db
483496
.prepare(
484-
`INSERT INTO access_log (ts, event, grant_id, session_sub, path, status, ip_hash, ua, country, referer, reason, bucket)
485-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
497+
`INSERT INTO access_log (ts, event, grant_id, session_sub, path, status, ip_hash, ua, country, city, region, as_org, referer, reason, bucket)
498+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
486499
ON CONFLICT DO NOTHING`,
487500
)
488501
.bind(
@@ -495,6 +508,9 @@ export function d1AuditSink(db: D1Database): AuditSink {
495508
e.ipHash ?? null,
496509
e.ua ?? null,
497510
e.country ?? null,
511+
e.city ?? null,
512+
e.region ?? null,
513+
e.asOrg ?? null,
498514
e.referer ?? null,
499515
e.reason ?? null,
500516
bucket,

‎src/core/audit.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export interface AccessEvent {
3434
ipHash?: string | null
3535
ua?: string | null
3636
country?: string | null
37+
/** Where and on what network the client is, from Cloudflare's `request.cf`: enough to tell "the person I sent it to" from "someone else", without keeping an address. */
38+
city?: string | null
39+
region?: string | null
40+
/** The client's network operator (`cf.asOrganization`), e.g. "Comcast" or "Amazon.com". */
41+
asOrg?: string | null
3742
referer?: string | null
3843
/**
3944
* Event detail. On `deny`: `expired`, `revoked`, `disabled`, `exhausted`,
@@ -56,21 +61,37 @@ export interface RequestMeta {
5661
ipHash: string | null
5762
ua: string | null
5863
country: string | null
64+
city: string | null
65+
region: string | null
66+
asOrg: string | null
5967
referer: string | null
6068
}
6169

70+
/** The slice of Cloudflare's `request.cf` that gets logged. */
71+
interface CfLocation {
72+
city?: string
73+
region?: string
74+
asOrganization?: string
75+
}
76+
6277
/**
63-
* Pull the loggable request metadata. Header-based (not `request.cf`) so this
64-
* stays runtime-agnostic; CF populates `CF-Connecting-IP`/`CF-IPCountry` for free.
78+
* Pull the loggable request metadata. The address and country come from
79+
* headers, which CF populates (`CF-Connecting-IP`/`CF-IPCountry`) and other
80+
* runtimes can set. City, region and network come from `request.cf` and are
81+
* null off Cloudflare.
6582
*/
6683
export async function requestMeta(req: Request, ipSecret: string): Promise<RequestMeta> {
6784
const h = req.headers
85+
const cf = (req as Request & { cf?: CfLocation }).cf
6886
const ip = h.get('CF-Connecting-IP') ?? h.get('X-Forwarded-For')?.split(',')[0]?.trim() ?? null
6987
return {
7088
path: new URL(req.url).pathname,
7189
ipHash: ip ? await hashIp(ip, ipSecret) : null,
7290
ua: h.get('User-Agent'),
7391
country: h.get('CF-IPCountry'),
92+
city: cf?.city || null,
93+
region: cf?.region || null,
94+
asOrg: cf?.asOrganization || null,
7495
referer: h.get('Referer'),
7596
}
7697
}

‎src/core/store.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ export interface StoredEvent {
202202
path: string | null
203203
reason: string | null
204204
country: string | null
205+
city: string | null
206+
region: string | null
207+
asOrg: string | null
208+
ua: string | null
209+
/** The keyed hash of the client's address: equal across one client's rows, unreadable as an address. */
210+
ipHash: string | null
211+
referer: string | null
205212
}
206213

207214
/** Convenience: what a store needs from `mintGrant` before it has an id/timestamps. */

‎test/routes.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createGate } from '../src/core/gate.js'
44
import { adminPolicy, anyEmailPolicy, firstMatch } from '../src/core/policy.js'
55
import { authRoutes } from '../src/core/routes.js'
66
import type { Auth } from '../src/core/types.js'
7+
import { hashIp } from '../src/core/tokens.js'
78
import { bytesToDataUri, gravatarUrl } from '../src/core/avatar.js'
89
import { type MemoryAssetStore, memoryAssetStore } from '../src/testing/index.js'
910
import { testDb } from './d1-shim.js'
@@ -515,6 +516,58 @@ describe('access log routes', () => {
515516
expect(res!.status).toBe(501)
516517
})
517518

519+
it("returns each row's client: browser, hashed address, and where Cloudflare places it", async () => {
520+
const cookie = await asAdmin()
521+
const minted = await post('/grants', { name: 'Bob', scopes: ['reports'] }, cookie)
522+
const id: string = minted.body.grant.id
523+
const exchange = (headers: Record<string, string>, cf?: object) => {
524+
const req = new Request(url('/exchange'), {
525+
method: 'POST',
526+
headers: { 'content-type': 'application/json', ...headers },
527+
body: JSON.stringify({ token: minted.body.token }),
528+
})
529+
return handle(cf ? Object.assign(req, { cf }) : req)
530+
}
531+
await exchange(
532+
{
533+
'CF-Connecting-IP': '203.0.113.7',
534+
'CF-IPCountry': 'US',
535+
'User-Agent': 'Mozilla/5.0 (Macintosh) Chrome/141',
536+
Referer: 'https://mail.example.com/',
537+
},
538+
{ city: 'Brooklyn', region: 'New York', asOrganization: 'Comcast', colo: 'EWR' },
539+
)
540+
// Off Cloudflare (or a client it can't place): no `cf`, so no location.
541+
await exchange({ 'User-Agent': 'curl/8.7' })
542+
543+
const log = await call(`/log?grant=${id}`, {}, cookie)
544+
const client = ({ event, country, city, region, asOrg, ua, ipHash, referer }: Record<string, unknown>) => ({
545+
event,
546+
country,
547+
city,
548+
region,
549+
asOrg,
550+
ua,
551+
ipHash,
552+
referer,
553+
})
554+
expect(log.body.events.map(client)).toEqual([
555+
{ event: 'redeem', country: null, city: null, region: null, asOrg: null, ua: 'curl/8.7', ipHash: null, referer: null },
556+
{
557+
event: 'redeem',
558+
country: 'US',
559+
city: 'Brooklyn',
560+
region: 'New York',
561+
asOrg: 'Comcast',
562+
ua: 'Mozilla/5.0 (Macintosh) Chrome/141',
563+
ipHash: await hashIp('203.0.113.7', SECRET),
564+
referer: 'https://mail.example.com/',
565+
},
566+
// The admin's mint went through `call`, which sends none of these.
567+
{ event: 'mint', country: null, city: null, region: null, asOrg: null, ua: null, ipHash: null, referer: null },
568+
])
569+
})
570+
518571
it('returns a link’s trail and its activity summary', async () => {
519572
const cookie = await asAdmin()
520573
const minted = await post('/grants', { name: 'Bob', scopes: ['reports'] }, cookie)

‎test/schema.test.mjs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ const tables = () => {
2121
}
2222

2323
describe('migrations/', () => {
24-
it('is one baseline file', () => {
25-
expect(migrationFiles(dir)).toEqual(['0001_init.sql'])
24+
// Pre-1.0: one baseline, plus any additions not yet squashed into it.
25+
it('is the baseline plus unsquashed additions', () => {
26+
expect(migrationFiles(dir)).toEqual(['0001_init.sql', '0002_access_log_location.sql'])
2627
})
2728

2829
it('builds exactly these tables and columns', () => {
@@ -62,6 +63,9 @@ describe('migrations/', () => {
6263
'referer TEXT',
6364
'reason TEXT',
6465
'bucket INTEGER',
66+
'city TEXT',
67+
'region TEXT',
68+
'as_org TEXT',
6569
],
6670
access_log_daily: [
6771
'day INTEGER NOT NULL PK#1',

0 commit comments

Comments
 (0)