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:
parent
237540c621
commit
1107272d38
67 changed files with 7972 additions and 296 deletions
32
.env.example
32
.env.example
|
|
@ -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
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -1,8 +1,9 @@
|
|||
# Zappier
|
||||
|
||||
[](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
|
||||
|
|
|
|||
235
openapi.yaml
235
openapi.yaml
|
|
@ -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
270
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
236
src/admin.ts
236
src/admin.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
664
src/app.ts
664
src/app.ts
|
|
@ -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) => {
|
||||
|
|
|
|||
105
src/auth.ts
105
src/auth.ts
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
27
src/billing-channel-resolve.ts
Normal file
27
src/billing-channel-resolve.ts
Normal 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
108
src/billing-channels.ts
Normal 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
49
src/billing-headers.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
196
src/db/api-token-repo.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
86
src/db/business-store-adapters.ts
Normal file
86
src/db/business-store-adapters.ts
Normal 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
28
src/db/business-store.ts
Normal 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';
|
||||
114
src/db/channel-rate-store.ts
Normal file
114
src/db/channel-rate-store.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
80
src/db/credit-adjustments.ts
Normal file
80
src/db/credit-adjustments.ts
Normal 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(),
|
||||
}));
|
||||
}
|
||||
|
|
@ -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
12
src/db/db-paths.ts
Normal 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
Loading…
Reference in a new issue