diff --git a/app/api/[...path]/route.ts b/app/api/[...path]/route.ts new file mode 100644 index 00000000..aae10f17 --- /dev/null +++ b/app/api/[...path]/route.ts @@ -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; diff --git a/app/api/mcp/[secret]/route.ts b/app/api/mcp/[secret]/route.ts new file mode 100644 index 00000000..05052167 --- /dev/null +++ b/app/api/mcp/[secret]/route.ts @@ -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; diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts new file mode 100644 index 00000000..cece5872 --- /dev/null +++ b/app/api/mcp/route.ts @@ -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; diff --git a/app/api/v1/ad-networks/[tool]/route.ts b/app/api/v1/ad-networks/[tool]/route.ts new file mode 100644 index 00000000..f9443a60 --- /dev/null +++ b/app/api/v1/ad-networks/[tool]/route.ts @@ -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; diff --git a/lib/data-api/ad-networks/core.ts b/lib/data-api/ad-networks/core.ts new file mode 100644 index 00000000..fdd0fa87 --- /dev/null +++ b/lib/data-api/ad-networks/core.ts @@ -0,0 +1,211 @@ +import { createHash } from 'node:crypto'; +import { domainToASCII } from 'node:url'; +import { adNetworkFacts } from '../../../app/(site)/(directories)/ad-networks/network-facts.ts'; +import { adNetworkNiches } from '../../ad-network-niches.ts'; +import { ApiError, MAX_ANSWER_BYTES, rowLimit, sqlEnabled } from '../config.ts'; +import { readTransaction, sqlReadTransaction } from '../database.ts'; +import type { Reader } from '../database.ts'; +import { definitions, caveats, sourceUrl } from './definitions.ts'; +import { toolSchemas, toolNames } from './schemas.ts'; +import type { Filters, ToolName } from './schemas.ts'; +import { validateReadonlySql } from './sql.ts'; +const listed = "r.status in ('confirmed','stale')"; +export const ageYears = 'extract(year from age(current_date,p.domain_created_on))'; +const ageBucket = `case when p.domain_created_on is null then null when ${ageYears}<3 then '0–2 years' when ${ageYears}<6 then '3–5 years' when ${ageYears}<11 then '6–10 years' when ${ageYears}<16 then '11–15 years' else '16+ years' end`; +const dimensionSql = { network:'n.slug',niche:'p.niche',registration_year:'extract(year from p.domain_created_on)::int',age_bucket:ageBucket,size_group:'p.traffic_group',platform:'p.platform' }; +export function filterSql(f: Partial) { + const values: unknown[] = []; + const add = (v: unknown) => { values.push(v); return `$${values.length}`; }; + const conditions = [listed]; + if (f.networks) conditions.push(`n.slug=any(${add(f.networks)}::text[])`); + if (f.relationship === 'managed') conditions.push("r.relationship_type='primary_or_exclusive_manager'"); + if (f.status) conditions.push(`r.status=${add(f.status)}`); + if (f.niche) conditions.push(`p.niche=${add(f.niche)}`); + if (f.platform) conditions.push(`p.platform=${add(f.platform)}`); + if (f.size_group) conditions.push(`p.traffic_group=${add(f.size_group)}`); + if (f.registered_after) conditions.push(`p.domain_created_on>${add(f.registered_after)}::date`); + if (f.registered_before) conditions.push(`p.domain_created_on<${add(f.registered_before)}::date`); + if (f.min_age !== undefined) conditions.push(`${ageYears}>=${add(f.min_age)}`); + if (f.max_age !== undefined) conditions.push(`${ageYears}<=${add(f.max_age)}`); + return { text: conditions.join(' and '), values }; +} +const joins = 'from ad_network_publishers p join ad_network_publisher_relationships r on r.publisher_id=p.id join ad_networks n on n.id=r.ad_network_id'; +export function normalizeDomain(input: string) { + const domain = domainToASCII(input.trim().toLowerCase().replace(/\.$/,'')); + if (domain.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain)) { + throw new ApiError(400,'invalid_domain','Use a bare domain such as example.com.'); + } + return domain; +} +export function validateInput(name: ToolName, input: unknown): Record { + const parsed = toolSchemas[name].safeParse(input); + if (!parsed.success) throw new ApiError(400,'invalid_input','Invalid input. Check the tool schema, types, and allowed values.'); + const f = parsed.data as Record; + if ((f.registered_after && f.registered_before && f.registered_after>=f.registered_before) + || (f.min_age !== undefined && f.max_age !== undefined && f.min_age>f.max_age) + || (f.networks && new Set(f.networks).size!==f.networks.length) + || (f.group_by && new Set(f.group_by).size!==f.group_by.length) + || (f.from && (f.from>f.to || Date.parse(f.to)-Date.parse(f.from)>366*86400000))) throw new ApiError(400,'invalid_input','Invalid range or duplicate dimension/network.'); + if (name==='lookup_site') f.domain=normalizeDomain(f.domain); + if (name==='run_readonly_sql') validateReadonlySql(f.sql); + return f; +} +async function grouped(db: Reader, f: Partial, dimensions: (keyof typeof dimensionSql)[], cap: number) { + const w=filterSql(f); + const base=(await db.query(`select count(distinct p.id)::int as total ${joins} where ${w.text}`,w.values))[0].total as number; + if (!dimensions.length) return { total:base,base:'distinct sites matching all filters; networks are OR',rows:[],truncated:false }; + const selections=dimensions.map((d,i)=>`${dimensionSql[d]} as group_${i+1}`); + const rows=await db.query(`select ${selections.join(',')},count(distinct p.id)::int as count ${joins} + where ${w.text} group by ${dimensions.map((_,i)=>i+1).join(',')} + order by count desc,${dimensions.map((_,i)=>`${i+1} nulls last`).join(',')} limit ${cap+1}`,w.values); + const truncated=rows.length>cap; + const networkBases=dimensions.includes('network') ? await db.query(`select n.slug,count(distinct p.id)::int as total ${joins} where ${w.text} group by n.slug`,w.values) : []; + return { total:base,base:'distinct filtered sites; network shares use their own filtered base; null groups include unknowns',dimensions, + network_bases:dimensions.includes('network')?Object.fromEntries((f.networks??networkBases.map(n=>n.slug)).map(slug=>[slug,networkBases.find(n=>n.slug===slug)?.total??0])):undefined, + rows:rows.slice(0,cap).map(row=>{ + const networkIndex=dimensions.indexOf('network'); + const denominator=networkIndex<0 ? base : networkBases.find(n=>n.slug===row[`group_${networkIndex+1}`])?.total ?? 0; + return {...Object.fromEntries(dimensions.map((d,i)=>[d,row[`group_${i+1}`]])),count:row.count,base:denominator,share_percent:denominator ? row.count/denominator*100 : 0}; + }),truncated }; +} +type NetworkRow = {slug:string;name:string;official_url:string|null;summary:string|null;listed:number;managed:number;confirmed:number;stale:number;median_domain_age_years:number|null;domain_age_base:number}; +async function networks(db: Reader) { + return (await db.query(`select n.slug,n.name,n.official_url,n.summary, + count(r.publisher_id) filter(where ${listed})::int as listed, + count(r.publisher_id) filter(where ${listed} and r.relationship_type='primary_or_exclusive_manager')::int as managed, + count(r.publisher_id) filter(where r.status='confirmed')::int as confirmed, + count(r.publisher_id) filter(where r.status='stale')::int as stale, + percentile_cont(0.5) within group(order by extract(epoch from age(current_date,p.domain_created_on))/31557600) + filter(where ${listed} and p.domain_created_on is not null)::float8 as median_domain_age_years, + count(r.publisher_id) filter(where ${listed} and p.domain_created_on is not null)::int as domain_age_base + from ad_networks n left join ad_network_publisher_relationships r on r.ad_network_id=n.id + left join ad_network_publishers p on p.id=r.publisher_id group by n.id,n.slug,n.name,n.official_url,n.summary order by n.slug limit 201`)).map(n=>({ + ...n,facts:adNetworkFacts[n.slug]??null,url:`${sourceUrl}/${n.slug}`, + })); +} +const siteFields = 'p.normalized_domain as domain,p.niche,p.platform,p.domain_created_on::text,p.traffic_group as size_group,p.last_successful_check_at'; +const membership = `coalesce((select jsonb_agg(jsonb_build_object('network',n.slug,'status',r.status,'relationship_type',r.relationship_type, + 'first_confirmed_at',r.first_confirmed_at,'last_confirmed_at',r.last_confirmed_at,'ended_at',r.ended_at) order by n.slug) + from ad_network_publisher_relationships r join ad_networks n on n.id=r.ad_network_id + where r.publisher_id=p.id and r.status in ('confirmed','stale')),'[]'::jsonb) as networks`; +const querySignature = (f: Record) => createHash('sha256').update(JSON.stringify({...f,cursor:undefined,limit:undefined})).digest('hex'); +export function decodeCursor(cursor: string, signature: string) { + try { + const c=JSON.parse(Buffer.from(cursor,'base64url').toString()); + if (c.signature!==signature || typeof c.domain!=='string' || c.domain.length>253 || !(c.date===null || /^\d{4}-\d{2}-\d{2}$/.test(c.date))) throw Error(); + return c as {domain:string;date:string|null}; + } catch { throw new ApiError(400,'invalid_cursor','Use next_cursor with the same filters and sort.'); } +} +async function find(db: Reader,f: Record,cap: number) { + const w=filterSql(f), limit=Math.min(cap,f.limit), signature=querySignature(f); + const values=[...w.values]; + const bind=(v: unknown) => {values.push(v);return `$${values.length}`;}; + let after=''; + if (f.cursor) { + const c=decodeCursor(f.cursor,signature),domain=bind(c.domain); + if (f.sort==='domain') after=`and p.normalized_domain>${domain}`; + else if (c.date===null) after=`and p.domain_created_on is null and p.normalized_domain>${domain}`; + else { + const date=bind(c.date),op=f.sort==='newest'?'<':'>'; + after=`and (p.domain_created_on${op}${date}::date or p.domain_created_on is null or (p.domain_created_on=${date}::date and p.normalized_domain>${domain}))`; + } + } + const order=f.sort==='domain'?'p.normalized_domain':`p.domain_created_on ${f.sort==='newest'?'desc':'asc'} nulls last,p.normalized_domain`; + const rows=await db.query(`select ${siteFields},${membership} from ad_network_publishers p + where exists(select 1 from ad_network_publisher_relationships r join ad_networks n on n.id=r.ad_network_id where r.publisher_id=p.id and ${w.text}) + ${after} order by ${order} limit ${limit+1}`,values); + const hasMore=rows.length>limit, sites=rows.slice(0,limit),last=sites.at(-1); + return {sites,limit,next_cursor:hasMore&&last ? Buffer.from(JSON.stringify({signature,domain:last.domain,date:last.domain_created_on})).toString('base64url'):null, + base:'distinct sites matching filters; networks on each row show all listed memberships, not only matched networks'}; +} +export function boundResults(data: Record,cap: number) { + const sets: {parent:Record;key:string;path:string}[]=[]; + for(const key of ['networks','rows','sites','relationships','events','movements','co_listed','daily_counts']) { + if(Array.isArray(data[key]))sets.push({parent:data,key,path:key}); + } + if(data.distributions)for(const [key,value] of Object.entries(data.distributions)) { + const parent=value as Record; + if(Array.isArray(parent.rows))sets.push({parent,key:'rows',path:`distributions.${key}.rows`}); + } + const counts=sets.map(()=>0);let left=cap; + while(left>0){let added=false;sets.forEach((set,i)=>{if(left>0&&counts[i]{if(set.parent[set.key].length>counts[i]){set.parent[set.key]=set.parent[set.key].slice(0,counts[i]);set.parent.truncated=true;truncatedSets.push(set.path);}}); + return {row_count:cap-left,truncated_sets:truncatedSets}; +} +export type DataAnswer = { as_of: unknown; queried_at: string; definitions: typeof definitions; caveats: string[]; filters: Record; url: string; data: Record; limits: { max_rows: number; row_count:number; truncated: boolean; truncated_sets:string[] } }; +export async function queryTool(db: Reader,name: ToolName,f: Record,cap: number): Promise> { + switch(name) { + case 'ad_networks_overview': { + const stats=(await db.query('select * from dataset_summary'))[0]; + const totals=(await db.query(`select count(distinct publisher_id)::int as listed,count(distinct publisher_id) filter(where relationship_type='primary_or_exclusive_manager')::int as managed from ad_network_publisher_relationships r where ${listed}`))[0]; + return { ...stats,...totals,measurement:'We check the publisher’s ads.txt for seller signals and MANAGERDOMAIN. AdSense and page-signature networks also require homepage signals. Discovery lists alone do not confirm a relationship.', + available_values:{niche:adNetworkNiches.map(n=>({slug:n.slug,name:n.name})),size_group:['top-10k','top-100k','top-1m','not-ranked'], + platform:(await db.query(`select distinct platform from ad_network_publishers where platform is not null order by platform limit 200`)).map(r=>r.platform), + networks:(await db.query(`select slug,name from ad_networks order by slug limit 200`))}, + tools:{ad_networks_overview:'Start here for definitions, coverage and values.',list_ad_networks:'Joining and payment facts and network totals.',get_ad_network:'Full network profile and distributions.',compare_ad_networks:'One dimension side by side.',count_sites:'Distinct counts with filters and up to two groups.',find_sites:'Bounded site list with cursor.',lookup_site:'One domain with current and past memberships.',network_movements:'Observed events with a strong quality warning.',...(sqlEnabled()?{run_readonly_sql:'Private SQL for documented safe views only.'}:{})}}; + } + case 'list_ad_networks': { + let rows=await networks(db); + rows=rows.filter(n=> (f.max_minimum_traffic===undefined || (n.facts?.minTraffic!=null && n.facts.minTraffic.amount<=f.max_minimum_traffic)) + && (f.traffic_unit===undefined || n.facts?.minTraffic?.unit===f.traffic_unit) + && (f.has_public_rule===undefined || (n.facts?.minTraffic!=null)===f.has_public_rule)); + rows.sort((a,b)=>f.sort==='name'?a.name.localeCompare(b.name):f.sort==='minimum_traffic'?(a.facts?.minTraffic?.amount??Infinity)-(b.facts?.minTraffic?.amount??Infinity)||a.slug.localeCompare(b.slug):(f.sort==='managed'?b.managed-a.managed:b.listed-a.listed)||a.slug.localeCompare(b.slug)); + return {networks:rows.slice(0,cap),truncated:rows.length>cap,has_public_rule:'Published numeric monthly minimum; unknown is not zero. Full joining facts may describe alternatives.'}; + } + case 'get_ad_network': { + const n=(await networks(db)).find(n=>n.slug===f.network);if(!n)throw new ApiError(404,'not_found','Unknown network slug.'); + const distributions:Record={}; + for(const dim of ['age_bucket','registration_year','niche','size_group','platform'] as const) distributions[dim]=await grouped(db,{networks:[f.network]},[dim],cap); + const co=await db.query(`select n.slug,count(distinct r.publisher_id)::int as count from ad_network_publisher_relationships r + join ad_networks n on n.id=r.ad_network_id where ${listed} and n.slug<>$1 and r.publisher_id in + (select r.publisher_id from ad_network_publisher_relationships r join ad_networks n on n.id=r.ad_network_id where n.slug=$1 and ${listed}) + group by n.slug order by count desc,n.slug limit ${cap+1}`,[f.network]); + const daily=await db.query(`select snapshot_on::text,listed_count,confirmed_count,stale_count,captured_at from ad_network_daily_counts d + join ad_networks n on n.id=d.ad_network_id where n.slug=$1 order by snapshot_on desc limit ${cap+1}`,[f.network]); + return {network:n,distributions,co_listed:co.slice(0,cap),daily_counts:daily.slice(0,cap),truncated:co.length>cap||daily.length>cap,history_order:'newest first; gaps are missing observations, never zero; snapshots are listed counts, not managed counts'}; + } + case 'count_sites': return grouped(db,f,f.group_by,cap); + case 'compare_ad_networks': return grouped(db,f,['network',f.dimension],cap); + case 'find_sites': return find(db,f,cap); + case 'lookup_site': { + const rows=await db.query(`select p.id,${siteFields},${ageYears}::int as domain_age_years,p.ads_txt_summary from ad_network_publishers p where p.normalized_domain=$1 limit 1`,[f.domain]); + if(!rows.length)throw new ApiError(404,'not_found','Domain not found in observed relationships.'); + const {id,...site}=rows[0]; + const relationships=await db.query(`select n.slug as network,r.status,r.relationship_type,r.first_confirmed_at,r.last_confirmed_at,r.ended_at + from ad_network_publisher_relationships r join ad_networks n on n.id=r.ad_network_id where r.publisher_id=$1 order by n.slug limit ${cap+1}`,[id]); + const events=await db.query(`select n.slug as network,e.event,e.observed_at,e.relationship_type from ad_network_relationship_events e + join ad_networks n on n.id=e.ad_network_id where e.publisher_id=$1 order by e.observed_at desc,n.slug,e.event limit ${cap+1}`,[id]); + return {site,relationships:relationships.slice(0,cap),events:events.slice(0,cap),truncated:relationships.length>cap||events.length>cap,history_order:'newest first'}; + } + case 'network_movements': { + const rows=await db.query(`select n.slug as network,(e.observed_at at time zone 'UTC')::date::text as date, + count(*) filter(where e.event='joined')::int as joined,count(*) filter(where e.event='left')::int as left, + count(distinct e.publisher_id) filter(where e.event='newly_found')::int as newly_found + from ad_network_relationship_events e join ad_networks n on n.id=e.ad_network_id + where e.observed_at>=($1::date::timestamp at time zone 'UTC') and e.observed_at<(($2::date+1)::timestamp at time zone 'UTC') + and ($3::text[] is null or n.slug=any($3)) group by 1,2 order by 2 desc,1 limit ${cap+1}`,[f.from,f.to,f.networks??null]); + return {movements:rows.slice(0,cap),truncated:rows.length>cap,known_fault_window:{search_from:'2026-09-28',repair_days:['2026-11-13','2026-11-14'],reliable_churn_series:false},base:'observed events per network and UTC date; missing dates do not imply zero'}; + } + case 'run_readonly_sql': { + if(!sqlEnabled())throw new ApiError(404,'disabled','SQL tool disabled.'); + const rows=await db.query(`select * from (${validateReadonlySql(f.sql)}) as bounded_data_query limit ${cap+1}`); + return {rows:rows.slice(0,cap),truncated:rows.length>cap,base:'defined by the SELECT; inspect aggregates for distinct-site counting'}; + } + } +} +export async function executeTool(name: ToolName,input: unknown,transaction?: typeof readTransaction): Promise { + if (!toolNames.includes(name)) throw new ApiError(404,'not_found','Unknown tool.'); + if (name==='run_readonly_sql' && !sqlEnabled()) throw new ApiError(404,'not_found','Not found.'); + const f=validateInput(name,input),cap=rowLimit(); + return (transaction ?? (name==='run_readonly_sql' ? sqlReadTransaction : readTransaction))(async db=>{ + const freshness=(await db.query('select * from dataset_summary'))[0]; + const data=await queryTool(db,name,f,cap); + const budget=boundResults(data,cap); + const result={as_of:freshness.as_of,queried_at:new Date().toISOString(),definitions,caveats,filters:f, + url:name==='get_ad_network'?`${sourceUrl}/${f.network}`:name==='lookup_site'?`https://stack127.com/ad-network-checker?domain=${encodeURIComponent(f.domain)}`:name==='network_movements'?'https://stack127.com/ad-networks/stats':sourceUrl, + data,limits:{max_rows:cap,...budget,truncated:JSON.stringify(data).includes('"truncated":true')}}; + if(Buffer.byteLength(JSON.stringify(result))>MAX_ANSWER_BYTES)throw new ApiError(413,'answer_too_large','Answer exceeds 1 MB. Narrow the query or lower the row limit.'); + return result; + }); +} diff --git a/lib/data-api/ad-networks/definitions.ts b/lib/data-api/ad-networks/definitions.ts new file mode 100644 index 00000000..1d4e4047 --- /dev/null +++ b/lib/data-api/ad-networks/definitions.ts @@ -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'; diff --git a/lib/data-api/ad-networks/schemas.ts b/lib/data-api/ad-networks/schemas.ts new file mode 100644 index 00000000..3b11056a --- /dev/null +++ b/lib/data-api/ad-networks/schemas.ts @@ -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; +export const toolNames = Object.keys(toolSchemas) as ToolName[]; diff --git a/lib/data-api/ad-networks/sql.ts b/lib/data-api/ad-networks/sql.ts new file mode 100644 index 00000000..fcc2c0e9 --- /dev/null +++ b/lib/data-api/ad-networks/sql.ts @@ -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; + 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; +} diff --git a/lib/data-api/auth.ts b/lib/data-api/auth.ts new file mode 100644 index 00000000..b1589782 --- /dev/null +++ b/lib/data-api/auth.ts @@ -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; +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 }; +}; diff --git a/lib/data-api/config.ts b/lib/data-api/config.ts new file mode 100644 index 00000000..3b7cd8c5 --- /dev/null +++ b/lib/data-api/config.ts @@ -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; diff --git a/lib/data-api/database.ts b/lib/data-api/database.ts new file mode 100644 index 00000000..35abe8e4 --- /dev/null +++ b/lib/data-api/database.ts @@ -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: (sql: string, values?: unknown[]) => Promise }; +async function transaction(pool: Pool, run: (db: Reader) => Promise): Promise { + 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 (sql: string, values?: unknown[]) => (await client.query(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(run: (db: Reader) => Promise): Promise { + return transaction(dataPool(), run); +} +export function sqlReadTransaction(run: (db: Reader) => Promise): Promise { + 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.'); +} diff --git a/lib/data-api/http.ts b/lib/data-api/http.ts new file mode 100644 index 00000000..608b919c --- /dev/null +++ b/lib/data-api/http.ts @@ -0,0 +1,37 @@ +import { authenticate } from './auth.ts'; +import type { Principal } from './auth.ts'; +import { ApiError, enabled, sqlEnabled } from './config.ts'; +import { consumeRateLimit } from './database.ts'; +import { privateHeaders, notFoundResponse } from './private-response.mjs'; +export { privateHeaders, notFoundResponse }; +export function errorResponse(error: unknown) { + const e=error instanceof ApiError?error:new ApiError(503,'unavailable','Data service unavailable.'); + if (e.status===404) return notFoundResponse(); + return Response.json({error:{code:e.code,message:e.message}},{status:e.status,headers:{...privateHeaders,...(e.status===429?{'Retry-After':'60'}:{})}}); +} +export async function authorize(request: Request,front: 'mcp'|'rest',secret?: string): Promise { + if(!enabled(front==='mcp'?'STACK127_MCP_ENABLED':'STACK127_REST_ENABLED'))throw new ApiError(404,'disabled','Not found.'); + const principal=await authenticate(request,front==='mcp'?secret:undefined); + const origin=request.headers.get('origin'); + const allowed=(process.env.STACK127_DATA_ALLOWED_ORIGINS||'https://stack127.com,https://chatgpt.com,https://claude.ai').split(','); + if(origin && !allowed.includes(origin))throw new ApiError(403,'origin','Origin not allowed.'); + await consumeRateLimit(principal); + return principal; +} +export async function readJson(request: Request) { + const reader=request.body?.getReader(); + if(!reader)throw new ApiError(400,'invalid_json','JSON body required.'); + const chunks:Uint8Array[]=[];let size=0; + try { + for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length; + if(size>65536){await reader.cancel();throw new ApiError(413,'body_too_large','JSON body exceeds 64 KB.');}chunks.push(value);} + try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new ApiError(400,'invalid_json','Invalid JSON.');} + } finally {reader.releaseLock();} +} +export function checkSqlAccess(tool: string,principal: Principal) { + if(tool==='run_readonly_sql' && (!sqlEnabled()||!principal.allowSql||principal.plan!=='private'))throw new ApiError(404,'disabled','SQL tool disabled.'); +} +export function usage(principal: Principal,tool: string,start: number,rowCount: number,status: string) { + // Existing structured app logging. Never log bodies, URLs, headers, SQL, or errors. + console.info(JSON.stringify({event:'data_api_usage',principal:principal.id,tool,duration_ms:Date.now()-start,row_count:rowCount,status})); +} diff --git a/lib/data-api/mcp.ts b/lib/data-api/mcp.ts new file mode 100644 index 00000000..cbf9aa88 --- /dev/null +++ b/lib/data-api/mcp.ts @@ -0,0 +1,68 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { z } from 'zod'; +import { ApiError, MAX_ANSWER_BYTES, sqlEnabled } from './config.ts'; +import { authorize, checkSqlAccess, errorResponse, privateHeaders, readJson, usage } from './http.ts'; +import { executeTool } from './ad-networks/core.ts'; +import { toolNames, toolSchemas } from './ad-networks/schemas.ts'; +import type { ToolName } from './ad-networks/schemas.ts'; +import { sqlSchema } from './ad-networks/sql.ts'; +const descriptions: Record = { + ad_networks_overview:'Start here before researching stack127 ad network data. Returns coverage totals, freshness, all definitions/caveats, allowed filters and a tool guide. Counts observed sites, not the whole market.', + list_ad_networks:'Use to choose networks by joining rules or compare listed/managed counts and payment terms. Returns sourced public facts with checkedAt, median RDAP domain age and measurement bases. Traffic units and alternative joining rules are not comparable without reading the facts.', + get_ad_network:'Use for a full profile of one network slug. Returns sourced joining/payment facts, confirmed/stale/listed/managed counts, age/year/niche/size/platform distributions, co-listed networks and bounded newest-first daily snapshots. Missing days are unknown; snapshots are listed counts.', + compare_ad_networks:'Use for 2–6 networks side by side on one dimension. Returns distinct-site counts, per-network filtered bases, and percentage shares including unknown groups. A site may belong to several networks. Bounded rows; check truncated.', + count_sites:'Use for flexible distinct-site counts with filters and up to two grouping dimensions. Networks are OR. Unfiltered totals deduplicate multi-network sites; network groups overlap. Returns explicit bases, shares and null/unknown groups. Bounded groups; check truncated.', + find_sites:'Use for a bounded list of matching public domains and all their listed network memberships, types and confirmation times. Also returns niche/platform/RDAP date/CrUX size and next_cursor. At most 200 sites; reuse cursor with identical filters/sort. No publisher names.', + lookup_site:'Use to inspect one bare domain. Returns current and past observed network relationships, confirmation/end times, public ads.txt summary, niche/platform/RDAP age/CrUX size and bounded newest-first events. No live crawl. A missing domain is unknown, not network-free.', + network_movements:'Use to inspect observed joins, departures and newly found sites by network/day over at most 366 days. Strong warning: history starts around 2026-10-19 and checker faults repaired 2026-11-13/14 caused false departures. Not reliable churn/growth. Missing days are unknown. Baseline imports excluded.', + run_readonly_sql:'Private only. Use when the other tools cannot express a read-only research query. Returns capped rows, metadata and truncation, using a dedicated restricted database login and 15s maximum statement timeout. Never a write tool. '+sqlSchema, +}; +export function mcpAnswer(answer: Awaited>) { + // Text-only MCP clients must receive the same answer. Apply the byte budget + // after pretty-printing as well; whitespace also consumes the response budget. + const text=JSON.stringify(answer,null,2); + if (Buffer.byteLength(text)>MAX_ANSWER_BYTES) throw new ApiError(413,'answer_too_large','Answer exceeds 1 MB. Narrow the query or lower the row limit.'); + return {structuredContent:answer,content:[{type:'text' as const,text}]}; +} +export async function handleMcp(request: Request,secret?: string): Promise { + let server:McpServer|undefined; + try { + const principal=await authorize(request,'mcp',secret); + if(request.method!=='POST')return new Response(null,{status:405,headers:{...privateHeaders,Allow:'POST'}}); + const body=await readJson(request); + // MCP batching is not accepted: each HTTP request has one rate-limit unit. + if(!body||Array.isArray(body)||typeof body!=='object')throw new ApiError(400,'invalid_request','One JSON-RPC request per HTTP request.'); + server=new McpServer({name:'stack127-private-ad-networks',version:'1.0.0'}, + {instructions:'Start with ad_networks_overview. Cite the returned stack127 URL. Carry definitions, as_of, filters and caveats into research. Listed sites may overlap between networks. Movements are not reliable growth/churn. All tools read data; SQL is private and optional.'}); + for(const name of toolNames.filter(n=>n!=='run_readonly_sql'||sqlEnabled())) { + server.registerTool(name,{ + title:name.split('_').map(w=>w[0].toUpperCase()+w.slice(1)).join(' '),description:descriptions[name], + inputSchema:toolSchemas[name],outputSchema:z.object({as_of:z.unknown(),queried_at:z.string(),definitions:z.record(z.string(),z.string()),caveats:z.array(z.string()),filters:z.record(z.string(),z.unknown()),url:z.string(),data:z.record(z.string(),z.unknown()),limits:z.object({max_rows:z.number(),row_count:z.number(),truncated:z.boolean(),truncated_sets:z.array(z.string())})}), + annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}, + },async (input: Record)=>{ + const start=Date.now(); + try { + checkSqlAccess(name,principal); + const answer=await executeTool(name,input); + const result=mcpAnswer(answer); + usage(principal,name,start,answer.limits.row_count,'ok'); + return result; + } catch(error) { + const e=error instanceof ApiError?error:new ApiError(503,'unavailable','Data service unavailable.'); + usage(principal,name,start,0,e.code); + return {isError:true,content:[{type:'text' as const,text:`${e.code}: ${e.message}`}]}; + } + }); + } + const transport=new WebStandardStreamableHTTPServerTransport({sessionIdGenerator:undefined,enableJsonResponse:true}); + await server.connect(transport); + const response=await transport.handleRequest(request,{parsedBody:body}); + // JSON mode has completed the tool call before returning; no SSE stream to retain. + const bytes=await response.arrayBuffer(); + return new Response(bytes,{status:response.status,headers:{...Object.fromEntries(response.headers),...privateHeaders}}); + } catch(error){return errorResponse(error);} finally { + // Cleanup must not escape the sanitized API error boundary. + try {await server?.close();} catch { /* The request has already completed. */ } + } +} diff --git a/lib/data-api/path-policy.mjs b/lib/data-api/path-policy.mjs new file mode 100644 index 00000000..a46602d0 --- /dev/null +++ b/lib/data-api/path-policy.mjs @@ -0,0 +1,40 @@ +// Inspect the target available to the proxy. Framework normalization may have +// already removed dot segments or redirected repeated slashes. +// Match at any depth to cover locale/basePath prefixes and Next data paths too. +function rawPath(target) { + return target.split('?', 1)[0].replace(/^[a-z][a-z\d+.-]*:\/\/[^/\\]*/i, ''); +} + +function containsPrivateNamespace(path) { + const parts = path.toLowerCase().replaceAll('\\', '/').split('/').filter(Boolean); + const nearPrivate = segments => segments.some((part, i) => + part === 'api' && /^(?:mcp|v1)/.test(segments[i + 1] ?? '')); + if (nearPrivate(parts)) return true; + const resolved = []; + for (const part of parts) { + if (part === '..') resolved.pop(); + else if (part !== '.') { + if (resolved.at(-1) === 'api' && /^(?:mcp|v1)/.test(part)) return true; + resolved.push(part); + } + } + return nearPrivate(resolved); +} + +/** @param {string} target Raw Node request URL, or a proxy URL. */ +export function isPrivateApiPath(target) { + let path = rawPath(target); + for (;;) { + if (containsPrivateNamespace(path)) return true; + // Decode individual bytes so an unrelated malformed escape cannot hide a + // separator. Repeated encoding is bounded by the original target length. + const decoded = path.replace(/%([\da-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + if (decoded === path) return false; + path = decoded; + } +} + +/** Only these exact spellings may pass the private proxy. Unknown routes get a 404. */ +export function isCanonicalPrivateApiPath(target) { + return /^\/api\/(?:mcp(?:\/[a-zA-Z0-9_-]+)?|v1(?:\/[a-z0-9_-]+)*)$/.test(rawPath(target)); +} diff --git a/lib/data-api/private-response.mjs b/lib/data-api/private-response.mjs new file mode 100644 index 00000000..d04577b5 --- /dev/null +++ b/lib/data-api/private-response.mjs @@ -0,0 +1,14 @@ +// Shared by the proxy and route handlers. No database imports. +export const privateHeaders = { + 'Cache-Control': 'no-store', + 'CDN-Cache-Control': 'no-store', + 'Cloudflare-CDN-Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', +}; + +export function notFoundResponse() { + return Response.json({error: {code: 'not_found', message: 'Not found.'}}, { + status: 404, headers: privateHeaders, + }); +} diff --git a/lib/data-api/rest.ts b/lib/data-api/rest.ts new file mode 100644 index 00000000..d16017e2 --- /dev/null +++ b/lib/data-api/rest.ts @@ -0,0 +1,20 @@ +import { ApiError, sqlEnabled } from './config.ts'; +import { authorize, checkSqlAccess, errorResponse, privateHeaders, readJson, usage } from './http.ts'; +import { executeTool } from './ad-networks/core.ts'; +import { toolNames } from './ad-networks/schemas.ts'; +import type { ToolName } from './ad-networks/schemas.ts'; +export async function handleRest(request: Request,tool: string): Promise { + try { + if(tool==='run_readonly_sql' && !sqlEnabled())throw new ApiError(404,'not_found','Not found.'); + if(!toolNames.includes(tool as ToolName))throw new ApiError(404,'not_found','Not found.'); + const principal=await authorize(request,'rest'); + if(request.method!=='POST')return new Response(null,{status:405,headers:{...privateHeaders,Allow:'POST'}}); + const name=tool as ToolName,start=Date.now(); + try { + checkSqlAccess(name,principal); + const result=await executeTool(name,await readJson(request)); + usage(principal,name,start,result.limits.row_count,'ok'); + return Response.json(result,{headers:privateHeaders}); + } catch(error){usage(principal,name,start,0,error instanceof ApiError?error.code:'unavailable');throw error;} + } catch(error){return errorResponse(error);} +} diff --git a/next.config.ts b/next.config.ts index 3145b02b..1ce48ad2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,9 @@ import { collectAllowedDevOrigins } from "./lib/allowed-dev-origins"; import { legacyShortcutRedirects } from "./lib/legacy-shortcut-redirects"; const nextConfig: NextConfig = { + // Keep normal development request logs, except the canonical private APIs. + // Next tests these expressions against the raw request URL. + logging: { incomingRequests: { ignore: [/^\/api\/(?:mcp|v1)(?:[/?]|$)/] } }, allowedDevOrigins: collectAllowedDevOrigins(), distDir: process.env.STACK127_TEST_DIST_DIR ?? ".next", images: { diff --git a/package-lock.json b/package-lock.json index e5e60907..3c843b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@internationalized/date": "^3.12.4", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", + "@modelcontextprotocol/sdk": "1.32.2", "@namefi/dnssec-audit": "^2.1.1", "@next/mdx": "16.2.9", "@types/pg": "^8.20.0", @@ -23,6 +24,7 @@ "pdf-lib": "^1.17.1", "pdfjs-dist": "^6.3.289", "pg": "^8.21.0", + "pgsql-ast-parser": "12.0.2", "react": "19.2.1", "react-aria-components": "^1.21.0", "react-dom": "19.2.1", @@ -33,7 +35,8 @@ "remark-parse": "^11.0.0", "tldts": "^7.4.15", "unified": "^11.0.5", - "yaml": "2.8.1" + "yaml": "2.8.1", + "zod": "4.6.7" }, "devDependencies": { "@types/dns-packet": "^5.6.5", @@ -5414,6 +5417,1269 @@ "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } + }, + "node_modules/@hono/node-server": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.8.tgz", + "integrity": "sha512-jdf7FHh6A1lzjpHHQUgxsg2GeER2vMetnAOfeActzcoli6jamlK+NAwL6Dsr5Fl3BS3tYUQzCdtZjU8Qxv7bEA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz", + "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.3.tgz", + "integrity": "sha512-mIteYONiEfc4xWf2F1WXovh1c8npzS00vNeiUPdwFF+QlVJni+gNvpQltLMQhob8rew/bWWc/bGTuUr0EUGt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.4", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.4.tgz", + "integrity": "sha512-LKBXzX700wz3zAGNNmVnjRI5mEbYkLv7UbMI4+9Xa+JPcg2UZh6AgY/2CZh9RrJjFM5ttC/N1lD+KzyFoZITCw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.18.tgz", + "integrity": "sha512-H9q/E1XizaWstJxUW881dmLxEj+t/TrGGnyxINkm4+sK0qUwsMtFdo2BWI9Vs3cIS19MU6b6iCijoUoEf4f3DA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.6", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz", + "integrity": "sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.17", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.17.tgz", + "integrity": "sha512-wOuK9V1yujQFH5S0ZWmLcDZN7+IKh9bUCC2ZPCf2HDlol3WWHy+PPyNjEI75FXRLMp3vDKQcupP59xhVU95hcg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pgsql-ast-parser": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz", + "integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==", + "license": "MIT", + "dependencies": { + "moo": "^0.5.1", + "nearley": "^2.19.5" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index a98909b2..02a72969 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "lint": "npm run typecheck", "test": "npm run test:core", - "test:core": "node --experimental-strip-types --test-concurrency=1 --test tests/stack127/database-errors.test.mjs tests/stack127/deployment-target.test.mjs tests/stack127/dokploy-release.test.mjs tests/stack127/page-registry.test.mjs tests/stack127/analytics.test.mjs tests/stack127/static-routes.test.mjs tests/stack127/cache-refresh.test.mjs tests/stack127/cache-worker.test.mjs tests/stack127/cache-targets.test.mjs tests/stack127/page-content.test.mjs tests/stack127/selfhost-db-recovery.test.mjs", + "test:core": "node --experimental-strip-types --test-concurrency=1 --test tests/stack127/database-errors.test.mjs tests/stack127/deployment-target.test.mjs tests/stack127/dokploy-release.test.mjs tests/stack127/page-registry.test.mjs tests/stack127/analytics.test.mjs tests/stack127/static-routes.test.mjs tests/stack127/cache-refresh.test.mjs tests/stack127/cache-worker.test.mjs tests/stack127/cache-targets.test.mjs tests/stack127/page-content.test.mjs tests/stack127/selfhost-db-recovery.test.mjs tests/data-api/core.test.mjs tests/data-api/proxy-headers.test.mjs tests/data-api/public-api-headers.test.mjs", "test:focused": "node --experimental-strip-types --test-concurrency=1 --test", "keyboard-shortcuts:sync": "node --experimental-strip-types scripts/keyboard-shortcuts/sync-pages.mjs", "keyboard-shortcuts:check": "node --experimental-strip-types scripts/keyboard-shortcuts/sync-pages.mjs --check", @@ -103,12 +103,16 @@ "ad-networks:gather-once": "node --experimental-strip-types scripts/ad-networks/gather-once.mjs", "ad-networks:enrichment:status": "node scripts/ad-networks/enrichment-status.mjs", "ad-networks:snapshot-local": "node scripts/ad-networks/snapshot-local.mjs", - "ad-networks:refresh": "node --experimental-strip-types scripts/ad-networks/refresh.mjs" + "ad-networks:refresh": "node --experimental-strip-types scripts/ad-networks/refresh.mjs", + "test:data-api": "node --experimental-strip-types --test tests/data-api/core.test.mjs tests/data-api/proxy-headers.test.mjs tests/data-api/public-api-headers.test.mjs", + "test:data-api:development": "STACK127_DATA_INTEGRATION=true node --env-file=.env.local --experimental-strip-types --test tests/data-api/integration.test.mjs", + "test:data-api:routing": "node --experimental-strip-types --test tests/data-api/next-routing.test.mjs" }, "dependencies": { "@internationalized/date": "^3.12.4", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", + "@modelcontextprotocol/sdk": "1.32.2", "@namefi/dnssec-audit": "^2.1.1", "@next/mdx": "16.2.9", "@types/pg": "^8.20.0", @@ -121,6 +125,7 @@ "pdf-lib": "^1.17.1", "pdfjs-dist": "^6.3.289", "pg": "^8.21.0", + "pgsql-ast-parser": "12.0.2", "react": "19.2.1", "react-aria-components": "^1.21.0", "react-dom": "19.2.1", @@ -131,7 +136,8 @@ "remark-parse": "^11.0.0", "tldts": "^7.4.15", "unified": "^11.0.5", - "yaml": "2.8.1" + "yaml": "2.8.1", + "zod": "4.6.7" }, "devDependencies": { "@types/dns-packet": "^5.6.5", diff --git a/proxy.ts b/proxy.ts index ed9672c9..c1e72d97 100644 --- a/proxy.ts +++ b/proxy.ts @@ -7,9 +7,28 @@ import { sitePageAvailable } from "@/lib/page-source"; import { absoluteUrl } from "@/lib/seo"; import { queryNeedsNoindex } from "@/lib/seo/query-indexing.mjs"; +import { isPrivateApiPath, isCanonicalPrivateApiPath } from "./lib/data-api/path-policy.mjs"; +import { notFoundResponse } from "./lib/data-api/private-response.mjs"; + +function isPublicApiPath(pathname: string) { + return /^\/api\/(?:ad-networks(?:\/|$)|cache(?:\/|$)|cli-apps(?:\/|$)|dns(?:\/|$)|domain(?:\/|$)|self-host-apps(?:\/|$)|shortcuts(?:\/|$)|resource-planner-app(?:\/|$)|resource-planner-index(?:\/|$)|ip-asn-lookup(?:\/|$)|health(?:\/|$))/.test(pathname); +} + export async function proxy(request: NextRequest) { const headers = new Headers(request.headers); const pathname = request.nextUrl.pathname; + // Only private namespaces bypass the existing public API cache/SEO policy. + // Reject noncanonical spellings that reach the proxy. Next may normalize + // repeated/backslashes or redirect trailing slashes before this runs. + if (isPrivateApiPath(request.url ?? pathname)) { + if (!isCanonicalPrivateApiPath(request.url ?? pathname)) return notFoundResponse(); + headers.delete("x-stack127-pathname"); + return request.headers.has("x-stack127-pathname") + ? NextResponse.next({ request: { headers } }) : NextResponse.next(); + } + if ((pathname === "/api" || pathname.startsWith("/api/")) && !isPublicApiPath(pathname)) { + return notFoundResponse(); + } const userQuery = new URLSearchParams(request.nextUrl.search); userQuery.delete("_rsc"); const withQueryIndexing = (response: NextResponse) => { @@ -74,6 +93,8 @@ export async function proxy(request: NextRequest) { export const config = { matcher: [ + "/api/mcp/:path*", + "/api/v1/:path*", "/sitemap.xml", "/robots.txt", "/((?!_next/static|_next/image|favicon.ico|favicon-16x16.png|favicon-32x32.png|apple-touch-icon.png|android-chrome-192x192.png|android-chrome-512x512.png|logos|app-icons|.*\\..*).*)", diff --git a/stack127_docs/README.md b/stack127_docs/README.md index 802f043e..0b6a3726 100644 --- a/stack127_docs/README.md +++ b/stack127_docs/README.md @@ -28,4 +28,5 @@ Keep each current decision in the operating doc it belongs to. Page-specific pla | `seo.md` | Canonicals, sitemap, robots, structured data, and indexing rules. | | `rdap.md` | Shared registration data engine contract for the planned `/domain-tools` pages. | | `ovh-vps-migration.md` | Runbook for moving stack127 to its own OVH VPS: IPv6, acceptance, cutover, and rollback. | +| [mcp-and-api.md](mcp-and-api.md) | Private ad network MCP and REST, authentication, limits, projection migration, and client connection steps. | | `production-deployment.md` | Active production target, preflight, deployment, and verification runbook. | diff --git a/stack127_docs/environment-inventory.md b/stack127_docs/environment-inventory.md index 3dec5ffc..d07bbc8c 100644 --- a/stack127_docs/environment-inventory.md +++ b/stack127_docs/environment-inventory.md @@ -373,3 +373,23 @@ When a credential changes: The tracked example files are schemas, not backups. A full recovery also needs the password manager entries and the secret records kept by each service. The rebrand's pre-migration production table recovery dump is stored outside the repository at `/Users/ravitejaknts/backups/stack127/2026-08-20/open_projects-before-provenance.dump`. It's mode `0600`. Its checksum is in the rebrand migration document. + +## Private ad network MCP and REST API + +Runtime owner: Infisical `stack127 /vps/web-app`. See [mcp-and-api.md](mcp-and-api.md) for formats, defaults, rotation and connection steps. These switches default off; no public mode is available. + +```text +STACK127_MCP_ENABLED +STACK127_REST_ENABLED +STACK127_DATA_SQL_ENABLED +STACK127_DATA_SQL_DATABASE_URL +STACK127_MCP_TOKEN +STACK127_MCP_PATH_SECRET +STACK127_DATA_RPM +STACK127_DATA_RPD +STACK127_DATA_MAX_ROWS +STACK127_DATA_TIMEOUT_MS +STACK127_DATA_ALLOWED_ORIGINS +``` + +The eight fixed tools use the existing app database login in read-only transactions. This release creates no custom reader role. `STACK127_MCP_ENABLED`, `STACK127_REST_ENABLED` and `STACK127_DATA_SQL_ENABLED` default off. Keep all three off until release checks pass; SQL must stay off for this release and `STACK127_DATA_SQL_DATABASE_URL` must remain absent. A future administrator must provision and audit a dedicated restricted SQL login before that key can be set. See the "Before you turn it on" checklist in [mcp-and-api.md](mcp-and-api.md) for live proxy-header, Traefik and Cloudflare log checks and credential rotation. diff --git a/stack127_docs/mcp-and-api.md b/stack127_docs/mcp-and-api.md new file mode 100644 index 00000000..b8936527 --- /dev/null +++ b/stack127_docs/mcp-and-api.md @@ -0,0 +1,238 @@ +# Private ad network MCP and REST API + +This is the owner's research API. It reads the same ad network dataset and sourced joining/payment facts as the public pages. It does not crawl a site on demand. Both front doors start disabled. Nothing creates a public product or a public connector listing. + +## One core, two front doors + +`lib/data-api/ad-networks/core.ts` owns the queries and answer shapes. `schemas.ts` owns the Zod inputs. `app/api/mcp/route.ts`, `app/api/mcp/[secret]/route.ts` and `app/api/v1/ad-networks/[tool]/route.ts` are adapters. REST uses JSON POST bodies, including for reads, to keep filters and SQL out of ordinary URL logs. Query parameters are not inputs. + +The official TypeScript SDK is pinned to `@modelcontextprotocol/sdk` **1.32.2**. npm reports publication on **2026-10-23**, 22 days before the **2026-11-14** review. Its [SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk) describes Streamable HTTP; the installed package includes the stateless and JSON-response examples. The server uses the SDK's Web Standards transport, no session IDs, and JSON responses. GET event streams and resumable sessions are not offered. There is no scheduler or new service. + +Every successful answer carries `as_of`, `queried_at`, definitions, caveats, normalized filters, a stack127 citation URL, data, and limits. `as_of` is the latest stored successful site check, relationship event, or daily snapshot capture, not the request time and not a guarantee that every row is that fresh. Overview also gives oldest/newest successful site checks. CrUX's import month is not retained in these serving tables; answers say it is unknown. Factual joining/payment sources have their own `checkedAt` date. + +## Tools and REST routes + +For every row below, the REST route is **POST `/api/v1/ad-networks/`**. The MCP tool name and REST suffix are identical. MCP includes titles, input descriptions/examples, output schemas and read-only annotations. Tool errors use MCP `isError`; REST uses HTTP status codes with `{error: {code, message}}`. + +| Tool / REST suffix | Use it for | +| --- | --- | +| `ad_networks_overview` | Start here: sites checked, distinct listed/managed totals, freshness, measurement, definitions, caveats, allowed values, tool guide. Input `{}`. | +| `list_ad_networks` | Counts, domain-age median and measured base, sourced joining/payment facts and checked dates. Filter published numeric minimum, traffic unit, or public numeric rule; sort by listed, managed, name or minimum traffic. | +| `get_ad_network` | One `network` slug, facts/sources, counts, domain-age buckets/median, registration years, niche/size/platform mix, co-listing and daily listed snapshots. | +| `compare_ad_networks` | 2–6 distinct `networks`, one `dimension`: niche, registration_year, age_bucket, size_group or platform. Counts and shares use each network's filtered distinct-site base. | +| `count_sites` | Distinct-site counter; up to two `group_by` dimensions, including network. Global totals deduplicate sites listed at several networks. | +| `find_sites` | Public domains and listed memberships, types, confirmation dates, niche, platform, RDAP date and CrUX size. `limit` 1–200, `sort` domain/newest/oldest, opaque `cursor`. Keep filters/sort unchanged between pages. | +| `lookup_site` | Bare `domain`: current/past relationships, first/last confirmation and end dates, public ads.txt summary, classification, age, size and events. Unknown domain returns 404. | +| `network_movements` | Optional `networks`, inclusive UTC `from` and `to`; at most 366 days apart. Observed joins/leaves/newly-found only, with a strong fault warning. | +| `run_readonly_sql` | Disabled for this release. Future private SQL requires a dedicated database login and a separate switch. A single SELECT against the documented safe views. Never available to a public principal. | + +`count_sites`, `find_sites` and `compare_ad_networks` share filters: `networks` (OR), `relationship` (`listed`, default, or `managed`), `niche`, `registered_after`/`registered_before` (exclusive RDAP dates), `min_age`/`max_age` (inclusive completed years), `size_group`, `platform`, and `status` (`confirmed` or `stale`). Other filters combine with AND. Missing classifications remain null groups. `find_sites` returns all listed networks for each matched site, even if only one network matched the filter. + +Listed means confirmed or stale. Managed means a listed `primary_or_exclusive_manager` relationship. This is the requested relationship-based measure; it differs from the public page's manager resolver, which also reconciles retained manager-domain evidence and ambiguous signals. Do not call those two measures interchangeable. The API reuses the public listed-status rule, domain-age bucket thresholds/median expression, source facts and observed-event definitions. Minimum traffic in sessions and pageviews is not interchangeable; use `traffic_unit`. `has_public_rule` specifically means a published numeric monthly traffic minimum, including an explicit zero; read full joining facts for alternatives. + +Movement answers are not reliable growth/churn series. History begins around October 19, 2026. Homepage/checker faults were repaired November 13/14; the conservative repair search begins September 28. Some false departures were repaired; others remain uncertain. Missing history days are unknown, not zero. See [the production runbook](../content_docs/ad-networks/production-runbook.md) and [growth status](brand-growth/status.md). + +## Authentication and future public plans + +`authenticate(request) -> Principal` is the replacement point for future user API keys/OAuth and paid plans. Today both credentials map to `private-owner`, plan `private`. `PUBLIC_MODE` is a hardcoded false constant. There is no anonymous fallback, public switch, signup, billing endpoint, or public SQL access. + +- `https://stack127.com/api/mcp`: requires `Authorization: Bearer `. +- `https://stack127.com/api/mcp/`: a bearer capability in the path, for browser connectors configured as no authentication. The server still authenticates the secret. Never use the literal placeholder as a URL. +- REST: always requires the bearer header; a URL secret is not accepted. + +Generate **two different 32-byte random secrets**, encoded as hex (64 characters) or base64url (43 characters). Comparisons hash both sides and use `timingSafeEqual`. Missing, short, or malformed configured secrets fail closed. A supplied invalid Authorization header cannot fall back to the path. Neither credential is stored in the database or logged by this code. + +Why this method: the [current OpenAI custom MCP guide](https://developers.openai.com/api/docs/guides/custom-mcp-server) supports “No authentication”, while [Claude's custom connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) supports “No sign in” and fixed request headers. Both were read **2026-11-14**. The path credential works across both without building an OAuth authorization server for one owner. Prefer the header endpoint where the client supports it. Before selling access, replace the authenticator, add per-tool/plan authorization, and use OAuth for browser clients. The query core and input/output contracts can remain. + +A secret URL is a credential. Before enabling it, the lead must check OVH reverse-proxy logs, Cloudflare request logs, error tracking and analytics. Redact or omit the full `/api/mcp/*` path from logs; do not retain the capability in screenshots, public issue text or monitoring URLs. Application usage logs contain no URLs, bodies, SQL, headers, raw errors or credentials. Keep this connector private to the owner; a shared connector URL grants everyone who receives it the same owner's access. + +## Environment keys + +The lead adds runtime values through Infisical **`stack127 /vps/web-app`**. No values were added or changed by this implementation. Keys are also indexed in [environment-inventory.md](environment-inventory.md). + +| Key | Format / default | +| --- | --- | +| `STACK127_MCP_ENABLED` | Exact `true` enables MCP; otherwise disabled. | +| `STACK127_REST_ENABLED` | Exact `true` enables REST; otherwise disabled. | +| `STACK127_DATA_SQL_ENABLED` | Defaults off. Must stay `false` for this release. The flag alone cannot expose SQL. | +| `STACK127_DATA_SQL_DATABASE_URL` | Dedicated restricted SQL login connection string. Must stay absent for this release. Never reuse `DATABASE_URL` / `DATABASE_POOLER_URL`. | +| `STACK127_MCP_TOKEN` | Random 32 bytes, hex or base64url. Header credential. | +| `STACK127_MCP_PATH_SECRET` | Different random 32 bytes, hex or base64url. Browser connector credential. Omit to disable path access. | +| `STACK127_DATA_RPM` | Positive integer, default 120; maximum 10,000. | +| `STACK127_DATA_RPD` | Positive integer, default 10,000; maximum 1,000,000. | +| `STACK127_DATA_MAX_ROWS` | Positive integer, default and maximum 200. | +| `STACK127_DATA_TIMEOUT_MS` | Positive integer milliseconds, default and maximum 15,000. | +| `STACK127_DATA_ALLOWED_ORIGINS` | Comma-separated exact origins. Default `https://stack127.com,https://chatgpt.com,https://claude.ai`. Requests without Origin are accepted after authentication. | + +Existing `DATABASE_POOLER_URL` / `DATABASE_URL` supply the eight fixed tools and rate-limit storage. They are never used for arbitrary SQL. No new database password is needed for this release. This implementation only uses `.env.local` for development checks. It does not change the managed env files. + +Generate each secret into a shell variable without printing it, then follow the existing Infisical stdin workflow. For example, the lead can use `openssl rand -hex 32` inside command substitution assigned to a task-specific variable. Do not put the generated value in a command argument or Git. Remove the variable after storing it. No generation command in this doc contains a real credential. + +## Limits, data isolation, and usage + +The migration is `supabase/migrations/20261114120000_private_ad_network_data_api.sql`. It creates private schemas, projected views and a counter table with RLS and explicit backend grants/policies. It creates no role and changes no role membership. It can be re-applied. Production release must use the existing `DATABASE_MIGRATION_URL` for owner `postgres`, not the runtime `stack127_app` URL. The owner can create schemas, views, tables, grants and policies; it cannot create roles. The runtime login cannot create schemas. + +Each fixed tool runs parameterized, code-controlled queries in a repeatable-read, read-only transaction as the normal app login. It sets a private search path, UTC timezone and statement timeout, and always commits or rolls back before releasing the connection. A dedicated pool has at most two connections, separate from page queries. No `SET ROLE` is used. The app login still has its existing broader privileges; the projections and controlled query code define the fixed tools' boundary, not a claim that the login itself is restricted. + +The unexposed `stack127_data` views omit `publisher_name`, evidence, hashes, leases, retries and discovery internals, and omit candidate-only sites/needs-review relationships. The ads.txt projection includes only line/direct/reseller/invalid counts and top seller systems. `dataset_summary` exposes aggregate coverage/freshness only. Anonymous and authenticated Supabase API roles have no grants to these schemas or objects. + +`run_readonly_sql` is kept in code but unavailable for this release. Without `STACK127_DATA_SQL_DATABASE_URL`, SQL is absent from MCP discovery and overview, core calls fail closed, and its REST route returns 404 before authentication/database access, even when `STACK127_DATA_SQL_ENABLED=true`. If later enabled, SQL uses a separate one-connection pool with only that key; it never falls back to the app pool. The parser accepts one SELECT against named views and a small built-in function/cast allowlist. Arrays, `array(...)`, collection aggregates (`array_agg`, `json_agg`, `jsonb_agg`, `string_agg`, `xmlagg` and any other function outside the allowlist), CTEs, comments, separators, locks, system catalogs and arbitrary functions are rejected. A capped outer SELECT limits rows. Parsing and `SET LOCAL ROLE` are not database isolation: arbitrary SQL needs its own restricted login. + +### Later admin bootstrap for SQL, not performed in this release + +An administrator with role-creation privileges must create a separate LOGIN with NOINHERIT, NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION, NOBYPASSRLS, no memberships, and a connection limit of one. Grant CONNECT to this database, USAGE only on `stack127_data`, and SELECT only on the six documented projected views (including `dataset_summary`). The login must own no objects and must not inherit app privileges or gain CREATE/TEMP privileges. Set a safe search path and read-only/timeout defaults as defense in depth. + +Audit effective privileges, including grants inherited from PUBLIC. A role-specific REVOKE cannot remove a PUBLIC grant. Remove PUBLIC access to other schemas, tables, sequences and dangerous functions only after checking other consumers, or use a separate research database whose PUBLIC privileges are already locked down. In particular, deny schema USAGE and object/function access in `net`, `extensions`, `public`, `stack127_api`, `auth`, `storage`, `vault` and every other application/extension schema. Test that `net.http_request_queue`, `net._http_response`, base tables, counters, private columns and write/side-effect functions are inaccessible. PostgreSQL's built-in catalogs remain subject to built-in visibility; the parser still rejects them. Test `RESET ROLE` and confirm it cannot reach any more privileged login. Do not store the dedicated URL or enable SQL until this audit passes. None of this bootstrap is part of the release migration. + +The row limit is a **total result-record budget per answer**, shared fairly across returned result sets (such as network distributions and daily history). Definitions, facts/sources and available filter values are metadata and do not consume that budget. Each SQL query also has a bounded result limit. `limits.row_count`, `limits.truncated`, `limits.truncated_sets` and local result-set `truncated` flags identify partial results. A result set can also be cut by its query cap; read the local flag. Never add incomplete returned groups to infer a total: use the supplied distinct total/base. Site cursors use stable keyset pagination, not offsets; live database changes between calls can still change membership. Other large result sets have no cursor; narrow their filters. Answers over 1 MB are rejected. MCP returns the full JSON answer as both structured content and readable text, and applies that same 1 MB limit after text formatting. Request bodies over 64 KB are rejected while reading. + +An atomic upsert in `stack127_api.rate_limits` enforces fixed UTC minute/day windows per principal, shared by MCP and REST and all app instances. It stores one row per principal, so the private deployment needs no cleanup job. This is a separate small write from the read-only data transaction. Both limits count authenticated HTTP requests, including initialize, tool discovery, errors and method checks. MCP batches are rejected, so one request cannot contain several tool calls. A limit returns HTTP 429, Retry-After 60; a daily limit clears at midnight UTC. Limit-storage errors fail closed. + +The existing application log stream receives JSON `data_api_usage` entries: principal, tool, duration_ms, row_count and status. It logs completed core calls and REST input failures; authentication failures, protocol discovery and MCP schema failures do not produce a tool usage row. A count-only answer reports zero result-list rows, with its aggregate in data.total. Do not treat this log as a billing ledger without adding durable authenticated attempt accounting. + +The proxy skips canonical links, page tags, cache-class headers and pathname propagation only for private namespaces and unknown API routes. Every existing public API keeps its previous headers, including page/cache tags, query canonicals and query noindex headers. Wrong MCP secrets and unknown API paths return the same generic JSON 404 and private headers. Usage logs contain only allowlisted metadata; caught application errors never print the request URL or raw exception. + +### Next routing and request logging + +The Dockerfile and the `dev`/`start` commands use the normal Next entries from `origin/main`: standalone `node server.js`, `next dev` and `next start`. There is no custom HTTP wrapper. Next owns shutdown, development restarts, config watching and upgrades. + +Browser connectors must use the exact canonical HTTPS URL: lowercase `/api/mcp/`, with no trailing slash, repeated slash, encoding or dot segment. Prefer `/api/mcp` with the bearer header when supported. Do not rely on a connector following redirects. + +Malformed spellings can receive a framework normalization redirect before the proxy or route handler runs. For example, `/api//mcp/caller-value?q=private` returns 308 with `Location: /api/mcp/caller-value?q=private`, `Refresh: 0;url=/api/mcp/caller-value?q=private` and that same destination as the response body (empty for HEAD). This only repeats the URL the caller already supplied. It does not disclose a configured credential or another caller's URL. Accepting that reflection preserves Next's normal server lifecycle. It is acceptable only with the edge non-storage checks below; a redirect must never become a shared cached response. + +Checked against installed Next **16.2.9**: + +- `dist/server/lib/router-utils/resolve-routes.js:50` orders config redirects before the proxy. Its repeated-slash/backslash branch at `:105` returns 308 before custom header rules or the proxy. `dist/server/base-server.js:543` also normalizes repeated slashes. +- `dist/server/lib/router-server.js:359` sends `Location`, `Refresh` and the destination body for that 308. It adds **no `Cache-Control`, `CDN-Cache-Control` or `Cloudflare-CDN-Cache-Control`**. App `no-store` headers cannot protect this early response. The HTTP routing test verifies the actual headers, including trailing-slash redirects. +- `dist/lib/load-custom-routes.js:529` generates trailing-slash redirects. Public redirect policy stays unchanged. No basePath, i18n, API redirect or API rewrite is configured. Dot segments can instead normalize directly to a route and receive its normal authentication response. +- `dist/server/config-shared.d.ts:241` supports `logging.incomingRequests.ignore: RegExp[]`. The config ignores only `/^\/api\/(?:mcp|v1)(?:[/?]|$)/`: the two canonical private namespaces, including query strings and child routes. Public incoming-request logging keeps the `origin/main` default. `dist/server/dev/log-requests.js:38` matches raw `request.url`; malformed aliases outside these namespaces are not promised log redaction. Never use a real capability in malformed development probes. +- Incoming-request logs are **development only**. The sole runtime call to `logRequests` is in `dist/server/dev/next-dev-server.js:372`; production `next-server.js` does not install that development request logger. Production Next does not log incoming requests by default. Reverse proxies, Cloudflare and application error logs still need their own checks. + +The routing test uses Next's own `startServer` listener and installed router, without a custom wrapper. It covers canonical URLs, wrong secrets, unknown API paths, seven methods, query strings, switches on/off and malformed spellings. Canonical responses never expose either synthetic credential, and use all three `no-store` headers. A malformed redirect may repeat only the caller's normalized URL. Origin-only tests cannot verify Cloudflare non-storage; that remains a required release check. + +Every response handled by the private proxy or route handlers has `Cache-Control: no-store`, `CDN-Cache-Control: no-store`, and `Cloudflare-CDN-Cache-Control: no-store`, including errors. Framework redirects are the exception described above. No Next cache wraps the queries. + +## Connect Claude.ai and Claude Desktop + +Read **2026-11-14**: [Anthropic's connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). Remote connectors need public HTTPS on port 443; requests originate from Anthropic, including Desktop. + +1. Enable MCP after release and log redaction. In Claude, open **Customize > Connectors > + Add > Add custom connector**. +2. Name it `stack127 private`. Enter the full secret-path endpoint; Continue. +3. Choose **No sign in**. Add it. No OAuth client credentials are needed. +4. Enable it using the conversation's **+ > Connectors** menu. Ask it to call `ad_networks_overview` first. +5. Claude Desktop uses the same account connector flow. Alternatively, use `/api/mcp` with No sign in and a fixed **Request headers** entry `Authorization: Bearer ` where those controls are present. + +Team/Enterprise owners may need to add it in Organization settings first. Do not publish or share the secret-path connector with a workspace. + +## Connect ChatGPT + +Read **2026-11-14**: [OpenAI's setup guide](https://developers.openai.com/api/docs/guides/custom-mcp-server). Current instructions use Plugins; Developer Mode is no longer a prerequisite in that experience. + +1. Open ChatGPT on the web, then **Plugins > + > Add custom MCP server**. +2. Name it `stack127 private`. Under Connection, enter the full HTTPS secret-path endpoint. +3. Choose **No authentication**. Review the risk notice and create the private plugin. +4. Review its tools, install it for yourself, then select it with **@** in a new conversation. Ask for `ad_networks_overview`. +5. Keep access private. Refresh tool definitions in its settings after changing the SQL switch or schemas. + +Account/workspace permissions still apply. If the account shows the legacy Apps UI, use Apps > Create and scan tools, following its permission controls. The [official availability guide](https://help.openai.com/en/articles/12584461-connecting-custom-mcp-servers-in-chatgpt), read the same day, says full MCP is available for Business/Enterprise/Edu and Pro can connect custom servers for reads. This server is read-only. The owner's actual account connection has not been tested by this implementation. + +## Connect Claude Code + +Read **2026-11-14**: [Claude Code MCP docs](https://code.claude.com/docs/en/mcp). Add a remote HTTP server with a bearer header using a private local/user config, not a committed project credential. This JSON is a template; supply the token through a supported local secret/environment reference: + +```json +{ + "mcpServers": { + "stack127-private": { + "type": "http", + "url": "https://stack127.com/api/mcp", + "headers": { "Authorization": "Bearer ${STACK127_MCP_TOKEN}" } + } + } +} +``` + +Use `claude mcp add-json` with the locally supplied JSON and `--scope user`, or use the documented HTTP add command. Run `/mcp` in Claude Code to check the connection, then ask for overview. Keep shell history free of literal credentials. Claude Desktop's remote account connector steps above avoid a separate local bridge. + +## Rotate or switch off + +To rotate, set a new token/path secret in Infisical, let the normal sync/release restart pick it up, and update the affected clients. Each credential accepts only its current value: old URLs/headers stop working as soon as the new runtime starts. Keep the two secrets separate so browser URLs and header clients can rotate independently. + +Set `STACK127_MCP_ENABLED=false` or `STACK127_REST_ENABLED=false` to disable that front door entirely; it returns 404 before database access. Keep `STACK127_DATA_SQL_ENABLED=false` and `STACK127_DATA_SQL_DATABASE_URL` absent for this release. Either condition hides SQL discovery and rejects direct calls. Refresh the clients' frozen tool list after changing SQL exposure. Public access remains off regardless of these flags. + +## Cloudflare and release checks for the lead + +See [cloudflare.md](cloudflare.md): `/api/*` goes to OVH without a Worker. This Worker exclusion is routing, not a cache bypass. + +The checked-in `scripts/stack127/cache/configure-cloudflare.mjs` excludes `/api/` from HTML/RSC rules, caches only named public JSON APIs, and rewrites successful document cache headers only for 200/304. Its final `Public errors: no store` rule matches **400 and above**, so it does not protect 3xx. Private MCP/REST paths do not match the named public JSON APIs. But the script preserves the installed detail rule's expression/settings, the installed HTML rule and other existing rules; it is not a complete snapshot of the live zone policy. + +Cloudflare's [default status caching](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/#edge-ttl), checked **2026-11-14**, does not cache 308 by default. These extensionless private routes are not cache candidates under the documented allowlist. However, [status TTL or override-origin settings](https://developers.cloudflare.com/cache/how-to/cache-rules/settings/#edge-ttl) can override origin directives. Therefore neither the absence of a Worker nor the current script proves a live 308 cannot be stored. Live rules were not read or changed in this implementation. + +### Required edge protection before issuing a capability URL + +The lead must verify the installed request and response cache rules, including status TTLs, Page Rules and any custom cache keys. Install the following protection if it is absent. Do not limit it to 200, errors or authenticated requests: secret-path connectors send no Authorization header. + +1. Add a final request cache rule in `http_request_cache_settings`, after every existing rule (on 2026-11-14 the last one was `Public robots: origin cache control`), because Cloudflare's [last matching rule wins](https://developers.cloudflare.com/cache/how-to/cache-rules/order/). Description `Private MCP and REST: no cache`, action `set_cache_settings`, parameters `{"cache":false,"browser_ttl":{"mode":"respect_origin"}}`, expression: + +```text +(http.host eq "stack127.com" and ( + starts_with(lower(http.request.uri.path), "/api/mcp") or + starts_with(lower(http.request.uri.path), "/api/v1") +)) +``` + + It covers only the private namespaces, so public JSON APIs keep their edge caching. The zone's URL normalization is `{"scope":"incoming","type":"cloudflare"}` (checked 2026-11-14), so repeated slashes and backslashes are normalized before matching and `/api//mcp` matches. Encoded separators and dot segments that normalize outside these prefixes are covered by step 2. +2. Add the response rule below at the end of `http_response_cache_settings`. It sends browser-visible `no-store` for origin redirects on the whole host, excluding 304 revalidation. Using the whole host also covers malformed targets whose dot segments or encoded separators remove the visible `/api/` prefix. It does not change the redirect destination or status. `cloudflare_only` must remain false/absent. + +```json +{ + "action": "set_cache_control", + "expression": "(http.host eq \"stack127.com\" and http.response.code in {301 302 303 307 308})", + "description": "Origin redirects: no store", + "action_parameters": { + "no-store": { "operation": "set" }, + "public": { "operation": "remove" }, + "max-age": { "operation": "remove" }, + "s-maxage": { "operation": "remove" }, + "stale-while-revalidate": { "operation": "remove" }, + "stale-if-error": { "operation": "remove" }, + "immutable": { "operation": "remove" } + } +} +``` + +3. Any cache-eligibility rule outside the API bypass that uses status TTLs or ignores origin directives must also forbid redirect storage. In that rule's `edge_ttl.status_code_ttl`, set `value: -1` for each redirect code (301, 302, 303, 307, 308), for example `{"status_code":308,"value":-1}`. Split any overlapping ranges and preserve its other status settings, including 304 revalidation. `-1` means no-store; `0` permits storage with revalidation and is insufficient. Check browser TTL overrides also cannot replace the response rule's no-store policy. This closes the case where a malformed target normalizes outside `/api/`. + +Keep these protections in the normal cache configuration when the lead implements the edge change. Purge any previously stored affected redirects after applying it. The local code does not make this Cloudflare change. Until the live checks pass, redirect non-storage is **unverified** and a real capability URL must not be issued. + +Use two distinct synthetic probe values, with feature flags off. Repeat each raw request with Node HTTP or `curl --path-as-is --request-target`, without following redirects. Canonical paths must return the generic no-store 404; malformed paths may return that response or a 308 containing only the caller's normalized URL. At the final HTTPS edge, every probe and every 3xx must send `Cache-Control: no-store`, never `CF-Cache-Status: HIT`, `STALE`, `UPDATING`, `REVALIDATED` or `EXPIRED`, and never an `Age` header. Repeat identical probes to check non-storage, then change the probe value: the new response must never contain the first value. Headers alone are not a substitute for checking installed bypass/status-TTL rules. + +Check WAF/Access does not challenge cloud MCP clients. Check request-log redaction at Cloudflare and OVH before issuing a path credential. This implementation did not inspect or change live Cloudflare rules, logs, WAF, or any server. + +The release workflow applies changed Supabase migrations before the app deploy; PR CI does not apply them. Development already has this migration. Before production enablement, review its view ownership/grants and verify the deployed app login is `stack127_app`. After normal release, check authenticated and unauthenticated responses, no-store headers, both client connections and SQL hidden by default. Do not enable features before the migration and runtime are live. + +## Before you turn it on + +- Keep MCP, REST and SQL flags off until the reviewed migration and runtime are deployed. Keep `STACK127_DATA_SQL_DATABASE_URL` absent throughout this release. +- Confirm the deployed container uses the unchanged standalone `node server.js` entry. Run `npm run test:data-api:routing` before release. +- Verify the live route's final headers on success, wrong/missing credentials, 404, 429 and errors, with query strings too. There must be no secret, `Cache-Tag`, canonical `Link`, `x-stack127-pathname` or public cache-class header. +- Complete the edge protection and repeated synthetic redirect probes above before issuing a real path credential. Canonical responses must never echo a secret. Malformed spellings may redirect to the caller's normalized URL; all such redirects must be uncached. Repeat after Next upgrades. +- Confirm Traefik access logging is off or redacts the full capability path. Check other reverse-proxy and error logs too. +- Review Cloudflare request logs, Logpush and Security Events for the secret path and configure omission/redaction before issuing a real URL. No live log settings were changed by this implementation. +- Rotate both credentials if a credential URL ever appears in a log. Update the owner's clients after rotation. +- Connect claude.ai and ChatGPT through the secret URL with no-auth selected. Connect Claude Code and REST with the bearer token. Check both credential types after enablement. + +Use synthetic values only, with feature flags off. Probe canonical `/api/mcp/`, wrong-secret and unknown `/api/` paths, plus repeated/trailing slashes, backslashes, encoded separators/letters and dot segments. Include malformed paths that resolve outside `/api/`. The raw-target cases in `tests/data-api/private-paths.mjs` cover the private routing matrix; expand the live probes for any edge normalization or rewrite rules. Check all seven methods with and without queries. Do not follow redirects or use a real configured secret in these probes. + +## Checks and adding a tool + +```bash +npm run quality +npm run test:data-api +npm run test:data-api:routing +npm run test:data-api:development +``` + +The development test uses `.env.local` and is skipped unless the effective connection exactly matches the pinned managed development project and its postgres pooler login. It starts only a temporary loopback HTTP adapter for the actual Next route handlers, and calls tools/list plus all eight fixed tools using the official SDK client. It tests REST parity, public count parity, grouping, cursor continuation, projection fields, read-only transactions, timeout, row limits, off switches, SQL hidden even with its flag on, and concurrent minute/day limits. Focused tests also run the real proxy plus route handlers for all methods and status/error paths, compare wrong-secret and unknown-API 404s, reject nested collections and check MCP text byte limits. It does not run a Next build or connect the owner's actual Claude/ChatGPT accounts. + +To add a tool, add its strict described schema in `schemas.ts`, its read-only query in `core.ts`, and its AI-facing description in `mcp.ts`. The generic REST route automatically exposes that same tool name. Add safe projections to a reviewed migration only if new fields are needed. Keep output metadata and the shared result-row budget. Add focused tests and extend the SDK development test; run quality. Release through the normal reviewer/PR path, then refresh connected tool lists. No separate MCP server deployment is needed. diff --git a/supabase/migrations/20261114120000_private_ad_network_data_api.sql b/supabase/migrations/20261114120000_private_ad_network_data_api.sql new file mode 100644 index 00000000..7d318c11 --- /dev/null +++ b/supabase/migrations/20261114120000_private_ad_network_data_api.sql @@ -0,0 +1,63 @@ +begin; +set local lock_timeout = '10s'; +set local statement_timeout = '120s'; + +create schema if not exists stack127_data; +revoke all on schema stack127_data from public, anon, authenticated; +-- Owner-executed, security-barrier projections for code-controlled queries. +-- No custom roles or membership changes: production cannot CREATE ROLE. +create or replace view stack127_data.ad_networks with (security_barrier=true) as + select id,slug,name,official_url,summary from public.ad_networks; +create or replace view stack127_data.ad_network_publishers with (security_barrier=true) as + select id,normalized_domain,niche,platform,domain_created_on,traffic_group,last_successful_check_at, + case when ads_txt_summary is not null then jsonb_build_object( + 'lines',ads_txt_summary->'lines','direct',ads_txt_summary->'direct', + 'reseller',ads_txt_summary->'reseller','invalid',ads_txt_summary->'invalid', + 'top_sellers',ads_txt_summary->'top_sellers') end as ads_txt_summary + from public.ad_network_publishers p + where exists(select 1 from public.ad_network_publisher_relationships r + where r.publisher_id=p.id and r.status in ('confirmed','stale','ended')); +create or replace view stack127_data.ad_network_publisher_relationships with (security_barrier=true) as + select ad_network_id,publisher_id,status,relationship_type,first_confirmed_at,last_confirmed_at,ended_at + from public.ad_network_publisher_relationships where status in ('confirmed','stale','ended'); +create or replace view stack127_data.ad_network_relationship_events with (security_barrier=true) as + select ad_network_id,publisher_id,event,observed_at,relationship_type + from public.ad_network_relationship_events where event in ('joined','left','newly_found'); +create or replace view stack127_data.ad_network_daily_counts with (security_barrier=true) as + select ad_network_id,snapshot_on,listed_count,confirmed_count,stale_count,captured_at + from public.ad_network_daily_counts; +create or replace view stack127_data.dataset_summary with (security_barrier=true) as + select count(*) filter(where last_successful_check_at is not null)::int as sites_checked, + min(last_successful_check_at) as oldest_site_check,max(last_successful_check_at) as newest_site_check, + greatest(max(last_successful_check_at),(select max(observed_at) from public.ad_network_relationship_events), + (select max(captured_at) from public.ad_network_daily_counts)) as as_of + from public.ad_network_publishers; +revoke all on all tables in schema stack127_data from public,anon,authenticated; + +create schema if not exists stack127_api; +revoke all on schema stack127_api from public,anon,authenticated; +create table if not exists stack127_api.rate_limits ( + principal text primary key, + minute_on timestamptz not null, + day_on timestamptz not null, + minute_count integer not null check(minute_count>=0), + day_count integer not null check(day_count>=0) +); +alter table stack127_api.rate_limits enable row level security; +revoke all on stack127_api.rate_limits from public,anon,authenticated; +-- Production uses stack127_app; managed development uses postgres. +-- Anonymous API roles have no access to projections or counters. +do $$ declare app_role text; begin + foreach app_role in array array['stack127_app','postgres'] loop + if exists(select 1 from pg_roles where rolname=app_role) then + execute format('grant usage on schema stack127_data to %I',app_role); + execute format('grant select on all tables in schema stack127_data to %I',app_role); + execute format('grant usage on schema stack127_api to %I',app_role); + execute format('grant select,insert,update on stack127_api.rate_limits to %I',app_role); + execute format('drop policy if exists %I on stack127_api.rate_limits','rate_limits_'||app_role); + execute format('create policy %I on stack127_api.rate_limits for all to %I using (true) with check (true)', + 'rate_limits_'||app_role,app_role); + end if; + end loop; +end $$; +commit; diff --git a/tests/data-api/core.test.mjs b/tests/data-api/core.test.mjs new file mode 100644 index 00000000..14eb5b52 --- /dev/null +++ b/tests/data-api/core.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { executeTool,filterSql,validateInput,decodeCursor,normalizeDomain } from '../../lib/data-api/ad-networks/core.ts'; +import { validateReadonlySql } from '../../lib/data-api/ad-networks/sql.ts'; +import { authenticate,matchesSecret } from '../../lib/data-api/auth.ts'; +import { rowLimit } from '../../lib/data-api/config.ts'; +import { handleMcp } from '../../lib/data-api/mcp.ts'; +import { handleRest } from '../../lib/data-api/rest.ts'; + +const token=randomBytes(32).toString('hex'); +test('private authentication rejects missing, malformed, short and wrong secrets; accepts bearer or path',async()=>{ + process.env.STACK127_MCP_TOKEN=token;process.env.STACK127_MCP_PATH_SECRET=token+'a'; + for(const headers of [{},{authorization:'Bearer wrong'},{authorization:'Basic '+token}])await assert.rejects(authenticate(new Request('http://localhost',{headers})),{status:401}); + assert.equal((await authenticate(new Request('http://localhost',{headers:{authorization:'Bearer '+token}}))).id,'private-owner'); + assert.equal((await authenticate(new Request('http://localhost'),token+'a')).id,'private-owner'); + await assert.rejects(authenticate(new Request('http://localhost',{headers:{authorization:'Bearer wrong'}}),token+'a')); + assert.equal(matchesSecret('short','short'),false);assert.equal(matchesSecret(token+'a',token),false); +}); +test('off switches fail closed before database/authentication',async()=>{ + delete process.env.STACK127_MCP_ENABLED;delete process.env.STACK127_REST_ENABLED; + for(const r of [await handleMcp(new Request('http://localhost',{method:'POST'})),await handleRest(new Request('http://localhost',{method:'POST'}),'count_sites')]){ + assert.equal(r.status,404);assert.equal(r.headers.get('cache-control'),'no-store'); + } +}); +test('input bounds and ranges reject unsafe/unbounded requests',()=>{ + for(const [name,input] of [['find_sites',{limit:201}],['count_sites',{status:'ended'}],['count_sites',{networks:['ezoic','ezoic']}],['count_sites',{group_by:['network','network']}],['count_sites',{group_by:['network','niche','platform']}],['count_sites',{registered_after:'2026-02-30'}],['count_sites',{registered_after:'2026-01-02',registered_before:'2026-01-01'}],['count_sites',{min_age:10,max_age:2}],['network_movements',{from:'2020-01-01',to:'2026-01-01'}],['compare_ad_networks',{networks:['ezoic'],dimension:'niche'}]])assert.throws(()=>validateInput(name,input),{status:400}); + process.env.STACK127_DATA_MAX_ROWS='201';assert.throws(rowLimit);delete process.env.STACK127_DATA_MAX_ROWS; + assert.throws(()=>decodeCursor('invalid','a'),{status:400}); + assert.equal(normalizeDomain('EXAMPLE.COM.'),'example.com');assert.throws(()=>normalizeDomain('https://example.com')); +}); +test('filters bind values and always use listed status, including managed',()=>{ + const f=filterSql({networks:['ezoic'],relationship:'managed',niche:"food' or 1=1--",registered_after:'2020-01-01',max_age:5}); + assert.match(f.text,/confirmed','stale/);assert.match(f.text,/primary_or_exclusive_manager/); + assert.ok(!f.text.includes("food'"));assert.deepEqual(f.values,[['ezoic'],"food' or 1=1--",'2020-01-01',5]); +}); +test('SQL parser rejects writes, batches, CTE mutations, locks, private fields by relation, functions and casts',()=>{ + for(const sql of ['delete from stack127_data.ad_networks','select 1; select 2','with x as (delete from public.ad_networks returning *) select * from x', + 'select * from public.ad_network_publishers','select * from pg_catalog.pg_authid','select pg_read_file(\'/etc/passwd\')', + "select current_setting('app.secret')",'select set_config(\'role\',\'postgres\',true)', + 'select * from stack127_data.ad_networks for update','select 1 into temporary table stolen', + 'select pg_sleep(10)','select * from stack127_data.ad_networks -- comment',"select 'public.ad_network_publishers'::regclass",'values (1)', + 'with x as (select 1) select * from x'])assert.throws(()=>validateReadonlySql(sql),{status:400},sql); + for(const sql of ['select slug,name from stack127_data.ad_networks order by slug', + 'select count(distinct p.id) from stack127_data.ad_network_publishers p join stack127_data.ad_network_publisher_relationships r on p.id=r.publisher_id', + 'select * from (select slug from stack127_data.ad_networks) as n'])assert.equal(validateReadonlySql(sql),sql); +}); +test('every core response carries definitions, caveats, filters, data time and citation',async()=>{ + const transaction=async run=>run({query:async sql=>sql.includes('dataset_summary')?[{as_of:'2026-11-14T00:00:00Z'}]:[{total:2}]}); + const answer=await executeTool('count_sites',{},transaction); + assert.equal(answer.data.total,2);assert.equal(answer.filters.relationship,'listed');assert.ok(answer.as_of); + assert.match(answer.definitions.not_ranked,/not small/);assert.ok(answer.definitions.managed);assert.ok(answer.definitions.shares); + assert.ok(answer.caveats.some(x=>x.includes('STRONG WARNING')));assert.ok(answer.url.startsWith('https://stack127.com/')); +}); + +test('one response shares a hard row budget across distributions and history',async()=>{ + const {boundResults}=await import('../../lib/data-api/ad-networks/core.ts'); + const data={co_listed:[1,2,3],daily_counts:[1,2,3],distributions:{niche:{rows:[1,2,3]},platform:{rows:[1,2]}}}; + const budget=boundResults(data,5); + assert.equal(budget.row_count,5);assert.ok(budget.truncated_sets.length); + assert.equal(data.co_listed.length+data.daily_counts.length+data.distributions.niche.rows.length+data.distributions.platform.rows.length,5); +}); +test('body limit is checked while reading, even without Content-Length',async()=>{ + const {readJson}=await import('../../lib/data-api/http.ts'); + await assert.rejects(readJson(new Request('http://localhost',{method:'POST',body:'x'.repeat(65537)})),{status:413}); + await assert.rejects(readJson(new Request('http://localhost',{method:'POST',body:'{'})),{status:400}); +}); + +test('SQL cannot pack unbounded records into arrays or collection aggregates',()=>{ + for(const sql of [ + 'select array(select normalized_domain from stack127_data.ad_network_publishers limit 1000)', + 'select array_agg(normalized_domain) from stack127_data.ad_network_publishers', + 'select json_agg(p) from stack127_data.ad_network_publishers p', + 'select jsonb_agg(normalized_domain) from stack127_data.ad_network_publishers', + "select string_agg(normalized_domain,',') from stack127_data.ad_network_publishers", + 'select xmlagg(normalized_domain) from stack127_data.ad_network_publishers', + 'select array[1,2,3]', + ]) assert.throws(()=>validateReadonlySql(sql),{status:400},sql); +}); +test('SQL flag alone cannot run a query or expose a REST route',async()=>{ + process.env.STACK127_DATA_SQL_ENABLED='true';delete process.env.STACK127_DATA_SQL_DATABASE_URL; + await assert.rejects(executeTool('run_readonly_sql',{sql:'select 1'},async()=>assert.fail('SQL reached a transaction')),{status:404}); + const response=await handleRest(new Request('http://localhost',{method:'POST'}),'run_readonly_sql'); + assert.equal(response.status,404); + delete process.env.STACK127_DATA_SQL_ENABLED; +}); +test('MCP text contains the full answer and applies the same byte limit after formatting',async()=>{ + const {mcpAnswer}=await import('../../lib/data-api/mcp.ts'); + const answer=await executeTool('count_sites',{},async run=>run({query:async sql=>sql.includes('dataset_summary')?[{as_of:null}]:[{total:42}]})); + const result=mcpAnswer(answer); + assert.deepEqual(JSON.parse(result.content[0].text),result.structuredContent); + assert.equal(JSON.parse(result.content[0].text).data.total,42); + assert.throws(()=>mcpAnswer({...answer,data:{huge:'x'.repeat(1_000_000)}}),{status:413}); + assert.throws(()=>mcpAnswer({...answer,data:{many:Array(50000).fill({a:1})}}),{status:413}); +}); + +test('future SQL uses only its dedicated pool and read-only transaction, including rollback',async()=>{ + const {sqlReadTransaction}=await import('../../lib/data-api/database.ts'); + const statements=[];let released=0; + const previous={app:globalThis.stack127DataApiPool,sql:globalThis.stack127DataSqlPool,flag:process.env.STACK127_DATA_SQL_ENABLED,url:process.env.STACK127_DATA_SQL_DATABASE_URL}; + globalThis.stack127DataApiPool={connect:()=>assert.fail('arbitrary SQL used the app login')}; + globalThis.stack127DataSqlPool={connect:async()=>({query:async(sql,values)=>{ + statements.push({sql,values});return {rows:sql.includes('dataset_summary')?[{as_of:null}]:[{value:1}]}; + },release(){released++;}})}; + Object.assign(process.env,{STACK127_DATA_SQL_ENABLED:'true',STACK127_DATA_SQL_DATABASE_URL:'postgresql://restricted:unused@localhost/research'}); + try { + const answer=await executeTool('run_readonly_sql',{sql:'select 1 as value'}); + assert.equal(answer.data.rows[0].value,1); + assert.match(statements[0].sql,/begin transaction.*read only/); + assert.ok(statements.some(q=>q.sql.includes('statement_timeout')&&q.values[0]==='15000')); + assert.ok(!statements.some(q=>/set local role/i.test(q.sql))); + assert.equal(statements.at(-1).sql,'commit'); + await assert.rejects(sqlReadTransaction(()=>{throw Error('callback failed');}),/callback failed/); + assert.equal(statements.at(-1).sql,'rollback');assert.equal(released,2); + } finally { + for(const [key,value] of [['STACK127_DATA_SQL_ENABLED',previous.flag],['STACK127_DATA_SQL_DATABASE_URL',previous.url]]) { + if(value===undefined)delete process.env[key];else process.env[key]=value; + } + globalThis.stack127DataApiPool=previous.app;globalThis.stack127DataSqlPool=previous.sql; + } +}); diff --git a/tests/data-api/fixtures/next-server.mjs b/tests/data-api/fixtures/next-server.mjs new file mode 100644 index 00000000..a2626dad --- /dev/null +++ b/tests/data-api/fixtures/next-server.mjs @@ -0,0 +1,5 @@ +// Use Next's own listener, routing and shutdown path, without a custom server. +import {startServer} from 'next/dist/server/lib/start-server.js'; +process.env.__NEXT_DEV_SERVER = '1'; +await startServer({dir: process.cwd(), isDev: true, port: 0, hostname: '127.0.0.1'}); +process.send({port: Number(process.env.PORT)}); diff --git a/tests/data-api/integration.test.mjs b/tests/data-api/integration.test.mjs new file mode 100644 index 00000000..df7a6549 --- /dev/null +++ b/tests/data-api/integration.test.mjs @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { parseEnv } from 'node:util'; +import { once } from 'node:events'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { POST as bearerMcp } from '../../app/api/mcp/route.ts'; +import { POST as pathMcp } from '../../app/api/mcp/[secret]/route.ts'; +import { POST as restRoute } from '../../app/api/v1/ad-networks/[tool]/route.ts'; +import { executeTool,validateInput } from '../../lib/data-api/ad-networks/core.ts'; +import { dataPool,readTransaction,consumeRateLimit } from '../../lib/data-api/database.ts'; +import { toolNames } from '../../lib/data-api/ad-networks/schemas.ts'; + +// Opt-in is insufficient: compare the effective connection with the checked +// development credential and pin the managed project identity before any write. +let devEnv={}; +try {devEnv=parseEnv(readFileSync(new URL('../../.env.local',import.meta.url),'utf8'));} catch { /* No development credentials: skip. */ } +const connection=process.env.DATABASE_POOLER_URL||process.env.DATABASE_URL; +const devConnection=devEnv.DATABASE_POOLER_URL||devEnv.DATABASE_URL; +const live=process.env.STACK127_DATA_INTEGRATION==='true' + && devEnv.SUPABASE_URL==='https://yzasonowwlvritqicatj.supabase.co' + && connection===devConnection + && new URL(connection).username==='postgres.yzasonowwlvritqicatj' + && new URL(connection).hostname.endsWith('.pooler.supabase.com'); +test('development: SDK Streamable HTTP calls every tool, REST parity, public-count parity, projection boundaries and durable limits',{skip:!live,timeout:180000},async()=>{ + delete process.env.STACK127_DATA_SQL_DATABASE_URL; + Object.assign(process.env,{STACK127_MCP_ENABLED:'true',STACK127_REST_ENABLED:'true',STACK127_DATA_SQL_ENABLED:'true',STACK127_MCP_TOKEN:randomBytes(32).toString('hex'),STACK127_MCP_PATH_SECRET:randomBytes(32).toString('hex'),STACK127_DATA_RPM:'1000'}); + const principal={id:'data-api-test-'+randomBytes(6).toString('hex'),plan:'private',allowSql:true}; + const server=createServer(async(req,res)=>{ + try { + const chunks=[];for await(const c of req)chunks.push(c); + const request=new Request(`http://127.0.0.1${req.url}`,{method:req.method,headers:req.headers,body:chunks.length?Buffer.concat(chunks):undefined}); + const path=new URL(request.url).pathname; + const response=path.startsWith('/api/v1/')?await restRoute(request,{params:Promise.resolve({tool:path.split('/').at(-1)})}):path.split('/')[3]?await pathMcp(request,{params:Promise.resolve({secret:path.split('/')[3]})}):await bearerMcp(request); + res.writeHead(response.status,Object.fromEntries(response.headers));res.end(Buffer.from(await response.arrayBuffer())); + }catch{res.writeHead(500);res.end();} + }); + const client=new Client({name:'stack127-data-api-test',version:'1.0.0'}); + server.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`; + try { + await client.connect(new StreamableHTTPClientTransport(new URL(base+'/api/mcp/'+process.env.STACK127_MCP_PATH_SECRET))); + const {tools}=await client.listTools();assert.deepEqual(tools.map(t=>t.name).sort(),toolNames.filter(n=>n!=='run_readonly_sql').toSorted()); + for(const t of tools){assert.equal(t.annotations.readOnlyHint,true);assert.ok(t.title&&t.description&&t.outputSchema);} + const sample=(await executeTool('find_sites',{limit:1})).data.sites[0];assert.ok(sample); + const inputs={ad_networks_overview:{},list_ad_networks:{},get_ad_network:{network:'mediavine'},compare_ad_networks:{networks:['mediavine','ezoic'],dimension:'niche'},count_sites:{networks:['mediavine'],relationship:'managed'},find_sites:{limit:2},lookup_site:{domain:sample.domain},network_movements:{from:'2026-10-19',to:'2026-11-14'},run_readonly_sql:{sql:'select slug,name from stack127_data.ad_networks order by slug'}}; + for(const name of toolNames.filter(n=>n!=='run_readonly_sql')){const result=await client.callTool({name,arguments:inputs[name]});assert.ok(!result.isError,`${name}: ${JSON.stringify(result.content)}`);const answer=result.structuredContent;assert.ok(answer.definitions&&answer.caveats&&answer.url);assert.ok('as_of' in answer);assert.deepEqual(JSON.parse(result.content[0].text),answer);assert.ok(!JSON.stringify(answer).includes('publisher_name'));console.log('SDK tool passed:',name);} + const response=await fetch(base+'/api/v1/ad-networks/count_sites',{method:'POST',headers:{authorization:'Bearer '+process.env.STACK127_MCP_TOKEN,'content-type':'application/json'},body:JSON.stringify(inputs.count_sites)}); + assert.equal(response.status,200);assert.equal(response.headers.get('cache-control'),'no-store');const rest=await response.json(); + // Same SQL definition used by loadAdNetworks/public listed rows, restricted to the agreed manager relationship. + const raw=await dataPool().connect();try{await raw.query('begin transaction read only'); + const direct=await raw.query("select count(distinct r.publisher_id)::int as total from public.ad_network_publisher_relationships r join public.ad_networks n on n.id=r.ad_network_id where n.slug='mediavine' and r.status in ('confirmed','stale') and r.relationship_type='primary_or_exclusive_manager'");assert.equal(rest.data.total,direct.rows[0].total); + const global=await raw.query("select count(distinct publisher_id)::int as total from public.ad_network_publisher_relationships where status in ('confirmed','stale')");assert.equal((await executeTool('count_sites',{})).data.total,global.rows[0].total); + console.log('Public query parity: managed mediavine',direct.rows[0].total,'global distinct listed',global.rows[0].total); + }finally{await raw.query('rollback');raw.release();} + const byGroup=await executeTool('count_sites',{networks:['mediavine','ezoic'],group_by:['network','niche']}); + for(const slug of ['mediavine','ezoic']){const rows=byGroup.data.rows.filter(r=>r.network===slug);assert.equal(rows.reduce((s,r)=>s+r.count,0),(await executeTool('count_sites',{networks:[slug]})).data.total);assert.ok(Math.abs(rows.reduce((s,r)=>s+r.share_percent,0)-100)<0.00001);} + for(const sort of ['domain','newest','oldest']){const first=await executeTool('find_sites',{limit:2,sort});const second=await executeTool('find_sites',{limit:2,sort,cursor:first.data.next_cursor});assert.equal(new Set([...first.data.sites,...second.data.sites].map(s=>s.domain)).size,4);} + await assert.rejects(readTransaction(db=>db.query('select publisher_name from ad_network_publishers'))); + await assert.rejects(readTransaction(db=>db.query("delete from ad_networks where false"))); + const identity=(await readTransaction(db=>db.query("select current_user as role,current_setting('transaction_read_only') as ro")))[0]; + assert.equal(identity.role,'postgres');assert.equal(identity.ro,'on'); + const before=await readTransaction(db=>db.query("select current_setting('statement_timeout') as timeout"));assert.equal(before[0].timeout,'15s'); + process.env.STACK127_DATA_TIMEOUT_MS='10';await assert.rejects(readTransaction(db=>db.query('select pg_sleep(0.05)')),{code:'57014'});delete process.env.STACK127_DATA_TIMEOUT_MS; + process.env.STACK127_DATA_MAX_ROWS='2';const capped=await executeTool('list_ad_networks',{});assert.equal(capped.data.networks.length,2);assert.equal(capped.limits.truncated,true);delete process.env.STACK127_DATA_MAX_ROWS; + process.env.STACK127_DATA_RPM='2';const rates=await Promise.allSettled(Array.from({length:5},()=>consumeRateLimit(principal)));assert.equal(rates.filter(r=>r.status==='fulfilled').length,2); + process.env.STACK127_DATA_RPM='1000';process.env.STACK127_DATA_RPD='2';await assert.rejects(consumeRateLimit(principal),{status:429}); + process.env.STACK127_DATA_RPD='10000';process.env.STACK127_DATA_SQL_ENABLED='false';const noSql=await client.listTools();assert.ok(!noSql.tools.some(t=>t.name==='run_readonly_sql')); + const rejectedSql=await fetch(base+'/api/v1/ad-networks/run_readonly_sql',{method:'POST',headers:{authorization:'Bearer '+process.env.STACK127_MCP_TOKEN},body:JSON.stringify({sql:'select 1'})});assert.equal(rejectedSql.status,404); + const invalid=await fetch(base+'/api/v1/ad-networks/find_sites',{method:'POST',headers:{authorization:'Bearer '+process.env.STACK127_MCP_TOKEN},body:'{"limit":201}'});assert.equal(invalid.status,400); + const unauthorized=await fetch(base+'/api/v1/ad-networks/count_sites',{method:'POST',body:'{}'});assert.equal(unauthorized.status,401); + const origin=await fetch(base+'/api/mcp',{method:'POST',headers:{origin:'https://evil.example',authorization:'Bearer '+process.env.STACK127_MCP_TOKEN},body:'{}'});assert.equal(origin.status,403); + }finally{await client.close();await new Promise(resolve=>server.close(resolve));await dataPool().query('delete from stack127_api.rate_limits where principal=$1',[principal.id]);await dataPool().end();} +}); diff --git a/tests/data-api/next-routing.test.mjs b/tests/data-api/next-routing.test.mjs new file mode 100644 index 00000000..6b3e1435 --- /dev/null +++ b/tests/data-api/next-routing.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {request} from 'node:http'; +import {readFileSync, writeFileSync} from 'node:fs'; +import {randomBytes} from 'node:crypto'; +import {canonicalPrivatePaths, malformedPrivatePaths} from './private-paths.mjs'; + +// Use raw HTTP targets: fetch's URL parser would erase dot segments under test. +function send(port, path, method) { + return new Promise((accept, reject) => { + const req = request({host: '127.0.0.1', port, path, method, agent: false}, response => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { body += chunk; }); + response.on('end', () => accept({status: response.statusCode, headers: response.headers, body})); + response.on('error', reject); + }); + req.on('error', reject); + req.setTimeout(30000, () => req.destroy(Error('routing test request timed out'))); + req.end(); + }); +} + +function privateResponse(response, method, status, label) { + assert.equal(response.status, status, label); + for (const name of ['cache-control', 'cdn-cache-control', 'cloudflare-cdn-cache-control']) { + assert.equal(response.headers[name], 'no-store', label + ' ' + name); + } + for (const name of ['location', 'refresh', 'cache-tag', 'link', 'x-stack127-pathname', 'x-stack127-cache-class']) { + assert.equal(response.headers[name], undefined, label + ' ' + name); + } + if (status === 404) assert.equal(response.body, method === 'HEAD' ? '' : '{"error":{"code":"not_found","message":"Not found."}}', label); +} + +for (const enabled of [false, true]) { + test(`Next routing protects canonical private URLs and only reflects caller URLs in normalization redirects; enabled=${enabled}`, {timeout: 180000}, async t => { + // Restore only Next dev's known declaration edit, preserving concurrent work. + const nextEnvPath = new URL('../../next-env.d.ts', import.meta.url); + const originalNextEnv = readFileSync(nextEnvPath, 'utf8'); + t.after(() => { + if (readFileSync(nextEnvPath, 'utf8') === originalNextEnv.replace('./.next/types/routes.d.ts', './.next/dev/types/routes.d.ts')) { + writeFileSync(nextEnvPath, originalNextEnv); + } + }); + const configuredSecret = randomBytes(32).toString('hex'); + const callerSecret = randomBytes(32).toString('hex'); + let logs = ''; + const child = spawn(process.execPath, ['tests/data-api/fixtures/next-server.mjs'], { + cwd: new URL('../../', import.meta.url), + env: {...process.env, NODE_ENV: 'development', NEXT_TELEMETRY_DISABLED: '1', + STACK127_MCP_ENABLED: String(enabled), STACK127_REST_ENABLED: String(enabled), STACK127_DATA_SQL_ENABLED: 'false', + STACK127_DATA_SQL_DATABASE_URL: '', STACK127_MCP_PATH_SECRET: configuredSecret}, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + child.stdout.on('data', chunk => { logs += chunk; }); + child.stderr.on('data', chunk => { logs += chunk; }); + t.after(async () => { + if (child.exitCode === null) { + const exited = new Promise(accept => child.once('exit', accept)); + const kill = setTimeout(() => child.kill('SIGKILL'), 10000).unref(); + child.kill('SIGTERM'); + await exited; + clearTimeout(kill); + } + // Noncanonical URLs are outside the narrow development log exclusion. + assert.ok(!logs.includes(configuredSecret), 'server output contained the configured capability'); + }); + const port = await new Promise((accept, reject) => { + child.once('message', ({port}) => accept(port)); + child.once('error', reject); + child.once('exit', code => reject(Error(`Next server exited ${code}: ${logs}`))); + }); + const publicRedirect = await send(port, '/dns-tools/dns-lookup/', 'GET'); + assert.equal(publicRedirect.status, 308); + assert.ok(publicRedirect.headers.location); + + let count = 0, redirects = 0; + for (const path of canonicalPrivatePaths(callerSecret)) { + for (const query of ['', '?q=private']) { + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']) { + const response = await send(port, path + query, method); + const label = method + ' ' + path.replaceAll(callerSecret, '') + query; + const status = enabled && ['/api/mcp', '/api/v1/ad-networks/count_sites'].includes(path) ? 401 : 404; + privateResponse(response, method, status, label); + for (const secret of [callerSecret, configuredSecret]) { + assert.ok(!JSON.stringify(response).includes(secret), label + ' credential leak'); + } + count++; + } + } + } + // A correct capability also stays private when the feature is switched off. + if (!enabled) for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']) { + const response = await send(port, '/api/mcp/' + configuredSecret, method); + privateResponse(response, method, 404, 'disabled correct capability'); + assert.ok(!JSON.stringify(response).includes(configuredSecret)); + count++; + } + assert.ok(!logs.includes(callerSecret), 'canonical request appeared in development logs'); + + for (const {path, destination, enabledStatus} of malformedPrivatePaths(callerSecret)) { + for (const query of ['', '?q=private']) { + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']) { + const response = await send(port, path + query, method); + const label = method + ' ' + path.replaceAll(callerSecret, '') + query; + assert.ok(!JSON.stringify(response).includes(configuredSecret), label + ' exposed a different credential'); + for (const name of ['cache-tag', 'link', 'x-stack127-pathname', 'x-stack127-cache-class']) { + assert.equal(response.headers[name], undefined, label + ' ' + name); + } + if (destination) { + assert.equal(response.status, 308, label); + assert.equal(response.headers.location, destination + query, label); + assert.equal(response.headers.refresh, '0;url=' + destination + query, label); + assert.equal(response.body, method === 'HEAD' ? '' : destination + query, label); + // This is the origin, without Cloudflare: early redirects run before + // app headers. Edge non-storage is a required separate release check. + for (const name of ['cache-control', 'cdn-cache-control', 'cloudflare-cdn-cache-control']) { + assert.equal(response.headers[name], undefined, label + ' unexpected origin cache directive'); + } + redirects++; + } else { + privateResponse(response, method, enabled ? enabledStatus ?? 404 : 404, label); + assert.ok(!JSON.stringify(response).includes(callerSecret), label + ' echoed a nonredirect URL'); + } + count++; + } + } + } + t.diagnostic(`${count} real HTTP requests; ${redirects} caller-only 308s with no origin cache directive; Cloudflare no-store/bypass must be verified at release; no build or database calls`); + }); +} diff --git a/tests/data-api/private-paths.mjs b/tests/data-api/private-paths.mjs new file mode 100644 index 00000000..6f6a8949 --- /dev/null +++ b/tests/data-api/private-paths.mjs @@ -0,0 +1,44 @@ +// Synthetic caller paths. No configured credential is needed for these probes. +export function canonicalPrivatePaths(secret) { + return [`/api/mcp/${secret}`, '/api/mcp', '/api/v1/ad-networks/count_sites', '/api/v1', '/api/does-not-exist']; +} + +// Expected normalization belongs to the caller's URL alone. The framework can +// return it before proxy.ts and route handlers can add their no-store headers. +export function malformedPrivatePaths(secret) { + return [ + {path: `http://stack127.test/api/mcp/${secret}`, destination: `http://stack127.test/api/mcp/${secret}`}, + {path: `http://stack127.test/api//mcp/${secret}`, destination: `http://stack127.test/api/mcp/${secret}`}, + {path: `/api/no/../mcp/../../other/${secret}//`, destination: `/other/${secret}/`}, + {path: `/api/mcp/../../other/${secret}//`, destination: `/other/${secret}/`}, + {path: `/api/mcp/${secret}/../..`, destination: `/api`}, + {path: `/api/mcp-extra/${secret}//`, destination: `/api/mcp-extra/${secret}/`}, + {path: `/api//mcp/${secret}`, destination: `/api/mcp/${secret}`}, + {path: `/api/mcp/${secret}//`, destination: `/api/mcp/${secret}/`}, + {path: `//api/mcp/${secret}`, destination: `/api/mcp/${secret}`}, + {path: `///api///mcp/${secret}`, destination: `/api/mcp/${secret}`}, + {path: `/api\\mcp\\${secret}`, destination: `/api/mcp/${secret}`}, + {path: `/api/mcp\\${secret}`, destination: `/api/mcp/${secret}`}, + {path: `/api/mcp/${secret}/`, destination: `/api/mcp/${secret}`}, + {path: '/api/mcp/', destination: '/api/mcp'}, + {path: '/api//v1/ad-networks/count_sites', destination: '/api/v1/ad-networks/count_sites'}, + {path: '/api/v1/ad-networks/count_sites/', destination: '/api/v1/ad-networks/count_sites'}, + {path: '/api/v1/', destination: '/api/v1'}, + {path: `/Api/mCp/${secret}/`, destination: `/Api/mCp/${secret}`}, + {path: `/base/api/mcp/${secret}/`, destination: `/base/api/mcp/${secret}`}, + {path: `/en/api/mcp/${secret}/`, destination: `/en/api/mcp/${secret}`}, + {path: `/base/en/api/mcp/${secret}/`, destination: `/base/en/api/mcp/${secret}`}, + {path: `/api%2Fmcp/${secret}/`, destination: `/api%2Fmcp/${secret}`}, + {path: `/api%2fmcp/%ZZ/${secret}//`, destination: `/api%2fmcp/%ZZ/${secret}/`}, + ...[ + `/api%2fmcp%2f${secret}`, `/api%5cmcp%5c${secret}`, `/%61pi/%6dcp/${secret}`, + `/api%252fmcp%252f${secret}`, `/api%25252fmcp/${secret}`, + `/api/./mcp/${secret}`, `/api/no/../mcp/${secret}`, `/api/%2e/mcp/${secret}`, + `/api/no/%2e%2E/mcp/${secret}`, `/api/no/%252e%252e/mcp/${secret}`, + `/api/mcp/${secret}/extra`, '/api%2fv1/ad-networks/count_sites', + `/other/../api/mcp/${secret}`, + `/API/MCP/${secret}`, '/API/V1/ad-networks/count_sites', + ].map(path => ({path})), + {path: '/api/no/../v1/ad-networks/count_sites', enabledStatus: 401}, + ]; +} diff --git a/tests/data-api/proxy-headers.test.mjs b/tests/data-api/proxy-headers.test.mjs new file mode 100644 index 00000000..40074504 --- /dev/null +++ b/tests/data-api/proxy-headers.test.mjs @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +import ts from 'typescript'; +import * as pathPolicy from '../../lib/data-api/path-policy.mjs'; +import * as privateResponse from '../../lib/data-api/private-response.mjs'; +import {randomBytes} from 'node:crypto'; +import * as bearer from '../../app/api/mcp/route.ts'; +import * as pathRoute from '../../app/api/mcp/[secret]/route.ts'; +import * as rest from '../../app/api/v1/ad-networks/[tool]/route.ts'; +import * as unknown from '../../app/api/[...path]/route.ts'; + +const require=createRequire(import.meta.url); +const {NextRequest}=require('next/server'); +// Execute the real proxy. Stub only public-page imports, which private API +// requests must never call. This avoids loading the unrelated Next page cache. +const exports={}; +const forbidden=()=>assert.fail('API entered public page metadata code'); +const publicModules={ + './lib/data-api/path-policy.mjs':pathPolicy, + './lib/data-api/private-response.mjs':privateResponse, + '@/lib/page-registry':{getExactPublishedPage:forbidden,slugFromPathname:forbidden}, + '@/lib/tool-catalogs':{getPublishedToolCatalog:forbidden}, + '@/lib/tool-pages':{getPublishedToolPage:forbidden}, + '@/lib/directory-pages':{getPublishedDirectoryPage:forbidden}, + '@/lib/page-source':{sitePageAvailable:forbidden}, + '@/lib/seo':{absoluteUrl:forbidden}, + '@/lib/seo/query-indexing.mjs':{queryNeedsNoindex:forbidden}, +}; +const source=ts.transpileModule(readFileSync(new URL('../../proxy.ts',import.meta.url),'utf8'),{ + compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}, +}).outputText; +vm.runInNewContext(source,{exports,Headers,URLSearchParams,process,require:name=>publicModules[name]??require(name)}); +const {proxy}=exports; + +// Follow Next's response-header merge and request-header override protocol. +async function pipeline(path,method,route,context,headers={}) { + const request=new NextRequest('http://localhost'+path,{method,headers, + ...(method==='POST'?{body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/list'}),headers:{...headers,'content-type':'application/json','accept':'application/json, text/event-stream'}}:{})}); + const middleware=await proxy(request); + for(const [key,value] of middleware.headers) { + assert.ok(!value.includes(secret),key+' exposed the path credential'); + assert.ok(!['cache-tag','link','x-stack127-pathname','x-middleware-request-x-stack127-pathname'].includes(key),key); + } + const forwarded=new Headers(request.headers); + const overrides=middleware.headers.get('x-middleware-override-headers'); + if(overrides!==null) { + for(const key of [...forwarded.keys()])forwarded.delete(key); + for(const key of overrides.split(',').filter(Boolean))forwarded.set(key,middleware.headers.get('x-middleware-request-'+key)); + } + assert.equal(forwarded.get('x-stack127-pathname'),null); + let response; + try {response=middleware.status===404?middleware:await route(new Request(request,{headers:forwarded}),context);} + catch {response=new Response(null,{status:500});} // Framework error boundary. + for(const [key,value] of middleware.headers)if(!key.startsWith('x-middleware-'))response.headers.set(key,value); + for(const [key,value] of response.headers) { + assert.ok(!value.includes(secret),key+' exposed the path credential'); + assert.ok(!['cache-tag','link','x-stack127-pathname','x-stack127-cache-class'].includes(key),key); + } + assert.ok(!(await response.clone().text()).includes(secret)); + return response; +} +const secret=randomBytes(32).toString('hex'); +let mode='ok'; +globalThis.stack127DataApiPool={ + query:async()=>{if(mode==='error')throw Error(secret);return {rowCount:mode==='rate'?0:1};}, + connect:async()=>({query:async sql=>({rows:sql.includes('dataset_summary')?[{as_of:null}]:sql.includes('count(')?[{total:42}]:[]}),release(){}}), +}; +test('real proxy plus real API routes never expose capability paths on success or errors, for every method',async t=>{ + Object.assign(process.env,{STACK127_MCP_ENABLED:'true',STACK127_REST_ENABLED:'true',STACK127_MCP_PATH_SECRET:secret,STACK127_MCP_TOKEN:secret+'a',STACK127_DATA_SQL_ENABLED:'true'}); + delete process.env.STACK127_DATA_SQL_DATABASE_URL; + const logs=[]; + for(const name of ['info','warn','error'])t.mock.method(console,name,(...args)=>logs.push(args.join(' '))); + const context={params:Promise.resolve({secret})}; + for(const method of ['GET','POST','PUT','PATCH','DELETE','OPTIONS','HEAD']) { + assert.equal((await pipeline('/api/mcp/'+secret+'?q=private',method,pathRoute[method],context)).status,method==='POST'?200:405); + assert.equal((await pipeline('/api/mcp/wrong?q=private',method,pathRoute[method],{params:Promise.resolve({secret:'wrong'})})).status,404); + assert.equal((await pipeline('/api/v1/ad-networks/count_sites?q=private',method,rest[method],{params:Promise.resolve({tool:'count_sites'})})).status,401); + assert.equal((await pipeline('/api/mcp?q=private',method,bearer[method])).status,401); + } + const discovery=await pipeline('/api/mcp/'+secret,'POST',pathRoute.POST,context); + assert.ok(!(await discovery.json()).result.tools.some(tool=>tool.name==='run_readonly_sql')); + const unknownTool=await pipeline('/api/v1/ad-networks/unknown?q=private','POST',rest.POST,{params:Promise.resolve({tool:'unknown'})}); + assert.equal(unknownTool.status,404); + await pipeline('/api/mcp/'+secret,'POST',pathRoute.POST,context,{'x-stack127-pathname':secret}); + const wrong=await pipeline('/api/mcp/wrong?q=private','POST',pathRoute.POST,{params:Promise.resolve({secret:'wrong'})}); + const missing=await pipeline('/api/does-not-exist?q=private','POST',unknown.POST); + assert.deepEqual([...wrong.headers],[...missing.headers]);assert.equal(await wrong.text(),await missing.text()); + mode='rate';assert.equal((await pipeline('/api/mcp/'+secret,'POST',pathRoute.POST,context)).status,429); + mode='error';assert.equal((await pipeline('/api/mcp/'+secret,'POST',pathRoute.POST,context)).status,503); + mode='ok';assert.equal((await pipeline('/api/mcp/'+secret,'POST',pathRoute.POST,{params:Promise.reject(Error(secret))})).status,500); + process.env.STACK127_MCP_ENABLED='false';assert.equal((await pipeline('/api/mcp/'+secret,'POST',pathRoute.POST,context)).status,404); + assert.ok(!logs.join('\n').includes(secret)); +}); + +test('installed Next logger omits canonical private namespaces and still logs public requests',t=>{ + const configExports={}; + const configCode=ts.transpileModule(readFileSync(new URL('../../next.config.ts',import.meta.url),'utf8'),{ + compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS,esModuleInterop:true}, + }).outputText; + vm.runInNewContext(configCode,{exports:configExports,process,URL,require:name=>{ + if(name==='@next/mdx')return ()=>config=>config; + if(name==='./lib/allowed-dev-origins')return {collectAllowedDevOrigins:()=>[]}; + if(name==='./lib/legacy-shortcut-redirects')return {legacyShortcutRedirects:[]}; + throw Error('Unexpected config import: '+name); + }}); + const {logRequests}=require('next/dist/server/dev/log-requests.js'); + const writes=[]; + const writer=t.mock.method(process.stdout,'write',chunk=>{writes.push(String(chunk));return true;}); + try { + for(const method of ['GET','POST','PUT','PATCH','DELETE','OPTIONS','HEAD']) { + for(const statusCode of [200,400,401,404,405,429,500,503]) { + for(const url of ['/api/mcp', '/api/mcp/'+secret, '/api/mcp/wrong', '/api/v1', '/api/v1/ad-networks/count_sites'].flatMap(path=>[path,path+'?q=private'])) { + logRequests({url,method},{statusCode},configExports.default.logging,1n,2n); + } + } + } + assert.deepEqual(writes,[]); + for (const url of ['/api/health', '/api/ad-networks/overview', '/api/mcp-public', '/api/v10', '/self-host-apps?q=private']) { + logRequests({url, method:'GET'}, {statusCode:200}, configExports.default.logging, 1n, 2n); + assert.ok(writes.some(line=>line.includes(url)), 'public request logging missing for '+url); + } + }finally{writer.mock.restore();} +}); diff --git a/tests/data-api/public-api-headers.test.mjs b/tests/data-api/public-api-headers.test.mjs new file mode 100644 index 00000000..9a16da08 --- /dev/null +++ b/tests/data-api/public-api-headers.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync, readdirSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import vm from 'node:vm'; +import ts from 'typescript'; +import * as pathPolicy from '../../lib/data-api/path-policy.mjs'; +import * as privateResponse from '../../lib/data-api/private-response.mjs'; +import * as queryIndexing from '../../lib/seo/query-indexing.mjs'; + +const require = createRequire(import.meta.url); +const {NextRequest} = require('next/server'); +const exports = {}; +const forbidden = () => assert.fail('public API attempted a publication lookup'); +const modules = { + './lib/data-api/path-policy.mjs': pathPolicy, + './lib/data-api/private-response.mjs': privateResponse, + '@/lib/page-registry': {getExactPublishedPage: forbidden, slugFromPathname: forbidden}, + '@/lib/tool-catalogs': {getPublishedToolCatalog: forbidden}, + '@/lib/tool-pages': {getPublishedToolPage: forbidden}, + '@/lib/directory-pages': {getPublishedDirectoryPage: forbidden}, + '@/lib/page-source': {sitePageAvailable: forbidden}, + '@/lib/seo': {absoluteUrl: path => new URL(path, 'https://stack127.com').href}, + '@/lib/seo/query-indexing.mjs': queryIndexing, +}; +vm.runInNewContext(ts.transpileModule(readFileSync(new URL('../../proxy.ts', import.meta.url), 'utf8'), { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS}, +}).outputText, {exports, Headers, URLSearchParams, process, require: name => modules[name] ?? require(name)}); + +function apiRoutes(directory, prefix = '/api') { + return readdirSync(directory, {withFileTypes: true}).flatMap(entry => { + if (!entry.isDirectory() || ['mcp', 'v1', '[...path]'].includes(entry.name)) return []; + const nested = new URL(entry.name + '/', directory); + const path = prefix + '/' + entry.name.replace('[slug]', 'example-app'); + return [ + ...(readdirSync(nested).includes('route.ts') ? [path] : []), + ...apiRoutes(nested, path), + ]; + }); +} + +// Header expectations taken from origin/main:proxy.ts before this private API +// work. Keep a small independent baseline, not a copy of the proxy file. +function mainHeaders(path, query) { + const tags = ['stack127-public', 'page:' + path.slice(1).replaceAll('/', ':')]; + if (['/api/self-host-apps/search', '/api/resource-planner-index'].includes(path)) tags.push('self-host-catalog'); + if ((path.startsWith('/api/self-host-apps/') && path !== '/api/self-host-apps/search') || path.startsWith('/api/resource-planner-app/')) tags.push('self-host-detail'); + if (path === '/api/cli-apps/index') tags.push('cli-apps-catalog'); + if (path.startsWith('/api/ad-networks/')) tags.push('ad-networks-catalog'); + const filters = new URLSearchParams(query); + filters.delete('_rsc'); filters.delete('page'); + const headers = new Headers({ + 'Cache-Tag': tags.join(','), + 'X-Stack127-Cache-Class': filters.size ? 'filters' : 'content', + 'x-middleware-next': '1', + 'x-middleware-override-headers': 'x-stack127-pathname', + 'x-middleware-request-x-stack127-pathname': path, + }); + if (query) { + headers.set('Link', `; rel="canonical"`); + if (queryIndexing.queryNeedsNoindex(query)) headers.set('X-Robots-Tag', 'noindex, follow'); + } + return [...headers]; +} + +test('every public API keeps all origin/main proxy headers, with and without queries', async t => { + const paths = [...apiRoutes(new URL('../../app/api/', import.meta.url)), '/api/self-host-apps/apps/example-app']; + assert.ok(paths.length >= 24); + t.diagnostic(`${paths.length} public API path shapes; ${paths.length * 6 * 7} full-header comparisons`); + for (const path of paths) { + for (const query of ['', '?q=word&page=2', '?page=2', '?_rsc=internal', '?utm_source=email', '?utm_source=email&q=word']) { + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']) { + const response = await exports.proxy(new NextRequest('https://stack127.com' + path + query, {method})); + assert.equal(response.status, 200, method + ' ' + path + query); + assert.deepEqual([...response.headers], mainHeaders(path, query), method + ' ' + path + query); + } + } + } +}); diff --git a/tests/stack127/analytics.test.mjs b/tests/stack127/analytics.test.mjs index 37aad278..f2ec5e41 100644 --- a/tests/stack127/analytics.test.mjs +++ b/tests/stack127/analytics.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import vm from "node:vm"; +import * as pathPolicy from "../../lib/data-api/path-policy.mjs"; +import * as privateResponse from "../../lib/data-api/private-response.mjs"; import ts from "typescript"; import * as queryIndexing from "../../lib/seo/query-indexing.mjs"; @@ -15,6 +17,8 @@ function publicationProxy({ published = false } = {}) { vm.runInNewContext(code, { exports, Headers, URLSearchParams, process: { env: { NODE_ENV: "production" } }, require(name) { + if (name === "./lib/data-api/path-policy.mjs") return pathPolicy; + if (name === "./lib/data-api/private-response.mjs") return privateResponse; if (name === "next/server") return { NextResponse: { next: () => ({ status: 200, headers: new Headers() }), @@ -109,3 +113,15 @@ test("directory parent and child documents share the inherited-icon purge tag", assert.equal(unrelated.headers.get("Cache-Tag").split(",").includes(tag), false); } }); + + +test("private and unknown API queries have no page canonical or pathname metadata", async () => { + const { proxy, lookups } = publicationProxy(); + for (const path of ["/api/mcp/private?q=word", "/api/v1/missing?q=word", "/api/unknown?q=word"]) { + const response = await proxy(request(path)); + for (const header of ["Cache-Tag", "Link", "X-Robots-Tag", "x-stack127-pathname", "X-Stack127-Cache-Class"]) { + assert.equal(response.headers.get(header), null, path + " " + header); + } + } + assert.deepEqual(lookups, []); +});