From 609082f88e1861d252e394902c71f2c39d1c1545 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Sat, 29 Aug 2026 18:55:36 +0000
Subject: [PATCH] Sell the directory as a corpus, on a four-hour clock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The directory stays open — every endpoint that answered a stranger
yesterday still does, and llms.txt says so in as many words. What is new
is a different artifact: the whole thing in bulk, which the open API was
never shaped to serve and which this database cannot afford to give away.
Four streams (feeds, items, extracts, authors) as gzipped NDJSON, cut on
fixed four-hour UTC boundaries. The boundaries are the product, not the
packaging: a window is the same set of rows whoever asks and whenever
they ask, so a buyer walking them in order provably sees every row
exactly once — no gaps from clock skew, no duplicates from a retry. Only
closed windows are served, because the one containing the present is
still filling. Streamed from a keyset cursor rather than built into a
file: there is nowhere to put a file, and a periodic dump job would be a
large read against a write-saturated database for an artifact most
windows nobody collects.
/sales carries no price and no rate card, only a contact form. Corpus
licensing is negotiated per buyer, and a self-serve button would be
selling terms nobody agreed to. Access is a hand-written row in
dataset_grants after a conversation; there is deliberately no endpoint
that mints one.
Two things the page is careful about, because both would otherwise be
assumed wrongly. Post records and article text are different datasets —
content_html stopped being written in 0031, so the 4.7M figure is
metadata and the prose is a smaller table — and publishers can now be
excluded from the corpus while staying in the directory, which is a
separate ask from removal on purpose. authors.email never leaves at all.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Pij2tFcRgqceMpheotFSoX
---
apps/web/src/app/account/page.jsx | 67 +-
apps/web/src/app/api/dataset/[name]/route.js | 234 ++++++
apps/web/src/app/api/dataset/route.js | 117 +++
apps/web/src/app/api/sales/contact/route.js | 193 +++++
apps/web/src/app/globals.css | 13 +
apps/web/src/app/layout.jsx | 6 +-
apps/web/src/app/sales/page.jsx | 299 ++++++++
apps/web/src/lib/corpus.js | 67 ++
apps/web/src/lib/dataset.js | 110 +++
apps/web/src/lib/datasetWindow.js | 133 ++++
apps/web/src/lib/llms.js | 9 +-
apps/web/test/dataset-window.test.js | 121 +++
packages/db/index.js | 1 +
.../20260829183852_dataset_access.sql | 150 ++++
packages/db/src/dataset.js | 703 ++++++++++++++++++
packages/db/test/dataset.test.js | 394 ++++++++++
packages/mail/src/send.js | 8 +-
17 files changed, 2619 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/app/api/dataset/[name]/route.js
create mode 100644 apps/web/src/app/api/dataset/route.js
create mode 100644 apps/web/src/app/api/sales/contact/route.js
create mode 100644 apps/web/src/app/sales/page.jsx
create mode 100644 apps/web/src/lib/corpus.js
create mode 100644 apps/web/src/lib/dataset.js
create mode 100644 apps/web/src/lib/datasetWindow.js
create mode 100644 apps/web/test/dataset-window.test.js
create mode 100644 packages/db/migrations/20260829183852_dataset_access.sql
create mode 100644 packages/db/src/dataset.js
create mode 100644 packages/db/test/dataset.test.js
diff --git a/apps/web/src/app/account/page.jsx b/apps/web/src/app/account/page.jsx
index 4fbce17..f729b67 100644
--- a/apps/web/src/app/account/page.jsx
+++ b/apps/web/src/app/account/page.jsx
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
-import { accounts, apikeys } from '@rssamplifier/db';
+import { accounts, apikeys, dataset } from '@rssamplifier/db';
import Toolbar from '../Toolbar.jsx';
import { AddPasskey } from '../Passkey.jsx';
@@ -7,6 +7,7 @@ import { db } from '../../lib/db.js';
import { currentUser } from '../../lib/auth.js';
import { topicLabel } from '../../lib/following.js';
import { ANONYMOUS_HOURLY } from '../../lib/ratelimit.js';
+import { latestClosedWindow } from '../../lib/datasetWindow.js';
export const dynamic = 'force-dynamic';
@@ -29,13 +30,19 @@ export default async function AccountPage({ searchParams }) {
const client = db();
const userId = String(user.id);
- const [follows, credentials, topics, keys] = await Promise.all([
+ const [follows, credentials, topics, keys, grant] = await Promise.all([
accounts.followedFeeds(client, userId),
accounts.credentialsForUser(client, userId),
accounts.followedTopics(client, userId),
apikeys.keysForUser(client, userId),
+ dataset.activeGrant(client, userId),
]);
+ // Only for an account that has one, so the overwhelming majority of readers —
+ // who will never license the corpus — do not pay a query to be told they have
+ // no downloads.
+ const pulls = grant ? await dataset.recentDownloads(client, String(grant.id), 5) : [];
+
// Revoked keys are kept in the table so a stale key found in a log can still
// be identified, but there is nothing for their owner to do about them.
const liveKeys = keys.filter((k) => !k.revoked_at);
@@ -138,6 +145,62 @@ export default async function AccountPage({ searchParams }) {
+ {/* Shown to every account, licensed or not, and that is deliberate. An
+ account with no licence is told so in one line with a link, which is
+ the answer to "did my access get set up?" — a question that otherwise
+ arrives by email. There is no button here because there is nothing to
+ press: a licence is written by hand after a conversation, and a page
+ that implied otherwise would be promising a checkout that does not
+ exist. */}
+
Training data
+ {grant ? (
+ <>
+
+ This account holds a {String(grant.plan)} corpus licence, granted{' '}
+ {formatDate(grant.granted_at)}
+ {grant.expires_at ? ` and running until ${formatDate(grant.expires_at)}` : ''}. Pull{' '}
+ {Number(grant.per_window_downloads)} times per dataset per window, and{' '}
+ {Number(grant.full_dumps_per_day)} full-history dump
+ {Number(grant.full_dumps_per_day) === 1 ? '' : 's'} a day. Use a session or any of your
+ API keys as a bearer token — the licence belongs to the account, so rotating a key costs
+ you nothing.
+
+
+ Newest complete window: {latestClosedWindow()}. The shape of every row is
+ at /api/dataset.
+
+ {pulls.length === 0 ? (
+
Nothing pulled yet.
+ ) : (
+
+ {pulls.map((p, i) => (
+
+
{String(p.dataset)}
+
+ {p.full_dump ? 'full history' : String(p.window_start)}
+
+ {/* A pull with no completed_at did not finish. Said plainly,
+ because it is the difference between "that window really
+ was small" and "your pipeline lost the connection", and
+ only one of those is worth investigating. */}
+ {p.completed_at
+ ? `${Number(p.rows_sent).toLocaleString('en-US')} rows · ${formatDate(p.created_at)}`
+ : `did not finish · started ${formatDate(p.created_at)}`}
+
+
+ ))}
+
+ )}
+ >
+ ) : (
+
+ This account has no corpus licence, and needs none for anything the directory serves
+ openly — the API, the OPML export and the MCP server all answer without one. Bulk access
+ to the whole directory as a training corpus is licensed separately:{' '}
+ what is in it, and how to ask.
+
+ )}
+
{/* What is followed, but not what it published: the river moved to
/following, which is the one place the blogs and the topics are merged
into a single list and the only one of the two that can be subscribed
diff --git a/apps/web/src/app/api/dataset/[name]/route.js b/apps/web/src/app/api/dataset/[name]/route.js
new file mode 100644
index 0000000..7639a0c
--- /dev/null
+++ b/apps/web/src/app/api/dataset/[name]/route.js
@@ -0,0 +1,234 @@
+import { dataset } from '@rssamplifier/db';
+
+import { db } from '../../../../lib/db.js';
+import {
+ datasetCaller,
+ resolveWindow,
+ latestClosedWindow,
+ startOfUtcDay,
+} from '../../../../lib/dataset.js';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * One dataset, one slice, streamed as gzipped NDJSON.
+ *
+ * ## Why this streams instead of building a file
+ *
+ * There is nowhere to put a file. This deployment has no object storage, and the
+ * database it would be built from is the same one serving the site — so a
+ * "build a dump every four hours" job would be a large periodic read against a
+ * write-saturated database, producing an artifact most windows nobody collects.
+ * Streaming inverts it: the work happens when somebody actually wants the data,
+ * nothing larger than one page is ever resident, and the buyer starts receiving
+ * rows immediately rather than after the last one is read.
+ *
+ * The window boundaries are what make that safe to sell. A streamed response is
+ * not obviously reproducible — but a slice cut on a fixed clock is the same set
+ * of rows whoever asks, so "stream it twice and get the same corpus" holds
+ * without an artifact existing anywhere in between. See `lib/dataset.js`.
+ *
+ * ## Why NDJSON and not CSV or Parquet
+ *
+ * Because the rows are not rectangular: a post carries an HTML body and an
+ * author record carries a nested array of links, and both survive a line of JSON
+ * unchanged. It is also the format that can be produced incrementally — a line
+ * at a time, with no header to write first and no footer to get right — which is
+ * what lets a dump be a stream at all. Parquet would be a better fit for a
+ * buyer's storage and a worse fit for ours; converting on receipt is one line of
+ * their pipeline.
+ *
+ * ## Why the count in the audit log can be short
+ *
+ * `finishDownload` runs when the stream closes cleanly. If the buyer hangs up
+ * partway, it never runs, and the row keeps a null `completed_at` — which is the
+ * signal that the pull broke rather than that it was small. That is deliberate:
+ * a broken pull must still count against the window allowance, or hanging up
+ * becomes a free way to run the query in a loop.
+ */
+
+/**
+ * @param {Request} req
+ * @param {{ params: Promise<{ name: string }> }} ctx
+ * @returns {Promise}
+ */
+export async function GET(req, { params }) {
+ const { name: raw } = await params;
+ // `.jsonl.gz` is stripped rather than required, so a caller may write the URL
+ // as a filename and get a file. Both spellings reach the same stream.
+ const name = String(raw ?? '')
+ .toLowerCase()
+ .replace(/\.(ndjson|jsonl)(\.gz)?$/, '');
+
+ const stream = dataset.streamFor(name);
+ if (!stream) {
+ return Response.json(
+ {
+ error: 'unknown-dataset',
+ detail: `No dataset called "${name}".`,
+ datasets: dataset.DATASETS,
+ manifest: '/api/dataset',
+ },
+ { status: 404, headers: { 'access-control-allow-origin': '*' } },
+ );
+ }
+
+ const caller = await datasetCaller(req);
+ if (!caller.ok) return caller.response;
+
+ const { user, grant, apiKeyId } = caller;
+ const client = db();
+ const url = new URL(req.url);
+ const full = url.searchParams.get('full') === '1';
+
+ // Two different allowances, because they bound two different costs. A window
+ // is a bounded index range; a full dump walks the table.
+ if (full) {
+ const taken = await dataset.fullDumpCount(client, String(grant.id), startOfUtcDay());
+ const allowed = Number(grant.full_dumps_per_day) || 0;
+ if (taken >= allowed) {
+ return exhausted(
+ 'full-dump-limit',
+ `This licence allows ${allowed} full-history pull${allowed === 1 ? '' : 's'} a day and has used ${taken}. Incremental windows are not affected — take ${latestClosedWindow()} instead.`,
+ );
+ }
+ }
+
+ const slice = full
+ ? { ok: true, start: null, end: null }
+ : resolveWindow(url.searchParams.get('window'));
+
+ if (!slice.ok) {
+ return Response.json(
+ { error: slice.error, detail: slice.detail, manifest: '/api/dataset' },
+ { status: 400, headers: { 'access-control-allow-origin': '*' } },
+ );
+ }
+
+ if (!full) {
+ const taken = await dataset.windowDownloadCount(
+ client,
+ String(grant.id),
+ name,
+ String(slice.start),
+ );
+ const allowed = Number(grant.per_window_downloads) || 0;
+ if (taken >= allowed) {
+ return exhausted(
+ 'window-limit',
+ `This licence allows ${allowed} pulls of one dataset per ${dataset.WINDOW_HOURS}-hour window, and has taken ${taken} of ${name} for ${slice.start}.`,
+ );
+ }
+ }
+
+ // Opened before the first byte, closed when the last one lands. See the note
+ // at the top on why the order matters.
+ const downloadId = await dataset.startDownload(client, {
+ grantId: String(grant.id),
+ userId: String(user.id),
+ dataset: name,
+ windowStart: full ? null : String(slice.start),
+ fullDump: full,
+ apiKeyId,
+ });
+
+ const encoder = new TextEncoder();
+ let rows = 0;
+
+ const lines = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const row of stream(client, { since: slice.start, until: slice.end })) {
+ controller.enqueue(encoder.encode(`${JSON.stringify(shape(name, row))}\n`));
+ rows += 1;
+ }
+ await dataset.finishDownload(client, downloadId, rows);
+ } catch (err) {
+ // The response is already on the wire with a 200, so this cannot become
+ // an error status — the same bind the OPML export is in. The download
+ // row is left with a null `completed_at`, which is exactly the record of
+ // "this one broke", and the buyer sees a truncated file rather than a
+ // silently short one because the byte count will not match a re-pull of
+ // the same window.
+ console.error(`dataset stream failed partway: ${name}`, err);
+ }
+ controller.close();
+ },
+ });
+
+ const filename = full
+ ? `rssamplifier-${name}-full.jsonl.gz`
+ : `rssamplifier-${name}-${String(slice.start).replace(/[:.]/g, '')}.jsonl.gz`;
+
+ return new Response(lines.pipeThrough(new CompressionStream('gzip')), {
+ headers: {
+ 'content-type': 'application/x-ndjson',
+ 'content-encoding': 'gzip',
+ 'content-disposition': `attachment; filename="${filename}"`,
+ // The buyer's pipeline reads these to decide when to ask again, so they
+ // are part of the interface rather than diagnostics.
+ 'x-dataset-name': name,
+ 'x-dataset-window': full ? 'full' : String(slice.start),
+ 'x-dataset-window-end': full ? '' : String(slice.end),
+ 'x-dataset-window-hours': String(dataset.WINDOW_HOURS),
+ 'x-dataset-next-window': latestClosedWindow(),
+ // No caching anywhere in between: a window is stable but a licence is not,
+ // and a proxy holding a corpus slice would serve it to the next caller.
+ 'cache-control': 'private, no-store',
+ 'access-control-allow-origin': '*',
+ },
+ });
+}
+
+/**
+ * A row, as the buyer sees it.
+ *
+ * The database's column names are an implementation detail and several of them
+ * would be actively misleading in a corpus — `cursor_rowid` is paging state that
+ * means nothing outside this process, and `links` arrives from SQLite as a JSON
+ * *string* that would otherwise land in the file double-encoded and have to be
+ * parsed twice.
+ *
+ * Everything else keeps its snake_case name deliberately. The manifest describes
+ * the columns, `/crawlstats` and the open API use the same vocabulary, and
+ * renaming them here would mean the corpus and the documentation disagree.
+ *
+ * @param {string} name
+ * @param {Record} row
+ * @returns {Record}
+ */
+function shape(name, row) {
+ const { cursor_rowid: _cursor, ...rest } = row;
+
+ if (name === 'authors' && typeof rest['links'] === 'string') {
+ try {
+ rest['links'] = JSON.parse(String(rest['links']));
+ } catch {
+ // json_group_array cannot really produce invalid JSON, but a corpus line
+ // that fails to parse is worse than one whose links arrive as a string.
+ rest['links'] = [];
+ }
+ }
+
+ if (name === 'feeds' && typeof rest['categories'] === 'string') {
+ try {
+ rest['categories'] = JSON.parse(String(rest['categories']));
+ } catch {
+ rest['categories'] = [];
+ }
+ }
+
+ return rest;
+}
+
+/**
+ * @param {string} error
+ * @param {string} detail
+ * @returns {Response}
+ */
+function exhausted(error, detail) {
+ return Response.json(
+ { error, detail, manifest: '/api/dataset' },
+ { status: 429, headers: { 'access-control-allow-origin': '*' } },
+ );
+}
diff --git a/apps/web/src/app/api/dataset/route.js b/apps/web/src/app/api/dataset/route.js
new file mode 100644
index 0000000..183de1c
--- /dev/null
+++ b/apps/web/src/app/api/dataset/route.js
@@ -0,0 +1,117 @@
+import { dataset } from '@rssamplifier/db';
+
+import { latestClosedWindow, windowEnd } from '../../../lib/datasetWindow.js';
+import { siteUrl } from '../../../lib/db.js';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * The manifest: what the corpus contains, how it is cut, and how to take it.
+ *
+ * Open, and answers without a licence. That is the whole reason it is a separate
+ * route from the streams it describes.
+ *
+ * A buyer's first question is "what is actually in there", and the honest way to
+ * answer it is a machine-readable description they can read before paying rather
+ * than a paragraph of marketing they have to trust. It also means the pipeline
+ * that will pull this every four hours can discover the current boundary instead
+ * of computing it — so if the cadence ever changes, their code follows without
+ * an email.
+ *
+ * What it deliberately does not contain is a price. There is none in this
+ * codebase; licensing is negotiated, and the manifest points at /sales for it.
+ *
+ * It also does not contain row counts. A `count(*)` over `feed_items` is a
+ * multi-second scan of 4.7 million rows against a database whose write path is
+ * already saturated, and this is an unauthenticated endpoint — putting one here
+ * would be handing anybody a way to make the site slow. The sales page shows the
+ * cached directory figures instead, and a licensed caller learns the exact size
+ * of a window by taking it.
+ */
+
+/**
+ * @returns {Response}
+ */
+export function GET() {
+ const newest = latestClosedWindow();
+ const base = siteUrl();
+
+ return Response.json(
+ {
+ corpus: 'rssamplifier.com',
+ description:
+ 'The open small-web directory as a training corpus: independent blogs, podcasts and video feeds, crawled continuously and sliced on a fixed clock.',
+
+ cadence: {
+ windowHours: dataset.WINDOW_HOURS,
+ // Named rather than implied. A pipeline reading this should walk
+ // boundaries in order; saying so here is cheaper than saying it in an
+ // email to every buyer.
+ semantics:
+ 'Each window is the half-open range [start, end) on the row timestamp named in `cutOn`. Windows are fixed against the Unix epoch, so a window is the same set of rows whoever asks and whenever they ask. Walk them in order to see every row exactly once.',
+ latestClosedWindow: newest,
+ latestWindowEnd: windowEnd(newest),
+ nextWindowOpensAt: windowEnd(newest),
+ },
+
+ datasets: {
+ feeds: {
+ description:
+ 'One row per feed in the directory: title, description, canonical feed and site URLs, language, category, item count and crawl state.',
+ cutOn: 'created_at — when the directory first saw the feed, so a window is the feeds that are new to it',
+ format: 'application/x-ndjson, gzipped',
+ url: `${base}/api/dataset/feeds`,
+ },
+ items: {
+ description:
+ 'One row per post: title, summary, author, canonical URL, publication date, and the feed it belongs to. This is metadata at scale rather than prose — see `bodies` below.',
+ cutOn: 'created_at — when the crawler ingested the post',
+ format: 'application/x-ndjson, gzipped',
+ url: `${base}/api/dataset/items`,
+ // Said here, unprompted, because it is the one thing about this corpus
+ // a buyer could reasonably assume wrongly and only discover after
+ // paying. Posts ingested before the change still carry their body.
+ bodies:
+ 'Posts ingested since 2026-08 do not carry `content_html`: storing a body for every post was 10GB of a 14GB database and the crawler stopped writing it. Full article text lives in the `extracts` dataset, for the subset that has been fetched. Older rows still carry the body they were ingested with.',
+ },
+ extracts: {
+ description:
+ 'The article itself, sanitized, for posts whose page has been fetched and parsed. Several thousand characters on average. This is the prose in the corpus.',
+ cutOn: 'fetched_at — when the article was read, not when it was published, so a window includes an old post that was fetched today',
+ format: 'application/x-ndjson, gzipped',
+ url: `${base}/api/dataset/extracts`,
+ coverage:
+ 'A fraction of `items`, not a parallel of it: an article is fetched when a reader opens the post, so this grows with attention rather than with the crawl.',
+ },
+ authors: {
+ description:
+ 'The people behind the feeds, keyed on a URL they control, with their homepages and social profiles folded in as a JSON array.',
+ cutOn: 'created_at, filtered rather than seeked — `authors` is small enough to walk whole',
+ format: 'application/x-ndjson, gzipped',
+ url: `${base}/api/dataset/authors`,
+ },
+ },
+
+ access: {
+ how: 'A licence, granted per buyer. Present a session cookie or an API key as a bearer token; the licence hangs off the account rather than the key, so keys can be rotated freely.',
+ enquiries: `${base}/sales`,
+ parameters: {
+ window:
+ 'ISO-8601 window boundary. Omit for the newest closed window. An unaligned or still-filling boundary is refused rather than rounded.',
+ full: 'full=1 takes the whole history instead of one window. Separately metered, and expensive — intended once, to seed a mirror, before switching to windows.',
+ },
+ limits:
+ 'Per-window and per-day allowances are set on the licence and reported in the `x-dataset-*` response headers.',
+ },
+
+ provenance: {
+ source:
+ 'Public feeds their publishers chose to syndicate. The directory is open and anyone may submit to it.',
+ optOut:
+ 'A publisher may be excluded from the corpus while staying in the directory, on request to hello@rssamplifier.com. Excluded feeds and every post and article belonging to them are absent from every dataset above.',
+ openApi: `Nothing here is a restriction on the open API: ${base}/api/feeds, /api/search, /opml and the MCP server all answer without an account.`,
+ },
+ },
+ { headers: { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=60' } },
+ );
+}
diff --git a/apps/web/src/app/api/sales/contact/route.js b/apps/web/src/app/api/sales/contact/route.js
new file mode 100644
index 0000000..29b99ef
--- /dev/null
+++ b/apps/web/src/app/api/sales/contact/route.js
@@ -0,0 +1,193 @@
+import { dataset } from '@rssamplifier/db';
+import { sendEmail, emailEnabled } from '@rssamplifier/mail';
+
+import { db } from '../../../../lib/db.js';
+import { requestMeta } from '../../../../lib/auth.js';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * The only way into a corpus conversation.
+ *
+ * ## Why a form here when /contact deliberately has none
+ *
+ * That page's comment says it plainly: "no form, because a form on a site with
+ * no accounts is one more thing to spam", and it routes to published addresses
+ * instead. The reasoning holds and this is not a reversal of it — it is a
+ * different trade. /contact's job is to get a stranger to the right mailbox,
+ * which a list of links does perfectly well. This page's job is to start a
+ * negotiation, and the fields below (what they want it for, at what scale) are
+ * exactly the ones that decide whether there is a deal and what it looks like.
+ * An email that omits them costs a round trip; a form that asks for them does
+ * not. So the spam is paid for rather than avoided, by the two guards below.
+ *
+ * ## Why it is written down before it is sent
+ *
+ * `sendEmail` reports a failure instead of throwing, and every caller in this
+ * codebase treats mail as optional infrastructure. That is right for a sign-in
+ * link, which the reader can simply request again — it is wrong for a sales
+ * enquiry, which the sender believes is delivered and will never send twice. So
+ * the row lands first, the mail is best-effort on top of it, and a Resend outage
+ * costs a notification rather than a customer.
+ */
+
+/** How long the flood window is. */
+const WINDOW_MS = 60 * 60 * 1000;
+
+/**
+ * Enquiries one address may send in that window.
+ *
+ * Three, not one. A genuine sender who mistypes their own email address and
+ * resends is the most likely repeat here, and refusing them is worse than
+ * accepting a third message from a spammer we are also storing and rate
+ * limiting.
+ */
+const MAX_PER_WINDOW = 3;
+
+/** Where an enquiry is announced, when mail is configured. */
+const INBOX = process.env['SALES_EMAIL'] || 'hello@rssamplifier.com';
+
+/**
+ * @param {Request} req
+ * @returns {Promise}
+ */
+export async function POST(req) {
+ const wantsHtml = (req.headers.get('accept') ?? '').includes('text/html');
+ const body = await readBody(req);
+ if (!body) return respond(wantsHtml, 400, 'bad-request', 'That form could not be read.');
+
+ // The honeypot. A field no human sees, no browser fills and every naive bot
+ // completes. Answered with the same success the real path gives, because a
+ // bot told it failed is a bot that tries again differently.
+ if (body.website) {
+ return respond(wantsHtml, 200, null, null);
+ }
+
+ const email = body.email.trim().toLowerCase();
+ // Deliberately the weakest possible check. This address is going to be read by
+ // a person who will reply to it, not authenticated — a strict pattern here
+ // rejects the valid addresses nobody remembers are valid and gains nothing.
+ if (!email || !email.includes('@') || email.length > 200) {
+ return respond(wantsHtml, 400, 'bad-email', 'That does not look like an email address.');
+ }
+
+ const useCase = body.useCase.trim();
+ if (useCase.length < 10) {
+ return respond(
+ wantsHtml,
+ 400,
+ 'no-use-case',
+ 'Please say what you want the corpus for — it is the field that decides what we can offer.',
+ );
+ }
+
+ const client = db();
+ const meta = await requestMeta();
+
+ const recent = await dataset.enquiryCountFrom(
+ client,
+ meta.ipHash,
+ new Date(Date.now() - WINDOW_MS).toISOString(),
+ );
+ if (recent >= MAX_PER_WINDOW) {
+ return respond(
+ wantsHtml,
+ 429,
+ 'too-many',
+ 'That is several enquiries in an hour. Email hello@rssamplifier.com directly and we will pick it up there.',
+ );
+ }
+
+ await dataset.insertEnquiry(client, {
+ name: body.name.trim().slice(0, 120) || null,
+ email: email.slice(0, 200),
+ org: body.org.trim().slice(0, 160) || null,
+ useCase: useCase.slice(0, 4000),
+ ipHash: meta.ipHash,
+ userAgent: meta.userAgent,
+ });
+
+ if (emailEnabled()) {
+ // Not awaited into the response. The enquiry is already stored, so the
+ // sender's answer does not depend on Resend being reachable this second, and
+ // making them wait on it would only ever make the form feel broken.
+ void sendEmail({
+ to: INBOX,
+ subject: `Corpus enquiry: ${body.org.trim() || email}`,
+ text: [
+ `From: ${body.name.trim() || '(no name)'} <${email}>`,
+ `Company: ${body.org.trim() || '(none given)'}`,
+ '',
+ 'What for:',
+ useCase.slice(0, 4000),
+ '',
+ // So a reply can be written without opening the database, and so the
+ // operator knows the row exists even if this mail is the only thing they
+ // ever see.
+ 'Stored in dataset_enquiries. Grant access with an insert into dataset_grants.',
+ ].join('\n'),
+ // Replying to the notification reaches the sender rather than ourselves,
+ // which is the difference between a queue and an inbox.
+ replyTo: email,
+ }).catch(() => {});
+ }
+
+ return respond(wantsHtml, 200, null, null);
+}
+
+/**
+ * HTML callers get a 303 back to the page; everyone else gets JSON.
+ *
+ * @param {boolean} wantsHtml
+ * @param {number} status
+ * @param {string|null} error
+ * @param {string|null} detail
+ * @returns {Response}
+ */
+function respond(wantsHtml, status, error, detail) {
+ if (wantsHtml) {
+ const to = error ? `/sales?error=${encodeURIComponent(error)}#enquire` : '/sales?sent=1#enquire';
+ return new Response(null, { status: 303, headers: { location: to } });
+ }
+
+ return Response.json(error ? { error, detail } : { ok: true }, {
+ status,
+ headers: { 'cache-control': 'no-store' },
+ });
+}
+
+/**
+ * Read a form or a JSON body into the same shape.
+ *
+ * @param {Request} req
+ * @returns {Promise<{ name: string, email: string, org: string, useCase: string, website: string }|null>}
+ */
+async function readBody(req) {
+ try {
+ const source = (req.headers.get('content-type') ?? '').includes('application/json')
+ ? await req.json()
+ : Object.fromEntries(await req.formData());
+
+ return {
+ name: str(source?.name),
+ email: str(source?.email),
+ org: str(source?.org),
+ // `useCase` for a JSON caller, `use_case` for the form field — the form
+ // uses the name that reads correctly in HTML and the API uses the one that
+ // reads correctly in JavaScript, and neither should have to know about the
+ // other.
+ useCase: str(source?.useCase) || str(source?.use_case),
+ website: str(source?.website),
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @param {unknown} value
+ * @returns {string}
+ */
+function str(value) {
+ return value == null ? '' : String(value);
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 6033d76..f5977ac 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -482,8 +482,21 @@ h2 {
margin-bottom: 2rem;
}
+/* Stacked fields need air between them. /discover got away without this with
+ two, because a textarea and an input read as separate blocks on their own;
+ /sales has four in a row and they ran together into one grey slab. */
+.submit-box > input,
+.submit-box > textarea {
+ margin-bottom: 0.75rem;
+}
+
+/* `email` belongs here for the same reason `text` does, and its absence was a
+ real bug rather than a style preference: /discover and /sales both take an
+ address in a `.submit-box`, and without it the field rendered at the browser's
+ default size beside full-width siblings — a form that looks half broken. */
textarea,
input[type='text'],
+input[type='email'],
input[type='search'] {
width: 100%;
font-family: var(--mono);
diff --git a/apps/web/src/app/layout.jsx b/apps/web/src/app/layout.jsx
index b05d870..a51031e 100644
--- a/apps/web/src/app/layout.jsx
+++ b/apps/web/src/app/layout.jsx
@@ -321,7 +321,11 @@ export default function RootLayout({ children }) {
Post records and article text are different things
+
+ This is the one thing worth reading twice before you talk to us, because it is the thing
+ most likely to be assumed wrongly.
+
+
+ The post records are the large dataset: title, summary, author, canonical
+ URL, publication date and the feed each post belongs to, {fmt(posts)} of them, growing by
+ hundreds of thousands a day. That is metadata at scale, and for a great many uses — link
+ graphs, recency signals, topic and language distribution, retrieval indexes — it is the part
+ that matters.
+
+
+ The article text is the smaller one
+ {corpus ? `: ${fmt(corpus.articles)} articles` : ''}, sanitized, averaging{' '}
+ {corpus ? `${fmt(corpus.sampledAvgChars)} characters` : 'several thousand characters'}{' '}
+ {corpus ? (
+ <>
+ (sampled over {fmt(corpus.sampleSize)} of them, not summed over all — the exact figure
+ is a quarter of a million row lookups this database should not be asked for on a page
+ load)
+ >
+ ) : null}
+ . It exists because an article is fetched and cached when a reader opens the post, so it
+ grows with attention rather than with the crawl. Posts ingested before August 2026 also
+ carry the body their feed published; newer ones do not, because storing one for every post
+ was ten gigabytes of a fourteen gigabyte database.
+
+
+ If you need prose at the scale of the metadata, say so — that is a change to what the
+ crawler stores rather than a parameter you can pass, and it is a conversation we are happy
+ to have.
+
+
+
How it is delivered
+
+ Four streams — feeds, items, extracts and{' '}
+ authors — each one gzipped NDJSON, one JSON object per line, streamed rather
+ than downloaded from a prepared file. The full machine-readable description lives at{' '}
+ /api/dataset and needs no account, so you can read the exact
+ shape of every row before you talk to anybody.
+
+
+ Every slice is a half-open range on a fixed {dataset.WINDOW_HOURS}-hour boundary, currently{' '}
+ {latestClosedWindow()}. That matters more than it sounds: a window is the same
+ set of rows whoever asks and whenever they ask, so a pipeline that walks boundaries in order
+ provably sees every row exactly once — no gaps from clock skew, no duplicates from a retry,
+ and a failed pull can simply be repeated. Only closed windows are served, because the one
+ containing the present is still filling.
+
+
+ Authenticate with a session or an API key as a bearer token. The licence belongs to the
+ account rather than to the key, so keys can be rotated without telling us. A full-history
+ pull is available too, separately metered — intended once, to seed a mirror, before
+ switching to windows for good.
+
+
+
Where it comes from
+
+ Public feeds their publishers chose to syndicate. Anyone may{' '}
+ submit a feed, every feed has{' '}
+ its own page here, and the directory has been open and free to read
+ since the day it launched — the JSON API,{' '}
+ the OPML export, llms.txt and{' '}
+ the MCP server all still answer without an account, and nothing on this
+ page changes that. What is licensed here is bulk access, which is a different artifact and a
+ real cost to serve.
+
+
+ A publisher may be excluded from the corpus while staying in the directory, by writing to{' '}
+ hello@rssamplifier.com. It is a separate ask
+ from removal on purpose — “list my blog, but do not sell my writing to a model” is a
+ coherent position and nobody should have to leave the directory to hold it. Excluded feeds,
+ and every post and article belonging to them, are absent from every stream.
+ {corpus && corpus.optedOut > 0 ? ` ${fmt(corpus.optedOut)} have asked so far.` : ''}
+
+
+
Talk to us
+ {sent ? (
+
+ Thank you — that is recorded and someone will reply to the address you gave. If you would
+ rather chase it, hello@rssamplifier.com{' '}
+ reaches the same people.
+
+ ) : null}
+ {error ? (
+
+ {errorMessage(error)}
+
+ ) : null}
+
+ Licensing is per buyer, so there is no price list here: what you may keep, whether you may
+ redistribute it and whether attribution travels with the text all move the number more than
+ volume does. Tell us what you want it for and we will come back with terms.
+
+
+ {/* A plain form posting to /api/*, like every other control on this site:
+ it works with JavaScript off, and the route answers an HTML caller with
+ a 303 back to this anchor and a JSON caller with JSON. */}
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * @param {string} code
+ * @returns {string}
+ */
+function errorMessage(code) {
+ switch (code) {
+ case 'bad-email':
+ return 'That does not look like an email address — we would have no way to reply.';
+ case 'no-use-case':
+ return 'Please say what you want the corpus for. It is the field that decides what we can offer.';
+ case 'too-many':
+ return 'That is several enquiries in an hour. Email hello@rssamplifier.com directly and we will pick it up there.';
+ default:
+ return 'That did not send. Try again, or email hello@rssamplifier.com.';
+ }
+}
+
+/**
+ * @param {unknown} n
+ * @returns {string}
+ */
+function fmt(n) {
+ return Number(n ?? 0).toLocaleString('en-US');
+}
diff --git a/apps/web/src/lib/corpus.js b/apps/web/src/lib/corpus.js
new file mode 100644
index 0000000..f609b0e
--- /dev/null
+++ b/apps/web/src/lib/corpus.js
@@ -0,0 +1,67 @@
+import { dataset, authors, remember } from '@rssamplifier/db';
+
+import { db } from './db.js';
+
+/**
+ * The figures on /sales, cached the way every other expensive count here is.
+ *
+ * The page has to carry real numbers — an offer to license a corpus is exactly
+ * the page where an invented figure destroys the value of the honest ones — and
+ * it is also a page a stranger loads cold. Those two pull against each other,
+ * and `remember` is how the rest of this codebase resolves it: compute rarely,
+ * serve stale while refreshing, and answer a failed read with a fallback the
+ * page can render rather than with an exception.
+ *
+ * Feed and post counts are not here. They come from `categoryStats`, which
+ * /advertise and /crawlstats already pay for and which is the same cache — a
+ * second copy of that scan for this page would be the one avoidable cost on it.
+ */
+
+/**
+ * How long these are trusted before a refresh is started behind the reader.
+ *
+ * An hour, matching `CATEGORY_TTL_MS`. A corpus does not change shape between
+ * two page loads, and the whole point of the pairing is that the value is
+ * refreshed by somebody who is not waiting for it.
+ */
+const TTL_MS = 60 * 60 * 1000;
+
+/**
+ * How stale they may get before a reader waits for a fresh one.
+ *
+ * A month, for the reason `CHART_MAX_STALE_MS` is a month: past this point
+ * `remember` stops serving the entry and makes the reader wait out the timeout
+ * instead — and then returns the same expired value from its catch anyway. A
+ * month-old article count on a sales page is a better answer than a page that
+ * hangs.
+ */
+const MAX_STALE_MS = 30 * 24 * 60 * 60 * 1000;
+
+/** Shorter than the client's own deadline, so a hung read gives the page back. */
+const TIMEOUT_MS = 8 * 1000;
+
+/**
+ * Corpus-specific counts: full-text articles, authors, and publishers opted out.
+ *
+ * @returns {Promise<{ articles: number, sampledAvgChars: number, sampleSize: number, authors: number, optedOut: number }|null>}
+ */
+export async function corpusFigures() {
+ return remember(
+ 'corpus:figures',
+ { ttlMs: TTL_MS, maxStaleMs: MAX_STALE_MS, timeoutMs: TIMEOUT_MS, fallback: null },
+ async () => {
+ const client = db();
+ const [articles, authorCount, optedOut] = await Promise.all([
+ dataset.articleFigures(client),
+ authors.countAuthors(client),
+ dataset.optedOutCount(client),
+ ]);
+
+ return {
+ ...articles,
+ authors: Number(authorCount ?? 0),
+ optedOut: Number(optedOut ?? 0),
+ };
+ },
+ );
+}
diff --git a/apps/web/src/lib/dataset.js b/apps/web/src/lib/dataset.js
new file mode 100644
index 0000000..f99270d
--- /dev/null
+++ b/apps/web/src/lib/dataset.js
@@ -0,0 +1,110 @@
+import { dataset, apikeys } from '@rssamplifier/db';
+import { apiKeyFromRequest, looksLikeApiKey, hashToken } from '@rssamplifier/auth';
+
+import { db } from './db.js';
+import { currentUser } from './auth.js';
+
+/**
+ * The gate on the corpus.
+ *
+ * The clock it is read by lives in ./datasetWindow.js, which is re-exported here
+ * so a route needs one import rather than two — the split is about what can be
+ * unit-tested, not about what a caller should have to know.
+ */
+export {
+ windowStart,
+ windowEnd,
+ latestClosedWindow,
+ resolveWindow,
+ startOfUtcDay,
+} from './datasetWindow.js';
+
+/**
+ * Who is asking for the corpus, and may they have it.
+ *
+ * Accepts a session or an API key, in that order, and that pairing is the point.
+ * A person evaluating the offer clicks a link in a browser and must not have to
+ * mint a credential to see whether the thing works; the pipeline that pulls it
+ * every four hours for the next year must never depend on a cookie. Both resolve
+ * to the same account and the same licence.
+ *
+ * The three refusals are deliberately distinguishable, because they need three
+ * different actions and collapsing them into one 403 sends every one of them to
+ * a human:
+ *
+ * * 401 — nobody is signed in and no key was presented. Sign in.
+ * * 401 — a key was presented and is not a key we know. Fix the credential.
+ * * 402 — we know exactly who you are and you have no licence. Talk to us.
+ *
+ * The 402 is the only one that is a sales question, and it is the one that
+ * carries a link to /sales.
+ *
+ * @param {Request} req
+ * @returns {Promise<{ ok: true, user: object, grant: object, apiKeyId: string|null } | { ok: false, response: Response }>}
+ */
+export async function datasetCaller(req) {
+ const client = db();
+ const presented = apiKeyFromRequest(req);
+
+ /** @type {object|null} */
+ let user = null;
+ /** @type {string|null} */
+ let apiKeyId = null;
+
+ if (presented) {
+ if (!looksLikeApiKey(presented)) {
+ return { ok: false, response: refuse(401, 'that is not a valid API key') };
+ }
+
+ const key = await apikeys.keyByHash(client, hashToken(presented));
+ if (!key) return { ok: false, response: refuse(401, 'unknown or revoked API key') };
+
+ apiKeyId = String(key.id);
+ // The key identifies an account; the licence hangs off the account, not off
+ // the key. So revoking one key does not cost a buyer their access, and a
+ // buyer rotating keys does not have to tell us.
+ user = { id: String(key.user_id) };
+ apikeys.touchKey(client, apiKeyId, key.last_used_at).catch(() => {});
+ } else {
+ user = await currentUser();
+ if (!user) {
+ return {
+ ok: false,
+ response: refuse(
+ 401,
+ 'the corpus needs an account: sign in, or send an API key as a bearer token',
+ ),
+ };
+ }
+ }
+
+ const grant = await dataset.activeGrant(client, String(user.id));
+ if (!grant) {
+ return {
+ ok: false,
+ response: Response.json(
+ {
+ error: 'no-dataset-licence',
+ detail:
+ 'This account has no corpus licence. Everything in the open directory stays free — /api/feeds, /api/search, /opml and the MCP server all answer without one.',
+ sales: 'https://rssamplifier.com/sales',
+ },
+ { status: 402, headers: { 'access-control-allow-origin': '*' } },
+ ),
+ };
+ }
+
+ return { ok: true, user, grant, apiKeyId };
+}
+
+/**
+ * @param {number} status
+ * @param {string} error
+ * @returns {Response}
+ */
+function refuse(status, error) {
+ return Response.json(
+ { error, sales: 'https://rssamplifier.com/sales' },
+ { status, headers: { 'access-control-allow-origin': '*' } },
+ );
+}
diff --git a/apps/web/src/lib/datasetWindow.js b/apps/web/src/lib/datasetWindow.js
new file mode 100644
index 0000000..8d29db1
--- /dev/null
+++ b/apps/web/src/lib/datasetWindow.js
@@ -0,0 +1,133 @@
+import { dataset } from '@rssamplifier/db';
+
+/**
+ * The four-hour clock the corpus is cut on.
+ *
+ * Deliberately free of any Next import, and that is the whole reason it is a
+ * file of its own rather than the top of `dataset.js`. Boundary arithmetic is
+ * the part of this feature most worth testing — an off-by-one here is a gap in
+ * somebody's corpus that neither side would notice for months — and the gate
+ * beside it reaches `next/headers` through `currentUser`, which cannot be
+ * imported by `node --test`. Splitting them means the arithmetic is tested and
+ * the gate is not, rather than neither being.
+ *
+ * ## Why the corpus is cut on a boundary rather than on "now"
+ *
+ * A buyer training on this data runs the same pull on a timer, for months. If
+ * each pull meant "everything since the moment you last asked", then their
+ * clock, our clock and the crawler's insert latency would all have to agree for
+ * the corpus to have no gaps and no duplicates — and they never will. A row
+ * whose `created_at` is a second before the request but which commits a second
+ * after it is invisible for ever, and nothing on either side would notice.
+ *
+ * Slicing on fixed UTC boundaries removes the question. `[00:00, 04:00)` is the
+ * same set of rows whoever asks and whenever they ask, so a pipeline that walks
+ * boundaries in order provably sees every row exactly once. It also makes a
+ * failed pull retryable without reasoning about what was already taken, and
+ * makes two callers comparing notes able to say they have the same artifact.
+ *
+ * ## Why only closed windows are served
+ *
+ * The window containing the present is still filling. Serving it would hand back
+ * a partial slice under an identifier that promises completeness, and the buyer
+ * would have no way to tell — the next pull of the same window would silently
+ * disagree with the last. So the newest window on offer is always the most
+ * recently *closed* one, and the manifest says when the next opens.
+ */
+
+/** Milliseconds in one slice. Derived, so the two constants cannot drift. */
+const WINDOW_MS = dataset.WINDOW_HOURS * 60 * 60 * 1000;
+
+/**
+ * The start of the window a moment falls in.
+ *
+ * Floors against the Unix epoch rather than against midnight. The epoch is a
+ * multiple of four hours, so the two happen to agree today — but only the epoch
+ * keeps agreeing if `WINDOW_HOURS` is ever changed to something that does not
+ * divide 24, and a boundary scheme that quietly breaks on a config change is not
+ * a contract.
+ *
+ * @param {number|Date} [at]
+ * @returns {string} ISO-8601, to the second, matching how the database stores time
+ */
+export function windowStart(at = Date.now()) {
+ const ms = at instanceof Date ? at.getTime() : at;
+ return new Date(Math.floor(ms / WINDOW_MS) * WINDOW_MS).toISOString();
+}
+
+/**
+ * The window after this one.
+ *
+ * @param {string} start
+ * @returns {string}
+ */
+export function windowEnd(start) {
+ return new Date(Date.parse(start) + WINDOW_MS).toISOString();
+}
+
+/**
+ * The newest window that has finished filling.
+ *
+ * @param {number} [now]
+ * @returns {string}
+ */
+export function latestClosedWindow(now = Date.now()) {
+ return windowStart(now - WINDOW_MS);
+}
+
+/**
+ * Read a requested window, or fall back to the newest closed one.
+ *
+ * Refuses rather than rounds. A caller who asks for `13:37` has a bug in their
+ * boundary arithmetic, and quietly serving them the 12:00 slice under their own
+ * label means their corpus is mislabelled in a way that only shows up much later
+ * as duplicated rows. Told plainly, they fix it on the first run.
+ *
+ * @param {string|null} requested
+ * @param {number} [now]
+ * @returns {{ ok: true, start: string, end: string } | { ok: false, error: string, detail: string }}
+ */
+export function resolveWindow(requested, now = Date.now()) {
+ const newest = latestClosedWindow(now);
+ if (!requested) return { ok: true, start: newest, end: windowEnd(newest) };
+
+ const ms = Date.parse(requested);
+ if (!Number.isFinite(ms)) {
+ return {
+ ok: false,
+ error: 'bad-window',
+ detail: `"${requested}" is not a timestamp. Pass a window boundary in ISO-8601, or omit it for the newest closed window.`,
+ };
+ }
+
+ const aligned = windowStart(ms);
+ if (aligned !== new Date(ms).toISOString()) {
+ return {
+ ok: false,
+ error: 'unaligned-window',
+ detail: `Windows are ${dataset.WINDOW_HOURS}-hourly and start on the boundary. The window containing that moment starts at ${aligned}.`,
+ };
+ }
+
+ if (Date.parse(aligned) > Date.parse(newest)) {
+ return {
+ ok: false,
+ error: 'window-not-closed',
+ detail: `That window has not finished filling. The newest complete one is ${newest}.`,
+ };
+ }
+
+ return { ok: true, start: aligned, end: windowEnd(aligned) };
+}
+
+/**
+ * The start of the current UTC day, for the full-dump allowance.
+ *
+ * @param {number} [now]
+ * @returns {string}
+ */
+export function startOfUtcDay(now = Date.now()) {
+ const d = new Date(now);
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())).toISOString();
+}
+
diff --git a/apps/web/src/lib/llms.js b/apps/web/src/lib/llms.js
index d261d51..ab26a0a 100644
--- a/apps/web/src/lib/llms.js
+++ b/apps/web/src/lib/llms.js
@@ -1,4 +1,4 @@
-import { q } from '@rssamplifier/db';
+import { q, dataset } from '@rssamplifier/db';
import { db, siteUrl } from './db.js';
@@ -173,10 +173,15 @@ export async function llmsTxt(opts = {}) {
`- [Submit](${base}/api/submit): POST {"url":"..."} or {"urls":[...]} or {"opml":"..."}`,
`- [Discover](${base}/api/discover): POST {"keywords":["..."]} — find blogs by subject`,
`- [Discovery status](${base}/api/discoveries/{id}): progress of one keyword run`,
+ // Listed among the open endpoints because the manifest *is* open — it
+ // describes the corpus, its cadence and the shape of every row without an
+ // account. Only the streams it points at need a licence, and the line says
+ // so rather than letting an agent discover it as a 402.
+ `- [Corpus manifest](${base}/api/dataset): what the whole directory looks like in bulk, sliced ${dataset.WINDOW_HOURS}-hourly as gzipped NDJSON. Open; the bulk streams themselves are licensed — ${base}/sales`,
'',
'## Notes for agents',
'',
- '- Every endpoint sends `access-control-allow-origin: *`. No key is required.',
+ '- Every endpoint above sends `access-control-allow-origin: *` and needs no key. The bulk corpus streams under `/api/dataset/{name}` are the one exception, and nothing else here depends on them.',
'- Summaries are plain text, already stripped of markup.',
'- Each feed has a stable page at /{slug} carrying schema.org Blog or PodcastSeries JSON-LD.',
'- Each author has a page at /authors/{slug} carrying schema.org Person with sameAs.',
diff --git a/apps/web/test/dataset-window.test.js b/apps/web/test/dataset-window.test.js
new file mode 100644
index 0000000..b4dd0c5
--- /dev/null
+++ b/apps/web/test/dataset-window.test.js
@@ -0,0 +1,121 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+ windowStart,
+ windowEnd,
+ latestClosedWindow,
+ resolveWindow,
+ startOfUtcDay,
+} from '../src/lib/datasetWindow.js';
+
+/**
+ * The clock the corpus is sold by.
+ *
+ * Worth testing out of proportion to its size. Every other failure in this
+ * feature is loud — a broken stream 500s, a missing licence 402s — but an
+ * off-by-one here is silent on both sides: the buyer's pipeline walks
+ * boundaries, receives a slice that quietly omits an hour, and neither we nor
+ * they find out until somebody audits a corpus months later. So the cases below
+ * are about the seams: the boundary itself, the moment either side of it, and
+ * every way a caller can name one that does not exist.
+ */
+
+/** 2026-08-29T14:23:11Z — mid-window, on purpose. */
+const MID = Date.parse('2026-08-29T14:23:11.000Z');
+
+test('a moment floors to the window containing it', () => {
+ assert.equal(windowStart(MID), '2026-08-29T12:00:00.000Z');
+});
+
+test('the boundary itself belongs to the window it opens, not the one it closes', () => {
+ // The half-open range is the whole contract: [12:00, 16:00) means a row
+ // stamped exactly 12:00 is in this window and not in the previous one. Get
+ // this wrong in either direction and every boundary row is either duplicated
+ // across two slices or in neither.
+ const boundary = Date.parse('2026-08-29T12:00:00.000Z');
+ assert.equal(windowStart(boundary), '2026-08-29T12:00:00.000Z');
+ assert.equal(windowStart(boundary - 1), '2026-08-29T08:00:00.000Z');
+});
+
+test('windows tile the day without gap or overlap', () => {
+ const seen = [];
+ let at = windowStart(Date.parse('2026-08-29T00:00:00.000Z'));
+ for (let i = 0; i < 6; i += 1) {
+ seen.push(at);
+ const end = windowEnd(at);
+ // The next window starts exactly where this one ended. Anything else is a
+ // gap or an overlap, and both are corpus bugs.
+ assert.equal(windowStart(Date.parse(end)), end);
+ at = end;
+ }
+
+ assert.deepEqual(seen, [
+ '2026-08-29T00:00:00.000Z',
+ '2026-08-29T04:00:00.000Z',
+ '2026-08-29T08:00:00.000Z',
+ '2026-08-29T12:00:00.000Z',
+ '2026-08-29T16:00:00.000Z',
+ '2026-08-29T20:00:00.000Z',
+ ]);
+ // Six four-hour windows land back on midnight: the tiling closes the day.
+ assert.equal(at, '2026-08-30T00:00:00.000Z');
+});
+
+test('the newest window on offer is the one before the one still filling', () => {
+ assert.equal(latestClosedWindow(MID), '2026-08-29T08:00:00.000Z');
+});
+
+test('no window means the newest closed one', () => {
+ const got = resolveWindow(null, MID);
+ assert.equal(got.ok, true);
+ assert.equal(got.start, '2026-08-29T08:00:00.000Z');
+ assert.equal(got.end, '2026-08-29T12:00:00.000Z');
+});
+
+test('an aligned past window is served as asked', () => {
+ const got = resolveWindow('2026-08-29T04:00:00.000Z', MID);
+ assert.equal(got.ok, true);
+ assert.equal(got.start, '2026-08-29T04:00:00.000Z');
+ assert.equal(got.end, '2026-08-29T08:00:00.000Z');
+});
+
+test('an unaligned window is refused rather than rounded', () => {
+ // Rounding here would be the worst possible kindness: the caller labels the
+ // slice with the boundary they asked for, we send them a different one, and
+ // their corpus is mislabelled in a way that only shows up much later as
+ // duplicated rows. Naming the aligned boundary in the refusal is what makes
+ // it fixable on the first run.
+ const got = resolveWindow('2026-08-29T05:30:00.000Z', MID);
+ assert.equal(got.ok, false);
+ assert.equal(got.error, 'unaligned-window');
+ assert.match(got.detail, /2026-08-29T04:00:00\.000Z/);
+});
+
+test('the window containing now has not closed and is refused', () => {
+ const got = resolveWindow('2026-08-29T12:00:00.000Z', MID);
+ assert.equal(got.ok, false);
+ assert.equal(got.error, 'window-not-closed');
+ assert.match(got.detail, /2026-08-29T08:00:00\.000Z/);
+});
+
+test('a future window is refused by the same rule', () => {
+ const got = resolveWindow('2027-01-01T00:00:00.000Z', MID);
+ assert.equal(got.ok, false);
+ assert.equal(got.error, 'window-not-closed');
+});
+
+test('a window that is not a timestamp is refused', () => {
+ const got = resolveWindow('last tuesday', MID);
+ assert.equal(got.ok, false);
+ assert.equal(got.error, 'bad-window');
+});
+
+test('the UTC day starts at midnight UTC wherever the server thinks it is', () => {
+ // The full-dump allowance is a per-UTC-day count, and a server in a westward
+ // timezone using local midnight would hand out a second full dump hours early
+ // — the most expensive query in this feature, doubled, invisibly.
+ assert.equal(startOfUtcDay(MID), '2026-08-29T00:00:00.000Z');
+ assert.equal(startOfUtcDay(Date.parse('2026-08-29T00:00:00.000Z')), '2026-08-29T00:00:00.000Z');
+ assert.equal(startOfUtcDay(Date.parse('2026-08-29T23:59:59.999Z')), '2026-08-29T00:00:00.000Z');
+});
diff --git a/packages/db/index.js b/packages/db/index.js
index c7a1b49..20e7350 100644
--- a/packages/db/index.js
+++ b/packages/db/index.js
@@ -19,3 +19,4 @@ export * as apikeys from './src/apikeys.js';
export * as authors from './src/authors.js';
export * as alerts from './src/alerts.js';
export * as social from './src/social.js';
+export * as dataset from './src/dataset.js';
diff --git a/packages/db/migrations/20260829183852_dataset_access.sql b/packages/db/migrations/20260829183852_dataset_access.sql
new file mode 100644
index 0000000..ecb07ae
--- /dev/null
+++ b/packages/db/migrations/20260829183852_dataset_access.sql
@@ -0,0 +1,150 @@
+-- Paid access to the directory as a training corpus, and the record of who has it.
+--
+-- Everything the API already serves stays open and needs no account: that
+-- promise is written into apps/web/src/lib/apiguard.js and nothing here touches
+-- it. A key still buys rate limit rather than access, /api/feeds still answers a
+-- stranger, and the MCP server still needs nobody's permission.
+--
+-- What is being sold is a different artifact. The open API answers questions one
+-- at a time — this topic, that feed, the last hundred posts — and is shaped for
+-- an agent reading the directory. A training corpus is the opposite shape: every
+-- row, in bulk, cut on a boundary you can name and re-fetch, incrementally, for
+-- months. Serving that from the open endpoints would be a denial of service
+-- against a database whose write path is already its binding constraint, which
+-- is why it was never available for free rather than why it is now paid.
+--
+-- Access is granted by hand, from a conversation that starts at /sales. There is
+-- no checkout and no price anywhere in this repository, deliberately: corpus
+-- licensing is negotiated per buyer — what they may keep, what they may
+-- republish, whether attribution travels with it — and a self-serve button would
+-- be selling terms nobody agreed to.
+
+-- ------------------------------------------------------------------ grants
+--
+-- One row per licensing agreement. Written by an operator after a deal, never by
+-- the application, which is why there is no "create grant" endpoint anywhere in
+-- the web app: the only way to mint one is a hand-written insert against the
+-- production database, and that is the intended amount of friction.
+create table if not exists dataset_grants (
+ id text primary key,
+ user_id text not null references users (id) on delete cascade,
+
+ -- The buyer's own label for the agreement, in our words: 'evaluation',
+ -- 'research', 'commercial'. Free text rather than a check constraint because
+ -- the shape of these deals is not yet known, and a vocabulary guessed now is a
+ -- migration later.
+ plan text not null default 'evaluation',
+
+ -- How many times one dataset may be pulled inside a single four-hour window.
+ -- Not 1: a dump is a long streamed response, and a connection that dies at
+ -- ninety percent must be retryable without waiting four hours for the next
+ -- window. Three is enough for a retry and a mistake, and far short of a loop.
+ per_window_downloads integer not null default 3,
+
+ -- Full-history pulls per UTC day. The expensive one — it walks the whole of
+ -- feed_items rather than one window of it — and the one a buyer needs exactly
+ -- once, at the start, before switching to incrementals forever after.
+ full_dumps_per_day integer not null default 1,
+
+ granted_at text not null,
+ -- Null means open-ended. A fixed-term licence sets it and the gate simply
+ -- stops opening; nothing has to run for it to expire.
+ expires_at text,
+ -- Revoked rather than deleted, for the reason api_keys are: a download that
+ -- turns up in the audit log after access ended is a question somebody will
+ -- want answered, and the answer needs this row to still exist.
+ revoked_at text,
+ note text
+);
+
+create index if not exists dataset_grants_user_idx on dataset_grants (user_id, granted_at desc);
+
+-- ------------------------------------------------------------------ audit
+--
+-- Every gated byte that left, keyed so the cadence limits above are enforced by
+-- counting rows rather than by holding state in a process that gets redeployed
+-- mid-window.
+--
+-- It is also what makes a licence enforceable. "You pulled the full corpus
+-- eleven times in March" is only sayable if somebody wrote it down.
+create table if not exists dataset_downloads (
+ id text primary key,
+ grant_id text not null references dataset_grants (id) on delete cascade,
+ user_id text not null references users (id) on delete cascade,
+ -- Which stream: 'feeds', 'items', 'extracts', 'authors'.
+ dataset text not null,
+ -- The four-hour boundary this pull was cut on, or null for a full dump. Two
+ -- callers asking for the same window get the same rows, so this identifies an
+ -- artifact rather than merely recording a time.
+ window_start text,
+ -- Set together with a null window_start: the full-history pull.
+ full_dump integer not null default 0,
+ -- Which key streamed it, when it was a key rather than a browser session.
+ api_key_id text,
+
+ rows_sent integer not null default 0,
+ -- Written when the stream closes cleanly, so a row with a null completed_at is
+ -- a pull that died partway. That is what distinguishes "they took it three
+ -- times" from "it broke twice", and it is why the retry allowance above can be
+ -- as small as it is.
+ completed_at text,
+ created_at text not null
+);
+
+create index if not exists dataset_downloads_window_idx
+ on dataset_downloads (grant_id, dataset, window_start, created_at desc);
+create index if not exists dataset_downloads_daily_idx
+ on dataset_downloads (grant_id, created_at desc);
+
+-- ------------------------------------------------------------------ enquiries
+--
+-- What the form on /sales writes. Stored rather than only emailed, because
+-- `sendEmail` reports failure instead of throwing, and a sales enquiry that
+-- vanished into a Resend outage is the most expensive message this site can
+-- drop.
+create table if not exists dataset_enquiries (
+ id text primary key,
+ name text,
+ email text not null,
+ org text,
+ -- What they want it for, in their words. The only field that decides anything.
+ use_case text not null,
+
+ -- Salted HMAC, never an address, exactly as `submissions` stores it. Enough to
+ -- spot a flood, useless as personal data.
+ ip_hash text,
+ user_agent text,
+ created_at text not null,
+ -- Set by hand when somebody has replied. A queue nobody can see the bottom of
+ -- is a queue nobody works.
+ handled_at text
+);
+
+create index if not exists dataset_enquiries_created_idx on dataset_enquiries (created_at desc);
+create index if not exists dataset_enquiries_ip_idx on dataset_enquiries (ip_hash, created_at desc);
+
+-- ------------------------------------------------------------------ opt-out
+--
+-- A publisher's veto over being sold as training data, separate from being
+-- indexed at all.
+--
+-- The two are genuinely different asks, and conflating them forces a false
+-- choice on the one party with the strongest claim here. "Link to my blog in
+-- your directory, but do not sell my writing to a model" is a coherent and
+-- increasingly common position; without this column the only way to express it
+-- is to ask for removal, and the directory loses a good feed over a question it
+-- never asked.
+--
+-- Defaults to 0 because the corpus is built from feeds their publishers chose to
+-- syndicate publicly, which is the same basis the directory itself stands on. It
+-- is set on request at hello@rssamplifier.com, and honoured by every stream in
+-- packages/db/src/dataset.js without exception — including items and extracts,
+-- which reach it through the feed a row belongs to rather than carrying their
+-- own copy of the flag.
+alter table feeds add column dataset_opt_out integer not null default 0;
+
+-- Partial, so it costs a few pages rather than an entry per feed. The question
+-- it answers is "who has opted out"; the dump path finds the flag through the
+-- feed's primary key instead and needs no index of its own.
+create index if not exists feeds_dataset_opt_out_idx
+ on feeds (dataset_opt_out) where dataset_opt_out = 1;
diff --git a/packages/db/src/dataset.js b/packages/db/src/dataset.js
new file mode 100644
index 0000000..f8a2b34
--- /dev/null
+++ b/packages/db/src/dataset.js
@@ -0,0 +1,703 @@
+import { newId, nowIso } from './client.js';
+
+/**
+ * The corpus: who may take it, what leaves, and the record of both.
+ *
+ * ## What is actually on offer, because it is not what it first looks like
+ *
+ * The obvious pitch — "4.7 million blog posts, full text" — is not true of this
+ * database and saying it would be the fastest way to make a buyer's first pull
+ * their last. Since `0031_item_body_on_demand.sql`, `feed_items.content_html` is
+ * no longer written: it was 10 GB of a 14 GB database and the size was what made
+ * write slots scarce. Bodies now live in `item_extracts`, which is populated
+ * when a reader opens a post rather than on every crawl.
+ *
+ * So there are two datasets here with genuinely different characters, and the
+ * manifest and the sales page both say so in as many words:
+ *
+ * * `items` — every post record. Title, summary, author, canonical URL,
+ * publication date, and the feed it came from. Millions of rows, growing by
+ * hundreds of thousands a day. Metadata at scale, not prose.
+ * * `extracts` — the article itself, sanitized, for the subset anybody has
+ * read. Hundreds of thousands of rows averaging several thousand characters.
+ * Prose, at a fraction of the row count.
+ *
+ * A buyer who needs the second at the scale of the first is asking us to change
+ * what the crawler stores, which is a conversation and not a query parameter.
+ *
+ * ## Why every stream here is a generator over a keyset cursor
+ *
+ * The same reason `eachFeedForExport` is. OFFSET makes SQLite walk and discard
+ * the rows before it, so paging a 4.7M-row table costs O(n²) row visits; a
+ * cursor compared against the last row seen lets each page start where the
+ * previous one stopped. And a generator means nothing larger than one page is
+ * ever resident, so a full dump does not have to fit in the web service's
+ * memory before the first byte reaches the buyer.
+ *
+ * ## Why the cursors are on rowid and not on id
+ *
+ * `feed_items` and `item_extracts` are ordinary rowid tables whose text primary
+ * key is a unique index rather than the row's address. SQLite stores the rowid
+ * in every index entry, so `feed_items_created_idx (created_at)` is physically
+ * `(created_at, rowid)` — which means a keyset on that exact pair seeks straight
+ * into an index that already exists. A cursor on `(created_at, id)` would need a
+ * new index over 4.7M rows, built at boot by the poller, against a database
+ * whose write path is its binding constraint. The pair is unique because the
+ * rowid is, so no two rows sharing a timestamp can straddle a page boundary
+ * ambiguously.
+ *
+ * @typedef {import('@libsql/client').Client} Client
+ */
+
+/**
+ * The streams a caller may ask for, and how each one is cut.
+ *
+ * Exported because three separate things need to agree on it — the manifest, the
+ * route's validation and the audit log's `dataset` column — and a vocabulary
+ * that lives in three places is a vocabulary that drifts.
+ */
+export const DATASETS = ['feeds', 'items', 'extracts', 'authors'];
+
+/**
+ * How wide a slice is, in hours.
+ *
+ * Four, matching the cadence the corpus is sold on. It is a constant rather than
+ * a parameter because it is a contract: a buyer's incremental pipeline computes
+ * the next boundary itself, and a window that changed width would silently leave
+ * a gap in their corpus that nothing on either side would notice.
+ */
+export const WINDOW_HOURS = 4;
+
+// ------------------------------------------------------------------ grants
+
+/**
+ * The licence this account is currently reading under, or null.
+ *
+ * Expiry and revocation are both evaluated here rather than by a job, so a
+ * lapsed licence stops working at the instant it lapses and nothing has to be
+ * running for that to be true.
+ *
+ * Newest first, so a renewal written beside an expiring row wins without anybody
+ * having to tidy up the old one.
+ *
+ * @param {Client} db
+ * @param {string} userId
+ * @returns {Promise