Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/api/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { notFoundResponse } from '../../../lib/data-api/http.ts';

// Unknown API paths and invalid MCP capability paths have one response shape.
export const runtime='nodejs';
export const dynamic='force-dynamic';
export function GET(){return notFoundResponse();}
export const POST=GET;
export const DELETE=GET;
export const PUT=GET;
export const PATCH=GET;
export const OPTIONS=GET;
export const HEAD=GET;
11 changes: 11 additions & 0 deletions app/api/mcp/[secret]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { handleMcp } from '../../../../lib/data-api/mcp.ts';
export const runtime='nodejs';
export const dynamic='force-dynamic';
type Context={params:Promise<{secret:string}>};
export async function POST(request:Request,context:Context){return handleMcp(request,(await context.params).secret);}
export const GET=POST;
export const DELETE=POST;
export const PUT=POST;
export const PATCH=POST;
export const OPTIONS=POST;
export const HEAD=POST;
10 changes: 10 additions & 0 deletions app/api/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { handleMcp } from '../../../lib/data-api/mcp.ts';
export const runtime='nodejs';
export const dynamic='force-dynamic';
export async function POST(request:Request){return handleMcp(request);}
export const GET=POST;
export const DELETE=POST;
export const PUT=POST;
export const PATCH=POST;
export const OPTIONS=POST;
export const HEAD=POST;
11 changes: 11 additions & 0 deletions app/api/v1/ad-networks/[tool]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { handleRest } from '../../../../../lib/data-api/rest.ts';
export const runtime='nodejs';
export const dynamic='force-dynamic';
export async function POST(request:Request,context:{params:Promise<{tool:string}>}){return handleRest(request,(await context.params).tool);}

