fix: fail-closed Verae timestamp certificates

Sync live /opt/verae/zappier-edge tree, then stop inventing mock-cert-*
receipts in production.

- When ZAPPIER_UPSTREAM is set and upstream is missing/non-2xx, return
  upstream status (or 502) with error=upstream_timestamp_failed
- Dev-only mock-cert-* behind ALLOW_MOCK_CERTS=true when upstream unset
- Tests for fail-closed single/batch paths; mock tests set ALLOW_MOCK_CERTS
This commit is contained in:
George Lambert 2026-10-05 13:52:37 -04:00
commit 1107272d38
67 changed files with 7972 additions and 296 deletions

View file

@ -1,5 +1,35 @@
# 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)
# Optional: override SQLite locations
# ZAPPIER_DB=/absolute/path/to/zappier.db
# BILLING_DB=/absolute/path/to/billing.db
# BusinessStore driver (D006 / WP-L). Default: sqlite-file (BILLING_DB).
# BUSINESS_STORE=sqlite-file
# BUSINESS_STORE=libsql
# Required when BUSINESS_STORE=libsql (fail closed if missing):
# LIBSQL_URL=file:./tmp-business.db
# LIBSQL_URL=libsql://<host>
# LIBSQL_AUTH_TOKEN=
# Password-reset email (POST /portal/api/password/forgot). Gmail: STARTTLS on 587
# with an app password. Leave SMTP_USER/SMTP_PASS unset to disable (endpoint 503s
# with error=email_unavailable; capabilities.emailAvailable=false).
# On .138 these live in /etc/verae/zappier-edge-smtp.env (root 0600, systemd drop-in).
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=veraetimestamp@gmail.com
# SMTP_FROM=veraetimestamp@gmail.com
# SMTP_FROM_NAME=Verae
# SMTP_PASS=
# Verae timestamp upstream (production: middleware on loopback).
# When set, edge fail-closes on non-2xx — never invents mock-cert-* receipts.
# ZAPPIER_UPSTREAM=http://127.0.0.1:3130
# Optional middleware API key forwarded as x-api-key to upstream.
# ZAPPIER_MIDDLEWARE_API_KEY=
# Dev-only: allow local mock-cert-* receipts when ZAPPIER_UPSTREAM is unset.
# Never enable in production.
# ALLOW_MOCK_CERTS=true

View file

