From cac27fef6b5dd0ba78d9d89810046d55ed66d5bb Mon Sep 17 00:00:00 2001 From: Cem Hurturk Date: Fri, 25 Sep 2026 10:03:30 +0000 Subject: [PATCH 01/56] admin: a day offset, a breakdown by link, and a count before an export --- README.md | 46 +++++++-- packages/admin/src/clicks.test.ts | 111 ++++++++++++++++++++ packages/admin/src/clicks.ts | 56 ++++++++++- packages/admin/src/reports.test.ts | 150 ++++++++++++++++++++++++++++ packages/admin/src/reports.ts | 94 +++++++++++++---- packages/core/src/reporting.test.ts | 26 ++++- packages/core/src/reporting.ts | 43 ++++++-- 7 files changed, 485 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 942f3ea..de14d14 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ on your own infrastructure, and your click data stays yours. - **Country rules** use the country looked up from the visitor's address. - **Reports over the admin API.** Clicks come back out: a summary of a window, a chart by hour or by day, and a breakdown by country, device, operating system, browser, referring - host, the target rotation chose, the traffic class, the action taken or the outcome. They + host, the target rotation chose, the traffic class, the action taken, the outcome, or the + link. They read hourly rollups written as the clicks arrive rather than the clicks themselves, so a window of a year is read as hours and not as every click inside it, and every number counts a click once even if the worker shipped its spool segment twice. **A report counts @@ -108,8 +109,9 @@ What does not work yet: - **A CSV of a report.** The export is the click log. A summary or a breakdown is already one small answer, and turning it into a file is a job for the web interface rather than something this API streams. -- **A time zone.** Every window, bucket and retention period is UTC. A preset like - "yesterday" is for whoever is asking to work out. +- **A time zone.** Every window and retention period is UTC, and so is a chart's bucket + size — `offset` only moves where a day bucket begins, in whole hours, and does nothing + for an hourly one. A preset like "yesterday" is for whoever is asking to work out. - **A breakdown by two things at once.** One dimension per request: clicks by country, or clicks by browser, never clicks by country by browser. - **A report of a window older than this install's rollups.** They are written as the clicks @@ -120,8 +122,8 @@ What does not work yet: The redirect keeps answering and keeps spooling throughout, so nothing is lost — it has simply not arrived yet. - **Reports answer nothing while ClickHouse is down.** `GET /api/reports/…`, - `GET /api/clicks` and `GET /api/clicks.csv` answer 503; domains, links and settings keep - working. + `GET /api/clicks`, `GET /api/clicks.csv` and `GET /api/clicks/count` answer 503; domains, + links and settings keep working. - **An export that fails part way through ends the connection** rather than finishing the file, because a file that looks complete and is not is worse than a download that broke. - **Any notification.** Nothing is emailed, posted or pushed anywhere: there is no mail @@ -437,11 +439,19 @@ needed. Windows are UTC, `from` is included and `to` is not, and a window may be curl -H "authorization: Bearer $KEY" \ "https://admin.example.com/api/reports/summary?from=2026-09-23T00:00:00Z&to=2026-09-24T00:00:00Z" -# A chart: bucket=hour or bucket=day, at most 2000 buckets in one answer +# A chart: bucket=hour or bucket=day, at most 2000 buckets in one answer. +# offset moves where a day begins: a whole number of hours from UTC midnight, +# -12 to 14, default 0. offset=3 counts days from 21:00 UTC — midnight at +# UTC+3. It changes nothing on an hourly chart, since a whole-hour offset +# moves no hour boundary, and a half-hour zone is counted from the nearest +# whole hour. curl -H "authorization: Bearer $KEY" \ - "https://admin.example.com/api/reports/timeseries?from=2026-09-01T00:00:00Z&to=2026-09-24T00:00:00Z&bucket=day" + "https://admin.example.com/api/reports/timeseries?from=2026-09-01T00:00:00Z&to=2026-09-24T00:00:00Z&bucket=day&offset=3" -# One dimension: country, device, os, browser, referrer, target, class, action, outcome +# One dimension: country, device, os, browser, referrer, target, class, action, +# outcome, link. A breakdown by link carries a link object — slug, host and +# name — on each row, read from the link table rather than the rollup; null +# when the id names no link any more. curl -H "authorization: Bearer $KEY" \ "https://admin.example.com/api/reports/breakdown?from=2026-09-23T00:00:00Z&to=2026-09-24T00:00:00Z&dimension=country&limit=20" @@ -449,6 +459,11 @@ curl -H "authorization: Bearer $KEY" \ curl -H "authorization: Bearer $KEY" \ "https://admin.example.com/api/clicks?from=2026-09-23T00:00:00Z&to=2026-09-24T00:00:00Z&class=bot&limit=100" +# How many rows an export of the same filters would write, before asking for +# the file: {"window":{...},"link":null,"count":1234,"cap":1000000,"truncated":false} +curl -H "authorization: Bearer $KEY" \ + "https://admin.example.com/api/clicks/count?from=2026-09-01T00:00:00Z&to=2026-09-24T00:00:00Z" + # And as a file curl -H "authorization: Bearer $KEY" -OJ \ "https://admin.example.com/api/clicks.csv?from=2026-09-01T00:00:00Z&to=2026-09-24T00:00:00Z" @@ -456,18 +471,19 @@ curl -H "authorization: Bearer $KEY" -OJ \ Add `&link=` to any of them for one link instead of all of them. **Nothing looks the link up**, so an id that belongs to no link is an empty answer rather than an error, on all -five of these. +six of these. **A report counts whole buckets; the log counts milliseconds.** Before a report is counted, `from` is floored and `to` raised to the next boundary, and **the boundary is the grain that report answers at**: the hour for the summary and the breakdown, because that is the grain the rollups hold, and the bucket you asked for on the chart — so `bucket=day` counts whole -days. The log and the export use the two instants exactly as they were sent. So the same +days, from where `offset` puts midnight. The log, the export and the count use the two +instants exactly as they were sent. So the same window can answer differently through these surfaces and none of them is broken: ask `from=2026-09-24T10:30:00Z&to=2026-09-24T10:50:00Z` and the summary, the hourly chart and the breakdown all count 10:00 to 11:00, a chart by day counts the whole of the 24th, and the log counts the twenty minutes you named — three different numbers from one request. Every one of -these five answers repeats the window it actually counted, in its own `window` field. Read +these six answers repeats the window it actually counted, in its own `window` field. Read that before comparing two numbers. **What the numbers mean.** `clicks` counts distinct clicks and `visitors` distinct visitor @@ -516,6 +532,14 @@ appended, since the clients an operator actually uses do not show trailers. Narr window and ask again; nothing is lost. The cap can be built as high as 10,000,000, and there is no environment variable for it yet. +**`GET /api/clicks/count`** answers the same question ahead of the download: it takes the +log's own filters and runs the same bounded probe, so `{"count":1234,"cap":1000000, +"truncated":false}` tells you how many rows the export would write and whether it would +stop at the cap, before you ask for the file. It is a query rather than a body, so it takes +a report slot and not the export's — asking it does not compete with a download already +running. The window can gain clicks between this answer and the export; the export's own +header stays the authority on the file it actually wrote. + **A cell that would otherwise look like a formula is prefixed with an apostrophe.** A spreadsheet runs a cell beginning `=`, `+`, `-` or `@`, or with whitespace in front of one, whether the cell is quoted or not. The two columns a visitor's own browser chooses the diff --git a/packages/admin/src/clicks.test.ts b/packages/admin/src/clicks.test.ts index 88ad27d..f92f9a1 100644 --- a/packages/admin/src/clicks.test.ts +++ b/packages/admin/src/clicks.test.ts @@ -904,6 +904,117 @@ describe('GET /api/clicks.csv', () => { }) }) +describe('GET /api/clicks/count', () => { + // The window holds three distinct clicks — the first one twice, which FINAL + // makes one — and the export's cap is a million. + it('says how many clicks an export of the window would write', async () => { + const r = await app.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}`, + headers: read(cookie), + }) + expect(r.statusCode).toBe(200) + expect(r.json()).toEqual({ + window: { from: '2026-09-24T00:00:00.000Z', to: '2026-09-25T00:00:00.000Z' }, + link: null, + count: 3, + cap: 1_000_000, + truncated: false, + }) + }) + + it('applies the log’s filters', async () => { + const r = await app.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}&class=bot`, + headers: read(cookie), + }) + expect(r.json().count).toBe(1) + }) + + it('says the export would stop at the cap, and counts no further than it', async () => { + const capped = testApp(pool, clock, { ch, exportRowCap: 2 }) + try { + const r = await capped.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}`, + headers: read(cookie), + }) + expect(r.json()).toEqual({ + window: { from: '2026-09-24T00:00:00.000Z', to: '2026-09-25T00:00:00.000Z' }, + link: null, + count: 2, + cap: 2, + truncated: true, + }) + } finally { + await capped.close() + } + }) + + it('does not claim the cap when the window holds exactly the cap', async () => { + const capped = testApp(pool, clock, { ch, exportRowCap: 3 }) + try { + const r = await capped.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}`, + headers: read(cookie), + }) + expect(r.json().count).toBe(3) + expect(r.json().truncated).toBe(false) + } finally { + await capped.close() + } + }) + + it.each([ + ['a page size', 'limit=10'], + ['a cursor', 'cursor=1.00000000-0000-4000-8000-000000000001'], + ['a field nobody knows', 'sort=time'], + ])('refuses %s, which mean nothing to a count', async (_label, extra) => { + const r = await app.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}&${extra}`, + headers: read(cookie), + }) + expect(r.statusCode).toBe(400) + expect(r.json().error).toBe('invalid_query') + }) + + it('needs a credential', async () => { + const r = await app.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}`, + headers: { host: ADMIN_HOST }, + }) + expect(r.statusCode).toBe(401) + }) + + it('takes a report slot, not the export’s', async () => { + const noExports = testApp(pool, clock, { ch, exportGate: new ConcurrencyGate(0) }) + const noReports = testApp(pool, clock, { ch, reportGate: new ConcurrencyGate(0) }) + try { + const a = await noExports.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}`, + headers: read(cookie), + }) + expect(a.statusCode).toBe(200) + const b = await noReports.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}`, + headers: read(cookie), + }) + expect(b.statusCode).toBe(429) + expect(b.json().error).toBe('too_many_reports') + expect(b.headers['retry-after']).toBe('1') + } finally { + await noExports.close() + await noReports.close() + } + }) +}) + /** * A gate that counts the slots it handed out and the times they were given back. * diff --git a/packages/admin/src/clicks.ts b/packages/admin/src/clicks.ts index ea72dbf..7c19e00 100644 --- a/packages/admin/src/clicks.ts +++ b/packages/admin/src/clicks.ts @@ -382,6 +382,17 @@ export const CSV_FIELDS = [ ] as const const ExportQuery = z.object({ ...CLICK_FILTER_FIELDS }).strict() +const CountQuery = z.object({ ...CLICK_FILTER_FIELDS }).strict() + +/** + * The bounded probe both the count and the export run: at most one row past + * the cap, so a window of a billion clicks costs what a window of a million + * and one does. Written once, so the rule about how many rows are counted + * cannot come to be stated differently in the two places that ask it. + */ +function probeQuery(where: string, cap: number): string { + return `SELECT count() AS n FROM (SELECT 1 FROM clicks FINAL WHERE ${where} LIMIT ${cap + 1})` +} /** * `20260924T000000Z`: the instant as digits, with the colons a filename cannot @@ -441,6 +452,49 @@ export function registerClickRoutes(app: FastifyInstance, ctx: AdminContext): vo ) }) + /** + * How many clicks an export of these filters would write, and whether it + * would stop at the cap — asked before a download starts, so that "the file + * stops at a million" is something the operator reads before the file + * rather than a header on a file they have already saved. + * + * The same bounded probe the export runs: it counts at most one row past the + * cap, so a window of a billion clicks costs what a window of a million and + * one does. Under the report gate rather than the export's: it is a query, + * not a body, and taking the only export slot to answer a count would refuse + * the export it is asked ahead of. The window can gain clicks between this + * answer and the download; the export's own header is still the authority on + * the file it wrote. + */ + app.get('/api/clicks/count', async (req) => { + requireCredential(req) + const q = readQuery(CountQuery, req.query) + const w = parseWindow(q, { alignMs: null }) + const ch = requireCh(ctx, req) + const cap = ctx.exportRowCap + return withSlot( + ctx.reportGate, + { code: 'too_many_reports', message: 'too many reports at once; try again' }, + async () => { + const { where, params } = clickFilter(q, w, null) + const [probe] = await chRows<{ n: string }>({ + ch, + req, + query: probeQuery(where, cap), + params, + }) + const n = Number(probe?.n ?? 0) + return { + window: { from: new Date(w.fromMs).toISOString(), to: new Date(w.toMs).toISOString() }, + link: w.linkId, + count: Math.min(n, cap), + cap, + truncated: n > cap, + } + }, + ) + }) + /** * The log as a file. * @@ -541,7 +595,7 @@ export function registerClickRoutes(app: FastifyInstance, ctx: AdminContext): vo const [probe] = await chRows<{ n: string }>({ ch, req, - query: `SELECT count() AS n FROM (SELECT 1 FROM clicks FINAL WHERE ${where} LIMIT ${cap + 1})`, + query: probeQuery(where, cap), params, }) const truncated = Number(probe?.n ?? 0) > cap diff --git a/packages/admin/src/reports.test.ts b/packages/admin/src/reports.test.ts index 1bd6f04..9196056 100644 --- a/packages/admin/src/reports.test.ts +++ b/packages/admin/src/reports.test.ts @@ -130,6 +130,20 @@ beforeAll(async () => { // must change none of them — the invariant the whole rollup design rests on, // and one no fixture of distinct ids can see. await insert([click()]) + // Link rows for the breakdown by link, which labels each id it counts. The + // ids are the ones the clicks above carry; a third link exists and has no + // clicks, and must not appear. No row is written for the zero id. + await pool.query( + `INSERT INTO domains (id, host, verified) VALUES ($1, 'go.example.test', true)`, + [DOMAIN], + ) + await pool.query( + `INSERT INTO links (id, domain_id, slug, name) VALUES + ($1, $3, 'spring', 'Spring offer'), + ($2, $3, 'autumn', NULL), + ('00000000-0000-4000-8000-0000000000a9', $3, 'unused', NULL)`, + [LINK_A, LINK_B, DOMAIN], + ) }) beforeEach(async () => { @@ -413,6 +427,83 @@ describe('GET /api/reports/timeseries', () => { }) }) + // Days from noon UTC, which is midnight at UTC-12. The four clicks at ten + // and the one at half past eleven on the 24th are in the day that began at + // noon on the 23rd; the one at half past midnight on the 25th is in the day + // that began at noon on the 24th. At offset zero the same request is three + // UTC days, which is what the second assertion is for. + it('counts days from where the offset puts midnight', async () => { + const r = await app.inject({ + method: 'GET', + url: '/api/reports/timeseries?from=2026-09-23T12:00:00.000Z&to=2026-09-25T12:00:00.000Z&bucket=day&offset=-12', + headers: read(cookie), + }) + expect(r.statusCode).toBe(200) + expect(r.json()).toEqual({ + window: { from: '2026-09-23T12:00:00.000Z', to: '2026-09-25T12:00:00.000Z' }, + link: null, + bucket: 'day', + buckets: [ + { at: '2026-09-23T12:00:00.000Z', clicks: 5, visitors: 2 }, + { at: '2026-09-24T12:00:00.000Z', clicks: 1, visitors: 1 }, + ], + newestHour: '2026-09-25T00:00:00.000Z', + }) + const utc = await app.inject({ + method: 'GET', + url: '/api/reports/timeseries?from=2026-09-23T12:00:00.000Z&to=2026-09-25T12:00:00.000Z&bucket=day', + headers: read(cookie), + }) + expect(utc.json().buckets).toHaveLength(3) + }) + + it('aligns a ragged window to the offset’s days, and says so', async () => { + const r = await app.inject({ + method: 'GET', + url: '/api/reports/timeseries?from=2026-09-24T10:30:00.000Z&to=2026-09-24T11:30:00.000Z&bucket=day&offset=3', + headers: read(cookie), + }) + expect(r.json().window).toEqual({ + from: '2026-09-23T21:00:00.000Z', + to: '2026-09-24T21:00:00.000Z', + }) + expect(r.json().buckets).toEqual([{ at: '2026-09-23T21:00:00.000Z', clicks: 5, visitors: 2 }]) + }) + + it('leaves an hour chart as it was, whatever the offset', async () => { + const q = 'from=2026-09-24T10:00:00.000Z&to=2026-09-24T12:00:00.000Z&bucket=hour' + const plain = await app.inject({ + method: 'GET', + url: `/api/reports/timeseries?${q}`, + headers: read(cookie), + }) + const moved = await app.inject({ + method: 'GET', + url: `/api/reports/timeseries?${q}&offset=5`, + headers: read(cookie), + }) + expect(moved.json()).toEqual(plain.json()) + expect(plain.json().buckets).toEqual([ + { at: '2026-09-24T10:00:00.000Z', clicks: 4, visitors: 2 }, + { at: '2026-09-24T11:00:00.000Z', clicks: 1, visitors: 1 }, + ]) + }) + + it.each([ + ['an offset west of every zone', 'offset=-13'], + ['an offset east of every zone', 'offset=15'], + ['half an hour', 'offset=5.5'], + ['something that is not a number', 'offset=east'], + ])('refuses %s', async (_label, extra) => { + const r = await app.inject({ + method: 'GET', + url: `/api/reports/timeseries?${WINDOW}&bucket=day&${extra}`, + headers: read(cookie), + }) + expect(r.statusCode).toBe(400) + expect(r.json().error).toBe('invalid_query') + }) + it('refuses more buckets than one response carries, and says to ask for days', async () => { const r = await app.inject({ method: 'GET', @@ -598,6 +689,53 @@ describe('GET /api/reports/breakdown', () => { }) }) + // Link A carries the first click, the second visitor's, the blocked bot's + // and the one at half past eleven; link B the same visitor's click on it. The + // unused link has no clicks and is not a row. + it('breaks a window down by link, and names each link', async () => { + const r = await breakdown('dimension=link') + expect(r.statusCode).toBe(200) + expect(r.json()).toEqual({ + window: COUNTED, + link: null, + dimension: 'link', + truncated: false, + rows: [ + { + value: LINK_A, + clicks: 4, + visitors: 2, + link: { slug: 'spring', host: 'go.example.test', name: 'Spring offer' }, + }, + { + value: LINK_B, + clicks: 1, + visitors: 1, + link: { slug: 'autumn', host: 'go.example.test', name: null }, + }, + ], + }) + }) + + it('says a link it counted no longer exists, rather than dropping the row', async () => { + await pool.query('DELETE FROM links WHERE id = $1', [LINK_B]) + try { + const r = await breakdown('dimension=link') + expect(r.json().rows[1]).toEqual({ value: LINK_B, clicks: 1, visitors: 1, link: null }) + } finally { + await pool.query( + `INSERT INTO links (id, domain_id, slug, name) VALUES ($1, $2, 'autumn', NULL)`, + [LINK_B, DOMAIN], + ) + } + }) + + it('carries no link field on any other dimension', async () => { + const r = await breakdown('dimension=country') + for (const row of r.json().rows) + expect(Object.keys(row).sort()).toEqual(['clicks', 'value', 'visitors']) + }) + it.each<[string, { value: string; clicks: number; visitors: number }[]]>([ [ 'action', @@ -994,6 +1132,18 @@ describe('parseWindow', () => { expect((thrown as HttpError).status).toBe(400) expect((thrown as HttpError).code).toBe('invalid_query') }) + + it('aligns the start to the offset’s day even when UTC’s day has turned', () => { + // 22:30 UTC on the 24th is 01:30 on the 25th at UTC+3, whose day began at + // 21:00 UTC on the 24th. Floored without the offset it would be 21:00 UTC + // on the 23rd. + const w = parseWindow( + { from: '2026-09-24T22:30:00.000Z', to: '2026-09-24T23:00:00.000Z' }, + { alignMs: 86_400_000, offsetMs: 3 * 3_600_000 }, + ) + expect(new Date(w.fromMs).toISOString()).toBe('2026-09-24T21:00:00.000Z') + expect(new Date(w.toMs).toISOString()).toBe('2026-09-25T21:00:00.000Z') + }) }) describe('the newest hour the install holds', () => { diff --git a/packages/admin/src/reports.ts b/packages/admin/src/reports.ts index 92e2c7b..42ed7d1 100644 --- a/packages/admin/src/reports.ts +++ b/packages/admin/src/reports.ts @@ -26,16 +26,18 @@ import { BUCKET_MS, type ConcurrencyGate, type KeyedDimension, + MAX_DAY_OFFSET_HOURS, MAX_REPORT_BUCKETS, MAX_REPORT_WINDOW_DAYS, MAX_REPORT_WINDOW_MS, + MIN_DAY_OFFSET_HOURS, REPORT_BUCKETS, REPORT_DIMENSIONS, type ReportBucket, bucketCount, isKeyedDimension, } from '@clickmonk/core' -import type { ClickHouseClient } from '@clickmonk/db' +import type { ClickHouseClient, Pool } from '@clickmonk/db' import type { FastifyInstance, FastifyRequest } from 'fastify' import { z } from 'zod' import type { AdminContext } from './app.js' @@ -122,16 +124,23 @@ const chTime = (ms: number): string => new Date(ms).toISOString().replace('T', ' * is 9,600 of them against a ceiling of 2,000, and the ceiling bites first * from about 84 days on. Whatever asks for buckets checks its own count; what * this function owes it is a window made of numbers. + * + * `offsetMs` moves the grid a day is aligned to; the chart passes it and + * nothing else does. */ export function parseWindow( q: { from: string; to: string; link?: string }, - o: { alignMs: number | null }, + o: { alignMs: number | null; offsetMs?: number }, ): ReportWindow { const rawFrom = Date.parse(q.from) const rawTo = Date.parse(q.to) const align = o.alignMs - const fromMs = align === null ? rawFrom : Math.floor(rawFrom / align) * align - const toMs = align === null ? rawTo : Math.ceil(rawTo / align) * align + // The offset moves the grid the window is aligned to: a day at UTC+3 begins + // at 21:00 UTC. Added before the floor and taken off after it, so the result + // is still an instant in UTC. + const off = o.offsetMs ?? 0 + const fromMs = align === null ? rawFrom : Math.floor((rawFrom + off) / align) * align - off + const toMs = align === null ? rawTo : Math.ceil((rawTo + off) / align) * align - off // `!(toMs > fromMs)` and not `toMs <= fromMs`: every comparison with NaN is // false, so the second form lets an unparseable date through, and NaN then // survives the alignment, the length check and the bucket ceiling — each of @@ -293,14 +302,25 @@ const SummaryQuery = z.object({ ...WINDOW_FIELDS }).strict() /** * How each bucket size is grouped. A module constant per bucket rather than * anything built from the request: the value that reaches the query text is - * one of exactly two strings written here. + * one of exactly two strings written here, and the offset is a bound + * parameter inside the second. + * + * The day's expression moves each hour by the offset, takes the start of that + * day, and moves it back, so a day at UTC+3 is grouped from 21:00 UTC. At an + * offset of zero it is `toStartOfDay(hour)`. */ const BUCKET_EXPR: Record = { hour: 'hour', - day: 'toStartOfDay(hour)', + day: 'addHours(toStartOfDay(addHours(hour, {offset:Int8})), negate({offset:Int8}))', } -const TimeseriesQuery = z.object({ ...WINDOW_FIELDS, bucket: z.enum(REPORT_BUCKETS) }).strict() +const TimeseriesQuery = z + .object({ + ...WINDOW_FIELDS, + bucket: z.enum(REPORT_BUCKETS), + offset: z.coerce.number().int().min(MIN_DAY_OFFSET_HOURS).max(MAX_DAY_OFFSET_HOURS).default(0), + }) + .strict() /** Values in one breakdown, and the ceiling a caller may ask for. */ export const DEFAULT_BREAKDOWN_ROWS = 100 @@ -316,6 +336,7 @@ const KEYED_COLUMN: Record = { class: 'traffic_class', action: 'action', outcome: 'outcome', + link: 'link_id', } const BreakdownQuery = z @@ -326,6 +347,27 @@ const BreakdownQuery = z }) .strict() +/** + * The slug, host and name of each link id given, for the rows a breakdown by + * link shows. Ids with no row are absent from the map. One query, bound as an + * array, over at most the 500 ids a breakdown returns. + */ +async function linkLabels( + pg: Pool, + ids: string[], +): Promise> { + const out = new Map() + if (ids.length === 0) return out + const r = await pg.query<{ id: string; slug: string; host: string; name: string | null }>( + `SELECT l.id, l.slug, d.host, l.name + FROM links l JOIN domains d ON d.id = l.domain_id + WHERE l.id = ANY($1::uuid[])`, + [ids], + ) + for (const row of r.rows) out.set(row.id, { slug: row.slug, host: row.host, name: row.name }) + return out +} + export function registerReportRoutes(app: FastifyInstance, ctx: AdminContext): void { /** * The numbers at the top of a dashboard: how many clicks, how many people, @@ -438,8 +480,9 @@ export function registerReportRoutes(app: FastifyInstance, ctx: AdminContext): v requireCredential(req) const q = readQuery(TimeseriesQuery, req.query) const step = BUCKET_MS[q.bucket] - const w = parseWindow(q, { alignMs: step }) - const buckets = bucketCount(w.fromMs, w.toMs, q.bucket) + const offsetMs = q.offset * HOUR_MS + const w = parseWindow(q, { alignMs: step, offsetMs }) + const buckets = bucketCount(w.fromMs, w.toMs, q.bucket, offsetMs) if (buckets > MAX_REPORT_BUCKETS) { // `return fail(…)` rather than a bare call, for the reason `requireCh` // spells out: `fail` is a const arrow, so TypeScript does not treat a @@ -466,7 +509,7 @@ export function registerReportRoutes(app: FastifyInstance, ctx: AdminContext): v uniqExactMerge(visitors_state) AS visitors FROM clicks_hourly WHERE ${windowClause(w)} GROUP BY at ORDER BY at`, - params: windowParams(w), + params: { ...windowParams(w), ...(q.bucket === 'day' ? { offset: q.offset } : {}) }, }) // The same freshness the summary answers with, and a chart needs it // more than a number does. Every bucket the rollup has no row for is @@ -513,12 +556,15 @@ export function registerReportRoutes(app: FastifyInstance, ctx: AdminContext): v /** * The top values of one dimension over the window. * - * Two tables answer it. The six open dimensions are rows of the per-dimension - * rollup, keyed by the dimension name; the three closed ones are keys of the - * hourly rollup, so they are read from there and grouped by their own column. - * Nothing in the query text comes from the request: the dimension chooses - * between two statements written here, and the only interpolated value is a - * row count the schema has already bounded to 1–500. + * The choice is between the per-dimension rollup and a keyed column of the + * hourly one. The six open dimensions are rows of the per-dimension rollup, + * keyed by the dimension name; the four keyed ones are columns of the hourly + * rollup's own key, so they are read from there and grouped by their own + * column. Nothing in the query text comes from the request: the dimension + * chooses between two statements written here, and the only interpolated + * value is a row count the schema has already bounded to 1–500. A breakdown + * by link adds one Postgres query for its labels, over exactly the ids the + * response shows. * * Ordered by clicks descending and then by value ascending, because a tie * broken by nothing at all is broken by whatever order the parts were read @@ -576,15 +622,29 @@ export function registerReportRoutes(app: FastifyInstance, ctx: AdminContext): v LIMIT ${q.limit + 1}`, params: { ...windowParams(w), ...(keyed ? {} : { dimension }) }, }) + const shown = rows.slice(0, q.limit) + // The labels for a breakdown by link, from Postgres, in one query over + // exactly the ids shown. A link deleted since its clicks were counted, + // and the zero id every click on no link carries, have no row and are + // answered with null rather than left out, so the rows still add up to + // the total beside them. + const labels = + dimension === 'link' + ? await linkLabels( + ctx.pg, + shown.map((r) => r.value), + ) + : null return { window: { from: new Date(w.fromMs).toISOString(), to: new Date(w.toMs).toISOString() }, link: w.linkId, dimension, truncated: rows.length > q.limit, - rows: rows.slice(0, q.limit).map((row) => ({ + rows: shown.map((row) => ({ value: row.value, clicks: count(row.clicks), visitors: count(row.visitors), + ...(labels === null ? {} : { link: labels.get(row.value) ?? null }), })), } }, diff --git a/packages/core/src/reporting.test.ts b/packages/core/src/reporting.test.ts index fb9aab0..77be7c7 100644 --- a/packages/core/src/reporting.test.ts +++ b/packages/core/src/reporting.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import { BUCKET_MS, KEYED_DIMENSIONS, + MAX_DAY_OFFSET_HOURS, MAX_REPORT_BUCKETS, MAX_REPORT_WINDOW_DAYS, MAX_REPORT_WINDOW_MS, + MIN_DAY_OFFSET_HOURS, REPORT_BUCKETS, REPORT_DIMENSIONS, ROLLUP_DIMENSIONS, @@ -51,12 +53,31 @@ describe('bucketCount', () => { // not a window of 400 hours on an endpoint that says 400 days. expect(MAX_REPORT_WINDOW_MS).toBe(34_560_000_000) }) + + it('counts days from where an offset puts midnight', () => { + const three = 3 * 3_600_000 + // 21:00 to 21:00 UTC is one day at UTC+3 and two at UTC. + expect(bucketCount(at('2026-09-23T21:00:00Z'), at('2026-09-24T21:00:00Z'), 'day', three)).toBe( + 1, + ) + expect(bucketCount(at('2026-09-23T21:00:00Z'), at('2026-09-24T21:00:00Z'), 'day')).toBe(2) + // West of UTC: midnight at UTC-12 is noon UTC. + const minusTwelve = -12 * 3_600_000 + expect( + bucketCount(at('2026-09-23T12:00:00Z'), at('2026-09-25T12:00:00Z'), 'day', minusTwelve), + ).toBe(2) + }) + + it('bounds the offset to the zones in use', () => { + expect(MIN_DAY_OFFSET_HOURS).toBe(-12) + expect(MAX_DAY_OFFSET_HOURS).toBe(14) + }) }) describe('the dimensions a breakdown may name', () => { - it('is the six the per-dimension rollup holds and the three that key the hourly one', () => { + it('is the six the per-dimension rollup holds and the four that key the hourly one', () => { expect(ROLLUP_DIMENSIONS).toEqual(['country', 'device', 'os', 'browser', 'referrer', 'target']) - expect(KEYED_DIMENSIONS).toEqual(['class', 'action', 'outcome']) + expect(KEYED_DIMENSIONS).toEqual(['class', 'action', 'outcome', 'link']) expect(REPORT_DIMENSIONS).toEqual([ 'country', 'device', @@ -67,6 +88,7 @@ describe('the dimensions a breakdown may name', () => { 'class', 'action', 'outcome', + 'link', ]) expect(REPORT_BUCKETS).toEqual(['hour', 'day']) }) diff --git a/packages/core/src/reporting.ts b/packages/core/src/reporting.ts index 20e91c5..9170715 100644 --- a/packages/core/src/reporting.ts +++ b/packages/core/src/reporting.ts @@ -10,9 +10,10 @@ * nothing here knows what "today" means. That is deliberate. A preset like * "last 7 days" needs a timezone; this install has no opinion about the * operator's, and a timezone parameter on five endpoints is five places to get - * it wrong. A caller computes a preset into a pair of instants. The hourly - * grain is what leaves a whole-hour offset possible later without a schema - * change; a half-hour zone would need one. + * it wrong. A caller computes a preset into a pair of instants. A chart by day + * takes one more thing, a whole-hour offset for where its days begin, which the + * hourly grain makes possible without a schema change; a half-hour zone is + * counted from the nearest whole hour. */ /** The two bucket sizes a chart is drawn at. */ @@ -46,12 +47,16 @@ export const ROLLUP_DIMENSIONS = [ export type RollupDimension = (typeof ROLLUP_DIMENSIONS)[number] /** - * Dimensions that are keys of the hourly rollup itself. All three are closed - * lists whose values cannot grow without a new click record version, so keying - * by them costs a bounded number of rows per hour and per link, and a summary - * can be answered from one table. + * Dimensions that are columns of the hourly rollup's own key, so a breakdown + * by one of them is a `GROUP BY` on that table and no second table is read. + * + * Three of them are closed lists whose values cannot grow without a new click + * record version. The fourth, `link`, is not closed — it grows with the links + * an operator creates — and is here because the link id was a key column from + * the start: every report can be asked for one link, and that filter needed it + * in the key. What the four share is the table, not the size of their lists. */ -export const KEYED_DIMENSIONS = ['class', 'action', 'outcome'] as const +export const KEYED_DIMENSIONS = ['class', 'action', 'outcome', 'link'] as const export type KeyedDimension = (typeof KEYED_DIMENSIONS)[number] export const REPORT_DIMENSIONS = [...ROLLUP_DIMENSIONS, ...KEYED_DIMENSIONS] as const @@ -69,6 +74,15 @@ export const MAX_REPORT_WINDOW_MS = MAX_REPORT_WINDOW_DAYS * 86_400_000 /** The most buckets one chart response carries. */ export const MAX_REPORT_BUCKETS = 2000 +/** + * Where a day bucket may begin, in whole hours from UTC midnight. Every zone + * in use lies inside this range. Whole hours only: the rollups are hourly, so + * a half-hour zone's days can only be counted from the nearest whole hour — + * the interface says so rather than pretending otherwise. + */ +export const MIN_DAY_OFFSET_HOURS = -12 +export const MAX_DAY_OFFSET_HOURS = 14 + /** * How many buckets the half-open window [fromMs, toMs) touches. * @@ -77,9 +91,18 @@ export const MAX_REPORT_BUCKETS = 2000 * two bars on a chart, and a ceiling computed the other way would let a * response through that is one bar bigger than the ceiling. Unix time carries * no leap seconds, so a day boundary is exact arithmetic on the epoch. + * + * `offsetMs` moves the boundaries: a day at UTC+3 begins at 21:00 UTC, so the + * count is taken on the shifted epoch. Zero for an hour bucket changes nothing, + * and a whole-hour offset on an hour bucket changes nothing either. */ -export function bucketCount(fromMs: number, toMs: number, bucket: ReportBucket): number { +export function bucketCount( + fromMs: number, + toMs: number, + bucket: ReportBucket, + offsetMs = 0, +): number { if (toMs <= fromMs) return 0 const ms = BUCKET_MS[bucket] - return Math.floor((toMs - 1) / ms) - Math.floor(fromMs / ms) + 1 + return Math.floor((toMs - 1 + offsetMs) / ms) - Math.floor((fromMs + offsetMs) / ms) + 1 } From 7b5649e3de45bdb4c70709f2556d82527c14f9ff Mon Sep 17 00:00:00 2001 From: Cem Hurturk Date: Fri, 25 Sep 2026 10:14:43 +0000 Subject: [PATCH 02/56] admin: pin the count's link filter --- packages/admin/src/clicks.test.ts | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/clicks.test.ts b/packages/admin/src/clicks.test.ts index f92f9a1..645a013 100644 --- a/packages/admin/src/clicks.test.ts +++ b/packages/admin/src/clicks.test.ts @@ -929,7 +929,39 @@ describe('GET /api/clicks/count', () => { url: `/api/clicks/count?${WINDOW}&class=bot`, headers: read(cookie), }) - expect(r.json().count).toBe(1) + expect(r.statusCode).toBe(200) + // Whole, not just `.count`: `link` is echoed from the parsed window on + // every case in this block, and a body only ever asserted at its least + // interesting value — `null` — can be answered by a constant. + expect(r.json()).toEqual({ + window: { from: '2026-09-24T00:00:00.000Z', to: '2026-09-25T00:00:00.000Z' }, + link: null, + count: 1, + cap: 1_000_000, + truncated: false, + }) + }) + + // Whole, and this is where `link` carries a value: link A is the first click + // and the bot click, both counted once each; link B's own click is outside + // this filter. Nothing in the block above exercises the link filter at all — + // every other case here leaves it unset — so an implementation that echoed + // the parsed window's `linkId` but never bound it into the probe's `WHERE` + // would still answer every other test in this file correctly. + it('counts one link when asked for one', async () => { + const r = await app.inject({ + method: 'GET', + url: `/api/clicks/count?${WINDOW}&link=${LINK_A}`, + headers: read(cookie), + }) + expect(r.statusCode).toBe(200) + expect(r.json()).toEqual({ + window: { from: '2026-09-24T00:00:00.000Z', to: '2026-09-25T00:00:00.000Z' }, + link: LINK_A, + count: 2, + cap: 1_000_000, + truncated: false, + }) }) it('says the export would stop at the cap, and counts no further than it', async () => { From e68d739588ed47ca6bdc8805e7eef6757787074b Mon Sep 17 00:00:00 2001 From: Cem Hurturk Date: Fri, 25 Sep 2026 10:35:11 +0000 Subject: [PATCH 03/56] admin: the link list newest first, searchable, with its cap in use --- README.md | 6 +- packages/admin/src/links.test.ts | 200 +++++++++++++++--- packages/admin/src/links.ts | 71 +++++-- packages/core/src/index.ts | 2 +- packages/core/src/version.test.ts | 2 +- .../migrations/postgres/009_link_listing.sql | 10 + 6 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 packages/db/migrations/postgres/009_link_listing.sql diff --git a/README.md b/README.md index de14d14..e4ccfa0 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,11 @@ not whether the install is configured, and not whether the account exists. That `GET /api/domains` returns at most 500 entries and `GET /api/keys` at most 200, each setting `"truncated": true` when there were more. `clickmonk domain list` has no such cap; `clickmonk apikey list` has the same one and says when it hit it. `GET /api/links` pages -properly, with `limit` and `cursor`. +properly, with `limit` and an opaque `cursor` from the previous answer — newest links +first. `q` searches the slug and the name, case-insensitively, as a substring; it reads +every link on the install, so it is fine for what an operator has and not a plan for a +much larger one. Each link in the response carries `createdAt` and, for a link with a +click cap, `capUsed` — `null` for a link with none. The install-wide settings are `GET /api/settings` and `PUT /api/settings` — the same things `clickmonk settings set` covers, in two halves: `traffic` and `retention`. The diff --git a/packages/admin/src/links.test.ts b/packages/admin/src/links.test.ts index 51762e8..ab04310 100644 --- a/packages/admin/src/links.test.ts +++ b/packages/admin/src/links.test.ts @@ -3,7 +3,7 @@ import { createPgPool } from '@clickmonk/db' import { TEST_PG_URL, resetDatabases, testCh, testPg } from '@clickmonk/db/testing' import type { FastifyInstance, LightMyRequestResponse } from 'fastify' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { MAX_LINK_PAGE, ownFields } from './links.js' +import { ownFields } from './links.js' import { clockFrom, read, signedIn, testApp, write } from './testing.js' const pg = testPg() @@ -442,55 +442,126 @@ describe('changing a link', () => { }) }) +/** Sets a link's creation time, so an order can be asserted without the database's clock. */ +const createdAt = (slug: string, iso: string) => + pg.query('UPDATE links SET created_at = $2 WHERE slug = $1', [slug, iso]) + +const slugs = (r: LightMyRequestResponse): string[] => + r.json().links.map((l: { slug: string }) => l.slug) + describe('listing links', () => { - it('pages, bounded, and stops rather than offering a cursor forever', async () => { - for (let i = 0; i < 5; i++) await create({ slug: `link-${i}`, targets: [target] }) - const first = await app.inject({ - method: 'GET', - url: '/api/links?limit=2', - headers: read(cookie), - }) - expect(first.json().links).toHaveLength(2) - const second = await app.inject({ + it('lists the newest first', async () => { + for (const slug of ['old', 'middle', 'new']) await create({ slug, targets: [target] }) + await createdAt('old', '2026-09-01T00:00:00.000Z') + await createdAt('middle', '2026-09-10T00:00:00.000Z') + await createdAt('new', '2026-09-20T00:00:00.000Z') + const r = await app.inject({ method: 'GET', url: '/api/links', headers: read(cookie) }) + expect(slugs(r)).toEqual(['new', 'middle', 'old']) + }) + + // Five links created in one instant, paged two at a time: every link exactly + // once, in id order within the instant, and no cursor after the last. A + // boundary that compared the time alone would skip the rest of the instant; + // one that compared it with `<=` would repeat it. + it('pages through links created in the same instant, each exactly once', async () => { + for (let i = 0; i < 5; i++) await create({ slug: `tie-${i}`, targets: [target] }) + await pg.query(`UPDATE links SET created_at = '2026-09-15T12:00:00.123456Z'`) + const ids = (await pg.query<{ id: string }>('SELECT id FROM links ORDER BY id DESC')).rows.map( + (row) => row.id, + ) + const seen: string[] = [] + let cursor: string | null = null + let pages = 0 + do { + const r: LightMyRequestResponse = await app.inject({ + method: 'GET', + url: `/api/links?limit=2${cursor ? `&cursor=${cursor}` : ''}`, + headers: read(cookie), + }) + seen.push(...r.json().links.map((l: { id: string }) => l.id)) + cursor = r.json().nextCursor + pages += 1 + } while (cursor !== null && pages < 10) + expect(seen).toEqual(ids) + expect(pages).toBe(3) + }) + + // Two links a microsecond apart, paged one at a time. A cursor that carried + // milliseconds would round both to the same instant and the second page + // would repeat the first link or skip the second. + it('keeps the microseconds in the cursor', async () => { + await create({ slug: 'first', targets: [target] }) + await create({ slug: 'second', targets: [target] }) + await createdAt('first', '2026-09-15T12:00:00.000001Z') + await createdAt('second', '2026-09-15T12:00:00.000002Z') + const one = await app.inject({ method: 'GET', - url: `/api/links?limit=2&cursor=${first.json().nextCursor}`, + url: '/api/links?limit=1', headers: read(cookie), }) - expect(second.json().links).toHaveLength(2) - const seen = [...first.json().links, ...second.json().links].map( - (l: { slug: string }) => l.slug, - ) - expect(new Set(seen).size).toBe(4) - const last = await app.inject({ + expect(slugs(one)).toEqual(['second']) + const two = await app.inject({ method: 'GET', - url: `/api/links?limit=2&cursor=${second.json().nextCursor}`, + url: `/api/links?limit=1&cursor=${one.json().nextCursor}`, headers: read(cookie), }) - expect(last.json().links).toHaveLength(1) - expect(last.json().nextCursor).toBeNull() + expect(slugs(two)).toEqual(['first']) }) - it('refuses a page larger than the bound, and a cursor that is not an id', async () => { - const tooBig = await app.inject({ + it('stops rather than offering a cursor forever', async () => { + await create({ slug: 'only', targets: [target] }) + const r = await app.inject({ method: 'GET', url: '/api/links?limit=2', headers: read(cookie) }) + expect(r.json().nextCursor).toBeNull() + }) + + it.each([ + ['a page larger than the bound', `limit=${201}`], + ['a cursor that is not a cursor', 'cursor=not-a-cursor'], + ['a cursor with an id that is not an id', 'cursor=1758000000000000.nope'], + ['a cursor in the old shape, a bare id', 'cursor=00000000-0000-4000-8000-000000000001'], + ['an empty search', 'q='], + ['a search longer than the bound', `q=${'x'.repeat(101)}`], + ])('refuses %s', async (_label, query) => { + const r = await app.inject({ method: 'GET', url: `/api/links?${query}`, headers: read(cookie) }) + expect(r.statusCode).toBe(400) + expect(r.json().error).toBe('invalid_body') + expect(Object.keys(r.json()).sort()).toEqual(['error', 'message']) + }) + + it('finds a link by part of its slug or its name, ignoring case', async () => { + await create({ slug: 'spring-sale', targets: [target] }) + await create({ slug: 'autumn', name: 'Big SPRING push', targets: [target] }) + await create({ slug: 'winter', targets: [target] }) + await createdAt('spring-sale', '2026-09-01T00:00:00.000Z') + await createdAt('autumn', '2026-09-02T00:00:00.000Z') + const r = await app.inject({ method: 'GET', url: '/api/links?q=Spring', headers: read(cookie) }) + expect(slugs(r)).toEqual(['autumn', 'spring-sale']) + }) + + it('searches for a percent sign and an underscore as themselves', async () => { + await create({ slug: 'half', name: '50% off', targets: [target] }) + await create({ slug: 'plain', name: '500 off', targets: [target] }) + await create({ slug: 'a_b', targets: [target] }) + await create({ slug: 'axb', targets: [target] }) + const percent = await app.inject({ method: 'GET', - url: `/api/links?limit=${MAX_LINK_PAGE + 1}`, + url: '/api/links?q=50%25', headers: read(cookie), }) - expect(tooBig.statusCode).toBe(400) - // The code and the whole body, because the interesting failure is a 500: - // a bound the schema no longer applies sends the value on to Postgres, - // which raises, and the route answers 500 — a refusal of a kind, and not - // this one. A test that read only "it did not work" would accept it. - expect(tooBig.json().error).toBe('invalid_body') - expect(Object.keys(tooBig.json()).sort()).toEqual(['error', 'message']) - const badCursor = await app.inject({ + expect(slugs(percent)).toEqual(['half']) + const underscore = await app.inject({ method: 'GET', - url: '/api/links?cursor=not-a-uuid', + url: '/api/links?q=a_', headers: read(cookie), }) - expect(badCursor.statusCode).toBe(400) - expect(badCursor.json().error).toBe('invalid_body') - expect(Object.keys(badCursor.json()).sort()).toEqual(['error', 'message']) + expect(slugs(underscore)).toEqual(['a_b']) + }) + + it('searches for a backslash as itself', async () => { + await create({ slug: 'slash', name: 'back\\slash', targets: [target] }) + await create({ slug: 'other', name: 'backslash', targets: [target] }) + const r = await app.inject({ method: 'GET', url: '/api/links?q=k%5Cs', headers: read(cookie) }) + expect(slugs(r)).toEqual(['slash']) }) it('filters by domain', async () => { @@ -514,6 +585,65 @@ describe('listing links', () => { }) expect(r.json().links.map((l: { slug: string }) => l.slug)).toEqual(['two']) }) + + it('combines a search with a domain', async () => { + await app.inject({ + method: 'POST', + url: '/api/domains', + headers: write(cookie), + payload: { host: 'two.example.test' }, + }) + await create({ slug: 'sale', targets: [target] }) + await app.inject({ + method: 'POST', + url: '/api/links', + headers: write(cookie), + payload: { host: 'two.example.test', slug: 'sale', targets: [target] }, + }) + const r = await app.inject({ + method: 'GET', + url: '/api/links?q=sale&domain=two.example.test', + headers: read(cookie), + }) + expect(r.json().links.map((l: { host: string }) => l.host)).toEqual(['two.example.test']) + }) +}) + +describe('what a link says about itself', () => { + it('says when it was created', async () => { + const id = (await create({ slug: 'dated', targets: [target] })).json().id + await createdAt('dated', '2026-09-15T12:00:00.123Z') + const r = await app.inject({ method: 'GET', url: `/api/links/${id}`, headers: read(cookie) }) + expect(r.json().createdAt).toBe('2026-09-15T12:00:00.123Z') + }) + + it('says how much of its cap is used, and nothing when it has no cap', async () => { + const capped = (await create({ slug: 'capped', clickCap: 1000, targets: [target] })).json() + const open = (await create({ slug: 'open', targets: [target] })).json() + // Created, never clicked: no counter row yet, and the answer is zero. + expect(capped.capUsed).toBe(0) + expect(open.capUsed).toBeNull() + await pg.query('INSERT INTO link_counters (link_id, clicks) VALUES ($1, 312)', [capped.id]) + const listed = await app.inject({ method: 'GET', url: '/api/links', headers: read(cookie) }) + const byslug = Object.fromEntries( + listed.json().links.map((l: { slug: string; capUsed: number | null }) => [l.slug, l.capUsed]), + ) + expect(byslug).toEqual({ capped: 312, open: null }) + }) + + // A counter left behind by a cap that has since been removed is not a cap in + // use: the link has no cap, so the answer is null whatever the row says. + it('says nothing about a cap it no longer has', async () => { + const link = (await create({ slug: 'was-capped', clickCap: 10, targets: [target] })).json() + await pg.query('INSERT INTO link_counters (link_id, clicks) VALUES ($1, 7)', [link.id]) + const r = await app.inject({ + method: 'PATCH', + url: `/api/links/${link.id}`, + headers: write(cookie), + payload: { clickCap: null }, + }) + expect(r.json().capUsed).toBeNull() + }) }) /** diff --git a/packages/admin/src/links.ts b/packages/admin/src/links.ts index c4242d9..1a2476b 100644 --- a/packages/admin/src/links.ts +++ b/packages/admin/src/links.ts @@ -69,15 +69,33 @@ const LinkFields = { const CreateBody = z.object({ host: z.string().min(1).max(253), ...LinkFields }).strict() const PatchBody = z.object(LinkFields).strict() + +/** + * A page boundary: the last link's creation time in microseconds since the + * epoch, and its id. Microseconds because the column holds them — a boundary + * rounded to milliseconds skips or repeats a link created in the same + * millisecond as the one before it. Opaque to a caller, and parsed strictly. + */ +const LINK_CURSOR_RE = + /^(\d{1,17})\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/ + +/** The longest search a caller may send. Longer than any slug; nobody types more. */ +export const MAX_LINK_SEARCH = 100 + const ListQuery = z .object({ domain: z.string().max(253).optional(), + q: z.string().min(1).max(MAX_LINK_SEARCH).optional(), limit: z.coerce.number().int().min(1).max(MAX_LINK_PAGE).default(DEFAULT_LINK_PAGE), - /** The last id of the previous page. The order is by id: stable, arbitrary. */ - cursor: z.string().uuid().optional(), + cursor: z.string().regex(LINK_CURSOR_RE, 'not a cursor from a previous page').optional(), }) .strict() +/** `%`, `_` and `\` as themselves in an `ILIKE … ESCAPE '\'` pattern. */ +export function likeLiteral(text: string): string { + return text.replace(/[\\%_]/g, (c) => `\\${c}`) +} + interface LinkRow { id: string domain_id: string @@ -94,17 +112,25 @@ interface LinkRow { passthrough: boolean traffic_actions: Record password_hash: string | null + created_at: Date + created_us: string + cap_used: string | null targets: { id: string; url: string; weight: number }[] | null } const SELECT_LINKS = `SELECT l.id, l.domain_id, d.host, l.slug, l.name, l.enabled, l.backup_url, l.device_urls, l.returning_url, l.countries, l.click_cap, l.expires_at, l.passthrough, l.traffic_actions, l.password_hash, + l.created_at, + (extract(epoch from l.created_at) * 1000000)::bigint AS created_us, + CASE WHEN l.click_cap IS NULL THEN NULL + ELSE coalesce(c.clicks, 0) END AS cap_used, json_agg(json_build_object('id', t.id, 'url', t.url, 'weight', t.weight) ORDER BY t.position) FILTER (WHERE t.id IS NOT NULL) AS targets FROM links l JOIN domains d ON d.id = l.domain_id + LEFT JOIN link_counters c ON c.link_id = l.id LEFT JOIN link_targets t ON t.link_id = l.id` /** What a link looks like over the API. The password appears only as a boolean. */ @@ -127,6 +153,8 @@ function asLink(l: LinkRow): Record { passthrough: l.passthrough, trafficActions: l.traffic_actions, hasPassword: l.password_hash !== null, + createdAt: l.created_at.toISOString(), + capUsed: l.cap_used === null ? null : Number(l.cap_used), } } @@ -150,7 +178,10 @@ function asInput(l: LinkRow): Record { async function linkById(pg: Pool, id: string): Promise { if (!z.string().uuid().safeParse(id).success) fail(404, 'not_found', 'no such link') - const r = await pg.query(`${SELECT_LINKS} WHERE l.id = $1 GROUP BY l.id, d.host`, [id]) + const r = await pg.query( + `${SELECT_LINKS} WHERE l.id = $1 GROUP BY l.id, d.host, c.clicks`, + [id], + ) const row = r.rows[0] if (!row) fail(404, 'not_found', 'no such link') return row as LinkRow @@ -257,28 +288,40 @@ export function registerLinkRoutes(app: FastifyInstance, ctx: AdminContext): voi app.get('/api/links', async (req) => { requireCredential(req) const q = readBody(ListQuery, ownFields(req.query)) - // `written`, not `q.domain` and `q.cursor`: both are optional, so a caller - // who sent neither has nothing of their own under either name, and a plain - // read is answered by the prototype — a filter nobody asked for, or a - // cursor that is not an id reaching the query as one. + // `written`, not `q.domain`, `q.q` and `q.cursor`: all three are optional, + // so a caller who sent none has nothing of their own under any of those + // names, and a plain read is answered by the prototype — a filter nobody + // asked for, or a cursor that is not a cursor reaching the query as one. const domain = written(q, 'domain') + const search = written(q, 'q') const cursor = written(q, 'cursor') const host = domain === undefined ? null : normaliseHost(domain) - if (domain !== undefined && host === null) fail(400, 'invalid_host', 'not a valid host name') + if (domain !== undefined && host === null) + return fail(400, 'invalid_host', 'not a valid host name') + const boundary = cursor === undefined ? null : LINK_CURSOR_RE.exec(cursor) const r = await ctx.pg.query( `${SELECT_LINKS} WHERE ($1::text IS NULL OR d.host = $1) - AND ($2::uuid IS NULL OR l.id > $2) - GROUP BY l.id, d.host - ORDER BY l.id - LIMIT $3`, - [host, cursor ?? null, q.limit], + AND ($2::text IS NULL OR l.slug ILIKE $2 ESCAPE '\\' OR l.name ILIKE $2 ESCAPE '\\') + AND ($3::bigint IS NULL + OR (l.created_at, l.id) < (timestamptz 'epoch' + $3 * interval '1 microsecond', $4::uuid)) + GROUP BY l.id, d.host, c.clicks + ORDER BY l.created_at DESC, l.id DESC + LIMIT $5`, + [ + host, + search === undefined ? null : `%${likeLiteral(search)}%`, + boundary?.[1] ?? null, + boundary?.[2] ?? null, + q.limit, + ], ) + const last = r.rows[r.rows.length - 1] return { links: r.rows.map(asLink), // Present only when a further page may exist, so a caller stops rather // than asking forever. - nextCursor: r.rows.length === q.limit ? (r.rows[r.rows.length - 1]?.id ?? null) : null, + nextCursor: r.rows.length === q.limit && last ? `${last.created_us}.${last.id}` : null, } }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1449057..de1861b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,7 @@ * The highest migration version this build understands. Every migration bumps * it; `migrate()` refuses to run against a database whose ledger is newer. */ -export const SCHEMA_VERSION = 8 +export const SCHEMA_VERSION = 9 export * from './admin-host.js' export * from './attempts.js' diff --git a/packages/core/src/version.test.ts b/packages/core/src/version.test.ts index a0ec073..fde79a1 100644 --- a/packages/core/src/version.test.ts +++ b/packages/core/src/version.test.ts @@ -5,6 +5,6 @@ describe('SCHEMA_VERSION', () => { it('is the highest migration version this build knows about', () => { // Bumped with every migration added. packages/db's // schema-version.test.ts ties it to the migration files on disk. - expect(SCHEMA_VERSION).toBe(8) + expect(SCHEMA_VERSION).toBe(9) }) }) diff --git a/packages/db/migrations/postgres/009_link_listing.sql b/packages/db/migrations/postgres/009_link_listing.sql new file mode 100644 index 0000000..a0a6210 --- /dev/null +++ b/packages/db/migrations/postgres/009_link_listing.sql @@ -0,0 +1,10 @@ +-- The order the link list is read in: newest first, the id breaking ties +-- between links created in the same transaction. +-- +-- CREATE INDEX takes a SHARE lock on links for as long as it runs. That blocks +-- writes to links and does not conflict with the ACCESS SHARE lock the +-- redirect's snapshot load takes, so a reload during this migration is not +-- held up by it, and the lock is on one table, so it cannot be half of a +-- deadlock. Not CONCURRENTLY: the migrator runs each file in a transaction, +-- and CREATE INDEX CONCURRENTLY cannot run inside one. +CREATE INDEX IF NOT EXISTS links_created_idx ON links (created_at DESC, id DESC); From 2d1dd80dec06e7ad0cd6af5a9ee69e8564bacd8f Mon Sep 17 00:00:00 2001 From: Cem Hurturk Date: Fri, 25 Sep 2026 10:53:18 +0000 Subject: [PATCH 04/56] admin: a status route for how fresh the numbers and the ip lists are --- README.md | 27 +++- docker-compose.yml | 5 + packages/admin/src/app.ts | 9 ++ packages/admin/src/config.test.ts | 7 + packages/admin/src/config.ts | 7 + packages/admin/src/domains.ts | 10 +- packages/admin/src/index.ts | 1 + packages/admin/src/reports.test.ts | 10 ++ packages/admin/src/status.test.ts | 218 +++++++++++++++++++++++++++++ packages/admin/src/status.ts | 93 ++++++++++++ test/stack/compose-config.test.ts | 12 ++ 11 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 packages/admin/src/status.test.ts create mode 100644 packages/admin/src/status.ts diff --git a/README.md b/README.md index e4ccfa0..1e4cfe1 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,9 @@ What does not work yet: simply not arrived yet. - **Reports answer nothing while ClickHouse is down.** `GET /api/reports/…`, `GET /api/clicks`, `GET /api/clicks.csv` and `GET /api/clicks/count` answer 503; domains, - links and settings keep working. + links and settings keep working. `GET /api/status` is the exception: it still answers + 200, with `"reporting":"unavailable"`, because it is the thing that says reporting is + down. - **An export that fails part way through ends the connection** rather than finishing the file, because a file that looks complete and is not is worse than a download that broke. - **Any notification.** Nothing is emailed, posted or pushed anywhere: there is no mail @@ -477,6 +479,25 @@ Add `&link=` to any of them for one link instead of all of them. **Nothing l link up**, so an id that belongs to no link is an empty answer rather than an error, on all six of these. +**`GET /api/status`** takes no query string and answers how fresh everything is in one read: + +```sh +curl -H "authorization: Bearer $KEY" "https://admin.example.com/api/status" +# {"newestHour":"2026-09-24T11:00:00.000Z","reporting":"ok", +# "ipData":{"country":{"version":"2026-09","fetchedAt":"2026-09-20T03:00:00.000Z"}, +# "asn":null,"datacenter":null,"tor":{"version":"…","fetchedAt":"…"}}, +# "ipDataProblem":null,"alerts":2} +``` + +`newestHour` and `reporting` are the summary's own freshness, answered here even when +ClickHouse cannot be reached: `reporting` is `"unavailable"` and `newestHour` is `null` +rather than a 503, because this is the route that says reporting is down. `ipData` is each +IP list's version and when it was fetched, `null` for a source never fetched and `null` for +the whole field on an install with no IP data yet — `CLICKMONK_IPDATA_UPDATE=off`, or a +fresh one. `ipDataProblem` is set, with `ipData` then `null` too, only when the manifest is +there and could not be read; the response never says why, so check the admin service's own +log for the reason. `alerts` is the count `GET /api/alerts` would list, capped the same way. + **A report counts whole buckets; the log counts milliseconds.** Before a report is counted, `from` is floored and `to` raised to the next boundary, and **the boundary is the grain that report answers at**: the hour for the summary and the breakdown, because that is the grain @@ -659,7 +680,9 @@ you find out. The worker downloads four lists to your server, and `clickmonk ipdata update` fetches them on demand. The redirect looks each visitor's address up in memory: no lookup leaves your server, and no request waits for a download. The lists take about 25 MB of -the redirect's memory, and each has a fixed ceiling. +the redirect's memory, and each has a fixed ceiling. The admin service also mounts this +volume, read-only, and reads it for nothing but `GET /api/status` — to say how old each +list is. The address looked up is the one Caddy passes on, which is the visitor's: Caddy is the only service with a published port, nothing outside the stack can open a connection to the diff --git a/docker-compose.yml b/docker-compose.yml index d8e93eb..588dbaf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,11 @@ services: CLICKMONK_ADMIN_HOST: ${CLICKMONK_ADMIN_HOST:-} CLICKMONK_TRUSTED_PROXIES: ${CLICKMONK_TRUSTED_PROXIES:-uniquelocal,loopback} CLICKMONK_DNS_SERVERS: ${CLICKMONK_DNS_SERVERS:-} + volumes: + # The IP data the worker writes, read here only to say how old it is. + # Read-only: this service has no reason to write it, and a mount it + # cannot write through is how that is enforced rather than promised. + - ipdata:/var/lib/clickmonk/ipdata:ro healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:9100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 5s diff --git a/packages/admin/src/app.ts b/packages/admin/src/app.ts index e730ad2..2a11e3d 100644 --- a/packages/admin/src/app.ts +++ b/packages/admin/src/app.ts @@ -21,6 +21,7 @@ import { registerLinkRoutes } from './links.js' import { registerReportRoutes } from './reports.js' import { registerSessionRoutes } from './session-routes.js' import { registerSettingsRoutes } from './settings-routes.js' +import { registerStatusRoutes } from './status.js' /** Failed sign-ins from one client before it is refused, and the window. */ export const LOGIN_ATTEMPT_LIMIT = 10 @@ -121,6 +122,13 @@ export interface AdminDeps { * has to read the log passes a destination stream here instead. */ log?: FastifyServerOptions['logger'] + /** + * Where the worker writes the IP data manifest, mounted read-only here. Read + * by the status route only. Absent in most tests, which then see the + * default directory — which does not exist on a test machine, and reads as + * "no IP data yet". + */ + ipdataDir?: string } export interface AdminContext extends AdminDeps { @@ -302,6 +310,7 @@ export function buildAdminApp( registerSettingsRoutes(app, ctx) registerReportRoutes(app, ctx) registerClickRoutes(app, ctx) + registerStatusRoutes(app, ctx) return app } diff --git a/packages/admin/src/config.test.ts b/packages/admin/src/config.test.ts index 32d2e35..d660562 100644 --- a/packages/admin/src/config.test.ts +++ b/packages/admin/src/config.test.ts @@ -25,9 +25,16 @@ describe('loadConfig', () => { port: 9100, trustedProxies: ['127.0.0.1'], dnsServers: [], + ipdataDir: '/var/lib/clickmonk/ipdata', }) }) + it('takes a set IP data directory as given', () => { + expect(loadConfig({ ...base, CLICKMONK_IPDATA_DIR: '/mnt/ipdata' }).ipdataDir).toBe( + '/mnt/ipdata', + ) + }) + // All four, one row each: the comment on them claims that an install with a // typo fails at boot with the variable named, and three of the four were // otherwise free to become optional with that claim still written down. diff --git a/packages/admin/src/config.ts b/packages/admin/src/config.ts index 168541c..6f716d0 100644 --- a/packages/admin/src/config.ts +++ b/packages/admin/src/config.ts @@ -1,5 +1,6 @@ import { AdminHostSchema, TrustedProxiesSchema, formatConfigError } from '@clickmonk/core' import type { ChConfig } from '@clickmonk/db' +import { DEFAULT_IPDATA_DIR } from '@clickmonk/ipdata' import { isResolverAddress } from '@clickmonk/worker/domains' import { z } from 'zod' @@ -46,6 +47,10 @@ const Schema = z.object({ // Used only by the on-demand domain check, which asks the same resolvers // the worker's scheduled pass does. CLICKMONK_DNS_SERVERS: DnsServers, + // Where the worker writes the IP data manifest. Mounted read-only into this + // service's container; the default is the same path the worker and the + // redirect use, from the one place that path is written down. + CLICKMONK_IPDATA_DIR: z.string().min(1).default(DEFAULT_IPDATA_DIR), }) export interface AdminConfig { @@ -56,6 +61,7 @@ export interface AdminConfig { port: number trustedProxies: string[] dnsServers: string[] + ipdataDir: string } export function loadConfig(env: NodeJS.ProcessEnv): AdminConfig { @@ -74,5 +80,6 @@ export function loadConfig(env: NodeJS.ProcessEnv): AdminConfig { port: e.CLICKMONK_ADMIN_PORT, trustedProxies: e.CLICKMONK_TRUSTED_PROXIES, dnsServers: e.CLICKMONK_DNS_SERVERS, + ipdataDir: e.CLICKMONK_IPDATA_DIR, } } diff --git a/packages/admin/src/domains.ts b/packages/admin/src/domains.ts index 38f6448..c73743d 100644 --- a/packages/admin/src/domains.ts +++ b/packages/admin/src/domains.ts @@ -43,6 +43,14 @@ import { fail, readBody } from './http.js' */ export const MAX_DOMAINS_LISTED = 500 +/** + * Which domains need attention: every one whose last check did not find the + * token, and every one no check has reached yet. One spelling, read by the + * listing below and by the count `GET /api/status` answers, so "which domains + * need attention" is never asked two ways that could answer differently. + */ +export const ALERT_CONDITION = `c.status IS NULL OR c.status <> 'verified'` + /** * How long after a check the same domain may be checked again over the API, * and how many checks this process runs at once. The worker's scheduled pass @@ -272,7 +280,7 @@ export function registerDomainRoutes(app: FastifyInstance, ctx: AdminContext): v requireCredential(req) const r = await ctx.pg.query( `${SELECT_DOMAINS} - WHERE c.status IS NULL OR c.status <> 'verified' + WHERE ${ALERT_CONDITION} ORDER BY d.host LIMIT ${MAX_DOMAINS_LISTED + 1}`, ) return { diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts index a2ed44b..bda0a83 100644 --- a/packages/admin/src/index.ts +++ b/packages/admin/src/index.ts @@ -34,6 +34,7 @@ const app = buildAdminApp( ch, adminHost: config.adminHost, dnsServers: config.dnsServers, + ipdataDir: config.ipdataDir, }, { trustProxy: config.trustedProxies }, ) diff --git a/packages/admin/src/reports.test.ts b/packages/admin/src/reports.test.ts index 9196056..0be85f1 100644 --- a/packages/admin/src/reports.test.ts +++ b/packages/admin/src/reports.test.ts @@ -1322,6 +1322,16 @@ describe('when ClickHouse is not there', () => { expect(r.statusCode).toBe(200) }) + // Status is the one report route that does not turn an unreachable store + // into a 503: it is the thing that tells the operator reporting is down, so + // it cannot do that by being down with it. + it('answers status 200 and says reporting is unavailable, rather than a 503', async () => { + const r = await deadApp.inject({ method: 'GET', url: '/api/status', headers: read(cookie) }) + expect(r.statusCode).toBe(200) + expect(r.json().reporting).toBe('unavailable') + expect(r.json().newestHour).toBeNull() + }) + // The answer is the same 503 as an unreachable store, so the status alone // pins nothing: with no guard at all the query is attempted on nothing, the // TypeError is caught where a ClickHouse failure is caught, and the caller diff --git a/packages/admin/src/status.test.ts b/packages/admin/src/status.test.ts new file mode 100644 index 0000000..1a67f68 --- /dev/null +++ b/packages/admin/src/status.test.ts @@ -0,0 +1,218 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ConcurrencyGate } from '@clickmonk/core' +import { resetDatabases, testCh, testPg } from '@clickmonk/db/testing' +import type { FastifyInstance } from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { ADMIN_HOST, clockFrom, read, signedIn, testApp } from './testing.js' + +const pool = testPg() +const ch = testCh() +const clock = clockFrom(new Date('2026-09-24T12:00:00.000Z')) +let app: FastifyInstance +let cookie = '' +let dir = '' + +const COUNTRY = { + file: 'country-0123456789abcdef.cmrt', + version: '2026-09', + fetchedAt: '2026-09-20T03:00:00.000Z', + entries: { k32: 10, k128: 2 }, +} +const TOR = { + file: 'tor-fedcba9876543210.cmrt', + version: '2026-09-24T06:00:00Z', + fetchedAt: '2026-09-24T06:05:00.000Z', + entries: { k32: 3, k128: 0 }, +} + +const manifest = (sources: Record) => + writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ v: 1, sources })) + +const status = (on: FastifyInstance) => + on.inject({ method: 'GET', url: '/api/status', headers: read(cookie) }) + +beforeAll(async () => { + await resetDatabases(pool, ch) + await ch.insert({ + table: 'clicks', + format: 'JSONEachRow', + values: [ + { + click_id: '01920000-0000-7000-8000-0000000000aa', + time: '2026-09-24 11:20:00.000', + host: 'go.example.test', + path: '/a', + domain_id: '00000000-0000-4000-8000-00000000000d', + link_id: '00000000-0000-4000-8000-0000000000a1', + outcome: 'target', + step: 'destination', + status: 302, + destination: 'https://example.com/', + target_id: '00000000-0000-4000-8000-0000000000b1', + visitor_id: 'v1', + returning: 0, + country: 'DE', + region: '', + city: '', + geo_source: 'dbip', + device: 'desktop', + user_agent: 'ua', + referrer: '', + ip: '198.51.100.7', + cap_unchecked: 0, + traffic_class: 'human', + signals: [], + action: '', + os: 'windows', + browser: 'chrome', + asn: 64500, + }, + ], + }) + // Three domains: one verified by a check that found its token, one whose last + // check did not, and one no check has reached. The last two are alerts. + await pool.query(`INSERT INTO domains (id, host, verified) VALUES + ('00000000-0000-4000-8000-0000000000d1', 'ok.example.test', true), + ('00000000-0000-4000-8000-0000000000d2', 'gone.example.test', true), + ('00000000-0000-4000-8000-0000000000d3', 'new.example.test', false)`) + await pool.query(`INSERT INTO domain_dns_checks (domain_id, status, detail, checked_at) VALUES + ('00000000-0000-4000-8000-0000000000d1', 'verified', '', '2026-09-24T11:00:00Z'), + ('00000000-0000-4000-8000-0000000000d2', 'missing_token', 'no TXT record', '2026-09-24T11:00:00Z')`) +}) + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'clickmonk-status-')) + app = testApp(pool, clock, { ch, ipdataDir: dir }) + cookie = await signedIn(app, pool, clock.now()) +}) + +afterEach(async () => { + await app.close() + rmSync(dir, { recursive: true, force: true }) + await pool.query('TRUNCATE admin_account, admin_recovery_codes, sessions, api_keys') +}) + +afterAll(async () => { + await pool.end() + await ch.close() +}) + +describe('GET /api/status', () => { + it('says how fresh the reports and the IP lists are, and how many domains need attention', async () => { + manifest({ country: COUNTRY, tor: TOR }) + const r = await status(app) + expect(r.statusCode).toBe(200) + expect(r.json()).toEqual({ + newestHour: '2026-09-24T11:00:00.000Z', + reporting: 'ok', + ipData: { + country: { version: '2026-09', fetchedAt: '2026-09-20T03:00:00.000Z' }, + asn: null, + datacenter: null, + tor: { version: '2026-09-24T06:00:00Z', fetchedAt: '2026-09-24T06:05:00.000Z' }, + }, + ipDataProblem: null, + alerts: 2, + }) + }) + + it('says there is no IP data yet, which is not a problem', async () => { + const r = await status(app) + expect(r.json().ipData).toBeNull() + expect(r.json().ipDataProblem).toBeNull() + }) + + it('says the IP data could not be read, without saying what the error was', async () => { + writeFileSync(join(dir, 'manifest.json'), '{"v": 2}') + const r = await status(app) + expect(r.statusCode).toBe(200) + expect(r.json().ipData).toBeNull() + expect(r.json().ipDataProblem).toBe('the IP data manifest could not be read') + expect(r.body).not.toContain(dir) + }) + + it('treats a directory that is not there as no IP data', async () => { + const elsewhere = testApp(pool, clock, { ch, ipdataDir: join(dir, 'not-there') }) + try { + const r = await status(elsewhere) + expect(r.json().ipData).toBeNull() + expect(r.json().ipDataProblem).toBeNull() + } finally { + await elsewhere.close() + } + }) + + // No test machine has `/var/lib/clickmonk/ipdata`, so an app built with no + // `ipdataDir` at all — every suite that does not pass one — falls back to + // that default and reads it the same way: no IP data yet, not a problem. + it('falls back to the default directory when none is given, and finds nothing there', async () => { + const defaulted = testApp(pool, clock, { ch }) + try { + const r = await status(defaulted) + expect(r.json().ipData).toBeNull() + expect(r.json().ipDataProblem).toBeNull() + } finally { + await defaulted.close() + } + }) + + it('answers when reporting does not, and says reporting is down', async () => { + const noStore = testApp(pool, clock, { ipdataDir: dir }) + try { + const r = await status(noStore) + expect(r.statusCode).toBe(200) + expect(r.json().newestHour).toBeNull() + expect(r.json().reporting).toBe('unavailable') + expect(r.json().alerts).toBe(2) + } finally { + await noStore.close() + } + }) + + // The count is capped exactly as the listing `GET /api/alerts` answers is: + // one query past the cap, asked for and dropped, so an install with far + // more broken domains than this still answers a small, fast number rather + // than counting all of them on every status read. + it('caps the alerts count the way the alert listing caps its rows', async () => { + await pool.query( + `INSERT INTO domains (host, verification_token) + SELECT 'bulk-' || n || '.example.test', lpad(to_hex(n), 32, '0') + FROM generate_series(1, 501) AS n`, + ) + try { + const r = await status(app) + expect(r.json().alerts).toBe(501) + } finally { + await pool.query(`DELETE FROM domains WHERE host LIKE 'bulk-%.example.test'`) + } + }) + + it('refuses when every report slot is taken, and says when to come back', async () => { + const full = testApp(pool, clock, { ch, ipdataDir: dir, reportGate: new ConcurrencyGate(0) }) + try { + const r = await status(full) + expect(r.statusCode).toBe(429) + expect(r.json().error).toBe('too_many_reports') + expect(r.headers['retry-after']).toBe('1') + } finally { + await full.close() + } + }) + + it('needs a credential', async () => { + const r = await app.inject({ method: 'GET', url: '/api/status', headers: { host: ADMIN_HOST } }) + expect(r.statusCode).toBe(401) + }) + + it('refuses a query string, which means nothing here', async () => { + const r = await app.inject({ + method: 'GET', + url: '/api/status?verbose=1', + headers: read(cookie), + }) + expect(r.statusCode).toBe(400) + expect(r.json().error).toBe('invalid_query') + }) +}) diff --git a/packages/admin/src/status.ts b/packages/admin/src/status.ts new file mode 100644 index 0000000..55fc901 --- /dev/null +++ b/packages/admin/src/status.ts @@ -0,0 +1,93 @@ +/** + * How fresh the numbers are, and how old the lists behind them. + * + * Three questions an operator cannot answer from a report: whether the newest + * clicks have reached the rollups yet, how old the IP lists are that classified + * them, and whether any domain needs attention. The interface shows all three + * in its header, which is why they are one read and not three. + * + * **It answers when reporting does not.** A store that cannot be reached is a + * `reporting: "unavailable"` in a 200, not a 503: this is the route that says + * reporting is down, and it cannot do that by being down with it. The store is + * read under the report gate like every other report, so a full gate is still a + * 429 — that is the gate being busy, not the store being gone. + * + * The IP lists are read from the manifest the worker writes, through the one + * reader of it every other service uses, on a volume this service mounts + * read-only. What the manifest could not be read *because of* goes to the log + * and never into the response, which would otherwise name a path on the host. + */ +import { DEFAULT_IPDATA_DIR, SOURCE_IDS, readManifest } from '@clickmonk/ipdata' +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import type { AdminContext } from './app.js' +import { requireCredential } from './auth.js' +import { ALERT_CONDITION, MAX_DOMAINS_LISTED } from './domains.js' +import { HttpError } from './http.js' +import { chRows, newestHourOrNull, readQuery, withSlot } from './reports.js' + +const StatusQuery = z.object({}).strict() + +type SourceStatus = { version: string; fetchedAt: string } | null + +function ipDataOf( + dir: string, + log: { error: (o: object, msg: string) => void }, +): { ipData: Record | null; ipDataProblem: string | null } { + try { + const m = readManifest(dir) + if (m === null) return { ipData: null, ipDataProblem: null } + const out: Record = {} + for (const id of SOURCE_IDS) { + const s = m.sources[id] + out[id] = s === undefined ? null : { version: s.version, fetchedAt: s.fetchedAt } + } + return { ipData: out, ipDataProblem: null } + } catch (err) { + log.error({ err }, 'the ip data manifest could not be read') + return { ipData: null, ipDataProblem: 'the IP data manifest could not be read' } + } +} + +export function registerStatusRoutes(app: FastifyInstance, ctx: AdminContext): void { + app.get('/api/status', async (req) => { + requireCredential(req) + readQuery(StatusQuery, req.query) + const alerts = await ctx.pg.query<{ n: string }>( + `SELECT count(*) AS n FROM ( + SELECT 1 FROM domains d LEFT JOIN domain_dns_checks c ON c.domain_id = d.id + WHERE ${ALERT_CONDITION} + LIMIT ${MAX_DOMAINS_LISTED + 1}) AS a`, + ) + const { ipData, ipDataProblem } = ipDataOf(ctx.ipdataDir ?? DEFAULT_IPDATA_DIR, req.log) + const base = { ipData, ipDataProblem, alerts: Number(alerts.rows[0]?.n ?? 0) } + if (!ctx.ch) { + req.log.error('status was asked for but this service has no clickhouse client') + return { newestHour: null, reporting: 'unavailable', ...base } + } + const ch = ctx.ch + return withSlot( + ctx.reportGate, + { code: 'too_many_reports', message: 'too many reports at once; try again' }, + async () => { + try { + const [newest] = await chRows<{ newest: string }>({ + ch, + req, + query: 'SELECT toString(max(hour)) AS newest FROM clicks_hourly', + params: {}, + }) + return { newestHour: newestHourOrNull(newest?.newest), reporting: 'ok', ...base } + } catch (err) { + // `chRows` has already logged the store's error and turned it into a + // 503; here that 503 is the answer's content, not its status. Only + // that failure is swallowed: anything else — a programming error in + // the row mapping, say — is a real fault and must not read as + // "reporting unavailable". + if (!(err instanceof HttpError) || err.code !== 'reporting_unavailable') throw err + return { newestHour: null, reporting: 'unavailable', ...base } + } + }, + ) + }) +} diff --git a/test/stack/compose-config.test.ts b/test/stack/compose-config.test.ts index e0df008..bfc8ae2 100644 --- a/test/stack/compose-config.test.ts +++ b/test/stack/compose-config.test.ts @@ -169,6 +169,18 @@ describe('what the stack publishes', () => { } }) + // Read-only: this service has no reason to write IP data, and a later edit + // that drops `:ro` is exactly the change nobody reviews. + it('mounts the IP data volume read-only on the admin service', () => { + const block = serviceBlock(cfg, 'admin') + // The resolved configuration expands the short `ipdata:/path:ro` form + // into the long one below; `read_only: true` is what this asserts, since + // that is the field a later edit dropping `:ro` would actually change. + expect(block).toMatch( + /\n {6}- type: volume\n {8}source: ipdata\n {8}target: \/var\/lib\/clickmonk\/ipdata\n {8}read_only: true\n/, + ) + }) + it('tells the redirect to believe a forwarded address only from the stack’s own network', () => { expect(cfg).toContain('CLICKMONK_TRUSTED_PROXIES: uniquelocal,loopback') }) From c1196f601148afc8a3a66854e530afabd6ca775f Mon Sep 17 00:00:00 2001 From: Cem Hurturk Date: Fri, 25 Sep 2026 11:01:45 +0000 Subject: [PATCH 05/56] admin: pin the status route's catch narrowing and its manifest-failure log --- packages/admin/src/domains.ts | 2 +- packages/admin/src/status.test.ts | 54 ++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/admin/src/domains.ts b/packages/admin/src/domains.ts index c73743d..8a15418 100644 --- a/packages/admin/src/domains.ts +++ b/packages/admin/src/domains.ts @@ -49,7 +49,7 @@ export const MAX_DOMAINS_LISTED = 500 * listing below and by the count `GET /api/status` answers, so "which domains * need attention" is never asked two ways that could answer differently. */ -export const ALERT_CONDITION = `c.status IS NULL OR c.status <> 'verified'` +export const ALERT_CONDITION = `(c.status IS NULL OR c.status <> 'verified')` /** * How long after a check the same domain may be checked again over the API, diff --git a/packages/admin/src/status.test.ts b/packages/admin/src/status.test.ts index 1a67f68..a33f000 100644 --- a/packages/admin/src/status.test.ts +++ b/packages/admin/src/status.test.ts @@ -2,11 +2,22 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { ConcurrencyGate } from '@clickmonk/core' +import type { ClickHouseClient } from '@clickmonk/db' import { resetDatabases, testCh, testPg } from '@clickmonk/db/testing' import type { FastifyInstance } from 'fastify' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { ADMIN_HOST, clockFrom, read, signedIn, testApp } from './testing.js' +/** A `log` option that captures each line rather than writing it, as `reports.test.ts` does. */ +const captured = (lines: string[]): { level: 'error'; stream: { write(line: string): void } } => ({ + level: 'error', + stream: { + write(line: string) { + lines.push(line) + }, + }, +}) + const pool = testPg() const ch = testCh() const clock = clockFrom(new Date('2026-09-24T12:00:00.000Z')) @@ -126,11 +137,22 @@ describe('GET /api/status', () => { it('says the IP data could not be read, without saying what the error was', async () => { writeFileSync(join(dir, 'manifest.json'), '{"v": 2}') - const r = await status(app) - expect(r.statusCode).toBe(200) - expect(r.json().ipData).toBeNull() - expect(r.json().ipDataProblem).toBe('the IP data manifest could not be read') - expect(r.body).not.toContain(dir) + const lines: string[] = [] + const logged = testApp(pool, clock, { ch, ipdataDir: dir, log: captured(lines) }) + try { + const r = await status(logged) + expect(r.statusCode).toBe(200) + expect(r.json().ipData).toBeNull() + expect(r.json().ipDataProblem).toBe('the IP data manifest could not be read') + expect(r.body).not.toContain(dir) + const manifestLines = lines.filter((l) => + l.includes('the ip data manifest could not be read'), + ) + expect(manifestLines.length).toBe(1) + expect((JSON.parse(manifestLines[0] as string) as { err?: unknown }).err).toBeDefined() + } finally { + await logged.close() + } }) it('treats a directory that is not there as no IP data', async () => { @@ -171,6 +193,28 @@ describe('GET /api/status', () => { } }) + // The catch that turns `chRows`' 503 into `reporting: "unavailable"` must + // narrow to that failure alone. A store that hands back a row + // `newestHourOrNull` cannot read — not a connection failure, a bug in the + // row mapping — must read as the internal error it is, not as reporting + // being down: the two say very different things to an operator, and only + // one of them is about this install's ClickHouse. + it('does not read a bug in reading the answer as reporting being unavailable', async () => { + const lines: string[] = [] + const badRow: ClickHouseClient = { + query: async () => ({ json: async () => [{ newest: 5 }] }), + } as unknown as ClickHouseClient + const broken = testApp(pool, clock, { ch: badRow, ipdataDir: dir, log: captured(lines) }) + try { + const r = await status(broken) + expect(r.statusCode).toBe(500) + expect(r.json()).toEqual({ error: 'internal', message: 'something went wrong' }) + expect(lines.filter((l) => l.includes('admin request failed')).length).toBe(1) + } finally { + await broken.close() + } + }) + // The count is capped exactly as the listing `GET /api/alerts` answers is: // one query past the cap, asked for and dropped, so an install with far // more broken domains than this still answers a small, fast number rather From c49afc76d33484b398958a7559a24adbb32332cf Mon Sep 17 00:00:00 2001 From: Cem Hurturk Date: Fri, 25 Sep 2026 11:20:26 +0000 Subject: [PATCH 06/56] ui: the interface package, its theme, and the gates it passes public/theme.js is added to Biome's ignore list: it is a deliberate ES5 classic script (runs before anything else, in any browser, ahead of the content security policy allowing no inline script at all), not code Biome's ordinary rules should reformat or flag for var/empty-catch. --- .gitignore | 5 + biome.json | 4 +- package.json | 2 +- packages/ui/index.html | 18 + packages/ui/package.json | 42 + packages/ui/playwright.config.ts | 13 + packages/ui/public/favicon.svg | 1 + packages/ui/public/theme.js | 8 + packages/ui/src/App.tsx | 3 + packages/ui/src/build-output.test.ts | 69 + packages/ui/src/fonts/NOTICE.md | 8 + packages/ui/src/fonts/OFL-source-code-pro.md | 93 + packages/ui/src/fonts/OFL-source-sans.md | 93 + packages/ui/src/fonts/OFL-source-serif.md | 93 + .../ui/src/fonts/source-code-pro-400.woff2 | Bin 0 -> 11656 bytes .../ui/src/fonts/source-code-pro-500.woff2 | Bin 0 -> 11604 bytes packages/ui/src/fonts/source-sans-3-400.woff2 | Bin 0 -> 15696 bytes packages/ui/src/fonts/source-sans-3-500.woff2 | Bin 0 -> 15652 bytes packages/ui/src/fonts/source-sans-3-600.woff2 | Bin 0 -> 15668 bytes packages/ui/src/fonts/source-sans-3-700.woff2 | Bin 0 -> 15596 bytes .../ui/src/fonts/source-serif-4-400.woff2 | Bin 0 -> 20088 bytes .../ui/src/fonts/source-serif-4-600.woff2 | Bin 0 -> 21532 bytes packages/ui/src/main.tsx | 8 + packages/ui/src/source-rules.test.ts | 80 + packages/ui/src/styles/theme.css | 106 + packages/ui/src/test-setup.ts | 44 + packages/ui/src/zone.test.ts | 9 + packages/ui/tsconfig.json | 16 + packages/ui/vite.config.ts | 17 + packages/ui/vitest.config.ts | 18 + pnpm-lock.yaml | 7032 +++++++++++++++-- tsconfig.json | 1 + vitest.config.ts | 6 +- 33 files changed, 7078 insertions(+), 711 deletions(-) create mode 100644 packages/ui/index.html create mode 100644 packages/ui/package.json create mode 100644 packages/ui/playwright.config.ts create mode 100644 packages/ui/public/favicon.svg create mode 100644 packages/ui/public/theme.js create mode 100644 packages/ui/src/App.tsx create mode 100644 packages/ui/src/build-output.test.ts create mode 100644 packages/ui/src/fonts/NOTICE.md create mode 100644 packages/ui/src/fonts/OFL-source-code-pro.md create mode 100644 packages/ui/src/fonts/OFL-source-sans.md create mode 100644 packages/ui/src/fonts/OFL-source-serif.md create mode 100644 packages/ui/src/fonts/source-code-pro-400.woff2 create mode 100644 packages/ui/src/fonts/source-code-pro-500.woff2 create mode 100644 packages/ui/src/fonts/source-sans-3-400.woff2 create mode 100644 packages/ui/src/fonts/source-sans-3-500.woff2 create mode 100644 packages/ui/src/fonts/source-sans-3-600.woff2 create mode 100644 packages/ui/src/fonts/source-sans-3-700.woff2 create mode 100644 packages/ui/src/fonts/source-serif-4-400.woff2 create mode 100644 packages/ui/src/fonts/source-serif-4-600.woff2 create mode 100644 packages/ui/src/main.tsx create mode 100644 packages/ui/src/source-rules.test.ts create mode 100644 packages/ui/src/styles/theme.css create mode 100644 packages/ui/src/test-setup.ts create mode 100644 packages/ui/src/zone.test.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vite.config.ts create mode 100644 packages/ui/vitest.config.ts diff --git a/.gitignore b/.gitignore index 58ffab3..d37e4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ docker-compose.override.yml # The TLS suite writes the local certificate authority root and the test DNS # zone here before it starts the stack. test/stack/tmp/ + +# tsc -b's own incremental build output for the interface package. Vite owns +# dist/ (already ignored above); this is a separate directory so the two +# builds never delete each other's output, and nothing reads it. +packages/ui/dist-ts/ diff --git a/biome.json b/biome.json index ac6d806..d08a287 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,9 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { "ignore": ["**/dist/**", "**/node_modules/**", "brand/**"] }, + "files": { + "ignore": ["**/dist/**", "**/node_modules/**", "brand/**", "packages/ui/public/theme.js"] + }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "linter": { "enabled": true, "rules": { "recommended": true } }, "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } } diff --git a/package.json b/package.json index c7d5254..0a12940 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "pnpm -r build", "build:image": "pnpm -r build:image", - "test": "vitest run", + "test": "vitest run && pnpm --filter @clickmonk/ui test", "test:watch": "vitest", "lint": "biome check .", "lint:fix": "biome check --write .", diff --git a/packages/ui/index.html b/packages/ui/index.html new file mode 100644 index 0000000..96e9f49 --- /dev/null +++ b/packages/ui/index.html @@ -0,0 +1,18 @@ + + + + + + + + ClickMonk + + + + +
+ + + diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..7920130 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,42 @@ +{ + "name": "@clickmonk/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "build:image": "vite build", + "dev": "vite", + "test": "vitest run" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.48.0", + "qrcode": "^1.5.4", + "radix-ui": "^1.6.7", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router": "^8.4.0", + "tailwind-merge": "^3.7.0" + }, + "devDependencies": { + "@clickmonk/core": "workspace:*", + "@clickmonk/ipdata": "workspace:*", + "@playwright/test": "^1.63.0", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^22.10.0", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.1.1", + "shadcn": "^3.5.0", + "tailwindcss": "^4.3.3", + "vite": "^8.3.1", + "vitest": "^4.1.11" + } +} diff --git a/packages/ui/playwright.config.ts b/packages/ui/playwright.config.ts new file mode 100644 index 0000000..92f5694 --- /dev/null +++ b/packages/ui/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test' + +// Placeholder until the browser suite is written: an empty test directory so +// `playwright test` has a valid config to run against from day one, rather +// than every later task inventing its own. +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { + baseURL: process.env.CSP_SERVER_URL ?? 'http://127.0.0.1:8788', + }, + reporter: [['list']], +}) diff --git a/packages/ui/public/favicon.svg b/packages/ui/public/favicon.svg new file mode 100644 index 0000000..3398ad6 --- /dev/null +++ b/packages/ui/public/favicon.svg @@ -0,0 +1 @@ +ClickMonk diff --git a/packages/ui/public/theme.js b/packages/ui/public/theme.js new file mode 100644 index 0000000..cf8abde --- /dev/null +++ b/packages/ui/public/theme.js @@ -0,0 +1,8 @@ +// Applies the stored theme before first paint, so a dark choice does not flash +// light for a frame. The key is the one ThemeToggle writes. Guarded, because a +// browser that refuses storage throws on access, and a theme is not worth a +// blank page. +try { + var t = localStorage.getItem('cm-theme') + if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t) +} catch (e) {} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx new file mode 100644 index 0000000..2cd5710 --- /dev/null +++ b/packages/ui/src/App.tsx @@ -0,0 +1,3 @@ +export function App() { + return
ClickMonk
+} diff --git a/packages/ui/src/build-output.test.ts b/packages/ui/src/build-output.test.ts new file mode 100644 index 0000000..b1cd0ad --- /dev/null +++ b/packages/ui/src/build-output.test.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' + +const DIST = join(import.meta.dirname, '..', 'dist') + +let html = '' + +beforeAll(() => { + if (!existsSync(join(DIST, 'index.html'))) + throw new Error( + 'run `pnpm --filter @clickmonk/ui build` first: this test reads the built output', + ) + html = readFileSync(join(DIST, 'index.html'), 'utf8') + const js = readdirSync(join(DIST, 'assets')).filter((f) => f.endsWith('.js')) + expect( + js, + 'one chunk: nothing is lazy-loaded, so an upgrade cannot leave a page asking for a chunk that is gone', + ).toHaveLength(1) +}) + +describe('the built page', () => { + it('carries no inline script, no style element and no style attribute', () => { + const scripts = [...html.matchAll(/]*>/g)].map((m) => m[0]) + expect(scripts.every((s) => /\bsrc=/.test(s))).toBe(true) + expect(scripts).toHaveLength(2) + expect(html).not.toMatch(/ { + expect(html.indexOf('/theme.js')).toBeGreaterThan(-1) + expect(html.indexOf('/theme.js')).toBeLessThan(html.indexOf('/assets/')) + }) + + it('applies a stored dark theme when the theme script runs', () => { + localStorage.setItem('cm-theme', 'dark') + new Function(readFileSync(join(DIST, 'theme.js'), 'utf8'))() + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + }) + + it('leaves the attribute off when nothing is stored, or something unknown is', () => { + localStorage.setItem('cm-theme', 'purple') + new Function(readFileSync(join(DIST, 'theme.js'), 'utf8'))() + expect(document.documentElement.hasAttribute('data-theme')).toBe(false) + }) + + // A module pulled into the bundle with a top-level side effect — a service + // package's crypto setup — throws the moment the bundle is evaluated, and the + // page is blank. Tree-shaking cannot prove a top-level call harmless, and a + // grep of the minified bundle cannot find it by name. So the real built + // bundle is evaluated here, and must render something. + it('evaluates without throwing, and renders', async () => { + const modules = import.meta.glob('../dist/assets/*.js') + const [load] = Object.values(modules) + if (!load) throw new Error('no built bundle') + document.body.innerHTML = '
' + const fetch = globalThis.fetch + globalThis.fetch = () => new Promise(() => {}) + try { + await load() + // React 19 schedules its first commit, so the tree is there a tick later. + await new Promise((r) => setTimeout(r, 0)) + expect(document.getElementById('root')?.children.length).toBeGreaterThan(0) + } finally { + globalThis.fetch = fetch + } + }) +}) diff --git a/packages/ui/src/fonts/NOTICE.md b/packages/ui/src/fonts/NOTICE.md new file mode 100644 index 0000000..009540f --- /dev/null +++ b/packages/ui/src/fonts/NOTICE.md @@ -0,0 +1,8 @@ +# Fonts + +Source Sans 3, Source Serif 4 and Source Code Pro, by Adobe, under the SIL Open +Font License 1.1 (the OFL files beside this one). Latin subsets. + +Served by the install itself rather than from a font service, so that an +operator's browser never asks a third party for anything while using the +interface. diff --git a/packages/ui/src/fonts/OFL-source-code-pro.md b/packages/ui/src/fonts/OFL-source-code-pro.md new file mode 100644 index 0000000..70288a8 --- /dev/null +++ b/packages/ui/src/fonts/OFL-source-code-pro.md @@ -0,0 +1,93 @@ +© 2023 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/ui/src/fonts/OFL-source-sans.md b/packages/ui/src/fonts/OFL-source-sans.md new file mode 100644 index 0000000..22c601b --- /dev/null +++ b/packages/ui/src/fonts/OFL-source-sans.md @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/ui/src/fonts/OFL-source-serif.md b/packages/ui/src/fonts/OFL-source-serif.md new file mode 100644 index 0000000..5871e1f --- /dev/null +++ b/packages/ui/src/fonts/OFL-source-serif.md @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/ui/src/fonts/source-code-pro-400.woff2 b/packages/ui/src/fonts/source-code-pro-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d1f2d84901c198496d42c130468a06721cdc9787 GIT binary patch literal 11656 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 CZCFG00A}vBm;^N1Rw>4b_b17 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�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 KeSM!!^6mjJc@zEs literal 0 HcmV?d00001 diff --git a/packages/ui/src/fonts/source-sans-3-600.woff2 b/packages/ui/src/fonts/source-sans-3-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..93511778f601f037194a86a011b356f298b4e035 GIT binary patch literal 15668 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 aqzmRLuoH$SetW;ZrJeO{{e>r+0RRA?c26?^ literal 0 HcmV?d00001 diff --git a/packages/ui/src/fonts/source-sans-3-700.woff2 b/packages/ui/src/fonts/source-sans-3-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3cb944ca31199f76bc2684bcd50a0ef935b2b8f0 GIT binary patch literal 15596 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 G0RR9FN-o#{ literal 0 HcmV?d00001 diff --git a/packages/ui/src/fonts/source-serif-4-400.woff2 b/packages/ui/src/fonts/source-serif-4-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e8c6f71d1b291f87020cea6c07cdb21d00fcd844 GIT binary patch literal 20088 zcmV)1K+V5*Pew8T0RR9108V%S5dZ)H0MV!b08SDB0RR9100000000000000000000 z0000PMjCV*fuT|ykpKo@KT}jeR9*mtS`i2efzM=trwt2*7ytn_0we>9ECe70h6)ED z3ZyrYN|9)4pPDnkog9&{lOo>V`&m8rNngvXH4szEkt1(rq#$tw3_8ctwtnVquq+tG zA)-=C7z-$v=vVBx_+Dpuoi#i^w;r`IaLJm*U)A5=_=|$A*{E$q3kWi*^e_?oP8;h2 z%;w5FqwY>mFO9sm`s5;o|L1LLXJmbi{Km;QyovI&yAvQyoc&xV zVr}~$5ueUb5urIH%iPE~E4ep-K;TJ0%SN;(a^41^jez!dNb;N!rQOQ8UtJ~<(w z5YxzmC(H0x1@LbpXEiV;+(TtArSL`bQ0kVZnOP?kR+Auc2#h=XH%zC;{VvB}^KC@5 zjzmNV%)icb{qpyJ&vEH|F5DFe_EXDSa9Fxb(wBUazD{?Fd?*DRg=F6~&r!&yV~6Yz z`ejNp6#(pW2wVi(nyKy9PBis(n9^zNG%^jt(rxQlpLhD@)2H$AmhO~V0IRGvfCAvN zB>S$cm9_n4_DxA?vy)O;dy+h3*?vx`o#h19&Vws}0Vs?Hpvj`(Z{(sX? z>D;+nB+G4%Tv4k?fCh#xU`lisZ}- z%Pas9GK3RyaX|$Np(<5SBaNU|T0@<5hWh9O*(l2Jq8MJS6a*#!`k5Wo#2@%DNgxoAe;u%-_x$8_r&Gh^=j!zg;9}+varIf(B02gC(b3U9 zNZ-qPD#Ou7xLU%17l~u+8YMOoNPXntRVt5EAvhLofj_T_6i0feix>Pl0}*~_nMOW~ zZT3!4LIgNK_8OqU-V1IQfKvewD1so(Sy%F*r1e_8xJC}t zNcsp!2k$OIP~E;tPeWaj3RFZ{nAR!6oT|)pY3AYElH2w2S+yIvRj?p!%3ywV8%8<0 z8jVjA3-3@Xp2w1W!Ly27jBq)RQ%QfuXU6q%Nd8vpy9XcuFQ;Xb@TB+qsDdB&fV0HX z;J-$3_*>(dY#irb7HzmCw%J=eQb6!LN<`O)F+7sk7SZ;uz(@pC_X3(VAicHybz8`A zS9wr-E_NUc3M5Q6?1&~e1Pt@l9p^SYqhxF&o@`6EIY;!!5o$*1;s!_uWQJ29e zvn8t87Uhnws5fvILRhqK2WT8F0o@F5n)>xhb0GF{)P;Ui11_#>Mf}ARG5rE~GAybR z|E)a_ETmF(s4|j%hf~si4Nq#ttPL5?)c?Wl7k(|oE$3rlT|9!UGJS3!W_ZPdC1ie>4z?uXf(7+)#GF2 zSAv;69f_-`H`T@bVR`5w%ciSaiDcVs0y(GGLZ$9iLB!WDh#vLe#b)~I5pHj1PVl{Pyr1y3~j5eB=*~nb=+}GhzOjlY<-36 zOrJ~JdIU8;?9D+NxR8~lS2zUw>L7%nKe)CALC0i2g1hO}=&%f?no6QLBue66J0{Y? zkc;XMvo5gl^J;YwrcS1T?@SmIK5xQsh@|+PN7sN~8GC zP;QQkKRz=1>U$45Im_<-JrVP`KW`;PkNYWdL(zevRf+1ZzSrw5>1y`9sI1M(#YIq9 z)OuQLO5yWu8d(Dw%FzW#Ei)Y3ht~BOwDi(d7&&kgo6)qSAfo&w*1s)mqxFpd{n7i4 zYud4u#)WkF1)_L(XJ-o!nJ9V@yCYfx8S=LxvB6^|^ zf-XvgmmBaL3>1Y8>Dd9Pb>5hU;N&*pNH& zafawFYi$05Rz91s6Hlu@1S1kS58&YdPsL~@U$IQE?rDe0uRC9;5}CkN)Rc7(t|G66 z#;YGAk|ncjx-`9h>T&fg0kG=>ZUUGokEu1nPqGGu32@`sxtn4=@u9( zWipQIEaahQW*h1X|6SjtZ>SFH^s5S%`FWDP(gBkT1ch`=e2Qzl8nDDt1c@a5)hb2u zWS@z4*)UQ)6?$eQrU(McNxHgv9?|-7B`=ZWwpV_7ly=q_^qbiRw;NSELlyM&_$Q**es*W*fsl4-k9@_g{0-9(Rz{FUM6Mg)T9fRS*Ayb=#Afo&RXKt>h_K&MQp$n6@tVcgI2qh93< z;%+$q8loVlWi-%4Q%y77T=Oim+zKnLvf38=9B|MPM-hJHPSQXJ$M~p3z%)W?sAmSa2q>lPhKP1?^?@umy5Td2T5kaF__x4 zHGFu|WdT4Dz@QJHfcoAvsdsP)kkTMK)N$2|3@4cf+RacEtSTq;itoK|&*%FVt zI9`uQ!2%QwcR(z1za?e)zevHRUHeMXRAtPsc9@mCMR(Ixz~4+SYWTV1#mG0B`t%+q z8kqz*(xYRSc2{IRHvTOwDq9{N$pvt*7G7z8^=cP*;q=|n;fDuN!>Tk))5h0n=(*cB znz?Wn=RvxtXb^I;<>~+Q+Fh#H<1+x!U8#1F^oQPbYa5{{e7qAt^~-q|#6-6#=fR*P zG6>BhZUE!@h()I+nCi|fl9D)R=BAiwwmIgjw$6GR8iyX}h2Bx3MMwCMk&$w8qSFN>T@ggOBFRIrGBqEox<(S~ zBF_3qVnfE|TMZ~(+5s@{9WeRs01MawrZDE$iw?Mh9o*zxDv)ol79;o8FV#f%a)IqE z!PDJx`|tJ`1Vv+WTP8z8cQ!{}qajRUNybM}T{@f2Y1_$t`v?oR{vjbQL}@ZBp$8P` zy0v7qa6ytRuY23Rz#Uy)mYXzovOT)v4lk!S_E@8vsIXwaf?Hf!)N)Sx&4?7$+4C&# zamu{4R4za0U0sOKDZWpb=A$=G5{f`_)^iVJ;eDC@u4E*#L5d;kjfT~$tb{?$)L>Vc zjvtOaJ>G&Vik3|q?e^&v5QM9$e8<}0r9P#BX6nV+=f;&-{`keon9(9!0GRxAS35MV(nL(N7)}57F z&IML?d-~gKlkf;Gb1i_ZDj`>OhfCbNCd8@MfBBDc)JQDhb~fXN=`dcE{o!4N>{E>pX$JD;E_)L?81>=t##Tm8a-Sl&lQ1hR`n)1scOo0HqLuNTMX= z>T0c@!M59Br(Jg2lMfMqPNrXku>7C^5=t^5cmhf!S`tP%R$aW-DE&|dqio07L24(? zE>gR3_LP=Uns`tzi%wp1u+x6>lTr#|v@$dW?mnSB8e?~VuSKX%Z!$>Na)(WSNXkxn zILz)#aID<@t;1i4*(l{8pN#7mjxtoN=5@0Pgx%#X*px)d-;JUpWb#R85%h3!>u~je ziOGc7nglSjM~*Z1_o?5O)=|w?Lce?X=0qfUv88TJ^R zhi)^Abxhw$dPrRWBEm#mzMbnd9?oE~4+aE(iqEPUGa38Sg!IW_;6w^#ydy$`{aSV< zKPV2U;sJx!u%lhOSF19ep;XTnh=yei;>GKh7TDRY4g_5RE1f;+R;Snb_8UwlLuI_n zwaMkmQdvJ#i_OhjR8@)~FZfgo9rFTYxjht1ioOM`yT!NrySUaN>P(`{j7CJjy(n*whDCMK&jO7&Yt zj~2!>V+j|w9$G%~_nH^PhfA7f+kk6rSy7Y>gFNlbL^s>v4t9L@ZKSqd!L=|u!Gsi} zX)2o(e|WwBZZrLFdcKj)+1Q6_xOL}~iBvJdTNO{LXC0GE4xz)>!cQA$S9^Cg&A*Hi z(<;|#@S+>ZCn38ZVJsWAQqc`K1q9oP*Od?{GDF5uMydA`l%-2eVYr|vio)0A5at>kK>vjSc9 zQlXE&{@33Cbqz8|eM1b@z($)j^3&fYLRf|(5{QD3uaS|()iN?v|MPv-Kuf0fA8)}J zHFW;ra(;OJOnChNw|Mtl1Sfz3D+uRb#)pO^cO^r~QKf81N8a%0%zW}E;0J$%!as6M z0{XC8j)l*86D8zhrGS@q4Y3j>aEKNzo>>fSfc=rRvSb7IPypTpi56jp5=T+b0QJDV zZPJtqq{5~`R_<3K0$hnmCsht`3Kr#5jsxJa?+DyUx?<&${NZFkDNa$LiFu$GK;fdR zn(HB)!$eA%apZ3%k;i6F{X_*eGwMHw!f{m2o7xerrZMX_?z*|2stUVy=*%}{S2YXFfD3@1Z&x5?}| zRE`#GHfPXIAjuJKw)H3 zW+-*{5?O~hhhAi&nIuC!fpD_7d9g1XvnF1Myr1%I0WU#bx7)$p;Qj|5YVD6Y*W)ht zgxfqj%ODQr?y4N=1oLJ-=`I^QOFqw~))O(&wL?LO>39u)Z{`V=0ogJm#3V^I`}9E< z)kB3Fj$ZO*H{)lF$K5?#hBm>Q2lcpUgbY}a-V}P> zw&3mkm?qqVG}hC8j)NW#!H-*GlUxElLp>2R8xv=13}l+JSzm7^n-BFo=siaUosi-G zziUKTxA(r+)7|e)uV*}6Kk}pQcc+_O>v9)9;63vk(@c;EMX)^&!g<}|T{+Bu`51nO zH{{3kQB>aGBl$NzyIE?e|KA08OdURFMxJQW)^)XcX-Av57B~Oy==yzi)YOFjRjI0^ zaG|_qW=@|vX;AE_5s*Uyzgz69=N@|OuInDV=(MBu+iA0ED=ad{G}lcq$`JkZ(8*m# zU91Ic(E1koJ6XAK)z>He_d71P#>_oFu4^IJQByR;KHSoeUr+7WKzdXCfWoFS~zGH^UgQT$8={=F~@GV+6+^Mg+V{{`1?^0IyRnE|q;pmXU^v|3kdb>_L#_(pgu} z_m)#$35(`0KnRf)0{ei|uqgI*v#1;Ccs_n|XFJ&LKIlNZ9_ISTmG+fKhWi$4AGoF$yLcuvcW%S(4nUSVyJUSF#T91_fJQN+woT8MfF^^xthnBqY14P0gf7*A z+2Jv_+y++p7B_VVCFAx3<|cBX4ih2>YS+y%Wz}=WVw@+?|h62Wn}V%Pm2a zh`XS{2OzBacb`c=pP;YLVBl6-Ls5p+kkW55*e@7F3}!o8=q+IJ2sGRT;9G0`qtl6I z^&YW)?7XlDGoP?<(=E5%ao0WfJ@C*Yk3I3!Gta$15D0dw%f3KQHE$3ZO4bU?{ zB*rej|0}P(@zy)LiiFnH^JD{&0EcOM5Xk&= zuH4*+P~&ygr#D8jXoM66(cC8qdnv)uRjh5SQ59qOP@UqljH9zJKkUhn zSB9j?Q>IRvE@S4b*~`qCJ1+|1&jz`3!;>JYD3L|Bu+RIQh>#+JfK7fcRT|`HsM;Vu zaxf#45HTufAgU?g45>`YONDF#)kY2MMc8^+Lth=)?@ga1S&CG_AsI4d$(Ez6TzTlD zztK7QmKx@by6;e+5D{%OH3e^cSzKji>n=Qt(`Pzf2qMi{a^=pl=n%x|rr;qPPPlgH z@R=MA^Y$XtLcY-jf<-^Jw^5n^pB}?K9anK3SnGhS|K|)9hWk6QQTd=Eef?!4A>Y{!j?Umq=O@dVa zYpAyc_B-vq*V&8CVZQx9mE$>VIRj!soU!zf<=O{>b@G2ThspJ)(*pkg`=dLK0!MBD zN2mR7Ki}!hg=a44i~#)T1D%(4zUaKz+26VI^p4YSI{n5||2_35;*98+iAaDQEe;T zZu{(s1}!WO3H*!O;DAGgI^w4T(QR}zjlZQi?M&)?4%?E@DZd;Hhdjz)i8SdlWGa^> zTaH|L@*_aO+mZea*t(hKdUJbQ?X=fH$B590t8TiJ>#0vH)cHYZyimiBBy~;sNw0-) zEfLV&0rMfy|AC7y2jEJt0NDE;*gXQ^lc#neSrXo9jREjJec%!HM;nS&^)>V@g~7;a zoWM;d{_3>-ch}CtZ2WhZvXK}nf?8V|c7rsKs34Yo5g|s%MfIGdVA2EFp|(aT8d#>rxRPiIV6lU84kI_m;KY)Gio29vj}A|n%#2n=M_ z@0_fVXK&AX_v5#~E=W-`oNi3259R;1lFtGb@FY%?gW#xvCH>(X?^S3Q@A3zX!_i1^ zQG?bwEPvT@5&tcvB83c#N=Rkrk{k1#T&2Yy4o=x@Gz2SY49cp*(%XNeRYfqoXY)yX zpA16(hQ(4IHXuo5GQ*xfikZ_U0t9DpOTbzQZ7C*f2F{got!5b3>&pUAt7P?%RQ5?~ zxz$w(I~6j26%#-pBv3w~Hi9ilCfekcHSVRyB*m=A(2I#nu zCY3Iis0cf0C!n7#un_Fx1V+#LJtKr=^}Ge^zPaPDWtg&O`V$1&q_I2#XP0RzI8``o zFd9}tp4S4(tAGh)_vp5S8PCg4M|Jh~q-uezg&;8HNNy2fQ)&!E;t}KLv+*~nZU6e| zz46YpYO)FY2nF6Wn!5?6{Bx8b52u*~Ge`0*0L+;V$JX=nkymORZ0iWJ78;zn*0|16Qj4=+zFAM14(rvw|Q zP!TALC;&XE#RCj1$8@=a<)z2k=^lk(n^DnY-g{Pg08EC`#X%r%tDZYg=$4vC>NKv; z@hgpOlfn_xooT;*=LA(IYBA7SmvYm6xgvNLVC~;dvWre$$)Wpi;?K8$Hx3Ep3Yjfl zw5>HhmX^L<0A{s8MwK%z=E)z|=^si;-YltjcF2ovw=)$#;Z&(H)G@WS1r_I_)(&aZ zIj79E!>hb0Zl%)LFGZ0r73nk;i?mM>1c6zn)=A$J;*C)=|R2g+8aVc1ckp0`_tKEIfZA)}<@~`zlRn z?*21ow2_>1Ev?fTxL5W$cY&ZgmAj%Cm=tU zi}D}IT21{&+527WBI`%vV^I|F4BYMu0pi*Bd`*=DE-o> zv~AJ4&d4P{9yP6e7y>rebt10A7R(TCtn=)V^=47Rbn-w`+NOfb{1b|-8I2=~96I%S zW7ta-er1~DDG$KmJTr!z_tW}k? zqu)#5K<&U+0X+|;&x@k4)X&MPvscqge^TR|; zBbzgw*2Yq1VM4y}Q4K-D0L5D&|*b zw{bBKZFuD~LaF@9e`u~u#X{By2T?XL_0j3_q-7%pQW%bYb>UgvQU8}Drj&<_poxqT_Tvv;d#RKJ{vT7pj#UOGlAR4 zzMA5qCr;CL6OjUSsyy&cjgdp7tA`e|J&xO+7!r@R$4;QLzOZ{WVm)#h3YjR*K+8Z1 z;@4wS8fWvFPaioSBi`}9raH4ikS?3Ca)s=_PlCua<(s$s$;*dCDV&t=NPi%k7l&+` z{NL;xskrB_1TC5?P2FxfQ|Ckom+xxDNCt>P0hSigJ{zH@tai?Q7@7;$y6}fXok1u_ z05`S=9T$VM&Vwcn@28;VEH9XNC4zWY>h{1xw~ZcfWTKRL?TK~vO313QO`#%j1?#@u zGHj1M=U7IYAan`C13B1C{XSp^32B{5pNh&(Ug=|n%R%@r4(AwQH>zSo#aSXmE@cxj z>uD8ZZjiu0QkcgzRx~&oti?QV=kBHI#q&NEk>?L-p&jAO70xRQA$JfBxV+1A&G&N- zuMUUJKqQEDh>3R2*(>hVE6EeQVW&qjw$HxRR@nMnZmHzqkXXwXcHznyR>NI8m4F3& zZ*#BJw-Rmey&xk?YqP^?=-TYo-PXz}<-753>WTMls#iz6O9owXxdKL&F}6KMP^&Vl zo&9OggalYz11C?Ys_d>ugM}fk+)-Am_TQchNpvqfj0{-{2To%*iJG90)1vBEIq%sE z7F)$+nAkKe9At!xk|&k=Pe8bOz%4QPtKj9NPZJfA2ziHAIV&3_$l>qs)>U#h$=6S7 zW4gUqIN-Sj7eMnU$a;MoUZ_>MoFF}0;svVh1g_@q`Y^$G%m0CY<=^9XP4w@`^{C6I zdQ24E6kSGbTnR@(;N+cDp4yoQMQ=|LJmcJ#v5eh+a_=QwpBJvfnpwxxvk3sCd)N-R z&&^ln>3?fT&%cr`-_pHuBRM8=2H8DfTe0GP-$|1xdkC@p zy=#7A%|O1^cA%mc98rAwn+hyQtq1l@oBhS9V#@MHC~QJ9K{f|yLyDTvDgyCY0_>tz zf`E{g1hYC*%zbueS{6P$L`}pg=w2ALz~<^*tu|3jvhpS(j|b9r-&UuOVhKcXUXJgJpF zjazWI*u852?*t#`(r&%0bM7b8WAKtqRxf!Ynd7Vxte){9NQ^m4_3ys7|7f*_GUJP} zs04y~V>4P0uS5J9s|qqwAj1r=@oeXr9S~HF zx%$8X3wh$MkT;=^4&=doG-ldy{R9}VDtL8V6oaHBC8geMlg0|@NmcGW|`T0&zBu$DjStkDJ|ByV-aMZ zHu~lrQ>TH)YCYR5Ze!U7Fzm@>yOZ-9w#u2Dri87aLp9Nl*c<2By9D}*anZYQyN0;ng}C`nnR7&3I#rqM$JdOPlfg_ z^QD$I`ph?I)epR#Q@(P31N0Yx$_+8XyX%9y79`@Qa8)u~kzE^qCVS$(<{FgCK5OL{ zC*QXRi<`yeldKppXHzareeRrtC}=p7$qh3->Z0{gnrJ?e=L+S7(8uffMX{p(@5Gg(}Lf@x`WDc}?!O8I)6Dc%s^(hH9QKFh6 zwJE$&9W~c?AVg%frkS?X)f=O#x|X>}I)o#0Feb`LSIN$#Ev)_eVj?itI%nhHtP>wJ zm+gb_nBOKaW8V+N=e_X!08Ys>QwI_XvaG%F4<2|YaRr5tX;SXG=`Mjy2+LC#rsjx` zm-@VuDBoT(f_y=Od6=Yp#BcpEu~=4~@7l(QRlQpq2~1#^vd{l0sl@`4t%Lu;riE^Y z1yTnu$$i&`Yg8PuOdfYl|J6&yRu8u07C|G|a|1%5N+HxjGWm1i*R~5(Mabb6Q)i6) z)OM6!O7G4DNxbOs_g+v2{-+aQa>qX<;s)bcEm%+0`t4% zKwYuTtFB*sDmyY3R$af{9iPx`*`YG_QuOelUXpP4On3iO$j zEVVYSMum3-C7Ss-OKPl0{wlxz(*brn3>jTiEH*x*dasjiR=tl)YFJo2fbprp6uk6T zgKJoY*u_JBzlJvrI}T~{?7sp>Ys?a}w~qWY0`MghG4x)H;ozf9MNc-hex3*D6Sh4x zTr#K((MyK87|v{r0Z0uqFnGHnA=ZTI&{$J^QwC^kLKWs#5JMHF?)oZ^(_QIlm~ME@ z9Aq1nDv^(lPBJbIG)pak44vLpDZYitjc*Z~J$kv&>($57eUfH$ksS|~6Y>VN)fE}D zB4NX<+Nwcat~th-lrWT)zTAz;tS;pt{vNkyu(md9cBebrT*IxjyD{lQSqZBPTUk7H z)M`?#tukJRnT6dR>&&tRvzGQOGOlfWGg!Q`y{%&H#)8&~YvhJ`>&iNjrjgN9+)^gX z#M#g$2lmhstMQqUUO{PPQeuY3n_owV?D*oz&^xmumYLYm{>Ls@j4@oe4(ZLtmk$=! zP+EAMd$IjJpT(9D@@669EA+F%1?XUH)ofiJoSQnofvKh#_&7t=s`PyUN@p9BJDps zk1%~Tw`_Q1VCP|jFCCuu!G)QIDHz>!jNx-E=;$&ROw3SIJE*+Qz6hO;1=4ZAYY(>V z3t@b&jopkUpsiR=Qq!9b3ZW$d{h${wNWN~gZ3SHfcOP6A1-6#&?$2dw@=VNMta%>KgSPcan?BT=F_$j3F_ragwR`&Ep`=Rr=91h{`$4?%Mc7p*zey=5KQhCP~c`C*XrC~*Fp2}d!gXIm8dA6Zi$Mi1y z?tWB@&cfjZJ~GV2$ObS5X~7#1-@o9dzkYbBD zT$8f8FFkA$S>DM0E?yO&lkdRAcf_o#Z@fk<&pXUuE`+{p4;G?10(r>!1HXcbmrwfj zmz_Wc7Z6y6M8@6{f^xNBbKK1M^L`}>*RUl~+;~(cN_6(+qH5_iQL(!|RA)^$RF5S% z#RWeslv*mRXm>PXoK)=q+|*F3!`zh@&YINj@|ePqvEcAs1~06T-4~a5J)+|Ka@eDbCU}JZ zOlz8vnA}1V`{Q5UNl>m`;*Sd`iiZI=HK)aH?#j!zO>Xvh9p$jT)RE>@y{5(ZKO)OE z$;HyYBxTtSc}Ym4H~6K;?}rP6=|G0G;M9|G$dQC>5D?ug(RLBv23|@?zV0JGLn^IU z6n~S$^cBo_o_OJwZT&{ZrUcKp-zI`Uc7pPtb+;A#_08oqe1@2F_#&@Sv;HKNedbfd zoWtoz7u?SnB>wab$$vq}+dGdg+qQzrQpjhUf>1~%s_`Lp>DD20i3|y$D3VhmH%ks4 z`=s`l|J)g!J4~?*db(O7p8J>&Jy|Ul&-;Gl43{-NbF37|kS<($D-PL-4}m;^jg|ne z`nC!3L6A3$wBtCTtF(&;f_cKatl2O-Gyj7(c57%P+?j6-4Ca(S z+D1D19zB4A$%m2ukSVDXPMld<6_RLC7XzghK=UQr&I7@G0m@b<(+d;SgSz+dt;o*n z9;=WJJ@G}l%zBswXKw9h+6zLyIN+TJ$PM9}T#^Y{-7u5q_+Txwpyn@$&tF(NnT_&X zYbEZYzZKAX)cY;W+-eur&Gs!0K&e?B7Ee!Mp{K9ImNhz9y&i9G+FS5{f^x4hgsOa5nz zV`77Jj?(3Ucx|Yg?eLyoqpSjUj(NP8;(t5oDMQi0Qbi9^mHxv4xrMk=t} zYAka2B1yss^yhXTMA~t(798^1FF8HXm(v=W_-OmB&#q%jpbmx_Fa zRHrgP>#zJ1!+ej4#nOuoUki;M)QYU9A9Xhu=q4ZdPSg0#2j!lXFwpj= zKrd3^t8h4#2Lwvnu)@`mv~YrO*$V7Ci~@mQKv*l!q1*Yrugy6m3R7v1?1~41*ZF{Y=-e<;u`=TR3W|6ma?pD&7>EQ2EmDa>gAhogEmiX7N46MIj`w`n>h(uwt zISOkNR*TKXYBEH>9#fRvbW=!{#^oVX9~CfMU{7y0MM^a`)yUVrKW@wjOo1yZ^s}N7 z{a{7q6eOU$@#pcc)~jr#8dEsE*}Etq)?(BwDkL+vf&O9Px*yY8nQ1Y@V^CrI#aObR zHAQ6U!H&vwcFdvq7897`sMJ?hcOHk{>tk;j`)UhwvfL3*RgYnM`DC>%YL#()9z#1X zyzMP*Z52cH`sQb>t!@bxHKKE|2kH#-Frvj612}@WN8C=yZ+_M2+GYFSS6Ffx588)c z3`qZyaz@9_TATXYLuV(%<+M61-8oh8#LGg~p(#dUMc#P^^L1#!aIw(fC@|xkq6PI1 zi=$M7l)2LMihga9=PX%1lp)EUm%gtiDw|DMV(wmw2trGQYAX3RDrvua`V5bWfu|B*k z_C($3HhPE}Cx&)|A*_3qfQ0{VeKBj>i+vU=qJBnPu&^Sl!YZ{PTDL|}g&b9GIJ%Ic z7K_PL=JBrmAz=^Y@Rat_=U|!Bf)u7ewuLPMYPavwq5HW7OvJ*8SdxIh4!W z4F8Kr?*H?=g39mV@kldHYRyUlt%f_kw94u=+%QWWZOFFq?7r@T<6l##+_nq-_93A{ z=FAmaRUYE&akJ3$nb40MmY`3>q&^E%6K*dRvQzIO=O8g>>OC=(hJIyPs>2&Vs}WFB z;VAE4aiu-%Y>Sz#>hJr8gaoH%f;(Ihc&|4AO)akM(l4v>2wO69N@NM}54^H02NXG{ z)*Rs%XJ+z?zVJM8`8WDO+dTnrx&%9lli4NvGNK6sd_pF1%}wdLi$<v_t~on>lPg<^h1F9(PPRddqJJW9l++MYQ zh{Yhj?=AD(fHSjaD6R`=UeoWhvC)NnP1MX#adSTLQn!yErgv~o## zg&bWJs}gBdt{edpDDvAZdEsJF+Si4ikWTrpR4!1`w(TfOqnAycGCkAn4oIdgO(kc{ z1vqpy`Yl5}y_VIDL6@r`U>)l1v94~&b|U<5cwS;_ZZ5Hn=lh1wf8O!5<%z9&HJ*c8 z^>3v**QV+ps6k1oT0M`X|H8Z_?&?l8>|cyjKuLAS{I-?7y|&e{pxZNXgFEWAu9}$b zUgsyikMH{l^4S>ceT7rMD%H3g%l#>}6qKknGYI-KhQ7EfyC(lk+XHiQgL<#042sAc zzJP;;d98+4SV|foGZ$9Ru(-=bCTFD#$unsr(URJb%Y_MS{+iWP0-hIqgrC{YGK$P zQN@Q?D_WyPS^dPCcNex*KiQsZ}|K`LUKY zzFORxVoJX!r%KrEjzD#LB=Si-zs~FST4dSGXSYO;i(s=Qs#BNQ5n%&Jc9@j#*0091-=UjW$^OGK?2*gA-6!r7v${9Lw68+ zzoXK3`R5i2{6D;aYZ<o#TYgib7`P?Bmp$H7@iwiKyzZ)1DlAr6%4-pdl6enO6;JUJy~?M?MS>4L$8 zQ!s4saimh{O8j?e>|Pm2942jUbTudFy!(y&8&I%*UvlLyuf6WQ8uw;5xKaFZcr`iA zLnardk=E3H{NDEX*T#}QF9;7Oj@gOa<@0`+y?EcLvk!9jFP=Z|knqsF*WTHed;QwU zV(A_5t7MNPI@5jfQ z(FfsoVLb1mH%()L^p<@#IvES55P~z{@s)M?A2>W zE`CqsH=vMji;VJi-K*Xi9N)Ni56Js~R<{91)TBK(e;dvP`Q1X@OWl8t|wbDG~8pEzJNa4EuU{wWaA@>CCD6{{EyedLBjyp;O0 zkIR^x-mlqVSfdtkd)DYEs-wzF!arU=C^axSSIxYfIm>ArZWE)#TIL&=>Wvi4b=G#G z%feAze@Q{7)S-=#zmMNKfr^G^sq4y3^UL!U3-h{LBZKPD#Gy!db)#Dx%L?R(KuP`U z`uf*95|4zB+`Moka^%}<=hA><8&y7ye0+A0!al7^?HzxF1+(QfUidxMPsBX8hnV{l zivdWv9yciufQxoXCMKf|$&zj5`N{G(B>K03ZkMsPRJ&EklI@U@FWz2vuEm^25`=2P z28+j^iSV}Xjvw!SK)js|MmO4g+Qpe#0=Y0}=e1Lp(zO4}B@a=qf~oybEa{UISKgwp z4iu7zJZqkLt|%+VsDVA^u)qZZ8K|T$I~{y8IhFe(c`R>gN!tu+y3McqjA-8i4s5i0 zbxm3wiR=$;y_PuEt9+qSeW{_1UD`nS5{p`F1uRd@>6AW%7JW zR%XZ4U`bNgH?G7cD>4`u?bZx8T{!Q_2**dNW%P|9%soVd$BTkyGN*o zeKL8z&7IXU$pZ$eU`mmqSi8n!3oR)r3oVZ78hB=|eZb5rtyxe4&-Yd@E-P6un(KV6 zqKHxq@;qo3%7^h4iHaCT(1`k7bRTU;ZrR7Z;(6y2FltF(+qdlWDo&p{6 z@leGC{eCn+VH{!N%NE#GB;g?ifHw&YX{2G*a}n|Kox}4LrDU>>+n<+VvE9vDNjf$z z`TfEVH@rBO$#NAE;frnWT*E2(C%|4xWnN=askNuymc0wkj%adZ+FX-ao0}_FtH+i( zW)qy3@k35a2-b#LIh;j7z3Uc%-Csx(W%5L+BvCF)3`h=G<#O8riS&R?F1H>4ox91$ zbPMprA|ifD8fm8B+i^VJxRV0G$&exSLYGdR9g?dxP0!XL#;Z|BxweoWrA^tedx?Yv zf)vd9gs{-3KMjL3f5i3^2*pJF0tEb5SV}~wGbh|b!MCPl6C*AB}l@legYs?99n?^fWu2hI}ZoE2D`) zYJXHr3E41zb^$$CU#KtVW*UBHI3;}Q9F8t`)0TFkYk zK&3<*?#M@l82!ik5T@gZZk}%L)USsKu%`xF3aqP|n(b>kqB)44|8bLha~h%YQ7I+r z6K+eO7;@aQS=Ck2fsorod3WB6{z%#1t%S5*XVdv0zkH^yN7ub9Ts$y`Ww|n<1P{;u z=KAgYiR+H*;E(Au>u}rR#Ydh%9r*Z7gOp>OJ}$49!|mm9`#1|0w68Z3V{LH8%^zhaP2)7;nkuH^ztK332Z)d}tqD7_@o%1?fOe**ZPEqlKIhb84C zQXz?WTtDeQaBlvUFTT5bX(f@=OCem<2_{5!!22xr-z4F2BDs)6DqHf?w?BcY>dkAT zo7bM3vs*D|H;B7dJ-e@MXG3&H!}K|QU{4{|t#c~bhJU&LQfV6(yoq%pZYA6IFMb@A zdSD%R|L2{0mxdM5jFZtA7uL7#(7Ds<;c#Vi`p?V3^cwfyZm{UZB}@+}edj5u`BgyW zNQ+^C&igE#G6(lYG&%G7G!WqS%MFyL-(s?UNoW7UV%~aFbq|K`snmD1n)~2VV3Sh0 zNv>F-Qm&lOJWi+YV==y>)4v9s5<-PMB|;|)FQki)TNuC3e#6E-0_Q^-odM)2nA2X& zX+V~)*t(iR^L?7^aVCw;lnVgmT+h%ucw%@o+3igHcZyI7n(a)8#nLiad=`tOU1+R( ze-)Y3Ku)RqW@llbrHe&knS{A(f$m#O$ip1$H5Vo+r*pq6@2B+yNAYfr5UrVEHx_UkxW=NOJ?9|QZoUy$&T9Wt(j&ABBl6uMbvoIJqH+@c* zVs=;C&e~{c;*IsAvj}7lk#KJ*IQ<5`h9~!uNPU|$(MRzU*RKJy&N<6>)JA3JWL=)| zCxKWJqk;m)(EDnggP!WDV9(gmoFo#==j*& z_*;jQrS&_~Qhl8(Wn9tQajS_pI$VHc)~#8zmXtP$Li}K3!894yT)Y_vFvnmKSUJ2bNH80}+(S!!iyNs0SF((peVJpxDOPx} zTknG_%oVQbz?H6*WEvm*|AW+O=|daaRHq#ltoBuV8#%@L;n)%)Q92`rH-V#{n_F(L zWnlJwo1GPm+PAwHCW4b4jvl0(vgkBAgU+IJ=sdcBE}~25GP;7UqHE|nyn$2uVU_bh zr_mX77M(-q(FJr7T|$@96?7F{L)YPrFa~}D?D`W#Cim3K>{l)c`vS+@9FvFss!MlA zs@$d>z?*t=rSLEZ8=XyfGzn0I>b@|Ns8$KYwuVqaFIcCU1Y`|5m`Qm)4)=`A2Tu0S>@PU_P^TPEa)(k7_e5 zWm^-bZT2xkdZ0IsLDf>NXU9Lyk!?Jqr~3(@1dOn8{WU+VFr-n$X;5`Be6=*x^*BIv z8EoYT<~^s(x_%z&yaF0S*U)9^pRFcztM7;RYY*oi`rPme-SQ#LHR-)CH?KhTNVoLx zU2@X|=SeB3U&r+dXbfHRT^7)}$h<;h`@M`_Cr$FaVq#_y^z)e_a{by})K{#ZdNuE2 z!7=K4GlCg%->U2Yofsl0QMZ1HF~xWAW6xODRo2_qd1SEsmE}r5V8$++YDq||HjcFF zCpWgQ(z#9Dc{}G06Vm2DZ71%s9<)=h>4(s&H?5?`;ca^09}oQXRK;Q7UelP@fo|gQ zWEaMorwr>MPv$gTNHe@NVf|5gTev~DZA3Weeh`a=q8qE$zty%Lb#ZSqp{Z;ek+|nAYeQwJeQ}N`V z_W}1euQ8FCW(;@y_0Kh$sj6!(%bVJKvq`zT`fYo(Jp%R-fWYt5PXA+(tWjq|)l2T~GfS~IV7|>` z#xl+twz(DSnatDBiWk{7n6dtP#(FSEZPRkSZ9BsThWz3U?F~i+@_#h_{O5Pje9Cis z!*dpp&1`-Xq8u*kw9-y8Ha4;j?G@Hd7!BPF(iSA7UBeS?EA4jJmN`v4zL(m;JmsvN zW$IhDV*I9006nvOODCUrJ`+Osl2*$gn{2zizEazi8P{0aaRXtWR@)#X+j(#M;oEk4KBIo zbi%rq*P?_rnJdt0lcAWXd+RF3q|>4KD9-WesmLi6IAeHyA!K7wZf=IbLtrxqfE(ek z1QlR{2=jm6`T*S6Uwl90`$6QPB)1u35B1ZuoTf}$8B@RE4h9^k!v+fx5FT3stXg-r z%>~;h2-|#az+S#{P}CoO4Rs7R1DD(wwv9$#6JO!>;R*HtHtHC3i5xvd$8NTr%; zZtvMfL!DIm_`1DzG_g%&W8;vh1*8jmuXGdIMbySFMxvtFT2q2FqpF9dKop!VhVx7t zyNu&CrB8Gc&0&A)hfb<=ZbcH6^mh(FrLGj|h57U$vg4xIt5i+4LZgVfr_Q<{95PuaO literal 0 HcmV?d00001 diff --git a/packages/ui/src/fonts/source-serif-4-600.woff2 b/packages/ui/src/fonts/source-serif-4-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fcbd11d2bfdeb4153c7cc2fbc9e27b8c1c268aba GIT binary patch literal 21532 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 diff --git a/packages/ui/src/source-rules.test.ts b/packages/ui/src/source-rules.test.ts new file mode 100644 index 0000000..68e8fd2 --- /dev/null +++ b/packages/ui/src/source-rules.test.ts @@ -0,0 +1,80 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SRC = join(import.meta.dirname) + +function files(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name) + if (statSync(p).isDirectory()) return name === 'fonts' ? [] : files(p) + return /\.(tsx?|css)$/.test(name) ? [p] : [] + }) +} + +const all = files(SRC).filter((p) => !p.endsWith('source-rules.test.ts')) +const shipped = all.filter((p) => !/\.test\.tsx?$/.test(p) && !p.endsWith('test-setup.ts')) + +/** Every match of a pattern in a set of files, as `path:line: text`. */ +function find(set: string[], pattern: RegExp): string[] { + const out: string[] = [] + for (const p of set) { + readFileSync(p, 'utf8') + .split('\n') + .forEach((line, i) => { + if (pattern.test(line)) out.push(`${relative(SRC, p)}:${i + 1}: ${line.trim()}`) + }) + } + return out +} + +describe('the source', () => { + it('states no colour of its own: every colour is a role from the brand tokens', () => { + const literal = /#[0-9a-fA-F]{3,8}\b|\b(rgb|rgba|hsl|hsla|oklch|oklab)\(/ + const palette = + /\b(text|bg|border|ring|fill|stroke|outline|from|to|via|decoration|shadow|accent|caret)-(white|black|(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3})\b/ + const dark = /\bdark:/ + const found = [...find(shipped, literal), ...find(shipped, palette), ...find(shipped, dark)] + expect( + found, + 'A colour in source bypasses the measured brand roles: a destructive button with text-white passes in light and fails in dark. Use a semantic class (bg-primary, text-destructive…) or var(--color-…).', + ).toEqual([]) + }) + + it('puts no HTML from a string into the page, and evaluates no string', () => { + const found = find(shipped, /dangerouslySetInnerHTML|\beval\(|new Function\(/) + expect( + found, + 'Text from a response is rendered as text. Nothing is parsed as HTML or run as code.', + ).toEqual([]) + }) + + it('imports nothing from the service’s packages at runtime', () => { + const found = find(shipped, /from '@clickmonk\/(core|ipdata|db|admin|worker)/).filter( + (l) => !/import type/.test(l), + ) + expect( + found, + 'The service packages pull in Node modules, and a bundle that evaluates one at load is a blank page. Restate the value in api/vocabulary.ts; its test compares the two.', + ).toEqual([]) + }) + + it('uses no Radix primitive that injects a style element', () => { + // An import statement can span several lines (a named import list broken + // one name per line), which a line-by-line match misses. Matched against + // each file's whole text instead, with `s` so `.` crosses newlines. + const primitives = + 'Dialog|AlertDialog|Select|DropdownMenu|Popover|Tooltip|HoverCard|ContextMenu|Menubar|NavigationMenu|Sheet' + const pattern = new RegExp( + `import\\s*\\{[^}]*\\b(${primitives})\\b[^}]*\\}\\s*from\\s*'radix-ui'`, + 's', + ) + const found = shipped + .filter((p) => pattern.test(readFileSync(p, 'utf8'))) + .map((p) => relative(SRC, p)) + expect( + found, + 'These inject