export const GET=POST;
export const DELETE=POST;
export const PUT=POST;
export const PATCH=POST;
export const OPTIONS=POST;
export const HEAD=POST;
211 changes: 211 additions & 0 deletions lib/data-api/ad-networks/core.ts

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions lib/data-api/ad-networks/definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const definitions = {
listed: "A site is listed when its relationship status is confirmed or stale. A site may be listed for several networks.",
managed: "A managed site has a listed relationship with relationship_type = primary_or_exclusive_manager. Its own ads.txt names that network as manager; this does not prove exclusivity.",
domain_age: "RDAP domain_created_on, not the site's launch date. Age buckets use completed years as of the UTC query date; medians use the public page's age()/31557600 definition. Unknown dates stay unknown.",
top_million: "Chrome UX Report traffic_group in (top-10k, top-100k, top-1m). These groups are exclusive. The imported CrUX month is not stored in the serving tables and is unknown here.",
not_ranked: "Not ranked means absent from the imported CrUX list, not small or zero traffic.",
niches: "stack127's single-label classification; about 88% clearly right in an 80-site hand check. It is an estimate, not an audited industry taxonomy.",
movement: "Observed joins and departures, not contract dates. newly_found is discovery, not a join. Baseline imports are excluded.",
shares: "Group counts are distinct sites. Share is count / that network's filtered distinct-site base, including unknown classifications. Global totals deduplicate across networks; adding network counts can double-count sites.",
};
export const movementWarning = 'STRONG WARNING: join/leave history starts around 2026-10-19. Checker faults caused false departures; blocked/challenged homepages and deferred/consent-held scripts were repaired on 2026-11-13/14. The conservative fault search window begins 2026-09-28; repairs do not prove all historical movements. Do not present these counts as reliable growth, churn, or market share changes.';
export const caveats = [
'Observed first-party signals, not a complete customer census. Ezoic and some manager counts are minimums.',
'ads.txt lists authorized sellers; it does not prove revenue, exclusivity, or an active contract. AdSense and page-signature networks also use homepage checks.',
'Stale means retained after an inconclusive check, not freshly confirmed. Last successful check is not a freshness guarantee for every field.',
'Unknown values remain null. Monthly traffic units and alternative joining rules are not interchangeable; read each network’s cited rules.',
movementWarning,
];
export const sourceUrl = 'https://stack127.com/ad-networks';
48 changes: 48 additions & 0 deletions lib/data-api/ad-networks/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { z } from 'zod';
const slug = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(80);
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(s => !Number.isNaN(Date.parse(s)) && new Date(s).toISOString().slice(0,10) === s, 'Use a real YYYY-MM-DD date');
export const dimensions = ['network','niche','registration_year','age_bucket','size_group','platform'] as const;
export const filtersShape = {
networks: z.array(slug).min(1).max(44).optional().describe('Network slugs, matched with OR. Example: ["mediavine","ezoic"]. Omit for all.'),
relationship: z.enum(['managed','listed']).default('listed').describe('managed = listed primary_or_exclusive_manager; listed = confirmed or stale.'),
niche: z.string().max(80).optional().describe('One stack127 niche slug; see overview. Example: food. Unknown values match no sites.'),
registered_after: date.optional().describe('Exclusive RDAP registration lower bound. Example: 2020-01-01.'),
registered_before: date.optional().describe('Exclusive RDAP registration upper bound. Example: 2026-01-01.'),
min_age: z.number().min(0).max(100).optional().describe('Minimum completed years since RDAP registration, inclusive. Example: 3.'),
max_age: z.number().min(0).max(100).optional().describe('Maximum completed years since RDAP registration, inclusive. Example: 10.'),
size_group: z.enum(['top-10k','top-100k','top-1m','not-ranked']).optional().describe('CrUX size group. not-ranked does not mean small. Example: top-1m.'),
platform: z.string().max(80).optional().describe('Platform value from overview. Example: WordPress. Exact, case-sensitive.'),
status: z.enum(['confirmed','stale']).optional().describe('Further restrict listed status. Ended relationships are available only in lookup_site and movements.'),
};
export const toolSchemas = {
ad_networks_overview: z.object({}).strict(),
list_ad_networks: z.object({
max_minimum_traffic: z.number().int().nonnegative().optional().describe('Maximum published monthly minimum. Unknown minimums are excluded; units differ, use traffic_unit. Example: 50000.'),
traffic_unit: z.enum(['sessions','pageviews','visitors','visits','monthly active users']).optional().describe('Only minimums in this exact unit. Example: sessions.'),
has_public_rule: z.boolean().optional().describe('Whether a numeric traffic minimum is public. null is unknown, never zero.'),
sort: z.enum(['listed','managed','name','minimum_traffic']).default('listed').describe('Descending counts, ascending names/minimums. Unknown minimums last.'),
}).strict(),
get_ad_network: z.object({ network: slug.describe('Network slug from overview. Example: mediavine.') }).strict(),
compare_ad_networks: z.object({ ...filtersShape,
networks: z.array(slug).min(2).max(6).describe('2–6 distinct network slugs. Example: ["mediavine","ezoic"].'),
dimension: z.enum(['niche','registration_year','age_bucket','size_group','platform']).describe('One comparison dimension. Example: niche.'),
}).strict(),
count_sites: z.object({ ...filtersShape,
group_by: z.array(z.enum(dimensions)).max(2).default([]).describe('Up to two distinct dimensions. Example: ["network","niche"]. Empty returns only the distinct-site total.'),
}).strict(),
find_sites: z.object({ ...filtersShape,
sort: z.enum(['domain','newest','oldest']).default('domain').describe('Stable domain/date order. Example: newest. Missing dates sort last.'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum sites, further capped by server setting. Example: 50.'),
cursor: z.string().max(1000).optional().describe('Opaque next_cursor from the same query. Omit on the first page.'),
}).strict(),
lookup_site: z.object({domain: z.string().max(253).describe('Bare hostname, without a URL or path. Example: example.com. IDN hostnames accepted.')}).strict(),
network_movements: z.object({
networks: z.array(slug).min(1).max(44).optional().describe('Optional network slugs. Example: ["mediavine"].'),
from: date.describe('Inclusive UTC start day. Example: 2026-10-19.'),
to: date.describe('Inclusive UTC end day, at most 366 days after from. Example: 2026-11-14.'),
}).strict(),
run_readonly_sql: z.object({sql: z.string().min(1).max(12000).describe('A single SELECT, no CTEs or comments. Only stack127_data views; see tool description. Example: SELECT slug,name FROM stack127_data.ad_networks ORDER BY slug')}).strict(),
};
export type ToolName = keyof typeof toolSchemas;
export type Filters = z.infer<typeof toolSchemas.count_sites>;
export const toolNames = Object.keys(toolSchemas) as ToolName[];
34 changes: 34 additions & 0 deletions lib/data-api/ad-networks/sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { parse } from 'pgsql-ast-parser';
import { ApiError } from '../config.ts';
export const sqlSchema = `Only these read-only views exist in stack127_data:
ad_networks(id,slug,name,official_url,summary);
ad_network_publishers(id,normalized_domain,niche,platform,domain_created_on,traffic_group,last_successful_check_at,ads_txt_summary);
ad_network_publisher_relationships(ad_network_id,publisher_id,status,relationship_type,first_confirmed_at,last_confirmed_at,ended_at);
ad_network_relationship_events(ad_network_id,publisher_id,event,observed_at,relationship_type);
ad_network_daily_counts(ad_network_id,snapshot_on,listed_count,confirmed_count,stale_count,captured_at).
Use schema-qualified view names. No CTEs, comments, locks, arrays, collection aggregates, system catalogs, arbitrary functions or casts. SELECT/subqueries/joins/aggregates are supported. Safe functions: count,sum,avg,min,max,round,coalesce,nullif,lower,upper,abs,date_trunc,extract. Hard row cap and timeout apply.`;
const tables = new Set(['ad_networks','ad_network_publishers','ad_network_publisher_relationships','ad_network_relationship_events','ad_network_daily_counts']);
const funcs = new Set(['count','sum','avg','min','max','round','coalesce','nullif','lower','upper','abs','date_trunc','extract']);
const types = new Set(['select','table','ref','call','integer','numeric','string','boolean','null','binary','unary','cast','case','extract','list','statement','INNER JOIN','LEFT JOIN','RIGHT JOIN','FULL JOIN','CROSS JOIN']);
export function validateReadonlySql(sql: string) {
const reject = () => { throw new ApiError(400,'invalid_sql','Use a single SELECT over the documented stack127_data views and safe functions.'); };
if (/--|\/\*|;|\$|\\/.test(sql)) reject();
let statements;
try { statements = parse(sql); } catch { return reject(); }
if (statements.length !== 1 || statements[0].type !== 'select') reject();
function walk(value: unknown) {
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) { value.forEach(walk); return; }
const node = value as Record<string, any>;
if ('type' in node && !types.has(node.type)) reject();
if (node.type === 'select' && (node.with || node.into || node.for || node.skip)) reject();
if (node.type === 'table' && (node.name?.schema !== 'stack127_data' || !tables.has(node.name?.name))) reject();
if (node.type === 'call' && (node.function?.schema || !funcs.has(node.function?.name?.toLowerCase()))) reject();
if (node.type === 'cast' && (node.to?.schema || !['text','int','integer','bigint','numeric','float','date','timestamp','boolean'].includes(node.to?.name))) reject();
// Qualified operators and alternate namespace escapes are not part of this SQL subset.
if (node.op && !['=','!=','<>','<','>','<=','>=','+','-','*','/','%','AND','OR','IN','NOT IN','LIKE','ILIKE','NOT LIKE','NOT ILIKE','IS','IS NOT','NOT','IS NULL','IS NOT NULL'].includes(node.op)) reject();
Object.values(node).forEach(walk);
}
walk(statements[0]);
return sql;
}
19 changes: 19 additions & 0 deletions lib/data-api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { ApiError } from './config.ts';
export type Principal = { id: string; plan: 'private'; allowSql: boolean };
export type Authenticator = (request: Request, pathSecret?: string) => Promise<Principal>;
export function matchesSecret(actual: string, expected: string | undefined) {
if (!expected || !/^[A-Za-z0-9_-]{43,256}$/.test(expected) || actual.length > 512) return false;
return timingSafeEqual(createHash('sha256').update(actual).digest(), createHash('sha256').update(expected).digest());
}
export const authenticate: Authenticator = async (request, pathSecret) => {
// A wrong capability path is an unknown endpoint, even with a valid header.
if (pathSecret !== undefined && !matchesSecret(pathSecret,process.env.STACK127_MCP_PATH_SECRET)) throw new ApiError(404,'not_found','Not found.');
const header = request.headers.get('authorization');
// A supplied invalid header never falls back to a URL credential.
const ok = header !== null
? /^Bearer [A-Za-z0-9_-]+$/.test(header) && matchesSecret(header.slice(7), process.env.STACK127_MCP_TOKEN)
: pathSecret !== undefined && matchesSecret(pathSecret, process.env.STACK127_MCP_PATH_SECRET);
if (!ok) throw new ApiError(401, 'unauthorized', 'Authentication required.');
return { id: 'private-owner', plan: 'private', allowSql: true };
};
20 changes: 20 additions & 0 deletions lib/data-api/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export class ApiError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) { super(message); this.status=status; this.code=code; }
}
export function integerEnv(name: string, fallback: number, max: number) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const n = Number(raw);
if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new ApiError(503, 'configuration', 'Invalid data API configuration.');
return n;
}
export const enabled = (name: string) => process.env[name] === 'true';
export const rowLimit = () => integerEnv('STACK127_DATA_MAX_ROWS', 200, 200);
// SQL must never fall back to the application's broadly privileged login.
export const sqlEnabled = () => enabled('STACK127_DATA_SQL_ENABLED') && Boolean(process.env.STACK127_DATA_SQL_DATABASE_URL?.trim());
export const MAX_ANSWER_BYTES = 1_000_000;
// Public plans must add their own authenticator and authorization policy here.
// There is deliberately no env flag that turns anonymous/public access on.
export const PUBLIC_MODE = false;
63 changes: 63 additions & 0 deletions lib/data-api/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Pool } from 'pg';
import type { QueryResultRow } from 'pg';
import { observePoolErrors } from '../database-errors.ts';
import { ApiError, integerEnv, sqlEnabled } from './config.ts';
import type { Principal } from './auth.ts';
const globalDb = globalThis as typeof globalThis & { stack127DataApiPool?: Pool; stack127DataSqlPool?: Pool };
export function dataPool() {
if (!globalDb.stack127DataApiPool) {
const connectionString=process.env.DATABASE_POOLER_URL || process.env.DATABASE_URL;
if(!connectionString)throw new ApiError(503,'configuration','Database is not configured.');
globalDb.stack127DataApiPool = new Pool({ connectionString,
max: 2, connectionTimeoutMillis: 15000, idleTimeoutMillis: 10000, query_timeout: 20000 });
observePoolErrors(globalDb.stack127DataApiPool);
}
return globalDb.stack127DataApiPool;
}
function sqlPool() {
if (!sqlEnabled()) throw new ApiError(404,'not_found','Not found.');
if (!globalDb.stack127DataSqlPool) {
globalDb.stack127DataSqlPool = new Pool({ connectionString: process.env.STACK127_DATA_SQL_DATABASE_URL,
max: 1, connectionTimeoutMillis: 15000, idleTimeoutMillis: 10000, query_timeout: 20000 });
observePoolErrors(globalDb.stack127DataSqlPool);
}
return globalDb.stack127DataSqlPool;
}
export type Reader = { query: <T extends QueryResultRow = QueryResultRow>(sql: string, values?: unknown[]) => Promise<T[]> };
async function transaction<T>(pool: Pool, run: (db: Reader) => Promise<T>): Promise<T> {
const client = await pool.connect();
let broken = false, committed = false;
try {
await client.query('begin transaction isolation level repeatable read read only');
await client.query("set local search_path = stack127_data, pg_catalog");
await client.query("set local timezone = 'UTC'");
await client.query("select set_config('statement_timeout', $1, true)", [String(integerEnv('STACK127_DATA_TIMEOUT_MS', 15000, 15000))]);
const result = await run({ query: async <R extends QueryResultRow>(sql: string, values?: unknown[]) => (await client.query<R>(sql, values)).rows });
await client.query('commit');
committed = true;
return result;
} finally {
if(!committed)try { await client.query('rollback'); } catch { broken = true; }
client.release(broken);
}
}
export function readTransaction<T>(run: (db: Reader) => Promise<T>): Promise<T> {
return transaction(dataPool(), run);
}
export function sqlReadTransaction<T>(run: (db: Reader) => Promise<T>): Promise<T> {
return transaction(sqlPool(), run);
}
// One atomic statement; fixed UTC minute/day windows, shared by both front doors.
export async function consumeRateLimit(principal: Principal) {
const result = await dataPool().query(`insert into stack127_api.rate_limits as r
(principal,minute_on,day_on,minute_count,day_count)
values ($1,date_trunc('minute',now()),date_trunc('day',now() at time zone 'UTC') at time zone 'UTC',1,1)
on conflict(principal) do update set
minute_on=excluded.minute_on,day_on=excluded.day_on,
minute_count=case when r.minute_on=excluded.minute_on then r.minute_count+1 else 1 end,
day_count=case when r.day_on=excluded.day_on then r.day_count+1 else 1 end
where (r.minute_on<>excluded.minute_on or r.minute_count<$2)
and (r.day_on<>excluded.day_on or r.day_count<$3) returning principal`,
[principal.id,integerEnv('STACK127_DATA_RPM',120,10000),integerEnv('STACK127_DATA_RPD',10000,1000000)]);
if (!result.rowCount) throw new ApiError(429,'rate_limit','Request limit reached. Try again in the next UTC window.');
}
Loading
Loading