From aa16fe1f1253bcf9a128ec9724720079ac21d5cc Mon Sep 17 00:00:00 2001 From: Ikerlaforga <19539979+Isonimus@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:01:41 +0200 Subject: [PATCH] feat(server): usage metering ledger (Phase 7 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A durable per-organization, per-UTC-calendar-month ledger of identity resolutions — the billable unit — replacing the ephemeral Redis rate-limit windows as the source of truth. Measurement only: Stripe, public signup, and hard enforcement are deferred follow-ups (ADR-0007). - migration 015: organizations.plan + monthly_resolution_limit (NULL = unlimited); usage_counters(organization_id, period_start, resolution_count, warned_80, warned_100). - lib/usage.ts: incrementUsage() — atomic UPSERT inside the resolution transaction in resolveSnapshot, so counting is exactly-once (the event_id dedup short-circuits before the tx; rollback un-counts). /v1/resolve is a non-persisting preview and intentionally does not count. - checkAndWarnThreshold() — soft limits: crossing 80%/100% flips a once-per- period guard and emits logger.warn + Sentry.captureMessage (reusing the shipped error tracking; no-op without SENTRY_DSN). Never blocks traffic. NULL limit = no-op, so self-host is metered but never warned. - GET /admin/usage (org-scoped) + Observatory Usage page (count vs limit, history); set-org-plan CLI for thin/assisted onboarding. - docs: ADR-0007, OpenAPI /admin/usage, ROADMAP, CHANGELOG. - tests: usage.integration (exactly-once, dedup, multi-project rollup, org isolation, threshold guards) + GET /admin/usage coverage. 148 server tests. --- CHANGELOG.md | 1 + ROADMAP.md | 12 +- apps/observatory/src/App.tsx | 2 + apps/observatory/src/components/Layout.tsx | 3 +- apps/observatory/src/lib/api.ts | 18 ++ apps/observatory/src/pages/Usage.tsx | 122 ++++++++++++ docs/adr/0007-usage-metering.md | 88 +++++++++ docs/adr/README.md | 1 + docs/openapi.yaml | 32 +++ packages/server/package.json | 1 + .../src/db/migrations/015_usage_metering.sql | 28 +++ .../server/src/lib/usage.integration.test.ts | 184 ++++++++++++++++++ packages/server/src/lib/usage.ts | 96 +++++++++ packages/server/src/pipeline/resolve.ts | 21 +- .../src/routes/admin.integration.test.ts | 29 +++ packages/server/src/routes/admin.ts | 33 ++++ packages/server/src/scripts/set-org-plan.ts | 50 +++++ 17 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 apps/observatory/src/pages/Usage.tsx create mode 100644 docs/adr/0007-usage-metering.md create mode 100644 packages/server/src/db/migrations/015_usage_metering.sql create mode 100644 packages/server/src/lib/usage.integration.test.ts create mode 100644 packages/server/src/lib/usage.ts create mode 100644 packages/server/src/scripts/set-org-plan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e1f2c..b40e269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ the first consumer-visible behaviour change and will drive the next SDK version - **Automation-detector wording**: the anti-tamper flag now reads "Anti-tamper signals" (not "Automation detected") when the combined confidence is weak — e.g. devtools open in a dev environment — so a human isn't labelled a bot. The machine-readable `code` (`automation_suspected`) is unchanged; the reason text also drops the `tamper.` prefix for readability. ### Internal +- **Usage metering** ([ADR-0007](docs/adr/0007-usage-metering.md), migration 015): a durable per-organization, per-UTC-calendar-month ledger of identity resolutions — the billable unit — replacing the ephemeral Redis rate-limit windows as the source of truth (slice 1 of Phase 7; Stripe + enforcement come later). The billable unit is one **committed** resolution: `incrementUsage` records it via an atomic UPSERT **inside the resolution transaction** in `resolveSnapshot`, so counting is **exactly-once** (the `event_id` dedup short-circuits before the transaction, so retries/duplicates don't count; a rollback un-counts). `POST /v1/resolve` is a non-persisting preview and intentionally doesn't count. **Soft** limits: `organizations` gains `plan` + `monthly_resolution_limit` (NULL = unlimited, so self-host is metered but never warned); crossing 80% / 100% flips a once-per-period guard and emits a `logger.warn` + `Sentry.captureMessage` (reusing the shipped error tracking; no new infra), but **never blocks traffic**. New `GET /admin/usage` (org-scoped) + an Observatory **Usage** page surface current-period count vs limit and recent history; a `set-org-plan` CLI sets a customer's plan/limit (thin/assisted onboarding). The single counter row per org/month is a documented write hotspot at scale (deferred: Redis hot counter / sharding). Deferred follow-ups: Stripe, public self-serve signup, hard enforcement. - **Error tracking (Sentry)** ([ADR-0006](docs/adr/0006-observability-sentry.md)): the hosted server was *instrumented but blind* — OpenTelemetry was wired but ships disabled (`OTEL_SDK_DISABLED=true`) and `pino` logs are ephemeral, so prod errors and outages went unseen. Added `@sentry/node` (v10) error capture + alerting for the server and worker. A new `instrument.ts` runs `Sentry.init` (preloaded via `--import` before app modules) and **no-ops without `SENTRY_DSN`** (`${SCENT_SECRET_KEY:-}`-style "env unset = disabled"), so dev/test/self-host stay inert. Express errors are caught via `setupExpressErrorHandler`; BullMQ job failures via explicit `captureException` in the worker `failed` handlers (+ `flush` on shutdown). Privacy posture for this PII-sensitive product: **EU-region project + strict scrubbing** (`sendDefaultPii: false` plus an exported, unit-tested `scrubPii` `beforeSend` that strips request bodies, cookies, the `x-api-key`/`cookie`/`authorization` headers, query string, and client IP). Errors-only by default (`tracesSampleRate` 0, env-overridable). New env `SENTRY_DSN`/`SENTRY_ENVIRONMENT`/`SENTRY_RELEASE`/`SENTRY_TRACES_SAMPLE_RATE` in the deploy `.env.example`, both compose services, and the runbook (with an external `/health` uptime-monitor note). Distributed traces/metrics/off-box logs to a managed backend remain a deferred phase 2 (the OTel wiring already exists). - **Organizations (multi-tenant) layer** ([ADR-0005](docs/adr/0005-organizations-and-tenancy.md), migrations 013–014): a new `organizations` table is now the tenant boundary above `projects`. The admin `owner` role is **re-scoped from a global superuser to org-scoped** — `canViewProject`/`canManageProject`, the `/admin/*` listing queries, and `requireProjectRead` filter by `organization_id`, and a cross-org project/user id returns `404` (no existence leak). 2FA policy moved from the install-wide `admin_settings.require_2fa` to per-org `organizations.require_2fa`; invites carry the inviter's org so an accepted account joins that company. Migration 013 backfills a single `Default` org for existing installs, so **self-host is unchanged** (one auto-created org); the `NOT NULL` FK backstop lands in migration 014 once every writer is org-aware. `create-admin`/`create-project` take an optional `[orgName]` (shared `findOrCreateOrgByName`). `organization_id` stays off the `/v1` data path (a project key already scopes data) — orgs are an admin/billing concern, the foundational prerequisite for hosted metering/billing. Public self-serve signup is deferred to the billing workstream. - **Docs: GDPR & consent** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md)): new [GDPR & consent integration guide](docs/integrations/gdpr-consent.md) (controller/processor split, CMP wiring per mode, lawful-basis guidance, data-subject rights, DPA stub); OpenAPI updated with the snapshot consent fields, the `LawfulBasis` schema, and the `DELETE`/`export` identity paths. diff --git a/ROADMAP.md b/ROADMAP.md index 788c93f..89accea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -365,7 +365,7 @@ A single demo app running both `@tindalabs/blindspot` and `@tindalabs/scent-sdk` ### Cloud infrastructure - [x] Multi-tenant architecture: all data scoped by project (every table FK'd to `projects` with `ON DELETE CASCADE`; API key → project_id resolved in auth). API keys are hashed at rest (SHA-256). -- [ ] Usage metering: count identity resolutions per billing period +- [x] Usage metering: durable per-org, per-UTC-month resolution ledger ([ADR-0007](docs/adr/0007-usage-metering.md), migration 015) — exactly-once in `resolveSnapshot`, soft limits, `GET /admin/usage` + Observatory Usage page. Stripe billing still pending. - [x] API key management UI (create, rotate, revoke) — Observatory **API Keys** page behind multi-user admin auth (bcrypt, server-side sessions, CSRF, per-IP rate limit); rotate/revoke bust the Redis auth cache immediately - [ ] Billing integration: Stripe (usage-based, metered billing) @@ -479,8 +479,8 @@ Full report: `c-level/reports/scent_2026-05-19.md` - [~] **Simultaneous launch**: GitHub repo is public and npm packages are published (`scent-sdk`/`-engine`/`-otel` @ `0.1.0`, trusted publishing). **Show HN post still pending** — the launch-day momentum play is not yet fired. - [ ] Launch `tindalabs.dev` landing page — `docs/tindalabs-landing-page-spec.md` has all the copy; HN traffic needs somewhere to land - [ ] Write and publish "Why FingerprintJS free tier isn't enough — and what we built instead" blog post on Dev.to and Medium (the migration guide is already written; this is the SEO play) -- [ ] Implement usage metering (`resolution_count` increment per project per billing period) — the revenue meter; unblocks Phase 7 Stripe integration -- [~] Implement Phase 7 SaaS: **API key management UI is done** (admin auth — multi-user accounts, sessions, CSRF — + Observatory create/rotate/revoke); Stripe usage-based billing + billing dashboard still pending (gated on usage metering above) +- [x] Implement usage metering — durable per-**org**, per-month resolution ledger ([ADR-0007](docs/adr/0007-usage-metering.md), migration 015), incremented exactly-once inside `resolveSnapshot`; soft warnings at 80%/100% (logger + Sentry), never blocks. The revenue meter; unblocks Stripe. +- [~] Implement Phase 7 SaaS: **API key management UI + usage metering done**; Stripe usage-based billing + billing dashboard still pending (now unblocked by the metering ledger) - [x] Publish accuracy benchmarks comparing Scent vs. FingerprintJS OSS vs. ThumbmarkJS ✅ — added `bench/` (`@tindalabs/scent-bench`, run via `pnpm bench`): a reproducible, seeded harness that holds signal *collection* constant and varies only the matcher, driving the **real** `@tindalabs/scent-engine`. Models the deterministic libs faithfully (re-identify iff the hashed component set is byte-for-byte identical). Headline ([`bench/RESULTS.md`](bench/RESULTS.md)): **weighted recall FingerprintJS 45% / ThumbmarkJS 55% / Scent 100%** under drift; deterministic IDs collapse to 0% the instant any hashed component changes (browser update, anti-fingerprinting, VPN/timezone, new monitor — Thumbmark survives only the monitor case by dropping screen geometry). Crucially it also reports Scent's **confidence gradient** (mean 1.00→0.80; "confirmed" share drops to 57.9% on browser updates, 15.8% on anti-fingerprinting) and a **false-merge counter-metric** — so it reads as measurement, not a brochure. Methodology + limits documented in `bench/README.md`. - [x] Publish `tindalabs/scent-server` Docker image so self-hosters can `docker pull` — auto-published on every main push to **both GHCR (`ghcr.io/tindalabs/scent-server`) and Docker Hub (`tindalabs/scent-server`)** via `.github/workflows/publish-docker.yml` - [ ] Reach out to 10–15 mid-market SaaS founders/CTOs who post publicly about fraud on Twitter/LinkedIn (primary community seeding) @@ -554,7 +554,7 @@ Full report: [`c-level/reports/scent_2026-06-14.md`](../c-level/reports/scent_20 **CFO — 7/10 (low burn, clean IP; revenue unbuilt, bus-factor-1)** - Low fixed-cost floor, near-zero debt liability, all inbound deps permissive, intentional BSL moat on the server. -- [ ] **Convert per-key Redis counters into a metering ledger + ship a thin paid tier** — cheapest path from cost-center to validated revenue; reuses infra already built. (Same as the long-standing "usage metering" item — now framed as the revenue gate.) +- [~] **Convert per-key Redis counters into a metering ledger + ship a thin paid tier** — metering ledger **SHIPPED** ([ADR-0007](docs/adr/0007-usage-metering.md): durable per-org/month Postgres counters, soft limits, Observatory Usage page). The thin paid tier (Stripe) is the remaining half. - [ ] **De-risk bus factor = 1** — document the engine's weight/threshold *rationale* + bring in a second maintainer. Dominant operational *and* acquisition risk. - [ ] **Verify MaxMind GeoLite2 *data* EULA** (separate from the npm code license) — load at runtime rather than bundling in the redistributed image. @@ -585,7 +585,7 @@ Full report: [`c-level/reports/scent_2026-06-14.md`](../c-level/reports/scent_20 - [ ] Per-admin audit log **Strategic (1–3 months)** -- [ ] Hosted free tier + metering ledger (reuse per-key Redis counters) — the revenue path +- [~] Hosted free tier + metering ledger — **metering ledger shipped** ([ADR-0007](docs/adr/0007-usage-metering.md)); free-tier enforcement + billing still pending - [ ] GDPR data-lifecycle layer (consent, retention TTL, IP minimization, export/delete) — converts the #1 liability into the market wedge - [ ] De-risk bus factor: document engine/threshold rationale + add a second maintainer - [ ] Postgres partitioning + read replica before scale @@ -687,7 +687,7 @@ isolation; metering/billing will anchor on it: - [x] Per-org 2FA policy (`organizations.require_2fa`) supersedes the install-wide toggle; invites carry the inviter's org so an accepted account joins that company. - [x] Org-aware provisioning via the bootstrap CLIs (`create-admin`/`create-project` take an optional `[orgName]`, shared `findOrCreateOrgByName`). - [ ] **Deferred to the billing workstream:** public self-serve signup (`POST /admin/signup`) — coupled to free-tier limits + Stripe. -- [ ] **Deferred:** anchor usage metering + Stripe (Phase 7) on `organizations`; Observatory org-management UI (org name/settings, switcher). +- [~] anchor usage metering + Stripe (Phase 7) on `organizations`: **usage metering shipped** ([ADR-0007](docs/adr/0007-usage-metering.md)); Stripe + the Observatory org-management UI (org name/settings, switcher) still deferred. **Why it composed:** mirrors how migration 009 backfilled `role` for existing admins — self-host stays a single auto-created org, so no behaviour change there; the hosted tier diff --git a/apps/observatory/src/App.tsx b/apps/observatory/src/App.tsx index 1c5319b..afe2c55 100644 --- a/apps/observatory/src/App.tsx +++ b/apps/observatory/src/App.tsx @@ -14,6 +14,7 @@ import { DriftTimeline } from './pages/DriftTimeline.js'; import { ClusterDetail } from './pages/ClusterDetail.js'; import { AccountClusters } from './pages/AccountClusters.js'; import { Settings } from './pages/Settings.js'; +import { Usage } from './pages/Usage.js'; export function App(): React.ReactElement { return ( @@ -25,6 +26,7 @@ export function App(): React.ReactElement { {/* Admin/account pages don't need a selected project, so they sit outside the project gate. */} } /> + } /> } /> }> } /> diff --git a/apps/observatory/src/components/Layout.tsx b/apps/observatory/src/components/Layout.tsx index 57e6807..d9c5d37 100644 --- a/apps/observatory/src/components/Layout.tsx +++ b/apps/observatory/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet, Navigate, useNavigate, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, GitBranch, Network, KeyRound, UserCog, CircleUser, LogOut } from 'lucide-react'; +import { LayoutDashboard, Users, GitBranch, Network, KeyRound, UserCog, CircleUser, Gauge, LogOut } from 'lucide-react'; import { cn } from '../lib/utils.js'; import { useAuth } from '../contexts/AuthContext.js'; import { useProjects } from '../contexts/ProjectContext.js'; @@ -9,6 +9,7 @@ const baseNav = [ { to: '/identities', label: 'Identities', icon: Users, end: false }, { to: '/accounts', label: 'Account clusters', icon: Network, end: false }, { to: '/settings', label: 'API keys', icon: KeyRound, end: false }, + { to: '/usage', label: 'Usage', icon: Gauge, end: false }, ]; export function Layout(): React.ReactElement { diff --git a/apps/observatory/src/lib/api.ts b/apps/observatory/src/lib/api.ts index ecebd82..8a9e68d 100644 --- a/apps/observatory/src/lib/api.ts +++ b/apps/observatory/src/lib/api.ts @@ -223,6 +223,24 @@ export function setRequireTwoFactor(require_2fa: boolean): Promise<{ require_2fa return adminFetch('/admin/settings', { method: 'PUT', body: JSON.stringify({ require_2fa }) }); } +// --- Usage metering (org-scoped; any admin can read their own org's usage) ---------- + +export interface UsageData { + plan: string; + // null = unlimited (self-host / un-provisioned). + limit: number | null; + // First day of the current UTC month (YYYY-MM-DD). + periodStart: string; + resolutionsThisPeriod: number; + // resolutionsThisPeriod / limit, or null when unlimited. + pctUsed: number | null; + history: { periodStart: string; resolutions: number }[]; +} + +export function getUsage(): Promise { + return adminFetch('/admin/usage'); +} + // Types mirroring server responses export interface Identity { diff --git a/apps/observatory/src/pages/Usage.tsx b/apps/observatory/src/pages/Usage.tsx new file mode 100644 index 0000000..fa6c14d --- /dev/null +++ b/apps/observatory/src/pages/Usage.tsx @@ -0,0 +1,122 @@ +import { useQuery } from '@tanstack/react-query'; +import { Gauge } from 'lucide-react'; +import { getUsage } from '../lib/api.js'; +import { Skeleton } from '../components/ui/skeleton.js'; + +// "2026-06-01" -> "Jun 2026" (rendered in UTC to match the server's period boundary). +function formatMonth(periodStart: string): string { + const [y, m] = periodStart.split('-').map(Number); + return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, 1)).toLocaleString(undefined, { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); +} + +const fmt = (n: number): string => n.toLocaleString(); + +export function Usage(): React.ReactElement { + const { data, isLoading } = useQuery({ queryKey: ['admin-usage'], queryFn: getUsage }); + + const limit = data?.limit ?? null; + const used = data?.resolutionsThisPeriod ?? 0; + const pct = data?.pctUsed != null ? data.pctUsed : null; + const pctClamped = pct != null ? Math.min(100, Math.round(pct * 100)) : null; + const barColor = pct == null ? 'bg-emerald-500' : pct >= 1 ? 'bg-red-500' : pct >= 0.8 ? 'bg-amber-500' : 'bg-emerald-500'; + + return ( +
+
+

Usage

+

+ Identity resolutions for the current billing period (UTC calendar month). +

+
+ + {isLoading ? ( + + ) : ( +
+
+
+

This period

+

+ {fmt(used)} + + {limit != null ? `/ ${fmt(limit)} resolutions` : 'resolutions'} + +

+
+ + {data?.plan ?? 'free'} plan + +
+ + {limit != null ? ( +
+
+
+
+

+ {pctClamped}% used + {pct != null && pct >= 1 && ( + — over the soft limit (still serving) + )} + {pct != null && pct >= 0.8 && pct < 1 && ( + — approaching the limit + )} +

+
+ ) : ( +

+ Unlimited — no monthly quota set for this organization. +

+ )} +
+ )} + +
+

Recent months

+
+ + + + + + + + + {isLoading && ( + + + + )} + {!isLoading && (data?.history.length ?? 0) === 0 && ( + + + + )} + {data?.history.map((h) => ( + + + + + ))} + +
Month + Resolutions +
+ +
+ No usage recorded yet. +
{formatMonth(h.periodStart)}{fmt(h.resolutions)}
+
+
+ +

+ One resolution = one processed identity observation. Limits are soft — exceeding + them never blocks traffic. +

+
+ ); +} diff --git a/docs/adr/0007-usage-metering.md b/docs/adr/0007-usage-metering.md new file mode 100644 index 0000000..4a8e12c --- /dev/null +++ b/docs/adr/0007-usage-metering.md @@ -0,0 +1,88 @@ +# ADR-0007: Usage metering — a durable per-organization resolution ledger (soft limits) + +**Status:** Accepted +**Date:** 2026-06-21 + +## Context + +Scent now has a tenant boundary ([ADR-0005](0005-organizations-and-tenancy.md)) and prod +error visibility ([ADR-0006](0006-observability-sentry.md)), but **no way to measure usage** — +the billable unit. The Phase 7 pricing (Free 10k / Pro $249·250k / Enterprise) can't be +applied, and Stripe can't be wired, without a durable per-customer count of work done. The +only existing counters are the ephemeral 60s Redis rate-limit windows (`incrFixedWindow`) — +not a billing source of truth. + +This is **slice 1** of billing: the measurement foundation only. Stripe, public self-serve +signup, and hard enforcement are explicit, deferred follow-ups. + +## Decision + +Add a durable **per-organization, per-UTC-calendar-month** ledger of identity resolutions, +with **soft** limits (measure + warn, never block). + +### Billable unit & counting point + +One billable resolution = **one committed snapshot**. Resolutions are metered **inside the +existing resolution transaction** in [`resolveSnapshot`](../../packages/server/src/pipeline/resolve.ts) +(`incrementUsage`, an atomic UPSERT into `usage_counters` right after the snapshot insert). +This yields **exactly-once** counting with no extra coordination: + +- The `event_id` dedup short-circuits **before** the transaction, so BullMQ at-least-once + retries and duplicate submissions never reach the increment. +- The increment shares the snapshot's commit/rollback — no count without a stored + observation, and none double-counted. + +Only the **committing** path is metered: `POST /v1/events` (async → worker → `resolveSnapshot`). +`POST /v1/resolve` is a **non-persisting preview** (confidence/risk for a login-flow check +without writing history) and intentionally does **not** count — you bill for committed +observations, not previews. Metering therefore lives in `resolveSnapshot` (the single commit +path), not at the HTTP boundary. + +### Schema (migration 015) + +- `organizations` gains `plan` (`text`, default `'free'` — forward-looking label for Stripe + + display) and `monthly_resolution_limit` (`integer`, **NULL = unlimited**). +- `usage_counters(organization_id, period_start, resolution_count, warned_80, warned_100, + updated_at)`, PK `(organization_id, period_start)`. `period_start` is the first day of the + UTC calendar month. + +### Soft limits & alerting + +`checkAndWarnThreshold` runs **after** the transaction commits (side effects out of the +transaction; metering must never delay or fail a resolution). With `monthly_resolution_limit` +NULL it is a no-op (so self-host never warns). On first crossing of 80% / 100% it flips a +once-per-period guard (`warned_80` / `warned_100`) and emits one `logger.warn` + +`Sentry.captureMessage(..., 'warning')` — reusing the shipped error tracking (no-op without +`SENTRY_DSN`), so no new alerting infrastructure. Exceeding the limit **never blocks traffic**. + +### Provisioning & surfacing + +- Operator CLI `set-org-plan [limit|unlimited]` sets a customer's plan/limit + (the thin/assisted onboarding path until an org-management UI exists). +- `GET /admin/usage` (org-scoped via the admin session) returns the current period's count, + limit, %, and recent history; the Observatory **Usage** page renders it. + +## Why it composes (self-host unaffected) + +`monthly_resolution_limit` defaults NULL, so the single auto-created self-host org is metered +but never warned — zero behaviour change. The counter is purely additive; existing resolution +behaviour and results are untouched (metering is fire-and-forget after commit). + +## Consequences + +- Usage is finally **visible** (per org, per month) — the prerequisite for billing and for + validating the pricing tiers against real traffic before enforcing them. +- The single counter row per org/month is a potential write hotspot under high concurrency + (an org's resolutions contend on one row). Fine at current scale (single box, one design + partner); documented mitigation if needed: a Redis hot counter flushed periodically, or + sharded counters. + +## Deferred (NOT built here) + +Stripe (customer/subscription/checkout/portal/webhooks + usage reporting, anchored on +`organizations.plan`); public self-serve signup (`POST /admin/signup`) + abuse guardrails; +**hard** quota enforcement (402/429); per-project usage breakdown; the Redis hot-counter +optimization; an Observatory org-management UI. + +Relates to [ADR-0005](0005-organizations-and-tenancy.md) (orgs are the billing anchor) and the +BSL "Tindalabs-hosted only" commercial model. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7dd786b..7d05b33 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,3 +10,4 @@ Each ADR documents a significant architectural choice: the context, the decision | [0004](0004-consent-and-data-lifecycle.md) | Consent is the controller's responsibility; the SDK enforces, never triggers | Accepted | | [0005](0005-organizations-and-tenancy.md) | Organizations are the tenant boundary; owner is org-scoped, not global | Accepted | | [0006](0006-observability-sentry.md) | Sentry-led error tracking now; OTel traces/logs to a backend deferred | Accepted | +| [0007](0007-usage-metering.md) | Usage metering — a durable per-organization resolution ledger (soft limits) | Accepted | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c09225e..5527aba 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -448,6 +448,38 @@ paths: schema: { $ref: "#/components/schemas/AdminIdentity" } "401": { $ref: "#/components/responses/Unauthorized" } + /admin/usage: + get: + tags: [Admin] + summary: Usage metering for the caller's organization + description: >- + Current billing-period (UTC calendar month) identity-resolution count for the + signed-in admin's organization, the soft monthly limit, and recent history. + `limit` is null for unlimited (self-host / un-provisioned). Limits are soft — + exceeding them warns but never blocks. + security: [{ AdminSession: [] }] + responses: + "200": + description: Usage for the caller's organization + content: + application/json: + schema: + type: object + properties: + plan: { type: string, example: free } + limit: { type: integer, nullable: true, example: 10000 } + periodStart: { type: string, format: date, example: "2026-06-01" } + resolutionsThisPeriod: { type: integer, example: 3421 } + pctUsed: { type: number, nullable: true, example: 0.3421 } + history: + type: array + items: + type: object + properties: + periodStart: { type: string, format: date } + resolutions: { type: integer } + "401": { $ref: "#/components/responses/Unauthorized" } + /admin/projects: get: tags: [Admin] diff --git a/packages/server/package.json b/packages/server/package.json index bd6b0f2..ce3131f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,6 +10,7 @@ "migrate": "tsx src/db/migrate-cli.ts", "create-project": "tsx src/scripts/create-project.ts", "create-admin": "tsx src/scripts/create-admin.ts", + "set-org-plan": "tsx src/scripts/set-org-plan.ts", "worker": "tsx src/worker.ts", "worker:start": "node --import ./dist/instrument.js --import ./dist/tracing.js dist/worker.js", "test": "vitest run", diff --git a/packages/server/src/db/migrations/015_usage_metering.sql b/packages/server/src/db/migrations/015_usage_metering.sql new file mode 100644 index 0000000..a082ca8 --- /dev/null +++ b/packages/server/src/db/migrations/015_usage_metering.sql @@ -0,0 +1,28 @@ +-- Usage metering: a durable per-organization, per-calendar-month ledger of identity +-- resolutions — the billable unit. Until now the only counters were the ephemeral 60s +-- Redis rate-limit windows, which can't anchor billing. This is slice 1 of Phase 7 +-- (metering only; Stripe and enforcement come later): measure usage, surface it, and +-- warn softly at thresholds — never block. +-- +-- Self-host is unaffected: organizations.monthly_resolution_limit defaults NULL +-- (unlimited), so the single auto-created org is metered but never warned. A hosted +-- customer's limit is set explicitly by the operator (set-org-plan CLI). + +-- Plan label (forward-looking, for the upcoming Stripe work + display) and the soft +-- monthly cap. NULL limit = unlimited / un-provisioned: counted but no warnings. +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free', + ADD COLUMN IF NOT EXISTS monthly_resolution_limit INTEGER; + +-- One row per org per UTC calendar month. resolution_count is incremented inside the +-- resolution transaction (exactly-once via the upstream event_id dedup). warned_80 / +-- warned_100 are once-per-threshold-per-period guards so a soft-limit alert fires once. +CREATE TABLE IF NOT EXISTS usage_counters ( + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + period_start DATE NOT NULL, -- first day of the UTC month + resolution_count BIGINT NOT NULL DEFAULT 0, + warned_80 BOOLEAN NOT NULL DEFAULT false, + warned_100 BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, period_start) +); diff --git a/packages/server/src/lib/usage.integration.test.ts b/packages/server/src/lib/usage.integration.test.ts new file mode 100644 index 0000000..b53ccde --- /dev/null +++ b/packages/server/src/lib/usage.integration.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { migrate } from '../db/migrate.js'; +import { db } from '../db/client.js'; +import { redis } from '../db/redis.js'; +import { resolveSnapshot } from '../pipeline/resolve.js'; +import { hashApiKey } from '../middleware/api-key.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; +import { checkAndWarnThreshold } from './usage.js'; + +// Usage metering: the billable unit is one committed resolution. Verifies the counter +// increments exactly once per resolution (and not on dedup), rolls up per organization, +// stays org-isolated, and that the soft-threshold guards flip once. Gated on DATABASE_URL +// like the other integration suites (CI provides it; skips locally without a DB). +const hasDb = Boolean(process.env['DATABASE_URL']); + +const ORG_A = 'Usage IT Org A'; +const ORG_B = 'Usage IT Org B'; +const KEY_A1 = 'usage-it-key-a1'; +const KEY_A2 = 'usage-it-key-a2'; +const KEY_B1 = 'usage-it-key-b1'; + +let orgAId: string; +let orgBId: string; +let projA1: string; +let projA2: string; +let projB1: string; + +function currentPeriod(): string { + const n = new Date(); + return `${n.getUTCFullYear()}-${String(n.getUTCMonth() + 1).padStart(2, '0')}-01`; +} + +async function usageCount(orgId: string): Promise { + const [row] = await db<{ resolution_count: string }[]>` + SELECT resolution_count FROM usage_counters + WHERE organization_id = ${orgId} AND period_start = ${currentPeriod()}::date + `; + return row ? Number(row.resolution_count) : 0; +} + +// Distinct signals per call (unless overridden) so each resolves as its own identity — +// keeps the metering assertions independent of the matching/cluster logic. +function resolveOn( + projectId: string, + opts: { identityId?: string; timestamp?: string } = {}, +): ReturnType { + return resolveSnapshot(db, { + projectId, + snap: { + identityId: opts.identityId ?? crypto.randomUUID(), + signals: { 'canvas.2d': crypto.randomUUID(), 'audio.hash': crypto.randomUUID(), 'platform.os': 'Linux' }, + persistencePolicy: 'balanced', + timestamp: opts.timestamp ?? new Date().toISOString(), + }, + clientIp: null, + }); +} + +async function makeProject(apiKey: string, name: string, orgId: string): Promise { + const [proj] = await db<{ id: string }[]>` + INSERT INTO projects (api_key_hash, name, organization_id) + VALUES (${hashApiKey(apiKey)}, ${name}, ${orgId}) RETURNING id + `; + return proj!.id; +} + +beforeAll(async () => { + if (!hasDb) return; + await migrate(); + await redis.flushdb(); + for (const k of [KEY_A1, KEY_A2, KEY_B1]) { + await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(k)}`; + } + await deleteTestOrg(ORG_A); + await deleteTestOrg(ORG_B); + orgAId = await createTestOrg(ORG_A); + orgBId = await createTestOrg(ORG_B); + projA1 = await makeProject(KEY_A1, 'Usage A1', orgAId); + projA2 = await makeProject(KEY_A2, 'Usage A2', orgAId); + projB1 = await makeProject(KEY_B1, 'Usage B1', orgBId); +}); + +afterAll(async () => { + if (!hasDb) return; + for (const k of [KEY_A1, KEY_A2, KEY_B1]) { + await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(k)}`; + } + await deleteTestOrg(ORG_A); // cascades usage_counters + await deleteTestOrg(ORG_B); + await redis.quit(); + await db.end(); +}); + +// Reset counters + the orgs' identities before each test so counts start at zero. +beforeEach(async () => { + if (!hasDb) return; + await db`DELETE FROM usage_counters WHERE organization_id IN (${orgAId}, ${orgBId})`; + for (const p of [projA1, projA2, projB1]) { + await db`DELETE FROM identities WHERE project_id = ${p}`; // cascades snapshots + } + await db`UPDATE organizations SET monthly_resolution_limit = NULL WHERE id IN (${orgAId}, ${orgBId})`; +}); + +describe.skipIf(!hasDb)('usage metering: counting', () => { + it('increments the org counter exactly once per committed resolution', async () => { + await resolveOn(projA1); + await resolveOn(projA1); + await resolveOn(projA1); + expect(await usageCount(orgAId)).toBe(3); + }); + + it('does NOT increment on a deduplicated (identical event_id) resolution', async () => { + const identityId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + await resolveOn(projA1, { identityId, timestamp }); + await resolveOn(projA1, { identityId, timestamp }); // same event_id → dedup no-op + expect(await usageCount(orgAId)).toBe(1); + }); + + it('rolls multiple projects in one org into a single org counter', async () => { + await resolveOn(projA1); + await resolveOn(projA2); + expect(await usageCount(orgAId)).toBe(2); + }); + + it('keeps usage isolated per organization', async () => { + await resolveOn(projA1); + await resolveOn(projB1); + await resolveOn(projB1); + expect(await usageCount(orgAId)).toBe(1); + expect(await usageCount(orgBId)).toBe(2); + }); +}); + +describe.skipIf(!hasDb)('usage metering: soft thresholds', () => { + // Seeds the current-period counter row directly so the threshold logic is tested + // deterministically (the live path fires checkAndWarnThreshold fire-and-forget). + async function seedCount(orgId: string, count: number): Promise { + await db` + INSERT INTO usage_counters (organization_id, period_start, resolution_count) + VALUES (${orgId}, ${currentPeriod()}::date, ${count}) + ON CONFLICT (organization_id, period_start) + DO UPDATE SET resolution_count = ${count}, warned_80 = false, warned_100 = false + `; + } + async function flags(orgId: string): Promise<{ warned_80: boolean; warned_100: boolean }> { + const [row] = await db<{ warned_80: boolean; warned_100: boolean }[]>` + SELECT warned_80, warned_100 FROM usage_counters + WHERE organization_id = ${orgId} AND period_start = ${currentPeriod()}::date + `; + return row!; + } + + it('never warns when the limit is NULL (unlimited / self-host)', async () => { + await db`UPDATE organizations SET monthly_resolution_limit = NULL WHERE id = ${orgAId}`; + await seedCount(orgAId, 1_000_000); + await checkAndWarnThreshold(db, orgAId, 1_000_000); + expect(await flags(orgAId)).toEqual({ warned_80: false, warned_100: false }); + }); + + it('does not warn below 80%', async () => { + await db`UPDATE organizations SET monthly_resolution_limit = 10 WHERE id = ${orgAId}`; + await seedCount(orgAId, 7); + await checkAndWarnThreshold(db, orgAId, 7); + expect(await flags(orgAId)).toEqual({ warned_80: false, warned_100: false }); + }); + + it('flips warned_80 once at 80% and is idempotent', async () => { + await db`UPDATE organizations SET monthly_resolution_limit = 10 WHERE id = ${orgAId}`; + await seedCount(orgAId, 8); + await checkAndWarnThreshold(db, orgAId, 8); + expect(await flags(orgAId)).toEqual({ warned_80: true, warned_100: false }); + // Idempotent: a second call at the same level doesn't reset or re-flip. + await checkAndWarnThreshold(db, orgAId, 8); + expect(await flags(orgAId)).toEqual({ warned_80: true, warned_100: false }); + }); + + it('flips both guards at 100%', async () => { + await db`UPDATE organizations SET monthly_resolution_limit = 10 WHERE id = ${orgAId}`; + await seedCount(orgAId, 12); + await checkAndWarnThreshold(db, orgAId, 12); + expect(await flags(orgAId)).toEqual({ warned_80: true, warned_100: true }); + }); +}); diff --git a/packages/server/src/lib/usage.ts b/packages/server/src/lib/usage.ts new file mode 100644 index 0000000..4bc6c99 --- /dev/null +++ b/packages/server/src/lib/usage.ts @@ -0,0 +1,96 @@ +import * as Sentry from '@sentry/node'; +import type { Sql, TransactionSql } from 'postgres'; +import { logger } from '../logger.js'; + +// Usage metering (Phase 7, slice 1). The billable unit is one committed identity +// resolution; this records it durably per organization per UTC calendar month. Soft +// posture: we measure and warn, never block. + +export interface UsageIncrement { + organizationId: string; + periodCount: number; +} + +// Atomically records one resolution for the project's organization in the current +// month and returns the running count. Resolves project -> org inline so the hot path +// needs no separate lookup. MUST run inside the resolution transaction: paired with the +// snapshot insert, the upstream event_id dedup guarantees exactly-once counting (retries +// short-circuit before the transaction) and a rollback un-counts atomically. +// +// Returns null only if the project has no organization (shouldn't happen post-migration +// 014's NOT NULL backstop) — callers then skip the threshold check. +export async function incrementUsage( + tx: TransactionSql, + projectId: string, +): Promise { + const [row] = await tx<{ organization_id: string; resolution_count: string }[]>` + INSERT INTO usage_counters (organization_id, period_start, resolution_count) + SELECT p.organization_id, date_trunc('month', now() AT TIME ZONE 'UTC')::date, 1 + FROM projects p + WHERE p.id = ${projectId} AND p.organization_id IS NOT NULL + ON CONFLICT (organization_id, period_start) + DO UPDATE SET resolution_count = usage_counters.resolution_count + 1, updated_at = now() + RETURNING organization_id, resolution_count + `; + if (!row) return null; + // resolution_count is BIGINT -> string over the wire; fine to Number() at these scales. + return { organizationId: row.organization_id, periodCount: Number(row.resolution_count) }; +} + +// Soft-limit alerting. Runs AFTER the resolution transaction commits (side effects out +// of the transaction; must never delay or fail a resolution). Reads the org's monthly +// limit (NULL/0 = unlimited -> no-op, so self-host never warns), and on first crossing +// of 80% / 100% flips a once-per-period guard and emits one warning to the logger and +// Sentry (reusing the shipped error-tracking setup; no-op without SENTRY_DSN). +export async function checkAndWarnThreshold( + db: Sql, + organizationId: string, + periodCount: number, +): Promise { + const [org] = await db<{ monthly_resolution_limit: number | null }[]>` + SELECT monthly_resolution_limit FROM organizations WHERE id = ${organizationId} LIMIT 1 + `; + const limit = org?.monthly_resolution_limit ?? null; + if (limit == null || limit <= 0) return; // unlimited / un-provisioned + const pct = periodCount / limit; + if (pct < 0.8) return; + + if (pct >= 1.0) { + // Flip both guards: once at 100% we never want a late 80% alert. + const flipped = await db` + UPDATE usage_counters + SET warned_100 = true, warned_80 = true + WHERE organization_id = ${organizationId} + AND period_start = date_trunc('month', now() AT TIME ZONE 'UTC')::date + AND warned_100 = false + RETURNING organization_id + `; + if (flipped.length > 0) emitWarning(organizationId, periodCount, limit, 100); + } else { + const flipped = await db` + UPDATE usage_counters + SET warned_80 = true + WHERE organization_id = ${organizationId} + AND period_start = date_trunc('month', now() AT TIME ZONE 'UTC')::date + AND warned_80 = false + RETURNING organization_id + `; + if (flipped.length > 0) emitWarning(organizationId, periodCount, limit, 80); + } +} + +function emitWarning( + organizationId: string, + periodCount: number, + limit: number, + threshold: 80 | 100, +): void { + logger.warn( + { organizationId, periodCount, limit, threshold }, + `organization reached ${threshold}% of its monthly resolution quota`, + ); + Sentry.captureMessage( + `Org ${organizationId} at ${threshold}% of monthly resolution quota (${periodCount}/${limit})`, + 'warning', + ); +} diff --git a/packages/server/src/pipeline/resolve.ts b/packages/server/src/pipeline/resolve.ts index fce98df..1953c3b 100644 --- a/packages/server/src/pipeline/resolve.ts +++ b/packages/server/src/pipeline/resolve.ts @@ -18,6 +18,8 @@ import type { SignalProfile } from '../engine/signal-profile.js'; import { minimizeIp } from '../lib/minimize-ip.js'; import { assessRisk } from '../risk/assess.js'; import { deliverWebhooks } from '../risk/webhook.js'; +import { incrementUsage, checkAndWarnThreshold } from '../lib/usage.js'; +import type { UsageIncrement } from '../lib/usage.js'; const tracer = trace.getTracer('scent-server'); @@ -109,9 +111,11 @@ export async function resolveSnapshot( let ambiguous = false; let newSnapId: string | null = null; - await db.begin(async (tx) => { + const usage = await db.begin(async (tx) => { await tx`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`; + let usageIncrement: UsageIncrement | null = null; + // Candidate retrieval: pre-filter on the denormalized latest_signal_hash, // returning only identities whose SimHash is within the Hamming threshold // (bit_count of the XOR); survivors' signals/profile come via LATERAL. Runs @@ -215,6 +219,12 @@ export async function resolveSnapshot( `; if (newSnap) newSnapId = newSnap.id; + // Meter this resolution against the project's org for the current month. Inside + // the transaction so it's exactly-once with the snapshot (retries dedup out above) + // and rolls back together; project->org is resolved inline. Returned from the + // transaction (rather than mutating an outer var) so it's correctly typed after. + usageIncrement = await incrementUsage(tx, projectId); + // Drift: compute against the previous snapshot for this identity. if (!isNew && newSnap) { const prevSnap = await tx<{ id: string; signals: SignalMap }[]>` @@ -242,8 +252,17 @@ export async function resolveSnapshot( if (!isNew && secondBest && secondBest.confidence >= CLUSTER_LINK_THRESHOLD) { await linkToCluster(tx, projectId, resolvedId, secondBest.identityId, secondBest.confidence); } + + return usageIncrement; }); + // Soft usage-limit alerting, after the commit so its reads/writes and any Sentry + // emit never delay or roll back the resolution. Fire-and-forget: metering must + // never affect the resolution result. + if (usage) { + void checkAndWarnThreshold(db, usage.organizationId, usage.periodCount); + } + const continuity = scoreToIdentityContinuity(finalConfidence); // Risk assessment runs outside the identity transaction so its DB writes diff --git a/packages/server/src/routes/admin.integration.test.ts b/packages/server/src/routes/admin.integration.test.ts index 17daf33..2f672a5 100644 --- a/packages/server/src/routes/admin.integration.test.ts +++ b/packages/server/src/routes/admin.integration.test.ts @@ -7,6 +7,7 @@ import { redis } from '../db/redis.js'; import { hashPassword } from '../admin/password.js'; import { hashApiKey } from '../middleware/api-key.js'; import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; +import { resolveSnapshot } from '../pipeline/resolve.js'; // Integration coverage for the admin management API: login/session, and project key // create/rotate/revoke including the data-API (/v1) authorization side effects and @@ -133,6 +134,34 @@ describe.skipIf(!hasDb)('admin management API (integration)', () => { expect(rows).toHaveLength(0); }); + it('reports org-scoped usage via GET /admin/usage', async () => { + // Set a soft limit, then create a project and commit one resolution. We drive + // resolveSnapshot directly (the metered, persisting path); POST /v1/resolve is a + // non-persisting preview and intentionally does not count toward usage. + await db`UPDATE organizations SET monthly_resolution_limit = 1000 WHERE name = ${ORG}`; + const created = await agent.post('/admin/projects').set('X-CSRF-Token', csrf).send({ name: 'AdminIT Usage' }); + expect(created.status).toBe(201); + await resolveSnapshot(db, { + projectId: created.body.project.id, + snap: { + identityId: crypto.randomUUID(), + signals: { 'canvas.2d': crypto.randomUUID(), 'platform.os': 'Linux' }, + persistencePolicy: 'balanced', + timestamp: new Date().toISOString(), + }, + clientIp: null, + }); + + const res = await agent.get('/admin/usage'); + expect(res.status).toBe(200); + expect(res.body.plan).toBeDefined(); + expect(res.body.limit).toBe(1000); + expect(res.body.periodStart).toMatch(/^\d{4}-\d{2}-01$/); + expect(res.body.resolutionsThisPeriod).toBeGreaterThanOrEqual(1); + expect(res.body.pctUsed).toBeGreaterThan(0); + expect(Array.isArray(res.body.history)).toBe(true); + }); + it('logout clears the session', async () => { await agent.post('/admin/logout').set('X-CSRF-Token', csrf); const me = await agent.get('/admin/me'); diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 76f9ce1..3ddc09e 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -210,6 +210,39 @@ adminRouter.get('/me', async (req: Request, res: Response): Promise => { res.json({ id: user.id, email: user.email, role: user.role, totpEnabled: user.totpEnabled, mustEnroll }); }); +// Usage metering for the caller's organization: current-month resolution count vs the +// soft limit, plus recent history. Org-scoped (any admin sees their own org's usage); +// GET, so no CSRF. limit null = unlimited (self-host / un-provisioned). +adminRouter.get('/usage', async (req: Request, res: Response): Promise => { + const org = req.adminUser!.organizationId; + const [orgRow] = await db<{ plan: string; monthly_resolution_limit: number | null }[]>` + SELECT plan, monthly_resolution_limit FROM organizations WHERE id = ${org} LIMIT 1 + `; + const history = await db<{ period_start: string; resolution_count: string }[]>` + SELECT to_char(period_start, 'YYYY-MM-DD') AS period_start, resolution_count + FROM usage_counters + WHERE organization_id = ${org} + ORDER BY period_start DESC + LIMIT 6 + `; + + const now = new Date(); + const periodStart = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-01`; + const resolutionsThisPeriod = Number( + history.find((h) => h.period_start === periodStart)?.resolution_count ?? 0, + ); + const limit = orgRow?.monthly_resolution_limit ?? null; + + res.json({ + plan: orgRow?.plan ?? 'free', + limit, + periodStart, + resolutionsThisPeriod, + pctUsed: limit && limit > 0 ? resolutionsThisPeriod / limit : null, + history: history.map((h) => ({ periodStart: h.period_start, resolutions: Number(h.resolution_count) })), + }); +}); + adminRouter.get('/projects', async (req: Request, res: Response): Promise => { const user = req.adminUser!; // Owners see every project (with the synthetic role 'owner'); members see only the diff --git a/packages/server/src/scripts/set-org-plan.ts b/packages/server/src/scripts/set-org-plan.ts new file mode 100644 index 0000000..918fc02 --- /dev/null +++ b/packages/server/src/scripts/set-org-plan.ts @@ -0,0 +1,50 @@ +import { db } from '../db/client.js'; +import { findOrCreateOrgByName } from '../lib/organizations.js'; + +// Set an organization's plan and soft monthly resolution limit. The thin/assisted +// onboarding path for Phase 7 metering: an operator activates a hosted customer's soft +// quota until the Observatory org-management UI lands. Enforcement is soft — exceeding +// the limit warns (logs + Sentry), it does not block. +// +// tsx src/scripts/set-org-plan.ts "" [limit|unlimited] (dev) +// docker compose exec scent-server node dist/scripts/set-org-plan.js "Acme" free 10000 +// +// limit: an integer, or 'unlimited' (NULL) — the default when omitted. The org is +// created if it doesn't exist (idempotent), so this doubles as provisioning. +async function main(): Promise { + const orgName = process.argv[2]?.trim(); + const plan = process.argv[3]?.trim(); + const limitArg = process.argv[4]?.trim(); + + if (!orgName || !plan) { + console.error('Usage: set-org-plan "" [limit|unlimited]'); + process.exit(1); + } + + let limit: number | null = null; + if (limitArg && limitArg !== 'unlimited') { + limit = Number(limitArg); + if (!Number.isInteger(limit) || limit < 0) { + console.error(`Invalid limit "${limitArg}": expected a non-negative integer or 'unlimited'.`); + process.exit(1); + } + } + + const organizationId = await findOrCreateOrgByName(orgName); + await db` + UPDATE organizations + SET plan = ${plan}, monthly_resolution_limit = ${limit} + WHERE id = ${organizationId} + `; + + console.error( + `Org "${orgName}" (${organizationId}) → plan=${plan}, monthly_resolution_limit=${limit ?? 'unlimited'}`, + ); + + await db.end(); +} + +main().catch((err: unknown) => { + console.error('Failed to set org plan:', err); + process.exit(1); +});