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