For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (
- [ ]) syntax.
Goal: Expose the enrichment engine as a product API: enrich a domain into the caller's per-org library (charging one lookup, hard-capped per plan with an upgrade error, never overage-billed), and list/export the library as JSON or CSV for outreach.
Architecture: New /v1/companies routes follow the existing /v1 pattern (resolveBearer auth, ApiError envelope, service-role Supabase). Lookup quota + charging live in plan-gate.ts against the company_lookups ledger. A pure normalizeDomain + billingPeriod are unit-tested; the routes are thin.
Tech Stack: Next.js route handlers (nodejs runtime), @/lib/api/auth, @/lib/api/errors, @/lib/api/plan-gate, the enrichment service, Supabase service-role client, Vitest.
Context the engineer needs
- Branch: continue on
feat/enrichment-engine. - Auth pattern (
apps/web/lib/api/auth.ts):resolveBearer({ bearer, ip }) -> { org_id, api_key_id, scopes }; helpersbearerFromHeaders(headers),clientIp(headers). ThrowsApiError. - Error envelope (
apps/web/lib/api/errors.ts):ApiError(code, message, { hint?, field?, retry_after_seconds? })andapiError(code, message, extras). Codes have fixed HTTP statuses in aSTATUSmap. Route shape:try { ... } catch (err) { if (err instanceof ApiError) return err.toResponse(); console.error(...); return apiError("internal", "Unexpected error."); }. - Plan gate (
apps/web/lib/api/plan-gate.ts):PlanKey = "free" | "pro" | "agency" | "enterprise"(this is authoritative; theOrgPlanSchemain@agent-metrics/typesis a stale outlier and is NOT used for gating).PLAN_FEATURES: Record<PlanKey, PlanFeatures>,getOrgPlan(orgId),getPlanFeatures(plan). Agency pools across the tree viaorganizations.parent_org_id. - Enrichment service (
apps/web/lib/enrichment/service.ts):enrichCompany(domain, { forceRefresh }) -> { record, fromCache }; writes/reads the globalcompany_researchcache.recordis anEnrichedCompany. - Tables:
org_companies (org_id, domain, research_id, external_id, tags, added_at, last_refreshed_at, unique(org_id,domain)),company_research (id, domain unique, company_data jsonb, ...),company_lookups (org_id, domain, kind 'new'|'refresh', billing_period 'YYYY-MM', created_at). - Lookup economics (Ralf): charge one lookup when a domain is NEW to the org's library or a user-requested refresh; viewing/listing/exporting existing entries is free. Hard monthly cap per plan, NO overage: at the cap, enrich/refresh returns an upgrade error. Caps: Free 10 (lifetime, one-time), Pro 100/mo, Agency 500/mo pooled across the tree, Enterprise unlimited.
- Domain runtime: routes that read the Authorization header are auto-dynamic; set
export const runtime = "nodejs";(argon2 + service role). Never useforce-dynamic.
File structure
- Create:
packages/core/src/enrichment/domain.ts(+ test) — purenormalizeDomain. - Modify:
packages/core/src/index.ts— export it. - Modify:
apps/web/lib/api/errors.ts— addlookup_quota_exceeded(402). - Modify:
apps/web/lib/api/plan-gate.ts—monthly_lookups,billingPeriod,countLookups,requireLookupQuota,chargeLookup. - Create:
apps/web/lib/api/plan-gate.test.ts— pure tests forbillingPeriod+ cap values. - Create:
apps/web/app/api/v1/companies/route.ts— POST (enrich) + GET (list, json/csv).
Task 1: Pure helpers + lookup quota/charging plumbing
Files: Create packages/core/src/enrichment/domain.ts (+ test); modify packages/core/src/index.ts, apps/web/lib/api/errors.ts, apps/web/lib/api/plan-gate.ts; create apps/web/lib/api/plan-gate.test.ts.
- Step 1: Write the failing test
packages/core/src/enrichment/domain.test.ts:
import { describe, expect, it } from "vitest";
import { normalizeDomain } from "./domain";
describe("normalizeDomain", () => {
it("strips scheme, www, path, port and lowercases", () => {
expect(normalizeDomain("https://www.Acme.NL/about?x=1")).toBe("acme.nl");
expect(normalizeDomain("HTTP://Mollie.com:443/")).toBe("mollie.com");
expect(normalizeDomain(" catawiki.com ")).toBe("catawiki.com");
});
it("accepts a bare domain", () => {
expect(normalizeDomain("picnic.app")).toBe("picnic.app");
});
it("returns null for junk", () => {
expect(normalizeDomain("not a domain")).toBeNull();
expect(normalizeDomain("")).toBeNull();
});
});-
Step 2: Run, verify FAIL.
pnpm exec vitest run packages/core/src/enrichment/domain.test.ts -
Step 3: Implement
packages/core/src/enrichment/domain.ts:
// Normalises a user-supplied domain to a bare lowercase host. Returns null if
// it does not look like a domain. Pure.
export function normalizeDomain(raw: string): string | null {
let s = raw.trim().toLowerCase();
if (!s) return null;
s = s.replace(/^[a-z]+:\/\//, ""); // scheme
s = s.replace(/^www\./, "");
s = s.split("/")[0] ?? s; // path
s = s.split("?")[0] ?? s; // query
s = s.split(":")[0] ?? s; // port
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(s)) {
return null;
}
return s;
}- Step 4: Run, verify PASS. Then add to
packages/core/src/index.ts:
export { normalizeDomain } from "./enrichment/domain";-
Step 5: Add the error code. In
apps/web/lib/api/errors.ts, add| "lookup_quota_exceeded"to theApiErrorCodeunion, andlookup_quota_exceeded: 402,to theSTATUSmap. -
Step 6: Add lookup plumbing to
apps/web/lib/api/plan-gate.ts. (a) Addmonthly_lookups: number | null;to thePlanFeaturestype. (b) Add it to every entry inPLAN_FEATURES:free10,pro100,agency500,enterprisenull. (c) Append these exports at the end of the file:
// 'YYYY-MM' billing period for a date (UTC). Pure.
export function billingPeriod(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
// The org ids whose lookups count against this org's quota. Agency pools the
// whole tree (anchor + children); everyone else is just themselves.
async function lookupScopeOrgIds(orgId: string, plan: PlanKey): Promise<string[]> {
if (plan !== "agency") return [orgId];
const admin = createSupabaseAdminClient();
const { data: me } = await admin
.from("organizations")
.select("parent_org_id")
.eq("id", orgId)
.maybeSingle();
const anchor = (me as { parent_org_id: string | null } | null)?.parent_org_id ?? orgId;
const { data: kids } = await admin.from("organizations").select("id").eq("parent_org_id", anchor);
const ids = new Set<string>([anchor, orgId]);
for (const k of (kids ?? []) as { id: string }[]) ids.add(k.id);
return [...ids];
}
// Lookups used: Free counts lifetime (its cap is one-time); paid plans count
// the current billing period.
async function countLookups(orgId: string, plan: PlanKey): Promise<number> {
const admin = createSupabaseAdminClient();
const ids = await lookupScopeOrgIds(orgId, plan);
let q = admin
.from("company_lookups")
.select("id", { count: "exact", head: true })
.in("org_id", ids);
if (plan !== "free") q = q.eq("billing_period", billingPeriod(new Date()));
const { count } = await q;
return count ?? 0;
}
// Throws lookup_quota_exceeded when the org is at its hard cap. No overage.
export async function requireLookupQuota(orgId: string): Promise<void> {
const plan = await getOrgPlan(orgId);
const cap = PLAN_FEATURES[plan].monthly_lookups;
if (cap === null) return;
const used = await countLookups(orgId, plan);
if (used >= cap) {
throw new ApiError(
"lookup_quota_exceeded",
`You have reached your plan's research lookup limit (${cap}).`,
{ hint: "Upgrade your plan to raise the limit." },
);
}
}
// Records one consumed lookup in the ledger.
export async function chargeLookup(
orgId: string,
domain: string,
kind: "new" | "refresh",
): Promise<void> {
const admin = createSupabaseAdminClient();
await admin
.from("company_lookups")
.insert({ org_id: orgId, domain, kind, billing_period: billingPeriod(new Date()) });
}- Step 7: Write
apps/web/lib/api/plan-gate.test.ts(pure parts only):
import { describe, expect, it } from "vitest";
import { PLAN_FEATURES, billingPeriod } from "./plan-gate";
describe("billingPeriod", () => {
it("formats YYYY-MM in UTC", () => {
expect(billingPeriod(new Date("2026-05-31T23:30:00Z"))).toBe("2026-05");
expect(billingPeriod(new Date("2026-01-01T00:00:00Z"))).toBe("2026-01");
});
});
describe("lookup caps", () => {
it("matches the agreed plan limits", () => {
expect(PLAN_FEATURES.free.monthly_lookups).toBe(10);
expect(PLAN_FEATURES.pro.monthly_lookups).toBe(100);
expect(PLAN_FEATURES.agency.monthly_lookups).toBe(500);
expect(PLAN_FEATURES.enterprise.monthly_lookups).toBeNull();
});
});Note: plan-gate.ts imports server-only, which vitest stubs, so this test imports fine.
-
Step 8: Run unit tests + typecheck.
pnpm exec vitest run packages/core/src/enrichment/domain.test.ts apps/web/lib/api/plan-gate.test.ts(green), thenpnpm --filter @agent-metrics/web exec tsc --noEmitandpnpm --filter @agent-metrics/core exec tsc --noEmit. -
Step 9: Commit.
git add packages/core/src/enrichment/domain.ts packages/core/src/enrichment/domain.test.ts packages/core/src/index.ts apps/web/lib/api/errors.ts apps/web/lib/api/plan-gate.ts apps/web/lib/api/plan-gate.test.ts
git commit -m "feat(api): lookup quota + charging plumbing + domain normaliser"
Body line: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>.
Task 2: POST + GET /v1/companies
Files: Create apps/web/app/api/v1/companies/route.ts.
- Step 1: Implement
apps/web/app/api/v1/companies/route.ts:
import { NextResponse, type NextRequest } from "next/server";
import { normalizeDomain } from "@agent-metrics/core";
import { type EnrichedCompany } from "@agent-metrics/types";
import { ApiError, apiError } from "@/lib/api/errors";
import { bearerFromHeaders, clientIp, resolveBearer } from "@/lib/api/auth";
import { chargeLookup, requireLookupQuota } from "@/lib/api/plan-gate";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { enrichCompany } from "@/lib/enrichment/service";
export const runtime = "nodejs";
// POST /v1/companies { domain, external_id?, tags? } ?refresh=true
// Enriches a domain into the caller's library. Charges one lookup when the
// company is new to the library or refresh=true; existing entries are free.
export async function POST(request: NextRequest) {
try {
const cached = await resolveBearer({
bearer: bearerFromHeaders(request.headers),
ip: clientIp(request.headers),
});
const body = (await request.json().catch(() => ({}))) as {
domain?: unknown;
external_id?: unknown;
tags?: unknown;
};
const domain = normalizeDomain(typeof body.domain === "string" ? body.domain : "");
if (!domain) {
throw new ApiError("validation_error", "A valid domain is required.", { field: "domain" });
}
const refresh = request.nextUrl.searchParams.get("refresh") === "true";
const externalId = typeof body.external_id === "string" ? body.external_id : null;
const tags = Array.isArray(body.tags)
? body.tags.filter((t): t is string => typeof t === "string")
: [];
const admin = createSupabaseAdminClient();
const { data: existing } = await admin
.from("org_companies")
.select("research_id")
.eq("org_id", cached.org_id)
.eq("domain", domain)
.maybeSingle();
// Free path: already owned and not a refresh -> return the stored research.
if (existing && !refresh) {
const { data: research } = await admin
.from("company_research")
.select("company_data")
.eq("id", (existing as { research_id: string }).research_id)
.maybeSingle();
return NextResponse.json({
company: (research as { company_data: EnrichedCompany } | null)?.company_data ?? null,
charged: false,
});
}
// Paid path: enforce the hard cap, then enrich + record ownership + charge.
await requireLookupQuota(cached.org_id);
const { record } = await enrichCompany(domain, { forceRefresh: refresh });
const { data: research } = await admin
.from("company_research")
.select("id")
.eq("domain", domain)
.maybeSingle();
await admin.from("org_companies").upsert(
{
org_id: cached.org_id,
domain,
research_id: (research as { id: string } | null)?.id ?? null,
external_id: externalId,
tags,
last_refreshed_at: new Date().toISOString(),
},
{ onConflict: "org_id,domain" },
);
await chargeLookup(cached.org_id, domain, existing ? "refresh" : "new");
return NextResponse.json({ company: record, charged: true }, { status: existing ? 200 : 201 });
} catch (err) {
if (err instanceof ApiError) return err.toResponse();
// eslint-disable-next-line no-console
console.error("[/v1/companies POST] internal", err);
return apiError("internal", "Unexpected error.");
}
}
// GET /v1/companies?format=json|csv&tag=...&limit=...
// Lists the caller's library. Free. The outreach pull.
export async function GET(request: NextRequest) {
try {
const cached = await resolveBearer({
bearer: bearerFromHeaders(request.headers),
ip: clientIp(request.headers),
});
const sp = request.nextUrl.searchParams;
const format = sp.get("format") === "csv" ? "csv" : "json";
const tag = sp.get("tag");
const limit = Math.min(Number(sp.get("limit") ?? "500") || 500, 1000);
const admin = createSupabaseAdminClient();
let q = admin
.from("org_companies")
.select(
"domain, external_id, tags, added_at, last_refreshed_at, company_research(company_data)",
)
.eq("org_id", cached.org_id)
.order("added_at", { ascending: false })
.limit(limit);
if (tag) q = q.contains("tags", [tag]);
const { data, error } = await q;
if (error) throw new ApiError("internal", "Could not list companies.");
const rows = (
(data ?? []) as Array<{
domain: string;
external_id: string | null;
tags: string[];
added_at: string;
last_refreshed_at: string;
company_research: { company_data: EnrichedCompany } | null;
}>
).map((r) => {
const c = r.company_research?.company_data;
return {
domain: r.domain,
external_id: r.external_id,
tags: r.tags,
company_name: c?.company_name ?? null,
country: c?.country ?? null,
industry: c?.industry ?? null,
headcount_estimate: c?.headcount_estimate ?? null,
annual_labour_cost_eur: c?.business_case?.annual_labour_cost_eur ?? null,
potential_saving_eur: c?.business_case?.potential_saving_eur ?? null,
confidence: c?.confidence?.overall ?? null,
added_at: r.added_at,
last_refreshed_at: r.last_refreshed_at,
};
});
if (format === "csv") {
const cols = [
"domain",
"external_id",
"company_name",
"country",
"industry",
"headcount_estimate",
"annual_labour_cost_eur",
"potential_saving_eur",
"confidence",
"tags",
"added_at",
"last_refreshed_at",
];
const esc = (v: unknown) => {
const s = v === null || v === undefined ? "" : Array.isArray(v) ? v.join("|") : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [cols.join(",")];
for (const r of rows)
lines.push(cols.map((c) => esc((r as Record<string, unknown>)[c])).join(","));
return new NextResponse(lines.join("\n"), {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="companies.csv"',
},
});
}
return NextResponse.json({ companies: rows });
} catch (err) {
if (err instanceof ApiError) return err.toResponse();
// eslint-disable-next-line no-console
console.error("[/v1/companies GET] internal", err);
return apiError("internal", "Unexpected error.");
}
}-
Step 2: Typecheck.
pnpm --filter @agent-metrics/web exec tsc --noEmit. The PostgREST embedded selectcompany_research(company_data)returns a typed-as-loose object; theascast handles it. Fix only this file if a real type error appears (e.g. the embed comes back as an array, in which case change the cast tocompany_research: { company_data: EnrichedCompany }[] | nulland read[0]). Report which shape you used. -
Step 3: Live smoke (real calls + DB; charges a lookup against an org). This needs a real api key + org. If one is readily available in the repo's test scripts (look in
apps/web/scripts/for an existing key/org helper) use it; otherwise SKIP the live HTTP smoke and report DONE_WITH_CONCERNS noting the route compiles and is covered by the plan's manual curl example below. Do NOT fabricate or seed a key. Manual curl for the human to run later:
curl -s -X POST "$APP_URL/v1/companies" -H "Authorization: Bearer hh_live_..." -H "content-type: application/json" -d '{"domain":"catawiki.com","tags":["nl-saas"]}' | head -c 600
curl -s "$APP_URL/v1/companies?format=csv" -H "Authorization: Bearer hh_live_..." | head
- Step 4: Commit.
git add apps/web/app/api/v1/companies/route.ts
git commit -m "feat(api): POST + GET /v1/companies (enrich into library, list, csv export)"
Body line as above.
Self-review (plan author)
- Spec coverage: lookup charging on new/refresh only; free view/list/export; hard cap with
lookup_quota_exceeded(402) and an upgrade hint, no overage; Free lifetime 10 vs paid monthly; Agency pooled across the tree; CSV + JSON list for the outreach pull;external_id+tagsfor campaign matching. Uses the existing auth + error + plan-gate patterns exactly. - Plan-name caveat resolved: plan-gate's
PlanKey(free/pro/agency/enterprise) is authoritative; no type rename needed here. - No placeholders: all code is concrete; the only conditional is the documented PostgREST embed shape in Task 2 Step 2.
Out of scope / next
GET /v1/companies/{domain},POST /v1/companies/{domain}/refresh, a dedicatedGET /v1/companies/export(Phase 3b).- Bulk submission + worker (Phase 4). Pricing page + tier migration (Phase 5). Frontend library UI (Phase 6).
- Fix the known
service.tsmislabel (stores loaded cost asgross_hourly_eur); align when convenient.