@ -1,8 +1,9 @@
# Zappier
[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
Metered API platform: per-endpoint pricing, customer types with multipliers and monthly credits, a usage ledger,...
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.
@ -139,32 +140,3 @@ To deploy it, create a Zapier developer account, run `zapier login`, then
npm test # root API/service suite (jest, 165 tests)
cd zapier-app && npm test # Zapier integration suite (mocha, 4 tests)
```
## Table of Contents
- [Install](#install)
- [Usage](#usage)
- [Contributing](#contributing)
- [License](#license)
## Install
```sh
git clone ssh://git@git.georgelambert.org:2223/marchon/zappier-edge.git
cd zappier-edge
```
## Usage
```sh
npm install
npm start
```
## Contributing
Questions: open an issue at https://git.georgelambert.org/marchon/zappier-edge/issues. Pull requests are accepted.
## License
UNLICENSED © George Lambert

View file

@ -1,10 +1,30 @@
openapi: 3.0.3
info:
title: Zappier API
version: 0.1.0
description: Metered data API. Every priced call returns its quote.
title: Zappier API (zap.verae.ai)
version: 0.2.0
description: |
Metered data API for Zapier and direct API clients.
**Timestamping (v1):** digests-only. Edge meters the call, then waits on
middleware `POST /api/v1/timestamp` (legacy alias `POST /api/timestamp`)
which calls Verae Time `POST /api/timestamp` — same path MCP uses on
api.veraetime.net. Responses include `apiVersion: v1`, `receipt_id` (CID),
`created_at`, and `merkle` when `wait` is true (default).
**Back-compat:** unversioned middleware `/api/*` aliases remain. Upstream
Verae Time and MCP keep `/api/timestamp` unchanged.
**Optional exports:** set `attachPdf: true` or `formats: ["pdf"]` (also
`csv`, `text`) for registration-document fields like mcp.verae.ai/register.html.
PDF is off by default.
**Auth:** prefer long-lived `xVerae-…` tokens from the customer portal
(`/portal` → Security). Send as `x-api-key` or `Authorization: Bearer`.
Legacy `key-…` customer keys remain valid. Create/list/revoke via
`/portal/api/tokens` (portal session required).
security:
- apiKey: []
- bearerToken: []
paths:
/v1/status:
get:
@ -58,10 +78,75 @@ paths:
responses:
'200':
description: OK
/v1/timestamps:
post:
operationId: timestampsBatch
summary: Batch register digests (wait + receipts; Verae /api/batch/timestamp)
description: |
Digests only — hash locally (same as https://mcp.verae.ai/).
Edge calls middleware `POST /api/v1/batch/timestamp` (legacy `/api/batch/timestamp`),
which uses Verae Time `POST /api/batch/timestamp` when the plan allows, otherwise
sequential `POST /api/timestamp` + wait (MCP-style). Max 500.
Default `wait: true` returns `receipt_id` / `created_at` / `merkle` per result.
`attachPdf` is opt-in.
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- type: object
required: [items]
properties:
items:
type: array
maxItems: 500
items:
$ref: '#/components/schemas/DigestStampItem'
wait:
type: boolean
default: true
attachPdf:
type: boolean
default: false
description: Opt-in registration PDF (pdfBase64) per result
formats:
$ref: '#/components/schemas/ResultFormats'
- type: object
required: [sha256s]
properties:
sha256s:
type: array
maxItems: 500
items:
type: string
description: 64-hex SHA-256 digest
wait:
type: boolean
default: true
attachPdf:
type: boolean
default: false
formats:
$ref: '#/components/schemas/ResultFormats'
responses:
'200':
description: Completed (wait=true) — results with receipt_id / created_at
content:
application/json:
schema:
$ref: '#/components/schemas/BatchStampResponse'
'202':
description: Accepted async (wait=false) — jobIds
/v1/timestamp:
post:
operationId: timestamp
summary: Register a timestamp (proxies middleware or in-process mock)
summary: Register one digest (wait + receipt; Verae /api/timestamp)
description: |
Digests only. Proxies middleware `POST /api/v1/timestamp` (legacy `/api/timestamp`)
→ Verae Time `POST /api/timestamp` (identical path MCP uses). Waits by default
and returns MCP-shaped receipt fields. Set `attachPdf: true` for the registration PDF.
requestBody:
required: true
content:
@ -71,13 +156,40 @@ paths:
properties:
data:
type: string
description: Raw UTF-8 (hashed server-side) or 64-hex digest
sha256:
type: string
description: Preferred 64-hex SHA-256
digest:
type: string
description: Alias of sha256
hashAlg:
type: string
default: SHA256
documentName:
type: string
description: Shown on registration PDF / text export
wait:
type: boolean
default: true
attachPdf:
type: boolean
default: false
description: Opt-in Verae Timestamp Registration Document (pdfBase64)
formats:
$ref: '#/components/schemas/ResultFormats'
restamp:
type: boolean
default: false
responses:
'200':
description: Completed — receipt_id (CID), created_at, merkle; pdfBase64 if attachPdf
content:
application/json:
schema:
$ref: '#/components/schemas/StampReceipt'
'202':
description: Accepted
description: Accepted async (wait=false)
/v1/hashes/{sha256}:
get:
operationId: hash-lookup
@ -142,3 +254,116 @@ components:
type: apiKey
in: header
name: x-api-key
description: Legacy `key-…` or long-lived `xVerae-…` token from the portal
bearerToken:
type: http
scheme: bearer
description: Same `xVerae-…` token as Bearer (LLM-key style)
schemas:
ResultFormats:
description: |
Export formats (register.html-style). Default is JSON body only.
Include `pdf` or set attachPdf=true for the registration PDF.
oneOf:
- type: string
enum: [json, csv, text, pdf, all]
- type: array
items:
type: string
enum: [json, csv, text, pdf]
DigestStampItem:
type: object
properties:
data:
type: string
description: 64-hex SHA-256 digest
digest:
type: string
sha256:
type: string
hashAlg:
type: string
default: SHA256
restamp:
type: boolean
default: false
documentName:
type: string
StampReceipt:
type: object
properties:
apiVersion:
type: string
example: v1
jobId:
type: string
format: uuid
sha256:
type: string
status:
type: string
example: completed
existing:
type: boolean
receipt_id:
type: string
description: Certificate CID (MCP receipt_id)
certificate:
type: string
description: Alias of receipt_id
created_at:
type: string
format: date-time
timestamp:
type: string
format: date-time
merkle:
type: string
description: Block index / merkle hint from Verae status metadata
scope:
type: string
example: global
lookup_url:
type: string
format: uri
documentName:
type: string
pdfBase64:
type: string
description: Present only when attachPdf=true or formats includes pdf
pdfFilename:
type: string
pdfContentType:
type: string
example: application/pdf
text:
type: string
csv:
type: string
quote:
type: object
BatchStampResponse:
type: object
properties:
apiVersion:
type: string
example: v1
mode:
type: string
enum: [batch, sequential]
jobIds:
type: array
items:
type: string
count:
type: integer
created:
type: integer
existing:
type: integer
totalCredits:
type: number
results:
type: array
items:
$ref: '#/components/schemas/StampReceipt'

270
package-lock.json generated
View file

@ -8,12 +8,14 @@
"name": "zappier",
"version": "0.1.0",
"dependencies": {
"@libsql/client": "^0.18.0",
"@types/qrcode": "^1.5.6",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-openapi-validator": "^5.3.0",
"nats": "^2.29.3",
"nodemailer": "^6.10.1",
"qrcode": "^1.5.4",
"stripe": "^16.0.0",
"swagger-ui-express": "^5.0.0",
@ -26,6 +28,7 @@
"@types/jest": "^29.5.12",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.0",
"@types/nodemailer": "^6.4.24",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@types/yamljs": "^0.2.34",
@ -1023,6 +1026,171 @@
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@libsql/client": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.18.0.tgz",
"integrity": "sha512-zMCCo58vv7w27j7+cnt/GW+X6/Rih6cHZ86PKi0AxCMOqMfrVgG9OZ5lW2bDMJhSeOjsv2szH2nJF3A9l36UmA==",
"license": "MIT",
"dependencies": {
"@libsql/core": "^0.18.0",
"@libsql/hrana-client": "^0.10.0",
"js-base64": "^3.7.5",
"libsql": "^0.5.28",
"promise-limit": "^2.7.0"
}
},
"node_modules/@libsql/core": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.18.0.tgz",
"integrity": "sha512-N8RR2CIpcgtbKZnXs2KVpw2lJQfinUj3I56e7qRDRpK18Hty0Rr8nr00VmFb4R/DsqOanPB/TaP3+QAQLZeXMg==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@libsql/darwin-arm64": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz",
"integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@libsql/darwin-x64": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz",
"integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@libsql/hrana-client": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz",
"integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==",
"license": "MIT",
"dependencies": {
"@libsql/isomorphic-ws": "^0.1.5",
"js-base64": "^3.7.5"
}
},
"node_modules/@libsql/isomorphic-ws": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz",
"integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==",
"license": "MIT",
"dependencies": {
"@types/ws": "^8.5.4",
"ws": "^8.13.0"
}
},
"node_modules/@libsql/linux-arm-gnueabihf": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz",
"integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/linux-arm-musleabihf": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz",
"integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/linux-arm64-gnu": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz",
"integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/linux-arm64-musl": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz",
"integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/linux-x64-gnu": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz",
"integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/linux-x64-musl": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz",
"integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/win32-x64-msvc": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz",
"integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@neon-rs/load": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz",
"integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -1316,6 +1484,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.24",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
"integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@ -1409,6 +1587,15 @@
"@types/serve-static": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yamljs": {
"version": "0.2.34",
"resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.34.tgz",
@ -4041,6 +4228,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/js-base64": {
"version": "3.9.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz",
"integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -4129,6 +4322,47 @@
"node": ">=6"
}
},
"node_modules/libsql": {
"version": "0.5.29",
"resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz",
"integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==",
"cpu": [
"x64",
"arm64",
"wasm32",
"arm"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"@neon-rs/load": "^0.0.4",
"detect-libc": "2.0.2"
},
"optionalDependencies": {
"@libsql/darwin-arm64": "0.5.29",
"@libsql/darwin-x64": "0.5.29",
"@libsql/linux-arm-gnueabihf": "0.5.29",
"@libsql/linux-arm-musleabihf": "0.5.29",
"@libsql/linux-arm64-gnu": "0.5.29",
"@libsql/linux-arm64-musl": "0.5.29",
"@libsql/linux-x64-gnu": "0.5.29",
"@libsql/linux-x64-musl": "0.5.29",
"@libsql/win32-x64-msvc": "0.5.29"
}
},
"node_modules/libsql/node_modules/detect-libc": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
"integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -4487,6 +4721,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -4796,6 +5039,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/promise-limit": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
"integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
"license": "ISC"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -6101,6 +6350,27 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -9,12 +9,14 @@
"test": "jest"
},
"dependencies": {
"@libsql/client": "^0.18.0",
"@types/qrcode": "^1.5.6",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-openapi-validator": "^5.3.0",
"nats": "^2.29.3",
"nodemailer": "^6.10.1",
"qrcode": "^1.5.4",
"stripe": "^16.0.0",
"swagger-ui-express": "^5.0.0",
@ -27,6 +29,7 @@
"@types/jest": "^29.5.12",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.0",
"@types/nodemailer": "^6.4.24",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@types/yamljs": "^0.2.34",

View file

@ -132,6 +132,8 @@ export interface SessionRepo {
/** Returns the session, or undefined when unknown or expired at nowMs. */
get(token: string, nowMs?: number): PortalSession | undefined;
delete(token: string): void;
/** Revokes every session for a customer (e.g. after a password reset). */
deleteForCustomer?(customerId: string): void;
}
export function newSessionToken(): string {
@ -162,4 +164,8 @@ export class InMemorySessionRepo implements SessionRepo {
delete(token: string): void {
this.sessions.delete(token);
}
deleteForCustomer(customerId: string): void {
for (const [k, v] of this.sessions) if (v.customerId === customerId) this.sessions.delete(k);
}
}

View file

@ -6,11 +6,18 @@ 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 { ConfigTierCatalog, PriceRule, PricingContext, PricingStore, TierConfig } from './pricing';
import { peergosAdminRouter } from './peergos-admin';
import {
BILLING_CHANNELS,
CREDIT_SKUS,
CUSTOMER_SEGMENTS,
DEFAULT_CHANNEL_SKU_CREDITS,
} from './billing-channels';
import { PROJECT_ROOT } from './paths';
import { billingRows, toCsv, usageTrend } from './reports';
import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { CreditLedger, CreditLedgerLike } from './credits';
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
@ -57,7 +64,7 @@ export function adminLoginRouter(users: AdminUserRepo): Router {
}
export function adminAuth(): RequestHandler {
return (req, res, next) => {
return async (req, res, next) => {
const expected = process.env.ADMIN_KEY ?? 'admin-dev-key';
if (req.header('x-admin-key') === expected) {
req.staff = { username: 'x-admin-key', permissions: ['*'], t: Date.now() };
@ -207,7 +214,11 @@ export function adminRouter(
store: PricingStore,
customers: CustomerRepo,
accounting: AccountingDeps,
credits: CreditLedger = new CreditLedger(),
credits: CreditLedgerLike = new CreditLedger(),
channelRates?: {
load: () => import('./billing-channels').ChannelRateTable;
save: (t: import('./billing-channels').ChannelRateTable, by?: string) => void;
},
): Router {
const router = Router();
@ -226,6 +237,49 @@ export function adminRouter(
res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
});
/** Sales: separate Zapier / MCP / API credit rates by customer segment. */
router.get('/channel-rates', (_req, res) => {
if (!channelRates) {
res.status(503).json({ error: 'channel rate store not configured' });
return;
}
res.json({
table: channelRates.load(),
channels: BILLING_CHANNELS,
segments: CUSTOMER_SEGMENTS,
skus: CREDIT_SKUS,
defaults: DEFAULT_CHANNEL_SKU_CREDITS,
note: 'Segment sets SKU credits per channel. Tier sets included stamps/GB only.',
});
});
router.put('/channel-rates', (req, res) => {
if (!channelRates) {
res.status(503).json({ error: 'channel rate store not configured' });
return;
}
const body = req.body ?? {};
if (!body.rates || typeof body.rates !== 'object') {
res.status(400).json({ error: 'rates object required' });
return;
}
const overageMultiplier =
typeof body.overageMultiplier === 'number' ? body.overageMultiplier : 1.5;
const prepaidOverageMultiplier =
typeof body.prepaidOverageMultiplier === 'number'
? body.prepaidOverageMultiplier
: 0.75;
channelRates.save(
{
rates: body.rates,
overageMultiplier,
prepaidOverageMultiplier,
},
req.staff?.username,
);
res.json({ ok: true, table: channelRates.load() });
});
router.put('/endpoints/:id', (req, res) => {
if (!isValidRule(req.body)) {
res.status(400).json({ error: 'invalid price rule' });
@ -254,37 +308,18 @@ export function adminRouter(
res.json({ ok: true });
});
router.get('/customers', (req, res) => {
router.get('/customers', async (req, res) => {
res.json({
customers: customers.list().map((c) => ({ ...c, apiKey: undefined, passwordHash: undefined, totpSecret: undefined })),
customers: (await customers.list()).map((c) => ({ ...c, apiKey: undefined, passwordHash: undefined, totpSecret: undefined })),
});
});
router.post('/customers', (req, res) => {
const { name, tierId } = req.body ?? {};
router.post('/customers', async (req, res) => {
const { name, tierId, id, billingType, storageMode, email, stripeCustomerId } = 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);
void ledgerPutCustomer({ customerId: customer.id, name: customer.name });
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' &&
@ -293,6 +328,74 @@ export function adminRouter(
res.status(400).json({ error: 'billingType must be stripe or purchase_order' });
return;
}
if (storageMode !== undefined && storageMode !== 'hosted' && storageMode !== 'byo') {
res.status(400).json({ error: 'storageMode must be hosted or byo' });
return;
}
const customer = {
id: typeof id === 'string' && id.trim() ? id.trim() : `cust_${randomUUID().slice(0, 8)}`,
name,
tierId,
apiKey: `key-${randomBytes(12).toString('hex')}`,
billingType: billingType ?? 'stripe',
storageMode: storageMode ?? 'hosted',
accountKind: 'org' as const,
email: typeof email === 'string' ? email : undefined,
stripeCustomerId: typeof stripeCustomerId === 'string' ? stripeCustomerId : undefined,
};
await customers.save(customer);
void ledgerPutCustomer({ customerId: customer.id, name: customer.name });
res.status(201).json(customer);
});
router.put('/customers/:id', async (req, res) => {
const existing = (await 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,
storageMode,
s3Endpoint,
s3Bucket,
s3Region,
includedStorageGbOverride,
storageOveragePerGbMillicentsOverride,
timestampFixedCentsOverride,
retentionBilling,
retentionIncrement,
retentionUntil,
amazonPassthroughFeesCents,
} = req.body ?? {};
if (
billingType !== undefined &&
billingType !== 'stripe' &&
billingType !== 'purchase_order'
) {
res.status(400).json({ error: 'billingType must be stripe or purchase_order' });
return;
}
if (storageMode !== undefined && storageMode !== 'hosted' && storageMode !== 'byo') {
res.status(400).json({ error: 'storageMode must be hosted or byo' });
return;
}
if (
retentionBilling !== undefined &&
retentionBilling !== 'none' &&
retentionBilling !== 'verae_compliance' &&
retentionBilling !== 'amazon_passthrough'
) {
res.status(400).json({
error: 'retentionBilling must be none, verae_compliance, or amazon_passthrough',
});
return;
}
const next = {
...existing,
...(name !== undefined ? { name } : {}),
@ -301,8 +404,21 @@ export function adminRouter(
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
...(billingType !== undefined ? { billingType } : {}),
...(email !== undefined ? { email } : {}),
...(storageMode !== undefined ? { storageMode } : {}),
...(s3Endpoint !== undefined ? { s3Endpoint } : {}),
...(s3Bucket !== undefined ? { s3Bucket } : {}),
...(s3Region !== undefined ? { s3Region } : {}),
...(includedStorageGbOverride !== undefined ? { includedStorageGbOverride } : {}),
...(storageOveragePerGbMillicentsOverride !== undefined
? { storageOveragePerGbMillicentsOverride }
: {}),
...(timestampFixedCentsOverride !== undefined ? { timestampFixedCentsOverride } : {}),
...(retentionBilling !== undefined ? { retentionBilling } : {}),
...(retentionIncrement !== undefined ? { retentionIncrement } : {}),
...(retentionUntil !== undefined ? { retentionUntil } : {}),
...(amazonPassthroughFeesCents !== undefined ? { amazonPassthroughFeesCents } : {}),
};
customers.save(next);
await customers.save(next);
void ledgerPutCustomer({ customerId: next.id, name: next.name, veraeUserId: next.veraeUserId });
res.json({ ok: true });
});
@ -315,7 +431,7 @@ export function adminRouter(
res.status(400).json({ error: 'customerId and cents required' });
return;
}
const customer = customers.list().find((c) => c.id === customerId);
const customer = (await customers.list()).find((c) => c.id === customerId);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
@ -341,7 +457,7 @@ export function adminRouter(
let prepaid = customer.balanceCents ?? 0;
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += delta;
customers.save({ ...customer, balanceCents: prepaid });
await customers.save({ ...customer, balanceCents: prepaid });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
res.status(201).json({ ...rec, prepaidCents: prepaid });
});
@ -352,7 +468,7 @@ export function adminRouter(
});
router.get('/statement/:id', async (req, res) => {
const customer = customers.list().find((c) => c.id === req.params.id);
const customer = (await customers.list()).find((c) => c.id === req.params.id);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
@ -369,7 +485,7 @@ export function adminRouter(
veraeUserId: customer.veraeUserId,
prepaidCents: customer.balanceCents ?? 0,
credits: credits.list(customer.id),
usage: accounting.usage.listFor(customer.id),
usage: await accounting.usage.listFor(customer.id),
invoices: accounting.invoices.list({ customerId: customer.id }),
}),
name: customer.name,
@ -378,8 +494,8 @@ export function adminRouter(
});
});
router.get('/sales/quote/:id', (req, res) => {
const customer = customers.list().find((c) => c.id === req.params.id);
router.get('/sales/quote/:id', async (req, res) => {
const customer = (await customers.list()).find((c) => c.id === req.params.id);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
@ -415,7 +531,7 @@ export function adminRouter(
/* ---------------- accounting: invoices ---------------- */
router.post('/invoices/generate', (req, res) => {
router.post('/invoices/generate', async (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' });
@ -426,7 +542,7 @@ export function adminRouter(
const generated: string[] = [];
const skipped: { customerId: string; reason: string }[] = [];
for (const customer of customers.list()) {
for (const customer of await customers.list()) {
if (customerId && customer.id !== customerId) continue;
const existing = accounting.invoices
.list({ customerId: customer.id, period })
@ -435,9 +551,9 @@ export function adminRouter(
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);
const entries = (await 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;
@ -474,21 +590,21 @@ export function adminRouter(
});
});
router.get('/invoices/:id', (req, res) => {
router.get('/invoices/:id', async (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;
const name = (await 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) => {
router.post('/invoices/:id/issue', async (req, res) => {
const invoice = accounting.invoices.get(req.params.id);
if (!invoice) {
res.status(404).json({ error: 'invoice not found' });
@ -510,10 +626,10 @@ export function adminRouter(
// 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 customer = (await 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 });
await customers.save({ ...customer, balanceCents: balance - invoice.billableCents });
const paid: Invoice = { ...issued, status: 'paid', paidAtMs: now };
accounting.invoices.save(paid);
res.json(paid);
@ -540,11 +656,14 @@ export function adminRouter(
/* ---------------- accounting: reports ---------------- */
router.get('/reports/billing', (req, res) => {
const all = customers.list().flatMap((c) => accounting.usage.listFor(c.id));
router.get('/reports/billing', async (req, res) => {
const custs = await customers.list();
const all = (
await Promise.all(custs.map((c) => accounting.usage.listFor(c.id)))
).flat();
const rows = billingRows({
entries: all,
customers: customers.list(),
customers: custs,
tiers: store.getTiers(),
...(parseDate(req.query.from) ? { from: parseDate(req.query.from)! } : {}),
...(parseDate(req.query.to) ? { to: parseDate(req.query.to)! } : {}),
@ -572,14 +691,15 @@ export function adminRouter(
res.json({ rows });
});
router.get('/reports/usage-trend', (req, res) => {
router.get('/reports/usage-trend', async (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))
const custs = (await customers.list()).filter((c) =>
customerId ? c.id === customerId : true,
);
const entries = (await Promise.all(custs.map((c) => accounting.usage.listFor(c.id))))
.flat()
.filter((e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to));
const bucket = req.query.bucket === 'week' ? 'week' : 'day';
res.json({ points: usageTrend(entries, bucket) });
@ -672,5 +792,17 @@ export function adminRouter(
});
});
const peergosPricing: PricingContext = {
get rateCard() {
return store.getRateCard();
},
get tiers() {
return new ConfigTierCatalog(store.getTiers());
},
};
router.use(peergosAdminRouter(customers, accounting.usage, peergosPricing));
return router;
}

View file

@ -10,13 +10,17 @@ import {
CustomerRepo,
InMemoryCustomerRepo,
} from './auth';
import { meter } from './meter';
import { billingCustomerId, meter } from './meter';
import { resolveBillingChannel } from './billing-channel-resolve';
import { lookupSkuCredits } from './billing-channels';
import { setBillingHeaders } from './billing-headers';
import { applyMonthlyCredit } from './billing/credit';
import {
ConfigTierCatalog,
InMemoryPricingStore,
PricingContext,
PricingStore,
quoteTimestamp,
} from './pricing';
import { InMemoryUsageRepo, UsageRepo } from './usage';
import { adminAuth, adminLoginRouter, adminPerms, adminRouter } from './admin';
@ -25,10 +29,172 @@ import { InMemorySessionRepo, SessionRepo } from './accounts';
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
import { PROJECT_ROOT } from './paths';
import { PaymentClient, portalRouter } from './portal';
import { proxyVerae } from './upstream';
import { CreditLedger } from './credits';
import { fetchVerae, proxyVerae } from './upstream';
import { CreditLedger, CreditLedgerLike } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
import { buildRegistrationPdf, registrationFilename } from './registration-pdf';
import { ApiTokenRepo, InMemoryApiTokenRepo } from './db/api-token-repo';
import { seoRouter } from './seo';
/**
* Parse export formats from body/query.
* Default: json only. PDF is opt-in via formats including "pdf" or attachPdf:true.
* Same for csv/text (register.html-style result types).
*/
function parseResultFormats(req: Request): Set<string> {
const set = new Set<string>(['json']);
if (req.body?.attachPdf === true || req.body?.attachPdf === 'true' || req.query?.attachPdf === 'true') {
set.add('pdf');
}
const raw =
req.body?.formats ??
req.body?.resultType ??
req.body?.format ??
req.query?.formats ??
req.query?.format;
if (raw == null || raw === '') return set;
const list = Array.isArray(raw)
? raw.map(String)
: String(raw)
.split(/[,+\s]+/)
.map((s) => s.trim())
.filter(Boolean);
for (const item of list) {
const v = item.toLowerCase();
if (v === 'all') {
set.add('csv');
set.add('text');
set.add('pdf');
continue;
}
if (v === 'json' || v === 'csv' || v === 'text' || v === 'pdf') set.add(v);
}
return set;
}
/** Production fail-closed: never invent mock-cert-* when an upstream is configured. */
function upstreamConfigured(): boolean {
return Boolean((process.env.ZAPPIER_UPSTREAM || '').trim());
}
/** Dev-only local mock certificates. Allowed only when upstream is unset. */
function allowMockCerts(): boolean {
return process.env.ALLOW_MOCK_CERTS === 'true' && !upstreamConfigured();
}
function rejectUpstreamTimestamp(
res: Response,
upstream: { status: number; body: unknown } | null,
): void {
if (upstreamConfigured()) {
const status =
upstream && typeof upstream.status === 'number' && upstream.status >= 400
? upstream.status
: 502;
const body: Record<string, unknown> = {
upstreamStatus: upstream?.status ?? null,
};
if (upstream?.body && typeof upstream.body === 'object' && !Array.isArray(upstream.body)) {
Object.assign(body, upstream.body as Record<string, unknown>);
} else if (typeof upstream?.body === 'string' && upstream.body) {
body.detail = upstream.body;
}
// Edge error wins over any upstream "error" field.
body.error = 'upstream_timestamp_failed';
res.status(status).json(body);
return;
}
res.status(503).json({
error: 'timestamp_upstream_unconfigured',
hint: 'Set ZAPPIER_UPSTREAM for real Verae stamps, or ALLOW_MOCK_CERTS=true for local mock certificates.',
});
}
function stampToText(row: {
documentName?: string;
sha256: string;
scope?: string;
created_at?: string;
receipt_id?: string;
merkle?: string;
lookup_url?: string;
}): string {
return [
'Verae Timestamp Registration',
`Document: ${row.documentName || '(digest only)'}`,
`SHA-256: ${row.sha256}`,
`Scope: ${(row.scope || 'global').toUpperCase()}`,
`Original timestamp: ${row.created_at || '—'}`,
`Receipt ID: ${row.receipt_id || '—'}`,
`Merkle / block: ${row.merkle || '—'}`,
`Ledger: VeraeTime global · api.veraetime.net`,
`Lookup: ${row.lookup_url || ''}`,
`Explorer: https://explorer.verae.ai/`,
'',
].join('\n');
}
async function attachRegistrationExports(
stamp: Record<string, unknown>,
formats: Set<string>,
documentName?: string,
): Promise<Record<string, unknown>> {
const sha256 = String(stamp.sha256 || stamp.digest || '');
const receiptId = String(stamp.receipt_id || stamp.certificate || '');
const createdAt = String(stamp.created_at || stamp.timestamp || '');
const merkle = String(stamp.merkle || '');
const lookup_url = `https://mcp.verae.ai/register.html?sha256=${encodeURIComponent(sha256)}${
receiptId ? `&receipt=${encodeURIComponent(receiptId)}` : ''
}`;
const row = {
documentName: documentName || (typeof stamp.documentName === 'string' ? stamp.documentName : undefined),
sha256,
scope: String(stamp.scope || 'global'),
created_at: createdAt,
receipt_id: receiptId,
merkle,
lookup_url,
};
const out: Record<string, unknown> = {
...stamp,
apiVersion: stamp.apiVersion || 'v1',
receipt_id: receiptId || stamp.receipt_id,
created_at: createdAt || stamp.created_at,
timestamp: createdAt || stamp.timestamp,
merkle: merkle || stamp.merkle,
lookup_url,
scope: row.scope,
};
if (formats.has('text')) {
out.text = stampToText(row);
}
if (formats.has('csv')) {
const cols = [
'documentName',
'sha256',
'scope',
'receipt_id',
'created_at',
'merkle',
'lookup_url',
];
const esc = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
out.csv =
cols.join(',') +
'\n' +
cols.map((c) => esc(String((row as Record<string, string | undefined>)[c] ?? ''))).join(',') +
'\n';
}
if (formats.has('pdf') && sha256) {
const pdf = await buildRegistrationPdf(row);
out.pdfBase64 = pdf.toString('base64');
out.pdfFilename = registrationFilename(row);
out.pdfContentType = 'application/pdf';
}
return out;
}
export interface StoredItem {
id: string;
@ -39,6 +205,16 @@ export interface StoredItem {
}
export const DEFAULT_CUSTOMERS: Customer[] = [
{
id: 'cust-platform',
name: 'Verae shared platform',
tierId: 'business',
apiKey: 'key-platform',
accountKind: 'org',
storageMode: 'hosted',
billingType: 'stripe',
retentionBilling: 'none',
},
{ 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' },
@ -52,8 +228,19 @@ export interface AppDeps {
adminUsers?: AdminUserRepo;
sessions?: SessionRepo;
payments?: PaymentClient;
/** Sales-editable Zapier/MCP/API rates by segment. */
channelRates?: {
load: () => import('./billing-channels').ChannelRateTable;
save: (t: import('./billing-channels').ChannelRateTable, by?: string) => void;
};
/** Outbound email (password reset); defaults to SMTP_* env config. */
mailer?: import('./mailer').Mailer;
/** QR renderer for 2FA setup; defaults to the qrcode package. */
qr?: (uri: string) => Promise<string>;
/** Durable when SqliteCreditLedger(billingDb); in-memory default for tests. */
credits?: CreditLedgerLike;
/** Long-lived xVerae- customer API tokens (LLM-key style). */
apiTokens?: ApiTokenRepo;
}
const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml');
@ -101,14 +288,23 @@ export function buildApp(deps: AppDeps = {}): {
},
};
const items: StoredItem[] = [];
const credits = new CreditLedger();
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) => {
const c = customers.list().find((row) => row.id === e.customerId);
natsPublish(
BILLING_SUBJECTS.USAGE_RECORDED,
{ ...e, veraeUserId: c?.veraeUserId, at: new Date().toISOString() },
'api',
);
const credits: CreditLedgerLike = deps.credits ?? new CreditLedger();
const onUsage = (e: {
customerId: string;
endpointId: string;
cents: number;
actorId?: string;
}) => {
void (async () => {
const c =
(await customers.findById(e.actorId ?? e.customerId)) ??
(await customers.findById(e.customerId));
natsPublish(
BILLING_SUBJECTS.USAGE_RECORDED,
{ ...e, veraeUserId: c?.veraeUserId, at: new Date().toISOString() },
'api',
);
})();
};
const hashIndex = new Map<
string,
@ -119,7 +315,22 @@ export function buildApp(deps: AppDeps = {}): {
const app = express();
app.use(express.json());
// Hostname-aware robots.txt, llms.txt, sitemap.xml, /portal/seo.json
app.use(seoRouter());
const spec = YAML.load(SPEC_PATH);
// Raw OpenAPI for Zapier Platform / codegen (Swagger UI is at /docs/)
app.get('/openapi.yaml', (_req, res) => {
res.type('application/yaml');
res.sendFile(SPEC_PATH);
});
app.get('/openapi.json', (_req, res) => {
res.json(spec);
});
// Product home → customer portal (account + xVerae- keys)
app.get('/', (_req, res) => {
res.redirect(302, '/portal/');
});
app.use(
'/docs',
swaggerUi.serve,
@ -128,6 +339,7 @@ export function buildApp(deps: AppDeps = {}): {
customCss:
'body::before{content:"OpenAPI explorer — stock Swagger UI for integrators. Not a customer surface. Use x-api-key.";' +
'display:block;background:#312e81;color:#eef0fe;font:650 13px -apple-system,sans-serif;padding:.7rem 1.2rem}',
swaggerUrl: '/openapi.yaml',
}),
);
@ -136,11 +348,19 @@ export function buildApp(deps: AppDeps = {}): {
adminLoginRouter(adminUsers),
adminAuth(),
adminPerms(),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits),
adminRouter(
pricingStore,
customers,
{ usage, invoices, users: adminUsers },
credits,
deps.channelRates,
),
);
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
app.use('/client', express.static(path.join(PROJECT_ROOT, 'client')));
const sessions = deps.sessions ?? new InMemorySessionRepo();
const apiTokens: ApiTokenRepo = deps.apiTokens ?? new InMemoryApiTokenRepo();
const qr =
deps.qr ??
(async (uri: string) => {
@ -163,6 +383,8 @@ export function buildApp(deps: AppDeps = {}): {
payments,
qr,
credits,
apiTokens,
mailer: deps.mailer,
}),
);
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
@ -171,7 +393,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ ok: true, role: 'zappier-edge' });
});
app.use('/v1', apiKeyAuth(customers));
app.use('/v1', apiKeyAuth(customers, apiTokens));
app.use(
OpenApiValidator.middleware({
apiSpec: SPEC_PATH,
@ -181,39 +403,412 @@ export function buildApp(deps: AppDeps = {}): {
}),
);
app.get('/v1/status', meter('status', usage, pricing, onUsage), (req, res) => {
app.get('/v1/status', meter('status', usage, pricing, onUsage, customers, deps.channelRates), (req, res) => {
res.json({ status: 'ok', quote: res.locals.quote });
});
app.post('/v1/transform', meter('transform', usage, pricing, onUsage), (req, res) => {
app.post('/v1/transform', meter('transform', usage, pricing, onUsage, customers, deps.channelRates), (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, onUsage), async (req, res) => {
if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return;
app.post('/v1/timestamp', meter('timestamp', usage, pricing, onUsage, customers, deps.channelRates), async (req, res) => {
const formats = parseResultFormats(req);
const data = req.body?.data != null ? String(req.body.data) : '';
const sha256 =
(req.body?.sha256 && String(req.body.sha256).toLowerCase()) ||
(req.body?.sha256 && String(req.body.sha256).toLowerCase().replace(/^sha256:/, '')) ||
(req.body?.digest && String(req.body.digest).toLowerCase().replace(/^sha256:/, '')) ||
(data && /^[a-f0-9]{64}$/i.test(data) ? data.toLowerCase() : '') ||
(data ? createHash('sha256').update(data, 'utf8').digest('hex') : '');
if (!sha256) {
res.status(400).json({ error: 'data or sha256 is required' });
return;
}
const documentName =
typeof req.body?.documentName === 'string'
? req.body.documentName
: typeof req.body?.filename === 'string'
? req.body.filename
: undefined;
// Preferred middleware path /api/v1/timestamp (legacy /api/timestamp still works).
const stampBody = {
data: sha256,
digest: sha256,
sha256,
hashAlg: 'SHA256',
restamp: req.body?.restamp === true,
wait: req.body?.wait !== false,
documentName,
attachPdf: req.body?.attachPdf === true,
formats: req.body?.formats,
};
let upstream = await fetchVerae(req, '/api/v1/timestamp', { body: stampBody });
if (!upstream || upstream.status === 404) {
upstream = await fetchVerae(req, '/api/timestamp', { body: stampBody });
}
if (upstream && upstream.status >= 200 && upstream.status < 300 && upstream.body && typeof upstream.body === 'object') {
const body = upstream.body as Record<string, unknown>;
const enriched = await attachRegistrationExports(body, formats, documentName);
if (formats.has('pdf') && req.query.format === 'pdf') {
const buf = Buffer.from(String(enriched.pdfBase64 || ''), 'base64');
res.setHeader('content-type', 'application/pdf');
res.setHeader(
'content-disposition',
`attachment; filename="${enriched.pdfFilename || 'verae-registration.pdf'}"`,
);
res.status(200).send(buf);
return;
}
const out: Record<string, unknown> = { ...enriched };
if (res.locals.quote) out.quote = res.locals.quote;
// Prefer 200 when wait completed with receipt_id.
const code = out.receipt_id || out.certificate ? 200 : upstream.status;
res.status(code).json(out);
return;
}
// Fail closed when upstream is configured (or mock not explicitly allowed).
if (!allowMockCerts()) {
rejectUpstreamTimestamp(res, upstream);
return;
}
// Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true
const existing = hashIndex.get(sha256);
if (existing) {
res.status(202).json({ jobId: existing.jobId, sha256, existing: true, timestamp: existing.timestamp });
const mockBody = {
jobId: existing.jobId,
sha256,
existing: true,
status: 'completed',
receipt_id: `mock-cert-${existing.jobId}`,
certificate: `mock-cert-${existing.jobId}`,
created_at: existing.timestamp,
timestamp: existing.timestamp,
merkle: '42',
scope: 'global',
};
res.status(200).json(await attachRegistrationExports(mockBody, formats, documentName));
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 });
hashIndex.set(sha256, { jobId, sha256, data, timestamp });
jobIndex.set(jobId, { jobId, sha256, data, timestamp });
const mockBody = {
jobId,
sha256,
existing: false,
status: 'completed',
receipt_id: `mock-cert-${jobId}`,
certificate: `mock-cert-${jobId}`,
created_at: timestamp,
timestamp,
merkle: '42',
scope: 'global',
};
res.status(200).json(await attachRegistrationExports(mockBody, formats, documentName));
});
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing, onUsage), async (req, res) => {
/**
* Batch timestamps — Verae Time multihash submit `POST /api/batch/timestamp`
* (https://api.veraetime.net/docs/swagger/index.html). Digests only (MCP shape).
*
* Upstream item: { data, digest, hashAlg: "SHA256", restamp: false } → { jobIds }
* Edge accepts sha256s[] (WASM/Zapier) or items[] with data/digest hex.
*/
app.post('/v1/timestamps', async (req, res) => {
const customer = req.customer;
if (!customer) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const HEX64 = /^[a-f0-9]{64}$/;
const normalizeDigest = (raw: string): string | null => {
const hex = String(raw).toLowerCase().replace(/^sha256:/, '').trim();
return HEX64.test(hex) ? hex : null;
};
/** MCP / middleware digests-only wire to api.veraetime.net */
type VeraeItem = {
data: string;
digest: string;
hashAlg: string;
restamp: boolean;
};
const veraeItems: VeraeItem[] = [];
const digests: string[] = [];
const asItems = req.body?.items;
const asSha = req.body?.sha256s;
if (Array.isArray(asItems) && asItems.length) {
for (const raw of asItems) {
if (!raw || typeof raw !== 'object') {
res.status(400).json({
error: 'items[] entries require data or digest (Verae BatchTimestampRequest)',
});
return;
}
const row = raw as {
data?: string;
digest?: string;
sha256?: string;
hashAlg?: string;
restamp?: boolean;
};
const fromDigest =
normalizeDigest(row.digest ?? '') ||
normalizeDigest(row.sha256 ?? '') ||
normalizeDigest(row.data ?? '');
if (!fromDigest) {
res.status(400).json({
error:
'items[] must carry a 64-hex SHA-256 in digest/sha256/data (hash locally; digests only)',
});
return;
}
const hashAlg = row.hashAlg || 'SHA256';
const restamp = row.restamp === true;
veraeItems.push({
data: fromDigest,
digest: fromDigest,
hashAlg,
restamp,
});
digests.push(fromDigest);
}
} else if (Array.isArray(asSha) && asSha.length) {
for (const raw of asSha) {
const hex = normalizeDigest(String(raw));
if (!hex) {
res.status(400).json({ error: `invalid sha256: ${String(raw).slice(0, 20)}…` });
return;
}
// Hash locally / stamp remotely — same as MCP stamp { sha256 } → global body.
veraeItems.push({
data: hex,
digest: hex,
hashAlg: 'SHA256',
restamp: false,
});
digests.push(hex);
}
} else {
res.status(400).json({
error:
'Provide sha256s: string[] or items: [{ digest|sha256|data (64-hex), hashAlg?, restamp? }], max 500',
});
return;
}
if (veraeItems.length > 500) {
res.status(400).json({ error: 'max 500 hashes per batch' });
return;
}
const parent = customer.parentId
? await customers.findById(customer.parentId)
: customer;
const billParent = parent ?? customer;
const channel = resolveBillingChannel(req);
const rateTable = deps.channelRates?.load();
const segment = (billParent.segment ?? customer.segment ?? 'individual') as
| 'individual'
| 'team'
| 'enterprise'
| 'purchase_order';
const stampCredits = rateTable
? lookupSkuCredits(rateTable, channel, segment, 'timestamp')
: 10;
const formats = parseResultFormats(req);
const wait = req.body?.wait !== false;
const upstreamBody = {
items: veraeItems,
wait,
attachPdf: req.body?.attachPdf === true,
formats: req.body?.formats,
};
// Preferred /api/v1/batch/timestamp; fall back to legacy /api/batch/timestamp.
let upstream = await fetchVerae(req, '/api/v1/batch/timestamp', {
body: upstreamBody,
});
if (!upstream || upstream.status === 404) {
upstream = await fetchVerae(req, '/api/batch/timestamp', { body: upstreamBody });
}
if (upstream && upstream.status >= 200 && upstream.status < 300) {
const ubody = (upstream.body && typeof upstream.body === 'object'
? (upstream.body as Record<string, unknown>)
: {}) as {
jobIds?: string[];
results?: Array<Record<string, unknown>>;
};
const jobIds = Array.isArray(ubody.jobIds) ? ubody.jobIds : [];
const upstreamResults = Array.isArray(ubody.results) ? ubody.results : [];
const results: Record<string, unknown>[] = [];
let created = 0;
let skippedExisting = 0;
let charged = 0;
const now = new Date().toISOString();
for (let i = 0; i < digests.length; i++) {
const sha256 = digests[i];
const ur = upstreamResults[i] || {};
const jobId = String(ur.jobId || jobIds[i] || randomUUID());
const prior = hashIndex.get(sha256);
const completedAt = String(ur.created_at || ur.timestamp || now);
if (prior || ur.existing === true) {
skippedExisting += 1;
const base = {
sha256,
jobId: prior?.jobId || jobId,
existing: true,
timestamp: prior?.timestamp || completedAt,
created_at: prior?.timestamp || completedAt,
receipt_id: ur.receipt_id || ur.certificate,
certificate: ur.certificate || ur.receipt_id,
merkle: ur.merkle,
status: ur.status || 'completed',
scope: 'global',
credits: 0,
};
results.push(await attachRegistrationExports(base, formats));
continue;
}
hashIndex.set(sha256, { jobId, sha256, timestamp: completedAt });
jobIndex.set(jobId, { jobId, sha256, timestamp: completedAt });
await usage.record({
customerId: billingCustomerId(customer),
endpointId: 'timestamp',
cents: stampCredits,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(),
});
onUsage({
customerId: billingCustomerId(customer),
endpointId: 'timestamp',
cents: stampCredits,
actorId: customer.id !== billingCustomerId(customer) ? customer.id : undefined,
});
charged += stampCredits;
created += 1;
const base = {
sha256,
jobId,
existing: false,
timestamp: completedAt,
created_at: completedAt,
receipt_id: ur.receipt_id || ur.certificate,
certificate: ur.certificate || ur.receipt_id,
merkle: ur.merkle,
status: ur.status || 'completed',
scope: 'global',
credits: stampCredits,
};
results.push(await attachRegistrationExports(base, formats));
}
const period = await usage.summaryFor(billingCustomerId(customer));
setBillingHeaders(res, {
spendCredits: charged,
balanceCredits: billParent.balanceCents ?? customer.balanceCents ?? 0,
periodSpendCredits: period.totalCents,
channel,
});
res.status(upstream.status).json({
...ubody,
jobIds: jobIds.length ? jobIds : results.map((r) => String(r.jobId)),
channel,
count: results.length,
created,
existing: skippedExisting,
totalCredits: charged,
balanceCredits: billParent.balanceCents ?? customer.balanceCents ?? 0,
results,
});
return;
}
// Fail closed when upstream is configured (or mock not explicitly allowed).
if (!allowMockCerts()) {
rejectUpstreamTimestamp(res, upstream);
return;
}
// Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true
const jobIds: string[] = [];
const results: {
sha256: string;
jobId: string;
existing: boolean;
timestamp: string;
credits: number;
}[] = [];
let created = 0;
let skippedExisting = 0;
let totalCredits = 0;
for (let i = 0; i < digests.length; i++) {
const sha256 = digests[i];
const existing = hashIndex.get(sha256);
if (existing) {
skippedExisting += 1;
jobIds.push(existing.jobId);
results.push({
sha256,
jobId: existing.jobId,
existing: true,
timestamp: existing.timestamp,
credits: 0,
});
continue;
}
const jobId = randomUUID();
const timestamp = new Date().toISOString();
hashIndex.set(sha256, { jobId, sha256, timestamp });
jobIndex.set(jobId, { jobId, sha256, timestamp });
await usage.record({
customerId: billingCustomerId(customer),
endpointId: 'timestamp',
cents: stampCredits,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(),
});
onUsage({
customerId: billingCustomerId(customer),
endpointId: 'timestamp',
cents: stampCredits,
actorId: customer.id !== billingCustomerId(customer) ? customer.id : undefined,
});
jobIds.push(jobId);
totalCredits += stampCredits;
created += 1;
results.push({ sha256, jobId, existing: false, timestamp, credits: stampCredits });
}
const periodMock = await usage.summaryFor(billingCustomerId(customer));
setBillingHeaders(res, {
spendCredits: totalCredits,
balanceCredits: billParent.balanceCents ?? customer.balanceCents ?? 0,
periodSpendCredits: periodMock.totalCents,
channel,
});
res.status(202).json({
// Verae BatchTimestampResponse
jobIds,
// Edge extras for Zapier / portal
channel,
count: results.length,
created,
existing: skippedExisting,
totalCredits,
balanceCredits: billParent.balanceCents ?? customer.balanceCents ?? 0,
results,
});
});
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing, onUsage, customers, deps.channelRates), 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) {
@ -231,7 +826,7 @@ export function buildApp(deps: AppDeps = {}): {
});
});
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing, onUsage), async (req, res) => {
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing, onUsage, customers, deps.channelRates), 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);
@ -242,7 +837,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ exists: true, ...rec });
});
app.post('/v1/add', meter('add', usage, pricing, onUsage), (req, res) => {
app.post('/v1/add', meter('add', usage, pricing, onUsage, customers, deps.channelRates), (req, res) => {
const number1 = Number(req.body?.number1);
const number2 = Number(req.body?.number2);
if (!Number.isFinite(number1) || !Number.isFinite(number2)) {
@ -252,7 +847,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ number1, number2, sum: number1 + number2, quote: res.locals.quote });
});
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing, onUsage), (req, res) => {
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing, onUsage, customers, deps.channelRates), (req, res) => {
const metadata = res.locals.parsedMetadata as Record<string, unknown>;
const files = (req.files as Express.Multer.File[]) ?? [];
const item: StoredItem = {
@ -266,17 +861,24 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ id: item.id, quote: res.locals.quote });
});
app.get('/v1/storage', meter('storage-list', usage, pricing, onUsage), (req, res) => {
app.get('/v1/storage', meter('storage-list', usage, pricing, onUsage, customers, deps.channelRates), (req, res) => {
res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
});
app.get('/v1/usage', (req, res) => {
app.get('/v1/usage', async (req, res) => {
const since = new Date();
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
const summary = usage.summaryFor(req.customer!.id, since);
const billedTo = billingCustomerId(req.customer!);
const summary = await usage.summaryFor(billedTo, since);
const tier = pricing.tiers.find(req.customer!.tierId);
res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
const body = tier ? applyMonthlyCredit(summary, tier) : summary;
res.json({
...body,
billedTo,
actorId: req.customer!.id,
storageMode: req.customer!.storageMode ?? 'hosted',
});
});
app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => {

View file

@ -1,12 +1,34 @@
import { RequestHandler } from 'express';
import { ApiTokenRepo, isXVeraeToken } from './db/api-token-repo';
export type BillingType = 'stripe' | 'purchase_order';
/** Hosted = Verae Seaweed S3; byo = customer passthrough endpoint ($0.01/GB-month). */
export type StorageMode = 'hosted' | 'byo';
export type AccountKind = 'org' | 'peergos_user';
/** none = retainer+overage or BYO ¢/GB; compliance modes bill full retained volume per increment. */
export type RetentionBilling = 'none' | 'verae_compliance' | 'amazon_passthrough';
export type RetentionIncrement = 'month' | 'quarter';
/** Commercial segment — sets default Zapier/MCP/API SKU credits (sales-owned). */
export type CustomerSegment =
| 'individual'
| 'team'
| 'enterprise'
| 'purchase_order';
export interface Customer {
id: string;
name: string;
tierId: string;
apiKey: string;
/**
* Sales segment for channel rate lookup. Absent → `individual`.
* Tier (`tierId`) controls included stamps/GB only.
*/
segment?: CustomerSegment;
stripeCustomerId?: string;
multiplierOverride?: number;
/** How this customer is billed. Absent means 'stripe' (the default). */
@ -25,13 +47,40 @@ export interface Customer {
balanceCents?: number;
/** Email a copy of each issued invoice to the customer's address. */
emailInvoicing?: boolean;
/**
* Fleet / Peergos hierarchy:
* - org: cust-platform (shared S3) or cust-NN (distinct S3); parentId undefined
* - peergos_user: parentId = billing parent; usage attributed under parent
*/
parentId?: string;
/** Default hosted. Children inherit parent's mode for storage quoting. */
storageMode?: StorageMode;
accountKind?: AccountKind;
/** Distinct-S3 cust-NN only. */
s3Endpoint?: string;
s3Bucket?: string;
s3Region?: string;
includedStorageGbOverride?: number;
storageOveragePerGbMillicentsOverride?: number;
timestampFixedCentsOverride?: number;
retentionBilling?: RetentionBilling;
retentionIncrement?: RetentionIncrement;
/** ISO date when current retention window ends. */
retentionUntil?: string;
/** Last reported Amazon fee slice (cents) for passthrough markup. */
amazonPassthroughFeesCents?: number;
}
/** Sync or async (libsql BusinessStore adapters). Call sites should `await`. */
export type MaybePromise<T> = T | Promise<T>;
export interface CustomerRepo {
findByApiKey(apiKey: string): Customer | undefined;
findByEmail(email: string): Customer | undefined;
list(): Customer[];
save(customer: Customer): void;
findByApiKey(apiKey: string): MaybePromise<Customer | undefined>;
findByEmail(email: string): MaybePromise<Customer | undefined>;
findById(id: string): MaybePromise<Customer | undefined>;
list(): MaybePromise<Customer[]>;
listChildren(parentId: string): MaybePromise<Customer[]>;
save(customer: Customer): MaybePromise<void>;
}
export class InMemoryCustomerRepo implements CustomerRepo {
@ -52,10 +101,18 @@ export class InMemoryCustomerRepo implements CustomerRepo {
return this.customers.find((c) => c.email?.toLowerCase() === needle);
}
findById(id: string): Customer | undefined {
return this.customers.find((c) => c.id === id);
}
list(): Customer[] {
return [...this.customers];
}
listChildren(parentId: string): Customer[] {
return this.customers.filter((c) => c.parentId === parentId);
}
save(customer: Customer): void {
const i = this.customers.findIndex((c) => c.id === customer.id);
if (i >= 0) this.customers[i] = customer;
@ -72,15 +129,37 @@ declare global {
}
}
export function apiKeyAuth(repo: CustomerRepo): RequestHandler {
/**
* Resolve customer from `x-api-key` or `Authorization: Bearer …`.
* Accepts legacy `key-…` customer keys and long-lived `xVerae-…` tokens.
*/
export function apiKeyAuth(repo: CustomerRepo, apiTokens?: ApiTokenRepo): 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();
void (async () => {
const headerKey = req.header('x-api-key')?.trim();
const auth = req.header('authorization')?.trim();
const bearer =
auth && /^Bearer\s+/i.test(auth) ? auth.replace(/^Bearer\s+/i, '').trim() : undefined;
const key = headerKey || bearer;
if (!key) {
res.status(401).json({ error: 'invalid or missing API key' });
return;
}
let customer = await repo.findByApiKey(key);
if (!customer && apiTokens && isXVeraeToken(key)) {
const customerId = apiTokens.findCustomerIdByToken(key);
if (customerId) {
customer = await repo.findById(customerId);
if (customer) apiTokens.touchLastUsed(key);
}
}
if (!customer) {
res.status(401).json({ error: 'invalid or missing API key' });
return;
}
req.customer = customer;
next();
})().catch(next);
};
}

View file

@ -0,0 +1,27 @@
import { Request } from 'express';
import { BillingChannel } from './billing-channels';
/**
* Resolve which sales rate card channel a request should bill against.
*
* Priority:
* 1. `x-billing-channel` header (zapier | mcp | api)
* 2. `channel` query param
* 3. Heuristics (Zapier UA → zapier; MCP UA / path → mcp)
* 4. Default `api`
*/
export function resolveBillingChannel(req: Request): BillingChannel {
const raw =
String(req.headers['x-billing-channel'] || '').trim().toLowerCase() ||
String(req.query.channel || '').trim().toLowerCase();
if (raw === 'zapier' || raw === 'mcp' || raw === 'api') return raw;
const ua = String(req.headers['user-agent'] || '').toLowerCase();
if (ua.includes('zapier')) return 'zapier';
if (ua.includes('mcp') || ua.includes('modelcontextprotocol')) return 'mcp';
const path = String(req.path || req.url || '');
if (path.includes('/mcp')) return 'mcp';
return 'api';
}

108
src/billing-channels.ts Normal file
View file

@ -0,0 +1,108 @@
/**
* Separate billing channels. Sales owns live rates per segment × channel × SKU.
* Tier only controls included volume (stamps / GB), not list credits.
*/
export type BillingChannel = 'zapier' | 'mcp' | 'api';
export type CustomerSegment =
| 'individual'
| 'team'
| 'enterprise'
| 'purchase_order';
export const BILLING_CHANNELS: BillingChannel[] = ['zapier', 'mcp', 'api'];
export const CUSTOMER_SEGMENTS: CustomerSegment[] = [
'individual',
'team',
'enterprise',
'purchase_order',
];
/** Stamp / storage SKUs quoted in credits. */
export type CreditSku =
| 'timestamp'
| 'timestamp-metadata'
| 'timestamp-attachment'
| 'timestamp-re'
| 'timestamp-metadata-update'
| 'timestamp-verify'
| 'storage-gb-month-byo'
| 'data-charge-storage'
| 'data-charge-retrieval';
export const CREDIT_SKUS: CreditSku[] = [
'timestamp',
'timestamp-metadata',
'timestamp-attachment',
'timestamp-re',
'timestamp-metadata-update',
'timestamp-verify',
'storage-gb-month-byo',
'data-charge-storage',
'data-charge-retrieval',
];
/** Engineering seed defaults (credits). Sales may overwrite in admin UI. */
export const DEFAULT_CHANNEL_SKU_CREDITS: Record<CreditSku, number> = {
timestamp: 10,
'timestamp-metadata': 15,
'timestamp-attachment': 25,
'timestamp-re': 5,
'timestamp-metadata-update': 15,
'timestamp-verify': 0,
'storage-gb-month-byo': 1,
'data-charge-storage': 0, // computed from GB × pin; seed unused
'data-charge-retrieval': 0,
};
export interface ChannelRateTable {
/** channel → segment → sku → credits */
rates: Record<BillingChannel, Record<CustomerSegment, Partial<Record<CreditSku, number>>>>;
overageMultiplier: number;
prepaidOverageMultiplier: number;
updatedAt?: string;
updatedBy?: string;
}
export function emptyChannelRates(
seed: Record<CreditSku, number> = DEFAULT_CHANNEL_SKU_CREDITS,
): ChannelRateTable {
const rates = {} as ChannelRateTable['rates'];
for (const ch of BILLING_CHANNELS) {
rates[ch] = {} as Record<CustomerSegment, Partial<Record<CreditSku, number>>>;
for (const seg of CUSTOMER_SEGMENTS) {
rates[ch][seg] = { ...seed };
}
}
return {
rates,
overageMultiplier: 1.5,
prepaidOverageMultiplier: 0.75,
};
}
export function lookupSkuCredits(
table: ChannelRateTable,
channel: BillingChannel,
segment: CustomerSegment,
sku: CreditSku,
): number {
const v = table.rates[channel]?.[segment]?.[sku];
if (typeof v === 'number') return v;
return DEFAULT_CHANNEL_SKU_CREDITS[sku] ?? 0;
}
export function applyCreditMode(
listCredits: number,
mode: 'included' | 'overage' | 'prepaid_overage',
table: ChannelRateTable,
): number {
const mult =
mode === 'overage'
? table.overageMultiplier
: mode === 'prepaid_overage'
? table.prepaidOverageMultiplier
: 1;
return Math.ceil(listCredits * mult);
}

49
src/billing-headers.ts Normal file
View file

@ -0,0 +1,49 @@
import { Response } from 'express';
/**
* Standard credit account headers on every metered response.
* Product language is credits (ledger ints historically named cents).
*
* Clients (Zapier Code, browsers, SDKs) can read these without a second
* /usage round trip. CORS exposes them for browser fetch.
*/
export const BILLING_HEADER_SPEND = 'X-Verae-Credits-Spend';
export const BILLING_HEADER_BALANCE = 'X-Verae-Credits-Balance';
export const BILLING_HEADER_PERIOD_SPEND = 'X-Verae-Credits-Period-Spend';
export const BILLING_HEADER_CHANNEL = 'X-Verae-Billing-Channel';
const EXPOSE = [
BILLING_HEADER_SPEND,
BILLING_HEADER_BALANCE,
BILLING_HEADER_PERIOD_SPEND,
BILLING_HEADER_CHANNEL,
].join(', ');
export interface BillingHeaderValues {
/** Credits charged on this request (0 for free / idempotent hits). */
spendCredits: number;
/** Prepaid wallet balance in credits after this request (informational). */
balanceCredits: number;
/** Optional MTD / period usage total for the billing parent. */
periodSpendCredits?: number;
channel?: string;
}
export function setBillingHeaders(res: Response, values: BillingHeaderValues): void {
res.setHeader(BILLING_HEADER_SPEND, String(Math.max(0, Math.trunc(values.spendCredits))));
res.setHeader(BILLING_HEADER_BALANCE, String(Math.trunc(values.balanceCredits)));
if (typeof values.periodSpendCredits === 'number') {
res.setHeader(
BILLING_HEADER_PERIOD_SPEND,
String(Math.max(0, Math.trunc(values.periodSpendCredits))),
);
}
if (values.channel) {
res.setHeader(BILLING_HEADER_CHANNEL, values.channel);
}
const prev = String(res.getHeader('Access-Control-Expose-Headers') || '');
const merged = prev
? Array.from(new Set([...prev.split(',').map((s) => s.trim()).filter(Boolean), ...EXPOSE.split(', ')])).join(', ')
: EXPOSE;
res.setHeader('Access-Control-Expose-Headers', merged);
}

View file

@ -1,3 +1,11 @@
import type Database from 'better-sqlite3';
import {
ensureCreditAdjustmentsSchema,
insertCreditAdjustment,
listCreditAdjustments,
type CreditAdjustmentRow,
} from './db/credit-adjustments';
export interface CreditAdjustment {
id: string;
customerId: string;
@ -7,7 +15,13 @@ export interface CreditAdjustment {
at: string;
}
export class CreditLedger {
export type CreditLedgerLike = {
add(row: Omit<CreditAdjustment, 'id' | 'at'> & { id?: string; at?: string }): CreditAdjustment;
list(customerId?: string): CreditAdjustment[];
};
/** In-memory ledger (tests / when no billing.db handle). */
export class CreditLedger implements CreditLedgerLike {
private rows: CreditAdjustment[] = [];
add(row: Omit<CreditAdjustment, 'id' | 'at'> & { id?: string; at?: string }): CreditAdjustment {
@ -27,3 +41,27 @@ export class CreditLedger {
return customerId ? this.rows.filter((r) => r.customerId === customerId) : this.rows;
}
}
/** Durable credit ledger on billing.db `credit_adjustments` (D006). */
export class SqliteCreditLedger implements CreditLedgerLike {
constructor(private readonly db: Database.Database) {
ensureCreditAdjustmentsSchema(db);
}
add(row: Omit<CreditAdjustment, 'id' | 'at'> & { id?: string; at?: string }): CreditAdjustment {
const rec: CreditAdjustment = {
id: row.id || `crd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
customerId: row.customerId,
cents: row.cents,
reason: row.reason,
agent: row.agent,
at: row.at || new Date().toISOString(),
};
insertCreditAdjustment(this.db, rec as CreditAdjustmentRow);
return rec;
}
list(customerId?: string): CreditAdjustment[] {
return listCreditAdjustments(this.db, customerId);
}
}

196
src/db/api-token-repo.ts Normal file
View file

@ -0,0 +1,196 @@
import { createHash, randomBytes } from 'crypto';
import type Database from 'better-sqlite3';
export const XVERAE_PREFIX = 'xVerae-';
export type ApiTokenRecord = {
id: string;
customerId: string;
name: string;
/** Public prefix for display, e.g. xVerae-ab12… */
prefix: string;
createdAt: string;
lastUsedAt?: string;
expiresAt?: string;
revokedAt?: string;
};
export type CreatedApiToken = ApiTokenRecord & {
/** Full secret — returned once at creation only. */
token: string;
};
function hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
function newTokenId(): string {
return `atk_${randomBytes(8).toString('hex')}`;
}
/** Long-lived customer API token (LLM-key style). */
export function mintXVeraeToken(): { token: string; prefix: string; hash: string } {
const secret = randomBytes(32).toString('base64url');
const token = `${XVERAE_PREFIX}${secret}`;
const prefix = `${XVERAE_PREFIX}${secret.slice(0, 6)}…`;
return { token, prefix, hash: hashToken(token) };
}
export function isXVeraeToken(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith(XVERAE_PREFIX) && value.length > XVERAE_PREFIX.length + 16;
}
export interface ApiTokenRepo {
create(input: {
customerId: string;
name?: string;
expiresAt?: string | null;
}): CreatedApiToken;
list(customerId: string): ApiTokenRecord[];
revoke(customerId: string, id: string): boolean;
/** Resolve active (non-revoked, non-expired) token → customerId. */
findCustomerIdByToken(token: string): string | undefined;
touchLastUsed(token: string): void;
}
export class InMemoryApiTokenRepo implements ApiTokenRepo {
private rows: Array<ApiTokenRecord & { hash: string }> = [];
create(input: { customerId: string; name?: string; expiresAt?: string | null }): CreatedApiToken {
const minted = mintXVeraeToken();
const rec: ApiTokenRecord & { hash: string } = {
id: newTokenId(),
customerId: input.customerId,
name: (input.name || 'default').slice(0, 120),
prefix: minted.prefix,
createdAt: new Date().toISOString(),
expiresAt: input.expiresAt || undefined,
hash: minted.hash,
};
this.rows.unshift(rec);
return { ...rec, token: minted.token };
}
list(customerId: string): ApiTokenRecord[] {
return this.rows
.filter((r) => r.customerId === customerId && !r.revokedAt)
.map(({ hash: _h, ...rest }) => rest);
}
revoke(customerId: string, id: string): boolean {
const row = this.rows.find((r) => r.id === id && r.customerId === customerId && !r.revokedAt);
if (!row) return false;
row.revokedAt = new Date().toISOString();
return true;
}
findCustomerIdByToken(token: string): string | undefined {
if (!isXVeraeToken(token)) return undefined;
const h = hashToken(token);
const now = Date.now();
const row = this.rows.find((r) => r.hash === h && !r.revokedAt);
if (!row) return undefined;
if (row.expiresAt && Date.parse(row.expiresAt) < now) return undefined;
return row.customerId;
}
touchLastUsed(token: string): void {
const h = hashToken(token);
const row = this.rows.find((r) => r.hash === h);
if (row) row.lastUsedAt = new Date().toISOString();
}
}
export class SqliteApiTokenRepo implements ApiTokenRepo {
constructor(private readonly db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
name TEXT NOT NULL,
prefix TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_ms INTEGER NOT NULL,
last_used_ms INTEGER,
expires_ms INTEGER,
revoked_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_customer ON api_tokens(customer_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
`);
}
create(input: { customerId: string; name?: string; expiresAt?: string | null }): CreatedApiToken {
const minted = mintXVeraeToken();
const id = newTokenId();
const createdMs = Date.now();
const expiresMs = input.expiresAt ? Date.parse(input.expiresAt) : null;
const name = (input.name || 'default').slice(0, 120);
this.db
.prepare(
`INSERT INTO api_tokens
(id, customer_id, name, prefix, token_hash, created_ms, expires_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(id, input.customerId, name, minted.prefix, minted.hash, createdMs, expiresMs);
return {
id,
customerId: input.customerId,
name,
prefix: minted.prefix,
createdAt: new Date(createdMs).toISOString(),
expiresAt: expiresMs ? new Date(expiresMs).toISOString() : undefined,
token: minted.token,
};
}
list(customerId: string): ApiTokenRecord[] {
const rows = this.db
.prepare(
`SELECT id, customer_id, name, prefix, created_ms, last_used_ms, expires_ms, revoked_ms
FROM api_tokens
WHERE customer_id = ? AND revoked_ms IS NULL
ORDER BY created_ms DESC`,
)
.all(customerId) as Record<string, unknown>[];
return rows.map((r) => ({
id: String(r.id),
customerId: String(r.customer_id),
name: String(r.name),
prefix: String(r.prefix),
createdAt: new Date(Number(r.created_ms)).toISOString(),
lastUsedAt: r.last_used_ms ? new Date(Number(r.last_used_ms)).toISOString() : undefined,
expiresAt: r.expires_ms ? new Date(Number(r.expires_ms)).toISOString() : undefined,
}));
}
revoke(customerId: string, id: string): boolean {
const info = this.db
.prepare(
`UPDATE api_tokens SET revoked_ms = ?
WHERE id = ? AND customer_id = ? AND revoked_ms IS NULL`,
)
.run(Date.now(), id, customerId);
return info.changes > 0;
}
findCustomerIdByToken(token: string): string | undefined {
if (!isXVeraeToken(token)) return undefined;
const h = hashToken(token);
const row = this.db
.prepare(
`SELECT customer_id, expires_ms, revoked_ms FROM api_tokens WHERE token_hash = ?`,
)
.get(h) as { customer_id: string; expires_ms: number | null; revoked_ms: number | null } | undefined;
if (!row || row.revoked_ms) return undefined;
if (row.expires_ms && row.expires_ms < Date.now()) return undefined;
return row.customer_id;
}
touchLastUsed(token: string): void {
if (!isXVeraeToken(token)) return;
this.db
.prepare(`UPDATE api_tokens SET last_used_ms = ? WHERE token_hash = ? AND revoked_ms IS NULL`)
.run(Date.now(), hashToken(token));
}
}

View file

@ -0,0 +1,86 @@
import { Customer, CustomerRepo } from '../auth';
import type { InvoiceRepo } from '../invoicing';
import type { PricingStore } from '../pricing';
import { UsageEntry, UsageRepo, UsageSummary } from '../usage';
import type { BusinessStore } from './business-store';
import type { SqliteChannelRateStore } from './channel-rate-store';
import type { ChainVerifyResult } from './ledger-chain';
import type { SqliteFileBusinessStore } from './sqlite-file-business-store';
/**
* Sync repos over SqliteFileBusinessStore's same BILLING_DB connection (D006 / WP-V2).
* Production default path (BUSINESS_STORE=sqlite-file).
*/
export function syncReposFromSqliteFile(store: SqliteFileBusinessStore): {
customers: CustomerRepo;
usage: UsageRepo & { verifyChain: () => ChainVerifyResult };
pricingStore: PricingStore;
channelRates: SqliteChannelRateStore;
invoices: InvoiceRepo;
} {
return {
customers: store.customerRepo,
usage: store.usageRepo,
pricingStore: store.pricingStore,
channelRates: store.channelRates,
invoices: store.invoices,
};
}
/**
* Async CustomerRepo backed by BusinessStore (libsql driver).
* Call sites must `await` (MaybePromise). findByEmail / listChildren filter listCustomers.
*/
export class BusinessStoreCustomerAdapter implements CustomerRepo {
constructor(private store: BusinessStore) {}
findByApiKey(apiKey: string) {
return this.store.findCustomerByApiKey(apiKey);
}
async findByEmail(email: string) {
const needle = email.toLowerCase();
const all = await this.store.listCustomers();
return all.find((c) => c.email?.toLowerCase() === needle);
}
findById(id: string) {
return this.store.findCustomerById(id);
}
list() {
return this.store.listCustomers();
}
async listChildren(parentId: string) {
const all = await this.store.listCustomers();
return all.filter((c) => c.parentId === parentId);
}
save(customer: Customer) {
return this.store.saveCustomer(customer);
}
}
/**
* Async UsageRepo backed by BusinessStore (libsql driver).
*/
export class BusinessStoreUsageAdapter implements UsageRepo {
constructor(private store: BusinessStore) {}
record(entry: UsageEntry) {
return this.store.recordUsage(entry);
}
listFor(customerId: string, since?: Date) {
return this.store.listUsage(customerId, since);
}
async summaryFor(customerId: string, since?: Date): Promise<UsageSummary> {
return this.store.summaryUsage(customerId, since);
}
verifyChain() {
return this.store.verifyUsageChain();
}
}

28
src/db/business-store.ts Normal file
View file

@ -0,0 +1,28 @@
import type { Customer } from '../auth';
import type { UsageEntry, UsageSummary } from '../usage';
import type { ChainVerifyResult } from './ledger-chain';
/**
* Shared business SoT (D006). Prod = libSQL; lab/dev may use SQLite file adapter.
* Methods are async so LibsqlBusinessStore (@libsql/client) and SqliteFile share one contract.
*/
export interface BusinessStore {
findCustomerByApiKey(apiKey: string): Promise<Customer | undefined>;
findCustomerById(id: string): Promise<Customer | undefined>;
listCustomers(): Promise<Customer[]>;
saveCustomer(customer: Customer): Promise<void>;
getBalanceCredits(customerId: string): Promise<number>;
adjustBalanceCredits(
customerId: string,
deltaCredits: number,
reason: string,
): Promise<number>;
recordUsage(entry: UsageEntry): Promise<void>;
listUsage(customerId: string, since?: Date): Promise<UsageEntry[]>;
summaryUsage(customerId: string, since?: Date): Promise<UsageSummary>;
verifyUsageChain(): Promise<ChainVerifyResult>;
}
export type BusinessStoreDriver = 'sqlite-file' | 'libsql';

View file

@ -0,0 +1,114 @@
import Database from 'better-sqlite3';
import {
BILLING_CHANNELS,
ChannelRateTable,
CreditSku,
CUSTOMER_SEGMENTS,
CustomerSegment,
BillingChannel,
emptyChannelRates,
} from '../billing-channels';
/**
* Persists sales-editable channel × segment × sku credit rates.
*/
export class SqliteChannelRateStore {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS channel_rates (
channel TEXT NOT NULL,
segment TEXT NOT NULL,
sku TEXT NOT NULL,
credits INTEGER NOT NULL,
PRIMARY KEY (channel, segment, sku)
);
CREATE TABLE IF NOT EXISTS channel_rate_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
overage_multiplier REAL NOT NULL,
prepaid_overage_multiplier REAL NOT NULL,
updated_at TEXT,
updated_by TEXT
);
`);
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM channel_rates').get() as {
n: number;
};
if (n === 0) {
this.save(emptyChannelRates());
}
}
load(): ChannelRateTable {
const table = emptyChannelRates();
const rows = this.db
.prepare('SELECT channel, segment, sku, credits FROM channel_rates')
.all() as { channel: string; segment: string; sku: string; credits: number }[];
for (const r of rows) {
const ch = r.channel as BillingChannel;
const seg = r.segment as CustomerSegment;
if (!table.rates[ch]) continue;
if (!table.rates[ch][seg]) table.rates[ch][seg] = {};
table.rates[ch][seg][r.sku as CreditSku] = r.credits;
}
const meta = this.db.prepare('SELECT * FROM channel_rate_meta WHERE id = 1').get() as
| Record<string, unknown>
| undefined;
if (meta) {
table.overageMultiplier = meta.overage_multiplier as number;
table.prepaidOverageMultiplier = meta.prepaid_overage_multiplier as number;
table.updatedAt = (meta.updated_at as string | null) ?? undefined;
table.updatedBy = (meta.updated_by as string | null) ?? undefined;
}
return table;
}
save(table: ChannelRateTable, updatedBy?: string): void {
const upsert = this.db.prepare(
`INSERT INTO channel_rates (channel, segment, sku, credits) VALUES (?, ?, ?, ?)
ON CONFLICT(channel, segment, sku) DO UPDATE SET credits = excluded.credits`,
);
const tx = this.db.transaction(() => {
for (const ch of BILLING_CHANNELS) {
for (const seg of CUSTOMER_SEGMENTS) {
const skus = table.rates[ch]?.[seg] ?? {};
for (const [sku, credits] of Object.entries(skus)) {
if (typeof credits !== 'number') continue;
upsert.run(ch, seg, sku, credits);
}
}
}
this.db
.prepare(
`INSERT INTO channel_rate_meta (id, overage_multiplier, prepaid_overage_multiplier, updated_at, updated_by)
VALUES (1, @o, @p, @at, @by)
ON CONFLICT(id) DO UPDATE SET
overage_multiplier = @o,
prepaid_overage_multiplier = @p,
updated_at = @at,
updated_by = @by`,
)
.run({
o: table.overageMultiplier,
p: table.prepaidOverageMultiplier,
at: new Date().toISOString(),
by: updatedBy ?? table.updatedBy ?? null,
});
});
tx();
}
setSku(
channel: BillingChannel,
segment: CustomerSegment,
sku: CreditSku,
credits: number,
updatedBy?: string,
): ChannelRateTable {
const table = this.load();
if (!table.rates[channel]) table.rates[channel] = {} as ChannelRateTable['rates'][BillingChannel];
if (!table.rates[channel][segment]) table.rates[channel][segment] = {};
table.rates[channel][segment][sku] = credits;
this.save(table, updatedBy);
return this.load();
}
}

View file

@ -0,0 +1,80 @@
import type Database from 'better-sqlite3';
/**
* Durable CS/prepaid credit ledger rows on billing.db (D006 SoT).
* Written by SqliteCreditLedger and BusinessStore.adjustBalanceCredits.
*/
export const CREDIT_ADJUSTMENTS_DDL = `
CREATE TABLE IF NOT EXISTS credit_adjustments (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
cents INTEGER NOT NULL,
reason TEXT NOT NULL,
agent TEXT NOT NULL,
at_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credit_adjustments_customer
ON credit_adjustments(customer_id, at_ms DESC);
`;
export function ensureCreditAdjustmentsSchema(db: Database.Database): void {
db.exec(CREDIT_ADJUSTMENTS_DDL);
}
export type CreditAdjustmentRow = {
id: string;
customerId: string;
cents: number;
reason: string;
agent: string;
at: string;
};
export function insertCreditAdjustment(
db: Database.Database,
row: CreditAdjustmentRow,
): void {
ensureCreditAdjustmentsSchema(db);
db.prepare(
`INSERT INTO credit_adjustments (id, customer_id, cents, reason, agent, at_ms)
VALUES (@id, @customerId, @cents, @reason, @agent, @atMs)`,
).run({
id: row.id,
customerId: row.customerId,
cents: row.cents,
reason: row.reason,
agent: row.agent,
atMs: Date.parse(row.at) || Date.now(),
});
}
export function listCreditAdjustments(
db: Database.Database,
customerId?: string,
): CreditAdjustmentRow[] {
ensureCreditAdjustmentsSchema(db);
const rows = customerId
? db
.prepare(
`SELECT id, customer_id, cents, reason, agent, at_ms
FROM credit_adjustments
WHERE customer_id = ?
ORDER BY at_ms DESC, id DESC`,
)
.all(customerId)
: db
.prepare(
`SELECT id, customer_id, cents, reason, agent, at_ms
FROM credit_adjustments
ORDER BY at_ms DESC, id DESC`,
)
.all();
return (rows as Record<string, unknown>[]).map((r) => ({
id: String(r.id),
customerId: String(r.customer_id),
cents: Number(r.cents),
reason: String(r.reason),
agent: String(r.agent),
at: new Date(Number(r.at_ms)).toISOString(),
}));
}

View file

@ -1,5 +1,14 @@
import Database from 'better-sqlite3';
import { Customer, CustomerRepo } from '../auth';
import {
AccountKind,
BillingType,
Customer,
CustomerRepo,
CustomerSegment,
RetentionBilling,
RetentionIncrement,
StorageMode,
} from '../auth';
export class SqliteCustomerRepo implements CustomerRepo {
constructor(private db: Database.Database, seed: Customer[] = []) {
@ -21,6 +30,25 @@ export class SqliteCustomerRepo implements CustomerRepo {
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');
this.ensureColumn('verae_user_id', 'verae_user_id TEXT');
this.ensureColumn('verae_username', 'verae_username TEXT');
this.ensureColumn('parent_id', 'parent_id TEXT');
this.ensureColumn('storage_mode', `storage_mode TEXT NOT NULL DEFAULT 'hosted'`);
this.ensureColumn('account_kind', `account_kind TEXT NOT NULL DEFAULT 'org'`);
this.ensureColumn('s3_endpoint', 's3_endpoint TEXT');
this.ensureColumn('s3_bucket', 's3_bucket TEXT');
this.ensureColumn('s3_region', 's3_region TEXT');
this.ensureColumn('included_storage_gb_override', 'included_storage_gb_override REAL');
this.ensureColumn(
'storage_overage_per_gb_millicents_override',
'storage_overage_per_gb_millicents_override INTEGER',
);
this.ensureColumn('timestamp_fixed_cents_override', 'timestamp_fixed_cents_override INTEGER');
this.ensureColumn('retention_billing', `retention_billing TEXT NOT NULL DEFAULT 'none'`);
this.ensureColumn('retention_increment', 'retention_increment TEXT');
this.ensureColumn('retention_until', 'retention_until TEXT');
this.ensureColumn('amazon_passthrough_fees_cents', 'amazon_passthrough_fees_cents INTEGER');
this.ensureColumn('segment', `segment TEXT NOT NULL DEFAULT 'individual'`);
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);
@ -48,6 +76,13 @@ export class SqliteCustomerRepo implements CustomerRepo {
return r ? toCustomer(r) : undefined;
}
findById(id: string): Customer | undefined {
const r = this.db.prepare('SELECT * FROM customers WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined;
return r ? toCustomer(r) : undefined;
}
list(): Customer[] {
const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
string,
@ -56,11 +91,35 @@ export class SqliteCustomerRepo implements CustomerRepo {
return rows.map(toCustomer);
}
listChildren(parentId: string): Customer[] {
const rows = this.db
.prepare('SELECT * FROM customers WHERE parent_id = ? ORDER BY id')
.all(parentId) 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)
`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, verae_user_id, verae_username,
parent_id, storage_mode, account_kind,
s3_endpoint, s3_bucket, s3_region,
included_storage_gb_override, storage_overage_per_gb_millicents_override,
timestamp_fixed_cents_override, retention_billing, retention_increment,
retention_until, amazon_passthrough_fees_cents, segment
) VALUES (
@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride,
@billingType, @email, @passwordHash, @totpSecret, @totpEnabled,
@balanceCents, @emailInvoicing, @veraeUserId, @veraeUsername,
@parentId, @storageMode, @accountKind,
@s3Endpoint, @s3Bucket, @s3Region,
@includedStorageGbOverride, @storageOveragePerGbMillicentsOverride,
@timestampFixedCentsOverride, @retentionBilling, @retentionIncrement,
@retentionUntil, @amazonPassthroughFeesCents, @segment
)
ON CONFLICT(id) DO UPDATE SET
name = @name,
tier_id = @tierId,
@ -73,7 +132,23 @@ export class SqliteCustomerRepo implements CustomerRepo {
totp_secret = @totpSecret,
totp_enabled = @totpEnabled,
balance_cents = @balanceCents,
email_invoicing = @emailInvoicing`,
email_invoicing = @emailInvoicing,
verae_user_id = @veraeUserId,
verae_username = @veraeUsername,
parent_id = @parentId,
storage_mode = @storageMode,
account_kind = @accountKind,
s3_endpoint = @s3Endpoint,
s3_bucket = @s3Bucket,
s3_region = @s3Region,
included_storage_gb_override = @includedStorageGbOverride,
storage_overage_per_gb_millicents_override = @storageOveragePerGbMillicentsOverride,
timestamp_fixed_cents_override = @timestampFixedCentsOverride,
retention_billing = @retentionBilling,
retention_increment = @retentionIncrement,
retention_until = @retentionUntil,
amazon_passthrough_fees_cents = @amazonPassthroughFeesCents,
segment = @segment`,
)
.run({
id: customer.id,
@ -89,11 +164,29 @@ export class SqliteCustomerRepo implements CustomerRepo {
totpEnabled: customer.totpEnabled ? 1 : 0,
balanceCents: customer.balanceCents ?? 0,
emailInvoicing: customer.emailInvoicing ? 1 : 0,
veraeUserId: customer.veraeUserId ?? null,
veraeUsername: customer.veraeUsername ?? null,
parentId: customer.parentId ?? null,
storageMode: customer.storageMode ?? 'hosted',
accountKind: customer.accountKind ?? (customer.parentId ? 'peergos_user' : 'org'),
s3Endpoint: customer.s3Endpoint ?? null,
s3Bucket: customer.s3Bucket ?? null,
s3Region: customer.s3Region ?? null,
includedStorageGbOverride: customer.includedStorageGbOverride ?? null,
storageOveragePerGbMillicentsOverride:
customer.storageOveragePerGbMillicentsOverride ?? null,
timestampFixedCentsOverride: customer.timestampFixedCentsOverride ?? null,
retentionBilling: customer.retentionBilling ?? 'none',
retentionIncrement: customer.retentionIncrement ?? null,
retentionUntil: customer.retentionUntil ?? null,
amazonPassthroughFeesCents: customer.amazonPassthroughFeesCents ?? null,
segment: customer.segment ?? 'individual',
});
}
}
function toCustomer(r: Record<string, unknown>): Customer {
const parentId = (r.parent_id as string | null) ?? undefined;
return {
id: r.id as string,
name: r.name as string,
@ -101,12 +194,30 @@ function toCustomer(r: Record<string, unknown>): Customer {
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'],
billingType: ((r.billing_type as string | null) ?? 'stripe') as BillingType,
email: (r.email as string | null) ?? undefined,
veraeUserId: (r.verae_user_id as string | null) ?? undefined,
veraeUsername: (r.verae_username 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,
parentId,
storageMode: ((r.storage_mode as string | null) ?? 'hosted') as StorageMode,
accountKind: ((r.account_kind as string | null) ??
(parentId ? 'peergos_user' : 'org')) as AccountKind,
s3Endpoint: (r.s3_endpoint as string | null) ?? undefined,
s3Bucket: (r.s3_bucket as string | null) ?? undefined,
s3Region: (r.s3_region as string | null) ?? undefined,
includedStorageGbOverride: (r.included_storage_gb_override as number | null) ?? undefined,
storageOveragePerGbMillicentsOverride:
(r.storage_overage_per_gb_millicents_override as number | null) ?? undefined,
timestampFixedCentsOverride: (r.timestamp_fixed_cents_override as number | null) ?? undefined,
retentionBilling: ((r.retention_billing as string | null) ?? 'none') as RetentionBilling,
retentionIncrement: (r.retention_increment as RetentionIncrement | null) ?? undefined,
retentionUntil: (r.retention_until as string | null) ?? undefined,
amazonPassthroughFeesCents: (r.amazon_passthrough_fees_cents as number | null) ?? undefined,
segment: ((r.segment as string | null) ?? 'individual') as CustomerSegment,
};
}

12
src/db/db-paths.ts Normal file
View file

@ -0,0 +1,12 @@
import path from 'path';
import { PROJECT_ROOT } from '../paths';
/** Auth / sessions DB (admin_users, portal_sessions). */
export function authDbPath(): string {
return process.env.ZAPPIER_DB ?? path.join(PROJECT_ROOT, 'zappier.db');
}
/** Billing / ledger DB (customers, usage, rates, invoices, tiers). */
export function billingDbPath(): string {
return process.env.BILLING_DB ?? path.join(PROJECT_ROOT, 'billing.db');
}

Some files were not shown because too many files have changed in this diff Show more