commit dd605a746cafb296ee26fe0be03c846c9e54cb0f Author: George Lambert Date: Fri Sep 11 15:16:56 2026 -0400 Initial import of zappier-edge from zapier monorepo diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b1bc858 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c18a62 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env (gitignored) and fill in your real Stripe secret key. +# The billing job (src/jobs/report-usage.ts) loads this via dotenv. +STRIPE_SECRET_KEY=sk_test_replace_me +# Optional: override the SQLite database location (default: zappier.db) +# ZAPPIER_DB=/absolute/path/to/zappier.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d50d8ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +zappier.db +zappier.db-journal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48a5d4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-bookworm-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +COPY admin ./admin +COPY portal ./portal +COPY openapi.yaml ./ +ENV PORT=3000 +EXPOSE 3000 +CMD ["npx", "ts-node", "src/index.ts"] diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..81f603f --- /dev/null +++ b/NATS.md @@ -0,0 +1,10 @@ +# NATS — zappier-edge + +This process **does not subscribe or publish** on NATS. + +| Direction | Address | Peer | Body | +|-----------|---------|------|------| +| IN HTTPS | `/v1/*` | Zapier Platform app | JSON + `x-api-key` | +| OUT HTTPS | middleware `/zapier/v1/*` | verae-middleware | same tenant request | + +NATS is private to middleware workers and archives. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9f0763 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Zappier + +Metered API platform: per-endpoint pricing, customer types with multipliers and +monthly credits, a usage ledger, Stripe metered billing, purchase-order +invoicing, a company admin console, a self-service customer portal, and a +Zapier integration. + +This is the **billing and user platform** (originally the standalone `zappier` git tree at `~/zappier`). Forgejo name: **zappier-edge**. It owns signup, TOTP, API keys, rate card, Stripe meter, PO invoices, admin users, and the customer portal. Verae middleware does not replace this. + +**Forgejo:** https://git.georgelambert.org/marchon/zappier-edge +**Catalog README:** https://zapier.georgelambert.org/packages/zappier/README.pdf +**Bring the whole system online:** https://zapier.georgelambert.org/packages/verae-ops/GETTING-STARTED.pdf + +## Documentation (public PDFs) + +- [USER-MANUAL](https://zapier.georgelambert.org/packages/zappier/docs/USER-MANUAL.pdf) — operations & usage +- [ACCOUNTING](https://zapier.georgelambert.org/packages/zappier/docs/ACCOUNTING.pdf) — invoices, PO billing, reports +- [USER-MANAGEMENT](https://zapier.georgelambert.org/packages/zappier/docs/USER-MANAGEMENT.pdf) — pricing, customer types +- [CUSTOMER-PORTAL](https://zapier.georgelambert.org/packages/zappier/docs/CUSTOMER-PORTAL.pdf) — signup, 2FA, reloads +- [DEVELOPER](https://zapier.georgelambert.org/packages/zappier/docs/DEVELOPER.pdf) +- [WALKTHROUGH](https://zapier.georgelambert.org/packages/zappier/docs/WALKTHROUGH.pdf) +- Markdown copies remain next to these files in `docs/` +- **Sample Zapier app:** `zapier-app/` — https://zapier.georgelambert.org/packages/zappier/zapier-app/README.pdf (if present) or the source tree in git + +## Surfaces + +| Surface | URL | Audience | +|---|---|---| +| Public API | `/v1/*` | API customers (`x-api-key`) | +| Interactive API docs | `/docs` | Integrating developers | +| Admin console | `/admin` | Company ops & accounting | +| Customer portal | `/portal` | End-user customers (signup, 2FA, billing) | +| Zapier app | `zapier-app/` | No-code users via Zapier | + +## Pricing model + +`openapi.yaml` defines the API surface; each `operationId` is a rate-card key. +Endpoints carry **list prices** (seed: `src/pricing.ts` → `DEFAULT_RATE_CARD`). +Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a +`monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card. +Individual customers can carry a `multiplierOverride`. +Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free. +Pricing is editable at runtime in the admin console. + +### Seed rate card (list prices, cents per call) + +| Endpoint | Model | List price | +| -------------- | -------- | -------------------------------------------- | +| `status` | free | 0 | +| `storage-list` | free | 0 | +| `transform` | fixed | 4 | +| `storage` | variable | 10 + 1 per KB metadata + 50 per MB attached | + +### Seed customer types + +| Tier | Multiplier | Monthly credit | Default rule (unlisted endpoints) | +| ---------- | ---------- | -------------- | --------------------------------- | +| `free` | 1.0 | 100 cents | none — call rejected with 403 | +| `pro` | 0.5 | 1000 cents | fixed 8 list → 4 billed | +| `business` | 0.25 | 10000 cents | fixed 8 list → 2 billed | + +Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI. +Adding a customer type = create it in the admin UI. Variable pricing = base per call + +metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier. + +## Quickstart + +```sh +npm install +npm run dev +``` + +The server starts on port 3000. API docs at +[http://localhost:3000/docs](http://localhost:3000/docs), admin console at +`/admin`, customer portal at `/portal`. + +## Deploying + +```sh +npm ci && npm run build +node dist/index.js # runs from ANY working directory +``` + +All runtime paths (SQLite default, `.env`, OpenAPI spec, static assets) +resolve from the installation root, so the compiled server works under +systemd, Docker, or cron regardless of cwd. `PORT` and `ZAPPIER_DB` remain +environment-overridable. + +## Environment variables + +| Variable | Default | Purpose | +| ------------------- | -------------- | --------------------------------------------------- | +| `PORT` | `3000` | HTTP port the server listens on | +| `ZAPPIER_DB` | `/zappier.db` | SQLite database file path | +| `ADMIN_KEY` | `admin-dev-key`| Admin UI / admin API key — **set a real secret in production** | +| `ADMIN_USER` | `admin` | Admin UI primary login username | +| `DEMO_ADMIN_USER` | `demo` | Admin UI demo login username | +| `DEMO_ADMIN_PASSWORD` | `$$$Adm1n###` | Demo login password — **override in production** | +| `STRIPE_SECRET_KEY` | _(none)_ | Stripe secret key — billing job and portal reloads | + +## Billing + +Usage is reported to Stripe by a job (loads `STRIPE_SECRET_KEY` from `.env`): + +```sh +npx ts-node src/jobs/report-usage.ts +``` + +The job sums each customer's usage since the first of the current month (UTC), +applies the tier's monthly credit, and reports only the **delta** above what was +already reported — re-runs are safe. Idempotency comes from three layers: a +`billing_reports` ledger (cumulative cents per customer per month), an atomic +`job_locks` run guard (1 h TTL), and a deterministic Stripe event `identifier` +(`customer:period:billable`) that dedupes crash retries. It requires a Stripe +meter named `zappier.api_cents` with Sum aggregation over the `value` field. + +A Kimi cron job ("Zappier billing · report usage to Stripe") runs it daily at +06:17 America/New_York with a completion notification. + +Purchase-order customers are invoiced manually from the admin console +(**Invoices** tab); prepaid balances from the customer portal are drawn down +automatically at invoice issue. See `docs/ACCOUNTING.md`. + +## Zapier app + +The companion Zapier integration lives in `zapier-app/`: + +```sh +cd zapier-app +npm install +npm test +``` + +To deploy it, create a Zapier developer account, run `zapier login`, then +`zapier push` from the `zapier-app/` directory. + +## Testing + +```sh +npm test # root API/service suite (jest, 165 tests) +cd zapier-app && npm test # Zapier integration suite (mocha, 4 tests) +``` diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..fdf0ba3 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,11 @@ +# zappier-edge + +**Job:** Metered commercial API, portal, admin, Stripe. Zapier’s only public HTTPS hop. + +**Expects:** Zapier Platform app with `x-api-key`. + +**Sends:** HTTPS to middleware `/zapier/v1/*` (or local mock `/v1/timestamp`, `/v1/add`, `/v1/hashes`, `/v1/receipts`). + +**Does not** speak NATS or `api.veraetime.net`. + +**Test:** `npm test` (Jest, 177+). diff --git a/admin/app.js b/admin/app.js new file mode 100644 index 0000000..cb2b350 --- /dev/null +++ b/admin/app.js @@ -0,0 +1,672 @@ +const state = { pricing: null, customers: [], invoices: [], report: null, trend: null, system: null, users: [] }; +const TOKEN_KEY = 'zappier-admin-token'; + +/* ---------------- auth ---------------- */ + +function token() { + return localStorage.getItem(TOKEN_KEY); +} + +function showLogin(message = '') { + document.getElementById('shell').classList.remove('on'); + document.getElementById('login').style.display = 'grid'; + document.getElementById('login-error').textContent = message; +} + +function showShell() { + document.getElementById('login').style.display = 'none'; + document.getElementById('shell').classList.add('on'); +} + +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('login-username').value.trim(); + const password = document.getElementById('login-password').value; + try { + const res = await fetch('/admin/api/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error((await res.json()).error || 'Login failed'); + const { token: t } = await res.json(); + localStorage.setItem(TOKEN_KEY, t); + showShell(); + load().catch((err) => say(err.message, true)); + } catch (err) { + showLogin(err.message); + } +}); + +document.getElementById('logout').addEventListener('click', () => { + localStorage.removeItem(TOKEN_KEY); + location.reload(); +}); + +/* ---------------- api + status ---------------- */ + +async function api(path, options = {}) { + const res = await fetch(`/admin/api${path}`, { + ...options, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` }, + }); + if (res.status === 403) { + localStorage.removeItem(TOKEN_KEY); + showLogin('Session expired — sign in again.'); + throw new Error('Session expired.'); + } + if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`); + return res.json(); +} + +let statusTimer; +function say(msg, isError = false) { + const el = document.getElementById('status'); + el.textContent = msg; + el.classList.toggle('error', isError); + el.classList.add('show'); + clearTimeout(statusTimer); + statusTimer = setTimeout(() => el.classList.remove('show'), 4000); +} + +/* ---------------- shared helpers ---------------- */ + +const fmt = (cents) => + (cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2); +const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—'); +const customerName = (id) => state.customers.find((c) => c.id === id)?.name ?? id; + +function customerOptions(selected, includeAll = false) { + const all = includeAll ? `` : ''; + return ( + all + + state.customers + .map((c) => ``) + .join('') + ); +} + +function currentPeriod() { + return new Date().toISOString().slice(0, 7); +} + +async function load() { + state.pricing = await api('/pricing'); + state.customers = (await api('/customers')).customers; + state.invoices = (await api('/invoices')).invoices; + state.users = (await api('/users')).users; + renderEndpoints(); + renderTiers(); + renderCustomers(); + renderInvoices(); + renderReports(); + renderSystem(); + renderUsers(); +} + +/* ---------------- rate card ---------------- */ + +function ruleInputs(id, rule) { + const fields = + rule.kind === 'fixed' + ? { fixedCents: rule.fixedCents } + : rule.kind === 'variable' + ? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents } + : {}; + return Object.entries(fields) + .map( + ([k, v]) => + ``, + ) + .join(''); +} + +function renderEndpoints() { + const rows = Object.entries(state.pricing.rateCard.endpoints) + .map( + ([id, rule]) => ` + ${id} + ${rule.kind} + + ${ruleInputs(id, rule)} + + + + + `, + ) + .join(''); + document.getElementById('endpoints').innerHTML = ` +

Rate card

+

Per-endpoint list prices, in cents. Changes apply to the next API call — no restart.

+
+ + ${rows} +
Endpoint (operationId)KindSet kindPrices (cents)
+
+

Add endpoint

+ + + +

The operationId must match an operation in openapi.yaml.

+
`; +} + +async function saveEndpoint(id) { + const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value; + const rule = { kind }; + document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => { + rule[el.dataset.field] = Number(el.value); + }); + if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0; + if (kind === 'variable') { + rule.baseCents = rule.baseCents ?? 0; + rule.perKbCents = rule.perKbCents ?? 0; + rule.perMbCents = rule.perMbCents ?? 0; + } + await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) }); + say(`Saved ${id}.`); + await load(); +} + +async function deleteEndpoint(id) { + await api(`/endpoints/${id}`, { method: 'DELETE' }); + say(`Deleted ${id} — calls to it now get 403 unless a tier has a default rule.`); + await load(); +} + +async function addEndpoint() { + const id = document.getElementById('new-endpoint-id').value.trim(); + const kind = document.getElementById('new-endpoint-kind').value; + if (!id) return say('Endpoint id required.', true); + const rule = + kind === 'free' + ? { kind } + : kind === 'fixed' + ? { kind, fixedCents: 0 } + : { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 }; + await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) }); + say(`Added ${id}.`); + await load(); +} + +/* ---------------- customer types ---------------- */ + +function renderTiers() { + const rows = state.pricing.tiers + .map( + (t) => ` + ${t.id} + + + + + + + + `, + ) + .join(''); + document.getElementById('tiers').innerHTML = ` +

Customer types

+

Multiplier scales every list price (0.5 = 50%). Monthly credit is free included usage, in cents.

+
+ + ${rows} +
IdNameMultiplierMonthly credit (cents)
+
+

Add customer type

+ + + multiplier + +

New types start with 0 monthly credit — edit after adding.

+
`; +} + +async function saveTier(id) { + const body = { id }; + document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => { + body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value; + }); + const existing = state.pricing.tiers.find((t) => t.id === id); + if (existing?.defaultRule) body.defaultRule = existing.defaultRule; + await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) }); + say(`Saved tier ${id}.`); + await load(); +} + +async function deleteTier(id) { + await api(`/tiers/${id}`, { method: 'DELETE' }); + say(`Deleted tier ${id}.`); + await load(); +} + +async function addTier() { + const id = document.getElementById('new-tier-id').value.trim(); + const name = document.getElementById('new-tier-name').value.trim(); + const multiplier = Number(document.getElementById('new-tier-multiplier').value); + if (!id || !name) return say('Tier id and name required.', true); + await api(`/tiers/${id}`, { + method: 'PUT', + body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }), + }); + say(`Added tier ${id}.`); + await load(); +} + +/* ---------------- customers ---------------- */ + +function renderCustomers() { + const tierOptions = (selected) => + state.pricing.tiers + .map((t) => ``) + .join(''); + const btOptions = (selected) => + ['stripe', 'purchase_order'] + .map((b) => ``) + .join(''); + const rows = state.customers + .map( + (c) => ` + ${c.id} + ${c.name} + + + + + + `, + ) + .join(''); + document.getElementById('customers').innerHTML = ` +

Customers

+

Assign types, billing method, and per-customer deals. A multiplier override replaces the type multiplier for that customer.

+
+ + ${rows} +
IdNameEmailTypeMultiplier overrideBilling
+
+

Add customer

+ + + +

The new customer's API key is shown once in the notification — copy it immediately. Set email and billing method after creating.

+
`; +} + +async function saveCustomer(id) { + const body = {}; + document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => { + if (el.value === '') return; + body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value; + }); + await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) }); + say(`Saved customer ${id}.`); + await load(); +} + +async function addCustomer() { + const name = document.getElementById('new-customer-name').value.trim(); + const tierId = document.getElementById('new-customer-tier').value; + if (!name) return say('Customer name required.', true); + const created = await api('/customers', { + method: 'POST', + body: JSON.stringify({ name, tierId }), + }); + say(`Created ${created.id} — API key: ${created.apiKey}`); + await load(); +} + +/* ---------------- invoices ---------------- */ + +function renderInvoices() { + const rows = state.invoices + .slice() + .sort((a, b) => b.id.localeCompare(a.id)) + .map((inv) => { + const actions = []; + actions.push(``); + if (inv.status === 'draft') + actions.push(``); + if (inv.status === 'issued') + actions.push(``); + return ` + ${inv.id} + ${customerName(inv.customerId)} + ${inv.period} + ${inv.status} + ${inv.billingType === 'stripe' ? 'Stripe' : 'PO'}${inv.poNumber ? ` ${inv.poNumber}` : ''} + ${fmt(inv.totalCents)} + ${fmt(inv.creditCents)} + ${fmt(inv.billableCents)} + ${fmtDate(inv.dueAtMs)} + ${actions.join('')} + `; + }) + .join(''); + document.getElementById('invoices').innerHTML = ` +

Invoices

+

Generate monthly invoices from metered usage, then issue and collect. Regenerating a period replaces drafts and skips issued/paid invoices.

+
+

Generate invoices

+
+ + + + +
+
+
+
+
+ + + + +
+ + + ${rows || ''} +
InvoiceCustomerPeriodStatusBillingTotalCreditDue amountDue date
No invoices yet — generate a period above.
+
`; +} + +async function generateInvoices() { + const period = document.getElementById('gen-period').value; + const customerId = document.getElementById('gen-customer').value; + const poNumber = document.getElementById('gen-po').value.trim(); + if (!period) return say('Pick a period first.', true); + const result = await api('/invoices/generate', { + method: 'POST', + body: JSON.stringify({ + period, + ...(customerId ? { customerId } : {}), + ...(poNumber ? { poNumber } : {}), + }), + }); + const skips = result.skipped + .map((s) => `
  • ${customerName(s.customerId)}: ${s.reason}
  • `) + .join(''); + document.getElementById('gen-result').innerHTML = + `

    Generated ${result.generated.length}: ${result.generated.join(', ') || '—'}

    ` + + (skips ? `` : ''); + say(`Generated ${result.generated.length} invoice(s), skipped ${result.skipped.length}.`); + await load(); +} + +async function refreshInvoices() { + const params = new URLSearchParams(); + const customerId = document.getElementById('inv-filter-customer').value; + const period = document.getElementById('inv-filter-period').value; + const status = document.getElementById('inv-filter-status').value; + if (customerId) params.set('customerId', customerId); + if (period) params.set('period', period); + if (status) params.set('status', status); + state.invoices = (await api(`/invoices?${params}`)).invoices; + renderInvoices(); +} + +async function invoiceAction(id, action) { + await api(`/invoices/${id}/${action}`, { method: 'POST', body: '{}' }); + say(action === 'issue' ? `Issued ${id}.` : `Marked ${id} paid.`); + await load(); +} + +async function viewInvoice(id) { + const res = await fetch(`/admin/api/invoices/${id}?format=html`, { + headers: { authorization: `Bearer ${token()}` }, + }); + if (!res.ok) return say(`Could not load ${id}.`, true); + const blob = await res.blob(); + window.open(URL.createObjectURL(blob), '_blank'); +} + +/* ---------------- reports ---------------- */ + +function renderReports() { + const periodStart = `${currentPeriod()}-01`; + document.getElementById('reports').innerHTML = ` +

    Reports

    +

    Billing and usage analytics across customers. All amounts in USD, converted from integer cents.

    +
    +

    Billing report

    +
    + + + + + + +
    +
    +
    +
    +
    +

    Usage trend

    +
    + +
    +
    +
    `; + runReport().catch((err) => say(err.message, true)); + runTrend().catch((err) => say(err.message, true)); +} + +function reportQuery() { + const params = new URLSearchParams(); + const from = document.getElementById('rep-from').value; + const to = document.getElementById('rep-to').value; + const customerId = document.getElementById('rep-customer').value; + const billingType = document.getElementById('rep-billing-type').value; + if (from) params.set('from', from); + if (to) params.set('to', to); + if (customerId) params.set('customerId', customerId); + if (billingType) params.set('billingType', billingType); + return params; +} + +async function runReport() { + const { rows } = await api(`/reports/billing?${reportQuery()}`); + state.report = rows; + const totals = rows.reduce( + (acc, r) => ({ + calls: acc.calls + r.calls, + totalCents: acc.totalCents + r.totalCents, + creditCents: acc.creditCents + r.creditCents, + billableCents: acc.billableCents + r.billableCents, + }), + { calls: 0, totalCents: 0, creditCents: 0, billableCents: 0 }, + ); + document.getElementById('rep-summary').innerHTML = ` +
    +
    Calls
    ${totals.calls.toLocaleString()}
    +
    Gross usage
    ${fmt(totals.totalCents)}
    +
    Credits applied
    ${fmt(totals.creditCents)}
    +
    Billable
    ${fmt(totals.billableCents)}
    +
    `; + document.getElementById('rep-table').innerHTML = ` + CustomerBillingCallsGrossCreditBillable + ${ + rows + .map( + (r) => ` + ${r.name} ${r.customerId} + ${r.billingType === 'stripe' ? 'Stripe' : 'PO'} + ${r.calls.toLocaleString()} + ${fmt(r.totalCents)} + ${fmt(r.creditCents)} + ${fmt(r.billableCents)} + `, + ) + .join('') || 'No usage in range.' + }`; +} + +async function downloadCsv() { + const params = reportQuery(); + params.set('format', 'csv'); + const res = await fetch(`/admin/api/reports/billing?${params}`, { + headers: { authorization: `Bearer ${token()}` }, + }); + if (!res.ok) return say('CSV download failed.', true); + const url = URL.createObjectURL(await res.blob()); + const a = document.createElement('a'); + a.href = url; + a.download = 'billing-report.csv'; + a.click(); + URL.revokeObjectURL(url); + say('CSV downloaded.'); +} + +async function runTrend() { + const bucket = document.getElementById('trend-bucket').value; + const params = reportQuery(); + params.delete('billingType'); + params.set('bucket', bucket); + const { points } = await api(`/reports/usage-trend?${params}`); + state.trend = points; + document.getElementById('trend-chart').innerHTML = points.length + ? trendChart(points) + : '

    No usage in range.

    '; +} + +function trendChart(points) { + const W = 920; + const H = 220; + const padL = 8; + const padB = 34; + const padT = 10; + const max = Math.max(...points.map((p) => p.cents), 1); + const band = (W - padL) / points.length; + const barW = Math.max(4, Math.min(48, band * 0.62)); + const bars = points + .map((p, i) => { + const h = ((H - padB - padT) * p.cents) / max; + const x = padL + i * band + (band - barW) / 2; + const y = H - padB - h; + const label = + points.length <= 31 || i % Math.ceil(points.length / 31) === 0 + ? `${p.bucket.slice(5)}` + : ''; + return `${p.bucket}: ${p.calls} calls, ${fmt(p.cents)}${label}`; + }) + .join(''); + return `${bars} +

    Hover a bar for exact calls and amount. Peak: ${fmt(max)}.

    `; +} + +/* ---------------- system ---------------- */ + +function renderSystem() { + document.getElementById('system').innerHTML = ` +

    System

    +

    Integration health and current-period billing snapshot.

    +

    Zapier integration

    Loading…

    +

    Current period (${currentPeriod()})

    Loading…

    `; + loadSystem().catch((err) => say(err.message, true)); +} + +async function loadSystem() { + const status = await api('/zapier/status'); + state.system = status; + document.getElementById('sys-zapier').innerHTML = ` +
    +
    App directory
    ${status.appDirPresent ? '✓ zapier-app/ found' : '✗ not found'}
    +
    Version
    ${status.version ?? '—'}
    +
    Triggers
    ${status.triggers.length ? status.triggers.join(', ') : '—'}
    +
    Creates
    ${status.creates.length ? status.creates.join(', ') : '—'}
    +
    `; + + const params = new URLSearchParams({ from: `${currentPeriod()}-01` }); + const { rows } = await api(`/reports/billing?${params}`); + const totals = rows.reduce( + (acc, r) => ({ calls: acc.calls + r.calls, billableCents: acc.billableCents + r.billableCents }), + { calls: 0, billableCents: 0 }, + ); + const unpaid = state.invoices.filter((i) => i.status === 'issued'); + document.getElementById('sys-period').innerHTML = ` +
    +
    Calls this period
    ${totals.calls.toLocaleString()}
    +
    Billable this period
    ${fmt(totals.billableCents)}
    +
    Open invoices
    ${unpaid.length}
    +
    Open amount
    ${fmt(unpaid.reduce((s, i) => s + i.billableCents, 0))}
    +
    `; +} + +/* ---------------- admin users ---------------- */ + +function renderUsers() { + const rows = state.users + .slice() + .sort((a, b) => a.username.localeCompare(b.username)) + .map( + (u) => ` + ${u.username} + ${u.active ? 'active' : 'inactive'} + ${fmtDate(u.createdMs)} + + + + `, + ) + .join(''); + document.getElementById('users').innerHTML = ` +

    Admin users

    +

    Accounts that can sign in to this console. Passwords are stored as scrypt hashes — never in plain text. The last active admin cannot be deactivated.

    +
    + + ${rows} +
    UsernameStatusCreated
    +
    +

    Add admin user

    + + + +

    Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.

    +
    `; +} + +async function addUser() { + const username = document.getElementById('new-user-name').value.trim(); + const password = document.getElementById('new-user-password').value; + if (!username || !password) return say('Username and password required.', true); + await api('/users', { method: 'POST', body: JSON.stringify({ username, password }) }); + say(`Created admin user ${username}.`); + await load(); +} + +async function toggleUser(username, activate) { + await api(`/users/${username}/${activate ? 'activate' : 'deactivate'}`, { + method: 'POST', + body: '{}', + }); + say(`${activate ? 'Activated' : 'Deactivated'} ${username}.`); + await load(); +} + +/* ---------------- tabs + boot ---------------- */ + +document.querySelectorAll('nav button').forEach((btn) => + btn.addEventListener('click', () => { + document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('main section').forEach((s) => (s.hidden = true)); + document.getElementById(btn.dataset.tab).hidden = false; + }), +); + +if (token()) { + showShell(); + load().catch((err) => say(err.message, true)); +} else { + showLogin(); +} diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..ea45a86 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,260 @@ + + + + + + Zappier Admin + + + +
    +
    +

    Zappier Admin

    +

    Sign in to manage pricing, customer types, and customers.

    + + + + +

    + +
    +
    + +
    + +
    +
    + + + + + + +
    +
    +

    + + + diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..a60aedc Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/ACCOUNTING.md b/docs/ACCOUNTING.md new file mode 100644 index 0000000..f0ef0de --- /dev/null +++ b/docs/ACCOUNTING.md @@ -0,0 +1,126 @@ +# Company Accounting Walkthrough + +This guide covers the company-side accounting surface: invoicing, purchase-order +billing, reports, trends, and CSV export. Everything here runs in the **admin +console** at `/admin`. + +> Audience: operations and accounting staff. For pricing and customer setup see +> `USER-MANAGEMENT.md`; for the end-user view see `CUSTOMER-PORTAL.md`. + +--- + +## 1. Sign in + +Open `http://:/admin` and sign in. The development credentials are +`demo` / `$$$Adm1n###` (override with `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD`; +the primary account is `ADMIN_USER` / `ADMIN_KEY`). + +![Admin login](screenshots/admin-login.png) + +--- + +## 2. Billing identity per customer + +Before invoicing, each customer needs a **billing type** and an **email** — set +on the **Customers** tab: + +- **Stripe** — metered usage is reported to Stripe by the daily billing job. +- **Purchase order** — invoiced manually with a PO number; issued PO invoices + get a 30-day due date automatically. + +![Customers with billing types](screenshots/admin-customers.png) + +Customers can also carry a **prepaid balance** (funded from the customer +portal). When an invoice is issued and the balance fully covers the billable +amount, the balance is drawn down and the invoice goes straight to **paid**. +Partial coverage is left untouched — there are no partial payments. + +--- + +## 3. Generate invoices + +On the **Invoices** tab, pick a **period** (month), optionally narrow to one +customer, optionally set a **PO number**, and click **Generate**. + +![Invoices tab](screenshots/admin-invoices.png) + +Generation rules: + +- One invoice per customer with usage in the period, grouped by endpoint. +- The tier **monthly credit** is applied; only the remainder is billable. +- Regenerating a period **replaces drafts** (e.g. after late-arriving usage) + and **skips issued/paid invoices** — the result panel lists who was skipped + and why. +- Invoice ids are `INV--`, e.g. `INV-2026-07-0002`. + +## 4. Issue, view, collect + +Each row shows status, billing type, totals, and due amount: + +- **Issue** (draft → issued) — finalizes the invoice; PO invoices get a due + date 30 days out. Balance-covered invoices skip straight to **paid**. +- **View** — opens the print-ready invoice in a new tab. Use the browser's + **Print → Save as PDF** to produce a PDF for the customer. +- **Mark paid** (issued → paid) — record collection for PO invoices. + +![Print-ready invoice](screenshots/admin-invoice-html.png) + +Filter the table by customer, period, or status with the filter bar. + +--- + +## 5. Reports and trends + +The **Reports** tab answers "who owes what, and how is usage trending?" + +![Reports tab](screenshots/admin-reports.png) + +- **Billing report** — per-customer calls, gross usage, credits applied, and + billable amount for a date range. Filter by customer or billing type (all + Stripe customers, all PO customers, or one customer). Summary cards total + the selection. +- **Download CSV** — the same rows as `billing-report.csv` with the active + filters, ready for the accounting package. +- **Usage trend** — daily or weekly buckets as a bar chart; hover a bar for + exact calls and amount. + +The same data is available as JSON/CSV from the API: +`GET /admin/api/reports/billing?from=…&to=…&customerId=…&billingType=…&format=csv` +and `GET /admin/api/reports/usage-trend?bucket=day|week`. + +--- + +## 6. System snapshot + +The **System** tab shows integration health (Zapier app directory, version, +triggers, creates) and the current period at a glance: calls, billable amount, +open invoice count, and open amount. + +![System tab](screenshots/admin-system.png) + +--- + +## 7. Automated Stripe reporting + +A daily job (`src/jobs/report-usage.ts`, scheduled separately) reports the +billable delta of every Stripe-billed customer to Stripe Billing meter events. +It is idempotent: a ledger records the cumulative reported cents per customer +per period, and only the delta since the last successful run is sent. PO +customers are excluded by having no `stripeCustomerId`. + +Environment (`.env` at the project root): + +``` +STRIPE_SECRET_KEY=sk_live_or_test_... +ZAPPIER_DB=/absolute/path/to/zappier.db # optional +``` + +--- + +## Data notes + +- Money is integer **cents** everywhere internally; the UI formats dollars. +- All accounting data lives in the SQLite database (`zappier.db` by default): + `invoices`, `invoice_lines`, `billing_ledger`, `customers`. +- The Stripe billing job and the admin console can run from any working + directory — all paths resolve from the installation root. diff --git a/docs/ACCOUNTING.pdf b/docs/ACCOUNTING.pdf new file mode 100644 index 0000000..e85e5f2 Binary files /dev/null and b/docs/ACCOUNTING.pdf differ diff --git a/docs/CUSTOMER-PORTAL.md b/docs/CUSTOMER-PORTAL.md new file mode 100644 index 0000000..10d5632 --- /dev/null +++ b/docs/CUSTOMER-PORTAL.md @@ -0,0 +1,98 @@ +# Customer Portal Walkthrough + +The self-service portal for end-user customers at `/portal`: account setup, +two-factor authentication, usage and balance, invoices, reloads, and API +documentation. + +> Audience: your API customers. For the company side see `ACCOUNTING.md` and +> `USER-MANAGEMENT.md`. + +--- + +## 1. Create your account + +Open `http://:/portal` and choose **Create an account**. + +![Sign up](screenshots/portal-signup.png) + +- Name, email, and a password of at least 8 characters. +- You start on the **Free** plan and get an **API key immediately**. +- If the company already created an account for you (you received an API key + by email), sign up with the **same email address** — your existing account, + plan, and API key are kept and the password you choose is attached to it. + +Sign-in afterwards is email + password on the same screen. + +![Sign in](screenshots/portal-login.png) + +--- + +## 2. Dashboard + +![Dashboard](screenshots/portal-dashboard.png) + +- **Usage this month** — gross usage since the 1st (UTC). +- **Included credit** — how much of your plan's monthly credit was consumed. +- **Billable** — what exceeds the credit (what you'd be invoiced today). +- **Prepaid balance** — funds available for automatic invoice payment. +- **Your API key** — copy it, or **regenerate** it. Regenerating invalidates + the old key immediately; use it as the `x-api-key` header. + +## 3. Usage & pricing + +The **API & pricing** tab shows the live rate card (free / fixed / variable +per endpoint, with size-based pricing for storage) and every plan's multiplier +and monthly credit — the same numbers the server bills from. + +![API & pricing](screenshots/portal-docs.png) + +The interactive API reference (Swagger UI) is linked at the top (`/docs`). + +## 4. Invoices + +![Invoices](screenshots/portal-invoices.png) + +Your invoice history with status (`draft`, `issued`, `paid`), totals, credit, +amount due, and due date. **View / print** opens a print-ready invoice — use +the browser's **Print → Save as PDF** for a copy. + +![Printable invoice](screenshots/portal-invoice-html.png) + +Only your own invoices are visible; other customers' ids return "not found". + +## 5. Billing: reloads & email invoicing + +![Billing](screenshots/portal-billing.png) + +- **Reload balance** — add $1–$10,000. Your prepaid balance is **drawn down + automatically** when an invoice is issued: if it fully covers the amount + due, the invoice is paid instantly. +- **Email invoicing** — receive a copy of each new invoice by email. + +## 6. Security: two-factor authentication + +On the **Security** tab, click **Set up 2FA**: + +![2FA setup](screenshots/portal-2fa-setup.png) + +1. Scan the QR code with any authenticator app (or type the secret manually). +2. Enter the 6-digit code it shows to enable 2FA. + +From then on, sign-in requires the password **and** the current code. You can +disable 2FA with a valid code from the same tab. Sessions expire after 7 days. + +--- + +## Portal API reference + +Session-based (`Authorization: Bearer ` from signup/login): + +- `POST /portal/api/signup` · `POST /portal/api/login` · `POST /portal/api/logout` +- `GET /portal/api/me` · `POST /portal/api/api-key` +- `GET /portal/api/usage` · `GET /portal/api/pricing` +- `GET /portal/api/invoices` · `GET /portal/api/invoices/:id[?format=html]` +- `POST /portal/api/2fa/setup|enable|disable` +- `POST /portal/api/reload` · `PUT /portal/api/email-invoicing` + +Security notes: passwords are stored as scrypt hashes; TOTP secrets are only +shown during setup; no hash or secret is ever returned by the API. diff --git a/docs/CUSTOMER-PORTAL.pdf b/docs/CUSTOMER-PORTAL.pdf new file mode 100644 index 0000000..82bb95d Binary files /dev/null and b/docs/CUSTOMER-PORTAL.pdf differ diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md new file mode 100644 index 0000000..5786e7f --- /dev/null +++ b/docs/DEVELOPER.md @@ -0,0 +1,406 @@ +# Zappier — Developer Documentation + +**Version:** 0.2.0 · **Last updated:** 2026-07-28 + +Metered API platform: per-endpoint pricing, per-tier multipliers, usage +ledger, Stripe metered billing, purchase-order invoicing, a company admin +console, a customer portal with TOTP 2FA, and a Zapier integration. This +document is the full technical reference. For operations, see +[USER-MANUAL.md](USER-MANUAL.md). + +--- + +## Table of contents + +1. [Architecture](#1-architecture) +2. [Module reference](#2-module-reference) +3. [Pricing engine](#3-pricing-engine) +4. [Request lifecycle](#4-request-lifecycle) +5. [Persistence (SQLite)](#5-persistence-sqlite) +6. [Billing pipeline](#6-billing-pipeline) +7. [API reference](#7-api-reference) +8. [Zapier app](#8-zapier-app) +9. [Testing](#9-testing) +10. [Configuration](#10-configuration) +11. [Extending the system](#11-extending-the-system) + +--- + +## 1. Architecture + +**Stack:** Node 20 · TypeScript (strict) · Express 4 · better-sqlite3 · +express-openapi-validator · swagger-ui-express · stripe SDK · Zapier Platform +(core ^19) · jest + supertest (root) and mocha (zapier-app). + +``` + ┌────────────────────────────────────────────┐ + │ Express app │ + │ (src/app.ts) │ + │ │ + Browser ── /admin ──────┼── static admin UI (admin/) │ + Browser ── /admin/api ──┼── adminAuth → adminRouter (src/admin.ts) │ + │ pricing · customers · invoices · │ + │ reports · zapier status │ + Browser ── /portal ─────┼── static portal SPA (portal/) │ + Browser ── /portal/api ─┼── portalRouter (src/portal.ts) │ + │ signup/login+2FA · me · usage · │ + │ invoices · reload · prefs │ + Browser ── /docs ───────┼── swagger-ui (openapi.yaml) │ + │ │ + Client ──── /v1/* ──────┼── apiKeyAuth (src/auth.ts) │ + │ └─ OpenAPI request validation │ + │ └─ meter() (src/meter.ts) │ + │ ├─ quoteCall() (src/pricing.ts) │ + │ └─ UsageRepo.record() │ + └──────────────┬─────────────────────────────┘ + │ + ┌───────────────┬───────────────┼───────────────┬────────────┐ + │ │ │ │ │ + SqliteUsageRepo SqliteCustomerRepo SqlitePricingStore SqliteInvoiceRepo + (usage_entries) (customers) (price_endpoints, (invoices, + │ │ tiers) invoice_lines) + │ SqliteSessionRepo (portal_sessions) │ + └───────────────┴───────────────┴───────────────┴────────────┘ + │ zappier.db (SQLite) + Cron (daily 06:17 ET) │ + "Zappier billing · report usage" │ + │ │ + └─► src/jobs/report-usage.ts ── reportMonthlyUsage() + ├─ computeBillableCents / computeDelta (src/billing/stripe.ts) + ├─ SqliteBillingReportRepo (billing_reports, job_locks) + └─ stripe.billing.meterEvents.create ──► Stripe +``` + +Design rules the codebase follows: + +- **Ports & adapters:** `UsageRepo`, `CustomerRepo`, `PricingStore`, + `MeterEventClient` are interfaces with in-memory adapters (tests) and SQLite + / Stripe adapters (production). Nothing outside `src/db/` and the job's + `main()` touches SQL or the Stripe SDK. +- **Live pricing reads:** the `PricingContext` getters in `buildApp` read the + store on every quote, so admin edits apply without a restart. +- **Money in integer cents everywhere.** No floats cross a boundary except + multipliers, which are applied once and rounded (`Math.round`). + +## 2. Module reference + +| File | Responsibility | Key exports | +|---|---|---| +| `src/index.ts` | Production entry: opens SQLite, wires repos, listens | — | +| `src/app.ts` | `buildApp(deps)` — full Express wiring; `StoredItem`; `DEFAULT_CUSTOMERS` | `buildApp`, `AppDeps` | +| `src/auth.ts` | Customer model + `x-api-key` middleware | `Customer`, `CustomerRepo`, `apiKeyAuth`, `InMemoryCustomerRepo` | +| `src/pricing.ts` | Pricing domain: rules, tiers, quote algorithm, store port | `PriceRule`, `TierConfig`, `Quote`, `quoteCall`, `PricingStore`, `DEFAULT_RATE_CARD`, `DEFAULT_TIERS` | +| `src/meter.ts` | Per-request metering middleware | `meter(endpointId, repo, pricing)` | +| `src/usage.ts` | Usage ledger domain + summaries | `UsageEntry`, `UsageSummary`, `summarize`, `UsageRepo`, `InMemoryUsageRepo` | +| `src/admin.ts` | Admin API (`/admin/api`) + admin-key guard | `adminAuth`, `adminRouter` | +| `src/billing/credit.ts` | Monthly credit application | `applyMonthlyCredit`, `BilledSummary` | +| `src/billing/stripe.ts` | Stripe-facing math + client port | `METER_EVENT_NAME`, `MeterEventClient`, `computeBillableCents`, `computeDelta`, `reportUsage` | +| `src/billing/reload.ts` | Portal reloads via Stripe PaymentIntents | `stripePaymentClient`, `hasRealStripeKey` | +| `src/accounts.ts` | Portal identity: scrypt passwords, RFC 6238 TOTP, sessions | `hashPassword`, `verifyPassword`, `totp`, `verifyTotp`, `generateTotpSecret`, `totpUri`, `SessionRepo`, `InMemorySessionRepo` | +| `src/invoicing.ts` | Invoice domain + generation | `Invoice`, `InvoiceLine`, `buildInvoice`, `InvoiceRepo`, `InMemoryInvoiceRepo` | +| `src/reports.ts` | Billing/usage aggregation + CSV | `billingRows`, `usageTrend`, `toCsv`, `BillingRow`, `TrendPoint` | +| `src/portal.ts` | Customer portal API (`/portal/api`) | `portalRouter`, `PortalDeps`, `PaymentClient`, `PaymentResult` | +| `src/paths.ts` | Installation-root resolution (cwd-independent) | `PROJECT_ROOT` | +| `src/jobs/report-usage.ts` | Billing job: delta reporting to Stripe | `reportMonthlyUsage`, `firstOfMonthUtc`, `ReportUsageDeps` | +| `src/db/usage-repo.ts` | SQLite adapter: `usage_entries` | `SqliteUsageRepo` | +| `src/db/customer-repo.ts` | SQLite adapter: `customers` (seeds when empty; idempotent column migrations) | `SqliteCustomerRepo` | +| `src/db/pricing-store.ts` | SQLite adapter: `price_endpoints`, `tiers` (seeds when empty) | `SqlitePricingStore` | +| `src/db/billing-repo.ts` | SQLite adapter: `billing_reports`, `job_locks` | `SqliteBillingReportRepo`, `BillingReportRepo`, `JobLockRepo` | +| `src/db/invoice-repo.ts` | SQLite adapter: `invoices`, `invoice_lines` | `SqliteInvoiceRepo` | +| `src/db/session-repo.ts` | SQLite adapter: `portal_sessions` | `SqliteSessionRepo` | +| `openapi.yaml` | Public API contract; drives validation and `/docs` | — | +| `admin/` | Dependency-free admin SPA (`index.html`, `app.js`) | — | +| `portal/` | Dependency-free customer portal SPA (`index.html`, `app.js`) | — | + +## 3. Pricing engine + +### Types (`src/pricing.ts`) + +```ts +type PriceRule = + | { kind: 'free' } + | { kind: 'fixed'; fixedCents: number } + | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }; + +interface TierConfig { + id: string; name: string; + multiplier: number; // e.g. 0.5 = 50% of list + monthlyCreditCents: number; // free included usage per month + defaultRule?: PriceRule; // fallback for endpoints with no rule +} + +interface Quote { + endpointId: string; + listCents: number; // before multiplier + totalCents: number; // after multiplier — this is what is recorded + breakdown: { baseCents: number; metadataCents: number; attachmentCents: number }; +} +``` + +### `quoteCall(pricing, tierId, endpointId, usage, multiplierOverride?)` + +1. Resolve tier — throws `Unknown tier: ` (mapped to 403 by `meter`). +2. Resolve rule: rate-card rule for `endpointId`, else the tier's + `defaultRule`, else throw `No price rule for /` (403). +3. Compute breakdown: + - `free` → all zeros. + - `fixed` → `baseCents = fixedCents`. + - `variable` → `baseCents` + `perKbCents × ceil(metadataBytes/1024)` + + `perMbCents × ceil(attachmentBytes/1048576)`. Note the ceiling: 1 byte of + metadata bills a full KB unit; attachments bill per started MB. +4. `listCents` = sum of breakdown; `multiplier = multiplierOverride ?? tier.multiplier`; + `totalCents = Math.round(listCents × multiplier)`. + +`totalCents` (never `listCents`) is what the usage ledger records and what the +billing pipeline sums. + +## 4. Request lifecycle + +For `POST /v1/storage`: + +1. `express.json()` parses JSON bodies (multipart handled by the validator's + multer — 25 MB per-file cap). +2. `apiKeyAuth` (`src/auth.ts`) — `x-api-key` → `Customer` on `req.customer`, + else 401. +3. `express-openapi-validator` checks the request against `openapi.yaml` + (400 on violation). For `/v1/storage`, `parseMetadata` then JSON-parses the + `metadata` form field into `res.locals.parsedMetadata` (400 on bad JSON). +4. `meter('storage', usage, pricing)` (`src/meter.ts`): + - measures `metadataBytes` (UTF-8 length of the JSON-stringified metadata) + and `attachmentBytes` (sum of multer file sizes), + - calls `quoteCall` — pricing errors become 403, + - records a `UsageEntry` with `cents = quote.totalCents`, + - stashes the quote in `res.locals.quote`. +5. The route handler builds the `StoredItem` (in-memory list) and responds + `{ id, quote }`. + +`GET /v1/usage` is **not** metered; it summarizes the caller's month-to-date +usage and applies the tier credit via `applyMonthlyCredit`. + +## 5. Persistence (SQLite) + +Single database file (`ZAPPIER_DB`, default `zappier.db`), WAL-agnostic +better-sqlite3, all tables created with `CREATE TABLE IF NOT EXISTS` in the +repo constructors. **Seeding rule:** `customers` and pricing tables seed from +`DEFAULT_CUSTOMERS` / `DEFAULT_RATE_CARD` / `DEFAULT_TIERS` only when empty. + +| Table | Columns | Written by | +|---|---|---| +| `usage_entries` | `id`, `customer_id`, `endpoint_id`, `cents`, `metadata_bytes`, `attachment_bytes`, `timestamp_ms` | `meter()` on every priced call | +| `customers` | `id` PK, `name`, `tier_id`, `api_key` UNIQUE, `stripe_customer_id`, `multiplier_override`, `billing_type`, `email`, `password_hash`, `totp_secret`, `totp_enabled`, `balance_cents`, `email_invoicing` | Admin API, portal API | +| `price_endpoints` | `endpoint_id` PK, `rule_json` | Admin API | +| `tiers` | `id` PK, `name`, `multiplier`, `monthly_credit_cents`, `default_rule_json` | Admin API | +| `invoices` | `id` PK, `customer_id`, `period`, `status`, cents totals, `billing_type`, `po_number`, lifecycle timestamps | Admin API (generate/issue/paid + balance drawdown) | +| `invoice_lines` | `invoice_id`, `endpoint_id`, `calls`, `cents` | Invoice generation | +| `portal_sessions` | `token` PK, `customer_id`, `created_ms`, `expires_ms` | Portal auth | +| `billing_reports` | `customer_id` + `period` PK, `reported_cents` (cumulative), `reported_at_ms` | Billing job, after each successful meter event | +| `job_locks` | `name` PK, `acquired_at_ms` | Billing job run guard | + +New customer columns are added by **idempotent migrations** (`PRAGMA +table_info` guard + `ALTER TABLE ADD COLUMN`) when the repo opens an older +database — no manual migration step. + +In tests, every repo is constructed over `:memory:` databases. + +## 6. Billing pipeline + +### 6.1 Math (`src/billing/stripe.ts`) + +```ts +computeBillableCents(entries, monthlyCreditCents) + = max(0, Σ entry.cents − monthlyCreditCents) + +computeDelta(billable, previouslyReported) + = max(0, billable − previouslyReported) +``` + +`reportUsage(client, stripeCustomerId, entries, monthlyCreditCents)` is the +original whole-month reporter — **retained as public API**; the job uses the +delta path instead. + +### 6.2 The job (`src/jobs/report-usage.ts`) + +`reportMonthlyUsage(deps)` per run: + +1. **Lock:** `locks.tryAcquireLock('report-usage', 1h TTL)` — a single atomic + `INSERT … ON CONFLICT … DO UPDATE … WHERE acquired_at_ms <= now − ttl`. + Failure aborts the run; release happens in `finally`. A crashed run's lock + is taken over after the TTL. +2. **Window:** `since = firstOfMonthUtc(now)` (injectable via `deps.since` for + tests); `period = since.toISOString().slice(0, 7)` (`YYYY-MM`). +3. Per customer with a `stripeCustomerId` (others skipped silently; unknown + `tierId` warns and skips): + - `entries = usage.listFor(customer.id, since)` + - `billable = computeBillableCents(entries, tier.monthlyCreditCents)` + - `prior = billingRepo.getReportedCents(customer.id, period)` (0 for a new + month — periods are isolated by the composite PK) + - `delta = computeDelta(billable, prior)`; `delta <= 0` → log skip, continue + - `createMeterEvent({ eventName: 'zappier.api_cents', + customerId: stripeCustomerId, value: String(delta), + identifier: `${stripeCustomerId}:${period}:${billable}` })` + - **only on success:** `upsertReportedCents(customer.id, period, billable)` + — cumulative, not the delta. + +**Idempotency guarantees (reviewed design):** + +- *Re-run safety:* second run with same usage → delta 0 → no Stripe call. +- *Mid-month growth:* only the increase is sent; the identifier embeds the new + cumulative billable, so legitimate growth is never deduped away. +- *Crash between Stripe success and ledger write:* retry sends a byte-identical + event; Stripe drops it via the `identifier` (uniqueness enforced within a + rolling 24 h window). +- *Partial failure:* customer A's ledger write commits before customer B is + attempted; B's failure leaves A correctly recorded. + +CLI: `npx ts-node src/jobs/report-usage.ts` (guarded by +`require.main === module`; loads `.env` via dotenv inside `main()`). +Scheduled by the Kimi cron job "Zappier billing · report usage to Stripe" +(`17 6 * * *`, America/New_York), which runs this command daily and reports +the outcome. + +## 7. API reference + +### Public API (`/v1`, auth: `x-api-key`) + +Defined in `openapi.yaml`; interactive docs at `/docs`. + +| Operation | Method & path | Price | Notes | +|---|---|---|---| +| `status` | GET `/v1/status` | free | Health + quote echo | +| `transform` | POST `/v1/transform` | fixed | `{text}` → `{output: TEXT, quote}` | +| `storage` | POST `/v1/storage` | variable | multipart: `metadata` (JSON string), `attachments[]` (≤25 MB/file) → `{id, quote}` | +| `storage-list` | GET `/v1/storage` | free | Caller's stored items | +| `usage` | GET `/v1/usage` | unmetered | Month-to-date summary with credit applied | + +Error envelope: `{ "error": string }` with 400 (validation/metadata), +401 (bad key), 403 (unknown tier / no price rule), 413 (file over 25 MB). + +### Admin API (`/admin/api`, auth: `x-admin-key` or login session) + +| Route | Purpose | +|---|---| +| POST `/login` | `{username, password}` → session token (accounts: `ADMIN_USER`/`ADMIN_KEY`, `DEMO_ADMIN_USER`/`DEMO_ADMIN_PASSWORD`) | +| GET `/pricing` | `{ rateCard, tiers }` | +| PUT `/endpoints/:id` | Upsert a `PriceRule` (validated: 400 on bad shape) | +| DELETE `/endpoints/:id` | Remove a rule (endpoint becomes 403 for tiers without a default rule) | +| PUT `/tiers/:id` | Upsert a `TierConfig` | +| DELETE `/tiers/:id` | Remove a tier | +| GET `/customers` | List customers **without** API keys | +| POST `/customers` | Create `{name, tierId}` → full customer incl. generated `apiKey` (201, shown once) | +| PUT `/customers/:id` | Patch `name` / `tierId` / `multiplierOverride` / `stripeCustomerId` / `billingType` / `email` | +| POST `/invoices/generate` | `{period, customerId?, poNumber?}` — drafts per customer with usage; regenerating replaces drafts, skips issued/paid → `{generated, skipped}` | +| GET `/invoices` | Filters: `customerId`, `period`, `status` | +| GET `/invoices/:id` | JSON, or print-ready HTML with `?format=html` | +| POST `/invoices/:id/issue` | draft → issued (PO gets 30-day due date). **Prepaid drawdown:** if the customer's `balanceCents` fully covers `billableCents`, the balance is deducted and the invoice is saved as paid instead | +| POST `/invoices/:id/paid` | issued → paid | +| GET `/reports/billing` | `from`/`to`/`customerId`/`billingType` filters; JSON rows or `format=csv` | +| GET `/reports/usage-trend` | `bucket=day\|week`, same range filters | +| GET `/zapier/status` | Zapier app dir presence, version, triggers, creates | + +### Portal API (`/portal/api`, auth: Bearer session) + +| Route | Purpose | +|---|---| +| POST `/signup` | `{name, email, password≥8}` → 201 `{token, customer}`. New customer on `free` with instant API key; an email match on a passwordless (admin-created) customer **claims** that account; 409 when the email already has a password | +| POST `/login` | `{email, password, totpCode?}` → `{token, customer}`; 401 `totp_required` when 2FA is on and the code is missing/wrong | +| POST `/logout` | Deletes the session | +| GET `/me` | Public profile — never includes `passwordHash`/`totpSecret` | +| POST `/api-key` | Regenerates the API key (old key dies immediately) | +| GET `/usage` | Month-to-date summary with tier credit applied | +| GET `/pricing` | Live rate card + tiers for the pricing page | +| GET `/invoices` · GET `/invoices/:id` | Own invoices only (others 404); `?format=html` print view | +| POST `/2fa/setup` | Generates + stores a TOTP secret (not yet enabled) → `{secret, uri, qr}` (QR as PNG data URL via `qrcode`) | +| POST `/2fa/enable` · POST `/2fa/disable` | `{code}` verified against the stored secret | +| POST `/reload` | `{amountCents}` integer $1–$10,000 via the injected `PaymentClient` — dev client credits instantly; Stripe client returns a `clientSecret` and credits on confirmation | +| PUT `/email-invoicing` | `{enabled}` preference | + +Sessions live in `portal_sessions` (7-day TTL) and survive restarts. +`src/accounts.ts` implements scrypt hashing (`scrypt:N:r:p:salt:hash`, +timing-safe compare) and RFC 6238 TOTP (HMAC-SHA1, 30 s step, 6 digits, +±1 step window) with no external crypto dependency. + +## 8. Zapier app + +`zapier-app/` — Zapier Platform (core ^19), CommonJS, mocha tests. + +| File | Purpose | +|---|---| +| `index.js` | App definition; wires auth, trigger, action | +| `authentication.js` | API-key auth; test call against `/v1/status` | +| `triggers/new_item.js` | Polling trigger: `GET /v1/storage`, newest first, dedupe by `id` | +| `creates/store_data.js` | Action: multipart `POST /v1/storage` (form-data), fields: metadata JSON + optional files | +| `test/` | mocha suite (4 tests): auth, trigger, action | + +Publish flow: `zapier login` → `zapier push` → invite users / submit for +review. The app's base URL must point at a publicly reachable deployment of +the API server. + +## 9. Testing + +```bash +npm test # jest, repo root — 165 tests / 22 suites +npx tsc --noEmit # type gate +cd zapier-app && npm test # mocha — 4 tests +``` + +Conventions: + +- **TDD** throughout; every module has in-memory adapters so tests never touch + disk or network. +- HTTP tests use **supertest** against `buildApp()` with in-memory repos. +- SQLite tests use `:memory:` databases. +- Stripe is faked by implementing `MeterEventClient`; the idempotency suite + (`tests/report-usage-idempotency.test.ts`) simulates growth, re-runs, month + rollover, Stripe throws, lock contention, and partial failure. +- The job is tested via the injectable `reportMonthlyUsage(deps)` — never by + executing `main()`. + +## 10. Configuration + +| Env var | Default | Used by | +|---|---|---| +| `PORT` | `3000` | `src/index.ts` | +| `ZAPPIER_DB` | `/zappier.db` | `src/index.ts`, billing job | +| `ADMIN_KEY` | `admin-dev-key` | `adminAuth()` | +| `ADMIN_USER` | `admin` | Admin login | +| `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | `demo` / `$$$Adm1n###` | Demo admin login | +| `STRIPE_SECRET_KEY` | — | Billing job; portal reloads when it starts with `sk_` (otherwise a dev payment client credits instantly) | + +Both `src/index.ts` and the billing job load `.env` from the installation +root (`src/paths.ts` `PROJECT_ROOT`) — never from the process cwd — so the +compiled server and the job run from any working directory. +`.env` is gitignored (`chmod 600`); `.env.example` documents the shape. +Git identity is configured repo-local; `.gitignore` covers `node_modules/`, +`dist/`, `.env`, `zappier.db*`. + +## 11. Extending the system + +**Add an API endpoint:** +1. Add the path + `operationId` to `openapi.yaml` (validation & docs follow + automatically). +2. Add the route in `src/app.ts`, wrapping the handler with + `meter('', usage, pricing)`. +3. Add a rate-card rule (admin UI or `PUT /admin/api/endpoints/`) + — otherwise tiers without a `defaultRule` get 403. +4. Write the failing test first; keep `npm test` + `tsc` green. + +**Add a customer type:** admin UI or `PUT /admin/api/tiers/:id` +(`{name, multiplier, monthlyCreditCents, defaultRule?}`). + +**Swap the storage backend:** implement `UsageRepo` / `CustomerRepo` / +`PricingStore` against your database and pass them to `buildApp({...})` — no +other code changes. Same for `BillingReportRepo`/`JobLockRepo` in the job. + +**Change the billing cadence:** the job is safe at any frequency (delta + +ledger + lock). The Kimi cron job controls scheduling; update its cron +expression to change cadence. + +**Known intentional limitations:** stored items are in-memory (restart clears +them; usage ledger is unaffected); `reportUsage` is retained but superseded by +the delta path; Stripe identifier dedup covers a rolling 24 h window; +`releaseLock` is not owner-scoped (harmless at this job's runtime); admin +session tokens are in-memory (portal sessions are persisted); Stripe reloads +credit the balance only after payment confirmation (no webhook endpoint yet — +dev client credits instantly); email invoicing stores the preference but +sending requires SMTP wiring (deferred); PO invoices with partial prepaid +coverage are not partially paid by design. diff --git a/docs/DEVELOPER.pdf b/docs/DEVELOPER.pdf new file mode 100644 index 0000000..28e1563 Binary files /dev/null and b/docs/DEVELOPER.pdf differ diff --git a/docs/USER-MANAGEMENT.md b/docs/USER-MANAGEMENT.md new file mode 100644 index 0000000..55a504c --- /dev/null +++ b/docs/USER-MANAGEMENT.md @@ -0,0 +1,111 @@ +# User Management Walkthrough + +How the company manages pricing, customer types, and customer accounts in the +**admin console** at `/admin`. + +> Audience: operations staff. For invoicing/reports see `ACCOUNTING.md`; for +> the end-user view see `CUSTOMER-PORTAL.md`. + +--- + +## 1. Admin accounts + +Admin sign-ins live in the `admin_users` table — passwords are stored as +**scrypt hashes**, never in plain text. On an empty database the table is +seeded from the environment: + +| Account | Username | Password (dev default) | Env override | +|---|---|---|---| +| Primary admin | `admin` | `admin-dev-key` | `ADMIN_USER` / `ADMIN_KEY` | +| Demo / stakeholder | `demo` | `$$$Adm1n###` | `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | + +The seed runs **only when the table is empty** — after that, accounts are +managed in the console and survive restarts (SQLite) and env changes. + +Sign-in issues a session token; API access is also possible with the +`x-admin-key: $ADMIN_KEY` header (used by automation). + +## 2. Users tab (admin account management) + +The **Users** tab lists every admin account and manages their lifecycle: + +![Admin users](screenshots/admin-users.png) + +- **Create** — username (letters, digits, `.` `_` `-`) plus a password of at + least 8 characters. The new account can sign in immediately. +- **Deactivate / Activate** — a deactivated account is blocked from signing in + right away (401), and reactivation restores access. Deactivation persists in + the database. +- **Safety guard** — the console refuses to deactivate the **last active + admin**, so you can never lock everyone out. + +Existing sessions stay valid until sign-out or server restart; deactivation +blocks *new* logins. + +## 3. Rate card (per-endpoint pricing) + +The **Rate card** tab sets list prices per API operation. Changes apply to the +**next API call** — no restart. + +![Rate card](screenshots/admin-rate-card.png) + +Three price kinds: + +- **free** — never charged (e.g. `status`, `storage-list`). +- **fixed** — a flat `fixedCents` per call (e.g. `transform` at 4¢). +- **variable** — `baseCents` per call plus size-based charges: + `perKbCents` per KB of metadata and `perMbCents` per MB of file attachments + (e.g. `storage`: 10¢ + 1¢/KB + 50¢/MB). This is how storing data with + metadata or attachments is priced by size. + +Add an endpoint with its `operationId` from `openapi.yaml`. Deleting an +endpoint makes calls to it fail with 403 unless the customer's type has a +default rule. + +## 4. Customer types (tiers) + +The **Customer types** tab defines plans: + +![Customer types](screenshots/admin-tiers.png) + +- **Multiplier** — scales every list price (0.5 = 50% of list, 0.25 = 75% off). +- **Monthly credit (cents)** — free included usage per month, consumed before + anything is billable. + +Defaults: `free` (1×, $1 credit), `pro` (0.5×, $10 credit, 8¢ default rule), +`business` (0.25×, $100 credit, 8¢ default rule). + +## 5. Customers + +The **Customers** tab manages individual accounts: + +![Customers](screenshots/admin-customers.png) + +- **Type** — assign any tier. +- **Multiplier override** — a per-customer deal that replaces the tier + multiplier (e.g. a strategic account at 0.2×). +- **Email** — used for portal login/claiming and email invoicing. +- **Billing** — `Stripe` (metered via the billing job) or `Purchase order` + (manual invoicing with PO numbers and 30-day terms). +- **Create** — generates a customer id and API key. **The API key is shown + once** in the notification — copy it immediately. + +Customers created here can **claim** their portal account: the first signup at +`/portal` with a matching email sets their password on the existing account +instead of creating a new one. + +## 6. Admin API reference + +Everything the UI does is available over HTTP (`x-admin-key` or session +Bearer): + +- `POST /admin/api/login` +- `GET /admin/api/users` · `POST /admin/api/users` + · `POST /admin/api/users/:username/activate|deactivate` +- `GET /admin/api/pricing` · `PUT/DELETE /admin/api/endpoints/:id` +- `PUT/DELETE /admin/api/tiers/:id` +- `GET/POST /admin/api/customers` · `PUT /admin/api/customers/:id` +- `POST /admin/api/invoices/generate` · `GET /admin/api/invoices` + · `POST /admin/api/invoices/:id/issue|paid` +- `GET /admin/api/reports/billing` · `GET /admin/api/reports/usage-trend` +- `GET /admin/api/zapier/status` diff --git a/docs/USER-MANAGEMENT.pdf b/docs/USER-MANAGEMENT.pdf new file mode 100644 index 0000000..054359f Binary files /dev/null and b/docs/USER-MANAGEMENT.pdf differ diff --git a/docs/USER-MANUAL.md b/docs/USER-MANUAL.md new file mode 100644 index 0000000..51c013b --- /dev/null +++ b/docs/USER-MANUAL.md @@ -0,0 +1,348 @@ +# Zappier — Operations & Usage Manual + +**Version:** 0.2.0 · **Last updated:** 2026-07-28 + +Zappier is a metered API platform: every API call your customers make is priced +per endpoint, adjusted by their customer type, tracked in a usage ledger, and +billed through Stripe once a day — or invoiced manually by purchase order. +Customers self-serve through the portal at `/portal`. This manual covers +running and operating the system. For internals, see +[DEVELOPER.md](DEVELOPER.md); for accounting procedures see +[ACCOUNTING.md](ACCOUNTING.md); for the end-user view see +[CUSTOMER-PORTAL.md](CUSTOMER-PORTAL.md). + +--- + +## Table of contents + +1. [Quick start](#1-quick-start) +2. [The three surfaces](#2-the-three-surfaces) +3. [How pricing works](#3-how-pricing-works) +4. [Operating the Pricing Admin UI](#4-operating-the-pricing-admin-ui) +5. [Using the public API](#5-using-the-public-api) +6. [Billing operations (Stripe)](#6-billing-operations-stripe) +7. [The Zapier integration](#7-the-zapier-integration) +8. [Day-to-day runbook](#8-day-to-day-runbook) +9. [Troubleshooting](#9-troubleshooting) + +--- + +## 1. Quick start + +```bash +cd /Users/marchon/zappier +npm install +npm run dev # starts the API on http://localhost:3000 +``` + +Environment variables (all optional except `STRIPE_SECRET_KEY` for billing): + +| Variable | Default | Purpose | +|---|---|---| +| `PORT` | `3000` | HTTP port for the API server | +| `ZAPPIER_DB` | `/zappier.db` | SQLite database file location | +| `ADMIN_KEY` | `admin-dev-key` | Key for the admin console and admin API | +| `ADMIN_USER` | `admin` | Admin console primary username | +| `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | `demo` / `$$$Adm1n###` | Demo sign-in — override in production | +| `STRIPE_SECRET_KEY` | — (required for billing) | Billing job + portal reloads, loaded from `.env` | + +All runtime paths (database default, `.env`, OpenAPI spec, static assets) +resolve from the installation root — the compiled server (`node +dist/index.js`) runs from any working directory, under systemd, Docker, or cron. + +The `.env` file at the repo root holds `STRIPE_SECRET_KEY`. It is gitignored +and owner-only (`chmod 600`). A template is in `.env.example`. + +On first start the database is created and seeded with: + +- **Rate card:** `status` (free), `storage-list` (free), `transform` (fixed 4¢), + `storage` (variable: 10¢ base + 1¢/KB metadata + 50¢/MB attachments) +- **Customer types:** Free (×1.0, 100¢/month credit), Pro (×0.5, 1000¢ credit), + Business (×0.25, 10000¢ credit) +- **Demo customers:** `key-ada` (Free), `key-grace` (Pro), `key-linus` (Business) + +> Seeding only happens into an **empty** database. Existing data is never +> overwritten on restart. + +--- + +## 2. The three surfaces + +| Surface | URL / location | Who it's for | +|---|---|---| +| **Public API** | `http://localhost:3000/v1/*` | Your API customers | +| **Interactive API docs** | `http://localhost:3000/docs` | Developers integrating with you | +| **Admin console** | `http://localhost:3000/admin` | You (operations & accounting) | +| **Customer portal** | `http://localhost:3000/portal` | End-user customers (self-service) | +| **Zapier app** | `zapier-app/` directory | No-code users via Zapier | + +![Interactive API docs](screenshots/api-docs.png) + +--- + +## 3. How pricing works + +Every priced call returns its **quote** in the response, so customers always +know what a call cost: + +```json +{ + "quote": { + "endpointId": "storage", + "listCents": 62, + "totalCents": 31, + "breakdown": { "baseCents": 10, "metadataCents": 2, "attachmentCents": 50 } + } +} +``` + +The price of a call is computed in three steps: + +1. **Endpoint rule** (from the rate card): + - `free` — always 0¢ + - `fixed` — a flat `fixedCents` per call + - `variable` — `baseCents` + `perKbCents` × ceil(metadata bytes / 1024) + + `perMbCents` × ceil(attachment bytes / 1 MB) +2. **Customer-type multiplier** — `totalCents = round(listCents × multiplier)`. + A per-customer **multiplier override** (set in the Customers tab) wins over + the type multiplier — use it for negotiated enterprise deals. +3. **Monthly credit** — at billing time, each customer's type credit + (e.g. Pro = 1000¢) is subtracted from their month-to-date total. Only the + excess is billed. + +**Worked example.** A Pro customer (×0.5) uploads a 1 MB file with 2 KB of +metadata to `storage`: + +- list = 10¢ base + 2¢ metadata + 50¢ attachment = **62¢** +- Pro multiplier: 62 × 0.5 = **31¢** charged to their usage ledger +- If their month-to-date is 1500¢ and the Pro credit is 1000¢, the daily + billing job reports **500¢** to Stripe. + +--- + +## 4. Operating the Pricing Admin UI + +Open `http://localhost:3000/admin` and sign in. Two accounts are available: + +| Username | Password | Purpose | +|---|---|---| +| `admin` | the `ADMIN_KEY` env value (default `admin-dev-key`) | Primary operator | +| `demo` | `$$$Adm1n###` (env `DEMO_ADMIN_PASSWORD`) | Demo / stakeholder access | + +Sessions are token-based and remembered in browser local storage until you +click **Sign out** or the server restarts (tokens are in-memory — just sign in +again). The legacy `x-admin-key` header still works for scripts and curl. + +### 4.1 Rate card tab + +![Rate card](screenshots/admin-rate-card.png) + +One row per API endpoint (matched by OpenAPI `operationId`). + +- **Change a price:** edit the kind (`free` / `fixed` / `variable`) and the + cent fields, then click **Save**. Takes effect on the next API call — no + restart needed. +- **Add an endpoint:** enter the `operationId` (must match `openapi.yaml`), + pick a kind, click **Add**. +- **Delete** removes the rule. If an endpoint has no rule and the customer's + type has no default rule, calls to it are rejected with 403 — deletion is + how you turn an endpoint **off**. + +### 4.2 Customer types tab + +![Customer types](screenshots/admin-tiers.png) + +Types are your pricing tiers. + +- **Multiplier** scales every price for that type (0.5 = 50% of list). +- **Monthly credit (cents)** is the free included usage per month. +- **Add customer type:** id, name, multiplier. New types start with 0 credit; + edit after adding. + +### 4.3 Customers tab + +![Customers](screenshots/admin-customers.png) + +- **Create** a customer: name + type. **The API key is shown once** in the + status line at the bottom — copy it immediately and send it to the customer. +- **Change type** with the dropdown, then **Save**. +- **Email** — used for portal sign-in/claiming and email invoicing. +- **Billing** — `Stripe` (metered by the daily job) or `Purchase order` + (manual invoicing with PO numbers; see [ACCOUNTING.md](ACCOUNTING.md)). +- **Multiplier override**: a number here replaces the type multiplier for this + customer only. Leave blank to inherit from the type. +- Stripe customer IDs are attached via the admin API + (`PUT /admin/api/customers/:id` with `{"stripeCustomerId": "cus_..."}`) — + see section 6. + +### 4.4 Invoices, Reports, System tabs + +The **Invoices** tab generates monthly invoices from metered usage (per period, +optionally per customer, with optional PO number), walks them +draft → issued → paid, and opens print-ready invoice pages. The **Reports** tab +produces date-ranged billing reports (all customers, one customer, or one +billing type) with CSV download, plus daily/weekly usage-trend charts. The +**System** tab shows Zapier integration health and the current-period billing +snapshot. Full procedures: [ACCOUNTING.md](ACCOUNTING.md). + +![Invoices](screenshots/admin-invoices.png) + +--- + +## 4A. Customer portal + +Your customers self-serve at `http://localhost:3000/portal`: signup (or +claiming an account you created, by email), sign-in with optional TOTP +two-factor authentication, month-to-date usage, invoice history with print +view, prepaid balance reloads (drawn down automatically at invoice issue), +email-invoicing preferences, API-key regeneration, and live pricing. The full +end-user guide is [CUSTOMER-PORTAL.md](CUSTOMER-PORTAL.md). + +--- + +## 5. Using the public API + +All calls need the customer's API key in the `x-api-key` header. Interactive +docs with a "Try it out" console are at `/docs`. + +```bash +# Free status check +curl -H 'x-api-key: key-ada' http://localhost:3000/v1/status + +# Fixed-price call (4¢ list) +curl -X POST -H 'x-api-key: key-grace' -H 'content-type: application/json' \ + -d '{"text":"hello"}' http://localhost:3000/v1/transform + +# Variable-price call: metadata + attachments +curl -X POST -H 'x-api-key: key-grace' \ + -F 'metadata={"title":"Q3 report"}' \ + -F 'attachments=@report.pdf' \ + http://localhost:3000/v1/storage + +# List your stored items (free) +curl -H 'x-api-key: key-grace' http://localhost:3000/v1/storage + +# Your month-to-date usage, with credit applied +curl -H 'x-api-key: key-grace' http://localhost:3000/v1/usage +``` + +Limits & validation: requests are validated against `openapi.yaml` (bad +requests get 400); attachments are capped at **25 MB per file**; `metadata` +must be valid JSON (400 otherwise). + +--- + +## 6. Billing operations (Stripe) + +### 6.1 How it works + +A scheduled job runs the billing reporter **daily at 06:17 America/New_York** +(Kimi cron job "Zappier billing · report usage to Stripe"). For each customer +with a Stripe ID it: + +1. Sums their usage since the 1st of the month, subtracts their type's monthly + credit → **billable cents**. +2. Reports only the **delta** above what was already reported this month to + Stripe as a meter event (`zappier.api_cents`, value = cents). +3. Records the new cumulative total in the `billing_reports` ledger. + +Re-running is always safe: the ledger makes repeats no-ops, a per-run lock +prevents overlapping executions, and a deterministic Stripe `identifier` +(`customer:period:billable`) dedupes crash retries. + +### 6.2 One-time Stripe setup (test mode) + +1. Dashboard (test mode ON) → **Billing → Meters → Create meter**: + event name `zappier.api_cents`, aggregation **Sum** of `value`, + customer mapping `stripe_customer_id`. +2. **Product catalog → Add product** "Zappier API usage" → price: recurring, + monthly, metered against that meter, **$0.01 per unit** (1 unit = 1 cent). +3. For each billable customer: create the Stripe Customer, attach a payment + method, and add a **subscription** with the metered price. Meter events for + customers without a metered subscription are recorded but never invoiced. +4. Put the `sk_test_...` key into `.env` (replace the placeholder). +5. Attach Stripe IDs to Zappier customers: + + ```bash + curl -X PUT -H 'x-admin-key: admin-dev-key' -H 'content-type: application/json' \ + -d '{"stripeCustomerId":"cus_..."}' \ + http://localhost:3000/admin/api/customers/cust_2 + ``` + +### 6.3 Verifying a run + +```bash +npx ts-node src/jobs/report-usage.ts +``` + +Expected output per customer: + +- `skip (nothing to report)` — no billable usage yet +- `skip (already reported Nc)` — no new usage since last run +- `: reported N billable cents to Stripe` — delta sent +- `report-usage: another run holds the lock, abort run` — safe concurrent abort + +Then check the meter's **Events** tab in the Stripe dashboard and the test +customer's **upcoming invoice**. + +### 6.4 Going live + +Repeat 6.2 steps 1–3 in live mode, replace `.env` with the `sk_live_...` key +from the **same Stripe account**, and keep the same cron. The live product +`prod_Uxv9SAeIOZzyx1` (currently deactivated) can be reactivated or recreated. + +--- + +## 7. The Zapier integration + +The `zapier-app/` directory contains the Zapier Platform app: + +- **Authentication:** API key — the user pastes their API base URL + (e.g. `http://localhost:3000`) and their `key-...` customer key; the + connection is tested against `/v1/status`. +- **Trigger "New Item":** polls `GET /v1/storage` for newly stored items. +- **Action "Store Data":** calls `POST /v1/storage` with metadata and optional + file attachments — billed per the rate card. + +To publish: create a Zapier developer account, `cd zapier-app && npm install +&& zapier login && zapier push`, then share the app or submit it to the Zapier +marketplace. + +--- + +## 8. Day-to-day runbook + +| Task | How | +|---|---| +| Change a price | Admin console → Rate card → Save | +| Add a customer | Admin console → Customers → Create → copy the one-time API key | +| Set a customer's billing type/email | Customers tab → Billing dropdown / Email field → Save | +| Invoice a period | Invoices tab → Generate → Issue → Mark paid (ACCOUNTING.md) | +| Export accounting data | Reports tab → filters → Download CSV | +| Give a customer a deal | Customers tab → multiplier override, or a new customer type | +| Turn an endpoint off | Rate card → Delete (calls get 403) | +| Check a customer's usage | Their portal dashboard, `GET /v1/usage` with their key, or query `zappier.db` | +| Check billing ran | Kimi notification after each 06:17 run; or run the job manually | +| Backup | Copy `zappier.db` (SQLite, single file) | +| Update dependencies | `npm outdated`, then `npm test` must stay green (165 tests) | + +--- + +## 9. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `401 invalid or missing API key` | Wrong/absent `x-api-key` | Re-issue key from Customers tab | +| `403 No price rule for ...` | Endpoint deleted from rate card and tier has no default rule | Re-add the rule, or set a tier `defaultRule` | +| `403 Unknown tier` | Customer's `tierId` doesn't exist | Fix the customer's type in the admin UI | +| `400 invalid metadata JSON` | `metadata` form field isn't valid JSON | Send e.g. `{"key":"value"}` | +| `413 ... file too large` | Attachment over 25 MB | Split or compress the file | +| Billing run: Stripe auth error | Placeholder/wrong key in `.env`, or key from a different Stripe account | Use the `sk_test`/`sk_live` key from the account that holds the meter | +| Stripe shows `METER_NOT_FOUND` invalid events | Meter missing or event name mismatch | Meter event name must be exactly `zappier.api_cents` | +| `another run holds the lock, abort run` | Overlapping runs, or a crash left a stale lock | Safe by design; stale locks expire after 1 hour | +| Admin UI: "invalid username or password" | Wrong credentials, or env overrides changed them | Check `ADMIN_KEY` / `DEMO_ADMIN_PASSWORD`; sign in again | +| Admin UI: "Session expired" | Server restarted (admin tokens are in-memory) | Sign in again | +| Portal login: `totp_required` | 2FA is enabled on the account | Enter the current 6-digit authenticator code | +| Portal: "invalid or expired session" | 7-day session expired | Sign in again | +| Portal reload didn't credit | Real `STRIPE_SECRET_KEY` configured → PaymentIntent awaits confirmation | Balance credits when the payment confirms; in dev (placeholder key) credit is instant | +| Invoice went straight to `paid` on Issue | Customer's prepaid balance fully covered the amount due | Working as designed — balance was drawn down | diff --git a/docs/USER-MANUAL.pdf b/docs/USER-MANUAL.pdf new file mode 100644 index 0000000..7f37d9b Binary files /dev/null and b/docs/USER-MANUAL.pdf differ diff --git a/docs/WALKTHROUGH.md b/docs/WALKTHROUGH.md new file mode 100644 index 0000000..74dad54 --- /dev/null +++ b/docs/WALKTHROUGH.md @@ -0,0 +1,154 @@ +# Zappier — Step-by-Step Usage Walkthrough + +**Version:** 0.1.0 · **Last updated:** 2026-07-27 + +A full guided tour: from starting the server to pricing changes, customer +creation, and live API calls. Every step shows the real screen you should see. +Companion documents: [USER-MANUAL.md](USER-MANUAL.md) · +[DEVELOPER.md](DEVELOPER.md). + +--- + +## Step 1 — Start the server + +```bash +cd /Users/marchon/zappier +npm install # first time only +npm run dev +``` + +Wait for the two "listening" lines: + +![Start the server](walkthrough/01-start-server.png) + +--- + +## Step 2 — Open the admin UI and sign in + +Go to **http://localhost:3000/admin**. You'll see the sign-in screen: + +![Sign in](walkthrough/02-login.png) + +Two accounts are available: + +| Username | Password | Purpose | +|---|---|---| +| `admin` | the `ADMIN_KEY` env value (default `admin-dev-key`) | Primary operator | +| `demo` | `$$$Adm1n###` | Demo / stakeholder access | + +> Override either credential with the `ADMIN_USER`, `ADMIN_KEY`, +> `DEMO_ADMIN_USER`, and `DEMO_ADMIN_PASSWORD` environment variables. + +--- + +## Step 3 — The rate card + +After sign-in you land on the **Rate card** — one row per API endpoint with +its price in cents: + +![Rate card](walkthrough/03-rate-card.png) + +- `status`, `storage-list` — **free** +- `transform` — **fixed** price per call +- `storage` — **variable**: base + per-KB metadata + per-MB attachments + +--- + +## Step 4 — Change a price + +Edit any cent field — here `transform` is changed from **4¢ to 6¢** — and click +**Save**. The change is live on the very next API call; no restart, no deploy. + +![Edit a price](walkthrough/04-edit-price.png) + +After saving, the table re-reads from the server and shows the new value: + +![Price saved](walkthrough/04b-saved-toast.png) + +--- + +## Step 5 — Customer types + +Click **Customer types** in the sidebar. Each type is a pricing tier: +a **multiplier** applied to every list price and a **monthly credit** of free +included usage (cents). + +![Customer types](walkthrough/05-tiers.png) + +--- + +## Step 6 — Add a customer type + +Fill the **Add customer type** form — here `edu` / Education / ×0.6 — and click +**Add**. The new type appears immediately and can be assigned to customers. + +![Add customer type](walkthrough/06-add-tier.png) + +> New types start with 0 monthly credit — edit the row and **Save** to grant one. + +--- + +## Step 7 — Customers + +Click **Customers** in the sidebar. This is where accounts live: their type, +and an optional **multiplier override** for per-customer deals (blank = +inherit from type). + +![Customers](walkthrough/07-customers.png) + +--- + +## Step 8 — Create a customer and copy the API key + +Enter a name, pick a type, click **Create**. The API key appears **once** in +the notification at the bottom-right — copy it and send it to the customer; +it is never shown again. + +![API key shown once](walkthrough/08b-api-key-toast.png) + +The new customer appears in the table right away: + +![Customer created](walkthrough/08-create-customer.png) + +--- + +## Step 9 — Explore the interactive API docs + +Open **http://localhost:3000/docs** — full Swagger docs with a "Try it out" +console. Click **Authorize** and paste a customer API key to make live calls +from the browser. + +![API docs](walkthrough/09-api-docs.png) + +--- + +## Step 10 — Make an API call + +Call the API with a customer key. Every priced response includes its **quote**, +so the cost of every call is transparent: + +![API call with quote](walkthrough/10-api-call.png) + +Note how the quote reflects the walkthrough itself: the 6¢ price set in step 4, +halved to 3¢ by Grace's Pro multiplier. + +--- + +## Step 11 — Check usage and credits + +Customers can check their own month-to-date usage anytime: + +![Usage summary](walkthrough/11-usage.png) + +`includedCents` is covered by the type's monthly credit; `billableCents` is +what the daily billing job would report to Stripe right now. + +--- + +## Where to go next + +- **Daily billing** runs automatically at 06:17 ET — see + [USER-MANUAL.md §6](USER-MANUAL.md#6-billing-operations-stripe) for the + Stripe meter/product/price setup and how to verify a run. +- **Troubleshooting:** [USER-MANUAL.md §9](USER-MANUAL.md#9-troubleshooting). +- **Internals:** [DEVELOPER.md](DEVELOPER.md). diff --git a/docs/WALKTHROUGH.pdf b/docs/WALKTHROUGH.pdf new file mode 100644 index 0000000..674cbb9 Binary files /dev/null and b/docs/WALKTHROUGH.pdf differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..72ecace --- /dev/null +++ b/docs/index.html @@ -0,0 +1,115 @@ + + + + + + Zappier — Documentation + + + +
    + Z +

    Zappier Documentation

    +

    Metered API platform — pricing, billing, invoicing, customer portal, and Zapier integration.

    +
    +
    +

    Operations

    + + +

    For your customers

    + + +

    Technical

    + +
    +
    Zappier · generated 2026-07-28
    + + diff --git a/docs/screenshots/admin-customers.png b/docs/screenshots/admin-customers.png new file mode 100644 index 0000000..fed2721 Binary files /dev/null and b/docs/screenshots/admin-customers.png differ diff --git a/docs/screenshots/admin-invoice-html.png b/docs/screenshots/admin-invoice-html.png new file mode 100644 index 0000000..a459545 Binary files /dev/null and b/docs/screenshots/admin-invoice-html.png differ diff --git a/docs/screenshots/admin-invoices-generate.png b/docs/screenshots/admin-invoices-generate.png new file mode 100644 index 0000000..9e2ecc5 Binary files /dev/null and b/docs/screenshots/admin-invoices-generate.png differ diff --git a/docs/screenshots/admin-invoices.png b/docs/screenshots/admin-invoices.png new file mode 100644 index 0000000..9e2ecc5 Binary files /dev/null and b/docs/screenshots/admin-invoices.png differ diff --git a/docs/screenshots/admin-login.png b/docs/screenshots/admin-login.png new file mode 100644 index 0000000..ef99111 Binary files /dev/null and b/docs/screenshots/admin-login.png differ diff --git a/docs/screenshots/admin-rate-card.png b/docs/screenshots/admin-rate-card.png new file mode 100644 index 0000000..9c0660b Binary files /dev/null and b/docs/screenshots/admin-rate-card.png differ diff --git a/docs/screenshots/admin-reports.png b/docs/screenshots/admin-reports.png new file mode 100644 index 0000000..1a821f8 Binary files /dev/null and b/docs/screenshots/admin-reports.png differ diff --git a/docs/screenshots/admin-system.png b/docs/screenshots/admin-system.png new file mode 100644 index 0000000..f56179a Binary files /dev/null and b/docs/screenshots/admin-system.png differ diff --git a/docs/screenshots/admin-tiers.png b/docs/screenshots/admin-tiers.png new file mode 100644 index 0000000..e6acf1b Binary files /dev/null and b/docs/screenshots/admin-tiers.png differ diff --git a/docs/screenshots/admin-users.png b/docs/screenshots/admin-users.png new file mode 100644 index 0000000..a0132c1 Binary files /dev/null and b/docs/screenshots/admin-users.png differ diff --git a/docs/screenshots/api-docs.png b/docs/screenshots/api-docs.png new file mode 100644 index 0000000..4232d35 Binary files /dev/null and b/docs/screenshots/api-docs.png differ diff --git a/docs/screenshots/portal-2fa-setup.png b/docs/screenshots/portal-2fa-setup.png new file mode 100644 index 0000000..e517573 Binary files /dev/null and b/docs/screenshots/portal-2fa-setup.png differ diff --git a/docs/screenshots/portal-billing.png b/docs/screenshots/portal-billing.png new file mode 100644 index 0000000..dedff80 Binary files /dev/null and b/docs/screenshots/portal-billing.png differ diff --git a/docs/screenshots/portal-dashboard.png b/docs/screenshots/portal-dashboard.png new file mode 100644 index 0000000..1497667 Binary files /dev/null and b/docs/screenshots/portal-dashboard.png differ diff --git a/docs/screenshots/portal-docs.png b/docs/screenshots/portal-docs.png new file mode 100644 index 0000000..e7a83a5 Binary files /dev/null and b/docs/screenshots/portal-docs.png differ diff --git a/docs/screenshots/portal-invoice-html.png b/docs/screenshots/portal-invoice-html.png new file mode 100644 index 0000000..300469b Binary files /dev/null and b/docs/screenshots/portal-invoice-html.png differ diff --git a/docs/screenshots/portal-invoices.png b/docs/screenshots/portal-invoices.png new file mode 100644 index 0000000..221b4e1 Binary files /dev/null and b/docs/screenshots/portal-invoices.png differ diff --git a/docs/screenshots/portal-login.png b/docs/screenshots/portal-login.png new file mode 100644 index 0000000..3ccf866 Binary files /dev/null and b/docs/screenshots/portal-login.png differ diff --git a/docs/screenshots/portal-security.png b/docs/screenshots/portal-security.png new file mode 100644 index 0000000..c42289f Binary files /dev/null and b/docs/screenshots/portal-security.png differ diff --git a/docs/screenshots/portal-signup.png b/docs/screenshots/portal-signup.png new file mode 100644 index 0000000..3d11d27 Binary files /dev/null and b/docs/screenshots/portal-signup.png differ diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 0000000..5f0d0db Binary files /dev/null and b/docs/superpowers/.DS_Store differ diff --git a/docs/superpowers/plans/2026-07-27-accounting-portal.md b/docs/superpowers/plans/2026-07-27-accounting-portal.md new file mode 100644 index 0000000..e06f281 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-accounting-portal.md @@ -0,0 +1,163 @@ +# Accounting, User Management & Customer Portal — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Extend Zappier with three surfaces: (a) company accounting & system management, (b) internal user management, and (c) an end-user customer portal — with full documentation and screenshot walkthroughs for each. + +**Architecture:** Same ports-and-adapters style as the existing codebase. New domain modules (`src/invoicing.ts`, `src/reports.ts`, `src/accounts.ts`) expose pure logic + repo interfaces; SQLite adapters live in `src/db/`; HTTP wiring goes into `src/admin.ts` (company side) and a new `src/portal.ts` (customer side). The admin SPA gains tabs; the portal is a second dependency-free SPA under `portal/`. No new runtime deps in Phase 1–2; Phase 3 adds `otplib`-style TOTP only if unavoidable — prefer `node:crypto` scrypt for passwords and a minimal TOTP implementation per RFC 6238. + +**Tech Stack:** Node 20 · TS strict · Express 4 · better-sqlite3 · jest/supertest. + +## Global Constraints + +- Money is integer cents everywhere; multipliers applied once, `Math.round`. +- Money/date logic must be pure functions, unit-tested without HTTP or SQLite. +- All SQL in `src/db/` repos only; all tables `CREATE TABLE IF NOT EXISTS`; schema changes for existing tables use idempotent `ALTER TABLE ... ADD COLUMN` guarded by `PRAGMA table_info` checks. +- Existing 87 jest tests + 4 mocha tests stay green; `npx tsc --noEmit` clean after every task. +- No servers left running after any verification; walkthrough screenshots use a throwaway `ZAPPIER_DB`. +- PDFs: Phase 1–2 deliver **print-ready HTML report/invoice pages** (browser Print → PDF, zero deps) plus CSV; binary PDF generation is deferred unless the owner asks. +- The demo password `$$$Adm1n###` and `admin-dev-key` remain dev defaults, overridable by env. + +## Surface map + +| Surface | URL | Auth | Audience | +|---|---|---|---| +| Admin SPA (extended) | `/admin` | login token / `x-admin-key` | Company ops & accounting | +| Customer portal SPA (new) | `/portal` | customer session token (+TOTP 2FA) | End-user customers | +| Public API | `/v1` | `x-api-key` | Unchanged | + +--- + +## Phase 1 — Accounting backend + +### Task 1: Schema + customer billing identity + +**Files:** +- Modify: `src/auth.ts` — `Customer` gains `billingType: 'stripe' | 'purchase_order'` (default `'stripe'`), `email?: string` +- Modify: `src/db/customer-repo.ts` — idempotent `ALTER TABLE customers ADD COLUMN billing_type TEXT NOT NULL DEFAULT 'stripe'`; same for `email TEXT`; map in `toCustomer` +- Modify: `src/admin.ts` — `PUT /customers/:id` accepts `billingType` (validated) and `email` +- Test: `tests/db-customer.test.ts`, `tests/admin.test.ts` (extend) + +**Interfaces:** +- Consumes: existing `CustomerRepo` +- Produces: `Customer.billingType`, `Customer.email` used by all later tasks + +- [x] Extend `tests/db-customer.test.ts`: migration on existing DB adds columns with `'stripe'` default; round-trip `billingType`/`email` +- [x] Extend `tests/admin.test.ts`: `PUT /customers/:id` sets `billingType: 'purchase_order'`; invalid value → 400 +- [x] Implement; run suites; commit `feat(accounting): customer billing types and email` + +### Task 2: Invoice engine + +**Files:** +- Create: `src/invoicing.ts` — types + pure generation logic + repo interface +- Create: `src/db/invoice-repo.ts` — SQLite adapter +- Test: `tests/invoicing.test.ts`, `tests/db-invoice.test.ts` + +**Interfaces:** +```ts +export interface Invoice { + id: string; // INV-2026-07- + customerId: string; + period: string; // YYYY-MM + status: 'draft' | 'issued' | 'paid'; + lines: InvoiceLine[]; // { endpointId, calls, cents } + totalCents: number; // sum of lines (gross usage) + creditCents: number; // monthly credit applied + billableCents: number; // totalCents - creditCents, floored at 0 + billingType: 'stripe' | 'purchase_order'; + poNumber?: string; // PO billing only + issuedAtMs?: number; dueAtMs?: number; paidAtMs?: number; +} +export interface InvoiceRepo { + save(invoice: Invoice): void; + get(id: string): Invoice | undefined; + list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[]; + nextSequence(period: string): number; +} +export function buildInvoice(args: { + customer: Customer; period: string; sequence: number; + entries: UsageEntry[]; tier: TierConfig; poNumber?: string; +}): Invoice; // groups entries by endpoint; status 'draft' +``` + +- [x] Tests: line grouping, credit math (partial/zero/excess), id format, PO fields +- [x] Repo tests: save/get/list filters, sequence increments per period +- [x] Implement; commit `feat(accounting): invoice engine` + +### Task 3: Reports service (billing + trends, CSV/JSON) + +**Files:** +- Create: `src/reports.ts` — `billingRows(entries, customers, tiers, range)` → rows `{customerId, name, billingType, calls, totalCents, creditCents, billableCents}`; `usageTrend(entries, bucket: 'day'|'week')` → `[{bucket, calls, cents}]`; `toCsv(rows)` with RFC-4180 escaping +- Test: `tests/reports.test.ts` + +- [x] Tests: date-range filtering (inclusive from, exclusive to), per-customer vs all, per-billingType filter, trend bucketing across month boundary, CSV quoting of commas/quotes/newlines +- [x] Implement; commit `feat(accounting): reports service` + +### Task 4: Admin accounting API + +**Files:** +- Modify: `src/admin.ts` — new routes (all behind existing admin auth): + - `POST /invoices/generate { period, customerId?, poNumber? }` → builds draft invoices for the period (all customers or one; idempotent per customer+period — regenerating replaces the draft) + - `POST /invoices/:id/issue`, `POST /invoices/:id/paid` + - `GET /invoices?customerId&period&status` + - `GET /invoices/:id` (+ `?format=html` print-ready invoice page) + - `GET /reports/billing?from&to&customerId&billingType&format=json|csv` + - `GET /reports/usage-trend?from&to&bucket&customerId` + - `GET /zapier/status` → `{ published: boolean, triggerCount, actionCount, baseUrl }` read from `zapier-app/` files (static inspection, no network) +- Test: `tests/admin-accounting.test.ts` + +- [x] Tests per route incl. CSV content-type, filter combos, invoice lifecycle transitions (draft→issued→paid; illegal transitions → 409) +- [x] Implement; commit `feat(accounting): admin accounting API` + +--- + +## Phase 2 — Admin UI: Accounting, Reports, Users tabs + +### Task 5: Invoices tab (generate, filter, lifecycle, print page) +### Task 6: Reports tab (date-range pickers, customer + billing-type filters, CSV download, trend charts as inline SVG) +### Task 7: Users tab (admin account management: list/create/deactivate admin users backed by a new `admin_users` table replacing the static two-account map; login endpoint reads the table; env seed preserved) +### Task 8: System tab (Zapier connection status, billing job last-run info from `billing_reports`/`job_locks`) + +Each: admin UI section + `tests/` coverage for any new API + screenshot verification. Commit per task. + +--- + +## Phase 3 — Customer portal (`/portal`) + +### Task 9: Customer identity +- `customers` += `password_hash`, `totp_secret`, `totp_enabled`, `email_verified` +- `src/accounts.ts`: scrypt hash/verify (`node:crypto`), session tokens (new `portal_sessions` table), signup `POST /portal/api/signup` (creates customer on `free` tier + issues API key), login `POST /portal/api/login` +- Tests: hash round-trip, signup/login flows, session expiry + +### Task 10: TOTP 2FA +- RFC 6238 TOTP (HMAC-SHA1, 30 s step, 6 digits) implemented in `src/accounts.ts` (no dep): `generateTotpSecret`, `totpUri(secret, email)`, `verifyTotp(secret, code, window=1)` +- Routes: `POST /portal/api/2fa/setup` (returns secret + otpauth URI; QR rendered client-side via a tiny inline QR lib or Google-Charts-free canvas QR — decision: render otpauth URI as text + QR via `qrcode` npm dep, portal-side only), `POST /portal/api/2fa/enable`, `POST /portal/api/2fa/verify` (login second step), `POST /portal/api/2fa/disable` +- Tests: known RFC vectors, window tolerance, login requires second factor when enabled + +### Task 11: Portal dashboard API +- `GET /portal/api/me` (profile, tier, apiKey, regenerate key `POST /portal/api/api-key`) +- `GET /portal/api/usage` (month-to-date + credit) +- `GET /portal/api/invoices` (own invoices only, scoped by session customer) +- `POST /portal/api/reload { amountCents }` — prepaid balance: `customers` += `balance_cents`; Stripe PaymentIntent via existing SDK (test mode); balance drawn down at invoice issue before metered reporting +- `PUT /portal/api/email-invoicing { enabled, email }` — stored prefs; billing job emails PO invoices (send via SMTP env config; dev: log-only transport) +- Tests: session scoping (cannot read other customers' invoices), reload math, key regeneration invalidates old key + +### Task 12: Portal SPA +- `portal/index.html` + `portal/app.js` in the same design language as the new admin: login/signup/2FA screens, dashboard (usage + balance), invoices (history + print), billing (reload, email invoicing), API docs + pricing info pages (rendered from `openapi.yaml` + live rate card) +- Screenshot verification of every screen + +--- + +## Phase 4 — Documentation + +### Task 13: `docs/ACCOUNTING.md` + PDF — company accounting walkthrough (invoices, PO billing, reports, trends, CSV export) with screenshots +### Task 14: `docs/USER-MANAGEMENT.md` + PDF — admin users walkthrough +### Task 15: `docs/CUSTOMER-PORTAL.md` + PDF — end-user walkthrough (signup, 2FA, reload, invoices, API docs) +### Task 16: Refresh `USER-MANUAL.md`, `DEVELOPER.md`, `README.md`; re-export all PDFs + +--- + +## Self-review notes + +- Spec coverage: Stripe **and** PO billing (Tasks 1–2, 11), invoicing histories (2, 4, 5), CSV/PDF + date range + all/individual/by-type (3, 4, 6), usage/billing trends (3, 6), management reports (4, 6, 8), Zapier connections (4, 8), user management (7), customer login/setup/2FA/invoices/reload/email invoicing/API docs/pricing (9–12), docs for all three surfaces (13–16). +- Type consistency: `Invoice`, `InvoiceRepo`, `billingRows`, `usageTrend`, `toCsv` signatures are defined once above and reused in every later task. diff --git a/docs/superpowers/plans/2026-07-27-api-pricing-zapier-launch.md b/docs/superpowers/plans/2026-07-27-api-pricing-zapier-launch.md new file mode 100644 index 0000000..1fc782d --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-api-pricing-zapier-launch.md @@ -0,0 +1,2981 @@ +# Metered API + Tiered Pricing + Admin UI + Zapier Distribution — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Zappier HTTP API driven by an OpenAPI spec, with per-call usage metering, runtime-editable pricing (rate card × tier multiplier + per-customer overrides + monthly credits) managed through an admin web UI, SQLite persistence, Stripe metered billing, and a published Zapier app. + +**Architecture:** `openapi.yaml` is the API source of truth — `express-openapi-validator` validates requests against it and `swagger-ui-express` serves docs at `/docs`; each spec `operationId` is a rate-card key. A pure pricing module quotes every call: endpoints carry **list prices** on a rate card, customer types are **tier configs** (multiplier + monthly credit + optional default rule), customers may carry a **multiplier override**; billed price = `round(list × multiplier)`. Pricing lives in a `PricingStore` (SQLite in the server, in-memory in tests) and is editable at runtime through an admin API + vanilla-JS admin UI at `/admin` — changes take effect on the next request. Every call is recorded in a usage repository; a monthly job subtracts the tier credit and reports the billable remainder to a Stripe Billing Meter. A separate `zapier-app/` package (Zapier Platform CLI, plain JS) exposes API-key auth, a polling trigger, and a create action against the same API. + +**Tech Stack:** Node 20, TypeScript (strict), Express 4, express-openapi-validator, swagger-ui-express, better-sqlite3, Jest + ts-jest + supertest, Stripe Node SDK (Billing Meters), Zapier Platform CLI. + +## Global Constraints + +- Project root: `/Users/marchon/zappier`. All paths below are relative to it; the server must be started from the project root (it loads `openapi.yaml` and `admin/` via `process.cwd()`). +- `openapi.yaml` is the API source of truth. Rate-card endpoint ids must match spec `operationId`s exactly. +- All money values are integer cents. List prices are computed first (per-KB / per-MB sizes rounded up), then the multiplier is applied once with `Math.round`. +- Free rules price at 0 cents on **every** tier, regardless of multiplier or overrides. +- Pricing is runtime-editable through the admin API/UI; changes take effect on the next request, no restart. +- Admin endpoints require the `x-admin-key` header (env `ADMIN_KEY`, dev default `admin-dev-key`). Never commit a real key. +- Customer-facing endpoints require the `x-api-key` header. +- Tests never touch real Stripe or Zapier accounts — unit tests use fakes; account steps are manual. +- Jest tests use in-memory repositories/stores. The server (`src/index.ts`) and the Stripe job use SQLite (`zappier.db`, override with the `ZAPPIER_DB` env var). + +--- + +### Task 1: Project scaffold + pricing engine + pricing stores + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `jest.config.js` +- Create: `src/pricing.ts` +- Test: `tests/pricing.test.ts` +- Modify: `README.md` (replace pricing section) + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `export type PriceRule = { kind: 'free' } | { kind: 'fixed'; fixedCents: number } | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }` + - `export interface TierConfig { id: string; name: string; multiplier: number; monthlyCreditCents: number; defaultRule?: PriceRule }` + - `export interface RateCard { endpoints: Record }` + - `export interface TierCatalog { find(id: string): TierConfig | undefined; list(): TierConfig[] }` and `export class ConfigTierCatalog implements TierCatalog` (constructor takes `TierConfig[]`) + - `export interface CallUsage { metadataBytes: number; attachmentBytes: number }` + - `export interface Quote { endpointId: string; listCents: number; totalCents: number; breakdown: { baseCents: number; metadataCents: number; attachmentCents: number } }` + - `export interface PricingContext { rateCard: RateCard; tiers: TierCatalog }` + - `export interface PricingStore { getRateCard(): RateCard; getTiers(): TierConfig[]; upsertEndpoint(endpointId: string, rule: PriceRule): void; deleteEndpoint(endpointId: string): void; upsertTier(tier: TierConfig): void; deleteTier(tierId: string): void }` + - `export class InMemoryPricingStore implements PricingStore` — constructor `(rateCard?: RateCard, tiers?: TierConfig[])`, defaults to the seeds below. + - `export const DEFAULT_RATE_CARD`, `DEFAULT_TIERS`, `DEFAULT_PRICING` (seed data; also used to seed SQLite in Task 7) + - `export function quoteCall(pricing: PricingContext, tierId: string, endpointId: string, usage: CallUsage, multiplierOverride?: number): Quote` — throws `Error("Unknown tier: ")` or `Error("No price rule for /")`. `multiplierOverride` (per-customer) beats the tier multiplier. + +- [ ] **Step 1: Scaffold the project** + +Create `package.json`: + +```json +{ + "name": "zappier", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "test": "jest" + }, + "dependencies": { + "express": "^4.19.2", + "express-openapi-validator": "^5.3.0", + "stripe": "^16.0.0", + "swagger-ui-express": "^5.0.0", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/multer": "^1.4.11", + "@types/node": "^20.14.0", + "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.6", + "@types/yamljs": "^0.2.34", + "jest": "^29.7.0", + "supertest": "^7.0.0", + "ts-jest": "^29.1.4", + "ts-node": "^10.9.2", + "typescript": "^5.5.0" + } +} +``` + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node", "jest", "multer"] + }, + "include": ["src"] +} +``` + +Create `jest.config.js`: + +```js +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], +}; +``` + +Run: `cd /Users/marchon/zappier && npm install` +Expected: installs cleanly, `node_modules/` exists. + +- [ ] **Step 2: Write the failing pricing tests** + +Create `tests/pricing.test.ts`: + +```ts +import { + ConfigTierCatalog, + DEFAULT_PRICING, + DEFAULT_TIERS, + InMemoryPricingStore, + quoteCall, +} from '../src/pricing'; + +const noUsage = { metadataBytes: 0, attachmentBytes: 0 }; + +describe('quoteCall', () => { + it('prices free endpoints at 0 on every tier, ignoring the multiplier', () => { + for (const tier of DEFAULT_TIERS) { + expect(quoteCall(DEFAULT_PRICING, tier.id, 'status', noUsage).totalCents).toBe(0); + expect(quoteCall(DEFAULT_PRICING, tier.id, 'storage-list', noUsage).totalCents).toBe(0); + } + }); + + it('applies the tier multiplier to fixed list prices', () => { + // transform list price: 4 cents + expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage).totalCents).toBe(4); + expect(quoteCall(DEFAULT_PRICING, 'pro', 'transform', noUsage).totalCents).toBe(2); + expect(quoteCall(DEFAULT_PRICING, 'business', 'transform', noUsage).totalCents).toBe(1); + }); + + it('prices storage as base + per-KB metadata + per-MB attachments at list rates', () => { + const usage = { metadataBytes: 2048, attachmentBytes: 2 * 1024 * 1024 }; + // list: 10 + 2 * 1 + 2 * 50 = 112 + expect(quoteCall(DEFAULT_PRICING, 'free', 'storage', usage).totalCents).toBe(112); + expect(quoteCall(DEFAULT_PRICING, 'pro', 'storage', usage).totalCents).toBe(56); + expect(quoteCall(DEFAULT_PRICING, 'business', 'storage', usage).totalCents).toBe(28); + }); + + it('rounds partial KB and MB up before applying the multiplier', () => { + const q = quoteCall(DEFAULT_PRICING, 'free', 'storage', { + metadataBytes: 1, + attachmentBytes: 1, + }); + // list: 10 + 1 KB * 1 + 1 MB * 50 = 61 + expect(q.listCents).toBe(61); + expect(q.totalCents).toBe(61); + }); + + it('exposes the list-price breakdown and the multiplied total', () => { + const q = quoteCall(DEFAULT_PRICING, 'pro', 'storage', { + metadataBytes: 1024, + attachmentBytes: 0, + }); + expect(q.breakdown).toEqual({ baseCents: 10, metadataCents: 1, attachmentCents: 0 }); + expect(q.listCents).toBe(11); + expect(q.totalCents).toBe(6); // Math.round(11 * 0.5) + }); + + it('lets a per-customer multiplier override beat the tier multiplier', () => { + expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage, 0.5).totalCents).toBe(2); + expect(quoteCall(DEFAULT_PRICING, 'free', 'status', noUsage, 0.5).totalCents).toBe(0); + }); + + it('falls back to the tier default rule for endpoints not on the rate card', () => { + // pro default rule: fixed 8 list -> round(8 * 0.5) = 4 + expect(quoteCall(DEFAULT_PRICING, 'pro', 'experimental', noUsage).totalCents).toBe(4); + }); + + it('throws for an endpoint with no rate-card entry and no tier default', () => { + expect(() => quoteCall(DEFAULT_PRICING, 'free', 'experimental', noUsage)).toThrow( + 'No price rule', + ); + }); + + it('throws for an unknown tier', () => { + expect(() => quoteCall(DEFAULT_PRICING, 'platinum', 'status', noUsage)).toThrow( + 'Unknown tier', + ); + }); +}); + +describe('ConfigTierCatalog', () => { + it('finds tiers by id and lists them', () => { + const catalog = new ConfigTierCatalog(DEFAULT_TIERS); + expect(catalog.find('pro')?.multiplier).toBe(0.5); + expect(catalog.find('nope')).toBeUndefined(); + expect(catalog.list().map((t) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('supports adding a customer type as pure config', () => { + const catalog = new ConfigTierCatalog([ + ...DEFAULT_TIERS, + { id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 }, + ]); + expect(catalog.find('edu')?.name).toBe('Education'); + }); +}); + +describe('InMemoryPricingStore', () => { + it('seeds from the default rate card and tiers', () => { + const store = new InMemoryPricingStore(); + expect(store.getRateCard().endpoints.status).toEqual({ kind: 'free' }); + expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('upserts and deletes endpoints', () => { + const store = new InMemoryPricingStore(); + store.upsertEndpoint('experimental', { kind: 'fixed', fixedCents: 9 }); + expect(store.getRateCard().endpoints.experimental).toEqual({ + kind: 'fixed', + fixedCents: 9, + }); + store.deleteEndpoint('experimental'); + expect(store.getRateCard().endpoints.experimental).toBeUndefined(); + }); + + it('upserts and deletes tiers', () => { + const store = new InMemoryPricingStore(); + store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 }); + expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.4); + store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 }); + expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1); + store.deleteTier('edu'); + expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm test` +Expected: FAIL — `Cannot find module '../src/pricing'`. + +- [ ] **Step 4: Implement the pricing engine and stores** + +Create `src/pricing.ts`: + +```ts +export type PriceRule = + | { kind: 'free' } + | { kind: 'fixed'; fixedCents: number } + | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }; + +export interface TierConfig { + id: string; + name: string; + multiplier: number; + monthlyCreditCents: number; + defaultRule?: PriceRule; +} + +export interface RateCard { + endpoints: Record; +} + +export interface TierCatalog { + find(id: string): TierConfig | undefined; + list(): TierConfig[]; +} + +export class ConfigTierCatalog implements TierCatalog { + constructor(private tiers: TierConfig[]) {} + + find(id: string): TierConfig | undefined { + return this.tiers.find((t) => t.id === id); + } + + list(): TierConfig[] { + return [...this.tiers]; + } +} + +export interface CallUsage { + metadataBytes: number; + attachmentBytes: number; +} + +export interface Quote { + endpointId: string; + listCents: number; + totalCents: number; + breakdown: { baseCents: number; metadataCents: number; attachmentCents: number }; +} + +export interface PricingContext { + rateCard: RateCard; + tiers: TierCatalog; +} + +export interface PricingStore { + getRateCard(): RateCard; + getTiers(): TierConfig[]; + upsertEndpoint(endpointId: string, rule: PriceRule): void; + deleteEndpoint(endpointId: string): void; + upsertTier(tier: TierConfig): void; + deleteTier(tierId: string): void; +} + +export const DEFAULT_RATE_CARD: RateCard = { + endpoints: { + status: { kind: 'free' }, + 'storage-list': { kind: 'free' }, + transform: { kind: 'fixed', fixedCents: 4 }, + storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 }, + }, +}; + +export const DEFAULT_TIERS: TierConfig[] = [ + { id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 }, + { + id: 'pro', + name: 'Pro', + multiplier: 0.5, + monthlyCreditCents: 1000, + defaultRule: { kind: 'fixed', fixedCents: 8 }, + }, + { + id: 'business', + name: 'Business', + multiplier: 0.25, + monthlyCreditCents: 10000, + defaultRule: { kind: 'fixed', fixedCents: 8 }, + }, +]; + +export const DEFAULT_PRICING: PricingContext = { + rateCard: DEFAULT_RATE_CARD, + tiers: new ConfigTierCatalog(DEFAULT_TIERS), +}; + +export class InMemoryPricingStore implements PricingStore { + private endpoints: Record; + private tiers: TierConfig[]; + + constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) { + this.endpoints = { ...rateCard.endpoints }; + this.tiers = [...tiers]; + } + + getRateCard(): RateCard { + return { endpoints: { ...this.endpoints } }; + } + + getTiers(): TierConfig[] { + return [...this.tiers]; + } + + upsertEndpoint(endpointId: string, rule: PriceRule): void { + this.endpoints[endpointId] = rule; + } + + deleteEndpoint(endpointId: string): void { + delete this.endpoints[endpointId]; + } + + upsertTier(tier: TierConfig): void { + const i = this.tiers.findIndex((t) => t.id === tier.id); + if (i >= 0) this.tiers[i] = tier; + else this.tiers.push(tier); + } + + deleteTier(tierId: string): void { + this.tiers = this.tiers.filter((t) => t.id !== tierId); + } +} + +export function quoteCall( + pricing: PricingContext, + tierId: string, + endpointId: string, + usage: CallUsage, + multiplierOverride?: number, +): Quote { + const tier = pricing.tiers.find(tierId); + if (!tier) throw new Error(`Unknown tier: ${tierId}`); + const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule; + if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`); + + if (rule.kind === 'free') { + return { + endpointId, + listCents: 0, + totalCents: 0, + breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 }, + }; + } + + let breakdown: Quote['breakdown']; + if (rule.kind === 'fixed') { + breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 }; + } else { + breakdown = { + baseCents: rule.baseCents, + metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents, + attachmentCents: + Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents, + }; + } + const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents; + const multiplier = multiplierOverride ?? tier.multiplier; + return { + endpointId, + listCents, + totalCents: Math.round(listCents * multiplier), + breakdown, + }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS — 14 tests in `tests/pricing.test.ts`. + +- [ ] **Step 6: Document the pricing model in the README** + +Replace the body of `README.md` below the title with: + +```markdown +## Pricing model + +`openapi.yaml` defines the API surface; each `operationId` is a rate-card key. +Endpoints carry **list prices** (seed: `src/pricing.ts` → `DEFAULT_RATE_CARD`). +Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a +`monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card. +Individual customers can carry a `multiplierOverride`. +Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free. +Pricing is editable at runtime via the admin UI at `/admin` (see Task 8). + +### Seed rate card (list prices, cents per call) + +| Endpoint | Model | List price | +| -------------- | -------- | -------------------------------------------- | +| `status` | free | 0 | +| `storage-list` | free | 0 | +| `transform` | fixed | 4 | +| `storage` | variable | 10 + 1 per KB metadata + 50 per MB attached | + +### Seed customer types + +| Tier | Multiplier | Monthly credit | Default rule (unlisted endpoints) | +| ---------- | ---------- | -------------- | --------------------------------- | +| `free` | 1.0 | 100 cents | none — call rejected with 403 | +| `pro` | 0.5 | 1000 cents | fixed 8 list → 4 billed | +| `business` | 0.25 | 10000 cents | fixed 8 list → 2 billed | + +Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI. +Adding a customer type = create it in the admin UI. Variable pricing = base per call + +metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier. +``` + +- [ ] **Step 7: Commit** + +```bash +cd /Users/marchon/zappier && git init -q 2>/dev/null; git add -A +git commit -m "feat: scaffold project with rate-card pricing engine and pricing stores" +``` + +--- + +### Task 2: Usage log + +**Files:** +- Create: `src/usage.ts` +- Test: `tests/usage.test.ts` + +**Interfaces:** +- Consumes: nothing from Task 1 (independent module). +- Produces: + - `export interface UsageEntry { customerId: string; endpointId: string; cents: number; metadataBytes: number; attachmentBytes: number; timestamp: Date }` + - `export interface UsageSummary { customerId: string; totalCents: number; calls: number; byEndpoint: Record }` + - `export function summarize(customerId: string, list: UsageEntry[]): UsageSummary` — shared aggregation helper (used again by the SQLite repo in Task 7). + - `export interface UsageRepo { record(entry: UsageEntry): void; listFor(customerId: string, since?: Date): UsageEntry[]; summaryFor(customerId: string, since?: Date): UsageSummary }` + - `export class InMemoryUsageRepo implements UsageRepo` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/usage.test.ts`: + +```ts +import { InMemoryUsageRepo, UsageEntry } from '../src/usage'; + +const entry = (over: Partial = {}): UsageEntry => ({ + customerId: 'cust_1', + endpointId: 'transform', + cents: 4, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date('2026-07-27T10:00:00Z'), + ...over, +}); + +describe('InMemoryUsageRepo', () => { + it('records and lists entries per customer', () => { + const repo = new InMemoryUsageRepo(); + repo.record(entry()); + repo.record(entry({ customerId: 'cust_2' })); + expect(repo.listFor('cust_1')).toHaveLength(1); + expect(repo.listFor('cust_2')).toHaveLength(1); + }); + + it('filters entries by since date', () => { + const repo = new InMemoryUsageRepo(); + repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') })); + repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') })); + expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1); + }); + + it('summarizes totals by endpoint', () => { + const repo = new InMemoryUsageRepo(); + repo.record(entry()); + repo.record(entry({ endpointId: 'storage', cents: 112 })); + repo.record(entry()); + const s = repo.summaryFor('cust_1'); + expect(s.customerId).toBe('cust_1'); + expect(s.calls).toBe(3); + expect(s.totalCents).toBe(120); + expect(s.byEndpoint.transform).toEqual({ calls: 2, cents: 8 }); + expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/usage.test.ts` +Expected: FAIL — `Cannot find module '../src/usage'`. + +- [ ] **Step 3: Implement the usage repo** + +Create `src/usage.ts`: + +```ts +export interface UsageEntry { + customerId: string; + endpointId: string; + cents: number; + metadataBytes: number; + attachmentBytes: number; + timestamp: Date; +} + +export interface UsageSummary { + customerId: string; + totalCents: number; + calls: number; + byEndpoint: Record; +} + +export function summarize(customerId: string, list: UsageEntry[]): UsageSummary { + const byEndpoint: UsageSummary['byEndpoint'] = {}; + for (const e of list) { + const bucket = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 }); + bucket.calls += 1; + bucket.cents += e.cents; + } + return { + customerId, + calls: list.length, + totalCents: list.reduce((sum, e) => sum + e.cents, 0), + byEndpoint, + }; +} + +export interface UsageRepo { + record(entry: UsageEntry): void; + listFor(customerId: string, since?: Date): UsageEntry[]; + summaryFor(customerId: string, since?: Date): UsageSummary; +} + +export class InMemoryUsageRepo implements UsageRepo { + private entries: UsageEntry[] = []; + + record(entry: UsageEntry): void { + this.entries.push(entry); + } + + listFor(customerId: string, since?: Date): UsageEntry[] { + return this.entries.filter( + (e) => e.customerId === customerId && (!since || e.timestamp >= since), + ); + } + + summaryFor(customerId: string, since?: Date): UsageSummary { + return summarize(customerId, this.listFor(customerId, since)); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/usage.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/usage.ts tests/usage.test.ts +git commit -m "feat: add usage log with shared summarizer and per-customer summaries" +``` + +--- + +### Task 3: API-key auth + customer model + +**Files:** +- Create: `src/auth.ts` +- Test: `tests/auth.test.ts` + +**Interfaces:** +- Consumes: nothing (a customer's tier is a plain `tierId` string resolved by `PricingContext.tiers` at quote time — auth never imports pricing). +- Produces: + - `export interface Customer { id: string; name: string; tierId: string; apiKey: string; stripeCustomerId?: string; multiplierOverride?: number }` + - `export interface CustomerRepo { findByApiKey(apiKey: string): Customer | undefined; list(): Customer[]; save(customer: Customer): void }` — `save` upserts by `id` (used by the admin API in Task 8). + - `export class InMemoryCustomerRepo implements CustomerRepo` — constructor takes `Customer[]`. + - `export function apiKeyAuth(repo: CustomerRepo): RequestHandler` — reads `x-api-key` header, sets `req.customer`, else 401 JSON `{ error: 'invalid or missing API key' }`. + - Global augmentation: `Express.Request.customer?: Customer`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/auth.test.ts`: + +```ts +import express from 'express'; +import request from 'supertest'; +import { apiKeyAuth, InMemoryCustomerRepo } from '../src/auth'; + +const repo = new InMemoryCustomerRepo([ + { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' }, + { id: 'cust_2', name: 'Grace', tierId: 'business', apiKey: 'key-grace', stripeCustomerId: 'cus_123' }, +]); + +const app = express(); +app.use(apiKeyAuth(repo)); +app.get('/ping', (req, res) => + res.json({ customerId: req.customer!.id, tierId: req.customer!.tierId }), +); + +describe('apiKeyAuth', () => { + it('rejects a missing key with 401', async () => { + const res = await request(app).get('/ping'); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/API key/); + }); + + it('rejects an unknown key with 401', async () => { + const res = await request(app).get('/ping').set('x-api-key', 'wrong'); + expect(res.status).toBe(401); + }); + + it('attaches the customer for a valid key', async () => { + const res = await request(app).get('/ping').set('x-api-key', 'key-ada'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ customerId: 'cust_1', tierId: 'pro' }); + }); +}); + +describe('InMemoryCustomerRepo', () => { + it('lists all customers', () => { + expect(repo.list().map((c) => c.id)).toEqual(['cust_1', 'cust_2']); + }); + + it('save() upserts by id', () => { + const local = new InMemoryCustomerRepo([ + { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' }, + ]); + local.save({ id: 'cust_1', name: 'Ada', tierId: 'business', apiKey: 'key-ada', multiplierOverride: 0.4 }); + local.save({ id: 'cust_9', name: 'New', tierId: 'free', apiKey: 'key-new' }); + expect(local.list()).toHaveLength(2); + const updated = local.findByApiKey('key-ada'); + expect(updated?.tierId).toBe('business'); + expect(updated?.multiplierOverride).toBe(0.4); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/auth.test.ts` +Expected: FAIL — `Cannot find module '../src/auth'`. + +- [ ] **Step 3: Implement auth** + +Create `src/auth.ts`: + +```ts +import { RequestHandler } from 'express'; + +export interface Customer { + id: string; + name: string; + tierId: string; + apiKey: string; + stripeCustomerId?: string; + multiplierOverride?: number; +} + +export interface CustomerRepo { + findByApiKey(apiKey: string): Customer | undefined; + list(): Customer[]; + save(customer: Customer): void; +} + +export class InMemoryCustomerRepo implements CustomerRepo { + constructor(private customers: Customer[] = []) {} + + findByApiKey(apiKey: string): Customer | undefined { + return this.customers.find((c) => c.apiKey === apiKey); + } + + list(): Customer[] { + return [...this.customers]; + } + + save(customer: Customer): void { + const i = this.customers.findIndex((c) => c.id === customer.id); + if (i >= 0) this.customers[i] = customer; + else this.customers.push(customer); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + customer?: Customer; + } + } +} + +export function apiKeyAuth(repo: CustomerRepo): RequestHandler { + return (req, res, next) => { + const key = req.header('x-api-key'); + const customer = key ? repo.findByApiKey(key) : undefined; + if (!customer) { + res.status(401).json({ error: 'invalid or missing API key' }); + return; + } + req.customer = customer; + next(); + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/auth.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/auth.ts tests/auth.test.ts +git commit -m "feat: add API-key auth and upsertable customer model with tier overrides" +``` + +--- + +### Task 4: Metering middleware + +**Files:** +- Create: `src/meter.ts` +- Test: `tests/meter.test.ts` + +**Interfaces:** +- Consumes: `quoteCall`, `PricingContext`, `Quote`, `DEFAULT_PRICING` from `src/pricing.ts`; `UsageRepo` from `src/usage.ts`; `req.customer` (with `tierId` and `multiplierOverride`) from `src/auth.ts`. +- Produces: `export function meter(endpointId: string, repo: UsageRepo, pricing: PricingContext): RequestHandler` — computes `metadataBytes` from `req.body.metadata` (byte length of its JSON form, `{}` when absent) and `attachmentBytes` from `req.files` (Multer-shaped array provided by express-openapi-validator, 0 when absent); quotes via `quoteCall(pricing, req.customer.tierId, endpointId, usage, req.customer.multiplierOverride)`; on a pricing error responds **403** with the error message and records nothing; otherwise records a `UsageEntry` with `cents = quote.totalCents` and sets `res.locals.quote`. Must run **after** `apiKeyAuth` and after the OpenAPI validator (which parses multipart bodies). + +- [ ] **Step 1: Write the failing tests** + +Create `tests/meter.test.ts`: + +```ts +import express from 'express'; +import request from 'supertest'; +import { apiKeyAuth, Customer, InMemoryCustomerRepo } from '../src/auth'; +import { meter } from '../src/meter'; +import { DEFAULT_PRICING } from '../src/pricing'; +import { InMemoryUsageRepo } from '../src/usage'; + +const FREE_ADA: Customer = { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' }; + +function buildApp(seed: Customer[] = [FREE_ADA]) { + const customers = new InMemoryCustomerRepo(seed); + const usage = new InMemoryUsageRepo(); + const app = express(); + app.use(express.json()); + app.use(apiKeyAuth(customers)); + app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) => + res.json({ quote: res.locals.quote }), + ); + app.post('/storage', meter('storage', usage, DEFAULT_PRICING), (req, res) => + res.json({ quote: res.locals.quote }), + ); + app.post('/experimental', meter('experimental', usage, DEFAULT_PRICING), (req, res) => + res.json({ quote: res.locals.quote }), + ); + return { app, usage }; +} + +describe('meter middleware', () => { + it('quotes a fixed endpoint at the multiplied tier price and records usage', async () => { + const { app, usage } = buildApp(); + const res = await request(app) + .post('/transform') + .set('x-api-key', 'key-ada') + .send({ text: 'hi' }); + expect(res.status).toBe(200); + expect(res.body.quote.totalCents).toBe(4); // free tier, multiplier 1 + const entries = usage.listFor('cust_1'); + expect(entries).toHaveLength(1); + expect(entries[0].endpointId).toBe('transform'); + expect(entries[0].cents).toBe(4); + }); + + it('charges per KB of metadata on variable endpoints', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/storage') + .set('x-api-key', 'key-ada') + .send({ metadata: { note: 'x'.repeat(2048) } }); + // JSON of metadata is 2059 bytes -> 3 KB -> list 10 + 3 * 1 = 13 + expect(res.body.quote.totalCents).toBe(13); + }); + + it('applies a per-customer multiplier override', async () => { + const { app } = buildApp([ + { id: 'cust_vip', name: 'Vip', tierId: 'free', apiKey: 'key-vip', multiplierOverride: 0.5 }, + ]); + const res = await request(app) + .post('/transform') + .set('x-api-key', 'key-vip') + .send({ text: 'hi' }); + expect(res.body.quote.totalCents).toBe(2); // list 4 x override 0.5 + }); + + it('returns 401 when no customer is attached', async () => { + const usage = new InMemoryUsageRepo(); + const app = express(); + app.use(express.json()); + app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) => + res.json({}), + ); + const res = await request(app).post('/transform').send({ text: 'hi' }); + expect(res.status).toBe(401); + expect(usage.listFor('cust_1')).toHaveLength(0); + }); + + it('returns 403 for an endpoint with no price rule on the caller tier', async () => { + const { app, usage } = buildApp(); + const res = await request(app) + .post('/experimental') + .set('x-api-key', 'key-ada') + .send({}); + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/No price rule/); + expect(usage.listFor('cust_1')).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/meter.test.ts` +Expected: FAIL — `Cannot find module '../src/meter'`. + +- [ ] **Step 3: Implement the middleware** + +Create `src/meter.ts`: + +```ts +import { RequestHandler } from 'express'; +import { PricingContext, Quote, quoteCall } from './pricing'; +import { UsageRepo } from './usage'; + +export function meter( + endpointId: string, + repo: UsageRepo, + pricing: PricingContext, +): RequestHandler { + return (req, res, next) => { + const customer = req.customer; + if (!customer) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const metadataBytes = Buffer.byteLength( + JSON.stringify(req.body?.metadata ?? {}), + 'utf8', + ); + const files = (req.files as Express.Multer.File[] | undefined) ?? []; + const attachmentBytes = files.reduce((sum, f) => sum + f.size, 0); + + let quote: Quote; + try { + quote = quoteCall( + pricing, + customer.tierId, + endpointId, + { metadataBytes, attachmentBytes }, + customer.multiplierOverride, + ); + } catch (err) { + res.status(403).json({ error: (err as Error).message }); + return; + } + + repo.record({ + customerId: customer.id, + endpointId, + cents: quote.totalCents, + metadataBytes, + attachmentBytes, + timestamp: new Date(), + }); + res.locals.quote = quote; + next(); + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/meter.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/meter.ts tests/meter.test.ts +git commit -m "feat: add metering middleware with tier overrides and 403 for unpriced calls" +``` + +--- + +### Task 5: OpenAPI-driven HTTP API + docs + +**Files:** +- Create: `openapi.yaml` +- Create: `src/app.ts` +- Create: `src/index.ts` +- Test: `tests/app.test.ts` + +**Interfaces:** +- Consumes: `apiKeyAuth`, `InMemoryCustomerRepo`, `CustomerRepo`, `Customer` (Task 3); `meter` (Task 4); `UsageRepo`, `InMemoryUsageRepo` (Task 2); `PricingContext`, `PricingStore`, `InMemoryPricingStore`, `ConfigTierCatalog` (Task 1). +- Produces: + - `openapi.yaml` — the API source of truth; `operationId`s: `status`, `transform`, `storage`, `storage-list`, `usage` (first four are rate-card keys). + - `export interface StoredItem { id: string; customerId: string; metadata: Record; attachments: { filename: string; size: number }[]; createdAt: string }` + - `export const DEFAULT_CUSTOMERS: Customer[]` — `key-ada` (free), `key-grace` (pro), `key-linus` (business). + - `export interface AppDeps { usage?: UsageRepo; customers?: CustomerRepo; pricingStore?: PricingStore }` + - `export function buildApp(deps?: AppDeps): { app: Express; usage: UsageRepo; customers: CustomerRepo; pricing: PricingContext; pricingStore: PricingStore; items: StoredItem[] }` — `pricing` is a **live** context whose getters read the store on every call, so admin edits (Task 8) take effect immediately. + - `GET /docs` — Swagger UI rendering `openapi.yaml`. + +- [ ] **Step 1: Write the OpenAPI spec** + +Create `openapi.yaml`: + +```yaml +openapi: 3.0.3 +info: + title: Zappier API + version: 0.1.0 + description: Metered data API. Every priced call returns its quote. +security: + - apiKey: [] +paths: + /v1/status: + get: + operationId: status + summary: Service status (free) + responses: + '200': + description: OK + /v1/transform: + post: + operationId: transform + summary: Uppercase a string (fixed price) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + responses: + '200': + description: OK + /v1/storage: + post: + operationId: storage + summary: Store metadata and file attachments (variable price by size) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + metadata: + type: string + description: JSON string of metadata + attachments: + type: array + items: + type: string + format: binary + responses: + '200': + description: OK + get: + operationId: storage-list + summary: List your stored items (free) + responses: + '200': + description: OK + /v1/usage: + get: + operationId: usage + summary: Your usage summary for the current period + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: x-api-key +``` + +- [ ] **Step 2: Write the failing tests** + +Create `tests/app.test.ts`: + +```ts +import request from 'supertest'; +import { buildApp } from '../src/app'; + +const KEY = 'key-ada'; // seeded free-tier customer + +describe('Zappier API', () => { + it('rejects calls without an API key', async () => { + const { app } = buildApp(); + const res = await request(app).get('/v1/status'); + expect(res.status).toBe(401); + }); + + it('GET /v1/status is free', async () => { + const { app } = buildApp(); + const res = await request(app).get('/v1/status').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.quote.totalCents).toBe(0); + }); + + it('POST /v1/transform uppercases text at the multiplied fixed price', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'hello' }); + expect(res.status).toBe(200); + expect(res.body.output).toBe('HELLO'); + expect(res.body.quote.totalCents).toBe(4); // list 4 x free-tier multiplier 1 + }); + + it('rejects a request that violates the OpenAPI schema with 400', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ wrong: 1 }); + expect(res.status).toBe(400); + }); + + it('POST /v1/storage stores metadata plus attachments and quotes by size', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', JSON.stringify({ title: 'report' })) + .attach('attachments', Buffer.alloc(1024 * 1024), 'one.bin') + .attach('attachments', Buffer.alloc(1024 * 1024), 'two.bin'); + expect(res.status).toBe(200); + expect(res.body.id).toBeTruthy(); + // metadata string is 20 bytes -> 1 KB; 2 MB attachments + // list 10 + 1 * 1 + 2 * 50 = 111, free-tier multiplier 1 + expect(res.body.quote.totalCents).toBe(111); + }); + + it('GET /v1/storage lists the caller items newest first', async () => { + const { app } = buildApp(); + await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', JSON.stringify({ title: 'a' })); + await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', JSON.stringify({ title: 'b' })); + const res = await request(app).get('/v1/storage').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(2); + expect(res.body.items[0].metadata.title).toBe('b'); + }); + + it('GET /v1/usage returns the caller summary', async () => { + const { app } = buildApp(); + await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'x' }); + const res = await request(app).get('/v1/usage').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.customerId).toBe('cust_1'); + expect(res.body.calls).toBe(1); + expect(res.body.totalCents).toBe(4); + }); + + it('charges less for a business-tier customer', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', 'key-linus') + .send({ text: 'hello' }); + expect(res.body.quote.totalCents).toBe(1); // list 4 x business multiplier 0.25 + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm test -- tests/app.test.ts` +Expected: FAIL — `Cannot find module '../src/app'`. + +- [ ] **Step 4: Implement the app and server** + +Create `src/app.ts`: + +```ts +import path from 'path'; +import { randomUUID } from 'crypto'; +import express, { Express, NextFunction, Request, Response } from 'express'; +import * as OpenApiValidator from 'express-openapi-validator'; +import swaggerUi from 'swagger-ui-express'; +import YAML from 'yamljs'; +import { + apiKeyAuth, + Customer, + CustomerRepo, + InMemoryCustomerRepo, +} from './auth'; +import { meter } from './meter'; +import { + ConfigTierCatalog, + InMemoryPricingStore, + PricingContext, + PricingStore, +} from './pricing'; +import { InMemoryUsageRepo, UsageRepo } from './usage'; + +export interface StoredItem { + id: string; + customerId: string; + metadata: Record; + attachments: { filename: string; size: number }[]; + createdAt: string; +} + +export const DEFAULT_CUSTOMERS: Customer[] = [ + { id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' }, + { id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' }, + { id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' }, +]; + +export interface AppDeps { + usage?: UsageRepo; + customers?: CustomerRepo; + pricingStore?: PricingStore; +} + +const SPEC_PATH = path.join(process.cwd(), 'openapi.yaml'); + +export function buildApp(deps: AppDeps = {}): { + app: Express; + usage: UsageRepo; + customers: CustomerRepo; + pricing: PricingContext; + pricingStore: PricingStore; + items: StoredItem[]; +} { + const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS); + const usage = deps.usage ?? new InMemoryUsageRepo(); + const pricingStore = deps.pricingStore ?? new InMemoryPricingStore(); + // Live pricing context: every quote reads the store, so admin edits apply immediately. + const pricing: PricingContext = { + get rateCard() { + return pricingStore.getRateCard(); + }, + get tiers() { + return new ConfigTierCatalog(pricingStore.getTiers()); + }, + }; + const items: StoredItem[] = []; + + const app = express(); + app.use(express.json()); + + const spec = YAML.load(SPEC_PATH); + app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec)); + + app.use('/v1', apiKeyAuth(customers)); + app.use( + OpenApiValidator.middleware({ + apiSpec: SPEC_PATH, + validateRequests: true, + validateResponses: false, + }), + ); + + app.get('/v1/status', meter('status', usage, pricing), (req, res) => { + res.json({ status: 'ok', quote: res.locals.quote }); + }); + + app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => { + const text = String(req.body?.text ?? ''); + res.json({ output: text.toUpperCase(), quote: res.locals.quote }); + }); + + app.post('/v1/storage', meter('storage', usage, pricing), (req, res) => { + const raw = req.body?.metadata; + const metadata = + typeof raw === 'string' ? JSON.parse(raw) : ((raw ?? {}) as Record); + const files = (req.files as Express.Multer.File[]) ?? []; + const item: StoredItem = { + id: randomUUID(), + customerId: req.customer!.id, + metadata, + attachments: files.map((f) => ({ filename: f.originalname, size: f.size })), + createdAt: new Date().toISOString(), + }; + items.unshift(item); + res.json({ id: item.id, quote: res.locals.quote }); + }); + + app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => { + res.json({ items: items.filter((i) => i.customerId === req.customer!.id) }); + }); + + app.get('/v1/usage', (req, res) => { + res.json(usage.summaryFor(req.customer!.id)); + }); + + app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message }); + }); + + return { app, usage, customers, pricing, pricingStore, items }; +} +``` + +Create `src/index.ts` (in-memory for now; Task 7 swaps in SQLite): + +```ts +import { buildApp } from './app'; + +const { app } = buildApp(); +const port = Number(process.env.PORT ?? 3000); +app.listen(port, () => { + console.log(`Zappier API listening on http://localhost:${port}`); + console.log(`OpenAPI docs at http://localhost:${port}/docs`); +}); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS — all suites, 35 tests total. + +- [ ] **Step 6: Smoke-test the running server** + +Run: `npm run dev` (in one terminal), then: + +```bash +curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/status +curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \ + -d '{"text":"hello"}' http://localhost:3000/v1/transform +curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \ + -d '{"wrong":1}' http://localhost:3000/v1/transform +``` + +Expected: `{"status":"ok",...}`, then `{"output":"HELLO",...}`, then a 400 validation error. Open http://localhost:3000/docs in a browser — Swagger UI renders the spec. Stop the server (Ctrl+C). + +- [ ] **Step 7: Commit** + +```bash +git add openapi.yaml src/app.ts src/index.ts tests/app.test.ts +git commit -m "feat: add OpenAPI-driven metered API with Swagger docs" +``` + +--- + +### Task 6: Monthly credit quotas + +**Files:** +- Create: `src/billing/credit.ts` +- Test: `tests/credit.test.ts` +- Modify: `src/app.ts` (`GET /v1/usage` route) +- Modify: `tests/app.test.ts` (usage-summary test) + +**Interfaces:** +- Consumes: `UsageSummary` (Task 2); `TierConfig`, `PricingContext` (Task 1); `buildApp`'s `pricing` and `usage` (Task 5). +- Produces: + - `export interface BilledSummary extends UsageSummary { includedCents: number; billableCents: number }` + - `export function applyMonthlyCredit(summary: UsageSummary, tier: TierConfig): BilledSummary` — `includedCents = min(totalCents, tier.monthlyCreditCents)`, `billableCents = totalCents - includedCents`. Used by the `/v1/usage` route here and by the Stripe job in Task 9. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/credit.test.ts`: + +```ts +import { applyMonthlyCredit } from '../src/billing/credit'; +import { DEFAULT_TIERS } from '../src/pricing'; +import { UsageSummary } from '../src/usage'; + +const freeTier = DEFAULT_TIERS.find((t) => t.id === 'free')!; // monthlyCreditCents: 100 + +const summary = (totalCents: number): UsageSummary => ({ + customerId: 'cust_1', + totalCents, + calls: 1, + byEndpoint: {}, +}); + +describe('applyMonthlyCredit', () => { + it('covers usage fully when under the monthly credit', () => { + const billed = applyMonthlyCredit(summary(60), freeTier); + expect(billed.includedCents).toBe(60); + expect(billed.billableCents).toBe(0); + expect(billed.totalCents).toBe(60); + }); + + it('bills only the overage when usage exceeds the credit', () => { + const billed = applyMonthlyCredit(summary(250), freeTier); + expect(billed.includedCents).toBe(100); + expect(billed.billableCents).toBe(150); + }); + + it('bills nothing when there is no usage', () => { + const billed = applyMonthlyCredit(summary(0), freeTier); + expect(billed.includedCents).toBe(0); + expect(billed.billableCents).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/credit.test.ts` +Expected: FAIL — `Cannot find module '../src/billing/credit'`. + +- [ ] **Step 3: Implement the credit module** + +Create `src/billing/credit.ts`: + +```ts +import { TierConfig } from '../pricing'; +import { UsageSummary } from '../usage'; + +export interface BilledSummary extends UsageSummary { + includedCents: number; + billableCents: number; +} + +export function applyMonthlyCredit( + summary: UsageSummary, + tier: TierConfig, +): BilledSummary { + const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents); + return { + ...summary, + includedCents, + billableCents: summary.totalCents - includedCents, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/credit.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Wire the credit into `GET /v1/usage`** + +In `src/app.ts`, add the import: + +```ts +import { applyMonthlyCredit } from './billing/credit'; +``` + +Replace the `/v1/usage` route with: + +```ts + app.get('/v1/usage', (req, res) => { + const summary = usage.summaryFor(req.customer!.id); + const tier = pricing.tiers.find(req.customer!.tierId); + res.json(tier ? applyMonthlyCredit(summary, tier) : summary); + }); +``` + +Replace the usage test in `tests/app.test.ts` with: + +```ts + it('GET /v1/usage returns the caller summary with monthly credit applied', async () => { + const { app } = buildApp(); + await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'x' }); + const res = await request(app).get('/v1/usage').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.customerId).toBe('cust_1'); + expect(res.body.calls).toBe(1); + expect(res.body.totalCents).toBe(4); + expect(res.body.includedCents).toBe(4); // free-tier credit (100) covers it + expect(res.body.billableCents).toBe(0); + }); +``` + +- [ ] **Step 6: Run the full suite** + +Run: `npm test` +Expected: PASS — 38 tests total. + +- [ ] **Step 7: Commit** + +```bash +git add src/billing/credit.ts src/app.ts tests/credit.test.ts tests/app.test.ts +git commit -m "feat: apply monthly credit quotas to usage summaries" +``` + +--- + +### Task 7: SQLite persistence (usage, customers, pricing) + +**Files:** +- Modify: `package.json` (via npm install) +- Create: `src/db/usage-repo.ts` +- Create: `src/db/customer-repo.ts` +- Create: `src/db/pricing-store.ts` +- Modify: `src/index.ts` (inject SQLite repos + store) +- Test: `tests/db-usage.test.ts` +- Test: `tests/db-customer.test.ts` +- Test: `tests/db-pricing.test.ts` + +**Interfaces:** +- Consumes: `UsageRepo`, `UsageEntry`, `UsageSummary`, `summarize` (Task 2); `CustomerRepo`, `Customer` (Task 3); `PricingStore`, `PriceRule`, `TierConfig`, `RateCard`, `DEFAULT_RATE_CARD`, `DEFAULT_TIERS` (Task 1); `AppDeps`, `DEFAULT_CUSTOMERS`, `buildApp` (Task 5). +- Produces: + - `export class SqliteUsageRepo implements UsageRepo` — `constructor(db: Database.Database)`; creates table `usage_entries` on first use. + - `export class SqliteCustomerRepo implements CustomerRepo` — `constructor(db: Database.Database, seed?: Customer[])`; creates table `customers` (with `multiplier_override`) and inserts `seed` only when the table is empty. Implements `save` as SQL upsert. + - `export class SqlitePricingStore implements PricingStore` — `constructor(db: Database.Database, seedCard?: RateCard, seedTiers?: TierConfig[])`; creates tables `price_endpoints` + `tiers` and seeds them only when empty. + - All three are injected into `buildApp({ usage, customers, pricingStore })` from `src/index.ts`; no route or middleware changes. + +- [ ] **Step 1: Install better-sqlite3** + +Run: `cd /Users/marchon/zappier && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3` +Expected: both added to `package.json`. + +- [ ] **Step 2: Write the failing tests** + +Create `tests/db-usage.test.ts`: + +```ts +import Database from 'better-sqlite3'; +import { SqliteUsageRepo } from '../src/db/usage-repo'; +import { UsageEntry } from '../src/usage'; + +const entry = (over: Partial = {}): UsageEntry => ({ + customerId: 'cust_1', + endpointId: 'transform', + cents: 4, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date('2026-07-27T10:00:00Z'), + ...over, +}); + +describe('SqliteUsageRepo', () => { + it('records and lists entries per customer', () => { + const repo = new SqliteUsageRepo(new Database(':memory:')); + repo.record(entry()); + repo.record(entry({ customerId: 'cust_2' })); + expect(repo.listFor('cust_1')).toHaveLength(1); + expect(repo.listFor('cust_2')).toHaveLength(1); + }); + + it('filters entries by since date', () => { + const repo = new SqliteUsageRepo(new Database(':memory:')); + repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') })); + repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') })); + expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1); + }); + + it('summarizes totals by endpoint', () => { + const repo = new SqliteUsageRepo(new Database(':memory:')); + repo.record(entry()); + repo.record(entry({ endpointId: 'storage', cents: 112 })); + const s = repo.summaryFor('cust_1'); + expect(s.calls).toBe(2); + expect(s.totalCents).toBe(116); + expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 }); + }); +}); +``` + +Create `tests/db-customer.test.ts`: + +```ts +import Database from 'better-sqlite3'; +import { Customer } from '../src/auth'; +import { SqliteCustomerRepo } from '../src/db/customer-repo'; + +const customer = (apiKey: string): Customer => ({ + id: 'cust_1', + name: 'Ada', + tierId: 'pro', + apiKey, + stripeCustomerId: 'cus_123', +}); + +describe('SqliteCustomerRepo', () => { + it('finds a customer by API key', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + expect(repo.findByApiKey('key-ada')?.tierId).toBe('pro'); + }); + + it('returns undefined for an unknown key', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + expect(repo.findByApiKey('wrong')).toBeUndefined(); + }); + + it('lists all customers with their Stripe ids', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + const all = repo.list(); + expect(all).toHaveLength(1); + expect(all[0].stripeCustomerId).toBe('cus_123'); + }); + + it('seeds only when the table is empty', () => { + const db = new Database(':memory:'); + new SqliteCustomerRepo(db, [customer('key-ada')]); + const again = new SqliteCustomerRepo(db, [customer('key-other')]); + expect(again.list().map((c) => c.apiKey)).toEqual(['key-ada']); + }); + + it('save() upserts including the multiplier override', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + repo.save({ ...customer('key-ada'), tierId: 'business', multiplierOverride: 0.4 }); + const updated = repo.findByApiKey('key-ada'); + expect(updated?.tierId).toBe('business'); + expect(updated?.multiplierOverride).toBe(0.4); + expect(repo.list()).toHaveLength(1); + }); +}); +``` + +Create `tests/db-pricing.test.ts`: + +```ts +import Database from 'better-sqlite3'; +import { SqlitePricingStore } from '../src/db/pricing-store'; + +describe('SqlitePricingStore', () => { + it('seeds the default rate card and tiers when empty', () => { + const store = new SqlitePricingStore(new Database(':memory:')); + expect(store.getRateCard().endpoints.transform).toEqual({ kind: 'fixed', fixedCents: 4 }); + expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('seeds only once', () => { + const db = new Database(':memory:'); + const first = new SqlitePricingStore(db); + first.deleteEndpoint('transform'); + const second = new SqlitePricingStore(db); + expect(second.getRateCard().endpoints.transform).toBeUndefined(); + }); + + it('upserts and deletes endpoints', () => { + const store = new SqlitePricingStore(new Database(':memory:')); + store.upsertEndpoint('experimental', { kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20 }); + expect(store.getRateCard().endpoints.experimental).toEqual({ + kind: 'variable', + baseCents: 3, + perKbCents: 2, + perMbCents: 20, + }); + store.deleteEndpoint('experimental'); + expect(store.getRateCard().endpoints.experimental).toBeUndefined(); + }); + + it('upserts tiers including default rules, and deletes them', () => { + const store = new SqlitePricingStore(new Database(':memory:')); + store.upsertTier({ + id: 'edu', + name: 'Education', + multiplier: 0.4, + monthlyCreditCents: 500, + defaultRule: { kind: 'fixed', fixedCents: 6 }, + }); + expect(store.getTiers().find((t) => t.id === 'edu')?.defaultRule).toEqual({ + kind: 'fixed', + fixedCents: 6, + }); + store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 }); + expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1); + expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.3); + store.deleteTier('edu'); + expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm test -- tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts` +Expected: FAIL — `Cannot find module '../src/db/...'`. + +- [ ] **Step 4: Implement the SQLite repos and pricing store** + +Create `src/db/usage-repo.ts`: + +```ts +import Database from 'better-sqlite3'; +import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage'; + +export class SqliteUsageRepo implements UsageRepo { + constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS usage_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + cents INTEGER NOT NULL, + metadata_bytes INTEGER NOT NULL, + attachment_bytes INTEGER NOT NULL, + timestamp_ms INTEGER NOT NULL + ) + `); + } + + record(entry: UsageEntry): void { + this.db + .prepare( + `INSERT INTO usage_entries + (customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + entry.customerId, + entry.endpointId, + entry.cents, + entry.metadataBytes, + entry.attachmentBytes, + entry.timestamp.getTime(), + ); + } + + listFor(customerId: string, since?: Date): UsageEntry[] { + const rows = ( + since + ? this.db + .prepare( + 'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms', + ) + .all(customerId, since.getTime()) + : this.db + .prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms') + .all(customerId) + ) as Record[]; + return rows.map((r) => ({ + customerId: r.customer_id as string, + endpointId: r.endpoint_id as string, + cents: r.cents as number, + metadataBytes: r.metadata_bytes as number, + attachmentBytes: r.attachment_bytes as number, + timestamp: new Date(r.timestamp_ms as number), + })); + } + + summaryFor(customerId: string, since?: Date): UsageSummary { + return summarize(customerId, this.listFor(customerId, since)); + } +} +``` + +Create `src/db/customer-repo.ts`: + +```ts +import Database from 'better-sqlite3'; +import { Customer, CustomerRepo } from '../auth'; + +export class SqliteCustomerRepo implements CustomerRepo { + constructor(private db: Database.Database, seed: Customer[] = []) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS customers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + tier_id TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + stripe_customer_id TEXT, + multiplier_override REAL + ) + `); + const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }; + if (n === 0) { + for (const c of seed) this.save(c); + } + } + + findByApiKey(apiKey: string): Customer | undefined { + const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as + | Record + | undefined; + return r ? toCustomer(r) : undefined; + } + + list(): Customer[] { + const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record< + string, + unknown + >[]; + return rows.map(toCustomer); + } + + save(customer: Customer): void { + this.db + .prepare( + `INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override) + VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride) + ON CONFLICT(id) DO UPDATE SET + name = @name, + tier_id = @tierId, + api_key = @apiKey, + stripe_customer_id = @stripeCustomerId, + multiplier_override = @multiplierOverride`, + ) + .run({ + id: customer.id, + name: customer.name, + tierId: customer.tierId, + apiKey: customer.apiKey, + stripeCustomerId: customer.stripeCustomerId ?? null, + multiplierOverride: customer.multiplierOverride ?? null, + }); + } +} + +function toCustomer(r: Record): Customer { + return { + id: r.id as string, + name: r.name as string, + tierId: r.tier_id as string, + apiKey: r.api_key as string, + stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined, + multiplierOverride: (r.multiplier_override as number | null) ?? undefined, + }; +} +``` + +Create `src/db/pricing-store.ts`: + +```ts +import Database from 'better-sqlite3'; +import { + DEFAULT_RATE_CARD, + DEFAULT_TIERS, + PriceRule, + PricingStore, + RateCard, + TierConfig, +} from '../pricing'; + +export class SqlitePricingStore implements PricingStore { + constructor( + private db: Database.Database, + seedCard: RateCard = DEFAULT_RATE_CARD, + seedTiers: TierConfig[] = DEFAULT_TIERS, + ) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS price_endpoints ( + endpoint_id TEXT PRIMARY KEY, + rule_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS tiers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + multiplier REAL NOT NULL, + monthly_credit_cents INTEGER NOT NULL, + default_rule_json TEXT + ) + `); + const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM price_endpoints').get() as { n: number }; + if (n === 0) { + for (const [id, rule] of Object.entries(seedCard.endpoints)) { + this.upsertEndpoint(id, rule); + } + for (const tier of seedTiers) { + this.upsertTier(tier); + } + } + } + + getRateCard(): RateCard { + const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record[]; + const endpoints: Record = {}; + for (const r of rows) { + endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule; + } + return { endpoints }; + } + + getTiers(): TierConfig[] { + const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record[]; + return rows.map((r) => ({ + id: r.id as string, + name: r.name as string, + multiplier: r.multiplier as number, + monthlyCreditCents: r.monthly_credit_cents as number, + defaultRule: r.default_rule_json + ? (JSON.parse(r.default_rule_json as string) as PriceRule) + : undefined, + })); + } + + upsertEndpoint(endpointId: string, rule: PriceRule): void { + this.db + .prepare( + `INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?) + ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`, + ) + .run(endpointId, JSON.stringify(rule)); + } + + deleteEndpoint(endpointId: string): void { + this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId); + } + + upsertTier(tier: TierConfig): void { + this.db + .prepare( + `INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json) + VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson) + ON CONFLICT(id) DO UPDATE SET + name = @name, + multiplier = @multiplier, + monthly_credit_cents = @monthlyCreditCents, + default_rule_json = @defaultRuleJson`, + ) + .run({ + id: tier.id, + name: tier.name, + multiplier: tier.multiplier, + monthlyCreditCents: tier.monthlyCreditCents, + defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null, + }); + } + + deleteTier(tierId: string): void { + this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId); + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS — 50 tests total. + +- [ ] **Step 6: Inject SQLite into the server** + +Replace `src/index.ts` with: + +```ts +import Database from 'better-sqlite3'; +import { buildApp, DEFAULT_CUSTOMERS } from './app'; +import { SqliteCustomerRepo } from './db/customer-repo'; +import { SqlitePricingStore } from './db/pricing-store'; +import { SqliteUsageRepo } from './db/usage-repo'; + +const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db'); +const usage = new SqliteUsageRepo(db); +const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS); +const pricingStore = new SqlitePricingStore(db); + +const { app } = buildApp({ usage, customers, pricingStore }); +const port = Number(process.env.PORT ?? 3000); +app.listen(port, () => { + console.log(`Zappier API listening on http://localhost:${port}`); + console.log(`OpenAPI docs at http://localhost:${port}/docs`); +}); +``` + +- [ ] **Step 7: Verify persistence across restarts** + +```bash +cd /Users/marchon/zappier +npm run dev & +sleep 2 +curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \ + -d '{"text":"hello"}' http://localhost:3000/v1/transform +kill %1 +npm run dev & +sleep 2 +curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/usage +kill %1 +``` + +Expected: the second `/v1/usage` response still shows `"calls":1,"totalCents":4` — usage survived the restart. Delete the smoke-test database afterwards: `rm -f zappier.db`. + +- [ ] **Step 8: Commit** + +```bash +git add package.json package-lock.json src/db src/index.ts tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts +git commit -m "feat: persist usage, customers, and pricing in SQLite" +``` + +--- + +### Task 8: Admin API + pricing web UI + +**Files:** +- Create: `src/admin.ts` +- Create: `admin/index.html` +- Create: `admin/app.js` +- Modify: `src/app.ts` (mount admin router + static UI) +- Test: `tests/admin.test.ts` + +**Interfaces:** +- Consumes: `PricingStore`, `PriceRule`, `TierConfig` (Task 1); `CustomerRepo`, `Customer` (Task 3); `buildApp` (Task 5). +- Produces: + - `export function adminAuth(): RequestHandler` — requires `x-admin-key` = `process.env.ADMIN_KEY ?? 'admin-dev-key'`, else 403. + - `export function adminRouter(store: PricingStore, customers: CustomerRepo): Router` with routes: + - `GET /pricing` → `{ rateCard, tiers }` + - `PUT /endpoints/:id` body `PriceRule` → upsert (400 on invalid rule) + - `DELETE /endpoints/:id` + - `PUT /tiers/:id` body `TierConfig` → upsert (400 on invalid config) + - `DELETE /tiers/:id` + - `GET /customers` → `{ customers }` (apiKey masked) + - `POST /customers` body `{ name, tierId }` → 201 with the created customer including its generated `apiKey` (shown once) + - `PUT /customers/:id` body partial `{ name?, tierId?, multiplierOverride?, stripeCustomerId? }` → update (404 on unknown id) + - `buildApp` mounts these at `/admin/api` (behind `adminAuth`) and serves the static UI at `/admin`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/admin.test.ts`: + +```ts +import request from 'supertest'; +import { buildApp } from '../src/app'; + +const ADMIN = { 'x-admin-key': 'admin-dev-key' }; +const KEY = 'key-ada'; + +describe('admin API', () => { + it('rejects calls without an admin key', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/pricing'); + expect(res.status).toBe(403); + }); + + it('returns the current pricing', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/pricing').set(ADMIN); + expect(res.status).toBe(200); + expect(res.body.rateCard.endpoints.status).toEqual({ kind: 'free' }); + expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('rejects an invalid price rule with 400', async () => { + const { app } = buildApp(); + const res = await request(app) + .put('/admin/api/endpoints/transform') + .set(ADMIN) + .send({ kind: 'sometimes' }); + expect(res.status).toBe(400); + }); + + it('reprices an endpoint live, without restart', async () => { + const { app } = buildApp(); + await request(app) + .put('/admin/api/endpoints/transform') + .set(ADMIN) + .send({ kind: 'fixed', fixedCents: 10 }); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'hi' }); + expect(res.body.quote.totalCents).toBe(10); // was 4 + }); + + it('creates a customer type live and prices calls for it', async () => { + const { app } = buildApp(); + await request(app) + .put('/admin/api/tiers/edu') + .set(ADMIN) + .send({ id: 'edu', name: 'Education', multiplier: 0.5, monthlyCreditCents: 500 }); + const created = await request(app) + .post('/admin/api/customers') + .set(ADMIN) + .send({ name: 'School', tierId: 'edu' }); + expect(created.status).toBe(201); + expect(created.body.apiKey).toMatch(/^key-/); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', created.body.apiKey) + .send({ text: 'hi' }); + expect(res.body.quote.totalCents).toBe(2); // list 4 x 0.5 + }); + + it('sets a per-customer multiplier override live', async () => { + const { app } = buildApp(); + const res = await request(app) + .put('/admin/api/customers/cust_1') + .set(ADMIN) + .send({ multiplierOverride: 0.5 }); + expect(res.status).toBe(200); + const call = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'hi' }); + expect(call.body.quote.totalCents).toBe(2); // was 4 + }); + + it('masks API keys in the customer list and 404s unknown customers', async () => { + const { app } = buildApp(); + const list = await request(app).get('/admin/api/customers').set(ADMIN); + expect(list.status).toBe(200); + expect(JSON.stringify(list.body)).not.toContain('key-ada'); + const missing = await request(app) + .put('/admin/api/customers/cust_nope') + .set(ADMIN) + .send({ tierId: 'pro' }); + expect(missing.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/admin.test.ts` +Expected: FAIL — admin routes 404 (and `src/admin.ts` missing). + +- [ ] **Step 3: Implement the admin router** + +Create `src/admin.ts`: + +```ts +import { randomBytes, randomUUID } from 'crypto'; +import { RequestHandler, Router } from 'express'; +import { CustomerRepo } from './auth'; +import { PriceRule, PricingStore, TierConfig } from './pricing'; + +export function adminAuth(): RequestHandler { + return (req, res, next) => { + const expected = process.env.ADMIN_KEY ?? 'admin-dev-key'; + if (req.header('x-admin-key') !== expected) { + res.status(403).json({ error: 'invalid or missing admin key' }); + return; + } + next(); + }; +} + +function isValidRule(rule: unknown): rule is PriceRule { + if (!rule || typeof rule !== 'object') return false; + const r = rule as Record; + if (r.kind === 'free') return true; + if (r.kind === 'fixed') return typeof r.fixedCents === 'number'; + if (r.kind === 'variable') { + return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number'); + } + return false; +} + +function isValidTier(tier: unknown): tier is TierConfig { + if (!tier || typeof tier !== 'object') return false; + const t = tier as Record; + return ( + typeof t.name === 'string' && + typeof t.multiplier === 'number' && + typeof t.monthlyCreditCents === 'number' && + (t.defaultRule === undefined || isValidRule(t.defaultRule)) + ); +} + +export function adminRouter(store: PricingStore, customers: CustomerRepo): Router { + const router = Router(); + + router.get('/pricing', (req, res) => { + res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() }); + }); + + router.put('/endpoints/:id', (req, res) => { + if (!isValidRule(req.body)) { + res.status(400).json({ error: 'invalid price rule' }); + return; + } + store.upsertEndpoint(req.params.id, req.body); + res.json({ ok: true }); + }); + + router.delete('/endpoints/:id', (req, res) => { + store.deleteEndpoint(req.params.id); + res.json({ ok: true }); + }); + + router.put('/tiers/:id', (req, res) => { + if (!isValidTier(req.body)) { + res.status(400).json({ error: 'invalid tier config' }); + return; + } + store.upsertTier({ ...req.body, id: req.params.id }); + res.json({ ok: true }); + }); + + router.delete('/tiers/:id', (req, res) => { + store.deleteTier(req.params.id); + res.json({ ok: true }); + }); + + router.get('/customers', (req, res) => { + res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) }); + }); + + router.post('/customers', (req, res) => { + const { name, tierId } = req.body ?? {}; + if (typeof name !== 'string' || typeof tierId !== 'string') { + res.status(400).json({ error: 'name and tierId required' }); + return; + } + const customer = { + id: `cust_${randomUUID().slice(0, 8)}`, + name, + tierId, + apiKey: `key-${randomBytes(12).toString('hex')}`, + }; + customers.save(customer); + res.status(201).json(customer); + }); + + router.put('/customers/:id', (req, res) => { + const existing = customers.list().find((c) => c.id === req.params.id); + if (!existing) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const { name, tierId, multiplierOverride, stripeCustomerId } = req.body ?? {}; + customers.save({ + ...existing, + ...(name !== undefined ? { name } : {}), + ...(tierId !== undefined ? { tierId } : {}), + ...(multiplierOverride !== undefined ? { multiplierOverride } : {}), + ...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}), + }); + res.json({ ok: true }); + }); + + return router; +} +``` + +- [ ] **Step 4: Mount the admin API and static UI in the app** + +In `src/app.ts`, add the import: + +```ts +import { adminAuth, adminRouter } from './admin'; +``` + +Add these two lines immediately after `app.use('/docs', ...)` (before the `/v1` auth middleware): + +```ts + app.use('/admin/api', adminAuth(), adminRouter(pricingStore, customers)); + app.use('/admin', express.static(path.join(process.cwd(), 'admin'))); +``` + +- [ ] **Step 5: Build the admin web UI** + +Create `admin/index.html`: + +```html + + + + + + Zappier Pricing Admin + + + +

    Zappier Pricing Admin

    + +
    + + +

    + + + +``` + +Create `admin/app.js`: + +```js +const state = { pricing: null, customers: [] }; + +function adminKey() { + let key = localStorage.getItem('zappier-admin-key'); + if (!key) { + key = prompt('Admin key:'); + localStorage.setItem('zappier-admin-key', key); + } + return key; +} + +async function api(path, options = {}) { + const res = await fetch(`/admin/api${path}`, { + ...options, + headers: { 'content-type': 'application/json', 'x-admin-key': adminKey() }, + }); + if (res.status === 403) { + localStorage.removeItem('zappier-admin-key'); + throw new Error('Admin key rejected — reload to re-enter.'); + } + if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`); + return res.json(); +} + +function say(msg) { + document.getElementById('status').textContent = msg; +} + +async function load() { + state.pricing = await api('/pricing'); + state.customers = (await api('/customers')).customers; + renderEndpoints(); + renderTiers(); + renderCustomers(); +} + +function ruleInputs(id, rule) { + const fields = + rule.kind === 'fixed' + ? { fixedCents: rule.fixedCents } + : rule.kind === 'variable' + ? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents } + : {}; + return Object.entries(fields) + .map( + ([k, v]) => + `${k} `, + ) + .join(' '); +} + +function renderEndpoints() { + const rows = Object.entries(state.pricing.rateCard.endpoints) + .map( + ([id, rule]) => ` + ${id} + + ${ruleInputs(id, rule)} + + + + + `, + ) + .join(''); + document.getElementById('endpoints').innerHTML = ` +

    Rate card

    + ${rows}
    Endpoint (operationId)KindPrices (cents)
    +

    Add endpoint

    + + + `; +} + +async function saveEndpoint(id) { + const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value; + const rule = { kind }; + document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => { + rule[el.dataset.field] = Number(el.value); + }); + if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0; + if (kind === 'variable') { + rule.baseCents = rule.baseCents ?? 0; + rule.perKbCents = rule.perKbCents ?? 0; + rule.perMbCents = rule.perMbCents ?? 0; + } + await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) }); + say(`Saved ${id}.`); + await load(); +} + +async function deleteEndpoint(id) { + await api(`/endpoints/${id}`, { method: 'DELETE' }); + say(`Deleted ${id}.`); + await load(); +} + +async function addEndpoint() { + const id = document.getElementById('new-endpoint-id').value.trim(); + const kind = document.getElementById('new-endpoint-kind').value; + if (!id) return say('Endpoint id required.'); + const rule = + kind === 'free' + ? { kind } + : kind === 'fixed' + ? { kind, fixedCents: 0 } + : { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 }; + await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) }); + say(`Added ${id}.`); + await load(); +} + +function renderTiers() { + const rows = state.pricing.tiers + .map( + (t) => ` + ${t.id} + + + + + + + + `, + ) + .join(''); + document.getElementById('tiers').innerHTML = ` +

    Customer types

    + ${rows}
    IdNameMultiplierMonthly credit (cents)
    +

    Add customer type

    + + + multiplier + `; +} + +async function saveTier(id) { + const body = { id }; + document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => { + body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value; + }); + const existing = state.pricing.tiers.find((t) => t.id === id); + if (existing?.defaultRule) body.defaultRule = existing.defaultRule; + await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) }); + say(`Saved tier ${id}.`); + await load(); +} + +async function deleteTier(id) { + await api(`/tiers/${id}`, { method: 'DELETE' }); + say(`Deleted tier ${id}.`); + await load(); +} + +async function addTier() { + const id = document.getElementById('new-tier-id').value.trim(); + const name = document.getElementById('new-tier-name').value.trim(); + const multiplier = Number(document.getElementById('new-tier-multiplier').value); + if (!id || !name) return say('Tier id and name required.'); + await api(`/tiers/${id}`, { + method: 'PUT', + body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }), + }); + say(`Added tier ${id}.`); + await load(); +} + +function renderCustomers() { + const tierOptions = (selected) => + state.pricing.tiers + .map((t) => ``) + .join(''); + const rows = state.customers + .map( + (c) => ` + ${c.id} + ${c.name} + + + + `, + ) + .join(''); + document.getElementById('customers').innerHTML = ` +

    Customers

    + ${rows}
    IdNameTypeMultiplier override
    +

    Add customer

    + + + +

    A new customer's API key is shown once in the status line below.

    `; +} + +async function saveCustomer(id) { + const body = {}; + document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => { + if (el.value === '') return; + body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value; + }); + await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) }); + say(`Saved customer ${id}.`); + await load(); +} + +async function addCustomer() { + const name = document.getElementById('new-customer-name').value.trim(); + const tierId = document.getElementById('new-customer-tier').value; + if (!name) return say('Customer name required.'); + const created = await api('/customers', { + method: 'POST', + body: JSON.stringify({ name, tierId }), + }); + say(`Created ${created.id} — API key: ${created.apiKey}`); + await load(); +} + +document.querySelectorAll('nav button').forEach((btn) => + btn.addEventListener('click', () => { + document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('section').forEach((s) => (s.hidden = true)); + document.getElementById(btn.dataset.tab).hidden = false; + }), +); + +load().catch((err) => say(err.message)); +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS — 57 tests total. + +- [ ] **Step 7: Verify the UI end-to-end (manual)** + +1. `npm run dev`, open http://localhost:3000/admin, enter `admin-dev-key`. +2. In **Rate card**, change `transform` to `fixedCents: 10`, Save. +3. `curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' -d '{"text":"hi"}' http://localhost:3000/v1/transform` → quote shows `totalCents: 10` (no restart). +4. In **Customer types**, add `edu` at 0.5×; in **Customers**, create a customer on `edu` and call the API with its new key. +5. Stop the server. Delete the smoke-test database: `rm -f zappier.db`. + +- [ ] **Step 8: Commit** + +```bash +git add src/admin.ts src/app.ts admin tests/admin.test.ts +git commit -m "feat: add admin API and web UI for live pricing management" +``` + +--- + +### Task 9: Stripe metered billing + +**Files:** +- Create: `src/billing/stripe.ts` +- Create: `src/jobs/report-usage.ts` +- Test: `tests/stripe.test.ts` + +**Interfaces:** +- Consumes: `UsageEntry` (Task 2); `PricingStore` (Task 1) / `SqlitePricingStore` (Task 7) for tier credits; `SqliteUsageRepo`, `SqliteCustomerRepo` (Task 7); `DEFAULT_CUSTOMERS` (Task 5); `Customer.stripeCustomerId` / `tierId` (Task 3); the credit semantics from Task 6. +- Produces: + - `export const METER_EVENT_NAME = 'zappier.api_cents'` + - `export interface MeterEventClient { createMeterEvent(params: { eventName: string; customerId: string; value: string }): Promise }` + - `export async function reportUsage(client: MeterEventClient, stripeCustomerId: string, entries: UsageEntry[], monthlyCreditCents: number): Promise` — sums entry cents, subtracts the monthly credit (floor 0), sends one meter event when the billable amount is > 0, returns the reported cents. + +- [ ] **Step 1: Create the Stripe account and billing meter (manual)** + +1. Sign up / log in at https://dashboard.stripe.com (test mode is fine). +2. Create a product per customer type: `Zappier Free`, `Zappier Pro`, `Zappier Business`. +3. Under **Billing → Meters**, create a meter named `zappier.api_cents`, aggregation = **Sum** of `value`. +4. Add a metered price to each tier's subscription using that meter. +5. Copy the test secret key (`sk_test_...`) for the job below. + +Expected: meter visible in the dashboard with event name `zappier.api_cents`. + +- [ ] **Step 2: Write the failing tests** + +Create `tests/stripe.test.ts`: + +```ts +import { MeterEventClient, reportUsage, METER_EVENT_NAME } from '../src/billing/stripe'; +import { UsageEntry } from '../src/usage'; + +const entry = (cents: number): UsageEntry => ({ + customerId: 'cust_1', + endpointId: 'storage', + cents, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date(), +}); + +const fakeClient = () => { + const calls: { eventName: string; customerId: string; value: string }[] = []; + const client: MeterEventClient = { + createMeterEvent: async (params) => { + calls.push(params); + }, + }; + return { client, calls }; +}; + +describe('reportUsage', () => { + it('reports usage minus the monthly credit as one meter event', async () => { + const { client, calls } = fakeClient(); + const reported = await reportUsage(client, 'cus_123', [entry(112), entry(4)], 100); + expect(reported).toBe(16); // 116 - 100 credit + expect(calls).toEqual([ + { eventName: METER_EVENT_NAME, customerId: 'cus_123', value: '16' }, + ]); + }); + + it('reports nothing when the credit covers all usage', async () => { + const { client, calls } = fakeClient(); + const reported = await reportUsage(client, 'cus_123', [entry(4)], 100); + expect(reported).toBe(0); + expect(calls).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm test -- tests/stripe.test.ts` +Expected: FAIL — `Cannot find module '../src/billing/stripe'`. + +- [ ] **Step 4: Implement the billing module and job** + +Create `src/billing/stripe.ts`: + +```ts +import { UsageEntry } from '../usage'; + +export const METER_EVENT_NAME = 'zappier.api_cents'; + +export interface MeterEventClient { + createMeterEvent(params: { + eventName: string; + customerId: string; + value: string; + }): Promise; +} + +export async function reportUsage( + client: MeterEventClient, + stripeCustomerId: string, + entries: UsageEntry[], + monthlyCreditCents: number, +): Promise { + const totalCents = entries.reduce((sum, e) => sum + e.cents, 0); + const billable = Math.max(0, totalCents - monthlyCreditCents); + if (billable <= 0) return 0; + await client.createMeterEvent({ + eventName: METER_EVENT_NAME, + customerId: stripeCustomerId, + value: String(billable), + }); + return billable; +} +``` + +Create `src/jobs/report-usage.ts` (run monthly, e.g. `STRIPE_SECRET_KEY=sk_test_... npx ts-node src/jobs/report-usage.ts`): + +```ts +import Database from 'better-sqlite3'; +import Stripe from 'stripe'; +import { DEFAULT_CUSTOMERS } from '../app'; +import { METER_EVENT_NAME, reportUsage } from '../billing/stripe'; +import { SqliteCustomerRepo } from '../db/customer-repo'; +import { SqlitePricingStore } from '../db/pricing-store'; +import { SqliteUsageRepo } from '../db/usage-repo'; + +async function main(): Promise { + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + const client = { + createMeterEvent: async (p: { eventName: string; customerId: string; value: string }) => { + await stripe.billing.meterEvents.create({ + event_name: METER_EVENT_NAME, + payload: { stripe_customer_id: p.customerId, value: p.value }, + }); + }, + }; + + const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db'); + const usage = new SqliteUsageRepo(db); + const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS); + const pricingStore = new SqlitePricingStore(db); + const tiers = pricingStore.getTiers(); + + const since = new Date(); + since.setUTCDate(1); + since.setUTCHours(0, 0, 0, 0); + + for (const customer of customers.list()) { + if (!customer.stripeCustomerId) continue; + const tier = tiers.find((t) => t.id === customer.tierId); + if (!tier) { + console.warn(`${customer.id}: unknown tier ${customer.tierId}, skipped`); + continue; + } + const entries = usage.listFor(customer.id, since); + const reported = await reportUsage( + client, + customer.stripeCustomerId, + entries, + tier.monthlyCreditCents, + ); + console.log(`${customer.id}: reported ${reported} billable cents to Stripe`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS — 59 tests total. + +- [ ] **Step 6: Commit** + +```bash +git add src/billing/stripe.ts src/jobs/report-usage.ts tests/stripe.test.ts +git commit -m "feat: report credit-adjusted monthly usage to Stripe billing meters" +``` + +--- + +### Task 10: Zapier app — account, CLI, auth, trigger, action, publish + +**Files:** +- Create: `zapier-app/package.json` +- Create: `zapier-app/index.js` +- Create: `zapier-app/authentication.js` +- Create: `zapier-app/triggers/new_item.js` +- Create: `zapier-app/creates/store_data.js` +- Test: `zapier-app/test/app.test.js` + +**Interfaces:** +- Consumes: the live API from Task 5 (`GET /v1/status`, `GET /v1/storage`, `POST /v1/storage`) with `x-api-key` auth. Pricing changes from Tasks 1–9 require **no** changes here — the Zapier app only calls endpoints; the server prices them. +- Produces: a Zapier integration named `zappier` with: + - Custom auth fields: `baseUrl` (default `http://localhost:3000`), `apiKey` (password). + - Trigger `new_item` (polling): returns `GET {baseUrl}/v1/storage` → `items` array. + - Action `store_data`: POSTs multipart `metadata` (JSON string of `{ title, note }`) + optional `attachments` file to `{baseUrl}/v1/storage`. + - `bundle.authData.apiKey` is injected as the `x-api-key` header on every request via a `beforeRequest`. + +- [ ] **Step 1: Create the Zapier account (manual)** + +1. Sign up at https://zapier.com (free plan is enough to build). +2. Open https://developer.zapier.com and accept the developer terms. +3. Install the CLI and log in: + +```bash +npm install -g zapier-platform-cli +zapier login +``` + +Expected: browser OAuth completes; `zapier apps` runs without auth errors. + +- [ ] **Step 2: Scaffold the app package** + +```bash +mkdir -p /Users/marchon/zappier/zapier-app/triggers /Users/marchon/zappier/zapier-app/creates /Users/marchon/zappier/zapier-app/test +``` + +Create `zapier-app/package.json`: + +```json +{ + "name": "zappier", + "version": "1.0.0", + "description": "Store data and files through the metered Zappier API.", + "main": "index.js", + "scripts": { + "test": "mocha --recursive --timeout 10000" + } +} +``` + +Run: `cd /Users/marchon/zappier/zapier-app && npm install zapier-platform-core form-data && npm install --save-dev mocha` +Expected: dependencies written into `package.json`. + +- [ ] **Step 3: Write the failing tests** + +Create `zapier-app/test/app.test.js`: + +```js +const assert = require('assert'); +const App = require('../index'); +const storeData = require('../creates/store_data'); + +describe('Zapier app definition', () => { + it('exposes custom auth, one trigger, and one action', () => { + assert.equal(App.authentication.type, 'custom'); + assert.ok(App.triggers.new_item); + assert.ok(App.creates.store_data); + assert.equal(App.beforeRequest.length, 1); + }); +}); + +describe('store_data perform', () => { + it('posts metadata JSON to /v1/storage', async () => { + const requests = []; + const z = { + request: async (opts) => { + requests.push(opts); + return { data: { id: 'item_1' } }; + }, + }; + const bundle = { + authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' }, + inputData: { title: 'report', note: 'hello' }, + }; + const result = await storeData.operation.perform(z, bundle); + assert.equal(result.id, 'item_1'); + assert.equal(requests.length, 1); + assert.equal(requests[0].method, 'POST'); + assert.equal(requests[0].url, 'http://localhost:3000/v1/storage'); + }); + + it('downloads the mapped file first, then posts it as an attachment', async () => { + const requests = []; + const z = { + request: async (opts) => { + requests.push(opts); + return opts.raw ? { body: Buffer.from('x') } : { data: { id: 'item_2' } }; + }, + }; + const bundle = { + authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' }, + inputData: { title: 'with file', file: 'https://example.com/f.pdf', filename: 'f.pdf' }, + }; + await storeData.operation.perform(z, bundle); + assert.equal(requests.length, 2); + assert.equal(requests[0].url, 'https://example.com/f.pdf'); + assert.equal(requests[0].raw, true); + assert.equal(requests[1].url, 'http://localhost:3000/v1/storage'); + assert.equal(requests[1].method, 'POST'); + }); +}); +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `cd /Users/marchon/zappier/zapier-app && npm test` +Expected: FAIL — `Cannot find module '../index'`. + +- [ ] **Step 5: Implement the Zapier app** + +Create `zapier-app/authentication.js`: + +```js +module.exports = { + type: 'custom', + test: (z, bundle) => + z.request({ url: `${bundle.authData.baseUrl}/v1/status` }).then((r) => r.data), + fields: [ + { + key: 'baseUrl', + label: 'API Base URL', + type: 'string', + required: true, + default: 'http://localhost:3000', + helpText: 'Where your Zappier API is running.', + }, + { + key: 'apiKey', + label: 'API Key', + type: 'password', + required: true, + helpText: 'Your Zappier customer API key.', + }, + ], + connectionLabel: '{{bundle.authData.baseUrl}}', +}; +``` + +Create `zapier-app/triggers/new_item.js`: + +```js +const perform = async (z, bundle) => { + const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage` }); + return response.data.items; +}; + +module.exports = { + key: 'new_item', + noun: 'Stored Item', + display: { + label: 'New Stored Item', + description: 'Triggers when a new item is stored through the Zappier API.', + }, + operation: { + type: 'polling', + perform, + sample: { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + customerId: 'cust_1', + metadata: { title: 'example' }, + attachments: [], + createdAt: '2026-07-27T10:00:00.000Z', + }, + }, +}; +``` + +Create `zapier-app/creates/store_data.js`: + +```js +const FormData = require('form-data'); + +const perform = async (z, bundle) => { + const form = new FormData(); + form.append( + 'metadata', + JSON.stringify({ title: bundle.inputData.title, note: bundle.inputData.note }), + ); + + if (bundle.inputData.file) { + const fileResponse = await z.request({ + url: bundle.inputData.file, + raw: true, + redirect: 'follow', + }); + form.append('attachments', fileResponse.body, { + filename: bundle.inputData.filename || 'attachment.bin', + }); + } + + const response = await z.request({ + url: `${bundle.authData.baseUrl}/v1/storage`, + method: 'POST', + body: form, + headers: form.getHeaders(), + }); + return response.data; +}; + +module.exports = { + key: 'store_data', + noun: 'Stored Item', + display: { + label: 'Store Data', + description: + 'Stores metadata and an optional file attachment. Priced per call plus metadata KB and attachment MB on your plan.', + }, + operation: { + inputFields: [ + { key: 'title', label: 'Title', type: 'string', required: true }, + { key: 'note', label: 'Note', type: 'text', required: false }, + { + key: 'file', + label: 'Attachment', + type: 'file', + required: false, + helpText: 'Optional file. Attachment size is billed per MB on your plan.', + }, + { key: 'filename', label: 'Filename', type: 'string', required: false }, + ], + perform, + sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }, + }, +}; +``` + +Create `zapier-app/index.js`: + +```js +const authentication = require('./authentication'); +const newItem = require('./triggers/new_item'); +const storeData = require('./creates/store_data'); + +const addApiKeyHeader = (request, z, bundle) => { + request.headers = request.headers || {}; + request.headers['x-api-key'] = bundle.authData.apiKey; + return request; +}; + +module.exports = { + version: require('./package.json').version, + platformVersion: require('zapier-platform-core').version, + authentication, + beforeRequest: [addApiKeyHeader], + triggers: { [newItem.key]: newItem }, + creates: { [storeData.key]: storeData }, +}; +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd /Users/marchon/zappier/zapier-app && npm test` +Expected: PASS — 3 tests. + +- [ ] **Step 7: End-to-end check against the live API** + +1. Start the API: `cd /Users/marchon/zappier && npm run dev`. +2. In `zapier-app/`, run `zapier test` (validates against the platform core too). +3. Manually verify with curl that `POST /v1/storage` accepts a multipart request matching the action's shape. + +Expected: tests pass; a stored item appears in `GET /v1/storage`. Stop the server afterwards. + +- [ ] **Step 8: Push to Zapier and build a test Zap** + +```bash +cd /Users/marchon/zappier/zapier-app +zapier push +``` + +Expected: `Push successful`; the app appears at https://developer.zapier.com. + +Then in https://zapier.com create a private Zap: trigger **New Stored Item**, connect with `baseUrl` + `key-ada`, and add the **Store Data** action in a second Zap. Turn them on and confirm runs in the Zap history. + +- [ ] **Step 9: Commit** + +```bash +cd /Users/marchon/zappier +git add zapier-app +git commit -m "feat: add Zapier integration with auth, polling trigger, and store action" +``` + +--- + +## Self-review notes + +- **Spec coverage:** Swagger/OpenAPI API definitions → Task 5 (`openapi.yaml` source of truth, request validation, Swagger UI at `/docs`; rate card keys = spec `operationId`s). Web-UI pricing per plan/customer type → Task 8 (rate card, tier, and customer editors) backed by Task 7's `SqlitePricingStore`; live (no-restart) repricing proven by admin tests. Many API calls → rate card keyed by `operationId` + per-tier default rule for unpriced endpoints + 403 for tiers without a default. Many customer types → tier CRUD in admin UI (config-only). Per-customer pricing → `multiplierOverride` (Tasks 1, 3, 4, 8). Free calls → `status` / `storage-list` free rules at 0 on all tiers. Fixed-price calls → `transform`. Variable size-based pricing for metadata + file attachments → `storage` rule and Tasks 4–5 size metering. Free quotas → Task 6 monthly credits. Billing collection → Task 9 Stripe meters (credit-adjusted). Zapier availability + account → Task 10. Persistence → Task 7 SQLite. +- **Placeholder scan:** no TBDs; every code step has full code, including the entire admin UI; manual steps (Stripe/Zapier account creation) have exact URLs and expected outcomes. +- **Type consistency:** `quoteCall(pricing, tierId, endpointId, usage, multiplierOverride?)` / `Quote` / `PricingContext` / `PricingStore` / `InMemoryPricingStore` (Task 1) used unchanged in Tasks 4–9; `UsageEntry` / `UsageRepo` / `summarize` (Task 2) used unchanged in Tasks 4, 7, 9; `Customer` (with `tierId`, `multiplierOverride`) / `CustomerRepo` (with `save`) (Task 3) used in Tasks 4, 5, 7, 8, 9; `meter(endpointId, repo, pricing)` (Task 4) used unchanged in Task 5; `AppDeps` / `DEFAULT_CUSTOMERS` / `buildApp(deps)` / live `pricing` context (Task 5) consumed by Tasks 6–9; `applyMonthlyCredit` / `BilledSummary` (Task 6) matches the credit semantics inside `reportUsage` (Task 9); `SqliteUsageRepo` / `SqliteCustomerRepo` / `SqlitePricingStore` (Task 7) are exactly the `UsageRepo` / `CustomerRepo` / `PricingStore` implementations `buildApp` and the Stripe job expect; `adminAuth` / `adminRouter` (Task 8) mounted in Task 5's `buildApp`; endpoint ids identical in `openapi.yaml` `operationId`s, rate card, routes, and Zapier app URLs; seeded test math consistent across suites (transform = 4/2/1 cents by tier; storage example = 112 list cents; admin reprice 4 → 10). diff --git a/docs/walkthrough/01-start-server.png b/docs/walkthrough/01-start-server.png new file mode 100644 index 0000000..9d2a2f9 Binary files /dev/null and b/docs/walkthrough/01-start-server.png differ diff --git a/docs/walkthrough/02-login.png b/docs/walkthrough/02-login.png new file mode 100644 index 0000000..ef99111 Binary files /dev/null and b/docs/walkthrough/02-login.png differ diff --git a/docs/walkthrough/03-rate-card.png b/docs/walkthrough/03-rate-card.png new file mode 100644 index 0000000..2487eea Binary files /dev/null and b/docs/walkthrough/03-rate-card.png differ diff --git a/docs/walkthrough/04-edit-price.png b/docs/walkthrough/04-edit-price.png new file mode 100644 index 0000000..87a5acc Binary files /dev/null and b/docs/walkthrough/04-edit-price.png differ diff --git a/docs/walkthrough/04b-saved-toast.png b/docs/walkthrough/04b-saved-toast.png new file mode 100644 index 0000000..f753131 Binary files /dev/null and b/docs/walkthrough/04b-saved-toast.png differ diff --git a/docs/walkthrough/05-tiers.png b/docs/walkthrough/05-tiers.png new file mode 100644 index 0000000..b45f6d0 Binary files /dev/null and b/docs/walkthrough/05-tiers.png differ diff --git a/docs/walkthrough/06-add-tier.png b/docs/walkthrough/06-add-tier.png new file mode 100644 index 0000000..2d6a48a Binary files /dev/null and b/docs/walkthrough/06-add-tier.png differ diff --git a/docs/walkthrough/07-customers.png b/docs/walkthrough/07-customers.png new file mode 100644 index 0000000..fbcbf49 Binary files /dev/null and b/docs/walkthrough/07-customers.png differ diff --git a/docs/walkthrough/08-create-customer.png b/docs/walkthrough/08-create-customer.png new file mode 100644 index 0000000..f324294 Binary files /dev/null and b/docs/walkthrough/08-create-customer.png differ diff --git a/docs/walkthrough/08b-api-key-toast.png b/docs/walkthrough/08b-api-key-toast.png new file mode 100644 index 0000000..45ddb92 Binary files /dev/null and b/docs/walkthrough/08b-api-key-toast.png differ diff --git a/docs/walkthrough/09-api-docs.png b/docs/walkthrough/09-api-docs.png new file mode 100644 index 0000000..4232d35 Binary files /dev/null and b/docs/walkthrough/09-api-docs.png differ diff --git a/docs/walkthrough/10-api-call.png b/docs/walkthrough/10-api-call.png new file mode 100644 index 0000000..ceee7d4 Binary files /dev/null and b/docs/walkthrough/10-api-call.png differ diff --git a/docs/walkthrough/11-usage.png b/docs/walkthrough/11-usage.png new file mode 100644 index 0000000..0c001a9 Binary files /dev/null and b/docs/walkthrough/11-usage.png differ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..a3794ac --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], +}; diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..f1ac451 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,144 @@ +openapi: 3.0.3 +info: + title: Zappier API + version: 0.1.0 + description: Metered data API. Every priced call returns its quote. +security: + - apiKey: [] +paths: + /v1/status: + get: + operationId: status + summary: Service status (free) + responses: + '200': + description: OK + /v1/transform: + post: + operationId: transform + summary: Uppercase a string (fixed price) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + responses: + '200': + description: OK + /v1/storage: + post: + operationId: storage + summary: Store metadata and file attachments (variable price by size) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + metadata: + type: string + description: JSON string of metadata + attachments: + type: array + items: + type: string + format: binary + responses: + '200': + description: OK + get: + operationId: storage-list + summary: List your stored items (free) + responses: + '200': + description: OK + /v1/timestamp: + post: + operationId: timestamp + summary: Register a timestamp (proxies middleware or in-process mock) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + data: + type: string + sha256: + type: string + hashAlg: + type: string + responses: + '202': + description: Accepted + /v1/hashes/{sha256}: + get: + operationId: hash-lookup + summary: Lookup a SHA256 timestamp (mock) + parameters: + - name: sha256 + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '404': + description: Missing + /v1/receipts/{jobId}: + get: + operationId: receipt + summary: Retrieval receipt JSON (mock job ids from /v1/timestamp) + parameters: + - name: jobId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '404': + description: Missing + /v1/add: + post: + operationId: add + summary: Add two numbers (activate-now sample) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [number1, number2] + properties: + number1: + type: number + description: First addend + number2: + type: number + description: Second addend + responses: + '200': + description: OK + /v1/usage: + get: + operationId: usage + summary: Your usage summary for the current period + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: x-api-key diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..53ecc97 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6165 @@ +{ + "name": "zappier", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zappier", + "version": "0.1.0", + "dependencies": { + "@types/qrcode": "^1.5.6", + "better-sqlite3": "^12.11.1", + "dotenv": "^17.4.2", + "express": "^4.19.2", + "express-openapi-validator": "^5.3.0", + "qrcode": "^1.5.4", + "stripe": "^16.0.0", + "swagger-ui-express": "^5.0.0", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/dotenv": "^6.1.1", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/multer": "^1.4.11", + "@types/node": "^20.14.0", + "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.6", + "@types/yamljs": "^0.2.34", + "jest": "^29.7.0", + "supertest": "^7.0.0", + "ts-jest": "^29.1.4", + "ts-node": "^10.9.2", + "typescript": "^5.5.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz", + "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + }, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/dotenv": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/yamljs": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.34.tgz", + "integrity": "sha512-gJvfRlv9ErxdOv7ux7UsJVePtX54NAvQyd8ncoiFqK8G5aeHIfQfGH2fbruvjAQ9657HwAaO54waS+Dsk2QTUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "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-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-openapi-validator": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-5.6.2.tgz", + "integrity": "sha512-fkDn4+ImUC4HTJ1g0cek/ItqYhmEO19AglJd2Iw2OJco0jLIbxIlDGVazmXbvvYeziU4Bnah2h+S2tb6NtWg8w==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^14.2.1", + "@types/multer": "^2.0.0", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "json-schema-traverse": "^1.0.0", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "media-typer": "^1.1.0", + "multer": "^2.0.2", + "ono": "^7.1.3", + "path-to-regexp": "^8.3.0", + "qs": "^6.14.1" + }, + "peerDependencies": { + "express": "*" + } + }, + "node_modules/express-openapi-validator/node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/express-openapi-validator/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/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-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "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/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "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-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "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": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "7.1.3" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "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/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stripe": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-16.12.0.tgz", + "integrity": "sha512-H7eFVLDxeTNNSn4JTRfL2//LzCbDrMSZ+2q1c7CanVWgK2qIW5TwS+0V7N9KcKZZNpYh/uCqK0PyZh/2UsaAtQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.11", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.11.tgz", + "integrity": "sha512-NEZzRuxHHQkbG3GCjNbzz+XRDoM7AztnXyzc2VCW5RXUvZBDW7bb3W29/SPfvav3yOzqnDTOLP2Xzbjxo0bldQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "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/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, + "node_modules/yamljs/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b799e3c --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "zappier", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "test": "jest" + }, + "dependencies": { + "@types/qrcode": "^1.5.6", + "better-sqlite3": "^12.11.1", + "dotenv": "^17.4.2", + "express": "^4.19.2", + "express-openapi-validator": "^5.3.0", + "qrcode": "^1.5.4", + "stripe": "^16.0.0", + "swagger-ui-express": "^5.0.0", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/dotenv": "^6.1.1", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/multer": "^1.4.11", + "@types/node": "^20.14.0", + "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.6", + "@types/yamljs": "^0.2.34", + "jest": "^29.7.0", + "supertest": "^7.0.0", + "ts-jest": "^29.1.4", + "ts-node": "^10.9.2", + "typescript": "^5.5.0" + } +} diff --git a/portal/app.js b/portal/app.js new file mode 100644 index 0000000..e9c0689 --- /dev/null +++ b/portal/app.js @@ -0,0 +1,356 @@ +const state = { me: null, usage: null, invoices: [], pricing: null }; +const TOKEN_KEY = 'zappier-portal-token'; +let authMode = 'login'; // 'login' | 'signup' + +/* ---------------- auth ---------------- */ + +function token() { + return localStorage.getItem(TOKEN_KEY); +} + +function showAuth(message = '') { + document.getElementById('shell').classList.remove('on'); + document.getElementById('auth').style.display = 'grid'; + document.getElementById('auth-error').textContent = message; +} + +function showShell() { + document.getElementById('auth').style.display = 'none'; + document.getElementById('shell').classList.add('on'); +} + +function setAuthMode(mode) { + authMode = mode; + const isSignup = mode === 'signup'; + document.getElementById('name-group').style.display = isSignup ? 'block' : 'none'; + document.getElementById('totp-group').style.display = 'none'; + document.getElementById('auth-sub').textContent = isSignup + ? 'Create your customer account — you get an API key immediately.' + : 'Sign in to your customer account.'; + document.getElementById('auth-submit').textContent = isSignup ? 'Create account' : 'Sign in'; + document.getElementById('auth-switch').innerHTML = isSignup + ? 'Already have an account? Sign in' + : 'New here? Create an account'; + document.getElementById('auth-error').textContent = ''; + document + .getElementById('auth-toggle') + .addEventListener('click', () => setAuthMode(isSignup ? 'login' : 'signup')); +} + +document.getElementById('auth-toggle').addEventListener('click', () => setAuthMode('signup')); + +document.getElementById('auth-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const email = document.getElementById('auth-email').value.trim(); + const password = document.getElementById('auth-password').value; + const totpCode = document.getElementById('auth-totp').value.trim(); + try { + const path = authMode === 'signup' ? '/portal/api/signup' : '/portal/api/login'; + const body = + authMode === 'signup' + ? { name: document.getElementById('auth-name').value.trim(), email, password } + : { email, password, ...(totpCode ? { totpCode } : {}) }; + const res = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + if (data.error === 'totp_required') { + document.getElementById('totp-group').style.display = 'block'; + document.getElementById('auth-totp').focus(); + throw new Error('Enter the 6-digit code from your authenticator app.'); + } + throw new Error(data.error || 'Authentication failed'); + } + localStorage.setItem(TOKEN_KEY, data.token); + showShell(); + load().catch((err) => say(err.message, true)); + } catch (err) { + document.getElementById('auth-error').textContent = err.message; + } +}); + +document.getElementById('logout').addEventListener('click', async () => { + try { + await api('/logout', { method: 'POST', body: '{}' }); + } catch { + /* session already gone */ + } + localStorage.removeItem(TOKEN_KEY); + location.reload(); +}); + +/* ---------------- api + status ---------------- */ + +async function api(path, options = {}) { + const res = await fetch(`/portal/api${path}`, { + ...options, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` }, + }); + if (res.status === 401) { + localStorage.removeItem(TOKEN_KEY); + showAuth('Session expired — sign in again.'); + throw new Error('Session expired.'); + } + if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`); + return res.json(); +} + +let statusTimer; +function say(msg, isError = false) { + const el = document.getElementById('status'); + el.textContent = msg; + el.classList.toggle('error', isError); + el.classList.add('show'); + clearTimeout(statusTimer); + statusTimer = setTimeout(() => el.classList.remove('show'), 4000); +} + +/* ---------------- shared helpers ---------------- */ + +const fmt = (cents) => (cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2); +const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—'); + +async function load() { + state.me = await api('/me'); + state.usage = await api('/usage'); + state.invoices = (await api('/invoices')).invoices; + state.pricing = await api('/pricing'); + renderDashboard(); + renderInvoices(); + renderBilling(); + renderSecurity(); + renderDocs(); +} + +/* ---------------- dashboard ---------------- */ + +function renderDashboard() { + const u = state.usage; + const me = state.me; + const tier = state.pricing.tiers.find((t) => t.id === me.tierId); + document.getElementById('dashboard').innerHTML = ` +

    Welcome, ${me.name}

    +

    ${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'}

    +
    +
    Usage this month
    ${fmt(u.totalCents)}
    +
    Included credit
    ${fmt(u.includedCents)}
    +
    Billable
    ${fmt(u.billableCents)}
    +
    Prepaid balance
    ${fmt(me.balanceCents)}
    +
    +
    +

    Your API key

    +
    + ${me.apiKey} + + +
    +

    Send it as the x-api-key header. Regenerating invalidates the old key immediately. Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.

    +
    `; +} + +function copyKey() { + navigator.clipboard.writeText(document.getElementById('api-key').textContent); + say('API key copied.'); +} + +async function regenerateKey() { + const res = await api('/api-key', { method: 'POST', body: '{}' }); + state.me.apiKey = res.apiKey; + renderDashboard(); + say('New API key issued — the old key no longer works.'); +} + +/* ---------------- invoices ---------------- */ + +function renderInvoices() { + const rows = state.invoices + .slice() + .sort((a, b) => b.id.localeCompare(a.id)) + .map( + (inv) => ` + ${inv.id} + ${inv.period} + ${inv.status} + ${fmt(inv.totalCents)} + ${fmt(inv.creditCents)} + ${fmt(inv.billableCents)} + ${fmtDate(inv.dueAtMs)} + + `, + ) + .join(''); + document.getElementById('invoices').innerHTML = ` +

    Invoices

    +

    Your invoice history. “View / print” opens a print-ready page — use the browser’s Print → Save as PDF.

    +
    + + ${rows || ''} +
    InvoicePeriodStatusTotalCreditAmount dueDue date
    No invoices yet.
    `; +} + +async function viewInvoice(id) { + const res = await fetch(`/portal/api/invoices/${id}?format=html`, { + headers: { authorization: `Bearer ${token()}` }, + }); + if (!res.ok) return say(`Could not load ${id}.`, true); + const blob = await res.blob(); + window.open(URL.createObjectURL(blob), '_blank'); +} + +/* ---------------- billing ---------------- */ + +function renderBilling() { + const me = state.me; + document.getElementById('billing').innerHTML = ` +

    Billing

    +

    Prepaid balance is drawn down automatically when an invoice is issued.

    +
    +

    Reload balance ${fmt(me.balanceCents)}

    +
    + + +
    +

    Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured you’ll complete payment securely.

    +
    +
    +

    Email invoicing

    +

    Send a copy of each issued invoice to ${me.email}.

    + +
    `; +} + +async function reload() { + const dollars = Number(document.getElementById('reload-amount').value); + const amountCents = Math.round(dollars * 100); + const res = await api('/reload', { method: 'POST', body: JSON.stringify({ amountCents }) }); + state.me.balanceCents = res.balanceCents; + renderBilling(); + if (res.mode === 'dev') say(`Balance reloaded — new balance ${fmt(res.balanceCents)}.`); + else say('Payment started — your balance updates when the payment confirms.'); +} + +async function toggleEmailInvoicing(enabled) { + await api('/email-invoicing', { method: 'PUT', body: JSON.stringify({ enabled }) }); + state.me.emailInvoicing = enabled; + say(enabled ? 'Email invoicing enabled.' : 'Email invoicing disabled.'); +} + +/* ---------------- security ---------------- */ + +function renderSecurity() { + const me = state.me; + document.getElementById('security').innerHTML = ` +

    Security

    +

    Two-factor authentication protects your account with a time-based code from an authenticator app.

    +
    +

    Two-factor authentication ${me.totpEnabled ? 'enabled' : 'disabled'}

    +
    + ${ + me.totpEnabled + ? `

    To disable 2FA, enter the current code from your authenticator app.

    +
    + + +
    ` + : `

    2FA is off. Set it up to require a code at sign-in.

    + +
    ` + } +
    +
    +
    +

    Password

    +

    Password changes are handled by support for now — sign out everywhere by signing back in (old sessions expire after 7 days).

    +
    `; +} + +async function setupTotp() { + const res = await api('/2fa/setup', { method: 'POST', body: '{}' }); + document.getElementById('totp-setup').innerHTML = ` +
    + 2FA QR code +
    +

    1. Scan with your authenticator app (or enter the secret manually):

    +

    ${res.secret}

    +

    2. Enter the 6-digit code it shows:

    +
    + + +
    +
    +
    `; +} + +async function enableTotp() { + const code = document.getElementById('totp-enable-code').value.trim(); + await api('/2fa/enable', { method: 'POST', body: JSON.stringify({ code }) }); + state.me.totpEnabled = true; + renderSecurity(); + say('Two-factor authentication is now on.'); +} + +async function disableTotp() { + const code = document.getElementById('totp-disable-code').value.trim(); + await api('/2fa/disable', { method: 'POST', body: JSON.stringify({ code }) }); + state.me.totpEnabled = false; + renderSecurity(); + say('Two-factor authentication disabled.'); +} + +/* ---------------- docs + pricing ---------------- */ + +function renderDocs() { + const rows = Object.entries(state.pricing.rateCard.endpoints) + .map(([id, rule]) => { + const price = + rule.kind === 'free' + ? 'Free' + : rule.kind === 'fixed' + ? `${rule.fixedCents}¢ / call` + : `${rule.baseCents}¢ + ${rule.perKbCents}¢/KB + ${rule.perMbCents}¢/MB`; + return `${id}${rule.kind}${price}`; + }) + .join(''); + const tiers = state.pricing.tiers + .map( + (t) => + `${t.id}${t.name}${t.multiplier}×${fmt(t.monthlyCreditCents)}`, + ) + .join(''); + document.getElementById('docs').innerHTML = ` +

    API & pricing

    +

    Interactive API reference lives at /docs (Swagger UI) — use your API key as x-api-key.

    +
    +

    Rate card (list prices)

    + ${rows}
    EndpointKindList price
    +
    +
    +

    Plans

    + ${tiers}
    IdNamePrice multiplierMonthly credit
    +

    Your plan’s multiplier scales list prices; the monthly credit is free included usage. You’re on ${state.me.tierId}.

    +
    `; +} + +/* ---------------- tabs + boot ---------------- */ + +document.querySelectorAll('nav button').forEach((btn) => + btn.addEventListener('click', () => { + document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('main section').forEach((s) => (s.hidden = true)); + document.getElementById(btn.dataset.tab).hidden = false; + }), +); + +if (token()) { + showShell(); + load().catch((err) => say(err.message, true)); +} else { + showAuth(); +} diff --git a/portal/index.html b/portal/index.html new file mode 100644 index 0000000..d7059c8 --- /dev/null +++ b/portal/index.html @@ -0,0 +1,269 @@ + + + + + + Zappier Portal + + + +
    +
    +

    Zappier Portal

    +

    Sign in to your customer account.

    + + + + + +
    + + +
    +

    + +

    New here? Create an account

    +
    +
    + +
    + +
    +
    + + + + +
    +
    +

    + + + diff --git a/src/accounting-export.ts b/src/accounting-export.ts new file mode 100644 index 0000000..fde79c9 --- /dev/null +++ b/src/accounting-export.ts @@ -0,0 +1,36 @@ +import { Invoice } from './invoicing'; + +function iifDate(ms: number): string { + const d = new Date(ms); + return `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')}/${d.getUTCFullYear()}`; +} + +/** QuickBooks IIF journal for issued/paid invoices (Accounts Receivable + Sales). */ +export function invoicesToQuickBooksIif(invoices: Invoice[]): string { + const lines = [ + '!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM', + '!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM', + '!ENDTRNS', + ]; + for (const inv of invoices) { + if (inv.status === 'draft') continue; + const date = iifDate(inv.issuedAtMs || inv.paidAtMs || Date.now()); + const dollars = (inv.billableCents / 100).toFixed(2); + const name = inv.customerId; + lines.push(`TRNS\t\tINVOICE\t${date}\tAccounts Receivable\t${name}\t${dollars}\t${inv.id}`); + lines.push(`SPL\t\tINVOICE\t${date}\tSales\t${name}\t-${dollars}\t${inv.id}`); + lines.push('ENDTRNS'); + } + return lines.join('\n') + '\n'; +} + +export function invoicesToAccountingCsv(invoices: Invoice[]): string { + const header = 'invoice_id,customer_id,period,status,billing_type,total_cents,credit_cents,billable_cents,po_number'; + const rows = invoices.map( + (i) => + [i.id, i.customerId, i.period, i.status, i.billingType, i.totalCents, i.creditCents, i.billableCents, i.poNumber || ''].join( + ',', + ), + ); + return [header, ...rows].join('\n') + '\n'; +} diff --git a/src/accounts.ts b/src/accounts.ts new file mode 100644 index 0000000..45c3ea4 --- /dev/null +++ b/src/accounts.ts @@ -0,0 +1,165 @@ +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +/** + * Customer-portal identity primitives: password hashing (scrypt), TOTP + * two-factor secrets (RFC 6238), and portal sessions. Pure functions plus + * repo interfaces; SQLite adapters live in src/db/. + */ + +/* ---------------- password hashing (scrypt) ---------------- */ + +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LEN = 32; + +/** Format: scrypt:N:r:p:: */ +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEY_LEN, { + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + }); + return `scrypt:${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}:${salt.toString('base64')}:${hash.toString('base64')}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const parts = stored.split(':'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + const [, n, r, p, saltB64, hashB64] = parts; + const expected = Buffer.from(hashB64, 'base64'); + if (expected.length === 0) return false; + const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, { + N: Number(n), + r: Number(r), + p: Number(p), + }); + return timingSafeEqual(actual, expected); +} + +/* ---------------- base32 (RFC 4648, no padding) ---------------- */ + +const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +export function base32Encode(buf: Buffer): string { + let bits = 0; + let value = 0; + let out = ''; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += B32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31]; + return out; +} + +export function base32Decode(s: string): Buffer { + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of s.toUpperCase().replace(/=+$/, '')) { + const idx = B32_ALPHABET.indexOf(ch); + if (idx < 0) throw new Error(`invalid base32 character: ${ch}`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(out); +} + +/* ---------------- TOTP (RFC 6238, HMAC-SHA1, 30 s step, 6 digits) ---------------- */ + +export function hotp(secret: string, counter: number, digits = 6): string { + const key = base32Decode(secret); + const msg = Buffer.alloc(8); + msg.writeBigUInt64BE(BigInt(counter)); + const h = createHmac('sha1', key).update(msg).digest(); + const offset = h[h.length - 1] & 0x0f; + const code = + (((h[offset] & 0x7f) << 24) | + (h[offset + 1] << 16) | + (h[offset + 2] << 8) | + h[offset + 3]) % + 10 ** digits; + return String(code).padStart(digits, '0'); +} + +export function totp(secret: string, atMs: number, stepSec = 30, digits = 6): string { + return hotp(secret, Math.floor(atMs / 1000 / stepSec), digits); +} + +export function verifyTotp( + secret: string, + code: string, + atMs: number, + window = 1, +): boolean { + if (!/^\d{6}$/.test(code)) return false; + for (let w = -window; w <= window; w++) { + if (totp(secret, atMs + w * 30_000) === code) return true; + } + return false; +} + +/** 160-bit secret, base32 without padding (authenticator-app standard). */ +export function generateTotpSecret(): string { + return base32Encode(randomBytes(20)); +} + +export function totpUri(secret: string, email: string, issuer = 'Zappier'): string { + return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; +} + +/* ---------------- portal sessions ---------------- */ + +export interface PortalSession { + token: string; + customerId: string; + createdMs: number; + expiresMs: number; +} + +export interface SessionRepo { + create(customerId: string, ttlMs: number): PortalSession; + /** Returns the session, or undefined when unknown or expired at nowMs. */ + get(token: string, nowMs?: number): PortalSession | undefined; + delete(token: string): void; +} + +export function newSessionToken(): string { + return randomBytes(24).toString('hex'); +} + +export class InMemorySessionRepo implements SessionRepo { + private sessions = new Map(); + + create(customerId: string, ttlMs: number): PortalSession { + const now = Date.now(); + const session: PortalSession = { + token: newSessionToken(), + customerId, + createdMs: now, + expiresMs: now + ttlMs, + }; + this.sessions.set(session.token, session); + return session; + } + + get(token: string, nowMs = Date.now()): PortalSession | undefined { + const s = this.sessions.get(token); + if (!s || s.expiresMs <= nowMs) return undefined; + return s; + } + + delete(token: string): void { + this.sessions.delete(token); + } +} diff --git a/src/admin-users.ts b/src/admin-users.ts new file mode 100644 index 0000000..6c5f264 --- /dev/null +++ b/src/admin-users.ts @@ -0,0 +1,75 @@ +import { randomBytes } from 'crypto'; +import { hashPassword } from './accounts'; + +/** + * Admin-console accounts. Replaces the original static two-account env map + * with a persisted table; the env values remain the seed for empty databases. + */ + +export interface AdminUser { + id: string; // usr_ + username: string; + passwordHash: string; + active: boolean; + createdMs: number; +} + +export interface AdminUserRepo { + list(): AdminUser[]; + findByUsername(username: string): AdminUser | undefined; + save(user: AdminUser): void; +} + +export function newAdminUserId(): string { + return `usr_${randomBytes(6).toString('hex')}`; +} + +/** The env-backed seed accounts (preserved from the pre-table behavior). */ +export function seedAdminUsersFromEnv(): { username: string; password: string }[] { + return [ + { + username: process.env.ADMIN_USER ?? 'admin', + password: process.env.ADMIN_KEY ?? 'admin-dev-key', + }, + { + username: process.env.DEMO_ADMIN_USER ?? 'demo', + password: process.env.DEMO_ADMIN_PASSWORD ?? '$$$Adm1n###', + }, + ]; +} + +export function makeAdminUser(username: string, password: string): AdminUser { + return { + id: newAdminUserId(), + username, + passwordHash: hashPassword(password), + active: true, + createdMs: Date.now(), + }; +} + +export class InMemoryAdminUserRepo implements AdminUserRepo { + private users: AdminUser[]; + + private constructor(users: AdminUser[]) { + this.users = users.map((u) => ({ ...u })); + } + + static seeded(seed: { username: string; password: string }[]): InMemoryAdminUserRepo { + return new InMemoryAdminUserRepo(seed.map((s) => makeAdminUser(s.username, s.password))); + } + + list(): AdminUser[] { + return [...this.users]; + } + + findByUsername(username: string): AdminUser | undefined { + return this.users.find((u) => u.username === username); + } + + save(user: AdminUser): void { + const i = this.users.findIndex((u) => u.id === user.id || u.username === user.username); + if (i >= 0) this.users[i] = user; + else this.users.push(user); + } +} diff --git a/src/admin.ts b/src/admin.ts new file mode 100644 index 0000000..db11b2b --- /dev/null +++ b/src/admin.ts @@ -0,0 +1,552 @@ +import { randomBytes, randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { RequestHandler, Router } from 'express'; +import { verifyPassword } from './accounts'; +import { AdminUserRepo, makeAdminUser } from './admin-users'; +import { CustomerRepo } from './auth'; +import { buildInvoice, Invoice, InvoiceRepo } from './invoicing'; +import { PriceRule, PricingStore, TierConfig } from './pricing'; +import { PROJECT_ROOT } from './paths'; +import { billingRows, toCsv, usageTrend } from './reports'; +import { UsageRepo } from './usage'; +import { CreditLedger } from './credits'; +import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export'; + +// Issued login tokens (in-memory; a restart simply requires logging in again). +const sessions = new Map(); + +export function adminLoginRouter(users: AdminUserRepo): Router { + const router = Router(); + router.post('/login', (req, res) => { + const { username, password } = req.body ?? {}; + if (typeof username !== 'string' || typeof password !== 'string') { + res.status(401).json({ error: 'invalid username or password' }); + return; + } + const user = users.findByUsername(username); + if (!user || !user.active || !verifyPassword(password, user.passwordHash)) { + res.status(401).json({ error: 'invalid username or password' }); + return; + } + const token = randomBytes(24).toString('hex'); + sessions.set(token, Date.now()); + res.json({ token }); + }); + return router; +} + +export function adminAuth(): RequestHandler { + return (req, res, next) => { + const expected = process.env.ADMIN_KEY ?? 'admin-dev-key'; + if (req.header('x-admin-key') === expected) return next(); + const bearer = req.header('authorization'); + const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined; + if (token && sessions.has(token)) return next(); + res.status(403).json({ error: 'invalid or missing admin key' }); + }; +} + +function isValidRule(rule: unknown): rule is PriceRule { + if (!rule || typeof rule !== 'object') return false; + const r = rule as Record; + if (r.kind === 'free') return true; + if (r.kind === 'fixed') return typeof r.fixedCents === 'number'; + if (r.kind === 'variable') { + return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number'); + } + return false; +} + +function isValidTier(tier: unknown): tier is TierConfig { + if (!tier || typeof tier !== 'object') return false; + const t = tier as Record; + return ( + typeof t.name === 'string' && + typeof t.multiplier === 'number' && + typeof t.monthlyCreditCents === 'number' && + (t.defaultRule === undefined || isValidRule(t.defaultRule)) + ); +} + +export interface AccountingDeps { + usage: UsageRepo; + invoices: InvoiceRepo; + users: AdminUserRepo; +} + +const PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/; + +function periodWindow(period: string): { from: Date; to: Date } { + const [y, m] = period.split('-').map(Number); + return { from: new Date(Date.UTC(y, m - 1, 1)), to: new Date(Date.UTC(y, m, 1)) }; +} + +function parseDate(value: unknown): Date | undefined { + if (typeof value !== 'string' || value.trim() === '') return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`; + +export function renderInvoiceHtml(invoice: Invoice, customerName: string): string { + const rows = invoice.lines + .map( + (l) => + `${escapeHtml(l.endpointId)}${l.calls}${dollars(l.cents)}`, + ) + .join(''); + const po = invoice.poNumber + ? `

    Purchase order: ${escapeHtml(invoice.poNumber)}

    ` + : ''; + const due = invoice.dueAtMs ? new Date(invoice.dueAtMs).toISOString().slice(0, 10) : '—'; + return `${invoice.id} + +

    Invoice ${escapeHtml(invoice.id)}

    +

    Zappier API usage · period ${escapeHtml(invoice.period)} · due ${due}

    +

    ${invoice.status.toUpperCase()}

    +

    Billed to: ${escapeHtml(customerName)} (${escapeHtml(invoice.customerId)})
    +Billing type: ${escapeHtml(invoice.billingType)}

    +${po} +${rows}
    EndpointCallsAmount
    + + + + +
    Usage total${dollars(invoice.totalCents)}
    Monthly credit−${dollars(invoice.creditCents)}
    Amount due${dollars(invoice.billableCents)}
    +`; +} + +export function adminRouter( + store: PricingStore, + customers: CustomerRepo, + accounting: AccountingDeps, + credits: CreditLedger = new CreditLedger(), +): Router { + const router = Router(); + + router.get('/pricing', (req, res) => { + res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() }); + }); + + router.put('/endpoints/:id', (req, res) => { + if (!isValidRule(req.body)) { + res.status(400).json({ error: 'invalid price rule' }); + return; + } + store.upsertEndpoint(req.params.id, req.body); + res.json({ ok: true }); + }); + + router.delete('/endpoints/:id', (req, res) => { + store.deleteEndpoint(req.params.id); + res.json({ ok: true }); + }); + + router.put('/tiers/:id', (req, res) => { + if (!isValidTier(req.body)) { + res.status(400).json({ error: 'invalid tier config' }); + return; + } + store.upsertTier({ ...req.body, id: req.params.id }); + res.json({ ok: true }); + }); + + router.delete('/tiers/:id', (req, res) => { + store.deleteTier(req.params.id); + res.json({ ok: true }); + }); + + router.get('/customers', (req, res) => { + res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) }); + }); + + router.post('/customers', (req, res) => { + const { name, tierId } = req.body ?? {}; + if (typeof name !== 'string' || typeof tierId !== 'string') { + res.status(400).json({ error: 'name and tierId required' }); + return; + } + const customer = { + id: `cust_${randomUUID().slice(0, 8)}`, + name, + tierId, + apiKey: `key-${randomBytes(12).toString('hex')}`, + }; + customers.save(customer); + res.status(201).json(customer); + }); + + router.put('/customers/:id', (req, res) => { + const existing = customers.list().find((c) => c.id === req.params.id); + if (!existing) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const { name, tierId, multiplierOverride, stripeCustomerId, billingType, email } = + req.body ?? {}; + if ( + billingType !== undefined && + billingType !== 'stripe' && + billingType !== 'purchase_order' + ) { + res.status(400).json({ error: 'billingType must be stripe or purchase_order' }); + return; + } + customers.save({ + ...existing, + ...(name !== undefined ? { name } : {}), + ...(tierId !== undefined ? { tierId } : {}), + ...(multiplierOverride !== undefined ? { multiplierOverride } : {}), + ...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}), + ...(billingType !== undefined ? { billingType } : {}), + ...(email !== undefined ? { email } : {}), + }); + res.json({ ok: true }); + }); + + /* ---------------- customer service: credits ---------------- */ + + router.post('/credits', (req, res) => { + const { customerId, cents, reason, agent } = req.body ?? {}; + if (typeof customerId !== 'string' || !Number.isFinite(Number(cents))) { + res.status(400).json({ error: 'customerId and cents required' }); + return; + } + const customer = customers.list().find((c) => c.id === customerId); + if (!customer) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const delta = Math.trunc(Number(cents)); + const rec = credits.add({ + customerId, + cents: delta, + reason: typeof reason === 'string' ? reason : 'credit adjustment', + agent: typeof agent === 'string' ? agent : 'admin', + }); + customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta }); + res.status(201).json(rec); + }); + + router.get('/credits', (req, res) => { + const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined; + res.json({ credits: credits.list(customerId) }); + }); + + router.get('/sales/quote/:id', (req, res) => { + const customer = customers.list().find((c) => c.id === req.params.id); + if (!customer) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const tier = store.getTiers().find((t) => t.id === customer.tierId); + res.json({ + customerId: customer.id, + name: customer.name, + tierId: customer.tierId, + multiplierOverride: customer.multiplierOverride ?? null, + monthlyCreditCents: tier?.monthlyCreditCents ?? 0, + listMultiplier: tier?.multiplier ?? 1, + }); + }); + + router.get('/exports/quickbooks.iif', (req, res) => { + const period = typeof req.query.period === 'string' ? req.query.period : undefined; + const invoices = accounting.invoices.list({ ...(period ? { period } : {}) }); + res + .type('text/plain') + .set('content-disposition', 'attachment; filename="zappier-quickbooks.iif"') + .send(invoicesToQuickBooksIif(invoices)); + }); + + router.get('/exports/accounting.csv', (req, res) => { + const period = typeof req.query.period === 'string' ? req.query.period : undefined; + const invoices = accounting.invoices.list({ ...(period ? { period } : {}) }); + res + .type('text/csv') + .set('content-disposition', 'attachment; filename="zappier-accounting.csv"') + .send(invoicesToAccountingCsv(invoices)); + }); + + /* ---------------- accounting: invoices ---------------- */ + + router.post('/invoices/generate', (req, res) => { + const { period, customerId, poNumber } = req.body ?? {}; + if (typeof period !== 'string' || !PERIOD_RE.test(period)) { + res.status(400).json({ error: 'period must be YYYY-MM' }); + return; + } + const { from, to } = periodWindow(period); + const tiers = store.getTiers(); + const generated: string[] = []; + const skipped: { customerId: string; reason: string }[] = []; + + for (const customer of customers.list()) { + if (customerId && customer.id !== customerId) continue; + const existing = accounting.invoices + .list({ customerId: customer.id, period }) + .find((i) => i.status !== 'draft'); + if (existing) { + skipped.push({ customerId: customer.id, reason: `${existing.id} already ${existing.status}` }); + continue; + } + const entries = accounting.usage + .listFor(customer.id, from) + .filter((e) => e.timestamp < to); + if (entries.length === 0) { + skipped.push({ customerId: customer.id, reason: 'no usage in period' }); + continue; + } + const tier = tiers.find((t) => t.id === customer.tierId); + if (!tier) { + skipped.push({ customerId: customer.id, reason: `unknown tier ${customer.tierId}` }); + continue; + } + const draft = accounting.invoices.list({ customerId: customer.id, period })[0]; + const invoice = buildInvoice({ + customer, + period, + sequence: draft ? Number(draft.id.slice(-4)) : accounting.invoices.nextSequence(period), + entries, + tier, + ...(poNumber !== undefined ? { poNumber } : draft?.poNumber !== undefined ? { poNumber: draft.poNumber } : {}), + }); + if (draft) invoice.id = draft.id; + accounting.invoices.save(invoice); + generated.push(invoice.id); + } + res.json({ generated, skipped }); + }); + + router.get('/invoices', (req, res) => { + const { customerId, period, status } = req.query; + res.json({ + invoices: accounting.invoices.list({ + ...(typeof customerId === 'string' ? { customerId } : {}), + ...(typeof period === 'string' ? { period } : {}), + ...(typeof status === 'string' ? { status: status as Invoice['status'] } : {}), + }), + }); + }); + + router.get('/invoices/:id', (req, res) => { + const invoice = accounting.invoices.get(req.params.id); + if (!invoice) { + res.status(404).json({ error: 'invoice not found' }); + return; + } + if (req.query.format === 'html') { + const name = customers.list().find((c) => c.id === invoice.customerId)?.name ?? invoice.customerId; + res.type('html').send(renderInvoiceHtml(invoice, name)); + return; + } + res.json(invoice); + }); + + router.post('/invoices/:id/issue', (req, res) => { + const invoice = accounting.invoices.get(req.params.id); + if (!invoice) { + res.status(404).json({ error: 'invoice not found' }); + return; + } + if (invoice.status !== 'draft') { + res.status(409).json({ error: `invoice is ${invoice.status}, not draft` }); + return; + } + const now = Date.now(); + const issued: Invoice = { + ...invoice, + status: 'issued', + issuedAtMs: now, + ...(invoice.billingType === 'purchase_order' + ? { dueAtMs: now + 30 * 24 * 60 * 60 * 1000 } + : {}), + }; + // Prepaid drawdown: a balance that fully covers the billable amount is + // deducted and the invoice goes straight to paid. Partial coverage stays + // untouched (no partial payments). + const customer = customers.list().find((c) => c.id === invoice.customerId); + const balance = customer?.balanceCents ?? 0; + if (customer && invoice.billableCents > 0 && balance >= invoice.billableCents) { + customers.save({ ...customer, balanceCents: balance - invoice.billableCents }); + const paid: Invoice = { ...issued, status: 'paid', paidAtMs: now }; + accounting.invoices.save(paid); + res.json(paid); + return; + } + accounting.invoices.save(issued); + res.json(issued); + }); + + router.post('/invoices/:id/paid', (req, res) => { + const invoice = accounting.invoices.get(req.params.id); + if (!invoice) { + res.status(404).json({ error: 'invoice not found' }); + return; + } + if (invoice.status !== 'issued') { + res.status(409).json({ error: `invoice is ${invoice.status}, not issued` }); + return; + } + const paid: Invoice = { ...invoice, status: 'paid', paidAtMs: Date.now() }; + accounting.invoices.save(paid); + res.json(paid); + }); + + /* ---------------- accounting: reports ---------------- */ + + router.get('/reports/billing', (req, res) => { + const all = customers.list().flatMap((c) => accounting.usage.listFor(c.id)); + const rows = billingRows({ + entries: all, + customers: customers.list(), + tiers: store.getTiers(), + ...(parseDate(req.query.from) ? { from: parseDate(req.query.from)! } : {}), + ...(parseDate(req.query.to) ? { to: parseDate(req.query.to)! } : {}), + ...(typeof req.query.customerId === 'string' ? { customerId: req.query.customerId } : {}), + ...(typeof req.query.billingType === 'string' + ? { billingType: req.query.billingType as 'stripe' | 'purchase_order' } + : {}), + }); + if (req.query.format === 'csv') { + const csv = toCsv(rows, [ + { key: 'customerId', label: 'Customer Id' }, + { key: 'name', label: 'Name' }, + { key: 'billingType', label: 'Billing Type' }, + { key: 'calls', label: 'Calls' }, + { key: 'totalCents', label: 'Total Cents' }, + { key: 'creditCents', label: 'Credit Cents' }, + { key: 'billableCents', label: 'Billable Cents' }, + ]); + res + .type('text/csv') + .set('content-disposition', 'attachment; filename="billing-report.csv"') + .send(csv); + return; + } + res.json({ rows }); + }); + + router.get('/reports/usage-trend', (req, res) => { + const from = parseDate(req.query.from); + const to = parseDate(req.query.to); + const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined; + const entries = customers + .list() + .filter((c) => (customerId ? c.id === customerId : true)) + .flatMap((c) => accounting.usage.listFor(c.id)) + .filter((e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to)); + const bucket = req.query.bucket === 'week' ? 'week' : 'day'; + res.json({ points: usageTrend(entries, bucket) }); + }); + + /* ---------------- admin user management ---------------- */ + + const USERNAME_RE = /^[a-zA-Z0-9_.-]+$/; + + router.get('/users', (req, res) => { + res.json({ + users: accounting.users.list().map((u) => ({ + id: u.id, + username: u.username, + active: u.active, + createdMs: u.createdMs, + })), + }); + }); + + router.post('/users', (req, res) => { + const { username, password } = req.body ?? {}; + if (typeof username !== 'string' || !USERNAME_RE.test(username)) { + res.status(400).json({ error: 'username must match [a-zA-Z0-9_.-]+' }); + return; + } + if (typeof password !== 'string' || password.length < 8) { + res.status(400).json({ error: 'password must be at least 8 characters' }); + return; + } + if (accounting.users.findByUsername(username)) { + res.status(409).json({ error: 'username already exists' }); + return; + } + const user = makeAdminUser(username, password); + accounting.users.save(user); + res.status(201).json({ + id: user.id, + username: user.username, + active: user.active, + createdMs: user.createdMs, + }); + }); + + const setActive = (active: boolean): RequestHandler => (req, res) => { + const user = accounting.users.findByUsername(req.params.username); + if (!user) { + res.status(404).json({ error: 'admin user not found' }); + return; + } + if (!active) { + const activeCount = accounting.users.list().filter((u) => u.active).length; + if (user.active && activeCount <= 1) { + res.status(400).json({ error: 'cannot deactivate the last active admin user' }); + return; + } + } + accounting.users.save({ ...user, active }); + res.json({ ok: true }); + }; + + router.post('/users/:username/deactivate', setActive(false)); + router.post('/users/:username/activate', setActive(true)); + + /* ---------------- system: Zapier integration status ---------------- */ + + router.get('/zapier/status', (req, res) => { + const dir = path.join(PROJECT_ROOT, 'zapier-app'); + const readDir = (sub: string): string[] => { + try { + return fs + .readdirSync(path.join(dir, sub)) + .filter((f) => f.endsWith('.js')) + .map((f) => f.replace(/\.js$/, '')); + } catch { + return []; + } + }; + let version: string | undefined; + try { + version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; + } catch { + version = undefined; + } + res.json({ + appDirPresent: fs.existsSync(dir), + version, + triggers: readDir('triggers'), + creates: readDir('creates'), + }); + }); + + return router; +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..52b3160 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,264 @@ +import path from 'path'; +import { createHash, randomUUID } from 'crypto'; +import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express'; +import * as OpenApiValidator from 'express-openapi-validator'; +import swaggerUi from 'swagger-ui-express'; +import YAML from 'yamljs'; +import { + apiKeyAuth, + Customer, + CustomerRepo, + InMemoryCustomerRepo, +} from './auth'; +import { meter } from './meter'; +import { applyMonthlyCredit } from './billing/credit'; +import { + ConfigTierCatalog, + InMemoryPricingStore, + PricingContext, + PricingStore, +} from './pricing'; +import { InMemoryUsageRepo, UsageRepo } from './usage'; +import { adminAuth, adminLoginRouter, adminRouter } from './admin'; +import { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users'; +import { InMemorySessionRepo, SessionRepo } from './accounts'; +import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing'; +import { PROJECT_ROOT } from './paths'; +import { PaymentClient, portalRouter } from './portal'; +import { proxyVerae } from './upstream'; + +export interface StoredItem { + id: string; + customerId: string; + metadata: Record; + attachments: { filename: string; size: number }[]; + createdAt: string; +} + +export const DEFAULT_CUSTOMERS: Customer[] = [ + { id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' }, + { id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' }, + { id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' }, +]; + +export interface AppDeps { + usage?: UsageRepo; + customers?: CustomerRepo; + pricingStore?: PricingStore; + invoices?: InvoiceRepo; + adminUsers?: AdminUserRepo; + sessions?: SessionRepo; + payments?: PaymentClient; + /** QR renderer for 2FA setup; defaults to the qrcode package. */ + qr?: (uri: string) => Promise; +} + +const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml'); + +const parseMetadata: RequestHandler = (req, res, next) => { + const raw = req.body?.metadata; + if (raw === undefined || raw === null || raw === '') { + res.locals.parsedMetadata = {}; + return next(); + } + if (typeof raw !== 'string') { + res.locals.parsedMetadata = raw; + return next(); + } + try { + res.locals.parsedMetadata = JSON.parse(raw); + next(); + } catch { + res.status(400).json({ error: 'invalid metadata JSON' }); + } +}; + +export function buildApp(deps: AppDeps = {}): { + app: Express; + usage: UsageRepo; + customers: CustomerRepo; + pricing: PricingContext; + pricingStore: PricingStore; + invoices: InvoiceRepo; + items: StoredItem[]; +} { + const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS); + const usage = deps.usage ?? new InMemoryUsageRepo(); + const pricingStore = deps.pricingStore ?? new InMemoryPricingStore(); + const invoices = deps.invoices ?? new InMemoryInvoiceRepo(); + const adminUsers = + deps.adminUsers ?? InMemoryAdminUserRepo.seeded(seedAdminUsersFromEnv()); + // Live pricing context: every quote reads the store, so admin edits apply immediately. + const pricing: PricingContext = { + get rateCard() { + return pricingStore.getRateCard(); + }, + get tiers() { + return new ConfigTierCatalog(pricingStore.getTiers()); + }, + }; + const items: StoredItem[] = []; + const hashIndex = new Map< + string, + { jobId: string; sha256: string; data?: string; timestamp: string } + >(); + const jobIndex = new Map(); + + const app = express(); + app.use(express.json()); + + const spec = YAML.load(SPEC_PATH); + app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec)); + + app.use( + '/admin/api', + adminLoginRouter(adminUsers), + adminAuth(), + adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }), + ); + app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin'))); + + const sessions = deps.sessions ?? new InMemorySessionRepo(); + const qr = + deps.qr ?? + (async (uri: string) => { + const qrcode = await import('qrcode'); + return qrcode.toDataURL(uri, { margin: 1, width: 220 }); + }); + const payments: PaymentClient = deps.payments ?? { + // Safe default for tests/dev: credits immediately, no Stripe call. + reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }), + }; + app.use( + '/portal/api', + portalRouter({ + customers, + sessions, + usage, + invoices, + tiers: () => pricingStore.getTiers(), + rateCard: () => pricingStore.getRateCard(), + payments, + qr, + }), + ); + app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal'))); + + app.get('/health', (_req, res) => { + res.json({ ok: true, role: 'zappier-edge' }); + }); + + app.use('/v1', apiKeyAuth(customers)); + app.use( + OpenApiValidator.middleware({ + apiSpec: SPEC_PATH, + validateRequests: true, + validateResponses: false, + fileUploader: { limits: { fileSize: 25 * 1024 * 1024 } }, + }), + ); + + app.get('/v1/status', meter('status', usage, pricing), (req, res) => { + res.json({ status: 'ok', quote: res.locals.quote }); + }); + + app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => { + const text = String(req.body?.text ?? ''); + res.json({ output: text.toUpperCase(), quote: res.locals.quote }); + }); + + app.post('/v1/timestamp', meter('timestamp', usage, pricing), async (req, res) => { + if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return; + const data = req.body?.data != null ? String(req.body.data) : ''; + const sha256 = + (req.body?.sha256 && String(req.body.sha256).toLowerCase()) || + (data ? createHash('sha256').update(data, 'utf8').digest('hex') : ''); + if (!sha256) { + res.status(400).json({ error: 'data or sha256 is required' }); + return; + } + const existing = hashIndex.get(sha256); + if (existing) { + res.status(202).json({ jobId: existing.jobId, sha256, existing: true, timestamp: existing.timestamp }); + return; + } + const jobId = randomUUID(); + const timestamp = new Date().toISOString(); + const rec = { jobId, sha256, data, timestamp }; + hashIndex.set(sha256, rec); + jobIndex.set(jobId, rec); + res.status(202).json({ jobId, sha256, existing: false, timestamp }); + }); + + app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), async (req, res) => { + if (await proxyVerae(req, res, `/zapier/v1/receipts/${req.params.jobId}`)) return; + const rec = jobIndex.get(String(req.params.jobId)); + if (!rec) { + res.status(404).json({ error: 'Job not found' }); + return; + } + res.json({ + type: 'verae.retrieval-receipt', + format: 'json', + jobId: rec.jobId, + sha256: rec.sha256, + timestamp: rec.timestamp, + extraSeal: { event: 'document.retrieved', retrievedAt: new Date().toISOString() }, + quote: res.locals.quote, + }); + }); + + app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), async (req, res) => { + if (await proxyVerae(req, res, `/zapier/v1/hashes/${req.params.sha256}`)) return; + const sha256 = String(req.params.sha256 || '').toLowerCase(); + const rec = hashIndex.get(sha256); + if (!rec) { + res.status(404).json({ error: 'Hash not found' }); + return; + } + res.json({ exists: true, ...rec }); + }); + + app.post('/v1/add', meter('add', usage, pricing), (req, res) => { + const number1 = Number(req.body?.number1); + const number2 = Number(req.body?.number2); + if (!Number.isFinite(number1) || !Number.isFinite(number2)) { + res.status(400).json({ error: 'number1 and number2 must be finite numbers' }); + return; + } + res.json({ number1, number2, sum: number1 + number2, quote: res.locals.quote }); + }); + + app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing), (req, res) => { + const metadata = res.locals.parsedMetadata as Record; + const files = (req.files as Express.Multer.File[]) ?? []; + const item: StoredItem = { + id: randomUUID(), + customerId: req.customer!.id, + metadata, + attachments: files.map((f) => ({ filename: f.originalname, size: f.size })), + createdAt: new Date().toISOString(), + }; + items.unshift(item); + res.json({ id: item.id, quote: res.locals.quote }); + }); + + app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => { + res.json({ items: items.filter((i) => i.customerId === req.customer!.id) }); + }); + + app.get('/v1/usage', (req, res) => { + const since = new Date(); + since.setUTCDate(1); + since.setUTCHours(0, 0, 0, 0); + const summary = usage.summaryFor(req.customer!.id, since); + const tier = pricing.tiers.find(req.customer!.tierId); + res.json(tier ? applyMonthlyCredit(summary, tier) : summary); + }); + + app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message }); + }); + + return { app, usage, customers, pricing, pricingStore, invoices, items }; +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..0512b42 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,83 @@ +import { RequestHandler } from 'express'; + +export type BillingType = 'stripe' | 'purchase_order'; + +export interface Customer { + id: string; + name: string; + tierId: string; + apiKey: string; + stripeCustomerId?: string; + multiplierOverride?: number; + /** How this customer is billed. Absent means 'stripe' (the default). */ + billingType?: BillingType; + email?: string; + /** scrypt hash for portal login. Absent = no portal password set yet. */ + passwordHash?: string; + /** Base32 TOTP secret. Present once 2FA setup begins. */ + totpSecret?: string; + /** True once the customer has confirmed a TOTP code. */ + totpEnabled?: boolean; + /** Prepaid balance in cents, drawn down when invoices are issued. */ + balanceCents?: number; + /** Email a copy of each issued invoice to the customer's address. */ + emailInvoicing?: boolean; +} + +export interface CustomerRepo { + findByApiKey(apiKey: string): Customer | undefined; + findByEmail(email: string): Customer | undefined; + list(): Customer[]; + save(customer: Customer): void; +} + +export class InMemoryCustomerRepo implements CustomerRepo { + private customers: Customer[]; + + constructor(customers: Customer[] = []) { + // Defensive copy: save() replaces elements, and callers often pass shared + // seed arrays (DEFAULT_CUSTOMERS) that must not be mutated across tests. + this.customers = customers.map((c) => ({ ...c })); + } + + findByApiKey(apiKey: string): Customer | undefined { + return this.customers.find((c) => c.apiKey === apiKey); + } + + findByEmail(email: string): Customer | undefined { + const needle = email.toLowerCase(); + return this.customers.find((c) => c.email?.toLowerCase() === needle); + } + + list(): Customer[] { + return [...this.customers]; + } + + save(customer: Customer): void { + const i = this.customers.findIndex((c) => c.id === customer.id); + if (i >= 0) this.customers[i] = customer; + else this.customers.push(customer); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + customer?: Customer; + } + } +} + +export function apiKeyAuth(repo: CustomerRepo): RequestHandler { + return (req, res, next) => { + const key = req.header('x-api-key'); + const customer = key ? repo.findByApiKey(key) : undefined; + if (!customer) { + res.status(401).json({ error: 'invalid or missing API key' }); + return; + } + req.customer = customer; + next(); + }; +} diff --git a/src/billing/credit.ts b/src/billing/credit.ts new file mode 100644 index 0000000..05ac026 --- /dev/null +++ b/src/billing/credit.ts @@ -0,0 +1,19 @@ +import { TierConfig } from '../pricing'; +import { UsageSummary } from '../usage'; + +export interface BilledSummary extends UsageSummary { + includedCents: number; + billableCents: number; +} + +export function applyMonthlyCredit( + summary: UsageSummary, + tier: TierConfig, +): BilledSummary { + const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents); + return { + ...summary, + includedCents, + billableCents: summary.totalCents - includedCents, + }; +} diff --git a/src/billing/reload.ts b/src/billing/reload.ts new file mode 100644 index 0000000..b58d7ed --- /dev/null +++ b/src/billing/reload.ts @@ -0,0 +1,31 @@ +import Stripe from 'stripe'; +import { PaymentClient } from '../portal'; + +/** + * Stripe-backed reloads: creates a PaymentIntent and returns its client + * secret. The balance is credited only after the payment confirms (webhook + * step); until then creditedCents is 0 and the intent is pending. + */ +export function stripePaymentClient(secretKey: string): PaymentClient { + const stripe = new Stripe(secretKey); + return { + reload: async (customer, amountCents) => { + const intent = await stripe.paymentIntents.create({ + amount: amountCents, + currency: 'usd', + automatic_payment_methods: { enabled: true }, + metadata: { customerId: customer.id }, + }); + return { + mode: 'stripe', + creditedCents: 0, + clientSecret: intent.client_secret ?? undefined, + }; + }, + }; +} + +/** True when the key looks usable (not empty / not the setup placeholder). */ +export function hasRealStripeKey(key: string | undefined): key is string { + return typeof key === 'string' && key.startsWith('sk_'); +} diff --git a/src/billing/stripe.ts b/src/billing/stripe.ts new file mode 100644 index 0000000..86e1323 --- /dev/null +++ b/src/billing/stripe.ts @@ -0,0 +1,41 @@ +import { UsageEntry } from '../usage'; + +export const METER_EVENT_NAME = 'zappier.api_cents'; + +export interface MeterEventClient { + createMeterEvent(params: { + eventName: string; + customerId: string; + value: string; + /** Stripe-side dedup key; events with the same identifier are dropped. */ + identifier?: string; + }): Promise; +} + +export function computeBillableCents(entries: UsageEntry[], monthlyCreditCents: number): number { + const totalCents = entries.reduce((sum, e) => sum + e.cents, 0); + return Math.max(0, totalCents - monthlyCreditCents); +} + +export function computeDelta(billableCents: number, previouslyReportedCents: number): number { + return Math.max(0, billableCents - previouslyReportedCents); +} + +// Public API: retained for API compatibility. The monthly billing job now uses +// the delta-based path in src/jobs/report-usage.ts (reportMonthlyUsage), which +// shares computeBillableCents with this function. Do not remove. +export async function reportUsage( + client: MeterEventClient, + stripeCustomerId: string, + entries: UsageEntry[], + monthlyCreditCents: number, +): Promise { + const billable = computeBillableCents(entries, monthlyCreditCents); + if (billable <= 0) return 0; + await client.createMeterEvent({ + eventName: METER_EVENT_NAME, + customerId: stripeCustomerId, + value: String(billable), + }); + return billable; +} diff --git a/src/credits.ts b/src/credits.ts new file mode 100644 index 0000000..e5854b5 --- /dev/null +++ b/src/credits.ts @@ -0,0 +1,29 @@ +export interface CreditAdjustment { + id: string; + customerId: string; + cents: number; + reason: string; + agent: string; + at: string; +} + +export class CreditLedger { + private rows: CreditAdjustment[] = []; + + add(row: Omit & { id?: string; at?: string }): CreditAdjustment { + const rec: CreditAdjustment = { + id: row.id || `crd_${Date.now().toString(36)}`, + customerId: row.customerId, + cents: row.cents, + reason: row.reason, + agent: row.agent, + at: row.at || new Date().toISOString(), + }; + this.rows.unshift(rec); + return rec; + } + + list(customerId?: string): CreditAdjustment[] { + return customerId ? this.rows.filter((r) => r.customerId === customerId) : this.rows; + } +} diff --git a/src/db/admin-user-repo.ts b/src/db/admin-user-repo.ts new file mode 100644 index 0000000..50f9775 --- /dev/null +++ b/src/db/admin-user-repo.ts @@ -0,0 +1,71 @@ +import Database from 'better-sqlite3'; +import { AdminUser, AdminUserRepo, makeAdminUser } from '../admin-users'; + +/** SQLite-backed admin accounts; seeds from env only when the table is empty. */ +export class SqliteAdminUserRepo implements AdminUserRepo { + private constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS admin_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_ms INTEGER NOT NULL + ) + `); + } + + static seeded( + db: Database.Database, + seed: { username: string; password: string }[], + ): SqliteAdminUserRepo { + const repo = new SqliteAdminUserRepo(db); + const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get() as { n: number }; + if (n === 0) { + for (const s of seed) repo.save(makeAdminUser(s.username, s.password)); + } + return repo; + } + + list(): AdminUser[] { + const rows = this.db + .prepare('SELECT * FROM admin_users ORDER BY username') + .all() as Record[]; + return rows.map(toAdminUser); + } + + findByUsername(username: string): AdminUser | undefined { + const r = this.db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as + | Record + | undefined; + return r ? toAdminUser(r) : undefined; + } + + save(user: AdminUser): void { + this.db + .prepare( + `INSERT INTO admin_users (id, username, password_hash, active, created_ms) + VALUES (@id, @username, @passwordHash, @active, @createdMs) + ON CONFLICT(username) DO UPDATE SET + password_hash = @passwordHash, + active = @active`, + ) + .run({ + id: user.id, + username: user.username, + passwordHash: user.passwordHash, + active: user.active ? 1 : 0, + createdMs: user.createdMs, + }); + } +} + +function toAdminUser(r: Record): AdminUser { + return { + id: r.id as string, + username: r.username as string, + passwordHash: r.password_hash as string, + active: (r.active as number) === 1, + createdMs: r.created_ms as number, + }; +} diff --git a/src/db/billing-repo.ts b/src/db/billing-repo.ts new file mode 100644 index 0000000..c0d135c --- /dev/null +++ b/src/db/billing-repo.ts @@ -0,0 +1,68 @@ +import Database from 'better-sqlite3'; + +export interface BillingReportRepo { + getReportedCents(customerId: string, period: string): number; + upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void; +} + +export interface JobLockRepo { + tryAcquireLock(name: string, ttlMs: number): boolean; + releaseLock(name: string): void; +} + +export class SqliteBillingReportRepo implements BillingReportRepo, JobLockRepo { + constructor( + private db: Database.Database, + private now: () => number = Date.now, + ) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS billing_reports ( + customer_id TEXT NOT NULL, + period TEXT NOT NULL, + reported_cents INTEGER NOT NULL, + reported_at_ms INTEGER NOT NULL, + PRIMARY KEY (customer_id, period) + ) + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS job_locks ( + name TEXT PRIMARY KEY, + acquired_at_ms INTEGER NOT NULL + ) + `); + } + + getReportedCents(customerId: string, period: string): number { + const row = this.db + .prepare('SELECT reported_cents FROM billing_reports WHERE customer_id = ? AND period = ?') + .get(customerId, period) as { reported_cents: number } | undefined; + return row?.reported_cents ?? 0; + } + + upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void { + this.db + .prepare( + `INSERT INTO billing_reports (customer_id, period, reported_cents, reported_at_ms) + VALUES (?, ?, ?, ?) + ON CONFLICT(customer_id, period) DO UPDATE SET + reported_cents = excluded.reported_cents, + reported_at_ms = excluded.reported_at_ms`, + ) + .run(customerId, period, cumulativeCents, this.now()); + } + + tryAcquireLock(name: string, ttlMs: number): boolean { + const result = this.db + .prepare( + `INSERT INTO job_locks (name, acquired_at_ms) VALUES (?, ?) + ON CONFLICT(name) DO UPDATE SET acquired_at_ms = excluded.acquired_at_ms + WHERE job_locks.acquired_at_ms <= excluded.acquired_at_ms - ?`, + ) + .run(name, this.now(), ttlMs); + return result.changes === 1; + } + + releaseLock(name: string): void { + this.db.prepare('DELETE FROM job_locks WHERE name = ?').run(name); + } +} diff --git a/src/db/customer-repo.ts b/src/db/customer-repo.ts new file mode 100644 index 0000000..a516e3b --- /dev/null +++ b/src/db/customer-repo.ts @@ -0,0 +1,112 @@ +import Database from 'better-sqlite3'; +import { Customer, CustomerRepo } from '../auth'; + +export class SqliteCustomerRepo implements CustomerRepo { + constructor(private db: Database.Database, seed: Customer[] = []) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS customers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + tier_id TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + stripe_customer_id TEXT, + multiplier_override REAL + ) + `); + // Idempotent migrations for pre-existing databases. + this.ensureColumn('billing_type', `billing_type TEXT NOT NULL DEFAULT 'stripe'`); + this.ensureColumn('email', 'email TEXT'); + this.ensureColumn('password_hash', 'password_hash TEXT'); + this.ensureColumn('totp_secret', 'totp_secret TEXT'); + this.ensureColumn('totp_enabled', 'totp_enabled INTEGER NOT NULL DEFAULT 0'); + this.ensureColumn('balance_cents', 'balance_cents INTEGER NOT NULL DEFAULT 0'); + this.ensureColumn('email_invoicing', 'email_invoicing INTEGER NOT NULL DEFAULT 0'); + const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }; + if (n === 0) { + for (const c of seed) this.save(c); + } + } + + private ensureColumn(name: string, ddl: string): void { + const cols = this.db.prepare('PRAGMA table_info(customers)').all() as { name: string }[]; + if (!cols.some((c) => c.name === name)) { + this.db.exec(`ALTER TABLE customers ADD COLUMN ${ddl}`); + } + } + + findByApiKey(apiKey: string): Customer | undefined { + const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as + | Record + | undefined; + return r ? toCustomer(r) : undefined; + } + + findByEmail(email: string): Customer | undefined { + const r = this.db + .prepare('SELECT * FROM customers WHERE lower(email) = lower(?)') + .get(email) as Record | undefined; + return r ? toCustomer(r) : undefined; + } + + list(): Customer[] { + const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record< + string, + unknown + >[]; + return rows.map(toCustomer); + } + + save(customer: Customer): void { + this.db + .prepare( + `INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override, billing_type, email, password_hash, totp_secret, totp_enabled, balance_cents, email_invoicing) + VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride, @billingType, @email, @passwordHash, @totpSecret, @totpEnabled, @balanceCents, @emailInvoicing) + ON CONFLICT(id) DO UPDATE SET + name = @name, + tier_id = @tierId, + api_key = @apiKey, + stripe_customer_id = @stripeCustomerId, + multiplier_override = @multiplierOverride, + billing_type = @billingType, + email = @email, + password_hash = @passwordHash, + totp_secret = @totpSecret, + totp_enabled = @totpEnabled, + balance_cents = @balanceCents, + email_invoicing = @emailInvoicing`, + ) + .run({ + id: customer.id, + name: customer.name, + tierId: customer.tierId, + apiKey: customer.apiKey, + stripeCustomerId: customer.stripeCustomerId ?? null, + multiplierOverride: customer.multiplierOverride ?? null, + billingType: customer.billingType ?? 'stripe', + email: customer.email ?? null, + passwordHash: customer.passwordHash ?? null, + totpSecret: customer.totpSecret ?? null, + totpEnabled: customer.totpEnabled ? 1 : 0, + balanceCents: customer.balanceCents ?? 0, + emailInvoicing: customer.emailInvoicing ? 1 : 0, + }); + } +} + +function toCustomer(r: Record): Customer { + return { + id: r.id as string, + name: r.name as string, + tierId: r.tier_id as string, + apiKey: r.api_key as string, + stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined, + multiplierOverride: (r.multiplier_override as number | null) ?? undefined, + billingType: ((r.billing_type as string | null) ?? 'stripe') as Customer['billingType'], + email: (r.email as string | null) ?? undefined, + passwordHash: (r.password_hash as string | null) ?? undefined, + totpSecret: (r.totp_secret as string | null) ?? undefined, + totpEnabled: ((r.totp_enabled as number | null) ?? 0) === 1, + balanceCents: (r.balance_cents as number | null) ?? 0, + emailInvoicing: ((r.email_invoicing as number | null) ?? 0) === 1, + }; +} diff --git a/src/db/invoice-repo.ts b/src/db/invoice-repo.ts new file mode 100644 index 0000000..9fd7a92 --- /dev/null +++ b/src/db/invoice-repo.ts @@ -0,0 +1,119 @@ +import Database from 'better-sqlite3'; +import { Invoice, InvoiceLine, InvoiceRepo } from '../invoicing'; + +export class SqliteInvoiceRepo implements InvoiceRepo { + constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS invoices ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + period TEXT NOT NULL, + status TEXT NOT NULL, + total_cents INTEGER NOT NULL, + credit_cents INTEGER NOT NULL, + billable_cents INTEGER NOT NULL, + billing_type TEXT NOT NULL DEFAULT 'stripe', + po_number TEXT, + issued_at_ms INTEGER, + due_at_ms INTEGER, + paid_at_ms INTEGER + ); + CREATE TABLE IF NOT EXISTS invoice_lines ( + invoice_id TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + calls INTEGER NOT NULL, + cents INTEGER NOT NULL, + PRIMARY KEY (invoice_id, endpoint_id) + ); + `); + } + + save(invoice: Invoice): void { + this.db + .prepare( + `INSERT INTO invoices + (id, customer_id, period, status, total_cents, credit_cents, billable_cents, + billing_type, po_number, issued_at_ms, due_at_ms, paid_at_ms) + VALUES (@id, @customerId, @period, @status, @totalCents, @creditCents, @billableCents, + @billingType, @poNumber, @issuedAtMs, @dueAtMs, @paidAtMs) + ON CONFLICT(id) DO UPDATE SET + customer_id = @customerId, period = @period, status = @status, + total_cents = @totalCents, credit_cents = @creditCents, + billable_cents = @billableCents, billing_type = @billingType, + po_number = @poNumber, issued_at_ms = @issuedAtMs, + due_at_ms = @dueAtMs, paid_at_ms = @paidAtMs`, + ) + .run({ + ...invoice, + poNumber: invoice.poNumber ?? null, + issuedAtMs: invoice.issuedAtMs ?? null, + dueAtMs: invoice.dueAtMs ?? null, + paidAtMs: invoice.paidAtMs ?? null, + }); + // Replace the line set atomically with the invoice row. + this.db.prepare('DELETE FROM invoice_lines WHERE invoice_id = ?').run(invoice.id); + const insert = this.db.prepare( + 'INSERT INTO invoice_lines (invoice_id, endpoint_id, calls, cents) VALUES (?, ?, ?, ?)', + ); + for (const line of invoice.lines) { + insert.run(invoice.id, line.endpointId, line.calls, line.cents); + } + } + + get(id: string): Invoice | undefined { + const row = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id) as + | Record + | undefined; + return row ? this.toInvoice(row) : undefined; + } + + list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[] { + const where: string[] = []; + const params: Record = {}; + if (filter.customerId) { + where.push('customer_id = @customerId'); + params.customerId = filter.customerId; + } + if (filter.period) { + where.push('period = @period'); + params.period = filter.period; + } + if (filter.status) { + where.push('status = @status'); + params.status = filter.status; + } + const sql = `SELECT * FROM invoices ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`; + const rows = this.db.prepare(sql).all(params) as Record[]; + return rows.map((r) => this.toInvoice(r)); + } + + nextSequence(period: string): number { + const { n } = this.db + .prepare('SELECT COUNT(*) AS n FROM invoices WHERE period = ?') + .get(period) as { n: number }; + return n + 1; + } + + private toInvoice(row: Record): Invoice { + const lines = this.db + .prepare('SELECT endpoint_id, calls, cents FROM invoice_lines WHERE invoice_id = ? ORDER BY endpoint_id') + .all(row.id as string) as { endpoint_id: string; calls: number; cents: number }[]; + return { + id: row.id as string, + customerId: row.customer_id as string, + period: row.period as string, + status: row.status as Invoice['status'], + lines: lines.map( + (l): InvoiceLine => ({ endpointId: l.endpoint_id, calls: l.calls, cents: l.cents }), + ), + totalCents: row.total_cents as number, + creditCents: row.credit_cents as number, + billableCents: row.billable_cents as number, + billingType: row.billing_type as Invoice['billingType'], + poNumber: (row.po_number as string | null) ?? undefined, + issuedAtMs: (row.issued_at_ms as number | null) ?? undefined, + dueAtMs: (row.due_at_ms as number | null) ?? undefined, + paidAtMs: (row.paid_at_ms as number | null) ?? undefined, + }; + } +} diff --git a/src/db/pricing-store.ts b/src/db/pricing-store.ts new file mode 100644 index 0000000..fef685d --- /dev/null +++ b/src/db/pricing-store.ts @@ -0,0 +1,106 @@ +import Database from 'better-sqlite3'; +import { + DEFAULT_RATE_CARD, + DEFAULT_TIERS, + PriceRule, + PricingStore, + RateCard, + TierConfig, +} from '../pricing'; + +export class SqlitePricingStore implements PricingStore { + constructor( + private db: Database.Database, + seedCard: RateCard = DEFAULT_RATE_CARD, + seedTiers: TierConfig[] = DEFAULT_TIERS, + ) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS price_endpoints ( + endpoint_id TEXT PRIMARY KEY, + rule_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS tiers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + multiplier REAL NOT NULL, + monthly_credit_cents INTEGER NOT NULL, + default_rule_json TEXT + ) + `); + const { n: endpointCount } = this.db + .prepare('SELECT COUNT(*) AS n FROM price_endpoints') + .get() as { n: number }; + if (endpointCount === 0) { + for (const [id, rule] of Object.entries(seedCard.endpoints)) { + this.upsertEndpoint(id, rule); + } + } + const { n: tierCount } = this.db.prepare('SELECT COUNT(*) AS n FROM tiers').get() as { + n: number; + }; + if (tierCount === 0) { + for (const tier of seedTiers) { + this.upsertTier(tier); + } + } + } + + getRateCard(): RateCard { + const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record[]; + const endpoints: Record = {}; + for (const r of rows) { + endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule; + } + return { endpoints }; + } + + getTiers(): TierConfig[] { + const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record[]; + return rows.map((r) => ({ + id: r.id as string, + name: r.name as string, + multiplier: r.multiplier as number, + monthlyCreditCents: r.monthly_credit_cents as number, + defaultRule: r.default_rule_json + ? (JSON.parse(r.default_rule_json as string) as PriceRule) + : undefined, + })); + } + + upsertEndpoint(endpointId: string, rule: PriceRule): void { + this.db + .prepare( + `INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?) + ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`, + ) + .run(endpointId, JSON.stringify(rule)); + } + + deleteEndpoint(endpointId: string): void { + this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId); + } + + upsertTier(tier: TierConfig): void { + this.db + .prepare( + `INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json) + VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson) + ON CONFLICT(id) DO UPDATE SET + name = @name, + multiplier = @multiplier, + monthly_credit_cents = @monthlyCreditCents, + default_rule_json = @defaultRuleJson`, + ) + .run({ + id: tier.id, + name: tier.name, + multiplier: tier.multiplier, + monthlyCreditCents: tier.monthlyCreditCents, + defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null, + }); + } + + deleteTier(tierId: string): void { + this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId); + } +} diff --git a/src/db/session-repo.ts b/src/db/session-repo.ts new file mode 100644 index 0000000..4749138 --- /dev/null +++ b/src/db/session-repo.ts @@ -0,0 +1,51 @@ +import Database from 'better-sqlite3'; +import { newSessionToken, PortalSession, SessionRepo } from '../accounts'; + +/** SQLite-backed portal sessions; survives server restarts. */ +export class SqliteSessionRepo implements SessionRepo { + constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS portal_sessions ( + token TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + created_ms INTEGER NOT NULL, + expires_ms INTEGER NOT NULL + ) + `); + } + + create(customerId: string, ttlMs: number): PortalSession { + const now = Date.now(); + const session: PortalSession = { + token: newSessionToken(), + customerId, + createdMs: now, + expiresMs: now + ttlMs, + }; + this.db + .prepare( + 'INSERT INTO portal_sessions (token, customer_id, created_ms, expires_ms) VALUES (?, ?, ?, ?)', + ) + .run(session.token, session.customerId, session.createdMs, session.expiresMs); + return session; + } + + get(token: string, nowMs = Date.now()): PortalSession | undefined { + const r = this.db + .prepare('SELECT * FROM portal_sessions WHERE token = ?') + .get(token) as + | { token: string; customer_id: string; created_ms: number; expires_ms: number } + | undefined; + if (!r || r.expires_ms <= nowMs) return undefined; + return { + token: r.token, + customerId: r.customer_id, + createdMs: r.created_ms, + expiresMs: r.expires_ms, + }; + } + + delete(token: string): void { + this.db.prepare('DELETE FROM portal_sessions WHERE token = ?').run(token); + } +} diff --git a/src/db/usage-repo.ts b/src/db/usage-repo.ts new file mode 100644 index 0000000..f95c949 --- /dev/null +++ b/src/db/usage-repo.ts @@ -0,0 +1,61 @@ +import Database from 'better-sqlite3'; +import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage'; + +export class SqliteUsageRepo implements UsageRepo { + constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS usage_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + cents INTEGER NOT NULL, + metadata_bytes INTEGER NOT NULL, + attachment_bytes INTEGER NOT NULL, + timestamp_ms INTEGER NOT NULL + ) + `); + } + + record(entry: UsageEntry): void { + this.db + .prepare( + `INSERT INTO usage_entries + (customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + entry.customerId, + entry.endpointId, + entry.cents, + entry.metadataBytes, + entry.attachmentBytes, + entry.timestamp.getTime(), + ); + } + + listFor(customerId: string, since?: Date): UsageEntry[] { + const rows = ( + since + ? this.db + .prepare( + 'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms', + ) + .all(customerId, since.getTime()) + : this.db + .prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms') + .all(customerId) + ) as Record[]; + return rows.map((r) => ({ + customerId: r.customer_id as string, + endpointId: r.endpoint_id as string, + cents: r.cents as number, + metadataBytes: r.metadata_bytes as number, + attachmentBytes: r.attachment_bytes as number, + timestamp: new Date(r.timestamp_ms as number), + })); + } + + summaryFor(customerId: string, since?: Date): UsageSummary { + return summarize(customerId, this.listFor(customerId, since)); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..998bd56 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,41 @@ +import path from 'path'; +import Database from 'better-sqlite3'; +import { config as loadEnv } from 'dotenv'; +import { buildApp, DEFAULT_CUSTOMERS } from './app'; +import { seedAdminUsersFromEnv } from './admin-users'; +import { hasRealStripeKey, stripePaymentClient } from './billing/reload'; +import { SqliteAdminUserRepo } from './db/admin-user-repo'; +import { SqliteCustomerRepo } from './db/customer-repo'; +import { SqliteInvoiceRepo } from './db/invoice-repo'; +import { SqlitePricingStore } from './db/pricing-store'; +import { SqliteSessionRepo } from './db/session-repo'; +import { SqliteUsageRepo } from './db/usage-repo'; +import { PROJECT_ROOT } from './paths'; +import { PaymentClient } from './portal'; + +loadEnv({ path: path.join(PROJECT_ROOT, '.env') }); + +const db = new Database(process.env.ZAPPIER_DB ?? path.join(PROJECT_ROOT, 'zappier.db')); +const usage = new SqliteUsageRepo(db); +const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS); +const pricingStore = new SqlitePricingStore(db); +const invoices = new SqliteInvoiceRepo(db); +const adminUsers = SqliteAdminUserRepo.seeded(db, seedAdminUsersFromEnv()); +const sessions = new SqliteSessionRepo(db); + +// Real Stripe reloads when a usable key is configured; otherwise a dev client +// credits the balance immediately (placeholder key, local development). +const payments: PaymentClient = hasRealStripeKey(process.env.STRIPE_SECRET_KEY) + ? stripePaymentClient(process.env.STRIPE_SECRET_KEY) + : { + reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }), + }; + +const { app } = buildApp({ usage, customers, pricingStore, invoices, adminUsers, sessions, payments }); +const port = Number(process.env.PORT ?? 3000); +app.listen(port, () => { + console.log(`Zappier API listening on http://localhost:${port}`); + console.log(`OpenAPI docs at http://localhost:${port}/docs`); + console.log(`Customer portal at http://localhost:${port}/portal`); + console.log(`Admin console at http://localhost:${port}/admin`); +}); diff --git a/src/invoicing.ts b/src/invoicing.ts new file mode 100644 index 0000000..1a0e694 --- /dev/null +++ b/src/invoicing.ts @@ -0,0 +1,106 @@ +import { Customer } from './auth'; +import { TierConfig } from './pricing'; +import { UsageEntry } from './usage'; + +export interface InvoiceLine { + endpointId: string; + calls: number; + cents: number; +} + +export interface Invoice { + /** INV--, e.g. INV-2026-07-0007 */ + id: string; + customerId: string; + /** Billing period, YYYY-MM */ + period: string; + status: 'draft' | 'issued' | 'paid'; + lines: InvoiceLine[]; + /** Gross usage in cents (sum of lines, before credit). */ + totalCents: number; + /** Monthly credit actually consumed. */ + creditCents: number; + /** totalCents − creditCents, floored at 0. */ + billableCents: number; + billingType: 'stripe' | 'purchase_order'; + poNumber?: string; + issuedAtMs?: number; + dueAtMs?: number; + paidAtMs?: number; +} + +export interface InvoiceRepo { + save(invoice: Invoice): void; + get(id: string): Invoice | undefined; + list(filter: { + customerId?: string; + period?: string; + status?: Invoice['status']; + }): Invoice[]; + /** 1-based sequence for the next invoice id within a period. */ + nextSequence(period: string): number; +} + +/** Test/dev adapter; production uses SqliteInvoiceRepo. */ +export class InMemoryInvoiceRepo implements InvoiceRepo { + private invoices = new Map(); + + save(invoice: Invoice): void { + this.invoices.set(invoice.id, { ...invoice, lines: invoice.lines.map((l) => ({ ...l })) }); + } + + get(id: string): Invoice | undefined { + return this.invoices.get(id); + } + + list(filter: { + customerId?: string; + period?: string; + status?: Invoice['status']; + }): Invoice[] { + return [...this.invoices.values()] + .filter((i) => (filter.customerId ? i.customerId === filter.customerId : true)) + .filter((i) => (filter.period ? i.period === filter.period : true)) + .filter((i) => (filter.status ? i.status === filter.status : true)) + .sort((a, b) => a.id.localeCompare(b.id)); + } + + nextSequence(period: string): number { + return [...this.invoices.values()].filter((i) => i.period === period).length + 1; + } +} + +export function buildInvoice(args: { + customer: Customer; + period: string; + sequence: number; + entries: UsageEntry[]; + tier: TierConfig; + poNumber?: string; +}): Invoice { + const { customer, period, sequence, entries, tier, poNumber } = args; + const byEndpoint = new Map(); + for (const e of entries) { + const line = byEndpoint.get(e.endpointId) ?? { endpointId: e.endpointId, calls: 0, cents: 0 }; + line.calls += 1; + line.cents += e.cents; + byEndpoint.set(e.endpointId, line); + } + const lines = [...byEndpoint.values()].sort((a, b) => + a.endpointId.localeCompare(b.endpointId), + ); + const totalCents = lines.reduce((sum, l) => sum + l.cents, 0); + const creditCents = Math.min(totalCents, tier.monthlyCreditCents); + return { + id: `INV-${period}-${String(sequence).padStart(4, '0')}`, + customerId: customer.id, + period, + status: 'draft', + lines, + totalCents, + creditCents, + billableCents: totalCents - creditCents, + billingType: customer.billingType ?? 'stripe', + ...(poNumber !== undefined ? { poNumber } : {}), + }; +} diff --git a/src/jobs/report-usage.ts b/src/jobs/report-usage.ts new file mode 100644 index 0000000..2c6607f --- /dev/null +++ b/src/jobs/report-usage.ts @@ -0,0 +1,129 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import Stripe from 'stripe'; +import { DEFAULT_CUSTOMERS } from '../app'; +import { CustomerRepo } from '../auth'; +import { + computeBillableCents, + computeDelta, + MeterEventClient, + METER_EVENT_NAME, +} from '../billing/stripe'; +import { BillingReportRepo, JobLockRepo, SqliteBillingReportRepo } from '../db/billing-repo'; +import { SqliteCustomerRepo } from '../db/customer-repo'; +import { SqlitePricingStore } from '../db/pricing-store'; +import { SqliteUsageRepo } from '../db/usage-repo'; +import { PROJECT_ROOT } from '../paths'; +import { TierConfig } from '../pricing'; +import { UsageRepo } from '../usage'; + +export interface ReportUsageDeps { + client: MeterEventClient; + usage: Pick; + customers: Pick; + tiers: TierConfig[]; + billingRepo: BillingReportRepo; + locks: JobLockRepo; + since?: Date; + log?: (message: string) => void; + warn?: (message: string) => void; +} + +export const REPORT_USAGE_LOCK = 'report-usage'; +// A crashed run leaves the lock behind; take it over after one hour. +const LOCK_TTL_MS = 60 * 60 * 1000; + +export function firstOfMonthUtc(now: Date): Date { + const since = new Date(now); + since.setUTCDate(1); + since.setUTCHours(0, 0, 0, 0); + return since; +} + +export async function reportMonthlyUsage(deps: ReportUsageDeps): Promise { + const log = deps.log ?? console.log; + const warn = deps.warn ?? console.warn; + if (!deps.locks.tryAcquireLock(REPORT_USAGE_LOCK, LOCK_TTL_MS)) { + log(`${REPORT_USAGE_LOCK}: another run holds the lock, abort run`); + return; + } + try { + const since = deps.since ?? firstOfMonthUtc(new Date()); + const period = since.toISOString().slice(0, 7); + + for (const customer of deps.customers.list()) { + if (!customer.stripeCustomerId) continue; + const tier = deps.tiers.find((t) => t.id === customer.tierId); + if (!tier) { + warn(`${customer.id}: unknown tier ${customer.tierId}, skipped`); + continue; + } + const entries = deps.usage.listFor(customer.id, since); + const billable = computeBillableCents(entries, tier.monthlyCreditCents); + const prior = deps.billingRepo.getReportedCents(customer.id, period); + const delta = computeDelta(billable, prior); + if (delta <= 0) { + log( + prior > 0 + ? `skip ${customer.id} ${period} (already reported ${prior}c)` + : `skip ${customer.id} ${period} (nothing to report)`, + ); + continue; + } + // Stripe meter events are additive: report only the delta, then record the + // cumulative billable amount. The ledger is updated only after the meter + // event succeeds so a failed run retries with the full outstanding delta. + // The identifier lets Stripe drop a duplicate if we crash after the meter + // event succeeds but before the ledger upsert commits and then retry. + await deps.client.createMeterEvent({ + eventName: METER_EVENT_NAME, + customerId: customer.stripeCustomerId, + value: String(delta), + identifier: `${customer.stripeCustomerId}:${period}:${billable}`, + }); + deps.billingRepo.upsertReportedCents(customer.id, period, billable); + log(`${customer.id}: reported ${delta} billable cents to Stripe`); + } + } finally { + deps.locks.releaseLock(REPORT_USAGE_LOCK); + } +} + +async function main(): Promise { + // Loads STRIPE_SECRET_KEY (and ZAPPIER_DB, if set) from the gitignored .env + // at the project root, regardless of the process working directory. + const { config: loadEnv } = await import('dotenv'); + loadEnv({ path: path.join(PROJECT_ROOT, '.env') }); + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + const client: MeterEventClient = { + createMeterEvent: async (p) => { + await stripe.billing.meterEvents.create({ + event_name: METER_EVENT_NAME, + payload: { stripe_customer_id: p.customerId, value: p.value }, + ...(p.identifier ? { identifier: p.identifier } : {}), + }); + }, + }; + + const db = new Database(process.env.ZAPPIER_DB ?? path.join(PROJECT_ROOT, 'zappier.db')); + const usage = new SqliteUsageRepo(db); + const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS); + const pricingStore = new SqlitePricingStore(db); + const billingRepo = new SqliteBillingReportRepo(db); + + await reportMonthlyUsage({ + client, + usage, + customers, + tiers: pricingStore.getTiers(), + billingRepo, + locks: billingRepo, + }); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/meter.ts b/src/meter.ts new file mode 100644 index 0000000..2c9f171 --- /dev/null +++ b/src/meter.ts @@ -0,0 +1,48 @@ +import { RequestHandler } from 'express'; +import { PricingContext, Quote, quoteCall } from './pricing'; +import { UsageRepo } from './usage'; + +export function meter( + endpointId: string, + repo: UsageRepo, + pricing: PricingContext, +): RequestHandler { + return (req, res, next) => { + const customer = req.customer; + if (!customer) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const metadataBytes = Buffer.byteLength( + JSON.stringify(req.body?.metadata ?? {}), + 'utf8', + ); + const files = (req.files as Express.Multer.File[] | undefined) ?? []; + const attachmentBytes = files.reduce((sum, f) => sum + f.size, 0); + + let quote: Quote; + try { + quote = quoteCall( + pricing, + customer.tierId, + endpointId, + { metadataBytes, attachmentBytes }, + customer.multiplierOverride, + ); + } catch (err) { + res.status(403).json({ error: (err as Error).message }); + return; + } + + repo.record({ + customerId: customer.id, + endpointId, + cents: quote.totalCents, + metadataBytes, + attachmentBytes, + timestamp: new Date(), + }); + res.locals.quote = quote; + next(); + }; +} diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..0cb49f1 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,9 @@ +import path from 'path'; + +/** + * Absolute path of the project root, resolved from this module's location so + * the app works from any working directory (systemd, Docker, cron, `node + * dist/index.js` from elsewhere). Holds for both layouts: ts-node running + * src/ (root is one level up) and compiled dist/ (same one level up). + */ +export const PROJECT_ROOT = path.join(__dirname, '..'); diff --git a/src/portal.ts b/src/portal.ts new file mode 100644 index 0000000..1e1b0b1 --- /dev/null +++ b/src/portal.ts @@ -0,0 +1,284 @@ +import { randomBytes } from 'crypto'; +import { RequestHandler, Router } from 'express'; +import { + generateTotpSecret, + hashPassword, + SessionRepo, + totpUri, + verifyPassword, + verifyTotp, +} from './accounts'; +import { renderInvoiceHtml } from './admin'; +import { Customer, CustomerRepo } from './auth'; +import { applyMonthlyCredit } from './billing/credit'; +import { InvoiceRepo } from './invoicing'; +import { RateCard, TierConfig } from './pricing'; +import { UsageRepo } from './usage'; + +/** + * Customer portal API (/portal/api): signup, login with optional TOTP 2FA, + * profile, API-key regeneration, usage, own invoices, prepaid reloads, and + * email-invoicing preferences. Sessions authenticate via Bearer token. + */ + +export interface PaymentResult { + mode: 'stripe' | 'dev'; + /** Cents actually credited to the balance now (0 while a Stripe intent awaits confirmation). */ + creditedCents: number; + clientSecret?: string; +} + +export interface PaymentClient { + reload(customer: Customer, amountCents: number): Promise; +} + +export interface PortalDeps { + customers: CustomerRepo; + sessions: SessionRepo; + usage: UsageRepo; + invoices: InvoiceRepo; + tiers: () => TierConfig[]; + rateCard: () => RateCard; + payments: PaymentClient; + /** Renders an otpauth URI as a QR data URL; injectable for tests. */ + qr: (uri: string) => Promise; + sessionTtlMs?: number; + now?: () => number; +} + +const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_RELOAD_CENTS = 100; // $1 +const MAX_RELOAD_CENTS = 1_000_000; // $10,000 + +/** Profile payload safe to return to the customer (no hashes/secrets). */ +function publicProfile(c: Customer) { + return { + id: c.id, + name: c.name, + email: c.email, + tierId: c.tierId, + billingType: c.billingType ?? 'stripe', + apiKey: c.apiKey, + balanceCents: c.balanceCents ?? 0, + totpEnabled: c.totpEnabled ?? false, + emailInvoicing: c.emailInvoicing ?? false, + }; +} + +function sessionAuth(deps: PortalDeps): RequestHandler { + return (req, res, next) => { + const bearer = req.header('authorization'); + const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined; + const session = token ? deps.sessions.get(token, (deps.now ?? Date.now)()) : undefined; + const customer = session + ? deps.customers.list().find((c) => c.id === session.customerId) + : undefined; + if (!customer) { + res.status(401).json({ error: 'invalid or expired session' }); + return; + } + req.customer = customer; + next(); + }; +} + +function save(deps: PortalDeps, customer: Customer): void { + deps.customers.save(customer); +} + +export function portalRouter(deps: PortalDeps): Router { + const router = Router(); + const ttl = deps.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS; + const now = deps.now ?? Date.now; + + /* ---------------- auth ---------------- */ + + router.post('/signup', (req, res) => { + const { name, email, password } = req.body ?? {}; + if (typeof name !== 'string' || name.trim().length === 0) { + res.status(400).json({ error: 'name is required' }); + return; + } + if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + res.status(400).json({ error: 'valid email is required' }); + return; + } + if (typeof password !== 'string' || password.length < 8) { + res.status(400).json({ error: 'password must be at least 8 characters' }); + return; + } + const passwordHash = hashPassword(password); + const existing = deps.customers.findByEmail(email); + let customer: Customer; + if (existing) { + if (existing.passwordHash) { + res.status(409).json({ error: 'an account with this email already exists' }); + return; + } + // Claim flow: an admin-created customer sets their portal password once. + customer = { ...existing, name: existing.name || name.trim(), email, passwordHash }; + } else { + customer = { + id: `cust_${randomBytes(4).toString('hex')}`, + name: name.trim(), + email, + tierId: 'free', + apiKey: `key-${randomBytes(8).toString('hex')}`, + billingType: 'stripe', + passwordHash, + balanceCents: 0, + }; + } + save(deps, customer); + const session = deps.sessions.create(customer.id, ttl); + res.status(201).json({ token: session.token, customer: publicProfile(customer) }); + }); + + router.post('/login', (req, res) => { + const { email, password, totpCode } = req.body ?? {}; + if (typeof email !== 'string' || typeof password !== 'string') { + res.status(400).json({ error: 'email and password are required' }); + return; + } + const customer = deps.customers.findByEmail(email); + if (!customer?.passwordHash || !verifyPassword(password, customer.passwordHash)) { + res.status(401).json({ error: 'invalid email or password' }); + return; + } + if (customer.totpEnabled) { + if ( + typeof totpCode !== 'string' || + !customer.totpSecret || + !verifyTotp(customer.totpSecret, totpCode, now()) + ) { + res.status(401).json({ error: 'totp_required' }); + return; + } + } + const session = deps.sessions.create(customer.id, ttl); + res.json({ token: session.token, customer: publicProfile(customer) }); + }); + + router.use(sessionAuth(deps)); + + router.post('/logout', (req, res) => { + const bearer = req.header('authorization')!; + deps.sessions.delete(bearer.slice(7)); + res.json({ ok: true }); + }); + + /* ---------------- profile + API key ---------------- */ + + router.get('/me', (req, res) => { + res.json(publicProfile(req.customer!)); + }); + + router.post('/api-key', (req, res) => { + const customer = { ...req.customer!, apiKey: `key-${randomBytes(8).toString('hex')}` }; + save(deps, customer); + res.json({ apiKey: customer.apiKey }); + }); + + /* ---------------- usage + invoices ---------------- */ + + router.get('/usage', (req, res) => { + const since = new Date(now()); + since.setUTCDate(1); + since.setUTCHours(0, 0, 0, 0); + const summary = deps.usage.summaryFor(req.customer!.id, since); + const tier = deps.tiers().find((t) => t.id === req.customer!.tierId); + res.json(tier ? applyMonthlyCredit(summary, tier) : summary); + }); + + router.get('/invoices', (req, res) => { + res.json({ invoices: deps.invoices.list({ customerId: req.customer!.id }) }); + }); + + router.get('/pricing', (req, res) => { + res.json({ tiers: deps.tiers(), rateCard: deps.rateCard() }); + }); + + router.get('/invoices/:id', (req, res) => { + const invoice = deps.invoices.get(req.params.id); + if (!invoice || invoice.customerId !== req.customer!.id) { + res.status(404).json({ error: 'invoice not found' }); + return; + } + if (req.query.format === 'html') { + res.type('html').send(renderInvoiceHtml(invoice, req.customer!.name)); + return; + } + res.json(invoice); + }); + + /* ---------------- 2FA ---------------- */ + + router.post('/2fa/setup', async (req, res) => { + const secret = generateTotpSecret(); + save(deps, { ...req.customer!, totpSecret: secret, totpEnabled: false }); + const uri = totpUri(secret, req.customer!.email ?? req.customer!.id); + res.json({ secret, uri, qr: await deps.qr(uri) }); + }); + + router.post('/2fa/enable', (req, res) => { + const { code } = req.body ?? {}; + const secret = req.customer!.totpSecret; + if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) { + res.status(400).json({ error: 'invalid code — scan the QR and try the current 6-digit code' }); + return; + } + save(deps, { ...req.customer!, totpEnabled: true }); + res.json({ ok: true, totpEnabled: true }); + }); + + router.post('/2fa/disable', (req, res) => { + const { code } = req.body ?? {}; + const secret = req.customer!.totpSecret; + if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) { + res.status(400).json({ error: 'invalid code' }); + return; + } + save(deps, { ...req.customer!, totpSecret: undefined, totpEnabled: false }); + res.json({ ok: true, totpEnabled: false }); + }); + + /* ---------------- billing ---------------- */ + + router.post('/reload', async (req, res) => { + const { amountCents } = req.body ?? {}; + if ( + typeof amountCents !== 'number' || + !Number.isInteger(amountCents) || + amountCents < MIN_RELOAD_CENTS || + amountCents > MAX_RELOAD_CENTS + ) { + res + .status(400) + .json({ error: `amountCents must be an integer between ${MIN_RELOAD_CENTS} and ${MAX_RELOAD_CENTS}` }); + return; + } + const result = await deps.payments.reload(req.customer!, amountCents); + const customer = { + ...req.customer!, + balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents, + }; + if (result.creditedCents > 0) save(deps, customer); + res.json({ + balanceCents: customer.balanceCents, + mode: result.mode, + ...(result.clientSecret ? { clientSecret: result.clientSecret } : {}), + }); + }); + + router.put('/email-invoicing', (req, res) => { + const { enabled } = req.body ?? {}; + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + save(deps, { ...req.customer!, emailInvoicing: enabled }); + res.json({ ok: true, emailInvoicing: enabled }); + }); + + return router; +} diff --git a/src/pricing.ts b/src/pricing.ts new file mode 100644 index 0000000..32bae74 --- /dev/null +++ b/src/pricing.ts @@ -0,0 +1,173 @@ +export type PriceRule = + | { kind: 'free' } + | { kind: 'fixed'; fixedCents: number } + | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }; + +export interface TierConfig { + id: string; + name: string; + multiplier: number; + monthlyCreditCents: number; + defaultRule?: PriceRule; +} + +export interface RateCard { + endpoints: Record; +} + +export interface TierCatalog { + find(id: string): TierConfig | undefined; + list(): TierConfig[]; +} + +export class ConfigTierCatalog implements TierCatalog { + constructor(private tiers: TierConfig[]) {} + + find(id: string): TierConfig | undefined { + return this.tiers.find((t) => t.id === id); + } + + list(): TierConfig[] { + return [...this.tiers]; + } +} + +export interface CallUsage { + metadataBytes: number; + attachmentBytes: number; +} + +export interface Quote { + endpointId: string; + listCents: number; + totalCents: number; + breakdown: { baseCents: number; metadataCents: number; attachmentCents: number }; +} + +export interface PricingContext { + rateCard: RateCard; + tiers: TierCatalog; +} + +export interface PricingStore { + getRateCard(): RateCard; + getTiers(): TierConfig[]; + upsertEndpoint(endpointId: string, rule: PriceRule): void; + deleteEndpoint(endpointId: string): void; + upsertTier(tier: TierConfig): void; + deleteTier(tierId: string): void; +} + +export const DEFAULT_RATE_CARD: RateCard = { + endpoints: { + status: { kind: 'free' }, + 'storage-list': { kind: 'free' }, + transform: { kind: 'fixed', fixedCents: 4 }, + add: { kind: 'free' }, + timestamp: { kind: 'fixed', fixedCents: 4 }, + 'hash-lookup': { kind: 'free' }, + receipt: { kind: 'free' }, + storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 }, + }, +}; + +export const DEFAULT_TIERS: TierConfig[] = [ + { id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 }, + { + id: 'pro', + name: 'Pro', + multiplier: 0.5, + monthlyCreditCents: 1000, + defaultRule: { kind: 'fixed', fixedCents: 8 }, + }, + { + id: 'business', + name: 'Business', + multiplier: 0.25, + monthlyCreditCents: 10000, + defaultRule: { kind: 'fixed', fixedCents: 8 }, + }, +]; + +export const DEFAULT_PRICING: PricingContext = { + rateCard: DEFAULT_RATE_CARD, + tiers: new ConfigTierCatalog(DEFAULT_TIERS), +}; + +export class InMemoryPricingStore implements PricingStore { + private endpoints: Record; + private tiers: TierConfig[]; + + constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) { + this.endpoints = { ...rateCard.endpoints }; + this.tiers = [...tiers]; + } + + getRateCard(): RateCard { + return { endpoints: { ...this.endpoints } }; + } + + getTiers(): TierConfig[] { + return [...this.tiers]; + } + + upsertEndpoint(endpointId: string, rule: PriceRule): void { + this.endpoints[endpointId] = rule; + } + + deleteEndpoint(endpointId: string): void { + delete this.endpoints[endpointId]; + } + + upsertTier(tier: TierConfig): void { + const i = this.tiers.findIndex((t) => t.id === tier.id); + if (i >= 0) this.tiers[i] = tier; + else this.tiers.push(tier); + } + + deleteTier(tierId: string): void { + this.tiers = this.tiers.filter((t) => t.id !== tierId); + } +} + +export function quoteCall( + pricing: PricingContext, + tierId: string, + endpointId: string, + usage: CallUsage, + multiplierOverride?: number, +): Quote { + const tier = pricing.tiers.find(tierId); + if (!tier) throw new Error(`Unknown tier: ${tierId}`); + const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule; + if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`); + + if (rule.kind === 'free') { + return { + endpointId, + listCents: 0, + totalCents: 0, + breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 }, + }; + } + + let breakdown: Quote['breakdown']; + if (rule.kind === 'fixed') { + breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 }; + } else { + breakdown = { + baseCents: rule.baseCents, + metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents, + attachmentCents: + Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents, + }; + } + const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents; + const multiplier = multiplierOverride ?? tier.multiplier; + return { + endpointId, + listCents, + totalCents: Math.round(listCents * multiplier), + breakdown, + }; +} diff --git a/src/reports.ts b/src/reports.ts new file mode 100644 index 0000000..6c95087 --- /dev/null +++ b/src/reports.ts @@ -0,0 +1,98 @@ +import { BillingType, Customer } from './auth'; +import { TierConfig } from './pricing'; +import { UsageEntry } from './usage'; + +export interface BillingRow { + customerId: string; + name: string; + billingType: BillingType; + calls: number; + totalCents: number; + creditCents: number; + billableCents: number; +} + +/** + * Per-customer billing aggregation. Range is inclusive `from`, exclusive `to`; + * omit both for all time. Every customer matching the filters appears, even + * with zero usage. + */ +export function billingRows(args: { + entries: UsageEntry[]; + customers: Customer[]; + tiers: TierConfig[]; + from?: Date; + to?: Date; + customerId?: string; + billingType?: BillingType; +}): BillingRow[] { + const { entries, customers, tiers, from, to, customerId, billingType } = args; + const inRange = entries.filter( + (e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to), + ); + return customers + .filter((c) => (customerId ? c.id === customerId : true)) + .filter((c) => (billingType ? (c.billingType ?? 'stripe') === billingType : true)) + .map((c) => { + const mine = inRange.filter((e) => e.customerId === c.id); + const totalCents = mine.reduce((sum, e) => sum + e.cents, 0); + const tier = tiers.find((t) => t.id === c.tierId); + const creditCents = Math.min(totalCents, tier?.monthlyCreditCents ?? 0); + return { + customerId: c.id, + name: c.name, + billingType: c.billingType ?? 'stripe', + calls: mine.length, + totalCents, + creditCents, + billableCents: totalCents - creditCents, + }; + }); +} + +export interface TrendPoint { + /** Day bucket: YYYY-MM-DD. Week bucket: the Monday (UTC) of that week, YYYY-MM-DD. */ + bucket: string; + calls: number; + cents: number; +} + +export function usageTrend(entries: UsageEntry[], bucket: 'day' | 'week'): TrendPoint[] { + const key = (d: Date): string => { + const day = new Date(d); + day.setUTCHours(0, 0, 0, 0); + if (bucket === 'week') { + // Shift back to Monday (ISO weeks start Monday; getUTCDay: Sun=0). + const dow = (day.getUTCDay() + 6) % 7; + day.setUTCDate(day.getUTCDate() - dow); + } + return day.toISOString().slice(0, 10); + }; + const buckets = new Map(); + for (const e of entries) { + const k = key(e.timestamp); + const point = buckets.get(k) ?? { bucket: k, calls: 0, cents: 0 }; + point.calls += 1; + point.cents += e.cents; + buckets.set(k, point); + } + return [...buckets.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)); +} + +export interface CsvColumn { + key: string; + label: string; +} + +/** RFC 4180 CSV with a header row. Values containing , " or newlines are quoted. */ +export function toCsv(rows: T[], columns: CsvColumn[]): string { + const cell = (v: unknown): string => { + const s = String(v ?? ''); + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const lines = [columns.map((c) => cell(c.label)).join(',')]; + for (const row of rows) { + lines.push(columns.map((c) => cell((row as Record)[c.key])).join(',')); + } + return lines.join('\n'); +} diff --git a/src/upstream.ts b/src/upstream.ts new file mode 100644 index 0000000..f29616b --- /dev/null +++ b/src/upstream.ts @@ -0,0 +1,45 @@ +import { Request, Response } from 'express'; + +export type UpstreamFetch = typeof fetch; + +/** + * After zappier meters the call, forward Verae operations to middleware. + * When ZAPPIER_UPSTREAM is unset, callers keep the local mock. + */ +export async function proxyVerae( + req: Request, + res: Response, + pathname: string, + fetchImpl: UpstreamFetch = fetch, +): Promise { + const base = (process.env.ZAPPIER_UPSTREAM || '').replace(/\/$/, ''); + if (!base) return false; + const url = new URL(pathname, `${base}/`); + for (const [k, v] of Object.entries(req.query)) { + if (typeof v === 'string') url.searchParams.set(k, v); + } + const headers: Record = { accept: 'application/json' }; + const key = req.header('x-api-key'); + if (key) headers['x-api-key'] = key; + const auth = req.header('authorization'); + if (auth) headers.authorization = auth; + const method = req.method.toUpperCase(); + const init: RequestInit = { method, headers }; + if (method !== 'GET' && method !== 'HEAD') { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(req.body ?? {}); + } + const r = await fetchImpl(url.toString(), init); + const text = await r.text(); + let body: unknown = text; + try { + body = text ? JSON.parse(text) : {}; + } catch { + /* keep text */ + } + if (body && typeof body === 'object' && !Array.isArray(body) && res.locals.quote) { + (body as Record).quote = res.locals.quote; + } + res.status(r.status).json(body); + return true; +} diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..ba7c0d2 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,54 @@ +export interface UsageEntry { + customerId: string; + endpointId: string; + cents: number; + metadataBytes: number; + attachmentBytes: number; + timestamp: Date; +} + +export interface UsageSummary { + customerId: string; + totalCents: number; + calls: number; + byEndpoint: Record; +} + +export function summarize(customerId: string, list: UsageEntry[]): UsageSummary { + const byEndpoint: UsageSummary['byEndpoint'] = {}; + for (const e of list) { + const bucket = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 }); + bucket.calls += 1; + bucket.cents += e.cents; + } + return { + customerId, + calls: list.length, + totalCents: list.reduce((sum, e) => sum + e.cents, 0), + byEndpoint, + }; +} + +export interface UsageRepo { + record(entry: UsageEntry): void; + listFor(customerId: string, since?: Date): UsageEntry[]; + summaryFor(customerId: string, since?: Date): UsageSummary; +} + +export class InMemoryUsageRepo implements UsageRepo { + private entries: UsageEntry[] = []; + + record(entry: UsageEntry): void { + this.entries.push(entry); + } + + listFor(customerId: string, since?: Date): UsageEntry[] { + return this.entries.filter( + (e) => e.customerId === customerId && (!since || e.timestamp >= since), + ); + } + + summaryFor(customerId: string, since?: Date): UsageSummary { + return summarize(customerId, this.listFor(customerId, since)); + } +} diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts new file mode 100644 index 0000000..c856a63 --- /dev/null +++ b/tests/accounts.test.ts @@ -0,0 +1,114 @@ +import { + base32Decode, + base32Encode, + generateTotpSecret, + hashPassword, + hotp, + InMemorySessionRepo, + totp, + totpUri, + verifyPassword, + verifyTotp, +} from '../src/accounts'; + +describe('password hashing (scrypt)', () => { + it('round-trips a correct password', () => { + const stored = hashPassword('correct horse battery staple'); + expect(verifyPassword('correct horse battery staple', stored)).toBe(true); + }); + + it('rejects a wrong password', () => { + const stored = hashPassword('correct horse battery staple'); + expect(verifyPassword('wrong', stored)).toBe(false); + }); + + it('uses a random salt per hash', () => { + expect(hashPassword('same')).not.toBe(hashPassword('same')); + }); + + it('rejects malformed stored hashes', () => { + expect(verifyPassword('x', 'not-a-hash')).toBe(false); + expect(verifyPassword('x', '')).toBe(false); + }); +}); + +describe('base32', () => { + it('round-trips bytes', () => { + const buf = Buffer.from('hello world, this is base32'); + expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true); + }); + + it('encodes without padding', () => { + expect(base32Encode(Buffer.from('f'))).toBe('MY'); + expect(base32Encode(Buffer.from('fo'))).toBe('MZXQ'); + }); +}); + +describe('TOTP (RFC 6238)', () => { + // RFC 6238 SHA-1 seed, ASCII "12345678901234567890", base32-encoded. + const RFC_SEED_B32 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; + + it.each([ + [59_000, '287082'], + [1_111_111_109_000, '081804'], + [1_234_567_890_000, '005924'], + ])('produces the RFC 6-digit truncation at %ims', (atMs, expected) => { + expect(totp(RFC_SEED_B32, atMs)).toBe(expected); + }); + + it('hotp pads short codes to 6 digits', () => { + expect(hotp(RFC_SEED_B32, Math.floor(1_234_567_890 / 30))).toBe('005924'); + }); + + it('verifyTotp accepts codes within the window and rejects outside', () => { + const at = 1_111_111_109_000; + const code = totp(RFC_SEED_B32, at); + expect(verifyTotp(RFC_SEED_B32, code, at)).toBe(true); + expect(verifyTotp(RFC_SEED_B32, code, at + 30_000)).toBe(true); // one step later, window 1 + expect(verifyTotp(RFC_SEED_B32, code, at + 90_000)).toBe(false); // three steps later + }); + + it('verifyTotp rejects malformed codes', () => { + expect(verifyTotp(RFC_SEED_B32, '12345', 59_000)).toBe(false); + expect(verifyTotp(RFC_SEED_B32, 'abcdef', 59_000)).toBe(false); + }); + + it('generateTotpSecret returns a decodable 160-bit base32 secret', () => { + const secret = generateTotpSecret(); + expect(secret).toMatch(/^[A-Z2-7]{32}$/); + expect(base32Decode(secret).length).toBe(20); + }); + + it('totpUri builds an otpauth URI', () => { + const uri = totpUri('ABC234', 'ada@example.com', 'Zappier'); + expect(uri).toBe( + 'otpauth://totp/Zappier:ada%40example.com?secret=ABC234&issuer=Zappier', + ); + }); +}); + +describe('InMemorySessionRepo', () => { + it('creates and retrieves sessions within the TTL', () => { + const repo = new InMemorySessionRepo(); + const s = repo.create('cust_1', 60_000); + expect(s.customerId).toBe('cust_1'); + expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1'); + }); + + it('expires sessions after the TTL', () => { + const repo = new InMemorySessionRepo(); + const s = repo.create('cust_1', 60_000); + expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined(); + }); + + it('deletes sessions (logout)', () => { + const repo = new InMemorySessionRepo(); + const s = repo.create('cust_1', 60_000); + repo.delete(s.token); + expect(repo.get(s.token, s.createdMs)).toBeUndefined(); + }); + + it('returns undefined for unknown tokens', () => { + expect(new InMemorySessionRepo().get('nope')).toBeUndefined(); + }); +}); diff --git a/tests/admin-accounting.test.ts b/tests/admin-accounting.test.ts new file mode 100644 index 0000000..a371dd8 --- /dev/null +++ b/tests/admin-accounting.test.ts @@ -0,0 +1,207 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; +import { UsageEntry } from '../src/usage'; + +const ADMIN = { 'x-admin-key': 'admin-dev-key' }; + +const entry = (customerId: string, endpointId: string, cents: number, iso: string): UsageEntry => ({ + customerId, + endpointId, + cents, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date(iso), +}); + +function seededApp() { + const built = buildApp(); + built.usage.record(entry('cust_2', 'transform', 3, '2026-07-05T10:00:00Z')); + built.usage.record(entry('cust_2', 'storage', 1500, '2026-07-06T10:00:00Z')); + built.usage.record(entry('cust_2', 'storage', 300, '2026-08-01T01:00:00Z')); + return built; +} + +describe('admin accounting API', () => { + it('generates draft invoices for customers with usage, skips the rest', async () => { + const { app } = seededApp(); + const res = await request(app) + .post('/admin/api/invoices/generate') + .set(ADMIN) + .send({ period: '2026-07' }); + expect(res.status).toBe(200); + expect(res.body.generated).toEqual(['INV-2026-07-0001']); + expect(res.body.skipped.map((s: { customerId: string }) => s.customerId)).toEqual( + expect.arrayContaining(['cust_1', 'cust_3']), + ); + const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN); + expect(inv.body.lines).toEqual([ + { endpointId: 'storage', calls: 1, cents: 1500 }, + { endpointId: 'transform', calls: 1, cents: 3 }, + ]); + expect(inv.body.totalCents).toBe(1503); + expect(inv.body.billableCents).toBe(503); // Pro credit 1000 + }); + + it('regenerating a draft keeps the id; issued invoices are skipped', async () => { + const { app, usage } = seededApp(); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + usage.record(entry('cust_2', 'transform', 3, '2026-07-20T10:00:00Z')); + const again = await request(app) + .post('/admin/api/invoices/generate') + .set(ADMIN) + .send({ period: '2026-07' }); + expect(again.body.generated).toEqual(['INV-2026-07-0001']); + const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN); + expect(inv.body.totalCents).toBe(1506); + + await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + const third = await request(app) + .post('/admin/api/invoices/generate') + .set(ADMIN) + .send({ period: '2026-07' }); + expect(third.body.generated).toEqual([]); + expect(third.body.skipped).toEqual( + expect.arrayContaining([expect.objectContaining({ customerId: 'cust_2' })]), + ); + }); + + it('walks the lifecycle draft → issued → paid and rejects illegal transitions', async () => { + const { app } = seededApp(); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + const payDraft = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN); + expect(payDraft.status).toBe(409); + + const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + expect(issue.status).toBe(200); + expect(issue.body.status).toBe('issued'); + expect(issue.body.issuedAtMs).toEqual(expect.any(Number)); + + const reissue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + expect(reissue.status).toBe(409); + + const paid = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN); + expect(paid.status).toBe(200); + expect(paid.body.status).toBe('paid'); + expect(paid.body.paidAtMs).toEqual(expect.any(Number)); + }); + + it('sets a due date on issued purchase-order invoices', async () => { + const { app } = seededApp(); + await request(app) + .put('/admin/api/customers/cust_2') + .set(ADMIN) + .send({ billingType: 'purchase_order' }); + await request(app) + .post('/admin/api/invoices/generate') + .set(ADMIN) + .send({ period: '2026-07', poNumber: 'PO-77' }); + const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + expect(issue.body.poNumber).toBe('PO-77'); + expect(issue.body.dueAtMs - issue.body.issuedAtMs).toBe(30 * 24 * 60 * 60 * 1000); + }); + + it('rejects a malformed period with 400', async () => { + const { app } = seededApp(); + const res = await request(app) + .post('/admin/api/invoices/generate') + .set(ADMIN) + .send({ period: 'July 2026' }); + expect(res.status).toBe(400); + }); + + it('lists invoices with filters and 404s unknown ids', async () => { + const { app } = seededApp(); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + const list = await request(app) + .get('/admin/api/invoices?customerId=cust_2&status=draft') + .set(ADMIN); + expect(list.body.invoices).toHaveLength(1); + const empty = await request(app).get('/admin/api/invoices?status=paid').set(ADMIN); + expect(empty.body.invoices).toHaveLength(0); + const missing = await request(app).get('/admin/api/invoices/INV-1999-01-0001').set(ADMIN); + expect(missing.status).toBe(404); + }); + + it('renders a print-ready HTML invoice', async () => { + const { app } = seededApp(); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + const res = await request(app) + .get('/admin/api/invoices/INV-2026-07-0001?format=html') + .set(ADMIN); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/html'); + expect(res.text).toContain('INV-2026-07-0001'); + expect(res.text).toContain('Grace (pro)'); + }); + + it('serves the billing report as JSON and CSV with filters', async () => { + const { app } = seededApp(); + const json = await request(app) + .get('/admin/api/reports/billing?from=2026-07-01&to=2026-08-01') + .set(ADMIN); + expect(json.status).toBe(200); + const grace = json.body.rows.find((r: { customerId: string }) => r.customerId === 'cust_2'); + expect(grace).toMatchObject({ calls: 2, totalCents: 1503, billableCents: 503 }); + + const csv = await request(app) + .get('/admin/api/reports/billing?format=csv&customerId=cust_2') + .set(ADMIN); + expect(csv.headers['content-type']).toContain('text/csv'); + expect(csv.text.split('\n')[0]).toBe('Customer Id,Name,Billing Type,Calls,Total Cents,Credit Cents,Billable Cents'); + expect(csv.text).toContain('cust_2,Grace (pro),stripe,3,1803,1000,803'); + + await request(app) + .put('/admin/api/customers/cust_3') + .set(ADMIN) + .send({ billingType: 'purchase_order' }); + const poOnly = await request(app) + .get('/admin/api/reports/billing?billingType=purchase_order') + .set(ADMIN); + expect(poOnly.body.rows.map((r: { customerId: string }) => r.customerId)).toEqual(['cust_3']); + }); + + it('serves the usage trend', async () => { + const { app } = seededApp(); + const res = await request(app) + .get('/admin/api/reports/usage-trend?bucket=day') + .set(ADMIN); + expect(res.status).toBe(200); + expect(res.body.points).toEqual([ + { bucket: '2026-07-05', calls: 1, cents: 3 }, + { bucket: '2026-07-06', calls: 1, cents: 1500 }, + { bucket: '2026-08-01', calls: 1, cents: 300 }, + ]); + }); + + it('reports Zapier app status from the integration directory', async () => { + const { app } = seededApp(); + const res = await request(app).get('/admin/api/zapier/status').set(ADMIN); + expect(res.status).toBe(200); + expect(res.body.triggers).toEqual(['new_item']); + expect(res.body.creates).toEqual(['store_data']); + }); + + it('draws down the prepaid balance at issue; full coverage marks the invoice paid', async () => { + const { app, customers } = seededApp(); + const grace = customers.findByApiKey('key-grace')!; + customers.save({ ...grace, balanceCents: 600 }); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + // billable is 503c after the Pro credit + const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + expect(issue.status).toBe(200); + expect(issue.body.status).toBe('paid'); + expect(issue.body.paidAtMs).toEqual(expect.any(Number)); + expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(97); + }); + + it('leaves the invoice issued and the balance untouched when coverage is partial', async () => { + const { app, customers } = seededApp(); + const grace = customers.findByApiKey('key-grace')!; + customers.save({ ...grace, balanceCents: 100 }); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + expect(issue.status).toBe(200); + expect(issue.body.status).toBe('issued'); + expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(100); + }); +}); diff --git a/tests/admin-users.test.ts b/tests/admin-users.test.ts new file mode 100644 index 0000000..6eb6f52 --- /dev/null +++ b/tests/admin-users.test.ts @@ -0,0 +1,115 @@ +import Database from 'better-sqlite3'; +import request from 'supertest'; +import { hashPassword } from '../src/accounts'; +import { InMemoryAdminUserRepo, seedAdminUsersFromEnv } from '../src/admin-users'; +import { buildApp } from '../src/app'; +import { SqliteAdminUserRepo } from '../src/db/admin-user-repo'; + +const ADMIN = { 'x-admin-key': 'admin-dev-key' }; + +describe('admin user repos', () => { + it('seeds from env defaults (in-memory)', () => { + const repo = InMemoryAdminUserRepo.seeded(seedAdminUsersFromEnv()); + expect(repo.findByUsername('admin')?.active).toBe(true); + expect(repo.findByUsername('demo')?.active).toBe(true); + expect(repo.findByUsername('admin')?.passwordHash).toMatch(/^scrypt:/); + }); + + it('seeds from env defaults (sqlite) and persists', () => { + const db = new Database(':memory:'); + const first = SqliteAdminUserRepo.seeded(db, seedAdminUsersFromEnv()); + first.save({ + id: 'usr_extra', + username: 'ops', + passwordHash: hashPassword('ops-password-1'), + active: true, + createdMs: Date.now(), + }); + const second = SqliteAdminUserRepo.seeded(db, seedAdminUsersFromEnv()); + expect(second.findByUsername('ops')?.active).toBe(true); + // re-seeding must not duplicate the env accounts + expect(second.list().filter((u) => u.username === 'admin')).toHaveLength(1); + }); + + it('deactivation persists in sqlite', () => { + const repo = SqliteAdminUserRepo.seeded(new Database(':memory:'), seedAdminUsersFromEnv()); + const demo = repo.findByUsername('demo')!; + repo.save({ ...demo, active: false }); + expect(repo.findByUsername('demo')?.active).toBe(false); + }); +}); + +describe('admin users API', () => { + it('lists users without password hashes', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/users').set(ADMIN); + expect(res.status).toBe(200); + const usernames = res.body.users.map((u: { username: string }) => u.username); + expect(usernames).toEqual(expect.arrayContaining(['admin', 'demo'])); + expect(res.body.users[0].passwordHash).toBeUndefined(); + }); + + it('creates a user who can then log in', async () => { + const { app } = buildApp(); + const created = await request(app) + .post('/admin/api/users') + .set(ADMIN) + .send({ username: 'finance', password: 'finance-pass-1' }); + expect(created.status).toBe(201); + const login = await request(app) + .post('/admin/api/login') + .send({ username: 'finance', password: 'finance-pass-1' }); + expect(login.status).toBe(200); + expect(login.body.token).toMatch(/^[a-f0-9]{48}$/); + }); + + it('rejects duplicates, weak passwords, and bad usernames', async () => { + const { app } = buildApp(); + const dup = await request(app) + .post('/admin/api/users') + .set(ADMIN) + .send({ username: 'demo', password: 'whatever-pass-1' }); + expect(dup.status).toBe(409); + const weak = await request(app) + .post('/admin/api/users') + .set(ADMIN) + .send({ username: 'x1', password: 'short' }); + expect(weak.status).toBe(400); + const bad = await request(app) + .post('/admin/api/users') + .set(ADMIN) + .send({ username: 'bad name!', password: 'fine-password-1' }); + expect(bad.status).toBe(400); + }); + + it('deactivation blocks login; reactivation restores it', async () => { + const { app } = buildApp(); + const off = await request(app).post('/admin/api/users/demo/deactivate').set(ADMIN); + expect(off.status).toBe(200); + const login = await request(app) + .post('/admin/api/login') + .send({ username: 'demo', password: '$$$Adm1n###' }); + expect(login.status).toBe(401); + + const on = await request(app).post('/admin/api/users/demo/activate').set(ADMIN); + expect(on.status).toBe(200); + const again = await request(app) + .post('/admin/api/login') + .send({ username: 'demo', password: '$$$Adm1n###' }); + expect(again.status).toBe(200); + }); + + it('refuses to deactivate the last active admin', async () => { + const { app } = buildApp(); + await request(app).post('/admin/api/users/demo/deactivate').set(ADMIN); + const last = await request(app).post('/admin/api/users/admin/deactivate').set(ADMIN); + expect(last.status).toBe(400); + expect(last.body.error).toContain('last active'); + }); + + it('404s unknown usernames on activate/deactivate', async () => { + const { app } = buildApp(); + const res = await request(app).post('/admin/api/users/nobody/deactivate').set(ADMIN); + expect(res.status).toBe(404); + }); +}); diff --git a/tests/admin.test.ts b/tests/admin.test.ts new file mode 100644 index 0000000..c4fefd2 --- /dev/null +++ b/tests/admin.test.ts @@ -0,0 +1,156 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; + +const ADMIN = { 'x-admin-key': 'admin-dev-key' }; +const KEY = 'key-ada'; + +describe('admin API', () => { + it('rejects calls without an admin key', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/pricing'); + expect(res.status).toBe(403); + }); + + it('returns the current pricing', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/pricing').set(ADMIN); + expect(res.status).toBe(200); + expect(res.body.rateCard.endpoints.status).toEqual({ kind: 'free' }); + expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('rejects an invalid price rule with 400', async () => { + const { app } = buildApp(); + const res = await request(app) + .put('/admin/api/endpoints/transform') + .set(ADMIN) + .send({ kind: 'sometimes' }); + expect(res.status).toBe(400); + }); + + it('reprices an endpoint live, without restart', async () => { + const { app } = buildApp(); + await request(app) + .put('/admin/api/endpoints/transform') + .set(ADMIN) + .send({ kind: 'fixed', fixedCents: 10 }); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'hi' }); + expect(res.body.quote.totalCents).toBe(10); // was 4 + }); + + it('creates a customer type live and prices calls for it', async () => { + const { app } = buildApp(); + await request(app) + .put('/admin/api/tiers/edu') + .set(ADMIN) + .send({ id: 'edu', name: 'Education', multiplier: 0.5, monthlyCreditCents: 500 }); + const created = await request(app) + .post('/admin/api/customers') + .set(ADMIN) + .send({ name: 'School', tierId: 'edu' }); + expect(created.status).toBe(201); + expect(created.body.apiKey).toMatch(/^key-/); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', created.body.apiKey) + .send({ text: 'hi' }); + expect(res.body.quote.totalCents).toBe(2); // list 4 x 0.5 + }); + + it('sets a per-customer multiplier override live', async () => { + const { app } = buildApp(); + const res = await request(app) + .put('/admin/api/customers/cust_1') + .set(ADMIN) + .send({ multiplierOverride: 0.5 }); + expect(res.status).toBe(200); + const call = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'hi' }); + expect(call.body.quote.totalCents).toBe(2); // was 4 + }); + + it('masks API keys in the customer list and 404s unknown customers', async () => { + const { app } = buildApp(); + const list = await request(app).get('/admin/api/customers').set(ADMIN); + expect(list.status).toBe(200); + expect(JSON.stringify(list.body)).not.toContain('key-ada'); + const missing = await request(app) + .put('/admin/api/customers/cust_nope') + .set(ADMIN) + .send({ tierId: 'pro' }); + expect(missing.status).toBe(404); + }); +}); + +describe('admin login', () => { + it('logs in the demo account and accepts the issued token', async () => { + const { app } = buildApp(); + const login = await request(app) + .post('/admin/api/login') + .send({ username: 'demo', password: '$$$Adm1n###' }); + expect(login.status).toBe(200); + expect(login.body.token).toMatch(/^[a-f0-9]{48}$/); + const res = await request(app) + .get('/admin/api/pricing') + .set('authorization', `Bearer ${login.body.token}`); + expect(res.status).toBe(200); + }); + + it('logs in the primary admin account with the admin key as password', async () => { + const { app } = buildApp(); + const login = await request(app) + .post('/admin/api/login') + .send({ username: 'admin', password: 'admin-dev-key' }); + expect(login.status).toBe(200); + }); + + it('rejects bad credentials with 401 and issues no token', async () => { + const { app } = buildApp(); + const login = await request(app) + .post('/admin/api/login') + .send({ username: 'demo', password: 'wrong' }); + expect(login.status).toBe(401); + expect(login.body.token).toBeUndefined(); + }); + + it('still accepts the legacy x-admin-key header', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/pricing').set(ADMIN); + expect(res.status).toBe(200); + }); + + it('rejects unknown bearer tokens', async () => { + const { app } = buildApp(); + const res = await request(app) + .get('/admin/api/pricing') + .set('authorization', 'Bearer deadbeef'); + expect(res.status).toBe(403); + }); + + it('sets billingType and email on a customer', async () => { + const { app } = buildApp(); + const res = await request(app) + .put('/admin/api/customers/cust_1') + .set(ADMIN) + .send({ billingType: 'purchase_order', email: 'ada@example.com' }); + expect(res.status).toBe(200); + const list = await request(app).get('/admin/api/customers').set(ADMIN); + const ada = list.body.customers.find((c: { id: string }) => c.id === 'cust_1'); + expect(ada.billingType).toBe('purchase_order'); + expect(ada.email).toBe('ada@example.com'); + }); + + it('rejects an invalid billingType with 400', async () => { + const { app } = buildApp(); + const res = await request(app) + .put('/admin/api/customers/cust_1') + .set(ADMIN) + .send({ billingType: 'carrier-pigeon' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/app.test.ts b/tests/app.test.ts new file mode 100644 index 0000000..80d5626 --- /dev/null +++ b/tests/app.test.ts @@ -0,0 +1,152 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; + +const KEY = 'key-ada'; // seeded free-tier customer + +describe('Zappier API', () => { + it('rejects calls without an API key', async () => { + const { app } = buildApp(); + const res = await request(app).get('/v1/status'); + expect(res.status).toBe(401); + }); + + it('GET /v1/status is free', async () => { + const { app } = buildApp(); + const res = await request(app).get('/v1/status').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.quote.totalCents).toBe(0); + }); + + it('POST /v1/timestamp is idempotent by sha256', async () => { + const { app } = buildApp(); + const a = await request(app) + .post('/v1/timestamp') + .set('x-api-key', KEY) + .send({ data: 'abc' }); + expect(a.status).toBe(202); + const b = await request(app) + .post('/v1/timestamp') + .set('x-api-key', KEY) + .send({ data: 'abc' }); + expect(b.body.jobId).toBe(a.body.jobId); + expect(b.body.existing).toBe(true); + const look = await request(app) + .get(`/v1/hashes/${a.body.sha256}`) + .set('x-api-key', KEY); + expect(look.status).toBe(200); + expect(look.body.jobId).toBe(a.body.jobId); + const receipt = await request(app) + .get(`/v1/receipts/${a.body.jobId}`) + .set('x-api-key', KEY); + expect(receipt.status).toBe(200); + expect(receipt.body.type).toBe('verae.retrieval-receipt'); + }); + + it('POST /v1/add returns the sum and is free', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/add') + .set('x-api-key', KEY) + .send({ number1: 2, number2: 3 }); + expect(res.status).toBe(200); + expect(res.body.sum).toBe(5); + expect(res.body.number1).toBe(2); + expect(res.body.number2).toBe(3); + expect(res.body.quote.totalCents).toBe(0); + }); + + it('POST /v1/add rejects missing fields', async () => { + const { app } = buildApp(); + const res = await request(app).post('/v1/add').set('x-api-key', KEY).send({ number1: 1 }); + expect(res.status).toBe(400); + }); + + it('POST /v1/transform uppercases text at the multiplied fixed price', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'hello' }); + expect(res.status).toBe(200); + expect(res.body.output).toBe('HELLO'); + expect(res.body.quote.totalCents).toBe(4); // list 4 x free-tier multiplier 1 + }); + + it('rejects a request that violates the OpenAPI schema with 400', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ wrong: 1 }); + expect(res.status).toBe(400); + }); + + it('POST /v1/storage stores metadata plus attachments and quotes by size', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', JSON.stringify({ title: 'report' })) + .attach('attachments', Buffer.alloc(1024 * 1024), 'one.bin') + .attach('attachments', Buffer.alloc(1024 * 1024), 'two.bin'); + expect(res.status).toBe(200); + expect(res.body.id).toBeTruthy(); + // metadata string is 20 bytes -> 1 KB; 2 MB attachments + // list 10 + 1 * 1 + 2 * 50 = 111, free-tier multiplier 1 + expect(res.body.quote.totalCents).toBe(111); + }); + + it('POST /v1/storage with malformed metadata JSON returns 400 and charges nothing', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', '{not json'); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid metadata JSON/); + const usageRes = await request(app).get('/v1/usage').set('x-api-key', KEY); + expect(usageRes.status).toBe(200); + expect(usageRes.body.totalCents).toBe(0); + }); + + it('GET /v1/storage lists the caller items newest first', async () => { + const { app } = buildApp(); + await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', JSON.stringify({ title: 'a' })); + await request(app) + .post('/v1/storage') + .set('x-api-key', KEY) + .field('metadata', JSON.stringify({ title: 'b' })); + const res = await request(app).get('/v1/storage').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(2); + expect(res.body.items[0].metadata.title).toBe('b'); + }); + + it('GET /v1/usage returns the caller summary with monthly credit applied', async () => { + const { app } = buildApp(); + await request(app) + .post('/v1/transform') + .set('x-api-key', KEY) + .send({ text: 'x' }); + const res = await request(app).get('/v1/usage').set('x-api-key', KEY); + expect(res.status).toBe(200); + expect(res.body.customerId).toBe('cust_1'); + expect(res.body.calls).toBe(1); + expect(res.body.totalCents).toBe(4); + expect(res.body.includedCents).toBe(4); // free-tier credit (100) covers it + expect(res.body.billableCents).toBe(0); + }); + + it('charges less for a business-tier customer', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/transform') + .set('x-api-key', 'key-linus') + .send({ text: 'hello' }); + expect(res.body.quote.totalCents).toBe(1); // list 4 x business multiplier 0.25 + }); +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..3641716 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,51 @@ +import express from 'express'; +import request from 'supertest'; +import { apiKeyAuth, InMemoryCustomerRepo } from '../src/auth'; + +const repo = new InMemoryCustomerRepo([ + { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' }, + { id: 'cust_2', name: 'Grace', tierId: 'business', apiKey: 'key-grace', stripeCustomerId: 'cus_123' }, +]); + +const app = express(); +app.use(apiKeyAuth(repo)); +app.get('/ping', (req, res) => + res.json({ customerId: req.customer!.id, tierId: req.customer!.tierId }), +); + +describe('apiKeyAuth', () => { + it('rejects a missing key with 401', async () => { + const res = await request(app).get('/ping'); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/API key/); + }); + + it('rejects an unknown key with 401', async () => { + const res = await request(app).get('/ping').set('x-api-key', 'wrong'); + expect(res.status).toBe(401); + }); + + it('attaches the customer for a valid key', async () => { + const res = await request(app).get('/ping').set('x-api-key', 'key-ada'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ customerId: 'cust_1', tierId: 'pro' }); + }); +}); + +describe('InMemoryCustomerRepo', () => { + it('lists all customers', () => { + expect(repo.list().map((c) => c.id)).toEqual(['cust_1', 'cust_2']); + }); + + it('save() upserts by id', () => { + const local = new InMemoryCustomerRepo([ + { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' }, + ]); + local.save({ id: 'cust_1', name: 'Ada', tierId: 'business', apiKey: 'key-ada', multiplierOverride: 0.4 }); + local.save({ id: 'cust_9', name: 'New', tierId: 'free', apiKey: 'key-new' }); + expect(local.list()).toHaveLength(2); + const updated = local.findByApiKey('key-ada'); + expect(updated?.tierId).toBe('business'); + expect(updated?.multiplierOverride).toBe(0.4); + }); +}); diff --git a/tests/billing-delta.test.ts b/tests/billing-delta.test.ts new file mode 100644 index 0000000..bac2adb --- /dev/null +++ b/tests/billing-delta.test.ts @@ -0,0 +1,19 @@ +import { computeDelta } from '../src/billing/stripe'; + +describe('computeDelta', () => { + it('returns the difference when billable exceeds prior', () => { + expect(computeDelta(800, 500)).toBe(300); + }); + + it('returns 0 when billable equals prior', () => { + expect(computeDelta(500, 500)).toBe(0); + }); + + it('never returns a negative value when billable is below prior', () => { + expect(computeDelta(300, 500)).toBe(0); + }); + + it('returns the full billable amount when nothing was reported before', () => { + expect(computeDelta(500, 0)).toBe(500); + }); +}); diff --git a/tests/credit.test.ts b/tests/credit.test.ts new file mode 100644 index 0000000..9f1370b --- /dev/null +++ b/tests/credit.test.ts @@ -0,0 +1,33 @@ +import { applyMonthlyCredit } from '../src/billing/credit'; +import { DEFAULT_TIERS } from '../src/pricing'; +import { UsageSummary } from '../src/usage'; + +const freeTier = DEFAULT_TIERS.find((t) => t.id === 'free')!; // monthlyCreditCents: 100 + +const summary = (totalCents: number): UsageSummary => ({ + customerId: 'cust_1', + totalCents, + calls: 1, + byEndpoint: {}, +}); + +describe('applyMonthlyCredit', () => { + it('covers usage fully when under the monthly credit', () => { + const billed = applyMonthlyCredit(summary(60), freeTier); + expect(billed.includedCents).toBe(60); + expect(billed.billableCents).toBe(0); + expect(billed.totalCents).toBe(60); + }); + + it('bills only the overage when usage exceeds the credit', () => { + const billed = applyMonthlyCredit(summary(250), freeTier); + expect(billed.includedCents).toBe(100); + expect(billed.billableCents).toBe(150); + }); + + it('bills nothing when there is no usage', () => { + const billed = applyMonthlyCredit(summary(0), freeTier); + expect(billed.includedCents).toBe(0); + expect(billed.billableCents).toBe(0); + }); +}); diff --git a/tests/db-billing.test.ts b/tests/db-billing.test.ts new file mode 100644 index 0000000..b8ae12d --- /dev/null +++ b/tests/db-billing.test.ts @@ -0,0 +1,58 @@ +import Database from 'better-sqlite3'; +import { SqliteBillingReportRepo } from '../src/db/billing-repo'; + +describe('SqliteBillingReportRepo', () => { + it('returns 0 for an unknown customer/period', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + expect(repo.getReportedCents('cust_1', '2026-07')).toBe(0); + }); + + it('round-trips an upserted cumulative value', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + repo.upsertReportedCents('cust_1', '2026-07', 500); + expect(repo.getReportedCents('cust_1', '2026-07')).toBe(500); + }); + + it('keeps periods and customers isolated', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + repo.upsertReportedCents('cust_1', '2026-07', 500); + expect(repo.getReportedCents('cust_1', '2026-08')).toBe(0); + expect(repo.getReportedCents('cust_2', '2026-07')).toBe(0); + }); + + it('replaces the cumulative value on repeated upsert (latest wins, not summed)', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + repo.upsertReportedCents('cust_1', '2026-07', 500); + repo.upsertReportedCents('cust_1', '2026-07', 800); + expect(repo.getReportedCents('cust_1', '2026-07')).toBe(800); + }); + + describe('job locks', () => { + it('acquires a free lock and refuses a second acquire until released', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true); + expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(false); + repo.releaseLock('report-usage'); + expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true); + }); + + it('takes over a lock whose age exceeds the TTL', () => { + let nowMs = 1_000_000; + const repo = new SqliteBillingReportRepo(new Database(':memory:'), () => nowMs); + expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true); + nowMs += 60_001; // lock is now stale + expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true); + }); + + it('keeps lock names independent', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true); + expect(repo.tryAcquireLock('other-job', 60_000)).toBe(true); + }); + + it('releaseLock is a no-op for a lock that was never held', () => { + const repo = new SqliteBillingReportRepo(new Database(':memory:')); + expect(() => repo.releaseLock('never-held')).not.toThrow(); + }); + }); +}); diff --git a/tests/db-customer.test.ts b/tests/db-customer.test.ts new file mode 100644 index 0000000..206a97b --- /dev/null +++ b/tests/db-customer.test.ts @@ -0,0 +1,116 @@ +import Database from 'better-sqlite3'; +import { Customer } from '../src/auth'; +import { SqliteCustomerRepo } from '../src/db/customer-repo'; + +const customer = (apiKey: string): Customer => ({ + id: 'cust_1', + name: 'Ada', + tierId: 'pro', + apiKey, + stripeCustomerId: 'cus_123', +}); + +describe('SqliteCustomerRepo', () => { + it('finds a customer by API key', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + expect(repo.findByApiKey('key-ada')?.tierId).toBe('pro'); + }); + + it('returns undefined for an unknown key', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + expect(repo.findByApiKey('wrong')).toBeUndefined(); + }); + + it('lists all customers with their Stripe ids', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + const all = repo.list(); + expect(all).toHaveLength(1); + expect(all[0].stripeCustomerId).toBe('cus_123'); + }); + + it('seeds only when the table is empty', () => { + const db = new Database(':memory:'); + new SqliteCustomerRepo(db, [customer('key-ada')]); + const again = new SqliteCustomerRepo(db, [customer('key-other')]); + expect(again.list().map((c) => c.apiKey)).toEqual(['key-ada']); + }); + + it('save() upserts including the multiplier override', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + repo.save({ ...customer('key-ada'), tierId: 'business', multiplierOverride: 0.4 }); + const updated = repo.findByApiKey('key-ada'); + expect(updated?.tierId).toBe('business'); + expect(updated?.multiplierOverride).toBe(0.4); + expect(repo.list()).toHaveLength(1); + }); + + it('round-trips billingType and email', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + repo.save({ ...customer('key-ada'), billingType: 'purchase_order', email: 'ada@example.com' }); + const updated = repo.findByApiKey('key-ada'); + expect(updated?.billingType).toBe('purchase_order'); + expect(updated?.email).toBe('ada@example.com'); + }); + + it('migrates a legacy table, defaulting billingType to stripe', () => { + const db = new Database(':memory:'); + db.exec(`CREATE TABLE customers ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, tier_id TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, stripe_customer_id TEXT, multiplier_override REAL + )`); + db.prepare( + `INSERT INTO customers (id, name, tier_id, api_key) VALUES ('cust_1', 'Ada', 'pro', 'key-ada')`, + ).run(); + const repo = new SqliteCustomerRepo(db); + const migrated = repo.findByApiKey('key-ada'); + expect(migrated?.billingType).toBe('stripe'); + expect(migrated?.email).toBeUndefined(); + // and new columns are writable after migration + repo.save({ ...migrated!, billingType: 'purchase_order' }); + expect(repo.findByApiKey('key-ada')?.billingType).toBe('purchase_order'); + }); + + it('finds a customer by email (case-insensitive)', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [ + { ...customer('key-ada'), email: 'Ada@Example.com' }, + ]); + expect(repo.findByEmail('ada@example.com')?.id).toBe('cust_1'); + expect(repo.findByEmail('nobody@example.com')).toBeUndefined(); + }); + + it('round-trips portal identity fields', () => { + const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); + repo.save({ + ...customer('key-ada'), + passwordHash: 'scrypt:16384:8:1:c2FsdA==:aGFzaA==', + totpSecret: 'ABC234', + totpEnabled: true, + balanceCents: 2500, + emailInvoicing: true, + }); + const updated = repo.findByApiKey('key-ada'); + expect(updated?.passwordHash).toBe('scrypt:16384:8:1:c2FsdA==:aGFzaA=='); + expect(updated?.totpSecret).toBe('ABC234'); + expect(updated?.totpEnabled).toBe(true); + expect(updated?.balanceCents).toBe(2500); + expect(updated?.emailInvoicing).toBe(true); + }); + + it('defaults portal identity fields for legacy rows', () => { + const db = new Database(':memory:'); + db.exec(`CREATE TABLE customers ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, tier_id TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, stripe_customer_id TEXT, multiplier_override REAL + )`); + db.prepare( + `INSERT INTO customers (id, name, tier_id, api_key) VALUES ('cust_1', 'Ada', 'pro', 'key-ada')`, + ).run(); + const repo = new SqliteCustomerRepo(db); + const migrated = repo.findByApiKey('key-ada'); + expect(migrated?.passwordHash).toBeUndefined(); + expect(migrated?.totpSecret).toBeUndefined(); + expect(migrated?.totpEnabled).toBe(false); + expect(migrated?.balanceCents).toBe(0); + expect(migrated?.emailInvoicing).toBe(false); + }); +}); diff --git a/tests/db-invoice.test.ts b/tests/db-invoice.test.ts new file mode 100644 index 0000000..7d33edb --- /dev/null +++ b/tests/db-invoice.test.ts @@ -0,0 +1,72 @@ +import Database from 'better-sqlite3'; +import { Invoice } from '../src/invoicing'; +import { SqliteInvoiceRepo } from '../src/db/invoice-repo'; + +const invoice = (overrides: Partial = {}): Invoice => ({ + id: 'INV-2026-07-0001', + customerId: 'cust_2', + period: '2026-07', + status: 'draft', + lines: [{ endpointId: 'storage', calls: 2, cents: 1500 }], + totalCents: 1500, + creditCents: 1000, + billableCents: 500, + billingType: 'stripe', + ...overrides, +}); + +describe('SqliteInvoiceRepo', () => { + it('saves and retrieves an invoice with its lines', () => { + const repo = new SqliteInvoiceRepo(new Database(':memory:')); + repo.save(invoice()); + const got = repo.get('INV-2026-07-0001'); + expect(got?.billableCents).toBe(500); + expect(got?.lines).toEqual([{ endpointId: 'storage', calls: 2, cents: 1500 }]); + }); + + it('re-saving an invoice replaces its lines (draft regeneration)', () => { + const repo = new SqliteInvoiceRepo(new Database(':memory:')); + repo.save(invoice()); + repo.save(invoice({ lines: [{ endpointId: 'transform', calls: 5, cents: 20 }], totalCents: 20 })); + const got = repo.get('INV-2026-07-0001'); + expect(got?.lines).toEqual([{ endpointId: 'transform', calls: 5, cents: 20 }]); + expect(repo.list({})).toHaveLength(1); + }); + + it('filters by customer, period, and status', () => { + const repo = new SqliteInvoiceRepo(new Database(':memory:')); + repo.save(invoice()); + repo.save(invoice({ id: 'INV-2026-07-0002', customerId: 'cust_3', status: 'issued' })); + repo.save(invoice({ id: 'INV-2026-08-0001', period: '2026-08' })); + expect(repo.list({ customerId: 'cust_3' })).toHaveLength(1); + expect(repo.list({ period: '2026-08' })).toHaveLength(1); + expect(repo.list({ status: 'issued' })).toHaveLength(1); + expect(repo.list({ period: '2026-07' })).toHaveLength(2); + expect(repo.list({})).toHaveLength(3); + }); + + it('increments sequences per period independently', () => { + const repo = new SqliteInvoiceRepo(new Database(':memory:')); + expect(repo.nextSequence('2026-07')).toBe(1); + repo.save(invoice()); + expect(repo.nextSequence('2026-07')).toBe(2); + expect(repo.nextSequence('2026-08')).toBe(1); + }); + + it('persists lifecycle timestamps and PO numbers', () => { + const repo = new SqliteInvoiceRepo(new Database(':memory:')); + repo.save( + invoice({ + billingType: 'purchase_order', + poNumber: 'PO-1', + status: 'issued', + issuedAtMs: 1000, + dueAtMs: 2000, + }), + ); + const got = repo.get('INV-2026-07-0001'); + expect(got?.poNumber).toBe('PO-1'); + expect(got?.issuedAtMs).toBe(1000); + expect(got?.dueAtMs).toBe(2000); + }); +}); diff --git a/tests/db-pricing.test.ts b/tests/db-pricing.test.ts new file mode 100644 index 0000000..abf3a3b --- /dev/null +++ b/tests/db-pricing.test.ts @@ -0,0 +1,63 @@ +import Database from 'better-sqlite3'; +import { SqlitePricingStore } from '../src/db/pricing-store'; + +describe('SqlitePricingStore', () => { + it('seeds the default rate card and tiers when empty', () => { + const store = new SqlitePricingStore(new Database(':memory:')); + expect(store.getRateCard().endpoints.transform).toEqual({ kind: 'fixed', fixedCents: 4 }); + expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('seeds only once', () => { + const db = new Database(':memory:'); + const first = new SqlitePricingStore(db); + first.deleteEndpoint('transform'); + const second = new SqlitePricingStore(db); + expect(second.getRateCard().endpoints.transform).toBeUndefined(); + }); + + it('reseeds endpoints without reverting admin tier edits', () => { + const db = new Database(':memory:'); + const first = new SqlitePricingStore(db); + for (const id of Object.keys(first.getRateCard().endpoints)) { + first.deleteEndpoint(id); + } + first.upsertTier({ id: 'pro', name: 'Pro', multiplier: 0.9, monthlyCreditCents: 1000 }); + const second = new SqlitePricingStore(db); + expect(second.getRateCard().endpoints.transform).toBeDefined(); + expect(second.getTiers().find((t) => t.id === 'pro')?.multiplier).toBe(0.9); + }); + + it('upserts and deletes endpoints', () => { + const store = new SqlitePricingStore(new Database(':memory:')); + store.upsertEndpoint('experimental', { kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20 }); + expect(store.getRateCard().endpoints.experimental).toEqual({ + kind: 'variable', + baseCents: 3, + perKbCents: 2, + perMbCents: 20, + }); + store.deleteEndpoint('experimental'); + expect(store.getRateCard().endpoints.experimental).toBeUndefined(); + }); + + it('upserts tiers including default rules, and deletes them', () => { + const store = new SqlitePricingStore(new Database(':memory:')); + store.upsertTier({ + id: 'edu', + name: 'Education', + multiplier: 0.4, + monthlyCreditCents: 500, + defaultRule: { kind: 'fixed', fixedCents: 6 }, + }); + expect(store.getTiers().find((t) => t.id === 'edu')?.defaultRule).toEqual({ + kind: 'fixed', + fixedCents: 6, + }); + store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 }); + expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1); + expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.3); + store.deleteTier('edu'); + expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined(); + }); +}); diff --git a/tests/db-session.test.ts b/tests/db-session.test.ts new file mode 100644 index 0000000..7512cde --- /dev/null +++ b/tests/db-session.test.ts @@ -0,0 +1,32 @@ +import Database from 'better-sqlite3'; +import { SqliteSessionRepo } from '../src/db/session-repo'; + +describe('SqliteSessionRepo', () => { + it('creates and retrieves sessions within the TTL', () => { + const repo = new SqliteSessionRepo(new Database(':memory:')); + const s = repo.create('cust_1', 60_000); + expect(s.customerId).toBe('cust_1'); + expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1'); + }); + + it('persists sessions across instances (same db)', () => { + const db = new Database(':memory:'); + const first = new SqliteSessionRepo(db); + const s = first.create('cust_1', 60_000); + const second = new SqliteSessionRepo(db); + expect(second.get(s.token, s.createdMs + 1_000)?.customerId).toBe('cust_1'); + }); + + it('expires sessions after the TTL', () => { + const repo = new SqliteSessionRepo(new Database(':memory:')); + const s = repo.create('cust_1', 60_000); + expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined(); + }); + + it('deletes sessions (logout)', () => { + const repo = new SqliteSessionRepo(new Database(':memory:')); + const s = repo.create('cust_1', 60_000); + repo.delete(s.token); + expect(repo.get(s.token, s.createdMs)).toBeUndefined(); + }); +}); diff --git a/tests/db-usage.test.ts b/tests/db-usage.test.ts new file mode 100644 index 0000000..488e9fa --- /dev/null +++ b/tests/db-usage.test.ts @@ -0,0 +1,40 @@ +import Database from 'better-sqlite3'; +import { SqliteUsageRepo } from '../src/db/usage-repo'; +import { UsageEntry } from '../src/usage'; + +const entry = (over: Partial = {}): UsageEntry => ({ + customerId: 'cust_1', + endpointId: 'transform', + cents: 4, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date('2026-07-27T10:00:00Z'), + ...over, +}); + +describe('SqliteUsageRepo', () => { + it('records and lists entries per customer', () => { + const repo = new SqliteUsageRepo(new Database(':memory:')); + repo.record(entry()); + repo.record(entry({ customerId: 'cust_2' })); + expect(repo.listFor('cust_1')).toHaveLength(1); + expect(repo.listFor('cust_2')).toHaveLength(1); + }); + + it('filters entries by since date', () => { + const repo = new SqliteUsageRepo(new Database(':memory:')); + repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') })); + repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') })); + expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1); + }); + + it('summarizes totals by endpoint', () => { + const repo = new SqliteUsageRepo(new Database(':memory:')); + repo.record(entry()); + repo.record(entry({ endpointId: 'storage', cents: 112 })); + const s = repo.summaryFor('cust_1'); + expect(s.calls).toBe(2); + expect(s.totalCents).toBe(116); + expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 }); + }); +}); diff --git a/tests/health-proxy-credits.test.ts b/tests/health-proxy-credits.test.ts new file mode 100644 index 0000000..7018954 --- /dev/null +++ b/tests/health-proxy-credits.test.ts @@ -0,0 +1,75 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; + +const KEY = 'key-ada'; +const ADMIN = { 'x-admin-key': 'admin-dev-key' }; + +describe('zappier-edge operational wiring', () => { + it('GET /health is public', async () => { + const { app } = buildApp(); + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.role).toBe('zappier-edge'); + }); + + it('proxies timestamp to middleware when ZAPPIER_UPSTREAM is set', async () => { + process.env.ZAPPIER_UPSTREAM = 'http://upstream.test'; + const orig = globalThis.fetch; + globalThis.fetch = jest.fn(async (url: string | URL) => { + expect(String(url)).toContain('/zapier/v1/timestamp'); + return new Response(JSON.stringify({ jobId: 'mw-1', sha256: 'abc' }), { + status: 202, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + try { + const { app } = buildApp(); + const res = await request(app).post('/v1/timestamp').set('x-api-key', KEY).send({ data: 'hello' }); + expect(res.status).toBe(202); + expect(res.body.jobId).toBe('mw-1'); + expect(res.body.quote.totalCents).toBeGreaterThanOrEqual(0); + } finally { + globalThis.fetch = orig; + delete process.env.ZAPPIER_UPSTREAM; + } + }); + + it('customer service can add prepaid credits', async () => { + const { app } = buildApp(); + const add = await request(app) + .post('/admin/api/credits') + .set(ADMIN) + .send({ customerId: 'cust_1', cents: 500, reason: 'goodwill', agent: 'cs-anna' }); + expect(add.status).toBe(201); + expect(add.body.cents).toBe(500); + const list = await request(app).get('/admin/api/credits?customerId=cust_1').set(ADMIN); + expect(list.body.credits[0].reason).toBe('goodwill'); + }); + + it('sales quote returns per-customer multiplier', async () => { + const { app } = buildApp(); + const res = await request(app).get('/admin/api/sales/quote/cust_2').set(ADMIN); + expect(res.status).toBe(200); + expect(res.body.tierId).toBe('pro'); + }); + + it('exports issued invoices as QuickBooks IIF', async () => { + const { app, usage } = buildApp(); + usage.record({ + customerId: 'cust_2', + endpointId: 'transform', + cents: 400, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date('2026-07-05T10:00:00Z'), + }); + await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); + await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); + const iif = await request(app).get('/admin/api/exports/quickbooks.iif?period=2026-07').set(ADMIN); + expect(iif.status).toBe(200); + expect(iif.text).toContain('TRNS'); + expect(iif.text).toContain('INV-2026-07-0001'); + const csv = await request(app).get('/admin/api/exports/accounting.csv?period=2026-07').set(ADMIN); + expect(csv.text).toContain('invoice_id'); + }); +}); diff --git a/tests/invoicing.test.ts b/tests/invoicing.test.ts new file mode 100644 index 0000000..169b69e --- /dev/null +++ b/tests/invoicing.test.ts @@ -0,0 +1,86 @@ +import { buildInvoice, Invoice } from '../src/invoicing'; +import { Customer } from '../src/auth'; +import { TierConfig } from '../src/pricing'; +import { UsageEntry } from '../src/usage'; + +const pro: TierConfig = { id: 'pro', name: 'Pro', multiplier: 0.5, monthlyCreditCents: 1000 }; + +const customer: Customer = { + id: 'cust_2', + name: 'Grace', + tierId: 'pro', + apiKey: 'key-grace', + stripeCustomerId: 'cus_123', +}; + +const entry = (endpointId: string, cents: number): UsageEntry => ({ + customerId: customer.id, + endpointId, + cents, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date('2026-07-10T12:00:00Z'), +}); + +describe('buildInvoice', () => { + it('groups usage entries into lines per endpoint, sorted', () => { + const inv = buildInvoice({ + customer, + period: '2026-07', + sequence: 1, + tier: pro, + entries: [entry('transform', 3), entry('storage', 6), entry('transform', 3)], + }); + expect(inv.lines).toEqual([ + { endpointId: 'storage', calls: 1, cents: 6 }, + { endpointId: 'transform', calls: 2, cents: 6 }, + ]); + expect(inv.totalCents).toBe(12); + }); + + it('applies a partial monthly credit', () => { + const inv = buildInvoice({ + customer, + period: '2026-07', + sequence: 1, + tier: pro, + entries: [entry('storage', 1500)], + }); + expect(inv.totalCents).toBe(1500); + expect(inv.creditCents).toBe(1000); + expect(inv.billableCents).toBe(500); + }); + + it('floors billable at zero when credit exceeds usage', () => { + const inv = buildInvoice({ + customer, + period: '2026-07', + sequence: 1, + tier: pro, + entries: [entry('storage', 400)], + }); + expect(inv.creditCents).toBe(400); + expect(inv.billableCents).toBe(0); + }); + + it('formats the id as INV-- and starts as draft', () => { + const inv = buildInvoice({ customer, period: '2026-07', sequence: 7, tier: pro, entries: [] }); + expect(inv.id).toBe('INV-2026-07-0007'); + expect(inv.status).toBe('draft'); + expect(inv.billingType).toBe('stripe'); + }); + + it('carries purchase-order billing fields', () => { + const poCustomer: Customer = { ...customer, billingType: 'purchase_order' }; + const inv = buildInvoice({ + customer: poCustomer, + period: '2026-07', + sequence: 2, + tier: pro, + entries: [entry('storage', 2000)], + poNumber: 'PO-9982', + }); + expect(inv.billingType).toBe('purchase_order'); + expect(inv.poNumber).toBe('PO-9982'); + }); +}); diff --git a/tests/meter.test.ts b/tests/meter.test.ts new file mode 100644 index 0000000..cf8a067 --- /dev/null +++ b/tests/meter.test.ts @@ -0,0 +1,86 @@ +import express from 'express'; +import request from 'supertest'; +import { apiKeyAuth, Customer, InMemoryCustomerRepo } from '../src/auth'; +import { meter } from '../src/meter'; +import { DEFAULT_PRICING } from '../src/pricing'; +import { InMemoryUsageRepo } from '../src/usage'; + +const FREE_ADA: Customer = { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' }; + +function buildApp(seed: Customer[] = [FREE_ADA]) { + const customers = new InMemoryCustomerRepo(seed); + const usage = new InMemoryUsageRepo(); + const app = express(); + app.use(express.json()); + app.use(apiKeyAuth(customers)); + app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) => + res.json({ quote: res.locals.quote }), + ); + app.post('/storage', meter('storage', usage, DEFAULT_PRICING), (req, res) => + res.json({ quote: res.locals.quote }), + ); + app.post('/experimental', meter('experimental', usage, DEFAULT_PRICING), (req, res) => + res.json({ quote: res.locals.quote }), + ); + return { app, usage }; +} + +describe('meter middleware', () => { + it('quotes a fixed endpoint at the multiplied tier price and records usage', async () => { + const { app, usage } = buildApp(); + const res = await request(app) + .post('/transform') + .set('x-api-key', 'key-ada') + .send({ text: 'hi' }); + expect(res.status).toBe(200); + expect(res.body.quote.totalCents).toBe(4); // free tier, multiplier 1 + const entries = usage.listFor('cust_1'); + expect(entries).toHaveLength(1); + expect(entries[0].endpointId).toBe('transform'); + expect(entries[0].cents).toBe(4); + }); + + it('charges per KB of metadata on variable endpoints', async () => { + const { app } = buildApp(); + const res = await request(app) + .post('/storage') + .set('x-api-key', 'key-ada') + .send({ metadata: { note: 'x'.repeat(2048) } }); + // JSON of metadata is 2059 bytes -> 3 KB -> list 10 + 3 * 1 = 13 + expect(res.body.quote.totalCents).toBe(13); + }); + + it('applies a per-customer multiplier override', async () => { + const { app } = buildApp([ + { id: 'cust_vip', name: 'Vip', tierId: 'free', apiKey: 'key-vip', multiplierOverride: 0.5 }, + ]); + const res = await request(app) + .post('/transform') + .set('x-api-key', 'key-vip') + .send({ text: 'hi' }); + expect(res.body.quote.totalCents).toBe(2); // list 4 x override 0.5 + }); + + it('returns 401 when no customer is attached', async () => { + const usage = new InMemoryUsageRepo(); + const app = express(); + app.use(express.json()); + app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) => + res.json({}), + ); + const res = await request(app).post('/transform').send({ text: 'hi' }); + expect(res.status).toBe(401); + expect(usage.listFor('cust_1')).toHaveLength(0); + }); + + it('returns 403 for an endpoint with no price rule on the caller tier', async () => { + const { app, usage } = buildApp(); + const res = await request(app) + .post('/experimental') + .set('x-api-key', 'key-ada') + .send({}); + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/No price rule/); + expect(usage.listFor('cust_1')).toHaveLength(0); + }); +}); diff --git a/tests/paths.test.ts b/tests/paths.test.ts new file mode 100644 index 0000000..4ed6d49 --- /dev/null +++ b/tests/paths.test.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; +import path from 'path'; +import { PROJECT_ROOT } from '../src/paths'; + +// The server must work from any working directory (systemd, Docker, cron, +// `node dist/index.js` launched from elsewhere), so every runtime path is +// resolved from PROJECT_ROOT rather than process.cwd(). +describe('PROJECT_ROOT', () => { + it('points at the project root regardless of cwd', () => { + expect(path.isAbsolute(PROJECT_ROOT)).toBe(true); + expect(fs.existsSync(path.join(PROJECT_ROOT, 'package.json'))).toBe(true); + }); + + it('resolves the OpenAPI spec served at /docs', () => { + expect(fs.existsSync(path.join(PROJECT_ROOT, 'openapi.yaml'))).toBe(true); + }); + + it('resolves the admin static assets served at /admin', () => { + expect(fs.existsSync(path.join(PROJECT_ROOT, 'admin', 'index.html'))).toBe(true); + }); +}); diff --git a/tests/portal.test.ts b/tests/portal.test.ts new file mode 100644 index 0000000..f95d2c6 --- /dev/null +++ b/tests/portal.test.ts @@ -0,0 +1,320 @@ +import request from 'supertest'; +import { totp } from '../src/accounts'; +import { buildApp } from '../src/app'; +import { PaymentClient } from '../src/portal'; + +const devPayments: PaymentClient = { + reload: async (_customer, amountCents) => ({ mode: 'dev' as const, creditedCents: amountCents }), +}; + +function portalApp() { + return buildApp({ + payments: devPayments, + qr: async (uri) => `data:image/png;base64,fake-qr-for:${uri}`, + }); +} + +async function signup(app: ReturnType['app'], email = 'ada@example.com') { + const res = await request(app) + .post('/portal/api/signup') + .send({ name: 'Ada', email, password: 'super-secret-1' }); + expect(res.status).toBe(201); + return res.body as { token: string; customer: { id: string; apiKey: string } }; +} + +describe('portal signup + login', () => { + it('signs up a new customer on the free tier with an API key', async () => { + const { app, customers } = portalApp(); + const { token, customer } = await signup(app); + expect(customer.apiKey).toMatch(/^key-/); + const stored = customers.findByEmail('ada@example.com'); + expect(stored?.tierId).toBe('free'); + expect(stored?.passwordHash).toMatch(/^scrypt:/); + const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`); + expect(me.status).toBe(200); + expect(me.body.tierId).toBe('free'); + expect(me.body.balanceCents).toBe(0); + }); + + it('never leaks passwordHash or totpSecret through the API', async () => { + const { app } = portalApp(); + const { token } = await signup(app); + const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`); + expect(me.body.passwordHash).toBeUndefined(); + expect(me.body.totpSecret).toBeUndefined(); + }); + + it('rejects weak passwords and duplicate emails', async () => { + const { app } = portalApp(); + const weak = await request(app) + .post('/portal/api/signup') + .send({ name: 'Ada', email: 'ada@example.com', password: 'short' }); + expect(weak.status).toBe(400); + await signup(app); + const dup = await request(app) + .post('/portal/api/signup') + .send({ name: 'Other', email: 'ada@example.com', password: 'super-secret-2' }); + expect(dup.status).toBe(409); + }); + + it('claims an admin-created customer by email on first signup', async () => { + const { app, customers } = portalApp(); + const ada = customers.findByApiKey('key-ada')!; + customers.save({ ...ada, email: 'ada@example.com' }); + const { customer } = await signup(app); + expect(customer.id).toBe('cust_1'); // same customer, now with a password + expect(customers.findByApiKey('key-ada')?.passwordHash).toMatch(/^scrypt:/); + }); + + it('logs in with email + password and rejects bad credentials', async () => { + const { app } = portalApp(); + await signup(app); + const bad = await request(app) + .post('/portal/api/login') + .send({ email: 'ada@example.com', password: 'wrong-password' }); + expect(bad.status).toBe(401); + const ok = await request(app) + .post('/portal/api/login') + .send({ email: 'ada@example.com', password: 'super-secret-1' }); + expect(ok.status).toBe(200); + expect(ok.body.token).toBeTruthy(); + }); + + it('requires a session for /me (401 without token)', async () => { + const { app } = portalApp(); + expect((await request(app).get('/portal/api/me')).status).toBe(401); + }); +}); + +describe('portal scoping', () => { + it('lists only the signed-in customer’s invoices', async () => { + const { app, customers, invoices } = portalApp(); + const { token, customer } = await signup(app); + invoices.save({ + id: 'INV-2026-07-0001', + customerId: customer.id, + period: '2026-07', + status: 'issued', + lines: [{ endpointId: 'transform', calls: 10, cents: 30 }], + totalCents: 30, + creditCents: 0, + billableCents: 30, + billingType: 'stripe', + }); + invoices.save({ + id: 'INV-2026-07-0002', + customerId: 'cust_2', + period: '2026-07', + status: 'issued', + lines: [], + totalCents: 999, + creditCents: 0, + billableCents: 999, + billingType: 'stripe', + }); + const res = await request(app) + .get('/portal/api/invoices') + .set('authorization', `Bearer ${token}`); + expect(res.body.invoices.map((i: { id: string }) => i.id)).toEqual(['INV-2026-07-0001']); + expect(customers.list()).toHaveLength(4); // 3 seeds + signup + }); + + it('returns 404 for another customer’s invoice (json and html)', async () => { + const { app, invoices } = portalApp(); + const { token } = await signup(app); + invoices.save({ + id: 'INV-2026-07-0009', + customerId: 'cust_2', + period: '2026-07', + status: 'issued', + lines: [], + totalCents: 999, + creditCents: 0, + billableCents: 999, + billingType: 'stripe', + }); + const json = await request(app) + .get('/portal/api/invoices/INV-2026-07-0009') + .set('authorization', `Bearer ${token}`); + expect(json.status).toBe(404); + const html = await request(app) + .get('/portal/api/invoices/INV-2026-07-0009?format=html') + .set('authorization', `Bearer ${token}`); + expect(html.status).toBe(404); + }); + + it('serves the customer’s own invoice as printable HTML', async () => { + const { app, invoices } = portalApp(); + const { token, customer } = await signup(app); + invoices.save({ + id: 'INV-2026-07-0001', + customerId: customer.id, + period: '2026-07', + status: 'issued', + lines: [{ endpointId: 'transform', calls: 10, cents: 30 }], + totalCents: 30, + creditCents: 0, + billableCents: 30, + billingType: 'stripe', + }); + const res = await request(app) + .get('/portal/api/invoices/INV-2026-07-0001?format=html') + .set('authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.text).toContain('INV-2026-07-0001'); + }); +}); + +describe('portal api key + usage', () => { + it('regenerates the API key; the old key stops working', async () => { + const { app } = portalApp(); + const { token, customer } = await signup(app); + const oldKey = customer.apiKey; + const res = await request(app) + .post('/portal/api/api-key') + .set('authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.apiKey).not.toBe(oldKey); + const stale = await request(app).get('/v1/status').set('x-api-key', oldKey); + expect(stale.status).toBe(401); + const fresh = await request(app).get('/v1/status').set('x-api-key', res.body.apiKey); + expect(fresh.status).toBe(200); + }); + + it('reports month-to-date usage with the tier credit applied', async () => { + const { app, usage, customers } = portalApp(); + const { token, customer } = await signup(app); + usage.record({ + customerId: customer.id, + endpointId: 'transform', + cents: 40, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date(), + }); + const res = await request(app).get('/portal/api/usage').set('authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.totalCents).toBe(40); + expect(res.body.includedCents).toBe(40); // free tier credit 100 covers it + expect(res.body.billableCents).toBe(0); + expect(customers.findByEmail('ada@example.com')).toBeTruthy(); + }); +}); + +describe('portal 2FA', () => { + it('setup → enable → login requires the TOTP code', async () => { + const { app, customers } = portalApp(); + const { token } = await signup(app); + const setup = await request(app) + .post('/portal/api/2fa/setup') + .set('authorization', `Bearer ${token}`); + expect(setup.status).toBe(200); + expect(setup.body.secret).toMatch(/^[A-Z2-7]{32}$/); + expect(setup.body.uri).toContain('otpauth://totp/'); + expect(setup.body.qr).toMatch(/^data:image\/png;base64,/); + + // Enabling with a wrong code fails. + const badEnable = await request(app) + .post('/portal/api/2fa/enable') + .set('authorization', `Bearer ${token}`) + .send({ code: '000000' }); + expect(badEnable.status).toBe(400); + + const code = totp(setup.body.secret, Date.now()); + const enable = await request(app) + .post('/portal/api/2fa/enable') + .set('authorization', `Bearer ${token}`) + .send({ code }); + expect(enable.status).toBe(200); + expect(customers.findByEmail('ada@example.com')?.totpEnabled).toBe(true); + + // Password alone no longer suffices. + const noCode = await request(app) + .post('/portal/api/login') + .send({ email: 'ada@example.com', password: 'super-secret-1' }); + expect(noCode.status).toBe(401); + expect(noCode.body.error).toBe('totp_required'); + + const withCode = await request(app) + .post('/portal/api/login') + .send({ + email: 'ada@example.com', + password: 'super-secret-1', + totpCode: totp(setup.body.secret, Date.now()), + }); + expect(withCode.status).toBe(200); + }); + + it('disables 2FA with a valid code', async () => { + const { app, customers } = portalApp(); + const { token } = await signup(app); + const setup = await request(app) + .post('/portal/api/2fa/setup') + .set('authorization', `Bearer ${token}`); + await request(app) + .post('/portal/api/2fa/enable') + .set('authorization', `Bearer ${token}`) + .send({ code: totp(setup.body.secret, Date.now()) }); + const disable = await request(app) + .post('/portal/api/2fa/disable') + .set('authorization', `Bearer ${token}`) + .send({ code: totp(setup.body.secret, Date.now()) }); + expect(disable.status).toBe(200); + const stored = customers.findByEmail('ada@example.com'); + expect(stored?.totpEnabled).toBe(false); + expect(stored?.totpSecret).toBeUndefined(); + }); +}); + +describe('portal billing', () => { + it('reloads the prepaid balance via the payment client', async () => { + const { app } = portalApp(); + const { token } = await signup(app); + const res = await request(app) + .post('/portal/api/reload') + .set('authorization', `Bearer ${token}`) + .send({ amountCents: 2500 }); + expect(res.status).toBe(200); + expect(res.body.balanceCents).toBe(2500); + expect(res.body.mode).toBe('dev'); + const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`); + expect(me.body.balanceCents).toBe(2500); + }); + + it('rejects out-of-range reload amounts', async () => { + const { app } = portalApp(); + const { token } = await signup(app); + for (const amountCents of [0, 50, -100, 2_000_000, 12.5]) { + const res = await request(app) + .post('/portal/api/reload') + .set('authorization', `Bearer ${token}`) + .send({ amountCents }); + expect(res.status).toBe(400); + } + }); + + it('serves the live rate card and tiers for the pricing page', async () => { + const { app } = portalApp(); + const { token } = await signup(app); + const res = await request(app) + .get('/portal/api/pricing') + .set('authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.rateCard.endpoints.transform).toBeTruthy(); + expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual( + expect.arrayContaining(['free', 'pro', 'business']), + ); + }); + + it('toggles email invoicing', async () => { + const { app } = portalApp(); + const { token } = await signup(app); + const res = await request(app) + .put('/portal/api/email-invoicing') + .set('authorization', `Bearer ${token}`) + .send({ enabled: true }); + expect(res.status).toBe(200); + const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`); + expect(me.body.emailInvoicing).toBe(true); + }); +}); diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 0000000..49542a9 --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,121 @@ +import { + ConfigTierCatalog, + DEFAULT_PRICING, + DEFAULT_TIERS, + InMemoryPricingStore, + quoteCall, +} from '../src/pricing'; + +const noUsage = { metadataBytes: 0, attachmentBytes: 0 }; + +describe('quoteCall', () => { + it('prices free endpoints at 0 on every tier, ignoring the multiplier', () => { + for (const tier of DEFAULT_TIERS) { + expect(quoteCall(DEFAULT_PRICING, tier.id, 'status', noUsage).totalCents).toBe(0); + expect(quoteCall(DEFAULT_PRICING, tier.id, 'storage-list', noUsage).totalCents).toBe(0); + } + }); + + it('applies the tier multiplier to fixed list prices', () => { + // transform list price: 4 cents + expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage).totalCents).toBe(4); + expect(quoteCall(DEFAULT_PRICING, 'pro', 'transform', noUsage).totalCents).toBe(2); + expect(quoteCall(DEFAULT_PRICING, 'business', 'transform', noUsage).totalCents).toBe(1); + }); + + it('prices storage as base + per-KB metadata + per-MB attachments at list rates', () => { + const usage = { metadataBytes: 2048, attachmentBytes: 2 * 1024 * 1024 }; + // list: 10 + 2 * 1 + 2 * 50 = 112 + expect(quoteCall(DEFAULT_PRICING, 'free', 'storage', usage).totalCents).toBe(112); + expect(quoteCall(DEFAULT_PRICING, 'pro', 'storage', usage).totalCents).toBe(56); + expect(quoteCall(DEFAULT_PRICING, 'business', 'storage', usage).totalCents).toBe(28); + }); + + it('rounds partial KB and MB up before applying the multiplier', () => { + const q = quoteCall(DEFAULT_PRICING, 'free', 'storage', { + metadataBytes: 1, + attachmentBytes: 1, + }); + // list: 10 + 1 KB * 1 + 1 MB * 50 = 61 + expect(q.listCents).toBe(61); + expect(q.totalCents).toBe(61); + }); + + it('exposes the list-price breakdown and the multiplied total', () => { + const q = quoteCall(DEFAULT_PRICING, 'pro', 'storage', { + metadataBytes: 1024, + attachmentBytes: 0, + }); + expect(q.breakdown).toEqual({ baseCents: 10, metadataCents: 1, attachmentCents: 0 }); + expect(q.listCents).toBe(11); + expect(q.totalCents).toBe(6); // Math.round(11 * 0.5) + }); + + it('lets a per-customer multiplier override beat the tier multiplier', () => { + expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage, 0.5).totalCents).toBe(2); + expect(quoteCall(DEFAULT_PRICING, 'free', 'status', noUsage, 0.5).totalCents).toBe(0); + }); + + it('falls back to the tier default rule for endpoints not on the rate card', () => { + // pro default rule: fixed 8 list -> round(8 * 0.5) = 4 + expect(quoteCall(DEFAULT_PRICING, 'pro', 'experimental', noUsage).totalCents).toBe(4); + }); + + it('throws for an endpoint with no rate-card entry and no tier default', () => { + expect(() => quoteCall(DEFAULT_PRICING, 'free', 'experimental', noUsage)).toThrow( + 'No price rule', + ); + }); + + it('throws for an unknown tier', () => { + expect(() => quoteCall(DEFAULT_PRICING, 'platinum', 'status', noUsage)).toThrow( + 'Unknown tier', + ); + }); +}); + +describe('ConfigTierCatalog', () => { + it('finds tiers by id and lists them', () => { + const catalog = new ConfigTierCatalog(DEFAULT_TIERS); + expect(catalog.find('pro')?.multiplier).toBe(0.5); + expect(catalog.find('nope')).toBeUndefined(); + expect(catalog.list().map((t) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('supports adding a customer type as pure config', () => { + const catalog = new ConfigTierCatalog([ + ...DEFAULT_TIERS, + { id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 }, + ]); + expect(catalog.find('edu')?.name).toBe('Education'); + }); +}); + +describe('InMemoryPricingStore', () => { + it('seeds from the default rate card and tiers', () => { + const store = new InMemoryPricingStore(); + expect(store.getRateCard().endpoints.status).toEqual({ kind: 'free' }); + expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']); + }); + + it('upserts and deletes endpoints', () => { + const store = new InMemoryPricingStore(); + store.upsertEndpoint('experimental', { kind: 'fixed', fixedCents: 9 }); + expect(store.getRateCard().endpoints.experimental).toEqual({ + kind: 'fixed', + fixedCents: 9, + }); + store.deleteEndpoint('experimental'); + expect(store.getRateCard().endpoints.experimental).toBeUndefined(); + }); + + it('upserts and deletes tiers', () => { + const store = new InMemoryPricingStore(); + store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 }); + expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.4); + store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 }); + expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1); + store.deleteTier('edu'); + expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined(); + }); +}); diff --git a/tests/report-usage-idempotency.test.ts b/tests/report-usage-idempotency.test.ts new file mode 100644 index 0000000..e727642 --- /dev/null +++ b/tests/report-usage-idempotency.test.ts @@ -0,0 +1,245 @@ +import Database from 'better-sqlite3'; +import { Customer } from '../src/auth'; +import { METER_EVENT_NAME, MeterEventClient } from '../src/billing/stripe'; +import { SqliteBillingReportRepo } from '../src/db/billing-repo'; +import { reportMonthlyUsage } from '../src/jobs/report-usage'; +import { TierConfig } from '../src/pricing'; +import { UsageEntry } from '../src/usage'; + +const SINCE = new Date('2026-07-01T00:00:00Z'); + +const customer: Customer = { + id: 'cust_1', + name: 'Acme', + tierId: 'pro', + apiKey: 'key_1', + stripeCustomerId: 'cus_123', +}; + +const customerB: Customer = { + id: 'cust_2', + name: 'Beta', + tierId: 'pro', + apiKey: 'key_2', + stripeCustomerId: 'cus_456', +}; + +const tier: TierConfig = { id: 'pro', name: 'Pro', multiplier: 1, monthlyCreditCents: 0 }; + +const entry = (cents: number, customerId = 'cust_1', timestamp = '2026-07-10T00:00:00Z'): UsageEntry => ({ + customerId, + endpointId: 'storage', + cents, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date(timestamp), +}); + +interface MeterCall { + eventName: string; + customerId: string; + value: string; + identifier?: string; +} + +function makeHarness(opts: { + entries: UsageEntry[]; + customers?: Customer[]; + throwFor?: (stripeCustomerId: string) => boolean; + since?: Date; + onMeterEvent?: (call: MeterCall) => void; + now?: () => number; + existingRepo?: SqliteBillingReportRepo; +}) { + const calls: MeterCall[] = []; + let shouldThrow = false; + const client: MeterEventClient = { + createMeterEvent: async (params) => { + if (shouldThrow || opts.throwFor?.(params.customerId)) throw new Error('stripe down'); + calls.push(params); + opts.onMeterEvent?.(params); + }, + }; + const billingRepo = + opts.existingRepo ?? new SqliteBillingReportRepo(new Database(':memory:'), opts.now); + const logs: string[] = []; + const run = (overrides: { entries?: UsageEntry[]; since?: Date } = {}) => + reportMonthlyUsage({ + client, + usage: { + listFor: (customerId: string) => + (overrides.entries ?? opts.entries).filter((e) => e.customerId === customerId), + }, + customers: { list: () => opts.customers ?? [customer] }, + tiers: [tier], + billingRepo, + locks: billingRepo, + since: overrides.since ?? opts.since ?? SINCE, + log: (m) => logs.push(m), + }); + return { + run, + calls, + logs, + billingRepo, + setShouldThrow: (v: boolean) => { + shouldThrow = v; + }, + }; +} + +describe('reportMonthlyUsage idempotency', () => { + it('reports the full billable amount on first run and upserts the ledger', async () => { + const h = makeHarness({ entries: [entry(500)] }); + await h.run(); + expect(h.calls).toEqual([ + { + eventName: METER_EVENT_NAME, + customerId: 'cus_123', + value: '500', + identifier: 'cus_123:2026-07:500', + }, + ]); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500); + }); + + it('sends no meter event when the same month is re-run with unchanged usage', async () => { + const h = makeHarness({ entries: [entry(500)] }); + await h.run(); + await h.run(); + expect(h.calls).toHaveLength(1); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500); + expect(h.logs[1]).toBe('skip cust_1 2026-07 (already reported 500c)'); + }); + + it('reports only the delta when usage grows within the same period', async () => { + const entries = [entry(500)]; + const h = makeHarness({ entries }); + await h.run(); + entries.push(entry(300)); + await h.run(); + expect(h.calls).toEqual([ + { + eventName: METER_EVENT_NAME, + customerId: 'cus_123', + value: '500', + identifier: 'cus_123:2026-07:500', + }, + { + eventName: METER_EVENT_NAME, + customerId: 'cus_123', + value: '300', + identifier: 'cus_123:2026-07:800', + }, + ]); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(800); + }); + + it('does not touch the ledger when the Stripe call fails, so a retry reports the full delta', async () => { + const h = makeHarness({ entries: [entry(500)] }); + h.setShouldThrow(true); + await expect(h.run()).rejects.toThrow('stripe down'); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(0); + h.setShouldThrow(false); + await h.run(); + expect(h.calls).toHaveLength(1); + expect(h.calls[0].value).toBe('500'); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500); + }); + + it('logs "nothing to report" instead of "already reported 0c" for a never-reported customer', async () => { + const h = makeHarness({ entries: [] }); + await h.run(); + expect(h.calls).toHaveLength(0); + expect(h.logs).toEqual(['skip cust_1 2026-07 (nothing to report)']); + }); + + it('reports the full amount again after month rollover (prior period does not leak)', async () => { + const h = makeHarness({ entries: [entry(500)] }); + await h.run(); + expect(h.calls[0]).toMatchObject({ value: '500', identifier: 'cus_123:2026-07:500' }); + + const augustEntries = [entry(700, 'cust_1', '2026-08-10T00:00:00Z')]; + await h.run({ entries: augustEntries, since: new Date('2026-08-01T00:00:00Z') }); + expect(h.calls).toHaveLength(2); + expect(h.calls[1]).toEqual({ + eventName: METER_EVENT_NAME, + customerId: 'cus_123', + value: '700', + identifier: 'cus_123:2026-08:700', + }); + expect(h.billingRepo.getReportedCents('cust_1', '2026-08')).toBe(700); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500); + }); + + it('keeps customer A reported in the ledger when customer B’s Stripe call fails', async () => { + const h = makeHarness({ + entries: [entry(500), entry(300, 'cust_2')], + customers: [customer, customerB], + throwFor: (id) => id === 'cus_456', + }); + await expect(h.run()).rejects.toThrow('stripe down'); + expect(h.calls).toHaveLength(1); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500); + expect(h.billingRepo.getReportedCents('cust_2', '2026-07')).toBe(0); + + // Retry: A is skipped (delta 0), B reports its full delta. + // Reuse the same repo so the ledger persists across "process restarts". + const h2 = makeHarness({ + entries: [entry(500), entry(300, 'cust_2')], + customers: [customer, customerB], + existingRepo: h.billingRepo, + }); + await h2.run(); + expect(h2.calls).toEqual([ + { + eventName: METER_EVENT_NAME, + customerId: 'cus_456', + value: '300', + identifier: 'cus_456:2026-07:300', + }, + ]); + }); + + it('aborts a second invocation while the lock is held, sending nothing', async () => { + let innerLogs: string[] = []; + let innerCalls = 0; + const h = makeHarness({ + entries: [entry(500)], + onMeterEvent: () => { + // Simulate a concurrent invocation (e.g. manual run while cron is in flight). + void innerRun(); + }, + }); + const innerRun = async () => { + innerCalls++; + await h.run(); + }; + // Capture logs of the inner run separately by swapping the log sink is + // overkill; the shared harness appends to the same array, which is fine: + // the inner run must only add the abort line and no meter call. + await h.run(); + innerLogs = h.logs.filter((l) => l.includes('abort')); + expect(innerCalls).toBe(1); + expect(h.calls).toHaveLength(1); // only the outer run reported + expect(innerLogs).toHaveLength(1); + }); + + it('takes over a stale lock left by a crashed run', async () => { + let nowMs = 1_000_000; + const h = makeHarness({ entries: [entry(500)], now: () => nowMs }); + // Simulate a crashed run that acquired the lock and never released it. + expect(h.billingRepo.tryAcquireLock('report-usage', 3_600_000)).toBe(true); + + // Within the TTL the job aborts. + await h.run(); + expect(h.calls).toHaveLength(0); + expect(h.logs.some((l) => l.includes('abort'))).toBe(true); + + // After the TTL the lock is stale and the run proceeds. + nowMs += 3_600_001; + await h.run(); + expect(h.calls).toHaveLength(1); + expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500); + }); +}); diff --git a/tests/reports.test.ts b/tests/reports.test.ts new file mode 100644 index 0000000..cd4d941 --- /dev/null +++ b/tests/reports.test.ts @@ -0,0 +1,100 @@ +import { billingRows, toCsv, usageTrend } from '../src/reports'; +import { Customer } from '../src/auth'; +import { TierConfig } from '../src/pricing'; +import { UsageEntry } from '../src/usage'; + +const tiers: TierConfig[] = [ + { id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 }, + { id: 'pro', name: 'Pro', multiplier: 0.5, monthlyCreditCents: 1000 }, +]; + +const customers: Customer[] = [ + { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' }, + { id: 'cust_2', name: 'Grace', tierId: 'pro', apiKey: 'key-grace' }, + { id: 'cust_3', name: 'Linus', tierId: 'pro', apiKey: 'key-linus', billingType: 'purchase_order' }, +]; + +const entry = (customerId: string, cents: number, iso: string): UsageEntry => ({ + customerId, + endpointId: 'storage', + cents, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date(iso), +}); + +describe('billingRows', () => { + const entries = [ + entry('cust_1', 150, '2026-07-05T10:00:00Z'), + entry('cust_2', 1500, '2026-07-06T10:00:00Z'), + entry('cust_2', 300, '2026-08-01T00:30:00Z'), + ]; + + it('aggregates per customer with tier credits', () => { + const rows = billingRows({ entries, customers, tiers }); + const ada = rows.find((r) => r.customerId === 'cust_1')!; + expect(ada).toMatchObject({ calls: 1, totalCents: 150, creditCents: 100, billableCents: 50 }); + const grace = rows.find((r) => r.customerId === 'cust_2')!; + expect(grace).toMatchObject({ calls: 2, totalCents: 1800, creditCents: 1000, billableCents: 800 }); + }); + + it('includes zero-usage customers and exposes billingType', () => { + const rows = billingRows({ entries, customers, tiers }); + const linus = rows.find((r) => r.customerId === 'cust_3')!; + expect(linus).toMatchObject({ calls: 0, totalCents: 0, billableCents: 0, billingType: 'purchase_order' }); + }); + + it('filters by date range (inclusive from, exclusive to)', () => { + const rows = billingRows({ + entries, + customers, + tiers, + from: new Date('2026-07-01T00:00:00Z'), + to: new Date('2026-08-01T00:00:00Z'), + }); + const grace = rows.find((r) => r.customerId === 'cust_2')!; + expect(grace.calls).toBe(1); // August entry excluded + expect(grace.totalCents).toBe(1500); + }); + + it('filters by customerId and billingType', () => { + expect(billingRows({ entries, customers, tiers, customerId: 'cust_1' })).toHaveLength(1); + const po = billingRows({ entries, customers, tiers, billingType: 'purchase_order' }); + expect(po.map((r) => r.customerId)).toEqual(['cust_3']); + }); +}); + +describe('usageTrend', () => { + it('buckets by day', () => { + const trend = usageTrend( + [entry('cust_1', 10, '2026-07-05T09:00:00Z'), entry('cust_2', 20, '2026-07-05T20:00:00Z'), entry('cust_1', 5, '2026-07-06T10:00:00Z')], + 'day', + ); + expect(trend).toEqual([ + { bucket: '2026-07-05', calls: 2, cents: 30 }, + { bucket: '2026-07-06', calls: 1, cents: 5 }, + ]); + }); + + it('buckets by ISO week (Monday key) across a month boundary', () => { + const trend = usageTrend( + [entry('cust_1', 10, '2026-07-31T10:00:00Z'), entry('cust_1', 20, '2026-08-01T10:00:00Z')], + 'week', + ); + expect(trend).toEqual([{ bucket: '2026-07-27', calls: 2, cents: 30 }]); + }); +}); + +describe('toCsv', () => { + it('escapes commas, quotes, and newlines per RFC 4180', () => { + const csv = toCsv( + [{ name: 'Ada, "The Great"', note: 'line1\nline2', cents: 50 }], + [ + { key: 'name', label: 'Name' }, + { key: 'note', label: 'Note' }, + { key: 'cents', label: 'Cents' }, + ], + ); + expect(csv).toBe('Name,Note,Cents\n"Ada, ""The Great""","line1\nline2",50'); + }); +}); diff --git a/tests/stripe.test.ts b/tests/stripe.test.ts new file mode 100644 index 0000000..caa0fdf --- /dev/null +++ b/tests/stripe.test.ts @@ -0,0 +1,39 @@ +import { MeterEventClient, reportUsage, METER_EVENT_NAME } from '../src/billing/stripe'; +import { UsageEntry } from '../src/usage'; + +const entry = (cents: number): UsageEntry => ({ + customerId: 'cust_1', + endpointId: 'storage', + cents, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date(), +}); + +const fakeClient = () => { + const calls: { eventName: string; customerId: string; value: string }[] = []; + const client: MeterEventClient = { + createMeterEvent: async (params) => { + calls.push(params); + }, + }; + return { client, calls }; +}; + +describe('reportUsage', () => { + it('reports usage minus the monthly credit as one meter event', async () => { + const { client, calls } = fakeClient(); + const reported = await reportUsage(client, 'cus_123', [entry(112), entry(4)], 100); + expect(reported).toBe(16); // 116 - 100 credit + expect(calls).toEqual([ + { eventName: METER_EVENT_NAME, customerId: 'cus_123', value: '16' }, + ]); + }); + + it('reports nothing when the credit covers all usage', async () => { + const { client, calls } = fakeClient(); + const reported = await reportUsage(client, 'cus_123', [entry(4)], 100); + expect(reported).toBe(0); + expect(calls).toHaveLength(0); + }); +}); diff --git a/tests/usage.test.ts b/tests/usage.test.ts new file mode 100644 index 0000000..23e7d75 --- /dev/null +++ b/tests/usage.test.ts @@ -0,0 +1,41 @@ +import { InMemoryUsageRepo, UsageEntry } from '../src/usage'; + +const entry = (over: Partial = {}): UsageEntry => ({ + customerId: 'cust_1', + endpointId: 'transform', + cents: 4, + metadataBytes: 0, + attachmentBytes: 0, + timestamp: new Date('2026-07-27T10:00:00Z'), + ...over, +}); + +describe('InMemoryUsageRepo', () => { + it('records and lists entries per customer', () => { + const repo = new InMemoryUsageRepo(); + repo.record(entry()); + repo.record(entry({ customerId: 'cust_2' })); + expect(repo.listFor('cust_1')).toHaveLength(1); + expect(repo.listFor('cust_2')).toHaveLength(1); + }); + + it('filters entries by since date', () => { + const repo = new InMemoryUsageRepo(); + repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') })); + repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') })); + expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1); + }); + + it('summarizes totals by endpoint', () => { + const repo = new InMemoryUsageRepo(); + repo.record(entry()); + repo.record(entry({ endpointId: 'storage', cents: 112 })); + repo.record(entry()); + const s = repo.summaryFor('cust_1'); + expect(s.customerId).toBe('cust_1'); + expect(s.calls).toBe(3); + expect(s.totalCents).toBe(120); + expect(s.byEndpoint.transform).toEqual({ calls: 2, cents: 8 }); + expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..51cc2c9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node", "jest", "multer"] + }, + "include": ["src"] +} diff --git a/zapier-app/README.md b/zapier-app/README.md new file mode 100644 index 0000000..17ead68 --- /dev/null +++ b/zapier-app/README.md @@ -0,0 +1,13 @@ +# Sample Zapier Platform app (zappier-edge) + +Minimal Zapier app that stores data/files through the **metered** zappier HTTPS API. This is a **sample**, not the Verae timestamp app (`verae-zapier-app`). + +**Catalog:** https://zapier.georgelambert.org/packages/zappier/zapier-app/README.pdf +**Parent:** https://zapier.georgelambert.org/packages/zappier/README.pdf + +```bash +cd packages/zappier/zapier-app +npm test +``` + +Point the app at the public zappier-edge base URL and an `x-api-key` from `/portal`. diff --git a/zapier-app/authentication.js b/zapier-app/authentication.js new file mode 100644 index 0000000..fd0d9df --- /dev/null +++ b/zapier-app/authentication.js @@ -0,0 +1,23 @@ +module.exports = { + type: 'custom', + test: (z, bundle) => + z.request({ url: `${bundle.authData.baseUrl}/v1/status` }).then((r) => r.data), + fields: [ + { + key: 'baseUrl', + label: 'API Base URL', + type: 'string', + required: true, + default: 'http://localhost:3000', + helpText: 'Where your Zappier API is running.', + }, + { + key: 'apiKey', + label: 'API Key', + type: 'password', + required: true, + helpText: 'Your Zappier customer API key.', + }, + ], + connectionLabel: '{{bundle.authData.baseUrl}}', +}; diff --git a/zapier-app/creates/store_data.js b/zapier-app/creates/store_data.js new file mode 100644 index 0000000..fb8f07c --- /dev/null +++ b/zapier-app/creates/store_data.js @@ -0,0 +1,54 @@ +const FormData = require('form-data'); + +const perform = async (z, bundle) => { + const form = new FormData(); + form.append( + 'metadata', + JSON.stringify({ title: bundle.inputData.title, note: bundle.inputData.note }), + ); + + if (bundle.inputData.file) { + const fileResponse = await z.request({ + url: bundle.inputData.file, + raw: true, + redirect: 'follow', + }); + form.append('attachments', fileResponse.body, { + filename: bundle.inputData.filename || 'attachment.bin', + }); + } + + const response = await z.request({ + url: `${bundle.authData.baseUrl}/v1/storage`, + method: 'POST', + body: form, + headers: form.getHeaders(), + }); + return response.data; +}; + +module.exports = { + key: 'store_data', + noun: 'Stored Item', + display: { + label: 'Store Data', + description: + 'Stores metadata and an optional file attachment. Priced per call plus metadata KB and attachment MB on your plan.', + }, + operation: { + inputFields: [ + { key: 'title', label: 'Title', type: 'string', required: true }, + { key: 'note', label: 'Note', type: 'text', required: false }, + { + key: 'file', + label: 'Attachment', + type: 'file', + required: false, + helpText: 'Optional file. Attachment size is billed per MB on your plan.', + }, + { key: 'filename', label: 'Filename', type: 'string', required: false }, + ], + perform, + sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }, + }, +}; diff --git a/zapier-app/index.js b/zapier-app/index.js new file mode 100644 index 0000000..9c64a19 --- /dev/null +++ b/zapier-app/index.js @@ -0,0 +1,20 @@ +const authentication = require('./authentication'); +const newItem = require('./triggers/new_item'); +const storeData = require('./creates/store_data'); + +const addApiKeyHeader = (request, z, bundle) => { + request.headers = request.headers || {}; + if (request.url && request.url.startsWith(bundle.authData.baseUrl)) { + request.headers['x-api-key'] = bundle.authData.apiKey; + } + return request; +}; + +module.exports = { + version: require('./package.json').version, + platformVersion: require('zapier-platform-core').version, + authentication, + beforeRequest: [addApiKeyHeader], + triggers: { [newItem.key]: newItem }, + creates: { [storeData.key]: storeData }, +}; diff --git a/zapier-app/package-lock.json b/zapier-app/package-lock.json new file mode 100644 index 0000000..f69ab42 --- /dev/null +++ b/zapier-app/package-lock.json @@ -0,0 +1,2087 @@ +{ + "name": "zappier", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zappier", + "version": "1.0.0", + "dependencies": { + "form-data": "^4.0.6", + "zapier-platform-core": "^19.0.0" + }, + "devDependencies": { + "mocha": "^11.7.6" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@zapier/secret-scrubber": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@zapier/secret-scrubber/-/secret-scrubber-1.1.6.tgz", + "integrity": "sha512-ETblIxh6lnkVHjVqQ8XVB2Ymeo7RudR7kIiZylXtYXAfpmGLXIzbk1h9fjF/SuO21k7jdxaMBaBdqUCBHv8+og==", + "license": "ISC", + "dependencies": { + "create-hash": "1.2.0", + "lodash.isplainobject": "4.0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fernet": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/fernet/-/fernet-0.3.3.tgz", + "integrity": "sha512-DvvqouVhv3VCor83wkQbSycekYUKDRQ1IKqcInaF5n5BSKgWBVfYLbSf7RRxojwQO0DZySiz5MlM2vO4MG3SUg==", + "license": "MIT", + "dependencies": { + "crypto-js": "~4.2.0", + "urlsafe-base64": "1.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "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/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-metaschema": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-metaschema/-/json-metaschema-1.3.0.tgz", + "integrity": "sha512-FMDPEZQzqIVOQZ3OxzWryI28W6IZ9QKLcHObO9bS+SJrwnV3xQD0QtnhtgSpIZd8Xuy0xBqeRA74vfTnpK4eVg==", + "license": "Public Domain" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsonschema": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz", + "integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT", + "optional": true + }, + "node_modules/urlsafe-base64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz", + "integrity": "sha512-RtuPeMy7c1UrHwproMZN9gN6kiZ0SvJwRaEzwZY0j9MypEkFqyBaKv176jvlPtg58Zh36bOkS0NFABXMHvvGCA==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zapier-platform-core": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/zapier-platform-core/-/zapier-platform-core-19.0.0.tgz", + "integrity": "sha512-BWUq7BUQCM6oOITKgfhuStuWGGmWuYQsDi/vVspHts1AAaURSANczrO2eYaLxagMxcX0JovoOyAYm8tQltsCmA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@zapier/secret-scrubber": "^1.1.2", + "content-disposition": "0.5.4", + "dotenv": "17.2.1", + "fernet": "^0.3.3", + "form-data": "4.0.5", + "json-schema-to-ts": "3.1.1", + "lodash": "4.18.1", + "mime-types": "3.0.1", + "node-abort-controller": "3.1.1", + "node-fetch": "2.7.0", + "oauth-sign": "0.9.0", + "semver": "7.7.2", + "zapier-platform-schema": "19.0.0" + }, + "engines": { + "node": ">=16", + "npm": ">=5.6.0" + }, + "optionalDependencies": { + "@types/node": "^20.3.1" + }, + "peerDependencies": { + "zapier-platform-legacy-scripting-runner": ">=3" + }, + "peerDependenciesMeta": { + "zapier-platform-legacy-scripting-runner": { + "optional": true + } + } + }, + "node_modules/zapier-platform-core/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/zapier-platform-core/node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/zapier-platform-core/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/zapier-platform-core/node_modules/mime-types/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/zapier-platform-schema": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/zapier-platform-schema/-/zapier-platform-schema-19.0.0.tgz", + "integrity": "sha512-mwy+1vaLBeFG5XGUbkmnwnk6EmY0yQeHKoY/wG24WtVuWacRKAmq9jbUZ1jVcor0zZBYyb7DD0GsYeSK+DR5dg==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "json-metaschema": "1.3.0", + "jsonschema": "1.5.0", + "lodash": "4.18.1" + } + } + } +} diff --git a/zapier-app/package.json b/zapier-app/package.json new file mode 100644 index 0000000..bde80b3 --- /dev/null +++ b/zapier-app/package.json @@ -0,0 +1,16 @@ +{ + "name": "zappier", + "version": "1.0.0", + "description": "Store data and files through the metered Zappier API.", + "main": "index.js", + "scripts": { + "test": "mocha --recursive --timeout 10000" + }, + "dependencies": { + "form-data": "^4.0.6", + "zapier-platform-core": "^19.0.0" + }, + "devDependencies": { + "mocha": "^11.7.6" + } +} diff --git a/zapier-app/test/app.test.js b/zapier-app/test/app.test.js new file mode 100644 index 0000000..ce9bf80 --- /dev/null +++ b/zapier-app/test/app.test.js @@ -0,0 +1,70 @@ +const assert = require('assert'); +const App = require('../index'); +const storeData = require('../creates/store_data'); + +describe('Zapier app definition', () => { + it('exposes custom auth, one trigger, and one action', () => { + assert.equal(App.authentication.type, 'custom'); + assert.ok(App.triggers.new_item); + assert.ok(App.creates.store_data); + assert.equal(App.beforeRequest.length, 1); + }); +}); + +describe('addApiKeyHeader (beforeRequest)', () => { + it('injects x-api-key only for the configured baseUrl', () => { + const addApiKeyHeader = App.beforeRequest[0]; + const bundle = { authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' } }; + + const own = addApiKeyHeader( + { url: 'http://localhost:3000/v1/storage', headers: {} }, + null, + bundle, + ); + assert.equal(own.headers['x-api-key'], 'key-ada'); + + const foreign = addApiKeyHeader({ url: 'https://evil.example.com/f.pdf', headers: {} }, null, bundle); + assert.equal(foreign.headers['x-api-key'], undefined); + }); +}); + +describe('store_data perform', () => { + it('posts metadata JSON to /v1/storage', async () => { + const requests = []; + const z = { + request: async (opts) => { + requests.push(opts); + return { data: { id: 'item_1' } }; + }, + }; + const bundle = { + authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' }, + inputData: { title: 'report', note: 'hello' }, + }; + const result = await storeData.operation.perform(z, bundle); + assert.equal(result.id, 'item_1'); + assert.equal(requests.length, 1); + assert.equal(requests[0].method, 'POST'); + assert.equal(requests[0].url, 'http://localhost:3000/v1/storage'); + }); + + it('downloads the mapped file first, then posts it as an attachment', async () => { + const requests = []; + const z = { + request: async (opts) => { + requests.push(opts); + return opts.raw ? { body: Buffer.from('x') } : { data: { id: 'item_2' } }; + }, + }; + const bundle = { + authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' }, + inputData: { title: 'with file', file: 'https://example.com/f.pdf', filename: 'f.pdf' }, + }; + await storeData.operation.perform(z, bundle); + assert.equal(requests.length, 2); + assert.equal(requests[0].url, 'https://example.com/f.pdf'); + assert.equal(requests[0].raw, true); + assert.equal(requests[1].url, 'http://localhost:3000/v1/storage'); + assert.equal(requests[1].method, 'POST'); + }); +}); diff --git a/zapier-app/triggers/new_item.js b/zapier-app/triggers/new_item.js new file mode 100644 index 0000000..171c600 --- /dev/null +++ b/zapier-app/triggers/new_item.js @@ -0,0 +1,24 @@ +const perform = async (z, bundle) => { + const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage` }); + return response.data.items; +}; + +module.exports = { + key: 'new_item', + noun: 'Stored Item', + display: { + label: 'New Stored Item', + description: 'Triggers when a new item is stored through the Zappier API.', + }, + operation: { + type: 'polling', + perform, + sample: { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + customerId: 'cust_1', + metadata: { title: 'example' }, + attachments: [], + createdAt: '2026-07-27T10:00:00.000Z', + }, + }, +};