Milestone 0: import zappier billing, Verae middleware, and Zapier research

Compose-ready workspace: packages/zappier (rate card, portal, Stripe),
packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier
(CLI app), vendor/zapier-platform, and research/zapier vendor corpus.

Gate 0 structure checks pass. Product code and research are not yet wired.
This commit is contained in:
George Lambert 2026-09-09 02:37:36 -04:00
commit b4150c8250
1364 changed files with 6814366 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
node_modules/
.venv/
dist/
data/
*.db
.env
.DS_Store
coverage/
*.log
__pycache__/
*.pyc
harness/data/
packages/zappier/zappier.db
packages/verae-zapier-middleware/data/
.grok-session
docs/sphinx/_build/

49
README.MD Normal file
View file

@ -0,0 +1,49 @@
# Verae Time × Zapier
Workspace that composes two existing systems and the official Zapier Platform SDK:
1. **`packages/zappier`** — custom pricing, API keys, customer portal, admin, Stripe, invoices.
2. **`packages/verae-zapier-middleware`** — timestamp adapter to `api.veraetime.net` (or `MOCK_VERAE`) plus NATS workers.
3. **`packages/verae-zapier`** — Zapier Platform CLI app.
4. **`vendor/zapier-platform`** — upstream CLI/core/schema/examples (reference).
5. **`research/zapier`** — vendor research, scrapes, cloned Zapier repos, playbooks ([research/README.md](research/README.md)).
They are **imported, not yet wired**. Public Zapier traffic should eventually hit zappier (`x-api-key`), which meters the call and proxies to the Verae middleware. See [docs/02-architecture/composition.md](docs/02-architecture/composition.md).
Original brief (features am): [docs/00-sources/workspace-brief.md](docs/00-sources/workspace-brief.md).
Where each tree came from: [docs/00-sources/provenance.md](docs/00-sources/provenance.md).
## Quick start
```bash
# Billing platform (portal :3000, admin, /docs)
cd packages/zappier && npm install && npm test && npm run dev
# Verae middleware (HTTP :3100, optional NATS)
cd packages/verae-zapier-middleware && npm install
cd ../.. && npm run gate:0
```
Gates (`npm run gate:N`) continue the middleware plan in [TODO.md](TODO.md). Phases 08, 10, 11 already passed in the source tree; 9 and 12+ are still open. Path prefixes now use `packages/`.
## Documentation map
| Doc | Contents |
|-----|----------|
| [docs/01-product/features-a-m.md](docs/01-product/features-a-m.md) | Requested Zapier surface vs owners |
| [docs/01-product/api-gap-analysis.md](docs/01-product/api-gap-analysis.md) | Live Verae OpenAPI vs am |
| [docs/01-product/billing-and-keys.md](docs/01-product/billing-and-keys.md) | Keys, rate card, invoices |
| [docs/02-architecture/overview.md](docs/02-architecture/overview.md) | Adapter internals (imported) |
| [docs/03-zapier/operations.md](docs/03-zapier/operations.md) | Platform nouns |
| [research/README.md](research/README.md) | Imported Zapier vendor research |
## Environment (middleware)
| Variable | Default | Purpose |
|----------|---------|---------|
| `PORT` | `3100` | Middleware HTTP |
| `VERAE_API_BASE_URL` | `http://localhost:8080` | Upstream Verae |
| `MOCK_VERAE` | `false` | Local mock of Verae HTTP |
| `NATS_ENABLED` | `false` | JetStream workers vs in-process poller |
Zappier uses `PORT` (3000), `ZAPPIER_DB`, `ADMIN_KEY`, `STRIPE_SECRET_KEY` — see `packages/zappier/README.md`.

659
TODO.md Normal file
View file

@ -0,0 +1,659 @@
# Verae Zapier Platform — Implementation TODO
**Workspace:** `/Users/marchon/apps/zapier` (imported 2026-09-09). Packages live under `packages/`. Commercial billing is `packages/zappier` — do not reimplement it in middleware. Composition waves: see `docs/02-architecture/composition.md`.
**Product goal:** Hybrid **HTTP edge + NATS workers** middleware between Zapier servers and `https://api.veraetime.net`.
**Architecture bottom line:**
- Zapier never speaks NATS (HTTPS only).
- `verae-zapier` (CLI app) runs on Zapier infrastructure and calls middleware.
- `verae-zapier-middleware` exposes `/zapier/v1/*` (auth, billing, sync API).
- **NATS + JetStream** owns job watching, completion events, and webhook delivery.
- Workers call `api.veraetime.net` over HTTPS and POST REST Hooks to Zapier.
**How to use this file**
1. Work **top to bottom**. Phases are dependency-ordered.
2. Every phase ends with a **GATE** (tests must pass) before the next phase starts.
3. Mark items `[x]` only after the gate command succeeds and results are recorded under **Gate log**.
4. Do not skip gates. Parallel work is only allowed **within** a phase when items share no dependency.
**Runtime debug**
- Failure tracing is off by default.
- Enable at runtime: `DEBUG_VERAE=1` or `DEBUG_VERAE=auth,nats,jobs,webhooks,http,billing`.
- See [docs/developer/debugging.md](docs/developer/debugging.md).
**Primary docs**
| Doc | Purpose |
|-----|---------|
| [README.md](README.md) | Repo overview and quick start |
| [docs/architecture/overview.md](docs/architecture/overview.md) | System diagram and data flows |
| [docs/architecture/nats-subjects.md](docs/architecture/nats-subjects.md) | Subject topology and payloads |
| [docs/developer/modules/](docs/developer/modules/) | Per-module function reference |
| [docs/plans/phase-gates.md](docs/plans/phase-gates.md) | Gate commands and acceptance criteria |
| [docs/api/middleware-openapi.yaml](docs/api/middleware-openapi.yaml) | Zapier-facing OpenAPI |
---
## Legend
| Marker | Meaning |
|--------|---------|
| `[ ]` | Not started |
| `[~]` | In progress |
| `[x]` | Done and **gate passed** |
| **BLOCKED** | Waiting on listed dependency |
| **GATE** | Mandatory test validation — no next phase until green |
---
## Phase 0 — Repository baseline and documentation contract
**Depends on:** nothing
**Unblocks:** Phase 1+
### Tasks
- [x] 0.1 Create monorepo layout (`verae-zapier-middleware/`, `verae-zapier/`, `docs/`)
- [x] 0.2 Write architecture overview and NATS subject map
- [x] 0.3 Write developer module documentation skeleton for all planned modules
- [x] 0.4 Document debugging facility contract (`DEBUG_VERAE`, namespaces, redaction rules)
- [x] 0.5 Write this dependency-ordered `TODO.md` with test gates
- [x] 0.6 Add root `README.md` and package scripts for gated testing
### GATE 0 — Documentation and structure
```bash
cd /Users/marchon/datacubes/verae-zapier-api
npm run gate:0
```
**Pass criteria**
- Required paths exist (see `scripts/gate-0-structure.mjs`)
- `TODO.md`, architecture docs, debugging doc, module docs present
- No empty required doc stubs
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | structure + docs gate |
---
## Phase 1 — Debug runtime facility
**Depends on:** Phase 0 GATE
**Unblocks:** All code phases (debug used everywhere)
### Tasks
- [x] 1.1 Implement `src/debug/logger.js` — namespaced logger, levels, redact secrets
- [x] 1.2 Implement `src/debug/trace.js` — request/job correlation IDs
- [x] 1.3 Implement `src/debug/config.js` — parse `DEBUG_VERAE` / `DEBUG_VERAE_LEVEL` at runtime
- [x] 1.4 Unit tests: enable/disable namespaces, redaction of tokens/passwords/api keys
- [x] 1.5 Document every debug export in `docs/developer/modules/debug.md`
### GATE 1 — Debug facility
```bash
npm run gate:1
# equivalent: node --test verae-zapier-middleware/test/unit/debug*.test.js
```
**Pass criteria**
- Logger silent when `DEBUG_VERAE` unset
- Enabling `DEBUG_VERAE=auth` only emits auth namespace
- Redaction masks `Bearer`, `zmw_`, `zmt_`, password fields
- Correlation ID stable across nested `trace.run` calls
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | 11/11 unit tests |
---
## Phase 2 — Config, errors, and core HTTP shell
**Depends on:** Phase 1 GATE
**Unblocks:** Auth, clients, routes
### Tasks
- [x] 2.1 `src/config.js` — env load, plan limits, NATS flags, debug passthrough
- [x] 2.2 `src/errors.js``AppError`, `asyncHandler`, `sendError` with debug dumps on 5xx
- [x] 2.3 `src/app.js` / `src/index.js` — Express app, `/health`, `/zapier` mount
- [x] 2.4 Unit tests for config defaults and health endpoint
- [x] 2.5 Module docs: `config.md` (+ function-reference for errors/app)
### GATE 2 — HTTP shell
```bash
npm run gate:2
```
**Pass criteria**
- `GET /health` returns `{ status: "ok" }`
- Unknown routes return structured JSON error
- Config reads `VERAE_API_BASE_URL`, `NATS_URL`, `NATS_ENABLED`, `DEBUG_VERAE`
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | health + config unit tests; /zapier returns 501 until Phase 56 routes |
---
## Phase 3 — Persistence stores (tenants, usage, webhooks)
**Depends on:** Phase 2 GATE
**Unblocks:** Auth bridge, billing, webhook subscribe API
### Tasks
- [x] 3.1 `src/store/db.js` — load/persist store (file for MVP; interface ready for Redis/Postgres)
- [x] 3.2 `src/store/tenants.js` — CRUD, API key lookup, `resolveLimits`
- [x] 3.3 `src/store/usage.js` — monthly counters, overage
- [x] 3.4 `src/store/webhooks.js` — subscribe/unsubscribe/list by tenant+event
- [x] 3.5 Unit tests for limits, API key mapping, webhook isolation per tenant
- [x] 3.6 Module docs for each store file
### GATE 3 — Stores
```bash
npm run gate:3
```
**Pass criteria**
- Create tenant → API key resolves to same tenant
- Free plan limits applied; enterprise contract overrides
- Webhook for tenant A never returned for tenant B
- Persist + reload round-trip preserves data
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | store unit tests |
---
## Phase 4 — Tokens and Verae HTTP client
**Depends on:** Phase 3 GATE
**Unblocks:** Auth service, timestamp/verify services
### Tasks
- [x] 4.1 `src/lib/tokens.js` — API keys (`zmw_`), session tokens (`zmt_`), HMAC sign/verify
- [x] 4.2 `src/clients/veraeClient.js` — login, validate, timestamp, status, verify, waitForJob
- [x] 4.3 Mock mode (`MOCK_VERAE=true`) for offline tests
- [x] 4.4 Debug spans for outbound HTTP (url, method, status, duration; **no raw secrets**)
- [x] 4.5 Unit tests: mock client lifecycle (create → pending → completed)
- [x] 4.6 Module docs: `tokens.md`, `veraeClient.md`
### GATE 4 — Client + tokens
```bash
npm run gate:4
```
**Pass criteria**
- Session token forge fails without secret
- Mock create returns `jobId`; wait reaches `completed`
- Debug log with `DEBUG_VERAE=http` shows request metadata without Authorization header values
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | tokens + mock client |
---
## Phase 5 — Auth bridge and entitlements (HTTP, no NATS yet)
**Depends on:** Phase 4 GATE
**Unblocks:** Timestamp/verify routes, Zapier custom auth
### Tasks
- [x] 5.1 `src/services/authService.js` — login credentials, login API key, resolveAuthContext, validateSession
- [x] 5.2 `src/services/entitlementService.js` — checkEntitlement, recordUsage
- [x] 5.3 `src/middleware/authenticate.js` — Bearer / x-api-key
- [x] 5.4 `src/middleware/rateLimit.js` — per-plan RPM
- [x] 5.5 `src/routes/authRoutes.js``POST /login`, `GET /me` under `/zapier/v1/auth`
- [x] 5.6 Integration tests: API key → me; quota 402; rate limit
- [x] 5.7 Module docs for auth + entitlement + middleware
### GATE 5 — Auth + billing gates
```bash
npm run gate:5
```
**Pass criteria**
- Seeded free tenant can call `/zapier/v1/auth/me`
- Exceeding free timestamp quota returns `402` + `QUOTA_EXCEEDED`
- Invalid key returns `401`
- Debug `DEBUG_VERAE=auth,billing` traces tenantId/plan without password
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | auth me + quota |
---
## Phase 6 — Sync HTTP API path (in-process poller fallback)
**Depends on:** Phase 5 GATE
**Unblocks:** NATS swap-in (Phase 7+), Zapier app wiring
**Note:** Implements full product behavior with `NATS_ENABLED=false` so Zapier can work before NATS.
### Tasks
- [x] 6.1 `src/services/timestampService.js` — create, createAndWait, batch, getStatus
- [x] 6.2 `src/services/verifyService.js` — verify, verifyBatch
- [x] 6.3 `src/services/webhookService.js` — subscribe/unsubscribe/deliver (HTTP)
- [x] 6.4 Routes: timestamp, verify, status, webhooks
- [x] 6.5 In-process `src/workers/inProcessJobPoller.js` (legacy path, feature-flagged)
- [x] 6.6 Integration tests with `MOCK_VERAE=true`, `NATS_ENABLED=false`
- [x] 6.7 Module docs for services + routes
### GATE 6 — Full HTTP middleware (no NATS)
```bash
npm run gate:6
```
**Pass criteria**
- `POST /zapier/v1/timestamp` → 202 + jobId
- `POST /zapier/v1/timestamp/wait` → completed status with certificate fields
- `POST /zapier/v1/verify` → valid true/false
- Webhook subscribe stores targetUrl; simulated complete invokes deliver (mock)
- All tests pass with NATS disabled
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | full HTTP mock path |
---
## Phase 7 — NATS connection, streams, and publishers
**Depends on:** Phase 6 GATE
**Unblocks:** Workers, production multi-instance
### Tasks
- [x] 7.1 Add local NATS JetStream to `docker-compose.yml`
- [x] 7.2 `src/nats/subjects.js` — subject constants
- [x] 7.3 `src/nats/connection.js` — connect, reconnect, ensure streams/consumers
- [x] 7.4 `src/nats/publishers.js` — enqueueWatch, publishJobEvent, enqueueWebhook
- [x] 7.5 Unit/integration tests against test NATS (or mocked JetStream interface)
- [x] 7.6 Module docs: `nats-connection.md`, `nats-subjects.md`, `nats-publishers.md`
- [x] 7.7 Debug namespace `nats` for publish/ack/error tracing
### GATE 7 — NATS infrastructure
```bash
npm run gate:7
# starts NATS if needed, ensures streams, runs nats tests
```
**Pass criteria**
- Streams `ZAPIER_JOBS`, `ZAPIER_EVENTS`, `ZAPIER_WEBHOOKS` exist
- Publish + pull consume one message end-to-end
- Connection failure surfaces structured error; debug logs connection lifecycle
- `NATS_ENABLED=false` still loads app without connecting
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | nats-server JetStream |
---
## Phase 8 — NATS workers (job poller + webhook deliver)
**Depends on:** Phase 7 GATE
**Unblocks:** Hybrid production path
### Tasks
- [x] 8.1 `src/workers/jobPollerWorker.js` — durable consumer on `verae.zapier.jobs.watch`
- [x] 8.2 `src/workers/webhookWorker.js` — durable consumer on `verae.zapier.webhooks.deliver`
- [x] 8.3 Wire `timestampService` to publish watch jobs when `NATS_ENABLED=true`
- [x] 8.4 Events consumer routes `jobs.events` → enqueue webhook deliveries for matching hooks
- [x] 8.5 **Never** put raw Verae JWT in NATS when avoidable — use `tokenRef` or re-login
- [x] 8.6 Integration tests: create → worker poll → event → webhook HTTP mock
- [x] 8.7 Module docs for workers; failure tracing via `DEBUG_VERAE=jobs,webhooks,nats`
### GATE 8 — Async path with NATS
```bash
npm run gate:8
```
**Pass criteria**
- With NATS up and `NATS_ENABLED=true`, async create eventually completes via worker
- Webhook target receives POST with `timestamp.completed` payload
- Redelivery: failing webhook is retried (JetStream max_deliver)
- Two concurrent workers do not double-complete same job (queue group)
- In-process poller **not** started when NATS enabled
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | workers watch→webhook |
---
## Phase 9 — `/timestamp/wait` via NATS events
**Depends on:** Phase 8 GATE
**Unblocks:** Efficient Zapier “Create and Wait” under multi-instance
### Tasks
- [ ] 9.1 API waits on job event (subscription or inbox) after enqueue watch
- [ ] 9.2 Hard timeout returns `{ jobId, status: "pending" }` (document Zapier fallback)
- [ ] 9.3 Tests: fast complete; timeout path; cancellation on client disconnect (if feasible)
- [ ] 9.4 Docs update: wait semantics, Zapier multi-step fallback
### GATE 9 — Wait path
```bash
npm run gate:9
```
**Pass criteria**
- Wait returns completed when worker finishes before timeout
- Wait returns pending + jobId on timeout without hanging forever
- Debug shows wait correlation id linking HTTP request → NATS event
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| | | |
---
## Phase 10 — Tenant signup / admin provision
**Depends on:** Phase 6 GATE (can parallel Phase 79 after 6)
**Unblocks:** Self-serve and enterprise keys for Zapier
### Tasks
- [x] 10.1 `src/services/tenantService.js` — selfServeSignup, provisionTenant
- [x] 10.2 Routes: public signup, admin tenants (`X-Admin-Secret`)
- [x] 10.3 Scripts: `seed-audiences.js`, `create-test-account.js`
- [x] 10.4 Tests for free signup and enterprise contract validation
- [x] 10.5 Module docs: `tenantService.md`, `tenantRoutes.md`
### GATE 10 — Tenancy
```bash
npm run gate:10
```
**Pass criteria**
- Signup returns `apiKey` + free plan
- Enterprise without contract rejected
- Admin list does not leak passwords
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | signup + admin |
---
## Phase 11 — Zapier CLI app (`verae-zapier`)
**Depends on:** Phase 6 GATE minimum; Phase 8 recommended for triggers
**Unblocks:** Private invites on Zapier
### Tasks
- [x] 11.1 `authentication.js` — custom API key → `/zapier/v1/auth/me`
- [x] 11.2 `index.js` — beforeRequest API key; afterResponse map 402/403
- [x] 11.3 Creates: timestamp_and_wait, create_timestamp, verify, batch
- [x] 11.4 Search: job_status
- [x] 11.5 Trigger: timestamp_completed REST Hook
- [x] 11.6 Unit tests with mocked middleware (nock or local server)
- [x] 11.7 `MIDDLEWARE_BASE_URL` env documentation
- [x] 11.8 Developer docs for each Zapier operation module
### GATE 11 — Zapier package
```bash
npm run gate:11
```
**Pass criteria**
- Auth test module returns tenant/plan label fields
- Each create/search perform builds correct middleware URL/body
- Trigger subscribe/unsubscribe payload shapes match middleware
- `zapier-platform validate` / local test suite green (when CLI available)
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| 2026-08-11 | pass | zapier package unit tests |
---
## Phase 12 — End-to-end validation (local stack)
**Depends on:** Gates 8, 10, 11
**Unblocks:** Remote deploy
### Tasks
- [ ] 12.1 `docker-compose.yml`: middleware + NATS (+ optional mock Verae)
- [ ] 12.2 Compose smoke script: health, auth, timestamp wait, webhook
- [ ] 12.3 Debug mode runbook: reproduce a failed job with `DEBUG_VERAE=*`
- [ ] 12.4 Record sample traces in `docs/developer/debugging.md`
### GATE 12 — E2E local
```bash
npm run gate:12
```
**Pass criteria**
- Compose stack healthy
- One full async path and one wait path succeed
- One REST Hook delivery succeeds against mock receiver
- Gate script exits 0
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| | | |
---
## Phase 13 — Production wiring to api.veraetime.net
**Depends on:** Phase 12 GATE
**Unblocks:** Real customer traffic
### Tasks
- [ ] 13.1 Set `VERAE_API_BASE_URL=https://api.veraetime.net`, `MOCK_VERAE=false`
- [ ] 13.2 Provision dedicated Zapier service user on Verae (not shared admin long-term)
- [ ] 13.3 TLS, secrets management (`TOKEN_SECRET`, `ADMIN_SECRET`, NATS auth)
- [ ] 13.4 Public HTTPS URL for middleware (required for Zapier cloud → you)
- [ ] 13.5 Live smoke tests (non-destructive verify + one timestamp)
- [ ] 13.6 Runbook: incident tracing with debug flags (no secrets in logs)
### GATE 13 — Production smoke
```bash
npm run gate:13
# requires env: PRODUCTION_SMOKE=1 and real credentials
```
**Pass criteria**
- Login + timestamp + status against live API succeed
- No secrets printed in CI logs
- Documented rollback: `NATS_ENABLED=false` still serves sync path
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| | | |
---
## Phase 14 — Private Zapier push and invite
**Depends on:** Phase 13 GATE
**Unblocks:** Beta users
### Tasks
- [ ] 14.1 `zapier-platform register` / `link`
- [ ] 14.2 `zapier-platform push` private version
- [ ] 14.3 Invite internal users; validate real Zap (Drive/Sheets → Timestamp → Slack)
- [ ] 14.4 Collect failures; enable `DEBUG_VERAE` only on middleware staging for repro
- [ ] 14.5 Update Progress / changelog
### GATE 14 — Human acceptance
**Pass criteria**
- At least one real Zap runs end-to-end with production middleware
- Auth connection label shows tenant + plan
- Async trigger or wait path verified by human sign-off
**Gate log**
| Date | Result | Notes |
|------|--------|-------|
| | | |
---
## Phase 15 — Hardening (post-beta)
**Depends on:** Phase 14
**Unblocks:** Public listing consideration
### Tasks
- [ ] 15.1 Replace file store with Postgres/Redis
- [ ] 15.2 NATS mTLS / account auth
- [ ] 15.3 Webhook SSRF protections
- [ ] 15.4 Usage stream consumer for billing export
- [ ] 15.5 Optional: Verae core publishes completion to NATS (drop polling)
- [ ] 15.6 Security review of tokenRef vs password-at-rest
### GATE 15 — Hardening checklist
**Pass criteria**
- Written security review checklist completed
- Load test: N concurrent workers, no duplicate webhook storms
- Backup/restore drill for tenant store
---
## Dependency graph (summary)
```text
0 Docs/structure
└─▶ 1 Debug facility
└─▶ 2 HTTP shell
└─▶ 3 Stores
└─▶ 4 Tokens + Verae client
└─▶ 5 Auth + entitlements
└─▶ 6 Sync HTTP API (+ in-process poller)
├─▶ 7 NATS infra ──▶ 8 Workers ──▶ 9 Wait-via-NATS
├─▶ 10 Tenancy (parallel after 6)
└─▶ 11 Zapier app (after 6; triggers prefer 8)
└─▶ 12 E2E local (needs 8,10,11)
└─▶ 13 Production Verae
└─▶ 14 Private Zapier push
└─▶ 15 Hardening
```
---
## npm gate commands (root)
| Script | Phase | What it runs |
|--------|-------|--------------|
| `npm run gate:0` | 0 | Structure + doc presence |
| `npm run gate:1` | 1 | Debug unit tests |
| `npm run gate:2` | 2 | Config/errors/app tests |
| `npm run gate:3` | 3 | Store tests |
| `npm run gate:4` | 4 | Tokens + client tests |
| `npm run gate:5` | 5 | Auth + entitlement tests |
| `npm run gate:6` | 6 | HTTP integration (NATS off) |
| `npm run gate:7` | 7 | NATS stream tests |
| `npm run gate:8` | 8 | Worker integration |
| `npm run gate:9` | 9 | Wait path tests |
| `npm run gate:10` | 10 | Tenant tests |
| `npm run gate:11` | 11 | Zapier package tests |
| `npm run gate:12` | 12 | Compose E2E |
| `npm run gate:13` | 13 | Live smoke (opt-in) |
| `npm run gate:all` | 012 | All automated gates in order; stop on first failure |
---
## Definition of done (platform MVP)
- [ ] All gates 012 green on clean machine
- [ ] `NATS_ENABLED=true` path used for job watch + webhooks
- [ ] `NATS_ENABLED=false` fallback still passes gate 6
- [ ] Developer docs cover every modules exported functions (params + returns)
- [ ] Debug can be enabled at runtime without redeploy (`DEBUG_VERAE=...`)
- [ ] No secrets in logs when debug is on (redaction tests green)
- [ ] Private Zapier version pushed and one human E2E Zap succeeds (gate 14)
---
## Working rules for implementers
1. **No phase advancement without gate.**
2. **Write tests first or with the code** for that phases modules.
3. **Document every new export** in `docs/developer/modules/<name>.md` and JSDoc in source.
4. **Instrument with `debug`** at boundaries: auth resolve, outbound Verae, NATS publish/consume, webhook HTTP.
5. **Prefer failing a gate** over merging incomplete behavior.
6. Update the **Gate log** tables when a gate passes.

View file

@ -0,0 +1,729 @@
# Getting started — Verae Time × Zapier
**System architecture, functionality, integration guide, and next steps**
This is the working document for the research workspace at `/Users/marchon/research/zapier`. It covers the full stack that connects Zapier automations to the Verae Timestamping Service, how to run and extend it locally, Zapier vs Verae billing, a client pattern for timestamped files on Peergos/IPFS with cold retrieve-on-demand, and what remains before a private Zapier listing.
Grok (and humans) should start here, then follow the playbook in [PLATFORM-REFERENCE.md](PLATFORM-REFERENCE.md).
---
## 1. How to start this workspace
```bash
cd /Users/marchon/research/zapier
./scripts/restart-grok.sh # Mongo tunnel + grok --resume
```
Already in Grok:
```
/zapier-build
```
Mandatory first query (same playbook as [PLATFORM-REFERENCE.md](PLATFORM-REFERENCE.md)):
```js
db.platform_reference.findOne({ kind: "guide", key: "build-new-connector" })
```
Keep the Mongo tunnel up (`./scripts/ensure-mongo-tunnel.sh`). Check readiness with `./scripts/zapier-status.sh`. Load CLI PATH and env with `source scripts/dev-env.sh`.
Leave the TUI with `/quit` (not `/new`). Restart details: [RESTART.md](RESTART.md).
---
## 2. System architecture
### 2.1 Purpose
Connect Zapier automations to Verae blockchain timestamping **without**:
- exposing raw Verae JWTs to Zapier end users
- requiring Zapier to poll async jobs on `api.veraetime.net`
- coupling billing and plan limits to the core timestamping API
- running a multi-instance edge with only in-memory job queues
### 2.2 Bottom line
![High-level architecture: Zapier cloud, middleware, NATS workers, Verae API, and REST Hooks](docs/diagrams/01-high-level-architecture.svg)
```text
Users → Zapier UI
Zapier cloud runs the Platform CLI app (verae-zapier or scratch/veraetime)
→ HTTPS only → verae-zapier-middleware /zapier/v1/*
→ (sync) HTTPS → https://api.veraetime.net
→ (async) NATS JetStream → workers
→ HTTPS → api.veraetime.net (status poll)
→ HTTPS → hooks.zapier.com (REST Hook delivery)
```
- Zapier **never** connects to NATS.
- Zapier **never** calls `api.veraetime.net` directly.
- Middleware HTTP owns auth, tenancy, entitlements, metering, and the public API surface.
- NATS owns durable job watching, completion events, and reliable webhook delivery (when `NATS_ENABLED=true`).
- With `NATS_ENABLED=false`, an in-process poller still implements the same HTTP product path (Phase 6).
### 2.3 Components
| Component | Runs where | Role |
|-----------|------------|------|
| **Zapier Platform CLI app** | Zapier cloud (when a Zap step runs) | Auth fields, map operations to `/zapier/v1/*`, attach Bearer token, translate 402/403 |
| **verae-zapier-middleware** | Your infrastructure, public HTTPS | Tenant identity, API keys, Verae login bridge, entitlements, REST Hook storage, publish work to NATS |
| **NATS + JetStream** | Private network with middleware | Work queues for job watch and webhook delivery; event stream for terminal job states |
| **Workers** | Same deploy or separate processes | Job poller, event router, webhook deliverer |
| **api.veraetime.net** | Verae production | Source of truth: login, timestamp jobs, status, verification |
#### Zapier app (two copies in this repo)
| Path | Language | Auth | Status |
|------|----------|------|--------|
| `verae-zapier-api/verae-zapier/` | JavaScript | Custom API key (`zmw_…`) | Phase 11 package; `MIDDLEWARE_BASE_URL` env |
| `scratch/veraetime/` | TypeScript (CLI 19.1.0) | Session: username/password **or** API key → `accessToken` | Local golden connector; `build` + `validate` |
Both talk **only** to middleware. The TypeScript app is the one this workspace validates day-to-day. The JavaScript app is the vendored product package from the middleware monorepo.
**The Zapier app does not:** call `api.veraetime.net`, speak NATS, or enforce plan quotas.
#### Middleware HTTP edge
![Middleware internals: public and protected /zapier/v1 routes, store, and flags](docs/diagrams/08-middleware-internals.svg)
Path: `verae-zapier-api/verae-zapier-middleware/`
- Express app: `GET /health`, mount `/zapier`
- Public: `POST /zapier/v1/auth/login`, `GET /zapier/v1/auth/me`, tenant signup
- Protected (Bearer or `x-api-key` + rate limit): timestamp, verify, status, webhooks
- Admin: `/zapier/v1/admin/*` behind `X-Admin-Secret`
- Store: file JSON MVP (`STORE_PATH`, default `./data/store.json`) — single-node only
#### NATS + workers
| Worker | Consumes | Calls |
|--------|----------|-------|
| Job poller | `verae.zapier.jobs.watch` | `GET /api/status/{jobId}` on Verae |
| Event router | `verae.zapier.jobs.events` | Enqueues webhook deliveries |
| Webhook deliver | `verae.zapier.webhooks.deliver` | `POST` Zapier `targetUrl` |
Streams: `ZAPIER_JOBS`, `ZAPIER_EVENTS`, `ZAPIER_WEBHOOKS` (optional `ZAPIER_USAGE`).
#### Verae Timestamping Service
- Swagger UI: https://api.veraetime.net/docs/swagger/index.html
- OpenAPI: https://api.veraetime.net/docs/swagger/openapi.yaml
- Local copy: [scratch/our-api/openapi.yaml](scratch/our-api/openapi.yaml)
- Auth: `POST /auth/login` → JWT in `token`; all other API routes `Authorization: Bearer <token>`
- Async create: `POST /api/timestamp`**202** `{ jobId }`
### 2.4 Security boundaries
![Security boundaries: public HTTPS versus private NATS, store, and Verae JWT](docs/diagrams/02-security-boundaries.svg)
```text
Public Internet
├─ Zapier cloud → Middleware HTTPS only
└─ Middleware → Zapier REST Hook HTTPS only
Private
├─ Middleware ↔ NATS (never expose NATS ports)
└─ Middleware / workers → api.veraetime.net HTTPS
```
Rules:
- Never put raw Verae JWTs in NATS when a `tokenRef` will do.
- Never expose NATS (`4222`) to the public internet.
- Debug logs must redact `Bearer`, `zmw_`, `zmt_`, passwords, and hook query secrets.
- Treat `targetUrl` as untrusted egress (timeouts; SSRF allowlist is Phase 15).
- Do not commit `~/.zapierrc`, `.env`, `store.json`, or `~/.mcp-env`.
### 2.5 Scaling model
- **HTTP edge:** stateless replicas behind a load balancer, **except** they share a store. File JSON is single-node. Multi-node needs Postgres/Redis (Phase 15).
- **NATS consumers:** queue groups — more workers increase poll/deliver throughput; two workers must not double-complete the same job.
- **Rollback:** `NATS_ENABLED=false` still serves the full HTTP API with the in-process poller.
---
## 3. Functionality
### 3.1 Authentication (two hops)
![Two-hop authentication: user to middleware tokens; middleware to Verae JWT](docs/diagrams/03-auth-two-hop.svg)
**End user → middleware**
| Mode | What the user enters | What happens |
|------|----------------------|--------------|
| Session (TypeScript app) | Username + password, **or** middleware API key | `POST /zapier/v1/auth/login``{ accessToken }` stored as `sessionKey` |
| Custom key (JS app) | Tenant API key `zmw_…` | Sent as `Authorization: Bearer` on every request; test is `GET /zapier/v1/auth/me` |
Connection test: `GET /zapier/v1/auth/me` → tenant, plan, usage. Connection label: username/plan (TS) or `tenantId (plan)` (JS).
401 on later calls: TypeScript app throws `z.errors.RefreshAuthError` so Zapier re-runs session `perform`.
**Middleware → Verae**
Middleware logs into Verae with the tenants Verae credentials (or mock client when `MOCK_VERAE=true`) and holds the JWT **server-side**. Zapier never sees that JWT.
Token types issued by middleware:
| Prefix | Kind |
|--------|------|
| `zmw_` | Tenant API key (long-lived) |
| `zmt_` | Middleware session token (HMAC, `TOKEN_SECRET`) |
### 3.2 Zapier operations ↔ middleware ↔ Verae
![Operations map: Zapier nouns to /zapier/v1 to Verae](docs/diagrams/07-operations-map.svg)
All Zapier routes are under `/zapier/v1`. Connector default base: `http://127.0.0.1:3100`. Production base is the **deployed middleware**, not `api.veraetime.net`.
| Zapier noun | Type | Middleware | Verae (what middleware wraps) | Notes |
|-------------|------|------------|-------------------------------|-------|
| Create Timestamp | create | `POST /timestamp` | `POST /api/timestamp` | Returns `{ jobId }` (202). Use with the hook trigger. |
| Create Timestamp and Wait | create | `POST /timestamp/wait` | create + poll/wait | Returns terminal status, or pending + `jobId` on timeout. |
| Create Batch Timestamps | create | `POST /timestamp/batch` | `POST /api/batch/timestamp` | `{ items: [{ data, hashAlg? }] }` |
| Verify Certificate | create | `POST /verify` | `POST /api/verify` | `{ certificate }``{ valid, timestamp, blockIndex }` |
| Find Job Status | search | `GET /status/{jobId}` | `GET /api/status/{jobId}` | Search: empty array if 404. |
| Find Job Verification | search (TS only) | `GET /status/{jobId}/verification` | `GET /api/verify/{jobId}` | Search. |
| Timestamp Completed | hook trigger | `POST /webhooks/subscribe` · `DELETE /webhooks/unsubscribe` | (no Verae hook) | Event `timestamp.completed`; Zapier supplies `targetUrl`. |
Not in v1 (admin HTML / user admin on Verae): dashboard, metrics, queue UI, user CRUD, batch verify/status, get-block-by-hash. Add later if a Zap needs them.
Create input (timestamp): required `data` (text), optional `hashAlg` (default SHA256).
### 3.3 Request flows
**A. Create Timestamp (async + hook)**
![Create Timestamp sequence: async job plus REST Hook](docs/diagrams/04-flow-async-timestamp.svg)
```text
Zapier → POST /zapier/v1/timestamp
Middleware: authenticate, checkEntitlement, POST /api/timestamp
Middleware: publish jobs.watch → return 202 { jobId }
Worker: poll GET /api/status/{jobId} until terminal → publish jobs.events
Event router: match webhooks → publish webhooks.deliver
Webhook worker: POST hooks.zapier.com/... (timestamp.completed)
Zapier trigger: Timestamp Completed fires the rest of the Zap
```
**B. Create Timestamp and Wait**
![Create Timestamp and Wait: in-process path versus Phase 9 NATS](docs/diagrams/05-flow-wait.svg)
```text
Zapier → POST /zapier/v1/timestamp/wait
Middleware: create + wait (in-process if NATS off; NATS events when Phase 9 lands)
→ StatusResponse (completed/failed) or { jobId, status: "pending" } on timeout
```
Today, wait works on the in-process path (`NATS_ENABLED=false`). Wait-via-NATS is **Phase 9 (open)**.
**C. Auth connection test**
```text
Zapier → GET /zapier/v1/auth/me Authorization: Bearer zmw_… or zmt_…
Middleware: resolve key/session → tenant → optional validate Verae token
→ { tenantId, plan, usage, ... }
```
### 3.4 NATS subjects (private)
![NATS topology: streams, subjects, consumers, and ack rules](docs/diagrams/06-nats-topology.svg)
| Subject | Publisher | Consumer | Payload gist |
|---------|-----------|----------|--------------|
| `verae.zapier.jobs.watch` | HTTP edge after create | `job-poller` (queue) | `tenantId`, `jobId`, `tokenRef`, attempts, `traceId` |
| `verae.zapier.jobs.events` | Job poller (terminal) | Event router; optional HTTP waiters | `timestamp.completed\|failed\|timeout`, status object |
| `verae.zapier.webhooks.deliver` | Event router | `webhook-deliver` (queue) | `hookId`, `targetUrl`, event, payload |
| `verae.zapier.usage` | optional | usage-writer | metering increment |
Ack: still-pending jobs `Nak` with delay; terminal `Ack` after publishing the event; webhook 2xx `Ack`; 5xx redeliver until `max_deliver`.
### 3.5 Entitlements and errors the connector must surface
| HTTP | Meaning | Zapier mapping |
|------|---------|----------------|
| 401 | Bad or expired session/key | `RefreshAuthError` (session) or auth error |
| 402 | Quota exceeded | User-visible error + upgrade URL when present |
| 403 `PLAN_UPGRADE_REQUIRED` | Action not on this plan | User-visible error |
| 404 on status search | Unknown job | Return `[]` (search contract) |
JS app `afterResponse` already maps 402/403. TypeScript app currently remaps 401 only — 402/403 mapping is a next-step item.
### 3.6 Feature flags and environment
| Variable | Default | Purpose |
|----------|---------|---------|
| `PORT` | `3100` | Middleware listen port |
| `VERAE_API_BASE_URL` | `http://localhost:8080` | Upstream Verae (prod: `https://api.veraetime.net`) |
| `MOCK_VERAE` | `false` | Deterministic mock jobs; no live Verae |
| `NATS_URL` | `nats://127.0.0.1:4222` | NATS server |
| `NATS_ENABLED` | `false` | JetStream workers vs in-process poller |
| `TOKEN_SECRET` | dev secret | HMAC for `zmt_` session tokens |
| `DEBUG_VERAE` | unset | Namespaces: `auth`, `nats`, `jobs`, `webhooks`, `http`, `billing` (or `1` for all) |
| `DEBUG_VERAE_LEVEL` | `debug` | `debug` \| `info` \| `warn` \| `error` |
| `STORE_PATH` | `./data/store.json` | MVP tenant/usage/webhook store |
| `MIDDLEWARE_BASE_URL` | `http://127.0.0.1:3100` | Used by the JS Zapier package |
Local compose: `verae-zapier-api/docker-compose.yml` (NATS + middleware). Middleware can run without NATS when the flag is off.
---
## 4. Integration guide
### 4.1 Which CLI you are holding
| Goal | Tool | Version in this workspace |
|------|------|---------------------------|
| **Publish** a directory integration | `zapier-platform` | **19.1.0** (`~/.npm-global/bin`) |
| **Consume** existing Zapier apps from code | `zapier-sdk` | **0.77.1** |
| AI client over the 9k catalog | Hosted MCP `https://mcp.zapier.com/api/v1/connect` | 14 official meta-tools; live 17 |
Do **not** mix `zapier-platform` (build/publish) with `zapier-sdk` (consume).
Do **not** recommend retired NLA / AI Actions.
Do **not** invent `selected_api` ids or action keys.
### 4.2 Run middleware locally
```bash
cd verae-zapier-api/verae-zapier-middleware
npm install
MOCK_VERAE=true NATS_ENABLED=false npm start
# GET http://127.0.0.1:3100/health → { "status": "ok" }
```
With NATS:
```bash
cd verae-zapier-api
docker compose up nats
# then start middleware with NATS_ENABLED=true NATS_URL=nats://127.0.0.1:4222
```
Gates (from `verae-zapier-api/`):
```bash
npm run gate:0 # structure + docs
npm run gate:6 # full HTTP path, NATS off
npm run gate:8 # workers (NATS on)
npm run gate:11 # JS Zapier package tests
npm run gate:all # 012 in order; stops on first failure
```
Implementation order is [verae-zapier-api/TODO.md](verae-zapier-api/TODO.md). Do not skip gates.
### 4.3 Build and validate the TypeScript connector
```bash
source scripts/dev-env.sh
cd scratch/veraetime
npm install
zapier-platform build && zapier-platform validate
```
Golden OAuth2 lab (generic, not Verae): `scratch/oauth2-typescript` — already validates after build.
Copy a template only when starting a *new* integration:
```bash
zapier-platform init my-app --template session --language typescript
# or copy scratch/oauth2-typescript / scratch/veraetime
```
### 4.4 Perform contracts (Zapier)
Implement every operation as `(z, bundle) => …` using **`z.request` only**.
- **Triggers and searches** return **arrays of objects**. Polling items need a stable `id`.
- **Creates** return **one object**.
- Refreshable 401 → `throw new z.errors.RefreshAuthError()`.
- Hook triggers implement `performSubscribe` / `performUnsubscribe` / `perform` / `performList` (sample for the editor).
- Do not call Verae or NATS from the app.
### 4.5 Login and publish (blocked until you authenticate)
There is no `~/.zapierrc` until you finish a browser login. Local `validate` works. `register` and `push` do not.
```bash
source scripts/dev-env.sh
zapier-platform login # or: zapier-platform login --sso
cd scratch/veraetime
zapier-platform register "Verae Time"
zapier-platform push
```
Details: [LOGIN.md](LOGIN.md). Never commit the deploy key.
Production Zapier cloud **cannot** reach `http://127.0.0.1:3100`. Before a real Zap you need a public HTTPS middleware URL and `api_base_url` / `MIDDLEWARE_BASE_URL` pointed at it (Phase 1314).
### 4.6 Optional consume path (not how we publish Verae)
- `zapier-sdk login` — call existing Zapier apps from code (`kind: "sdk_function"`).
- Hosted Zapier MCP — discover → enable → inspect → execute. Writes need explicit user approval. Successful executes cost **2 Zapier tasks**. See [MCP-REFERENCE.md](MCP-REFERENCE.md).
### 4.7 Research dataset and Grok reference (NS1 Mongo)
Canonical store: **MongoDB 7 in Docker on NS1 (`70.88.205.138`)**, bound to **127.0.0.1:27017 only**. Connect only through the SSH tunnel:
```bash
./scripts/mongo-tunnel.sh
# ssh -N -L 27017:127.0.0.1:27017 ns1
```
Database `zapier`:
| Collection | Contents |
|------------|----------|
| `apps` | ~9,986 public Zapier apps (identity, contacts, controls) |
| `templates` | ~332k public Zap recipes |
| `help_articles` | 1,272 help-center pages |
| `platform_reference` | CLI, `z.*`, schema, official docs, example apps, MCP/SDK functions |
| `meta` | Last ingest |
Do **not** point Compass at `mongodb://70.88.205.138:27017`. Credentials live in `~/.mcp-env` (mode 600), not git. Full notes: [MONGO.md](MONGO.md).
Useful queries:
```js
db.platform_reference.findOne({ kind: "guide", key: "build-new-connector" })
db.platform_reference.find({ kind: "core_function", key: "z.request" })
db.platform_reference.find({ kind: "cli_function", key: "init" })
db.platform_reference.find({ kind: "template", key: "session-auth" })
db.platform_reference.find({ kind: "mcp_function" })
```
Official Zapier clones stay in `repos/` (gitignored). Refresh with `./scripts/clone-zapier-repos.sh`.
---
## 5. Workspace map
![Workspace: repo, CLIs, Mongo tunnel, publish versus consume](docs/diagrams/10-workspace-integration.svg)
| Path | Use |
|------|-----|
| [getting-started.md](getting-started.md) / [getting-started.pdf](getting-started.pdf) | This architecture + integration guide |
| [PLATFORM-REFERENCE.md](PLATFORM-REFERENCE.md) | Routing table for all Zapier work |
| [FUNCTIONS-REFERENCE.md](FUNCTIONS-REFERENCE.md) | Every CLI / `z.*` / SDK function |
| [MCP-REFERENCE.md](MCP-REFERENCE.md) | Hosted MCP meta-tools |
| [LOGIN.md](LOGIN.md) | Browser login for platform + SDK |
| [RESTART.md](RESTART.md) | Quit and resume this session |
| [MONGO.md](MONGO.md) | NS1 Mongo, tunnel, collections |
| `.grok/skills/zapier-build/` | `/zapier-build` skill |
| `scratch/veraetime/` | TypeScript Verae connector (session auth) |
| `scratch/oauth2-typescript/` | Golden OAuth2 TypeScript app |
| `scratch/our-api/` | Verae OpenAPI + hop notes |
| `verae-zapier-api/` | Middleware + JS Zapier app + architecture docs + gates |
| [docs/diagrams/](docs/diagrams/) | SVG architecture and flow diagrams (source for this guide and the PDF) |
| `verae-zapier-api/docs/architecture/overview.md` | Component diagram (source of truth for the edge) |
| `verae-zapier-api/docs/architecture/nats-subjects.md` | Subjects, streams, payloads |
| `verae-zapier-api/docs/api/middleware-openapi.yaml` | Zapier-facing OpenAPI |
| `verae-zapier-api/TODO.md` | Phased plan with test gates |
| `scripts/dev-env.sh` | PATH + env |
| `scripts/ensure-mongo-tunnel.sh` / `mongo-tunnel.sh` | NS1 tunnel |
| `scripts/restart-grok.sh` / `zapier-status.sh` | Session + readiness |
| `repos/` | Official Zapier clones (local only) |
---
## 6. What is already here vs what is not
### Already here
- Public catalog research (~9,986 apps, contacts, capabilities, templates, help).
- Platform reference in Mongo + on disk; `/zapier-build` skill.
- Platform CLI 19.1.0 and SDK CLI 0.77.1 on `PATH` via `dev-env.sh`.
- Golden apps that **validate locally** without a Zapier login.
- Verae OpenAPI ingested (`scratch/our-api/openapi.yaml`).
- Full middleware source: auth, tenants, entitlements, timestamp/verify/status/webhooks, mock Verae, NATS publishers, workers, debug redaction.
- Two Zapier app implementations wired to `/zapier/v1`.
- Gates **08, 10, 11** recorded as passed in `TODO.md` (as of 2026-08-11 on the original monorepo).
### Not here yet
1. **Zapier developer login** — no `~/.zapierrc`; cannot `register` / `push`.
2. **Public HTTPS middleware** — Zapier cloud cannot hit localhost.
3. **Live invoke** of `scratch/veraetime` against middleware + real or mock Verae in this workspace (validate is schema-only).
4. **Phase 9**`/timestamp/wait` subscribed to NATS events (multi-instance wait).
5. **Phase 12** — compose E2E smoke (health + auth + wait + webhook) as a gate.
6. **Phase 13** — production `VERAE_API_BASE_URL`, dedicated Zapier service user, secrets, TLS.
7. **Phase 14** — private push + human Zap (Drive/Sheets → Timestamp → Slack).
8. **Phase 15** — Postgres/Redis store, NATS mTLS, webhook SSRF allowlist.
9. **TS connector polish** — map 402/403 like the JS app; optional polling admin trigger for `/admin/timestamps`.
10. **Grok MCP handshake** — Mongo MCP and chrome-bridge need a healthy tunnel / Chrome Connect after restart (`/mcps`).
11. **Peergos / pin / Glacier retrieval service** — proposed in §10; not a v1 connector operation.
This repo has the Zapier **platform** docs and the **Verae** OpenAPI. It still cannot invent new Verae endpoints. If a Zap needs an admin route that is not in the v1 table, add middleware + connector operations from the OpenAPI — do not guess.
---
## 7. Next steps (recommended order)
![Phase roadmap: gates 015, Phase 9 open](docs/diagrams/09-phase-roadmap.svg)
Work the product path in this order. Parallelism is only safe where `TODO.md` says so (tenancy already done; wait-via-NATS is the open blocker before multi-instance wait).
### Now — local integration (this workspace)
1. Start middleware with `MOCK_VERAE=true` and `NATS_ENABLED=false`.
2. Create a free tenant (`POST /zapier/v1/signup` or seed script) and confirm `GET /health` + `GET /zapier/v1/auth/me`.
3. `zapier-platform build && zapier-platform validate` in `scratch/veraetime`.
4. After `zapier-platform login`, `zapier-platform invoke auth test` and invoke create/search against local middleware (`--debug`).
5. Map 402/403 in `scratch/veraetime/src/middleware.ts` to match the JS app.
### Next — close middleware gaps
6. **Phase 9:** wait on `verae.zapier.jobs.events` with a hard timeout (`pending` + `jobId`).
7. **Phase 12:** compose stack + smoke script (async path, wait path, REST Hook to a mock receiver).
8. Confirm `NATS_ENABLED=true` and `false` both still pass their gates.
### Then — production and private listing
9. **Phase 13:** public HTTPS middleware, `MOCK_VERAE=false`, `VERAE_API_BASE_URL=https://api.veraetime.net`, dedicated Verae service user, managed secrets.
10. Point the connector `api_base_url` at that origin. Zapier cloud must reach it.
11. **Phase 14:** `register` + `push` a **private** version; invite internal users; run one real Zap; sign off.
12. Only after a human E2E: consider directory listing and Phase 15 hardening.
### Ongoing — research / Grok
13. Keep `./scripts/ensure-mongo-tunnel.sh` running; after restart hit `/mcps` and refresh **mongodb**.
14. Re-ingest `platform_reference` on NS1 after recloning official repos.
15. Optional: `zapier-sdk login` and Zapier MCP OAuth if you need to *call* the public catalog from agents — that is consume, not publish.
---
## 8. Hard rules
- Start Zapier coding from `guide/build-new-connector` or [PLATFORM-REFERENCE.md](PLATFORM-REFERENCE.md).
- Connector → middleware `/zapier/v1` only. Never `api.veraetime.net` from Zapier. Never NATS from Zapier.
- Use `z.request` in performs. Triggers/searches return arrays; creates return one object.
- `zapier-platform` publishes; `zapier-sdk` / hosted MCP consume. Do not mix.
- Do not recommend retired NLA / AI Actions.
- Do not invent vendor APIs, auth schemes, or Zapier `selected_api` keys.
- Mongo on NS1 is localhost-only; always tunnel.
- No secrets in git or in `DEBUG_VERAE` output.
---
## 9. Zapier billing models
Zapier and Verae bill **separately**. A client Zap that timestamps a file pays Zapier for successful **tasks** and pays Verae (via middleware entitlements) for **timestamp / verify** operations. Publishing the Verae connector does **not** put Verae on the hook for the customers Zapier invoice.
Figures below are **USD, August 2026**, from [zapier.com/pricing](https://zapier.com/pricing). Annual prices are the discounted per-month equivalent. Confirm live rates before quoting a customer.
![Zapier and Verae billing layers](docs/diagrams/11-zapier-billing.svg)
### 9.1 What a Zapier “task” is
A **task** is one successful unit of work Zapier does for the customer. Failed steps are free.
| Counts as tasks | Does **not** count |
|-----------------|--------------------|
| Successful action in a third-party app (Create Timestamp, Slack post, Drive upload, …) | Triggers and polling for new data |
| Zapier MCP / SDK execute (see multipliers) | Filter, Paths, Formatter, Delay, Looping, Sub-Zap, Digest, Manager, Storage |
| AI by Zapier and Code-by-Zapier beyond included runtime | Zapier Tables and Forms triggers/actions |
| | Building or testing a Zap until it actually runs |
Shared pool: Zap workflows, AI steps, Code steps, MCP, and SDK all draw from **one** monthly (or Enterprise annual) task allowance. There is no separate MCP budget.
**Multipliers** (official rate card; confirm [zapier.com/pricing/rates](https://zapier.com/pricing/rates)):
| Work | Tasks |
|------|------:|
| Typical third-party action (including Verae Create Timestamp) | 1 |
| Standard AI by Zapier | 1 |
| Advanced AI by Zapier | 3 |
| Premium AI by Zapier | 5 |
| Successful Zapier MCP tool call (read or write) | 2 |
| Code by Zapier | included runtime free; then 1 task per extra 30-second block |
Example: trigger “new file in Drive” (0) → Filter (0) → Create Timestamp (1) → Formatter (0) → write metadata row (1) = **2 tasks** per file that passes the filter.
### 9.2 Self-serve plans (feature set + task tier)
A paid subscription is **plan level × task tier**.
| Plan | Seats | Workflows | Polling | Entry task tier (annual) | Standout |
|------|------:|-----------|---------|--------------------------|----------|
| **Free** | 1 | Two-step only | 15 min | 100 tasks / month, $0 | Try automation; no pay-per-task overflow |
| **Professional** | 1 | Multi-step; premium apps; webhooks | 2 min | 750 tasks from **$19.99**/mo annual ($29.99 monthly) | Filters, Paths, Formatter, AI by Zapier, Autoreplay |
| **Team** | 25 | Same as Pro | 1 min | 2,000 tasks from **$69**/mo annual ($103.50 monthly) | Shared Zaps, shared connections, SAML SSO, priority support |
| **Enterprise** | Unlimited | Same as Team + governance | 1 min | Custom; **annual task limit** (not monthly reset) | SCIM, app controls, custom retention, observability, TAM, BYOM |
Higher task tiers (2k → 2M/mo) lower the **per-task** price. Team starts at 2,000. Volumes above 2M go through Sales. 14-day Professional trial (no card). Non-profit: extra 15% off (not on pay-per-task).
### 9.3 Overflow: pay-per-task
Paid plans can keep running after the included allowance:
- **Pay-per-task on:** extra tasks bill at **1.25×** the plans base task rate (annual) or **2.5×** (monthly). Ceiling is **3×** the subscribed tasks, then Zaps pause until the next cycle or an upgrade.
- **Pay-per-task off:** usage **stops** at the allowance.
- Free has no overflow. Enterprise uses an annual pool instead of a monthly reset.
### 9.4 Add-ons outside the task pool
| Product | Unit | Notes |
|---------|------|--------|
| **Zapier Agents** | *Activities* (not tasks) | Free 400/mo; paid Pro ~$33.33/mo annual for 1,500. Does not consume Zap tasks. |
| **Zapier Chatbots** | Feature tiers (count of bots) | Free includes 2; paid adds more. Not usage-metered on tasks. |
### 9.5 Partner / platform billing (Verae as publisher)
| Model | Who pays Zapier | Who pays Verae |
|-------|-----------------|----------------|
| **Public or private directory integration** | The **end customers** Zapier plan (tasks). Publishing is **free**; Zapier does not bill the partner for usage of their app. | The customers Verae tenant (middleware plan / API key). |
| **Zapier MCP / SDK (consume)** | Same customer task pool (MCP execute = 2 tasks). SDK is **free in beta**; Zapier will announce when beta pricing starts. | Only if the MCP/SDK action hits Verae. |
| **Powered by Zapier / White Label / embed** | Usually the **product company** (usage-based). End users authorize apps in *your* UI; Zapier has said end users need not have their own Zapier bill for background runs. Contract with Zapier Sales. | Verae bills the product company or the tenant, depending on how you provision keys. |
| **Retired NLA / AI Actions** | Do not sell or design around this. | — |
Verae should not promise “unlimited Zapier” or absorb a customers Zapier invoice unless a White Label contract says so.
### 9.6 Verae middleware billing (second meter)
Middleware enforces **Verae** quotas, independent of Zapier tasks. Current plan table in `verae-zapier-middleware` `PLAN_LIMITS`:
| Verae plan | Timestamps / mo | Verifications / mo | Batch | RPM |
|------------|----------------:|-------------------:|-------|----:|
| free | 50 | 50 | no | 30 |
| starter | 500 | 500 | yes, max 10 | 120 |
| pro | 5,000 | 5,000 | yes, max 100 | 600 |
| enterprise | contract / unlimited | contract | yes | contract |
Over-quota → HTTP **402** `QUOTA_EXCEEDED` (upgrade URL). Wrong plan for batch → **403** `PLAN_UPGRADE_REQUIRED`. Those errors must surface in the Zap; they are not Zapier task overages.
A client therefore sees **two invoices**: Zapier (tasks) and Verae (timestamps). Design Zaps so a 402 does not retry in a tight loop (that still burns Zapier tasks on each failed? — failed actions are **not** Zapier tasks, but Autoreplay/retries can still hammer Verae).
### 9.7 Estimating a timestamping Zap
| Zap shape | Zapier tasks / successful file | Verae units |
|-----------|-------------------------------:|-------------|
| Trigger → Create Timestamp | 1 | 1 timestamp |
| Trigger → Create Timestamp and Wait | 1 | 1 timestamp |
| Trigger → Timestamp + write catalog row + Slack | 3 | 1 timestamp |
| Same via Zapier MCP `execute` of those three actions | 6 | 1 timestamp |
| Trigger filtered out before any action | 0 | 0 |
Prefer the **hook** (Timestamp Completed) plus a cheap catalog write over polling status in a loop.
---
## 10. Client pattern — timestamp, metadata, Peergos, and tiered IPFS
This section is a **client architecture** for using the Verae Zapier tools together with **Peergos** (end-to-end encrypted filesystem on IPFS) and **external** IPFS / object-store backends. It is **not** implemented as connector operations today. Do not invent Peergos or AWS APIs in the Zapier app; add middleware routes only when those APIs exist and are documented.
Goal: clients can (1) timestamp content, (2) append that proof to metadata, (3) store the file in Peergos, (4) pin a **hot cache** on one or more IPFS providers, (5) migrate bytes to **long-term low-cost** object storage (Amazon S3 Glacier family, or an Apache Iceberg catalog on S3), and (6) **rehydrate on demand** when an IPFS request hits an index — without keeping every file live on a gateway 24/7.
![Timestamped files: Peergos, pin cache, and cold retrieve-on-demand](docs/diagrams/12-peergos-ipfs-tiered-storage.svg)
### 10.1 What each layer is for
| Layer | Role | Stays hot? |
|-------|------|------------|
| **Zapier + Verae connector** | Orchestrate: hash → timestamp → write metadata; notify on `timestamp.completed` | n/a (control plane) |
| **Verae Time** | Blockchain timestamp of a payload (typically a **content hash** or a metadata JSON, not the raw file bytes) | Proof is small; keep forever |
| **Metadata / index** | Maps `cid``jobId`, Verae certificate fields, Peergos path, storage class, restore handle | **Yes** — this is the only always-on map |
| **Peergos** | User-owned, e2e-encrypted filesystem on IPFS/libp2p. Host cannot read file or most metadata. Sharing and apps stay in the users graph. | Users chosen Peergos host; not a public CDN |
| **Cached pin (hot IPFS)** | Pinning service or your Kubo cluster (Pinata, Filebase, web3.storage, self-hosted). Serves frequent `ipfs get` / gateway hits. | **Only for a TTL or working set** |
| **Cold object store** | Amazon **S3 Glacier Instant Retrieval**, **Flexible Retrieval**, or **Deep Archive**; or another cheap archive (Filecoin deal, Storj, Backblaze B2 + lifecycle). Optional **Apache Iceberg** table on S3 as the *catalog* of CID → bucket/key → storage class → restore job. | **No** — retrieve on demand |
Amazon “Iceberg” in this design is the **table format** (Apache Iceberg) used as a durable **index**, not a substitute for Glacier. Long-term **bytes** live in Glacier-class (or equivalent) object storage. The user-facing name “Iceberg” is easy to mix with Glacier; keep the two distinct in customer docs.
### 10.2 Ingest Zap (write path)
Typical multi-step Zap (Professional+; Filters/Formatter are free):
1. **Trigger** — new file in Drive, Dropbox, email, or a Peergos outbox / webhook. (0 Zapier tasks)
2. **Hash** — SHA-256 (or the hashAlg Verae accepts) of the bytes, or of the canonical metadata envelope. Prefer hashing **ciphertext** if the file is already encrypted for Peergos, so the timestamp commits to what is stored.
3. **Create Timestamp** (or Create and Wait) — `data` = hash or compact JSON `{ cid?, sha256, size, mime, source }`. (1 Zapier task, 1 Verae timestamp)
4. **Store file in Peergos** — user or service account writes the file into `/cubes/…` or a shared folder via a **documented** Peergos API or a future middleware route. Peergos assigns / retains an IPFS **CID** for the blocks.
5. **Hot pin (optional)** — Pinning Services API (or Filebase/Pinata) pins that CID for a cache TTL (hoursdays), not forever.
6. **Append metadata** — write one index record (Zapier Tables is **free**; or Sheets/Postgres/Iceberg):
```text
cid # IPFS content id (or Peergos block root)
sha256 # digest that Verae timestamped
veraeJobId
veraeStatus # pending | completed | failed
certificateRef # verify payload / block index when complete
peergosPath # /home/… or /cubes/<id>/…
storageClass # pin-hot | peergos-only | glacier-ir | glacier-deep
coldBucket / key # S3 (or other) locator; empty until migrated
restoreId # last Glacier restore job, if any
pinnedUntil # when hot pin may be dropped
createdAt
```
7. **Timestamp Completed** hook — when middleware finishes the job, update the same row with certificate fields and notify Slack/email. (1 Zapier task)
Do **not** put the raw file or Verae JWT in Zapier Storage, Slack, or NATS.
### 10.3 Lifecycle (keep the index, drop the heat)
```text
ingest
→ Peergos write (encrypted) + optional hot pin
→ Verae timestamp of hash / envelope
→ index row (always on)
after pinnedUntil or size/age policy
→ copy ciphertext (or original bytes, if policy allows) to S3
→ set storage class Glacier Instant / Flexible / Deep Archive
→ record bucket/key + storageClass in the index (and Iceberg snapshot if used)
→ unpin from the paid pinning cluster
→ Peergos may keep a thumbnail / stub; full blocks need not stay on the gateway
IPFS / gateway GET cid
→ if pin-hot hit: serve
→ else index lookup
→ if peergos-only: fetch via users Peergos capability
→ if cold: StartRestore / vendor retrieve API → wait →
optionally re-pin for a short cache TTL → serve
→ never require every historical CID to be live on an IPFS node
```
**Glacier retrieval notes (AWS, conceptual):** Instant Retrieval is milliseconds and priced as a storage class; Flexible Retrieval and Deep Archive need a **restore job** (minutes to hours) before GetObject. The index must store enough to call the restore API (`bucket`, `key`, `versionId`, `restoreId`). Map IPFS requests to that restore; return `202` + `Retry-After` to the gateway until the object is hydrated.
**Apache Iceberg** (optional): partition the catalog by `storageClass` and date so you can expire pin rows and audit restores with SQL, without loading every object. Iceberg does not store the file bytes.
### 10.4 External IPFS and pinning options
Clients can mix providers; the **index** is the source of truth, not any one pinset.
| Backend | Typical use |
|---------|-------------|
| Self-hosted Kubo / cluster | Hot working set you control |
| Pinata, Filebase, web3.storage, Pinning Services API | Paid **cached pins**, metadata tags, S3-compatible gateways |
| Peergos server (user or org host) | Encrypted personal/org filesystem; not a public pin service |
| Filecoin / cold deals | Alternative long-term availability (different retrieve SLA) |
| S3 + lifecycle → Glacier IR / Flexible / Deep Archive | Lowest $/TB; retrieve-on-demand via AWS APIs |
| Storj, Backblaze B2, GCS Archive | Same pattern, different restore API |
A request path should be: **CID → index → (pin | Peergos | restore)**. Do not walk every pin provider on every miss.
### 10.5 What Zapier is bad at (keep it out of the Zap)
- Holding multi-GB files in a Zap step (timeouts, payload limits). Hash and pass **references** (Drive id, Peergos path, CID).
- Being the IPFS gateway. Use a small **retrieval service** (your infra) that reads the index and talks to Peergos / pin / Glacier.
- Polling Glacier restore every second (burns tasks). Use a webhook, queue, or “Find Job Status”-style search on a timer Zap with a Filter.
### 10.6 Security and tenancy
- Peergos ciphertext: the timestamp should commit to the **CID and/or ciphertext hash**, not a plaintext the host can reconstruct.
- Capabilities / sharing stay in Peergos; the public index should not leak readable paths or unencrypted names if the threat model forbids it (store opaque ids).
- Middleware still never exposes Verae JWTs. Retrieval workers use **tenant-scoped** cloud credentials, not the Zapier connection.
- 402/403 from Verae must not be “fixed” by falling back to an unauthenticated public pin of customer data.
### 10.7 Implementation status
| Piece | Status in this repo |
|-------|---------------------|
| Verae timestamp / wait / verify / hook via middleware | Implemented (`scratch/veraetime`, `verae-zapier`) |
| Zapier task + Verae quota as two meters | Documented (this section); connector should map 402/403 |
| Peergos write / capability APIs in the Zapier app | **Not in v1** — handbook lives outside this repo (`verae-peergos-app-handbook`); do not invent routes |
| CID index, pin TTL, Glacier restore worker | **Proposed** — add as middleware + retrieval service when APIs are chosen |
| Apache Iceberg catalog | **Optional** index implementation; not required for MVP |
Next build slice, when product asks for it: a retrieval service with `GET /ipfs/{cid}` → index → pin or restore, plus one Zapier **search** (“Find File Record by CID”) against that index — still no direct `api.veraetime.net` from Zapier.

View file

@ -0,0 +1,13 @@
# Grok share — Zapier API Key & Usage Management
**URL:** https://grok.com/share/bGVnYWN5LWNvcHk_50aff768-fc34-4db7-8842-df4a058b815e
**Title (public page):** Zapier API Key & Usage Management
## Extraction status
The share is a JS-rendered Grok conversation. Unauthenticated fetch and search returned only the title. Browser MCP could not attach (existing Chrome profile lock) during planning.
**TODO (PR 2):** Open the share in a browser, copy the full transcript into this file (user turns + assistant turns), then map any key/usage model onto `packages/zappier` (portal keys, rate card, monthly credit) vs `packages/verae-zapier-middleware` (`zmw_`, `PLAN_LIMITS`).
Until that extract exists, treat `/Users/marchon/zappier` (now `packages/zappier`) as the implemented API-key and usage platform.

View file

@ -0,0 +1,24 @@
# Provenance
This workspace composes three existing trees. Canonical work after import is here (`/Users/marchon/apps/zapier`). Originals were not deleted.
| Package / tree | Imported from | Role |
|----------------|---------------|------|
| `packages/zappier` | `/Users/marchon/zappier` | Commercial platform: custom rate card, tiers, API keys, customer portal, admin, Stripe meter, PO invoices, usage ledger. Demo `/v1/transform` and `/v1/storage` are **not** Verae. |
| `packages/verae-zapier-middleware` | `/Users/marchon/datacubes/verae-zapier-api/verae-zapier-middleware` | Verae timestamp adapter: `/zapier/v1/*`, MOCK_VERAE, NATS workers, REST Hooks. Coarse `PLAN_LIMITS` — not used on the public edge after composition. |
| `packages/verae-zapier` | `/Users/marchon/datacubes/verae-zapier-api/verae-zapier` | Zapier Platform CLI app (JS). Today talks to middleware with `zmw_` keys; will talk to zappier `x-api-key` after PR 5. |
| `vendor/zapier-platform` | `/Users/marchon/research/zapier-platform` (upstream `git@github.com:zapier/zapier-platform.git`) | Official CLI/core/schema/examples. Reference only; do not fork for product code. |
| `docs/00-sources/veraetime-openapi.yaml` | `https://api.veraetime.net/docs/swagger/openapi.yaml` | Snapshot of live Timestamping Service API 1.0.0. |
| `docs/00-sources/workspace-brief.md` | Original `README.MD` in this repo | Feature list am and Grok share URL. |
| `docs/00-sources/getting-started-research.md` | `/Users/marchon/research/verae-research/07-integrations-zapier/zapier/getting-started.md` | Research notes (paths still mention the old research workspace). |
| `docs/01-product/zapier-billing-research.md` | `…/zapier/docs/zapier-billing.md` | Zapier task pricing vs Verae meters. |
| `research/zapier` | `/Users/marchon/research/verae-research/07-integrations-zapier/zapier` | Full vendor-research workspace: catalog scrapes, contacts, 15 verticals, cloned Zapier repos, playbooks, diagrams, TS scratch connector. `node_modules` / `.venv` / nested `.git` omitted. |
**Not imported:**
- any `node_modules` or Python `.venv`
- nested `.git` metadata inside cloned repos
- `zappier.db`, middleware `data/` stores
**Not yet wired:** zappiers Zapier demo app does not call Verae. Verae middleware does not use zappiers rate card or portal.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
read https://grok.com/share/bGVnYWN5LWNvcHk_50aff768-fc34-4db7-8842-df4a058b815e
and document all of it into a group of MD files to plan for a zapier integration with with Vera Blockchain
The Verae Blockchain is identified at listed at https://api.veraetime.net/docs/swagger/index.html
the https://api.veraetime.net/docs/swagger/openapi.yaml is funtion reference.
The key features we need to setup for Zapier is for
a) the customers to get their own api key
b) to signup for billing after their usage limit has been exceeded
c) A call to verae server to register a SHA256 object for timestamping or return it's original timestamp with a reference ID
d) A call to Verae server to lookup the exitance and timestamp for an existing Timestamped SHA256 Key
e) A call to Verae server to both register a SHA256 object for timestamping but to also store metadata (2 types public and private)
f) A call to Verae server to both register a SHA256 object for timestamping but to also store metadata (2 types public and private) and to attach a binary object to be stored long term in Encrypted Long Term Storage
g) A call to Verae server to both register a SHA256 object for timestamping but to also store metadata (2 types public and private) and to share an encrypted file in longterm storage with someone else, either as an individial file or as a directory or directory tree with a decription key
h) A call to Verae server to retrieve a both a timestamped SHA256 Object and it's metadata
i) A call to Verae server to retrieve a certified receipt of an existing timestamp - cross signed with a extra seal to record the retrieval of a document (as either a pdf or a json file)
j) setup payment method
k) get invoices for paymets
l) get receipts for payments
m) subscription management
----------
Verae has designed a middleware piece that needs to able to handle incoming and returned traffic - but is not currently connected to verae servers, so we should setup a test harness to test the zapier integration while we do that.
Once we have built the zapier interface, we will implement the service through our designed gateway to send high performance messages through a NATS.io cluster to process the data.
can you review and pull into this directory tree the Zapier Directory I have been working on from ../../research/zapier-platform as a subdirectory or subproject so that I can have you update the documentation, consider these features, and help me get a zapier integratiotn up amd running?

View file

@ -0,0 +1,36 @@
# API gap analysis
Live spec snapshot: [veraetime-openapi.yaml](../00-sources/veraetime-openapi.yaml)
Swagger UI: https://api.veraetime.net/docs/swagger/index.html
## What Verae actually exposes
Auth: `POST /auth/login` → JWT. All API routes `Authorization: Bearer <jwt>`.
| Operation | Path | Notes |
|-----------|------|--------|
| createTimestamp | `POST /api/timestamp` | Body `{ data, hashAlg? }`, **202** `{ jobId }` |
| createBatchTimestamp | `POST /api/batch/timestamp` | `{ items: [...] }` |
| verifyTimestamp | `POST /api/verify` | `{ certificate }``{ valid, timestamp, blockIndex }` |
| verifyBatchTimestamp | `POST /api/batch/verify` | |
| getJobStatus | `GET /api/status/{jobId}` | pending / completed / failed |
| getJobVerification | `GET /api/verify/{jobId}` | |
| getBatchJobStatus | `POST /api/batch/status` | |
| Admin | `/admin/*` | dashboard, metrics, blockchain, queue, timestamps, hash |
| Users | `/auth/users` | admin CRUD |
## Gaps vs README am
- No customer API-key issuance (a) — **zappier owns this**.
- No billing, invoices, payment methods, subscriptions (b, jm) — **zappier owns this**.
- No lookup-by-SHA256 or idempotent “return original timestamp” (c, d).
- No public/private metadata (e, h).
- No encrypted object storage or sharing (f, g).
- No certified retrieval receipt / extra seal (i).
- Timestamp input is `data` + `hashAlg`, not a first-class SHA256 key.
## Adapter rule
The middleware may expose a **richer** Zapier-facing contract (hash index, metadata, receipts) implemented by `MOCK_VERAE` / mock stores.
The **live** client (`packages/verae-zapier-middleware/src/clients/veraeClient.js`) must only call paths in the snapshot OpenAPI. Unknown fields must not be sent to `https://api.veraetime.net`.

View file

@ -0,0 +1,45 @@
# Billing and API keys
Implementation: `packages/zappier`. Manuals: `packages/zappier/docs/`.
## Keys (feature a)
- Customer signup at `/portal` issues an API key immediately (Free plan).
- Header: `x-api-key`.
- Dashboard can regenerate (invalidates the old key).
- Admin can provision customers.
Zapier connection test should use this key, not middleware `zmw_`.
## Pricing (custom)
`openapi.yaml` `operationId` is the rate-card key. List prices are runtime-editable in `/admin`.
Billed cents = `round(list × tier multiplier)`; usage up to `monthlyCreditCents` is free. Per-customer `multiplierOverride` allowed.
Seed tiers: `free` (1.0, 100¢), `pro` (0.5, 1000¢), `business` (0.25, 10000¢).
After composition, rate-card keys become Verae operations (`timestamp`, `timestamp-wait`, `verify`, `status`, …), not demo `transform`/`storage`.
## After limit (feature b)
- Unlisted endpoint on free (no defaultRule) → 403.
- Credit/meter continues; Stripe job reports **delta** above monthly credit (`zappier.api_cents`).
- Portal **Reload balance** ($1$10,000) prepaid drawdown on invoice issue.
- Admin **Invoices** tab for purchase-order customers.
Zapier must surface 402/403 with a link to `/portal` billing (same idea as middleware `upgradeUrl`).
## Invoices and receipts (k, l)
Portal invoice list + print-ready HTML. Admin generate/issue/paid. These are **payment** documents.
Certified timestamp retrieval receipts (feature i) are a different object on the Verae adapter.
## Payment method (j)
Portal Stripe reload is implemented. Card-on-file / Stripe Subscription objects for (m) are an open question; tiers today are customer types in SQLite.
## Two invoices
Customers may pay Zapier for **tasks** and Verae/zappier for **timestamp usage**. See [zapier-billing-research.md](zapier-billing-research.md). Do not meter Verae usage in Zapier task units.

View file

@ -0,0 +1,27 @@
# README features am
Source: [workspace-brief.md](../00-sources/workspace-brief.md). Owners after composition: [composition.md](../02-architecture/composition.md).
| ID | Capability | Owner | Status in imported code | Live Verae OpenAPI |
|----|------------|-------|-------------------------|--------------------|
| a | Customer gets own API key | zappier `/portal` signup + regen | Built (`x-api-key`) | No |
| b | Billing after usage limit | zappier meter + credit + portal reload + Stripe + admin PO | Built | No |
| c | Register SHA256 or return original timestamp + ref | verae middleware | Partial: `POST /zapier/v1/timestamp``jobId`. No idempotent hash key | `POST /api/timestamp` (`data`, `hashAlg`) → 202 `jobId` |
| d | Lookup by SHA256 | verae mock (later) | Missing | No (verify is certificate; status is jobId) |
| e | Timestamp + public/private metadata | verae mock | Missing | No |
| f | e + encrypted LTS binary | verae mock | Missing. Do not use zappier demo `/v1/storage` | No |
| g | Share file/dir + decryption key | verae mock | Missing | No |
| h | Retrieve SHA256 + metadata | verae status/verify | Partial by `jobId` | `GET /api/status/{jobId}`, `GET /api/verify/{jobId}` |
| i | Certified retrieval receipt (PDF/JSON) extra seal | verae mock | Missing. Not a payment invoice | No |
| j | Setup payment method | zappier portal reload (Stripe) | Built (prepaid reload; card-on-file TBD) | No |
| k | Get invoices | zappier portal + admin | Built | No |
| l | Get payment receipts | zappier invoice HTML/print | Built as invoices | No |
| m | Subscription management | zappier tiers `free`/`pro`/`business` | Built as customer type, not Stripe Subscription | No |
## Zapier Platform nouns (today vs target)
**Today (`packages/verae-zapier`):** Create Timestamp, Create Timestamp and Wait, Batch, Verify, Find Job Status, Timestamp Completed (REST Hook). Auth: Bearer `zmw_`.
**Target public edge (`packages/zappier` `/v1/*`):** same operations, metered, `x-api-key`. Plus Find Usage / List Invoices from zappier.
**Not in v1 listing:** Verae admin HTML, user CRUD, get-block-by-hash.

View file

@ -0,0 +1,291 @@
# Zapier cost structure and billing models
**Planning brief for Verae Time**
Source: official Zapier pricing at [zapier.com/pricing](https://zapier.com/pricing), captured 1718 August 2026 (USD).
Confirm live rates before a customer quote. The rate card at `/pricing/rates` was not fetchable at write time; multipliers below are those Zapier publishes on the main pricing page and “What is a task?” article (updated June 2026).
This document explains **how Zapier charges**, with examples, so we can design **Veraes** meter without colliding with or accidentally subsidizing Zapier.
---
## 1. The one idea that unlocks the rest
Zapier does **not** charge per Zap, per app, or per seat on Professional. It charges for **successful work**.
You buy two things that travel together:
1. A **plan level** — the feature set (Free / Professional / Team / Enterprise).
2. A **task tier** — a monthly (or Enterprise annual) allowance of **tasks**.
Then three other economies sit **beside** that, not inside it:
| Economy | Unit | Used for |
|---------|------|----------|
| **Core Zapier** | Task | Zaps, AI-by-Zapier, Code-by-Zapier, MCP, SDK |
| **Agents add-on** | Activity | Zapier Agents (does not draw tasks) |
| **Chatbots add-on** | Feature tier (number of bots) | Zapier Chatbots (not task-metered) |
| **Verae (us)** | Timestamp / verify / batch | Middleware `PLAN_LIMITS` — a **second invoice** |
Verae never appears on the Zapier invoice unless we sign a **White Label / Powered by Zapier** contract and resell Zapier usage ourselves.
---
## 2. What a task is (and is not)
A **task** is counted when Zapier **successfully completes** a unit of work. Failures are free.
### Counts
- A successful **action in another product**: Create Timestamp, post Slack, add a Drive file, write a Sheet row, call a webhook.
- A successful **Zapier MCP** tool execute (read or write).
- **AI by Zapier** and **Code by Zapier** (with multipliers / extra runtime — §4).
### Does not count
- The **trigger** (including polling every 115 minutes).
- **Logic and prep**: Filter, Paths, Formatter, Delay, Looping, Sub-Zap, Digest, Zapier Manager, Storage.
- **Zapier Tables** and **Zapier Forms** triggers and actions.
- Building, turning on, or testing a Zap until a step actually succeeds in production.
Shared pool: one allowance for the whole account. There is no separate MCP budget.
---
## 3. Plan levels (what you can build)
Plans are cumulative. Task **volume** is chosen separately (§5).
| | Free | Professional | Team | Enterprise |
|--|------|--------------|------|------------|
| Seats | 1 | 1 | 25 | Unlimited |
| Zap shape | Two-step only (trigger + 1 action) | Multi-step | Multi-step | Multi-step |
| Polling interval | 15 min | 2 min | 1 min | 1 min |
| Premium apps, webhooks | No | Yes | Yes | Yes |
| Filters, Paths, Formatter, AI by Zapier | No | Yes | Yes | Yes |
| Shared Zaps / connections, SAML SSO | No | No | Yes | Yes |
| SCIM, app controls, custom retention, TAM, BYOM | No | No | No | Yes |
| Task cycle | Monthly | Monthly | Monthly | **Annual** task limit |
| Pay-per-task overflow | No | Optional | Optional | Custom |
| Entry price (annual, USD/mo) | $0 (100 tasks) | **$19.99** (750 tasks) | **$69** (2,000 tasks) | Sales |
Also: 14-day Professional trial (no card). Non-profit: extra 15% off the subscription, **not** on pay-per-task. Live chat on Professional only at the **2,000+** task tier.
**Implication for Verae:** a Free customer can run **only** “new file → Create Timestamp” (two steps). Anything with a catalog write, Slack notify, or Wait **plus** another action needs Professional.
---
## 4. Variable pricing inside the task pool
Not every successful step costs one task. Zapier uses **multipliers** so expensive compute costs more of the same allowance.
| Work | Tasks per success | Notes |
|------|------------------:|-------|
| Typical third-party action (Verae Create Timestamp, Slack, Drive, Sheets, webhook) | **1** | The default. Design around this. |
| Standard AI by Zapier (default model) | **1** | Same as a normal action |
| Advanced AI by Zapier | **3** | Confirm on `/pricing/rates` |
| Premium AI by Zapier | **5** | Confirm on `/pricing/rates` |
| Zapier MCP `execute` (read or write) | **2** | Discover / inspect / enable are free meta-tools |
| Code by Zapier | 0 during **included** runtime; then **1 per extra 30 s** | Included: Free 1 s, Pro/Team 30 s, Enterprise 2 min. Extended runtime is opt-in on paid plans (18 min cap). |
| Zapier SDK | Free **while in beta** | Zapier will announce when beta pricing starts; assume it will join the task pool |
**Failed steps = 0 Zapier tasks.** They can still hit Verae (Autoreplay / customer retries). That is our problem, not Zapiers.
---
## 5. Task tiers and list price (USD, August 2026)
Self-serve is sold as **plan × tier**. Annual is ~33% off monthly. Figures are **per month**.
### Professional
| Tasks / mo | Annual / mo | Monthly / mo | Implied $/task (annual) |
|-----------:|------------:|-------------:|------------------------:|
| 750 | $19.99 | $29.99 | $0.027 |
| 1,500 | $39.00 | $58.50 | $0.026 |
| 2,000 | $49.00 | $73.50 | $0.025 |
| 5,000 | $89.00 | $133.50 | $0.018 |
| 10,000 | $129.00 | $193.50 | $0.013 |
| 20,000 | $189.00 | $283.50 | $0.0095 |
| 50,000 | $289.00 | $433.50 | $0.0058 |
| 100,000 | $489.00 | $733.50 | $0.0049 |
| 200,000 | $769.00 | $1,149.00 | $0.0038 |
| 500,000 | $1,499.00 | $2,199.00 | $0.0030 |
| 1,000,000 | $2,199.00 | $3,299.00 | $0.0022 |
| 2,000,000 | $3,389.00 | $5,099.00 | $0.0017 |
### Team (starts at 2,000)
| Tasks / mo | Annual / mo | Monthly / mo | Implied $/task (annual) |
|-----------:|------------:|-------------:|------------------------:|
| 2,000 | $69.00 | $103.50 | $0.035 |
| 5,000 | $119.00 | $178.50 | $0.024 |
| 10,000 | $169.00 | $253.50 | $0.017 |
| 20,000 | $249.00 | $373.50 | $0.012 |
| 50,000 | $399.00 | $598.50 | $0.008 |
| 100,000 | $599.00 | $898.50 | $0.006 |
| 1,000,000 | $2,499.00 | $3,749.00 | $0.0025 |
| 2,000,000 | $3,999.00 | $5,999.00 | $0.0020 |
Teams **entry** $/task is higher than Pro because you are buying seats, shared connections, and SSO — not cheaper tasks.
Tiers also exist at 300k / 400k / 750k / 1.25M / 1.5M / 1.75M (see getting-started §9 or zapier.com/pricing). Above 2M: Sales.
---
## 6. Overflow (pay-per-task)
On paid plans, a toggle decides what happens at the allowance:
| Setting | What happens |
|---------|----------------|
| **On** | Zaps and MCP keep running. Extra tasks bill at **1.25×** the plans base $/task (annual) or **2.5×** (monthly). Hard ceiling: **3×** subscribed tasks, then pause until next cycle or upgrade. |
| **Off** | Everything **stops** at the allowance. |
| **Free** | No overflow. Hits 100 and stops. |
Worked overage: Professional 750 annual → base ≈ $19.99 / 750 = **$0.0267**. Overflow ≈ **$0.0333**/task. Monthly base ≈ $0.0400; overflow ≈ **$0.100**/task. Same usage is ~3× more expensive on monthly overflow than annual overflow.
Enterprise uses an **annual** pool so seasonal spikes do not reset every month.
---
## 7. Other Zapier products (different models)
| Product | Model | Relation to tasks |
|---------|--------|-------------------|
| **Zap workflows** | Task | Core |
| **Zapier MCP** | Same task pool; **2 tasks** per successful execute | Meta-tools free |
| **Zapier SDK** | Beta free; expect tasks later | Consume, not publish |
| **AI by Zapier** | Task × model tier (1 / 3 / 5) | Inside the Zap |
| **Code by Zapier** | Included seconds + 1 task / 30 s extra | Inside the Zap |
| **Tables / Forms** | Plan limits (records, pages, upload size); **0 tasks** when used in Zaps | Separate caps |
| **Agents** | **Activities** (Free 400/mo; paid ~$33.33/mo annual for 1,500). Per-run cap 10 (Free) / 40 (paid). | Does **not** use tasks |
| **Chatbots** | Bot-count tiers (Free 2; paid ≈5 / ≈20) | Does **not** use tasks |
| **Canvas / Copilot** | Included; Free Copilot has a daily message limit | Not a usage meter |
| **Directory integration (us)** | **$0** to publish. Customers Zapier plan pays tasks. | Partner is not billed |
| **White Label / Powered by Zapier / embed** | Usage-based **to the product company**. End users may not need their own Zapier bill. | Sales contract |
| **NLA / AI Actions** | Retired | Do not design around this |
---
## 8. Worked examples (so the variables are visible)
Assume **Professional, billed annually**, and that every named action succeeds. Verae units are **our** meter.
### Example A — Two-step timestamp (Free-capable)
`New file in Drive``Verae: Create Timestamp`
| Volume | Zapier tasks | Fits | Zapier $ (annual) | Verae units |
|-------:|-------------:|------|-------------------|------------:|
| 80 files / mo | 80 | Free | $0 | 80 timestamps |
| 200 | 200 | Pro 750 | $19.99 | 200 |
| 600 | 600 | Pro 750 | $19.99 | 600 → **starter** (500) overflows **us** |
Lesson: the customer can still be on Zapier Free while **we** 402 them. The two meters trip at different points.
### Example B — Production pattern we recommend
`New file` → Filter → `Create Timestamp` → Formatter → write catalog row (Tables = **0** tasks) → Slack
If Slack is the only other third-party action: **2 tasks / file** (timestamp + Slack). Tables write is free.
| Files / mo | Passing filter | Zapier tasks | Cheapest Pro tier | Zapier $ | Verae timestamps |
|-----------:|---------------:|-------------:|-------------------|----------:|-----------------:|
| 1,000 | 20% (200) | 400 | 750 | $19.99 | 200 |
| 1,000 | 100% | 2,000 | 2,000 | $49 | 1,000 → **pro** on our side |
| 10,000 | 100% | 20,000 | 20,000 | $189 | 10,000 → enterprise / contract |
Filter early: 800 of 1,000 files dropped = **0 Zapier tasks and 0 Verae stamps** for those 800.
### Example C — Create and Wait vs hook
- **Wait in the Zap:** 1 task (the wait action). Fine for interactive “give me the certificate now.”
- **Async + Timestamp Completed hook + update row:** 1 task to create + 1 task when the hook fires an action = 2 tasks, but the Zap does not sit open. Prefer this at volume.
Polling `Find Job Status` every minute in a loop is the anti-pattern: each successful search is a task.
### Example D — Same work via Zapier MCP (an agent)
Agent calls: execute Create Timestamp, execute write Sheet, execute Slack.
3 executes × **2 tasks** = **6 tasks / file**, plus any Agent **activities** if they used Zapier Agents.
200 files = **1,200 Zapier tasks** → Pro 1,500 at **$39**/mo — three times the Zapier cost of Example B for the same Verae stamps (200).
**Planning rule:** MCP-shaped clients burn Zapier 2× per hop. Do not price Verae as if the customers only cost is our stamp.
### Example E — AI enrichment in the Zap
`New file` → Standard AI extract metadata (1) → Create Timestamp (1) → Sheet (1) = **3 tasks**.
Swap in Premium AI: **5 + 1 + 1 = 7 tasks**.
1,000 files: 3,000 vs 7,000 tasks → $89 vs $129 on Pro annual.
AI model choice is the customers Zapier bill, not ours — unless we bury AI inside **our** action (we should not).
### Example F — Overflow month
Customer on Pro 750 annual ($19.99). A campaign pushes 1,400 successful timestamp actions (and nothing else).
- Included: 750
- Overflow: 650 × ~$0.0333 ≈ **$22**
- Total Zapier ≈ **$42** that month
- Still under the 3× ceiling (2,250)
If they were on **monthly** Pro 750 ($29.99): overflow 650 × ~$0.100 ≈ **$65** + $30 = **~$95**.
### Example G — White Label (we are the billed party)
If Verae embeds Zapier and Zapier bills **us** per task, then 10,000 customer files × 2 tasks = 20,000 tasks ≈ **$189**/mo (Pro annual list) **plus** our timestamp COGS. That number has to sit in **our** COGS, not the customers Zapier account.
---
## 9. Veraes meter today (so we can place it)
From `verae-zapier-middleware` `PLAN_LIMITS`:
| Verae plan | Timestamps / mo | Verifications | Batch | RPM |
|------------|----------------:|--------------:|-------|----:|
| free | 50 | 50 | no | 30 |
| starter | 500 | 500 | yes, max 10 | 120 |
| pro | 5,000 | 5,000 | yes, max 100 | 600 |
| enterprise | unlimited / contract | contract | yes | 3,000 |
Over-quota → **402** `QUOTA_EXCEEDED`. Batch on free → **403** `PLAN_UPGRADE_REQUIRED`.
These fire **independently** of Zapiers pause / pay-per-task.
---
## 10. How this should shape *our* billing
1. **Never bundle “unlimited Zapier.”** We do not control their task tier, MCP multiplier, or overflow toggle.
2. **Meter what we uniquely do:** accepted timestamp jobs, verifies, maybe stored GB / pin-days / Glacier restores — not Zap steps.
3. **Mirror Zapiers shape if we want familiarity:** plan + included units + optional overage with a ceiling. Customers already understand 402 vs pause.
4. **Do not use Zapier-like 2× MCP pricing on our API.** MCP is Zapiers tax on agent hops. Our HTTP `/zapier/v1/timestamp` should stay **one Verae unit** whether the Zap used 1 task or an agent used 2.
5. **Price batch as a plan gate**, not as “N Zapier tasks.” Zapier will still charge **1 task** for the batch action; we decide whether 100 items cost 1 or 100 of *our* units. Today we count batch size against `batchMaxItems` and timestamp quota.
6. **Hook-friendly packaging:** include enough monthly stamps that the recommended “create + hook + catalog” pattern is cheaper on **our** side than polling.
7. **Three customer motions, three packages:**
- **Directory user** — they pay Zapier + they pay Verae (API key). Our list price must look fair next to ~$0.02$0.03 per Zapier task at Pro entry.
- **MCP / agent user** — they already pay 2 Zapier tasks per hop; keep Verae simple or they will skip us.
- **Embed / White Label** — we may owe Zapier usage; our price to the end user must cover that COGS or we only sell the timestamp and let them bring their own Zapier.
8. **Storage / Peergos / Glacier is a third meter** (see getting-started §10). Do not hide pin-days or restore fees inside “one timestamp.”
9. **Failed Zapier steps are free for them, not for us** if we already accepted the job. Bill on **accepted jobId** (202), not on Zapiers success callback.
10. **Quote two lines** in every proposal: “Your Zapier plan (estimate N tasks)” and “Verae (M timestamps).” Never a single blended number.
### Rough juxtaposition (order-of-magnitude)
At Pro 750 annual, Zapiers included work is about **2.7 cents per task**.
If our Create Timestamp is one Zapier task + one Verae stamp, the customers *Zapier* share of a two-step Zap is ~$0.027. Our stamp should be priced from **our** chain/ops cost, not matched 1:1 to that 2.7¢ — but if we charge **dollars** per stamp while Zapier is **cents** per step, SMB Zaps will feel “Verae-expensive” even when Zapier is the bigger invoice at volume.
---
## 11. Planning checklist
- [ ] For each target Zap, count **billable Zapier actions** (not steps on the canvas).
- [ ] Multiply MCP executes by **2**.
- [ ] Apply AI 1 / 3 / 5 if they enrich in the Zap.
- [ ] Pick the cheapest **Zapier** tier that covers that task count (or overflow math).
- [ ] Count **Verae** timestamps / verifies / batch separately; check 50 / 500 / 5,000 tripwires.
- [ ] If we embed Zapier, put Zapier list price into **our** COGS.
- [ ] Keep Peergos pin / Glacier restore off the timestamp SKU.
- [ ] Re-check [zapier.com/pricing](https://zapier.com/pricing) before any contract; this brief is dated **18 August 2026**.

View file

@ -0,0 +1,33 @@
# Composition: zappier commercial edge + Verae adapter
```text
Users → Zapier UI
Zapier cloud runs packages/verae-zapier
--HTTPS, x-api-key--> packages/zappier /v1/*
(meter, quote, 401/403, usage)
--internal HTTPS--> packages/verae-zapier-middleware /zapier/v1/*
--sync--> api.veraetime.net or MOCK_VERAE
--NATS--> workers --> Verae + Zapier REST Hooks
Humans → zappier /portal signup, API key, usage, reloads, invoices
Ops → zappier /admin rate card, tiers, customers, PO invoices
```
## Ownership
| Concern | Package |
|---------|---------|
| API keys, custom pricing, Stripe, invoices, portal | `packages/zappier` |
| Timestamp/verify/status, NATS, REST Hooks, mock Verae | `packages/verae-zapier-middleware` |
| Zapier Platform nouns (creates/searches/triggers) | `packages/verae-zapier` |
| Official SDK reference | `vendor/zapier-platform` |
Zapier never talks to NATS or `api.veraetime.net`.
Do not reimplement Stripe, invoices, or the rate-card UI inside the Verae middleware.
Public keys are zappier `x-api-key` (issued at portal signup). Middleware `zmw_` / `PLAN_LIMITS` stay internal until composition PR 4 removes them from the public path.
Demo zappier routes `/v1/transform` and `/v1/storage` are a metering sandbox, not Verae timestamping or encrypted LTS.
Payment invoices (zappier) are not certified timestamp receipts (Verae feature i).

View file

@ -0,0 +1,101 @@
# NATS Subject Topology
All subjects are prefixed with `verae.zapier.` to isolate this platform from other Verae messaging.
## Streams
| Stream | Subjects | Retention | Purpose |
|--------|----------|-----------|---------|
| `ZAPIER_JOBS` | `verae.zapier.jobs.watch` | Work queue | Poll Verae for job status |
| `ZAPIER_EVENTS` | `verae.zapier.jobs.events` | Limits (time) | Terminal job outcomes |
| `ZAPIER_WEBHOOKS` | `verae.zapier.webhooks.deliver` | Work queue | POST to Zapier hook URLs |
| `ZAPIER_USAGE` (optional) | `verae.zapier.usage` | Limits | Billing export |
## Subjects
### `verae.zapier.jobs.watch`
**Published by:** HTTP edge after successful `POST /api/timestamp` (or batch item).
**Consumed by:** `job-poller` durable consumer (queue group).
**Payload**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tenantId` | string | yes | Owning tenant |
| `jobId` | string | yes | Verae job id |
| `tokenRef` | string | preferred | Opaque ref to resolve Verae credentials (avoid raw JWT) |
| `veraeToken` | string | discouraged | Only if tokenRef unavailable; redacted in logs |
| `enqueuedAt` | ISO-8601 | yes | Enqueue time |
| `attempt` | number | yes | Delivery attempt (0-based) |
| `maxAttempts` | number | yes | Stop after this many polls |
| `intervalMs` | number | yes | Suggested delay between polls |
| `traceId` | string | yes | Correlation id for debug |
### `verae.zapier.jobs.events`
**Published by:** Job poller when status is `completed`, `failed`, or `timeout`.
**Consumed by:** Event router → webhook enqueue; optional waiters on HTTP edge.
**Payload**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `event` | string | yes | `timestamp.completed` \| `timestamp.failed` \| `timestamp.timeout` |
| `tenantId` | string | yes | Tenant id |
| `jobId` | string | yes | Job id |
| `status` | object | yes | Verae `StatusResponse` shape (or synthetic timeout) |
| `traceId` | string | yes | Correlation id |
| `emittedAt` | ISO-8601 | yes | Event time |
### `verae.zapier.webhooks.deliver`
**Published by:** Event router for each matching subscription.
**Consumed by:** `webhook-deliver` queue group.
**Payload**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `hookId` | string | yes | Stored subscription id |
| `tenantId` | string | yes | Tenant |
| `targetUrl` | string | yes | Zapier REST Hook URL |
| `event` | string | yes | Event name |
| `payload` | object | yes | Body POSTed to Zapier |
| `attempt` | number | yes | Attempt count |
| `traceId` | string | yes | Correlation id |
### `verae.zapier.usage` (optional)
| Field | Type | Description |
|-------|------|-------------|
| `tenantId` | string | Tenant |
| `action` | string | `timestamp` \| `verify` \| `status` \| … |
| `amount` | number | Increment |
| `at` | ISO-8601 | Timestamp |
## Consumers
| Name | Stream | Mode | Notes |
|------|--------|------|-------|
| `job-poller` | `ZAPIER_JOBS` | Pull, queue | Nak with delay when still pending |
| `event-webhook-router` | `ZAPIER_EVENTS` | Push/pull | Fan-out to deliver subjects |
| `webhook-deliver` | `ZAPIER_WEBHOOKS` | Pull, queue | HTTP POST with backoff |
| `usage-writer` | `ZAPIER_USAGE` | Optional | Persist metering |
## Ack semantics
| Situation | Action |
|-----------|--------|
| Job still `pending` | `Nak` with delay ≈ `intervalMs` or republish with `attempt+1` |
| Job terminal | Publish event, `Ack` watch message |
| Webhook HTTP 2xx | `Ack` |
| Webhook HTTP 5xx / network | `Nak` / redelivery until `max_deliver` |
| Poison message | Term after max_deliver; write DLQ log with `DEBUG_VERAE=webhooks` |
## Security rules
1. Prefer `tokenRef` over embedding Verae JWTs in messages.
2. NATS must use private network + auth (Phase 15: mTLS).
3. Debug logs must redact tokens and `targetUrl` query secrets if any.
4. Treat `targetUrl` as untrusted egress (timeouts, size limits, SSRF allowlist later).

View file

@ -0,0 +1,132 @@
# Architecture Overview
## Purpose
Connect Zapier automations to Verae blockchain timestamping without:
- exposing raw Verae JWTs to end users,
- requiring Zapier to poll async jobs,
- coupling billing/plans to the core timestamping API,
- running multi-instance middleware with in-memory only job queues.
## Components
### 1. `verae-zapier` (Zapier Platform CLI app)
**Runs on:** Zapiers cloud when a Zap step executes.
**Responsibilities:**
- Custom auth field `api_key` (middleware-issued `zmw_…` keys).
- Map Zapier actions/searches/triggers to middleware HTTPS routes.
- Attach `Authorization: Bearer <api_key>` on every request.
- Translate middleware `402` / `403` into user-visible Zapier errors.
**Does not:**
- Call `api.veraetime.net` directly.
- Speak NATS.
- Enforce plan quotas (middleware does).
### 2. `verae-zapier-middleware` HTTP edge
**Runs on:** Your infrastructure (public HTTPS).
**Responsibilities:**
- Tenant identity and API keys.
- Auth bridge: API key → Verae login → JWT (server-side).
- Entitlement checks and usage metering.
- Synchronous API surface under `/zapier/v1/*`.
- REST Hook subscribe/unsubscribe storage.
- Publish async work to NATS when `NATS_ENABLED=true`.
### 3. NATS + JetStream
**Runs on:** Private network with middleware.
**Responsibilities:**
- Durable work queue for job status polling.
- Event stream for terminal job states.
- Work queue for Zapier webhook HTTP delivery with retries.
### 4. Workers
**Runs on:** Same deploy as middleware or separate worker processes.
| Worker | Consumes | Calls |
|--------|----------|-------|
| Job poller | `verae.zapier.jobs.watch` | `GET /api/status/{jobId}` on Verae |
| Event router | `verae.zapier.jobs.events` | Enqueues webhook deliveries |
| Webhook deliver | `verae.zapier.webhooks.deliver` | `POST` Zapier `targetUrl` |
### 5. `api.veraetime.net` (Verae Timestamping Service)
**Source of truth** for login, timestamp jobs, status, and verification.
OpenAPI: production Swagger / `Verae-Swagger.yaml`.
## Request flows
### A. Create Timestamp (async)
```text
Zapier → POST /zapier/v1/timestamp
Middleware: authenticate, checkEntitlement, POST /api/timestamp
Middleware: publish jobs.watch → return 202 { jobId }
Worker: poll status until terminal → publish jobs.events
Event router: match webhooks → publish webhooks.deliver
Webhook worker: POST hooks.zapier.com/...
```
### B. Create Timestamp and Wait
```text
Zapier → POST /zapier/v1/timestamp/wait
Middleware: create + wait for jobs.events (or in-process wait if NATS off)
→ return StatusResponse (completed/failed) or pending+jobId on timeout
```
### C. Auth connection test
```text
Zapier → GET /zapier/v1/auth/me Authorization: Bearer zmw_…
Middleware: resolve API key → tenant → optional validate Verae token
→ { tenantId, plan, usage, ... }
```
## Feature flags
| Flag | Effect |
|------|--------|
| `NATS_ENABLED=false` | In-process job poller; still full HTTP API (Phase 6 path) |
| `NATS_ENABLED=true` | JetStream workers; no in-process poller |
| `MOCK_VERAE=true` | No live Verae; deterministic mock jobs for tests |
| `DEBUG_VERAE=…` | Runtime failure tracing (see debugging.md) |
## Security boundaries
```text
Public Internet
├─ Zapier → Middleware HTTPS only
└─ Middleware → Zapier webhook HTTPS only
Private
├─ Middleware ↔ NATS
└─ Middleware/Workers → api.veraetime.net HTTPS
```
Never expose NATS ports to the public internet.
## Scaling model
- **HTTP edge:** scale replicas behind a load balancer (stateless except shared store).
- **NATS consumers:** queue groups — adding workers increases poll/deliver throughput.
- **Store:** file JSON is MVP single-node; Postgres/Redis required for multi-node (Phase 15).
## Related documents
- [nats-subjects.md](nats-subjects.md) — subjects, streams, payload schemas
- [../plans/phase-gates.md](../plans/phase-gates.md) — test gates
- [../../TODO.md](../../TODO.md) — full implementation order

17
docs/03-zapier/auth.md Normal file
View file

@ -0,0 +1,17 @@
# Zapier authentication
## Target (after PR 5)
- Type: custom / API key.
- Fields: `apiKey`, `baseUrl` (default public zappier origin).
- Header: `x-api-key`.
- Test: `GET /v1/status` or `GET /v1/me` if added.
- Errors: 401 invalid key; 403/402 quota or unpriced endpoint → user-visible upgrade text pointing at `/portal`.
## Today (imported CLI app)
- Bearer middleware API key `zmw_…`.
- Test: `GET /zapier/v1/auth/me`.
- `afterResponse` maps 402 and `PLAN_UPGRADE_REQUIRED`.
Do not send Verae JWTs to Zapier. Middleware (or zappier→middleware) logs into Verae server-side.

View file

@ -0,0 +1,16 @@
# Zapier operations
Current package: `packages/verae-zapier` (imported from datacubes; still aimed at middleware).
| Noun | Type | Middleware today | Target public path (zappier) |
|------|------|------------------|------------------------------|
| Create Timestamp | create | `POST /zapier/v1/timestamp` | `POST /v1/timestamp` |
| Create Timestamp and Wait | create | `POST /zapier/v1/timestamp/wait` | `POST /v1/timestamp/wait` |
| Create Batch Timestamps | create | `POST /zapier/v1/timestamp/batch` | `POST /v1/timestamp/batch` |
| Verify Certificate | create | `POST /zapier/v1/verify` | `POST /v1/verify` |
| Find Job Status | search | `GET /zapier/v1/status/{jobId}` | `GET /v1/status/{jobId}` |
| Timestamp Completed | REST Hook | subscribe/unsubscribe webhooks | proxy through zappier or map customer id |
Later: Find by SHA256, retrieve metadata, certified receipt, Find Usage, List Invoices.
Reference examples: `vendor/zapier-platform/example-apps/` (custom-auth, files, rest-hooks, create).

View file

@ -0,0 +1,80 @@
openapi: 3.0.0
info:
title: Verae Zapier Middleware API
description: |
Zapier-facing HTTP surface. Zapier CLI app calls these routes only.
Async job watching and webhook delivery may use NATS behind this API.
version: 0.1.0
servers:
- url: http://localhost:3100
description: Local middleware
paths:
/health:
get:
summary: Liveness probe
operationId: health
responses:
'200':
description: OK
/zapier/v1/auth/me:
get:
summary: Validate API key connection (Zapier auth test)
operationId: authMe
security:
- BearerAuth: []
responses:
'200':
description: Tenant + plan + usage
'401':
description: Unauthorized
/zapier/v1/timestamp:
post:
summary: Create timestamp (async jobId)
operationId: createTimestamp
security:
- BearerAuth: []
responses:
'202':
description: Accepted
/zapier/v1/timestamp/wait:
post:
summary: Create timestamp and wait for completion
operationId: createTimestampAndWait
security:
- BearerAuth: []
responses:
'200':
description: Completed or failed status
/zapier/v1/verify:
post:
summary: Verify certificate
operationId: verifyTimestamp
security:
- BearerAuth: []
responses:
'200':
description: Verification result
/zapier/v1/webhooks/subscribe:
post:
summary: REST Hook subscribe
operationId: webhookSubscribe
security:
- BearerAuth: []
responses:
'201':
description: Subscribed
/zapier/v1/webhooks/unsubscribe:
delete:
summary: REST Hook unsubscribe
operationId: webhookUnsubscribe
security:
- BearerAuth: []
responses:
'200':
description: Unsubscribed
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: Middleware API key (zmw_…) or session token (zmt_…)

147
docs/developer/debugging.md Normal file
View file

@ -0,0 +1,147 @@
# Runtime Debugging and Failure Tracing
## Goals
- Trace failures across **HTTP → NATS → Verae → Zapier webhook** without redeploying.
- Keep secrets out of logs even when debug is enabled.
- Allow selective namespaces so production noise stays low.
## Enabling debug (runtime)
Debug is **off** when `DEBUG_VERAE` is unset or empty.
```bash
# Everything
export DEBUG_VERAE=1
# or
export DEBUG_VERAE=*
# Selected namespaces (comma-separated)
export DEBUG_VERAE=auth,nats,jobs,webhooks,http,billing,trace
# Minimum level: debug | info | warn | error
export DEBUG_VERAE_LEVEL=debug
# Optional: write to file as well as stderr
export DEBUG_VERAE_FILE=/var/log/verae-zapier-debug.log
```
Restart is **not** required if the process reads env only at boot — current implementation reads env at process start. To change flags:
```bash
# systemd / docker: update env and restart one replica
# or send future SIGHUP support (Phase 15)
```
### Docker example
```yaml
environment:
DEBUG_VERAE: "auth,jobs,nats,webhooks"
DEBUG_VERAE_LEVEL: "debug"
```
## Namespaces
| Namespace | What it traces |
|-----------|----------------|
| `auth` | API key resolve, session parse, login outcomes (no passwords) |
| `billing` | Entitlement checks, quota decisions, plan limits |
| `http` | Outbound Verae requests: method, path, status, duration |
| `nats` | Connect, publish, consume, ack/nak, stream ensure |
| `jobs` | Watch enqueue, poll attempts, terminal transitions |
| `webhooks` | Subscribe, deliver attempts, HTTP status to Zapier |
| `trace` | Correlation id enter/exit spans |
| `app` | Boot, config summary (redacted), shutdown |
`DEBUG_VERAE=1` or `*` enables **all** namespaces.
## Correlation IDs
Every inbound HTTP request should get a `traceId` (generated or from `X-Trace-Id` header).
That id is:
- returned optionally as `X-Trace-Id` on responses,
- attached to NATS payloads as `traceId`,
- included in every debug line for that flow.
Example log line:
```text
2026-08-11T16:00:00.000Z DEBUG jobs [trace=a1b2c3d4] poll attempt=3 jobId=550e… status=pending
```
## Redaction rules
Always redacted (replaced with `[REDACTED]`):
- Headers: `authorization`, `x-api-key`, `cookie`
- Fields named: `password`, `veraePassword`, `token`, `veraeToken`, `accessToken`, `apiKey`, `api_key`, `secret`
- String values matching: `Bearer …`, `zmw_…`, `zmt_…`, long JWTs (`eyJ…`)
`targetUrl` host is kept; query string may be stripped if it contains tokens.
## Using debug for common failures
### 401 from middleware
```bash
DEBUG_VERAE=auth,trace npm start
# reproduce Zapier connection test
# look for resolveAuthContext failures
```
### Job never completes / trigger never fires
```bash
DEBUG_VERAE=jobs,nats,webhooks,http
# confirm: watch published → poll status → event emitted → deliver POST status
```
### 402 quota
```bash
DEBUG_VERAE=billing
# confirm plan limits vs usage counters
```
### Verae upstream errors
```bash
DEBUG_VERAE=http,jobs
# status codes and paths only; body may be summarized
```
## Programmatic API
See [modules/debug.md](modules/debug.md) and source under `verae-zapier-middleware/src/debug/`.
```js
import { createDebugger } from '../debug/logger.js';
import { withTrace, getTraceId } from '../debug/trace.js';
const log = createDebugger('jobs');
log.debug('poll start', { jobId });
log.error('poll failed', { jobId, err: err.message });
```
## Tests
Phase 1 gate verifies:
- silence when disabled,
- namespace filtering,
- redaction,
- trace id propagation.
```bash
npm run gate:1
```
## Production caution
- Prefer staging with narrowed namespaces.
- Never commit files containing live debug output with customer data.
- CI should run with debug off except dedicated debug unit tests that assert redaction.

View file

@ -0,0 +1,45 @@
# Developer Module Index
Each module document lists **every exported function**, its purpose, inputs, outputs, and side effects.
Implementation status follows [TODO.md](../../../TODO.md) phases. Docs describe the **target contract** so implementers and reviewers share one API surface.
## Middleware (`verae-zapier-middleware/src`)
| Module doc | Source path | Phase |
|------------|-------------|-------|
| [debug.md](debug.md) | `debug/` | 1 |
| [config.md](config.md) | `config.js` | 2 |
| [errors.md](errors.md) | `errors.js` | 2 |
| [app.md](app.md) | `app.js`, `index.js` | 2 |
| [store-db.md](store-db.md) | `store/db.js` | 3 |
| [store-tenants.md](store-tenants.md) | `store/tenants.js` | 3 |
| [store-usage.md](store-usage.md) | `store/usage.js` | 3 |
| [store-webhooks.md](store-webhooks.md) | `store/webhooks.js` | 3 |
| [tokens.md](tokens.md) | `lib/tokens.js` | 4 |
| [veraeClient.md](veraeClient.md) | `clients/veraeClient.js` | 4 |
| [authService.md](authService.md) | `services/authService.js` | 5 |
| [entitlementService.md](entitlementService.md) | `services/entitlementService.js` | 5 |
| [middleware-http.md](middleware-http.md) | `middleware/*.js` | 5 |
| [timestampService.md](timestampService.md) | `services/timestampService.js` | 6 |
| [verifyService.md](verifyService.md) | `services/verifyService.js` | 6 |
| [webhookService.md](webhookService.md) | `services/webhookService.js` | 6 |
| [routes.md](routes.md) | `routes/*.js` | 6 |
| [nats.md](nats.md) | `nats/*.js` | 7 |
| [workers.md](workers.md) | `workers/*.js` | 89 |
| [tenantService.md](tenantService.md) | `services/tenantService.js` | 10 |
## Zapier app (`verae-zapier`)
| Module doc | Source path | Phase |
|------------|-------------|-------|
| [zapier-app.md](zapier-app.md) | `index.js`, `authentication.js`, creates/searches/triggers | 11 |
## Documentation rules
When you add or change an export:
1. Update JSDoc in the source file (`@param`, `@returns`, purpose sentence).
2. Update the matching module markdown table.
3. Add/adjust unit tests in the phase that owns the module.
4. Instrument boundaries with `createDebugger('<namespace>')`.

View file

@ -0,0 +1,27 @@
# Module: `config`
**Path:** `verae-zapier-middleware/src/config.js`
**Phase:** 2
**Debug namespace:** `app`
## Purpose
Central runtime settings for HTTP, Verae upstream, NATS flags, and plan limits.
## Exports
### `loadEnvFile()`
Loads `.env` without overriding existing env vars. Called at module load.
### `config`
See [function-reference.md](function-reference.md#configjs) for field table.
### `PLAN_LIMITS`
Default quotas per plan name (`free` | `starter` | `pro` | `enterprise`).
## Environment variables
Documented in `verae-zapier-middleware/.env.example` and root `README.md`.

View file

@ -0,0 +1,103 @@
# Module: `debug`
**Path:** `verae-zapier-middleware/src/debug/`
**Phase:** 1
**Purpose:** Runtime-toggleable failure tracing with secret redaction and correlation IDs.
## Files
| File | Role |
|------|------|
| `config.js` | Parse env into enable/namespaces/level |
| `redact.js` | Strip secrets from log metadata |
| `logger.js` | Namespaced `createDebugger` |
| `trace-context.js` | ALS store (no logger import) |
| `trace.js` | `withTrace`, Express middleware |
| `index.js` | Public barrel export |
---
## `parseDebugVeraeEnv(raw)`
| | |
|--|--|
| **For** | Interpret `DEBUG_VERAE` env string |
| **Input** | `raw: string \| undefined` |
| **Output** | `{ enabled: boolean, namespaces: Set<string>\|null }` |
| **Notes** | `1`/`*` → all namespaces; `"auth,nats"` → allow-list |
## `loadDebugConfig(env?)`
| | |
|--|--|
| **For** | Build full debug config from environment |
| **Input** | `env?: NodeJS.ProcessEnv` (default `process.env`) |
| **Output** | `DebugConfig` `{ enabled, namespaces, level, filePath }` |
## `shouldLog(config, namespace, messageLevel)`
| | |
|--|--|
| **For** | Decide if a line should emit |
| **Input** | config, namespace string, level |
| **Output** | `boolean` |
## `redact(input)`
| | |
|--|--|
| **For** | Deep-redact secrets for safe logging |
| **Input** | any JSON-like value |
| **Output** | redacted clone |
## `createDebugger(namespace)`
| | |
|--|--|
| **For** | Get `{ debug, info, warn, error }` logger for a namespace |
| **Input** | `namespace: string` |
| **Output** | `Debugger` |
| **Side effects** | Writes to stderr (or test sink) when enabled |
### `Debugger.debug|info|warn|error(message, meta?)`
| | |
|--|--|
| **Input** | `message: string`, `meta?: Record<string, unknown>` |
| **Output** | `void` |
## `withTrace(options, fn)`
| | |
|--|--|
| **For** | Run work under a correlation id |
| **Input** | `{ traceId?, span? }`, `fn: () => T\|Promise<T>` |
| **Output** | `T\|Promise<T>` |
## `getTraceId()`
| | |
|--|--|
| **For** | Read active correlation id |
| **Output** | `string\|null` |
## `traceMiddleware(req, res, next)`
| | |
|--|--|
| **For** | Per-request Express trace context |
| **Input** | Express `(req, res, next)` |
| **Output** | `void` |
| **Sets** | `req.traceId`, response header `X-Trace-Id` |
## `setDebugTestSink(sink)`
| | |
|--|--|
| **For** | Capture log lines in unit tests |
| **Input** | `((line: string) => void) \| null` |
| **Output** | `void` |
## Env reference
See [../debugging.md](../debugging.md).

View file

@ -0,0 +1,486 @@
# Function Reference — All Modules
Canonical I/O contracts for the Verae Zapier middleware and Zapier app.
Source of truth for reviewers; keep in sync with JSDoc in code.
Namespaces for debug: `auth`, `billing`, `http`, `nats`, `jobs`, `webhooks`, `trace`, `app`.
---
## config.js
### `loadEnvFile()`
| | |
|--|--|
| **For** | Load `.env` into `process.env` if keys are unset |
| **Input** | none (reads file next to package root) |
| **Output** | `void` |
### `config` (exported object)
| Field | Type | Description |
|-------|------|-------------|
| `port` | number | HTTP port |
| `host` | string | Bind address |
| `veraeApiBaseUrl` | string | Upstream Verae base URL (no trailing slash) |
| `mockVerae` | boolean | Use mock client |
| `natsEnabled` | boolean | Use JetStream workers |
| `natsUrl` | string | NATS connection URL |
| `tokenSecret` | string | HMAC secret for `zmt_` tokens |
| `jobPollIntervalMs` | number | Poll delay |
| `jobPollMaxAttempts` | number | Max poll attempts |
| `storePath` | string | Absolute path to store file |
| `upgradeUrl` | string | Billing upgrade link for 402 bodies |
| `adminSecret` | string | Admin route secret |
### `PLAN_LIMITS`
Map of plan name → `{ timestamps, verifications, batch, batchMaxItems, requestsPerMinute }`.
`null` means unlimited.
---
## errors.js
### `class AppError extends Error`
| | |
|--|--|
| **For** | Structured operational errors returned as JSON |
| **Constructor** | `(message: string, { status?, code?, details? })` |
| **Properties** | `status: number`, `code: string`, `details: unknown` |
### `asyncHandler(fn)`
| | |
|--|--|
| **For** | Wrap async Express handlers so rejections hit error middleware |
| **Input** | `(req, res, next) => Promise<any>` |
| **Output** | Express middleware function |
### `sendError(res, err)`
| | |
|--|--|
| **For** | Write JSON error response; log 5xx with debug |
| **Input** | Express `res`, `Error\|AppError` |
| **Output** | `void` |
---
## app.js / index.js
### `createApp()`
| | |
|--|--|
| **For** | Build Express application (no listen) |
| **Input** | none |
| **Output** | `express.Application` |
| **Mounts** | `GET /health`, `/zapier/*`, error handler, `traceMiddleware` |
### `main()` (index.js)
| | |
|--|--|
| **For** | Boot store, optional NATS, workers, listen |
| **Input** | none |
| **Output** | `Promise<void>` |
---
## store/db.js
### `loadStore()`
| | |
|--|--|
| **For** | Load JSON store into memory |
| **Output** | store object |
### `getStore()`
| | |
|--|--|
| **For** | Access in-memory store |
| **Output** | `{ tenants, apiKeys, usage, webhooks, jobWatchers }` |
### `persist()`
| | |
|--|--|
| **For** | Flush store to disk |
| **Output** | `void` |
---
## store/tenants.js
### `getTenant(tenantId)`
| Input | `tenantId: string` |
| Output | `Tenant\|null` |
### `getTenantByApiKey(apiKey)`
| Input | `apiKey: string` |
| Output | `Tenant\|null` |
### `listTenants()`
| Output | `Tenant[]` |
### `createTenant({ id, name, plan, veraeUsername, veraePassword, contract?, apiKey?, metadata? })`
| Output | `{ tenant: Tenant, apiKey: string }` |
### `resolveLimits(tenant)`
| Output | Effective limits including enterprise contract overrides |
---
## store/usage.js
### `getUsage(tenantId)`
| Output | counters for current period |
### `getUsageSummary(tenantId)`
| Output | public-safe usage + limits snapshot |
### `incrementUsage(tenantId, metric, amount?)`
| For | Atomically increment a counter and persist |
---
## store/webhooks.js
### `createWebhook({ tenantId, targetUrl, event })`
| Output | `{ id, tenantId, targetUrl, event, createdAt }` |
### `deleteWebhook({ tenantId, hookId?, targetUrl? })`
| Output | `boolean` removed |
### `getActiveWebhooks(tenantId, event)`
| Output | `Webhook[]` |
### `listWebhooksForTenant(tenantId)`
| Output | `Webhook[]` |
---
## lib/tokens.js
### `issueSessionToken({ tenantId, veraeToken, expiresAt })`
| Output | `string` (`zmt_…`) |
### `parseSessionToken(token)`
| Output | `{ tenantId, veraeToken, expiresAt, nonce }\|null` |
### `generateApiKey()`
| Output | `string` (`zmw_…`) |
### `isApiKey(value)`
| Output | `boolean` |
### `extractBearerToken(header)`
| Input | `Authorization` header string |
| Output | token string or `null` |
---
## clients/veraeClient.js
All methods that hit the network log under `DEBUG_VERAE=http`.
### `veraeClient.login(credentials)`
| Input | `{ username, password }` |
| Output | `{ token, expiresAt, user }` |
### `veraeClient.validate(token)`
| Input | Verae JWT |
| Output | validation object |
### `veraeClient.createTimestamp(token, body)`
| Input | JWT, `{ data, hashAlg? }` |
| Output | `{ jobId }` |
### `veraeClient.createBatchTimestamp(token, body)`
| Input | JWT, `{ items: [{ data, hashAlg? }] }` |
| Output | `{ jobIds: string[] }` |
### `veraeClient.getStatus(token, jobId)`
| Output | StatusResponse |
### `veraeClient.getBatchStatus(token, body)`
| Input | `{ jobIds: string[] }` |
| Output | `{ results }` |
### `veraeClient.verify(token, body)`
| Input | `{ certificate }` |
| Output | `{ valid, timestamp?, blockIndex? }` |
### `veraeClient.verifyBatch(token, body)`
| Input | `{ certificates: string[] }` |
| Output | `{ results }` |
### `veraeClient.waitForJob(token, jobId, { maxAttempts, intervalMs })`
| Output | terminal StatusResponse or throws `GATEWAY_TIMEOUT` |
---
## services/authService.js
Debug namespace: `auth`.
### `loginWithCredentials({ username, password, tenant? })`
| Output | `{ accessToken, expiresAt, tenant, user }` |
### `loginWithApiKey(apiKey)`
| Output | same as loginWithCredentials after tenant lookup |
### `resolveAuthContext(rawToken)`
| Input | API key or session token |
| Output | `{ tenantId, tenant, veraeToken, authMethod }` |
### `validateSession(rawToken)`
| Output | `{ valid, tenantId, authMethod, user }` |
---
## services/entitlementService.js
Debug namespace: `billing`.
### `checkEntitlement(tenantId, action, { amount? })`
| Actions | `timestamp`, `verify`, `batch_timestamp` |
| Throws | `402 QUOTA_EXCEEDED`, `403 PLAN_UPGRADE_REQUIRED` |
| Output | `{ tenant, limits, usage }` |
### `recordUsage(tenantId, action, { amount? })`
| Output | `void` |
---
## middleware/authenticate.js
### `authenticate(req, res, next)`
| For | Populate `req.auth` via `resolveAuthContext` |
| Reads | `Authorization: Bearer` or `x-api-key` |
## middleware/rateLimit.js
### `rateLimit(req, res, next)`
| For | Enforce plan `requestsPerMinute` |
| Throws | `429 RATE_LIMITED` |
---
## services/timestampService.js
Debug: `jobs`, `billing`, `http`.
### `createTimestamp(ctx, body)`
| Input | auth context, `{ data, hashAlg? }` |
| Output | `{ jobId }` |
| Side effects | usage++, enqueue watch (NATS or in-process) |
### `createTimestampAndWait(ctx, body)`
| Output | StatusResponse (or pending on timeout when NATS wait enabled) |
### `createBatchTimestamp(ctx, body)`
| Output | `{ jobIds }` |
### `getJobStatus(ctx, jobId)` / `getBatchJobStatus` / `getJobVerification`
| Output | status payloads from Verae |
---
## services/verifyService.js
### `verifyTimestamp(ctx, body)` / `verifyBatch(ctx, body)`
| Output | verify results; records usage |
---
## services/webhookService.js
Debug: `webhooks`.
### `subscribe(ctx, { targetUrl, event })`
| Output | webhook record |
### `unsubscribe(ctx, { hookId?, targetUrl? })`
| Output | `{ removed: true }` |
### `deliverWebhook(targetUrl, payload)`
| Output | `{ ok: boolean, status: number }` |
---
## services/tenantService.js
### `selfServeSignup({ email, name, veraeUsername, veraePassword })`
| Output | `{ tenant, apiKey, zapierSetup }` |
### `provisionTenant({ id?, name, plan, veraeUsername, veraePassword, contract?, metadata?, audience? })`
| Output | `{ tenant, apiKey }` |
### `listProvisionedTenants()`
| Output | public tenant summaries (no passwords) |
---
## nats/subjects.js
### Constants
`SUBJECTS.JOBS_WATCH`, `JOBS_EVENTS`, `WEBHOOKS_DELIVER`, `USAGE`
`STREAMS.ZAPIER_JOBS`, `ZAPIER_EVENTS`, `ZAPIER_WEBHOOKS`
---
## nats/connection.js
### `connectNats(url?)`
| Output | `{ nc, js, jsm }` NATS connection handles |
### `ensureStreams(jsm)`
| For | Idempotent stream create |
| Output | `Promise<void>` |
### `closeNats()`
| Output | `Promise<void>` |
---
## nats/publishers.js
### `enqueueWatch(jobWatchPayload)`
| Input | see architecture/nats-subjects.md |
| Output | `Promise<{ seq }>` |
### `publishJobEvent(eventPayload)`
| Output | `Promise<void>` |
### `enqueueWebhook(deliverPayload)`
| Output | `Promise<void>` |
---
## workers/jobPollerWorker.js
### `startJobPollerWorker()`
| For | Pull-consume watch queue; poll Verae; emit events |
| Output | stop handle `{ stop() }` |
## workers/webhookWorker.js
### `startWebhookWorker()`
| For | Deliver POSTs to Zapier with retry via JetStream |
| Output | `{ stop() }` |
## workers/inProcessJobPoller.js
### `startInProcessJobPoller()` / `stopInProcessJobPoller()`
| For | Phase 6 fallback when `NATS_ENABLED=false` |
---
## Routes (all under `/zapier`)
| Method | Path | Handler purpose |
|--------|------|-----------------|
| POST | `/v1/auth/login` | Credentials or api_key → session |
| GET | `/v1/auth/me` | Validate connection for Zapier |
| POST | `/v1/timestamp` | Async create |
| POST | `/v1/timestamp/wait` | Create and wait |
| POST | `/v1/timestamp/batch` | Batch create |
| POST | `/v1/verify` | Verify certificate |
| GET | `/v1/status/:jobId` | Job status |
| POST | `/v1/webhooks/subscribe` | REST Hook subscribe |
| DELETE | `/v1/webhooks/unsubscribe` | REST Hook unsubscribe |
| POST | `/v1/signup` | Self-serve tenant |
| POST | `/v1/admin/tenants` | Admin provision |
---
## Zapier app modules
### `authentication.test` / fields
| Input from user | `api_key` |
| Test URL | `GET {MIDDLEWARE_BASE_URL}/zapier/v1/auth/me` |
### `beforeRequest: addApiKey`
| Sets | `Authorization: Bearer ${bundle.authData.api_key}` |
### Creates
| Key | Middleware path |
|-----|-----------------|
| `timestamp_and_wait` | POST `/zapier/v1/timestamp/wait` |
| `create_timestamp` | POST `/zapier/v1/timestamp` |
| `verify_timestamp` | POST `/zapier/v1/verify` |
| `batch_timestamp` | POST `/zapier/v1/timestamp/batch` |
### Search `job_status`
| Path | GET `/zapier/v1/status/{jobId}` |
### Trigger `timestamp_completed`
| Subscribe | POST `/zapier/v1/webhooks/subscribe` |
| Unsubscribe | DELETE `/zapier/v1/webhooks/unsubscribe` |
| Perform | return `[bundle.cleanedRequest]` |

View file

@ -0,0 +1,28 @@
# Module: `nats`
**Path:** `verae-zapier-middleware/src/nats/`
**Phase:** 7
**Debug namespace:** `nats`
## Files
| File | Purpose |
|------|---------|
| `subjects.js` | Subject, stream, consumer name constants |
| `connection.js` | Connect, ensure streams, close |
| `publishers.js` | enqueueWatch, publishJobEvent, enqueueWebhook |
## Function contracts
Full I/O tables: [function-reference.md](function-reference.md#natssubjectsjs)
Payload schemas: [../../architecture/nats-subjects.md](../../architecture/nats-subjects.md)
## Implementation status
Stubs throw until **GATE 7**. Call sites must check `config.natsEnabled` and fall back to in-process poller (Phase 6).
## Security
- Prefer `tokenRef` over raw Verae JWT in messages.
- All publish paths log via `createDebugger('nats')` with redaction.
- NATS must remain private (not exposed to Zapier).

View file

@ -0,0 +1,35 @@
# Module: `workers`
**Path:** `verae-zapier-middleware/src/workers/`
**Phases:** 6 (in-process), 89 (NATS)
## Files
| File | Phase | Purpose |
|------|-------|---------|
| `inProcessJobPoller.js` | 6 | Fallback poller when `NATS_ENABLED=false` |
| `jobPollerWorker.js` | 8 | JetStream job watch consumer |
| `webhookWorker.js` | 8 | JetStream webhook deliver consumer |
## `startJobPollerWorker()`
| | |
|--|--|
| **For** | Poll Verae until job terminal; publish events |
| **Input** | none (uses NATS + config) |
| **Output** | `Promise<{ stop: () => Promise<void> }>` |
| **Debug** | `jobs`, `http`, `nats` |
## `startWebhookWorker()`
| | |
|--|--|
| **For** | POST completion payloads to Zapier hook URLs |
| **Output** | `Promise<{ stop: () => Promise<void> }>` |
| **Debug** | `webhooks`, `nats` |
## Rules
- Only one of in-process poller **or** NATS job worker should run.
- Never log raw JWTs or full webhook secrets.
- Queue groups allow horizontal scale without double delivery.

189
docs/plans/phase-gates.md Normal file
View file

@ -0,0 +1,189 @@
# Phase Gates — Commands and Acceptance Criteria
Gates enforce **test-before-next-step**. Run from repository root:
```bash
cd /Users/marchon/apps/zapier
npm run gate:N
```
`npm run gate:all` runs gates `0``12` sequentially and **exits non-zero** on first failure.
## Gate 0 — Structure and documentation
```bash
npm run gate:0
```
| Check | Requirement |
|-------|-------------|
| Layout | `TODO.md`, `README.md`, middleware + zapier dirs, docs tree |
| Architecture | `docs/architecture/overview.md`, `nats-subjects.md` |
| Developer | `docs/developer/debugging.md`, `docs/developer/modules/*.md` |
| Plan | `docs/plans/phase-gates.md` |
## Gate 1 — Debug facility
```bash
npm run gate:1
```
Tests: `verae-zapier-middleware/test/unit/debug*.test.js`
| Check | Requirement |
|-------|-------------|
| Off by default | No output when `DEBUG_VERAE` unset |
| Namespace filter | `auth` does not emit `nats` lines |
| Redaction | Tokens/passwords not present in formatted output |
| Trace | Child spans inherit `traceId` |
## Gate 2 — HTTP shell
```bash
npm run gate:2
```
| Check | Requirement |
|-------|-------------|
| Health | `GET /health` → 200 `{ status: "ok" }` |
| Errors | Thrown `AppError` → JSON `{ error, code }` |
| Config | Reads NATS and debug env keys |
## Gate 3 — Stores
```bash
npm run gate:3
```
| Check | Requirement |
|-------|-------------|
| Tenants | API key maps to tenant |
| Limits | Plan + enterprise contract |
| Webhooks | Tenant isolation |
| Persist | Reload preserves state |
## Gate 4 — Tokens + Verae client
```bash
npm run gate:4
```
| Check | Requirement |
|-------|-------------|
| HMAC | Bad signature rejected |
| Mock job | create → wait → completed |
| HTTP debug | No Authorization values logged |
## Gate 5 — Auth + entitlements
```bash
npm run gate:5
```
| Check | Requirement |
|-------|-------------|
| Me | Valid API key |
| 401 | Invalid key |
| 402 | Over quota |
## Gate 6 — Sync HTTP API (NATS off)
```bash
NATS_ENABLED=false MOCK_VERAE=true npm run gate:6
```
| Check | Requirement |
|-------|-------------|
| Timestamp | 202 + jobId |
| Wait | completed status |
| Verify | boolean valid |
| Webhooks | subscribe stored |
## Gate 7 — NATS infrastructure
```bash
npm run gate:7
```
| Check | Requirement |
|-------|-------------|
| Streams | Created/idempotent ensure |
| Pub/sub | One message round-trip |
| Flag off | App boots without NATS |
## Gate 8 — Workers
```bash
NATS_ENABLED=true MOCK_VERAE=true npm run gate:8
```
| Check | Requirement |
|-------|-------------|
| Watch→event | Terminal event published |
| Webhook | Mock receiver got POST |
| Queue group | No double complete |
## Gate 9 — Wait via NATS
```bash
npm run gate:9
```
| Check | Requirement |
|-------|-------------|
| Success | Wait returns completed |
| Timeout | Returns pending + jobId |
## Gate 10 — Tenancy
```bash
npm run gate:10
```
| Check | Requirement |
|-------|-------------|
| Signup | free + apiKey |
| Enterprise | requires contract |
## Gate 11 — Zapier package
```bash
npm run gate:11
```
| Check | Requirement |
|-------|-------------|
| Auth module | fields + test URL |
| Creates | correct middleware paths |
| Trigger | subscribe shapes |
## Gate 12 — E2E local compose
```bash
npm run gate:12
```
| Check | Requirement |
|-------|-------------|
| Stack | health green |
| Paths | async + wait + hook |
## Gate 13 — Production smoke (opt-in)
```bash
PRODUCTION_SMOKE=1 npm run gate:13
```
Requires real secrets; skipped in default `gate:all`.
## Gate 14 — Human acceptance
Manual checklist in `TODO.md` (not automated).
## Gate 15 — Hardening
Checklist + load notes in `TODO.md`.
## Recording results
After each green gate, update the **Gate log** table in `TODO.md` with date and `pass`.

View file

@ -0,0 +1,25 @@
# Local stack for Phase 7+ (NATS) and Phase 12 E2E.
# Middleware HTTP can run without NATS when NATS_ENABLED=false.
services:
nats:
image: nats:2.10
command: ["-js", "-m", "8222"]
ports:
- "4222:4222"
- "8222:8222"
middleware:
build: ./verae-zapier-middleware
ports:
- "3100:3100"
environment:
PORT: "3100"
VERAE_API_BASE_URL: ${VERAE_API_BASE_URL:-https://api.veraetime.net}
MOCK_VERAE: ${MOCK_VERAE:-true}
NATS_ENABLED: ${NATS_ENABLED:-false}
NATS_URL: nats://nats:4222
TOKEN_SECRET: ${TOKEN_SECRET:-dev-secret}
DEBUG_VERAE: ${DEBUG_VERAE:-}
depends_on:
- nats

40
harness/compose.yaml Normal file
View file

@ -0,0 +1,40 @@
# Local disconnected stack (PR 3+). Zappier commercial edge is added in PR 4.
# Middleware HTTP can run without NATS when NATS_ENABLED=false.
services:
nats:
image: nats:2.10
command: ["-js", "-m", "8222"]
ports:
- "4222:4222"
- "8222:8222"
middleware:
build: ../packages/verae-zapier-middleware
ports:
- "3100:3100"
environment:
PORT: "3100"
VERAE_API_BASE_URL: ${VERAE_API_BASE_URL:-http://mock-verae:8080}
MOCK_VERAE: ${MOCK_VERAE:-true}
NATS_ENABLED: ${NATS_ENABLED:-false}
NATS_URL: nats://nats:4222
TOKEN_SECRET: ${TOKEN_SECRET:-dev-secret}
DEBUG_VERAE: ${DEBUG_VERAE:-}
depends_on:
- nats
zappier:
image: node:20
working_dir: /app
volumes:
- ../packages/zappier:/app
command: ["npx", "ts-node", "src/index.ts"]
ports:
- "3000:3000"
environment:
PORT: "3000"
ZAPPIER_DB: /app/data/zappier.db
ADMIN_KEY: ${ADMIN_KEY:-admin-dev-key}
depends_on:
- middleware

View file

@ -0,0 +1,7 @@
# Mock Verae
Until `api.veraetime.net` is connected, set `MOCK_VERAE=true` on the middleware.
The in-process mock lives in `packages/verae-zapier-middleware/src/clients/veraeClient.js` (`create` → pending → completed certificate).
This directory is reserved for a standalone mock HTTP service if compose needs a process on `:8080` (PR 3). Do not send gap features (hash lookup, metadata, LTS, receipts) to the live OpenAPI client.

10
harness/scripts/smoke.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Disconnected smoke (expand in PR 3 / PR 4).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo "workspace: $ROOT"
curl -fsS "http://127.0.0.1:3100/health" || {
echo "middleware /health not up — start packages/verae-zapier-middleware with MOCK_VERAE=true"
exit 1
}
echo "middleware health ok"

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "verae-zapier-workspace",
"version": "0.1.0",
"private": true,
"description": "Verae Time × Zapier: commercial edge (zappier) + timestamp middleware + Platform CLI app",
"type": "module",
"scripts": {
"gate:0": "node scripts/gate-0-structure.mjs",
"gate:1": "npm --prefix packages/verae-zapier-middleware run test:gate1",
"gate:2": "npm --prefix packages/verae-zapier-middleware run test:gate2",
"gate:3": "npm --prefix packages/verae-zapier-middleware run test:gate3",
"gate:4": "npm --prefix packages/verae-zapier-middleware run test:gate4",
"gate:5": "npm --prefix packages/verae-zapier-middleware run test:gate5",
"gate:6": "npm --prefix packages/verae-zapier-middleware run test:gate6",
"gate:7": "npm --prefix packages/verae-zapier-middleware run test:gate7",
"gate:8": "npm --prefix packages/verae-zapier-middleware run test:gate8",
"gate:9": "node scripts/gate-not-ready.mjs 9",
"gate:10": "npm --prefix packages/verae-zapier-middleware run test:gate10",
"gate:11": "node --test packages/verae-zapier/test/app.test.js",
"gate:12": "node scripts/gate-not-ready.mjs 12",
"gate:13": "node scripts/gate-not-ready.mjs 13",
"gate:all": "node scripts/gate-all.mjs",
"test:middleware": "npm --prefix packages/verae-zapier-middleware test",
"test:zappier": "npm --prefix packages/zappier test"
},
"engines": {
"node": ">=20.0.0"
},
"license": "UNLICENSED"
}

View file

@ -0,0 +1,30 @@
# HTTP
PORT=3100
HOST=0.0.0.0
# Upstream Verae
VERAE_API_BASE_URL=https://api.veraetime.net
MOCK_VERAE=false
# NATS (Phase 7+)
NATS_ENABLED=false
NATS_URL=nats://127.0.0.1:4222
# Security
TOKEN_SECRET=change-me-in-production
ADMIN_SECRET=change-me-admin
# Job polling
JOB_POLL_INTERVAL_MS=2000
JOB_POLL_MAX_ATTEMPTS=60
# Store
STORE_PATH=./data/store.json
# Billing UX
UPGRADE_URL=https://veraetime.net/billing
# Runtime debug (see docs/developer/debugging.md)
# DEBUG_VERAE=auth,nats,jobs,webhooks,http,billing
# DEBUG_VERAE_LEVEL=debug
# DEBUG_VERAE_FILE=/tmp/verae-zapier-debug.log

View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src ./src
ENV PORT=3100
EXPOSE 3100
CMD ["node", "src/index.js"]

View file

@ -0,0 +1,870 @@
{
"name": "verae-zapier-middleware",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "verae-zapier-middleware",
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"express": "^4.21.2",
"nats": "^2.28.2"
},
"engines": {
"node": ">=22.0.0"
},
"optionalDependencies": {
"nats": "^2.28.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/nats": {
"version": "2.28.2",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.28.2.tgz",
"integrity": "sha512-02cvR8EPach+0BfVaQjPgsbPFn6uMjEQAuvXS2ppg8jiWEm2KYdfmeFmtshiU9b2+kFh3LSEKMEaIfRgk3K8tw==",
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense",
"optional": true
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

View file

@ -0,0 +1,37 @@
{
"name": "verae-zapier-middleware",
"version": "0.1.0",
"description": "HTTP edge + NATS workers bridging Zapier and api.veraetime.net",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "MOCK_VERAE=true NATS_ENABLED=false node --test test/unit/**/*.test.js test/integration/auth.test.js test/integration/http-api.test.js",
"test:unit": "MOCK_VERAE=true NATS_ENABLED=false node --test test/unit/**/*.test.js",
"test:gate1": "node --test test/unit/debug.test.js",
"test:gate2": "MOCK_VERAE=true node --test test/unit/config.test.js test/unit/app.test.js",
"test:gate3": "MOCK_VERAE=true NATS_ENABLED=false node --test test/unit/store.test.js",
"test:gate4": "MOCK_VERAE=true NATS_ENABLED=false node --test test/unit/tokens.test.js test/unit/veraeClient.test.js",
"test:gate5": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/auth.test.js",
"test:gate6": "MOCK_VERAE=true NATS_ENABLED=false JOB_POLL_INTERVAL_MS=20 JOB_POLL_MAX_ATTEMPTS=50 node --test test/integration/http-api.test.js",
"test:gate7": "MOCK_VERAE=true NATS_ENABLED=true NATS_FORCE_CONNECT=1 NATS_URL=nats://127.0.0.1:4222 node --test test/integration/nats.test.js",
"test:gate8": "MOCK_VERAE=true NATS_ENABLED=true NATS_FORCE_CONNECT=1 NATS_URL=nats://127.0.0.1:4222 JOB_POLL_INTERVAL_MS=50 node --test test/integration/nats-workers.test.js",
"test:gate10": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/tenants.test.js"
},
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"express": "^4.21.2",
"nats": "^2.28.2"
},
"keywords": [
"verae",
"zapier",
"middleware",
"nats",
"timestamping"
],
"license": "UNLICENSED"
}

View file

@ -0,0 +1,50 @@
/**
* @fileoverview Express application factory for the Zapier-facing HTTP edge.
* @module app
*/
import express from 'express';
import { sendError } from './errors.js';
import { createDebugger } from './debug/logger.js';
import { traceMiddleware } from './debug/trace.js';
import { apiRoutes } from './routes/index.js';
import { loadStore } from './store/db.js';
const log = createDebugger('app');
/**
* Create the Express app (does not listen).
*
* @param {{ load?: boolean }} [options]
* @returns {import('express').Express}
*/
export function createApp(options = {}) {
if (options.load !== false) {
loadStore();
}
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use(traceMiddleware);
/**
* Liveness probe.
*/
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
service: 'verae-zapier-middleware',
});
});
app.use('/zapier', apiRoutes);
app.use((err, _req, res, _next) => {
sendError(res, err);
});
log.info('express app created');
return app;
}

View file

@ -0,0 +1,287 @@
/**
* @fileoverview HTTP client for api.veraetime.net (with mock mode).
* @module clients/veraeClient
*/
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('http');
const mockJobs = new Map();
/**
* @param {number} ms
* @returns {Promise<void>}
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function mockLogin({ username, password }) {
if (!username || !password) {
throw new AppError('Invalid credentials', { status: 401, code: 'UNAUTHORIZED' });
}
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
return {
token: `mock-jwt-${username}`,
expiresAt,
user: {
id: randomUUID(),
username,
role: username.includes('admin') ? 'admin' : 'user',
},
};
}
async function mockValidate(token) {
if (!token?.startsWith('mock-jwt-')) {
throw new AppError('Invalid or expired token', { status: 401, code: 'UNAUTHORIZED' });
}
const username = token.replace('mock-jwt-', '');
return {
valid: true,
userId: randomUUID(),
username,
role: 'user',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
}
async function mockCreateTimestamp({ data, hashAlg }) {
if (!data) {
throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' });
}
const jobId = randomUUID();
mockJobs.set(jobId, {
id: jobId,
status: 'pending',
createdAt: Date.now(),
data,
hashAlg: hashAlg ?? 'SHA256',
});
setTimeout(() => {
const job = mockJobs.get(jobId);
if (!job) return;
job.status = 'completed';
job.result = `mock-cert-${jobId}`;
job.completedAt = new Date().toISOString();
job.metadata = {
blockIndex: 42,
timestamp: job.completedAt,
certificate: job.result,
};
}, 150);
return { jobId };
}
async function mockGetStatus(jobId) {
const job = mockJobs.get(jobId);
if (!job) {
throw new AppError('Job not found', { status: 404, code: 'NOT_FOUND' });
}
return {
id: job.id,
status: job.status,
result: job.result,
completedAt: job.completedAt,
metadata: job.metadata,
error: job.error,
};
}
async function mockVerify({ certificate }) {
if (!certificate) {
throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' });
}
const valid = certificate.startsWith('mock-cert-') || certificate.startsWith('eyJ');
return valid
? { valid: true, timestamp: new Date().toISOString(), blockIndex: 42 }
: { valid: false };
}
/**
* Low-level fetch to Verae API.
* @param {string} path
* @param {{ method?: string, token?: string, body?: unknown }} [options]
* @returns {Promise<any>}
*/
async function request(path, { method = 'GET', token, body } = {}) {
const url = `${config.veraeApiBaseUrl}${path}`;
const headers = { Accept: 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const started = Date.now();
log.debug('verae request', { method, path, hasToken: Boolean(token) });
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let payload = null;
const text = await response.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = { error: text };
}
}
log.debug('verae response', {
method,
path,
status: response.status,
durationMs: Date.now() - started,
});
if (!response.ok) {
throw new AppError(payload?.error ?? `Verae API error (${response.status})`, {
status: response.status,
code: payload?.code ?? 'VERAE_API_ERROR',
details: payload,
});
}
return payload;
}
/**
* Verae API client (mock when config.mockVerae is true).
*/
export const veraeClient = {
/**
* @param {{ username: string, password: string }} credentials
*/
async login(credentials) {
if (config.mockVerae) return mockLogin(credentials);
return request('/auth/login', { method: 'POST', body: credentials });
},
/**
* @param {string} token
*/
async validate(token) {
if (config.mockVerae) return mockValidate(token);
return request('/auth/validate', { token });
},
/**
* @param {string} token
* @param {{ data: string, hashAlg?: string }} body
*/
async createTimestamp(token, body) {
if (config.mockVerae) return mockCreateTimestamp(body);
return request('/api/timestamp', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {{ items: Array<{ data: string, hashAlg?: string }> }} body
*/
async createBatchTimestamp(token, body) {
if (config.mockVerae) {
const jobIds = [];
for (const item of body.items ?? []) {
const res = await mockCreateTimestamp(item);
jobIds.push(res.jobId);
}
return { jobIds };
}
return request('/api/batch/timestamp', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {string} jobId
*/
async getStatus(token, jobId) {
if (config.mockVerae) return mockGetStatus(jobId);
return request(`/api/status/${encodeURIComponent(jobId)}`, { token });
},
/**
* @param {string} token
* @param {{ jobIds: string[] }} body
*/
async getBatchStatus(token, body) {
if (config.mockVerae) {
const results = {};
for (const jobId of body.jobIds ?? []) {
results[jobId] = await mockGetStatus(jobId);
}
return { results };
}
return request('/api/batch/status', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {{ certificate: string }} body
*/
async verify(token, body) {
if (config.mockVerae) return mockVerify(body);
return request('/api/verify', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {{ certificates: string[] }} body
*/
async verifyBatch(token, body) {
if (config.mockVerae) {
const results = [];
for (const certificate of body.certificates ?? []) {
results.push(await mockVerify({ certificate }));
}
return { results };
}
return request('/api/batch/verify', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {string} jobId
*/
async getJobVerification(token, jobId) {
if (config.mockVerae) return mockGetStatus(jobId);
return request(`/api/verify/${encodeURIComponent(jobId)}`, { token });
},
/**
* Poll until completed/failed or timeout.
* @param {string} token
* @param {string} jobId
* @param {{ maxAttempts: number, intervalMs: number }} options
*/
async waitForJob(token, jobId, { maxAttempts, intervalMs }) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const status = await this.getStatus(token, jobId);
if (status.status === 'completed' || status.status === 'failed') {
return status;
}
await delay(intervalMs);
}
throw new AppError(`Job ${jobId} timed out`, { status: 504, code: 'GATEWAY_TIMEOUT' });
},
};
/**
* Clear mock jobs (tests only).
* @returns {void}
*/
export function clearMockJobs() {
mockJobs.clear();
}

View file

@ -0,0 +1,142 @@
/**
* @fileoverview Process configuration for the Verae Zapier middleware.
* @module config
*
* Loads optional `.env` then exports typed settings used by HTTP, NATS, and workers.
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDebugger } from './debug/logger.js';
const log = createDebugger('app');
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');
/**
* Load KEY=VALUE pairs from `.env` without overriding existing process.env keys.
* @returns {void}
*/
export function loadEnvFile() {
const envPath = resolve(rootDir, '.env');
if (!existsSync(envPath)) return;
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
loadEnvFile();
/**
* @param {string|undefined} value
* @param {boolean} [fallback=false]
* @returns {boolean}
*/
function bool(value, fallback = false) {
if (value === undefined) return fallback;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
/**
* @param {string|undefined} value
* @param {number} fallback
* @returns {number}
*/
function int(value, fallback) {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* Runtime configuration object.
* @type {{
* port: number,
* host: string,
* veraeApiBaseUrl: string,
* mockVerae: boolean,
* natsEnabled: boolean,
* natsUrl: string,
* tokenSecret: string,
* jobPollIntervalMs: number,
* jobPollMaxAttempts: number,
* storePath: string,
* upgradeUrl: string,
* adminSecret: string,
* }}
*/
export const config = {
port: int(process.env.PORT, 3100),
host: process.env.HOST ?? '0.0.0.0',
veraeApiBaseUrl: (process.env.VERAE_API_BASE_URL ?? 'http://localhost:8080').replace(
/\/$/,
'',
),
mockVerae: bool(process.env.MOCK_VERAE, false),
natsEnabled: bool(process.env.NATS_ENABLED, false),
natsUrl: process.env.NATS_URL ?? 'nats://127.0.0.1:4222',
tokenSecret: process.env.TOKEN_SECRET ?? 'dev-secret-change-me',
jobPollIntervalMs: int(process.env.JOB_POLL_INTERVAL_MS, 2000),
jobPollMaxAttempts: int(process.env.JOB_POLL_MAX_ATTEMPTS, 60),
storePath: resolve(rootDir, process.env.STORE_PATH ?? './data/store.json'),
upgradeUrl: process.env.UPGRADE_URL ?? 'https://veraetime.net/billing',
adminSecret: process.env.ADMIN_SECRET ?? 'change-me-admin',
};
/**
* Default plan limits. `null` numeric fields mean unlimited.
* @type {Record<string, {
* timestamps: number|null,
* verifications: number|null,
* batch: boolean,
* batchMaxItems: number|null,
* requestsPerMinute: number
* }>}
*/
export const PLAN_LIMITS = {
free: {
timestamps: 50,
verifications: 50,
batch: false,
batchMaxItems: 0,
requestsPerMinute: 30,
},
starter: {
timestamps: 500,
verifications: 500,
batch: true,
batchMaxItems: 10,
requestsPerMinute: 120,
},
pro: {
timestamps: 5000,
verifications: 5000,
batch: true,
batchMaxItems: 100,
requestsPerMinute: 600,
},
enterprise: {
timestamps: null,
verifications: null,
batch: true,
batchMaxItems: null,
requestsPerMinute: 3000,
},
};
log.info('config loaded', {
veraeApiBaseUrl: config.veraeApiBaseUrl,
mockVerae: config.mockVerae,
natsEnabled: config.natsEnabled,
natsUrl: config.natsUrl,
port: config.port,
});

View file

@ -0,0 +1,106 @@
/**
* @fileoverview Runtime debug configuration.
*
* Reads process environment at call time of {@link loadDebugConfig} so tests can
* mutate `process.env` between cases. Production servers typically load once at boot.
*
* @module debug/config
*/
/**
* @typedef {Object} DebugConfig
* @property {boolean} enabled - True when any debug output should be produced.
* @property {Set<string>|null} namespaces - Allowed namespaces; `null` means all.
* @property {'debug'|'info'|'warn'|'error'} level - Minimum severity to emit.
* @property {string|null} filePath - Optional secondary log file path.
*/
const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
/**
* Parse `DEBUG_VERAE` into an enabled flag and optional namespace allow-list.
*
* @param {string|undefined} raw - Raw env value (e.g. `"1"`, `"*"`, `"auth,nats"`).
* @returns {{ enabled: boolean, namespaces: Set<string>|null }}
*
* @example
* parseDebugVeraeEnv('auth,jobs'); // { enabled: true, namespaces: Set{'auth','jobs'} }
* parseDebugVeraeEnv(undefined); // { enabled: false, namespaces: null }
*/
export function parseDebugVeraeEnv(raw) {
if (raw === undefined || raw === null) {
return { enabled: false, namespaces: null };
}
const trimmed = String(raw).trim();
if (!trimmed || trimmed === '0' || trimmed.toLowerCase() === 'false' || trimmed.toLowerCase() === 'off') {
return { enabled: false, namespaces: null };
}
if (trimmed === '1' || trimmed === '*' || trimmed.toLowerCase() === 'true' || trimmed.toLowerCase() === 'all') {
return { enabled: true, namespaces: null };
}
const parts = trimmed
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
if (parts.length === 0) {
return { enabled: false, namespaces: null };
}
return { enabled: true, namespaces: new Set(parts) };
}
/**
* Normalize a level string to a known level.
*
* @param {string|undefined} raw - Env level value.
* @param {'debug'|'info'|'warn'|'error'} [fallback='debug'] - Default level.
* @returns {'debug'|'info'|'warn'|'error'}
*/
export function parseLevel(raw, fallback = 'debug') {
const value = String(raw ?? fallback).toLowerCase();
if (value in LEVEL_ORDER) return /** @type {'debug'|'info'|'warn'|'error'} */ (value);
return fallback;
}
/**
* Load full debug configuration from `process.env`.
*
* Environment variables:
* - `DEBUG_VERAE` enable + namespaces (see {@link parseDebugVeraeEnv})
* - `DEBUG_VERAE_LEVEL` minimum level
* - `DEBUG_VERAE_FILE` optional log file path
*
* @param {NodeJS.ProcessEnv} [env=process.env] - Environment map (injectable for tests).
* @returns {DebugConfig}
*/
export function loadDebugConfig(env = process.env) {
const { enabled, namespaces } = parseDebugVeraeEnv(env.DEBUG_VERAE);
return {
enabled,
namespaces,
level: parseLevel(env.DEBUG_VERAE_LEVEL, 'debug'),
filePath: env.DEBUG_VERAE_FILE ? String(env.DEBUG_VERAE_FILE) : null,
};
}
/**
* Whether a message at `messageLevel` in `namespace` should be emitted.
*
* @param {DebugConfig} config - Active config.
* @param {string} namespace - Logger namespace (e.g. `auth`).
* @param {'debug'|'info'|'warn'|'error'} messageLevel - Message severity.
* @returns {boolean}
*/
export function shouldLog(config, namespace, messageLevel) {
if (!config.enabled) return false;
if (config.namespaces && !config.namespaces.has(String(namespace).toLowerCase())) {
return false;
}
return LEVEL_ORDER[messageLevel] >= LEVEL_ORDER[config.level];
}
export { LEVEL_ORDER };

View file

@ -0,0 +1,24 @@
/**
* @fileoverview Public exports for the runtime debug facility.
* @module debug
*/
export {
loadDebugConfig,
parseDebugVeraeEnv,
parseLevel,
shouldLog,
LEVEL_ORDER,
} from './config.js';
export { createDebugger, formatLogLine, setDebugTestSink } from './logger.js';
export { redact, redactString } from './redact.js';
export {
generateTraceId,
getTraceId,
getTraceContext,
withTrace,
traceMiddleware,
} from './trace.js';

View file

@ -0,0 +1,121 @@
/**
* @fileoverview Namespaced debug logger with runtime enable/disable.
* @module debug/logger
*/
import { appendFileSync } from 'node:fs';
import { loadDebugConfig, shouldLog } from './config.js';
import { redact } from './redact.js';
import { getTraceId } from './trace-context.js';
/**
* @typedef {Object} Debugger
* @property {(msg: string, meta?: Record<string, unknown>) => void} debug
* @property {(msg: string, meta?: Record<string, unknown>) => void} info
* @property {(msg: string, meta?: Record<string, unknown>) => void} warn
* @property {(msg: string, meta?: Record<string, unknown>) => void} error
* @property {string} namespace
*/
/**
* Optional sink for tests when set, lines go here instead of/in addition to stderr.
* @type {null|((line: string) => void)}
*/
let testSink = null;
/**
* Install a test sink that captures formatted log lines.
* Used only by unit tests; do not use in production code.
*
* @param {null|((line: string) => void)} sink - Callback or null to clear.
* @returns {void}
*/
export function setDebugTestSink(sink) {
testSink = sink;
}
/**
* Format a single log line.
*
* @param {object} parts
* @param {string} parts.level
* @param {string} parts.namespace
* @param {string} parts.message
* @param {Record<string, unknown>|undefined} parts.meta
* @param {string|null} parts.traceId
* @returns {string}
*/
export function formatLogLine({ level, namespace, message, meta, traceId }) {
const ts = new Date().toISOString();
const tracePart = traceId ? ` [trace=${traceId}]` : '';
const metaPart =
meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(redact(meta))}` : '';
return `${ts} ${level.toUpperCase()} ${namespace}${tracePart} ${message}${metaPart}`;
}
/**
* Emit a log line to stderr, optional file, and optional test sink.
*
* @param {string} line - Full formatted line.
* @param {string|null} filePath - Optional file from config.
* @returns {void}
*/
function emit(line, filePath) {
if (testSink) {
testSink(line);
} else {
// eslint-disable-next-line no-console
console.error(line);
}
if (filePath) {
try {
appendFileSync(filePath, `${line}\n`, 'utf8');
} catch {
// ignore file errors in debug path
}
}
}
/**
* Create a namespaced debugger bound to current env configuration.
*
* Configuration is re-read on every log call so tests can toggle `DEBUG_VERAE`
* without reloading modules. Slight cost is acceptable for debug-only paths.
*
* @param {string} namespace - Namespace such as `auth`, `nats`, `jobs`.
* @returns {Debugger} Logger with debug/info/warn/error methods.
*
* @example
* const log = createDebugger('jobs');
* log.debug('poll start', { jobId: 'abc' });
*/
export function createDebugger(namespace) {
const ns = String(namespace || 'app').toLowerCase();
/**
* @param {'debug'|'info'|'warn'|'error'} level
* @param {string} message
* @param {Record<string, unknown>} [meta]
*/
function write(level, message, meta) {
const config = loadDebugConfig();
if (!shouldLog(config, ns, level)) return;
const line = formatLogLine({
level,
namespace: ns,
message: String(message),
meta,
traceId: getTraceId(),
});
emit(line, config.filePath);
}
return {
namespace: ns,
debug: (message, meta) => write('debug', message, meta),
info: (message, meta) => write('info', message, meta),
warn: (message, meta) => write('warn', message, meta),
error: (message, meta) => write('error', message, meta),
};
}

View file

@ -0,0 +1,93 @@
/**
* @fileoverview Secret redaction for debug logs.
* @module debug/redact
*/
const SENSITIVE_KEYS = new Set([
'password',
'veraepassword',
'token',
'veraetoken',
'accesstoken',
'apikey',
'api_key',
'secret',
'authorization',
'x-api-key',
'cookie',
'sessionkey',
]);
const BEARER_RE = /^Bearer\s+.+/i;
const API_KEY_RE = /^zmw_[A-Za-z0-9_-]+/;
const SESSION_RE = /^zmt_[A-Za-z0-9_.-]+/;
const JWT_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
/**
* Redact a single string value if it looks like a secret.
*
* @param {string} value - Raw string.
* @returns {string} Original or `[REDACTED]`.
*/
export function redactString(value) {
if (typeof value !== 'string') return value;
if (
BEARER_RE.test(value) ||
API_KEY_RE.test(value) ||
SESSION_RE.test(value) ||
JWT_RE.test(value)
) {
return '[REDACTED]';
}
return value;
}
/**
* Deep-clone plain data structures while redacting sensitive keys and values.
*
* @param {unknown} input - Any JSON-like value.
* @param {number} [depth=0] - Recursion depth guard.
* @returns {unknown} Redacted structure safe for logging.
*
* @example
* redact({ password: 'x', jobId: '1' }); // { password: '[REDACTED]', jobId: '1' }
*/
export function redact(input, depth = 0) {
if (depth > 8) return '[MaxDepth]';
if (input === null || input === undefined) return input;
if (typeof input === 'string') return redactString(input);
if (typeof input === 'number' || typeof input === 'boolean') return input;
if (input instanceof Error) {
return { name: input.name, message: redactString(input.message), stack: undefined };
}
if (Array.isArray(input)) {
return input.map((item) => redact(item, depth + 1));
}
if (typeof input === 'object') {
/** @type {Record<string, unknown>} */
const out = {};
for (const [key, value] of Object.entries(input)) {
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
out[key] = '[REDACTED]';
} else if (key.toLowerCase() === 'targeturl' && typeof value === 'string') {
try {
const u = new URL(value);
out[key] = `${u.origin}${u.pathname}`;
} catch {
out[key] = '[REDACTED_URL]';
}
} else {
out[key] = redact(value, depth + 1);
}
}
return out;
}
return String(input);
}

View file

@ -0,0 +1,39 @@
/**
* @fileoverview AsyncLocalStorage-backed trace context (no logger dependency).
* Split from trace.js to avoid circular imports with logger.js.
* @module debug/trace-context
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomBytes } from 'node:crypto';
/**
* @typedef {Object} TraceContext
* @property {string} traceId
* @property {string} [span]
*/
/** @type {AsyncLocalStorage<TraceContext>} */
export const traceStorage = new AsyncLocalStorage();
/**
* Generate a short opaque trace id (16 hex chars).
* @returns {string}
*/
export function generateTraceId() {
return randomBytes(8).toString('hex');
}
/**
* @returns {string|null}
*/
export function getTraceId() {
return traceStorage.getStore()?.traceId ?? null;
}
/**
* @returns {TraceContext|null}
*/
export function getTraceContext() {
return traceStorage.getStore() ?? null;
}

View file

@ -0,0 +1,95 @@
/**
* @fileoverview Correlation / trace IDs for failure tracing across HTTP and NATS.
* @module debug/trace
*/
import { createDebugger } from './logger.js';
import {
traceStorage,
generateTraceId,
getTraceId,
getTraceContext,
} from './trace-context.js';
export { generateTraceId, getTraceId, getTraceContext };
const log = createDebugger('trace');
/**
* Run `fn` within a trace context. Nested calls inherit the same `traceId`
* unless `traceId` is explicitly overridden.
*
* @template T
* @param {object|(() => T|Promise<T>)} options - Options or the callback itself.
* @param {string} [options.traceId] - Existing id (e.g. from `X-Trace-Id` header).
* @param {string} [options.span] - Human label for this span (logged when debug on).
* @param {() => T|Promise<T>} [fn] - Work to execute inside the context when options is an object.
* @returns {T|Promise<T>} Return value of `fn`.
*
* @example
* await withTrace({ span: 'timestamp.wait', traceId: req.headers['x-trace-id'] }, async () => {
* // getTraceId() is stable here and in awaited children
* });
*/
export function withTrace(options, fn) {
const opts = typeof options === 'function' ? {} : options ?? {};
const callback = typeof options === 'function' ? options : fn;
if (typeof callback !== 'function') {
throw new TypeError('withTrace requires a function to execute');
}
const parent = traceStorage.getStore();
const traceId = opts.traceId || parent?.traceId || generateTraceId();
const span = opts.span || parent?.span || 'root';
const ctx = { traceId, span };
return traceStorage.run(ctx, () => {
log.debug('span enter', { span });
try {
const result = callback();
if (result && typeof result.then === 'function') {
return result.then(
(value) => {
log.debug('span exit', { span, ok: true });
return value;
},
(err) => {
log.debug('span exit', { span, ok: false, error: err?.message });
throw err;
},
);
}
log.debug('span exit', { span, ok: true });
return result;
} catch (err) {
log.debug('span exit', { span, ok: false, error: err?.message });
throw err;
}
});
}
/**
* Express middleware that establishes a trace context per request.
*
* Reads `X-Trace-Id` when present; otherwise generates a new id.
* Sets `req.traceId` and response header `X-Trace-Id`.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
*/
export function traceMiddleware(req, res, next) {
const incoming = req.headers['x-trace-id'];
const traceId =
typeof incoming === 'string' && incoming.trim() ? incoming.trim() : generateTraceId();
req.traceId = traceId;
res.setHeader('X-Trace-Id', traceId);
withTrace({ traceId, span: `${req.method} ${req.path}` }, () => {
next();
});
}

View file

@ -0,0 +1,75 @@
/**
* @fileoverview Structured errors and Express helpers.
* @module errors
*/
import { createDebugger } from './debug/logger.js';
import { getTraceId } from './debug/trace-context.js';
const log = createDebugger('app');
/**
* Operational error with HTTP status and machine-readable code.
*/
export class AppError extends Error {
/**
* @param {string} message - Human-readable error.
* @param {object} [options]
* @param {number} [options.status=500] - HTTP status code.
* @param {string} [options.code='INTERNAL_ERROR'] - Machine code.
* @param {unknown} [options.details] - Optional structured details (safe for clients).
*/
constructor(message, { status = 500, code = 'INTERNAL_ERROR', details } = {}) {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
this.details = details;
}
}
/**
* Wrap an async Express handler so rejected promises reach the error middleware.
*
* @param {(req: import('express').Request, res: import('express').Response, next: import('express').NextFunction) => Promise<unknown>} fn
* @returns {import('express').RequestHandler}
*/
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
/**
* Send a JSON error response. Logs server errors when debug is enabled.
*
* @param {import('express').Response} res
* @param {Error|AppError} err
* @returns {void}
*/
export function sendError(res, err) {
const status = err instanceof AppError ? err.status : 500;
const code = err instanceof AppError ? err.code : 'INTERNAL_ERROR';
const message = err?.message || 'Internal server error';
const details = err instanceof AppError ? err.details : undefined;
const traceId = getTraceId();
if (status >= 500) {
log.error('request failed', {
status,
code,
message,
traceId,
stack: err?.stack,
});
} else {
log.debug('client error', { status, code, message, traceId });
}
res.status(status).json({
error: message,
code,
...(details !== undefined ? { details } : {}),
...(traceId ? { traceId } : {}),
});
}

View file

@ -0,0 +1,51 @@
/**
* @fileoverview Process entrypoint: HTTP listen + optional in-process poller.
* @module index
*/
import { createApp } from './app.js';
import { config } from './config.js';
import { createDebugger } from './debug/logger.js';
import { startInProcessJobPoller } from './workers/inProcessJobPoller.js';
import { connectNats, ensureStreams } from './nats/connection.js';
import { startJobPollerWorker } from './workers/jobPollerWorker.js';
import { startWebhookWorker } from './workers/webhookWorker.js';
const log = createDebugger('app');
/**
* Start the HTTP server and background workers.
* @returns {import('http').Server}
*/
export function startServer() {
const app = createApp();
if (config.natsEnabled) {
connectNats()
.then(({ jsm }) => ensureStreams(jsm))
.then(() => Promise.all([startJobPollerWorker(), startWebhookWorker()]))
.then(() => log.info('NATS workers started'))
.catch((err) => log.error('NATS worker start failed', { error: err.message }));
} else {
startInProcessJobPoller();
}
const server = app.listen(config.port, config.host, () => {
log.info('middleware listening', {
host: config.host,
port: config.port,
veraeApiBaseUrl: config.veraeApiBaseUrl,
mockVerae: config.mockVerae,
natsEnabled: config.natsEnabled,
});
// eslint-disable-next-line no-console
console.log(
`Verae Zapier middleware listening on http://${config.host}:${config.port}`,
);
});
return server;
}
if (process.argv[1]?.includes('index.js')) {
startServer();
}

View file

@ -0,0 +1,97 @@
/**
* @fileoverview API keys and HMAC session tokens for the middleware auth bridge.
* @module lib/tokens
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
const TOKEN_PREFIX = 'zmt_';
const API_KEY_PREFIX = 'zmw_';
/**
* @param {string} payload
* @returns {string}
*/
function sign(payload) {
return createHmac('sha256', config.tokenSecret).update(payload).digest('base64url');
}
/**
* Issue a signed middleware session token embedding tenant + Verae JWT.
*
* @param {object} params
* @param {string} params.tenantId
* @param {string} params.veraeToken
* @param {string} [params.expiresAt]
* @returns {string} Token string starting with `zmt_`
*/
export function issueSessionToken({ tenantId, veraeToken, expiresAt }) {
const payload = Buffer.from(
JSON.stringify({
tenantId,
veraeToken,
expiresAt,
nonce: randomBytes(8).toString('hex'),
}),
).toString('base64url');
const signature = sign(payload);
return `${TOKEN_PREFIX}${payload}.${signature}`;
}
/**
* Parse and verify a session token.
*
* @param {string} token
* @returns {{ tenantId: string, veraeToken: string, expiresAt?: string, nonce?: string }|null}
*/
export function parseSessionToken(token) {
if (!token?.startsWith(TOKEN_PREFIX)) return null;
const raw = token.slice(TOKEN_PREFIX.length);
const dot = raw.lastIndexOf('.');
if (dot === -1) return null;
const payload = raw.slice(0, dot);
const signature = raw.slice(dot + 1);
const expected = sign(payload);
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return null;
}
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
/**
* Generate a new public API key (`zmw_…`).
* @returns {string}
*/
export function generateApiKey() {
return `${API_KEY_PREFIX}${randomBytes(24).toString('base64url')}`;
}
/**
* @param {unknown} value
* @returns {boolean}
*/
export function isApiKey(value) {
return typeof value === 'string' && value.startsWith(API_KEY_PREFIX);
}
/**
* Extract bearer credential from an Authorization header.
* @param {string|undefined} header
* @returns {string|null}
*/
export function extractBearerToken(header) {
if (!header) return null;
const match = String(header).match(/^Bearer\s+(.+)$/i);
return match?.[1] ?? null;
}

View file

@ -0,0 +1,27 @@
/**
* @fileoverview Express auth middleware populates req.auth.
* @module middleware/authenticate
*/
import { extractBearerToken } from '../lib/tokens.js';
import { resolveAuthContext } from '../services/authService.js';
import { asyncHandler } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
/**
* Resolve Bearer or x-api-key into `req.auth`.
*/
export const authenticate = asyncHandler(async (req, res, next) => {
const rawToken =
extractBearerToken(req.headers.authorization) ?? req.headers['x-api-key'] ?? null;
req.auth = await resolveAuthContext(rawToken);
log.debug('authenticated', {
tenantId: req.auth.tenantId,
method: req.auth.authMethod,
plan: req.auth.tenant?.plan,
});
next();
});

View file

@ -0,0 +1,56 @@
/**
* @fileoverview Simple in-memory per-tenant rate limiter.
* @module middleware/rateLimit
*/
import { AppError, asyncHandler } from '../errors.js';
import { getTenant, resolveLimits } from '../store/tenants.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
/** @type {Map<string, { windowStart: number, count: number }>} */
const windows = new Map();
/**
* Reset rate limit windows (tests).
* @returns {void}
*/
export function resetRateLimitWindows() {
windows.clear();
}
/**
* Enforce plan requestsPerMinute using a 60s sliding fixed window.
*/
export const rateLimit = asyncHandler(async (req, res, next) => {
const tenantId = req.auth?.tenantId;
if (!tenantId) return next();
const tenant = getTenant(tenantId);
const limits = tenant
? resolveLimits(tenant)
: { requestsPerMinute: 30 };
const rpm = limits.requestsPerMinute ?? 30;
const now = Date.now();
const windowMs = 60_000;
let entry = windows.get(tenantId);
if (!entry || now - entry.windowStart >= windowMs) {
entry = { windowStart: now, count: 0 };
windows.set(tenantId, entry);
}
entry.count += 1;
if (entry.count > rpm) {
log.debug('rate limited', { tenantId, count: entry.count, rpm });
throw new AppError('Rate limit exceeded', {
status: 429,
code: 'RATE_LIMITED',
details: { requestsPerMinute: rpm },
});
}
next();
});

View file

@ -0,0 +1,111 @@
/**
* @fileoverview NATS + JetStream connection lifecycle.
* @module nats/connection
*/
import { createDebugger } from '../debug/logger.js';
import { config } from '../config.js';
import { SUBJECTS, STREAMS } from './subjects.js';
const log = createDebugger('nats');
/** @type {import('nats').NatsConnection|null} */
let nc = null;
/** @type {import('nats').JetStreamClient|null} */
let js = null;
/** @type {import('nats').JetStreamManager|null} */
let jsm = null;
/**
* Connect to NATS and return JetStream handles.
*
* @param {string} [url=config.natsUrl]
* @returns {Promise<{ nc: import('nats').NatsConnection, js: import('nats').JetStreamClient, jsm: import('nats').JetStreamManager }>}
*/
export async function connectNats(url = config.natsUrl) {
if (!config.natsEnabled && process.env.NATS_FORCE_CONNECT !== '1') {
log.debug('connect skipped — NATS_ENABLED=false');
throw new Error('NATS is disabled (NATS_ENABLED=false)');
}
if (nc && js && jsm) {
return { nc, js, jsm };
}
log.info('connecting to NATS', { url });
const { connect } = await import('nats');
nc = await connect({ servers: url, name: 'verae-zapier-middleware' });
js = nc.jetstream();
jsm = await nc.jetstreamManager();
log.info('NATS connected', { url });
return { nc, js, jsm };
}
/**
* Idempotently create JetStream streams required by this middleware.
*
* @param {import('nats').JetStreamManager} [manager]
* @returns {Promise<void>}
*/
export async function ensureStreams(manager) {
const m = manager ?? jsm;
if (!m) {
throw new Error('JetStream manager not available — call connectNats first');
}
/** @type {Array<{ name: string, subjects: string[] }>} */
const defs = [
{ name: STREAMS.ZAPIER_JOBS, subjects: [SUBJECTS.JOBS_WATCH] },
{ name: STREAMS.ZAPIER_EVENTS, subjects: [SUBJECTS.JOBS_EVENTS] },
{ name: STREAMS.ZAPIER_WEBHOOKS, subjects: [SUBJECTS.WEBHOOKS_DELIVER] },
];
for (const def of defs) {
try {
await m.streams.info(def.name);
log.debug('stream exists', { stream: def.name });
} catch {
await m.streams.add({
name: def.name,
subjects: def.subjects,
retention: 'limits',
storage: 'file',
max_age: 24 * 60 * 60 * 1e9, // 24h in ns
num_replicas: 1,
});
log.info('stream created', { stream: def.name, subjects: def.subjects });
}
}
}
/**
* Close the shared NATS connection if open.
* @returns {Promise<void>}
*/
export async function closeNats() {
if (!nc) {
log.debug('closeNats: no active connection');
return;
}
log.info('closing NATS connection');
await nc.drain();
nc = null;
js = null;
jsm = null;
}
/**
* @returns {import('nats').JetStreamClient|null}
*/
export function getJetStream() {
return js;
}
/**
* @returns {boolean}
*/
export function isNatsConnected() {
return Boolean(nc && !nc.isClosed());
}

View file

@ -0,0 +1,95 @@
/**
* @fileoverview JetStream publishers for jobs, events, and webhooks.
* @module nats/publishers
*/
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
import { SUBJECTS } from './subjects.js';
import { getJetStream, connectNats } from './connection.js';
const log = createDebugger('nats');
/**
* @returns {Promise<import('nats').JetStreamClient>}
*/
async function requireJs() {
let js = getJetStream();
if (!js) {
const handles = await connectNats();
js = handles.js;
}
return js;
}
/**
* @param {object} partial
* @returns {Promise<{ seq: number }>}
*/
export async function enqueueWatch(partial) {
const msg = {
attempt: 0,
enqueuedAt: new Date().toISOString(),
traceId: getTraceId() || 'no-trace',
...partial,
};
log.debug('enqueueWatch', {
subject: SUBJECTS.JOBS_WATCH,
tenantId: msg.tenantId,
jobId: msg.jobId,
attempt: msg.attempt,
traceId: msg.traceId,
});
const js = await requireJs();
const ack = await js.publish(SUBJECTS.JOBS_WATCH, JSON.stringify(msg));
return { seq: Number(ack.seq) };
}
/**
* @param {object} partial
* @returns {Promise<{ seq: number }>}
*/
export async function publishJobEvent(partial) {
const msg = {
emittedAt: new Date().toISOString(),
traceId: getTraceId() || 'no-trace',
...partial,
};
log.debug('publishJobEvent', {
subject: SUBJECTS.JOBS_EVENTS,
event: msg.event,
jobId: msg.jobId,
tenantId: msg.tenantId,
});
const js = await requireJs();
const ack = await js.publish(SUBJECTS.JOBS_EVENTS, JSON.stringify(msg));
return { seq: Number(ack.seq) };
}
/**
* @param {object} partial
* @returns {Promise<{ seq: number }>}
*/
export async function enqueueWebhook(partial) {
const msg = {
attempt: 1,
traceId: getTraceId() || 'no-trace',
...partial,
};
log.debug('enqueueWebhook', {
subject: SUBJECTS.WEBHOOKS_DELIVER,
hookId: msg.hookId,
tenantId: msg.tenantId,
event: msg.event,
targetUrl: msg.targetUrl,
});
const js = await requireJs();
const ack = await js.publish(SUBJECTS.WEBHOOKS_DELIVER, JSON.stringify(msg));
return { seq: Number(ack.seq) };
}

View file

@ -0,0 +1,43 @@
/**
* @fileoverview NATS subject and stream name constants.
* @module nats/subjects
*
* See docs/architecture/nats-subjects.md for payload schemas.
*/
/**
* Subject strings used by publishers and consumers.
* @readonly
*/
export const SUBJECTS = Object.freeze({
/** Work queue: poll Verae job status */
JOBS_WATCH: 'verae.zapier.jobs.watch',
/** Terminal job outcomes */
JOBS_EVENTS: 'verae.zapier.jobs.events',
/** Work queue: HTTP POST to Zapier REST Hooks */
WEBHOOKS_DELIVER: 'verae.zapier.webhooks.deliver',
/** Optional metering stream */
USAGE: 'verae.zapier.usage',
});
/**
* JetStream stream names.
* @readonly
*/
export const STREAMS = Object.freeze({
ZAPIER_JOBS: 'ZAPIER_JOBS',
ZAPIER_EVENTS: 'ZAPIER_EVENTS',
ZAPIER_WEBHOOKS: 'ZAPIER_WEBHOOKS',
ZAPIER_USAGE: 'ZAPIER_USAGE',
});
/**
* Durable consumer names (queue groups).
* @readonly
*/
export const CONSUMERS = Object.freeze({
JOB_POLLER: 'job-poller',
EVENT_WEBHOOK_ROUTER: 'event-webhook-router',
WEBHOOK_DELIVER: 'webhook-deliver',
USAGE_WRITER: 'usage-writer',
});

View file

@ -0,0 +1,57 @@
/**
* @fileoverview Auth routes under /zapier/v1/auth
* @module routes/authRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { loginWithCredentials, loginWithApiKey, validateSession } from '../services/authService.js';
import { getUsageSummary } from '../store/usage.js';
import { getTenant } from '../store/tenants.js';
import { extractBearerToken } from '../lib/tokens.js';
export const authRoutes = Router();
authRoutes.post(
'/login',
asyncHandler(async (req, res) => {
const { username, password, api_key: apiKey } = req.body ?? {};
if (apiKey) {
const session = await loginWithApiKey(apiKey);
return res.json(session);
}
if (!username || !password) {
throw new AppError('username and password are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
const session = await loginWithCredentials({ username, password });
res.json(session);
}),
);
authRoutes.get(
'/me',
asyncHandler(async (req, res) => {
const rawToken =
extractBearerToken(req.headers.authorization) ?? req.headers['x-api-key'] ?? null;
if (!rawToken) {
throw new AppError('Missing authorization token', { status: 401, code: 'UNAUTHORIZED' });
}
const validation = await validateSession(rawToken);
const tenant = getTenant(validation.tenantId);
const usage = getUsageSummary(validation.tenantId);
res.json({
...validation,
plan: tenant?.plan ?? validation.plan ?? 'free',
usage,
});
}),
);

View file

@ -0,0 +1,33 @@
/**
* @fileoverview Mount all /zapier routes.
* @module routes/index
*/
import { Router } from 'express';
import { authRoutes } from './authRoutes.js';
import { timestampRoutes } from './timestampRoutes.js';
import { verifyRoutes } from './verifyRoutes.js';
import { statusRoutes } from './statusRoutes.js';
import { webhookRoutes } from './webhookRoutes.js';
import { publicTenantRoutes, adminTenantRoutes } from './tenantRoutes.js';
import { authenticate } from '../middleware/authenticate.js';
import { rateLimit } from '../middleware/rateLimit.js';
export const apiRoutes = Router();
// Public
apiRoutes.use('/v1/auth', authRoutes);
apiRoutes.use('/v1', publicTenantRoutes);
apiRoutes.use('/v1/admin', adminTenantRoutes);
// Protected
const protectedRoutes = Router();
protectedRoutes.use(authenticate);
protectedRoutes.use(rateLimit);
protectedRoutes.use('/timestamp', timestampRoutes);
protectedRoutes.use('/verify', verifyRoutes);
protectedRoutes.use('/status', statusRoutes);
protectedRoutes.use('/webhooks', webhookRoutes);
apiRoutes.use('/v1', protectedRoutes);

View file

@ -0,0 +1,46 @@
/**
* @module routes/statusRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
getJobStatus,
getBatchJobStatus,
getJobVerification,
} from '../services/timestampService.js';
export const statusRoutes = Router();
// Static/more-specific routes first
statusRoutes.post(
'/batch',
asyncHandler(async (req, res) => {
const { jobIds } = req.body ?? {};
if (!Array.isArray(jobIds) || jobIds.length === 0) {
throw new AppError('jobIds array is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await getBatchJobStatus(req.auth, { jobIds });
res.json(result);
}),
);
statusRoutes.get(
'/:jobId/verification',
asyncHandler(async (req, res) => {
const result = await getJobVerification(req.auth, req.params.jobId);
res.json(result);
}),
);
statusRoutes.get(
'/:jobId',
asyncHandler(async (req, res) => {
const { jobId } = req.params;
if (!jobId) {
throw new AppError('jobId is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await getJobStatus(req.auth, jobId);
res.json(result);
}),
);

View file

@ -0,0 +1,46 @@
/**
* @module routes/tenantRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
selfServeSignup,
provisionTenant,
listProvisionedTenants,
} from '../services/tenantService.js';
import { config } from '../config.js';
export const publicTenantRoutes = Router();
export const adminTenantRoutes = Router();
publicTenantRoutes.post(
'/signup',
asyncHandler(async (req, res) => {
const result = await selfServeSignup(req.body ?? {});
res.status(201).json(result);
}),
);
adminTenantRoutes.use((req, _res, next) => {
const secret = req.headers['x-admin-secret'];
if (secret !== config.adminSecret) {
return next(new AppError('Invalid admin secret', { status: 403, code: 'FORBIDDEN' }));
}
next();
});
adminTenantRoutes.post(
'/tenants',
asyncHandler(async (req, res) => {
const result = await provisionTenant(req.body ?? {});
res.status(201).json(result);
}),
);
adminTenantRoutes.get(
'/tenants',
asyncHandler(async (_req, res) => {
res.json({ tenants: listProvisionedTenants() });
}),
);

View file

@ -0,0 +1,49 @@
/**
* @module routes/timestampRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
createTimestamp,
createTimestampAndWait,
createBatchTimestamp,
} from '../services/timestampService.js';
export const timestampRoutes = Router();
timestampRoutes.post(
'/',
asyncHandler(async (req, res) => {
const { data, hashAlg } = req.body ?? {};
if (!data) {
throw new AppError('data is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await createTimestamp(req.auth, { data, hashAlg });
res.status(202).json(result);
}),
);
timestampRoutes.post(
'/wait',
asyncHandler(async (req, res) => {
const { data, hashAlg } = req.body ?? {};
if (!data) {
throw new AppError('data is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await createTimestampAndWait(req.auth, { data, hashAlg });
res.json(result);
}),
);
timestampRoutes.post(
'/batch',
asyncHandler(async (req, res) => {
const { items } = req.body ?? {};
if (!Array.isArray(items) || items.length === 0) {
throw new AppError('items array is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await createBatchTimestamp(req.auth, { items });
res.status(202).json(result);
}),
);

View file

@ -0,0 +1,36 @@
/**
* @module routes/verifyRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { verifyTimestamp, verifyBatch } from '../services/verifyService.js';
export const verifyRoutes = Router();
verifyRoutes.post(
'/',
asyncHandler(async (req, res) => {
const { certificate } = req.body ?? {};
if (!certificate) {
throw new AppError('certificate is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await verifyTimestamp(req.auth, { certificate });
res.json(result);
}),
);
verifyRoutes.post(
'/batch',
asyncHandler(async (req, res) => {
const { certificates } = req.body ?? {};
if (!Array.isArray(certificates) || certificates.length === 0) {
throw new AppError('certificates array is required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
const result = await verifyBatch(req.auth, { certificates });
res.json(result);
}),
);

View file

@ -0,0 +1,44 @@
/**
* @module routes/webhookRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { subscribe, unsubscribe } from '../services/webhookService.js';
import { listWebhooksForTenant } from '../store/webhooks.js';
export const webhookRoutes = Router();
webhookRoutes.post(
'/subscribe',
asyncHandler(async (req, res) => {
const { targetUrl, event = 'timestamp.completed' } = req.body ?? {};
try {
const hook = subscribe(req.auth, { targetUrl, event });
res.status(201).json(hook);
} catch (err) {
throw new AppError(err.message, { status: 400, code: 'VALIDATION_ERROR' });
}
}),
);
webhookRoutes.delete(
'/unsubscribe',
asyncHandler(async (req, res) => {
const { hookId, targetUrl } = req.body ?? req.query ?? {};
try {
const result = unsubscribe(req.auth, { hookId, targetUrl });
res.json(result);
} catch (err) {
throw new AppError(err.message, { status: 404, code: 'NOT_FOUND' });
}
}),
);
webhookRoutes.get(
'/',
asyncHandler(async (req, res) => {
const hooks = listWebhooksForTenant(req.auth.tenantId);
res.json({ webhooks: hooks });
}),
);

View file

@ -0,0 +1,141 @@
/**
* @fileoverview Auth bridge: API keys / sessions Verae JWT context.
* @module services/authService
*/
import { veraeClient } from '../clients/veraeClient.js';
import { getTenantByApiKey, getTenant } from '../store/tenants.js';
import { issueSessionToken, parseSessionToken, isApiKey } from '../lib/tokens.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
/**
* Login with Verae username/password and issue middleware session token.
*
* @param {object} params
* @param {string} params.username
* @param {string} params.password
* @param {object} [params.tenant]
* @returns {Promise<{ accessToken: string, expiresAt: string, tenant: object, user: object }>}
*/
export async function loginWithCredentials({ username, password, tenant }) {
log.debug('loginWithCredentials', { username, tenantId: tenant?.id });
const verae = await veraeClient.login({ username, password });
if (tenant?.veraeUsername && tenant.veraeUsername !== username) {
throw new AppError('Credentials do not match tenant account', {
status: 403,
code: 'FORBIDDEN',
});
}
const tenantId = tenant?.id ?? `user:${verae.user.username}`;
const accessToken = issueSessionToken({
tenantId,
veraeToken: verae.token,
expiresAt: verae.expiresAt,
});
return {
accessToken,
expiresAt: verae.expiresAt,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan }
: { id: tenantId, name: verae.user.username, plan: 'free' },
user: verae.user,
};
}
/**
* Resolve API key to tenant and login with stored Verae credentials.
* @param {string} apiKey
* @returns {Promise<Awaited<ReturnType<typeof loginWithCredentials>>>}
*/
export async function loginWithApiKey(apiKey) {
const tenant = getTenantByApiKey(apiKey);
if (!tenant) {
log.debug('invalid api key');
throw new AppError('Invalid API key', { status: 401, code: 'UNAUTHORIZED' });
}
if (!tenant.veraeUsername || !tenant.veraePassword) {
throw new AppError('Tenant is missing Verae credentials', {
status: 500,
code: 'TENANT_MISCONFIGURED',
});
}
log.debug('loginWithApiKey', { tenantId: tenant.id, plan: tenant.plan });
return loginWithCredentials({
username: tenant.veraeUsername,
password: tenant.veraePassword,
tenant,
});
}
/**
* Resolve bearer credential into request auth context.
*
* @param {string|null} rawToken
* @returns {Promise<{ tenantId: string, tenant: object, veraeToken: string, authMethod: string }>}
*/
export async function resolveAuthContext(rawToken) {
if (!rawToken) {
throw new AppError('Missing authorization token', { status: 401, code: 'UNAUTHORIZED' });
}
if (isApiKey(rawToken)) {
const session = await loginWithApiKey(rawToken);
const parsed = parseSessionToken(session.accessToken);
const tenant = getTenant(session.tenant.id) ?? session.tenant;
return {
tenantId: session.tenant.id,
tenant: { id: session.tenant.id, name: session.tenant.name, plan: session.tenant.plan },
veraeToken: parsed.veraeToken,
authMethod: 'api_key',
fullTenant: tenant,
};
}
const parsed = parseSessionToken(rawToken);
if (!parsed?.veraeToken) {
throw new AppError('Invalid or expired session token', { status: 401, code: 'UNAUTHORIZED' });
}
if (parsed.expiresAt && Date.parse(parsed.expiresAt) < Date.now()) {
throw new AppError('Session token expired', { status: 401, code: 'TOKEN_EXPIRED' });
}
const tenant = getTenant(parsed.tenantId);
log.debug('session auth', { tenantId: parsed.tenantId });
return {
tenantId: parsed.tenantId,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan }
: { id: parsed.tenantId, plan: 'free' },
veraeToken: parsed.veraeToken,
authMethod: 'session',
fullTenant: tenant,
};
}
/**
* Validate auth and optionally ping Verae /auth/validate.
* @param {string} rawToken
* @returns {Promise<object>}
*/
export async function validateSession(rawToken) {
const context = await resolveAuthContext(rawToken);
const validation = await veraeClient.validate(context.veraeToken);
return {
valid: true,
tenantId: context.tenantId,
plan: context.tenant.plan,
authMethod: context.authMethod,
user: validation,
};
}

View file

@ -0,0 +1,160 @@
/**
* @fileoverview Plan quotas and usage recording.
* @module services/entitlementService
*/
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { getTenant, resolveLimits } from '../store/tenants.js';
import { getUsage, incrementUsage } from '../store/usage.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
function isUnlimited(value) {
return value === null || value === undefined;
}
function quotaExceeded({ limit, used, allowOverage }) {
if (isUnlimited(limit)) return false;
if (used < limit) return false;
return !allowOverage;
}
/**
* Ensure tenant may perform an action under plan limits.
*
* @param {string} tenantId
* @param {'timestamp'|'verify'|'batch_timestamp'} action
* @param {{ amount?: number }} [options]
* @returns {{ tenant: object, limits: object, usage: object }}
*/
export function checkEntitlement(tenantId, action, { amount = 1 } = {}) {
const tenant = getTenant(tenantId);
if (!tenant) {
throw new AppError('Unknown tenant', { status: 401, code: 'UNAUTHORIZED' });
}
const limits = resolveLimits(tenant);
const usage = getUsage(tenantId);
log.debug('checkEntitlement', {
tenantId,
action,
plan: tenant.plan,
amount,
timestampsUsed: usage.timestamps,
timestampsLimit: limits.timestamps,
});
if (action === 'batch_timestamp') {
if (!limits.batch) {
throw new AppError('Batch timestamps require a paid plan', {
status: 403,
code: 'PLAN_UPGRADE_REQUIRED',
details: { upgradeUrl: config.upgradeUrl },
});
}
if (!isUnlimited(limits.batchMaxItems) && amount > limits.batchMaxItems) {
throw new AppError(`Batch size exceeds plan limit of ${limits.batchMaxItems}`, {
status: 403,
code: 'BATCH_LIMIT_EXCEEDED',
details: { upgradeUrl: config.upgradeUrl, limit: limits.batchMaxItems },
});
}
}
if (action === 'timestamp') {
if (
quotaExceeded({
limit: limits.timestamps,
used: usage.timestamps,
allowOverage: limits.allowOverage,
})
) {
const err = new AppError('Monthly timestamp quota exceeded', {
status: 402,
code: 'QUOTA_EXCEEDED',
details: {
limit: limits.timestamps,
used: usage.timestamps,
period: 'monthly',
upgradeUrl: config.upgradeUrl,
},
});
throw err;
}
}
if (action === 'verify') {
if (
quotaExceeded({
limit: limits.verifications,
used: usage.verifications,
allowOverage: limits.allowOverage,
})
) {
throw new AppError('Monthly verification quota exceeded', {
status: 402,
code: 'QUOTA_EXCEEDED',
details: {
limit: limits.verifications,
used: usage.verifications,
period: 'monthly',
upgradeUrl: config.upgradeUrl,
},
});
}
}
return { tenant, limits, usage };
}
/**
* Record usage after a successful action.
*
* @param {string} tenantId
* @param {string} action
* @param {{ amount?: number }} [options]
* @returns {void}
*/
export function recordUsage(tenantId, action, { amount = 1 } = {}) {
const tenant = getTenant(tenantId);
if (!tenant) return;
const limits = resolveLimits(tenant);
const usage = getUsage(tenantId);
if (action === 'timestamp') {
if (
!isUnlimited(limits.timestamps) &&
usage.timestamps >= limits.timestamps &&
limits.allowOverage
) {
incrementUsage(tenantId, 'overage.timestamps', amount);
}
incrementUsage(tenantId, 'timestamps', amount);
}
if (action === 'verify') {
if (
!isUnlimited(limits.verifications) &&
usage.verifications >= limits.verifications &&
limits.allowOverage
) {
incrementUsage(tenantId, 'overage.verifications', amount);
}
incrementUsage(tenantId, 'verifications', amount);
}
if (action === 'status') {
incrementUsage(tenantId, 'statusChecks', amount);
}
if (action === 'batch_timestamp') {
incrementUsage(tenantId, 'batchTimestamps', amount);
incrementUsage(tenantId, 'timestamps', amount);
}
log.debug('recordUsage', { tenantId, action, amount });
}

View file

@ -0,0 +1,137 @@
/**
* @fileoverview Self-serve signup and admin tenant provisioning.
* @module services/tenantService
*/
import { randomUUID } from 'node:crypto';
import { createTenant, getTenant, listTenants } from '../store/tenants.js';
import { veraeClient } from '../clients/veraeClient.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
const ALLOWED_PLANS = new Set(['free', 'starter', 'pro', 'enterprise']);
function slugify(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
}
async function validateVeraeCredentials(username, password) {
try {
await veraeClient.login({ username, password });
return true;
} catch (err) {
throw new AppError('Invalid Verae credentials', {
status: 400,
code: 'INVALID_VERAE_CREDENTIALS',
details: { message: err.message },
});
}
}
/**
* Public free-tier signup.
* @param {{ email: string, name: string, veraeUsername: string, veraePassword: string }} params
*/
export async function selfServeSignup({ email, name, veraeUsername, veraePassword }) {
if (!email || !name || !veraeUsername || !veraePassword) {
throw new AppError('email, name, veraeUsername, and veraePassword are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
await validateVeraeCredentials(veraeUsername, veraePassword);
const id = `tenant-${slugify(email)}-${randomUUID().slice(0, 8)}`;
const { tenant, apiKey } = createTenant({
id,
name,
plan: 'free',
veraeUsername,
veraePassword,
contract: null,
metadata: { email, audience: 'self-serve', createdVia: 'signup' },
});
log.info('self-serve signup', { tenantId: tenant.id });
return {
tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan, email },
apiKey,
zapierSetup: {
authType: 'custom',
field: 'api_key',
middlewareUrl: '/zapier/v1/auth/me',
},
};
}
/**
* Admin provision (internal / enterprise).
*/
export async function provisionTenant({
id,
name,
plan,
veraeUsername,
veraePassword,
contract = null,
metadata = {},
audience = 'admin',
}) {
if (!name || !plan || !veraeUsername || !veraePassword) {
throw new AppError('name, plan, veraeUsername, and veraePassword are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
if (!ALLOWED_PLANS.has(plan)) {
throw new AppError(`Invalid plan: ${plan}`, { status: 400, code: 'VALIDATION_ERROR' });
}
if (plan === 'enterprise' && !contract) {
throw new AppError('enterprise tenants require a contract object', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
await validateVeraeCredentials(veraeUsername, veraePassword);
const tenantId = id ?? `tenant-${slugify(name)}-${randomUUID().slice(0, 8)}`;
if (getTenant(tenantId)) {
throw new AppError('Tenant already exists', { status: 409, code: 'CONFLICT' });
}
const { tenant, apiKey } = createTenant({
id: tenantId,
name,
plan,
veraeUsername,
veraePassword,
contract,
metadata: { ...metadata, audience, createdVia: 'provision' },
});
log.info('tenant provisioned', { tenantId: tenant.id, plan, audience });
return { tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan }, apiKey };
}
/**
* @returns {Array<object>}
*/
export function listProvisionedTenants() {
return listTenants().map((tenant) => ({
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
audience: tenant.metadata?.audience ?? 'unknown',
createdAt: tenant.createdAt,
}));
}

View file

@ -0,0 +1,122 @@
/**
* @fileoverview Timestamp create/status operations with billing + job enqueue.
* @module services/timestampService
*/
import { config } from '../config.js';
import { veraeClient } from '../clients/veraeClient.js';
import { enqueueJob } from '../store/jobWatchers.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
const log = createDebugger('jobs');
/**
* Enqueue async job watch via NATS or in-process store.
* @param {object} ctx
* @param {string} jobId
*/
async function enqueueWatchForJob(ctx, jobId) {
const traceId = getTraceId() ?? undefined;
if (config.natsEnabled) {
const { enqueueWatch } = await import('../nats/publishers.js');
await enqueueWatch({
tenantId: ctx.tenantId,
jobId,
// Prefer re-login in worker; include token for MVP simplicity when mock
veraeToken: ctx.veraeToken,
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
traceId,
});
log.debug('watch enqueued on NATS', { jobId, tenantId: ctx.tenantId });
return;
}
enqueueJob({
tenantId: ctx.tenantId,
jobId,
veraeToken: ctx.veraeToken,
traceId,
});
}
/**
* @param {object} ctx - Auth context with tenantId, veraeToken
* @param {{ data: string, hashAlg?: string }} body
* @returns {Promise<{ jobId: string }>}
*/
export async function createTimestamp(ctx, body) {
checkEntitlement(ctx.tenantId, 'timestamp');
const result = await veraeClient.createTimestamp(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'timestamp');
await enqueueWatchForJob(ctx, result.jobId);
log.debug('timestamp created', { jobId: result.jobId, tenantId: ctx.tenantId });
return result;
}
/**
* @param {object} ctx
* @param {{ data: string, hashAlg?: string }} body
* @returns {Promise<object>} StatusResponse
*/
export async function createTimestampAndWait(ctx, body) {
const created = await createTimestamp(ctx, body);
const status = await veraeClient.waitForJob(ctx.veraeToken, created.jobId, {
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
});
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {{ items: Array<{ data: string, hashAlg?: string }> }} body
*/
export async function createBatchTimestamp(ctx, body) {
const itemCount = body.items?.length ?? 0;
checkEntitlement(ctx.tenantId, 'batch_timestamp', { amount: itemCount });
const result = await veraeClient.createBatchTimestamp(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'batch_timestamp', { amount: itemCount });
for (const jobId of result.jobIds ?? []) {
await enqueueWatchForJob(ctx, jobId);
}
return result;
}
/**
* @param {object} ctx
* @param {string} jobId
*/
export async function getJobStatus(ctx, jobId) {
const status = await veraeClient.getStatus(ctx.veraeToken, jobId);
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {{ jobIds: string[] }} body
*/
export async function getBatchJobStatus(ctx, body) {
const status = await veraeClient.getBatchStatus(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'status', { amount: body.jobIds?.length ?? 1 });
return status;
}
/**
* @param {object} ctx
* @param {string} jobId
*/
export async function getJobVerification(ctx, jobId) {
const status = await veraeClient.getJobVerification(ctx.veraeToken, jobId);
recordUsage(ctx.tenantId, 'status');
return status;
}

View file

@ -0,0 +1,34 @@
/**
* @fileoverview Certificate verification with usage metering.
* @module services/verifyService
*/
import { veraeClient } from '../clients/veraeClient.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
/**
* @param {object} ctx
* @param {{ certificate: string }} body
*/
export async function verifyTimestamp(ctx, body) {
checkEntitlement(ctx.tenantId, 'verify');
const result = await veraeClient.verify(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'verify');
log.debug('verify result', { tenantId: ctx.tenantId, valid: result.valid });
return result;
}
/**
* @param {object} ctx
* @param {{ certificates: string[] }} body
*/
export async function verifyBatch(ctx, body) {
const amount = body.certificates?.length ?? 1;
checkEntitlement(ctx.tenantId, 'verify', { amount });
const result = await veraeClient.verifyBatch(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'verify', { amount });
return result;
}

View file

@ -0,0 +1,70 @@
/**
* @fileoverview REST Hook subscribe/unsubscribe and HTTP delivery.
* @module services/webhookService
*/
import { createWebhook, deleteWebhook } from '../store/webhooks.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('webhooks');
const ALLOWED_EVENTS = new Set(['timestamp.completed', 'timestamp.failed']);
/**
* @param {object} ctx - auth context
* @param {{ targetUrl: string, event: string }} params
*/
export function subscribe(ctx, { targetUrl, event }) {
if (!targetUrl) {
throw new Error('targetUrl is required');
}
if (!ALLOWED_EVENTS.has(event)) {
throw new Error(`Unsupported event: ${event}`);
}
return createWebhook({
tenantId: ctx.tenantId,
targetUrl,
event,
});
}
/**
* @param {object} ctx
* @param {{ hookId?: string, targetUrl?: string }} params
*/
export function unsubscribe(ctx, { hookId, targetUrl }) {
const removed = deleteWebhook({
tenantId: ctx.tenantId,
hookId,
targetUrl,
});
if (!removed) {
throw new Error('Webhook subscription not found');
}
return { removed: true };
}
/**
* POST payload to Zapier target URL.
* @param {string} targetUrl
* @param {object} payload
* @returns {Promise<{ ok: boolean, status: number }>}
*/
export async function deliverWebhook(targetUrl, payload) {
log.debug('deliverWebhook', { targetUrl, event: payload?.event });
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Verae-Zapier-Middleware/1.0',
},
body: JSON.stringify(payload),
});
log.debug('deliverWebhook result', { status: response.status, ok: response.ok });
return { ok: response.ok, status: response.status };
}

View file

@ -0,0 +1,114 @@
/**
* @fileoverview JSON file-backed in-memory store (MVP persistence).
* @module store/db
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { config } from '../config.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('app');
/**
* @typedef {Object} StoreShape
* @property {Record<string, object>} tenants
* @property {Record<string, string>} apiKeys - apiKey tenantId
* @property {Record<string, object>} usage - tenantId counters
* @property {object[]} webhooks
* @property {object[]} jobWatchers
*/
/** @type {StoreShape|null} */
let store = null;
/**
* Create an empty store document.
* @returns {StoreShape}
*/
export function emptyStore() {
return {
tenants: {},
apiKeys: {},
usage: {},
webhooks: [],
jobWatchers: [],
};
}
/**
* Load store from disk into memory (or create empty if missing).
* @param {string} [path=config.storePath]
* @returns {StoreShape}
*/
export function loadStore(path = config.storePath) {
if (store) return store;
if (existsSync(path)) {
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw);
store = {
...emptyStore(),
...parsed,
tenants: parsed.tenants ?? {},
apiKeys: parsed.apiKeys ?? {},
usage: parsed.usage ?? {},
webhooks: Array.isArray(parsed.webhooks) ? parsed.webhooks : [],
jobWatchers: Array.isArray(parsed.jobWatchers) ? parsed.jobWatchers : [],
};
log.debug('store loaded', { path, tenants: Object.keys(store.tenants).length });
} catch (err) {
log.error('store load failed, using empty', { path, error: err.message });
store = emptyStore();
}
} else {
store = emptyStore();
log.debug('store initialized empty', { path });
}
return store;
}
/**
* Access the in-memory store (loads if needed).
* @returns {StoreShape}
*/
export function getStore() {
if (!store) return loadStore();
return store;
}
/**
* Replace the in-memory store (tests only).
* @param {StoreShape|null} next
* @returns {void}
*/
export function setStoreForTests(next) {
store = next;
}
/**
* Persist the in-memory store to disk.
* @param {string} [path=config.storePath]
* @returns {void}
*/
export function persist(path = config.storePath) {
const data = getStore();
const dir = dirname(path);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
log.debug('store persisted', { path });
}
/**
* Force reload from disk (drops memory).
* @param {string} [path=config.storePath]
* @returns {StoreShape}
*/
export function reloadStore(path = config.storePath) {
store = null;
return loadStore(path);
}

View file

@ -0,0 +1,89 @@
/**
* @fileoverview In-process job watch queue (NATS_ENABLED=false path).
* @module store/jobWatchers
*/
import { randomUUID } from 'node:crypto';
import { getStore, persist } from './db.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
/**
* @typedef {Object} JobWatcher
* @property {string} id
* @property {string} tenantId
* @property {string} jobId
* @property {string} veraeToken
* @property {string} status
* @property {number} attempts
* @property {string} createdAt
* @property {string} updatedAt
* @property {string} [traceId]
*/
/**
* @param {object} params
* @param {string} params.tenantId
* @param {string} params.jobId
* @param {string} params.veraeToken
* @param {string} [params.traceId]
* @returns {JobWatcher}
*/
export function enqueueJob({ tenantId, jobId, veraeToken, traceId }) {
/** @type {JobWatcher} */
const watcher = {
id: randomUUID(),
tenantId,
jobId,
veraeToken,
status: 'pending',
attempts: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
traceId,
};
const store = getStore();
store.jobWatchers.push(watcher);
persist();
log.debug('job enqueued', { watcherId: watcher.id, jobId, tenantId });
return watcher;
}
/**
* @returns {JobWatcher[]}
*/
export function listPendingJobs() {
return getStore().jobWatchers.filter((job) => job.status === 'pending');
}
/**
* @param {string} id
* @param {Partial<JobWatcher>} patch
* @returns {JobWatcher|null}
*/
export function updateJobWatcher(id, patch) {
const store = getStore();
const index = store.jobWatchers.findIndex((job) => job.id === id);
if (index === -1) return null;
store.jobWatchers[index] = {
...store.jobWatchers[index],
...patch,
updatedAt: new Date().toISOString(),
};
persist();
return store.jobWatchers[index];
}
/**
* @param {string} id
* @returns {void}
*/
export function removeJobWatcher(id) {
const store = getStore();
store.jobWatchers = store.jobWatchers.filter((job) => job.id !== id);
persist();
log.debug('job watcher removed', { id });
}

View file

@ -0,0 +1,137 @@
/**
* @fileoverview Tenant and API key persistence.
* @module store/tenants
*/
import { getStore, persist } from './db.js';
import { generateApiKey } from '../lib/tokens.js';
import { PLAN_LIMITS } from '../config.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
/**
* @typedef {Object} Tenant
* @property {string} id
* @property {string} name
* @property {string} plan
* @property {string} veraeUsername
* @property {string} veraePassword
* @property {object|null} contract
* @property {object} [metadata]
* @property {string} createdAt
*/
/**
* @param {string} tenantId
* @returns {Tenant|null}
*/
export function getTenant(tenantId) {
return getStore().tenants[tenantId] ?? null;
}
/**
* @param {string} apiKey
* @returns {Tenant|null}
*/
export function getTenantByApiKey(apiKey) {
const store = getStore();
const tenantId = store.apiKeys[apiKey];
return tenantId ? store.tenants[tenantId] ?? null : null;
}
/**
* @returns {Tenant[]}
*/
export function listTenants() {
return Object.values(getStore().tenants);
}
/**
* @param {Tenant} tenant
* @returns {Tenant}
*/
export function upsertTenant(tenant) {
const store = getStore();
store.tenants[tenant.id] = tenant;
persist();
log.debug('tenant upserted', { tenantId: tenant.id, plan: tenant.plan });
return tenant;
}
/**
* Create a tenant and bind a new API key.
*
* @param {object} params
* @param {string} params.id
* @param {string} params.name
* @param {string} [params.plan='free']
* @param {string} params.veraeUsername
* @param {string} params.veraePassword
* @param {object|null} [params.contract=null]
* @param {string} [params.apiKey]
* @param {object} [params.metadata]
* @returns {{ tenant: Tenant, apiKey: string }}
*/
export function createTenant({
id,
name,
plan = 'free',
veraeUsername,
veraePassword,
contract = null,
apiKey = generateApiKey(),
metadata = {},
}) {
const store = getStore();
/** @type {Tenant} */
const tenant = {
id,
name,
plan,
veraeUsername,
veraePassword,
contract,
metadata,
createdAt: new Date().toISOString(),
};
store.tenants[id] = tenant;
store.apiKeys[apiKey] = id;
persist();
log.info('tenant created', { tenantId: id, plan, audience: metadata.audience });
return { tenant, apiKey };
}
/**
* Resolve effective limits for a tenant (plan defaults or enterprise contract).
*
* @param {Tenant} tenant
* @returns {{
* timestamps: number|null,
* verifications: number|null,
* batch: boolean,
* batchMaxItems: number|null,
* requestsPerMinute: number,
* allowOverage: boolean,
* overageRates: object
* }}
*/
export function resolveLimits(tenant) {
if (tenant.plan === 'enterprise' && tenant.contract) {
return {
timestamps: tenant.contract.includedTimestamps ?? null,
verifications: tenant.contract.includedVerifications ?? null,
batch: tenant.contract.batch !== false,
batchMaxItems: tenant.contract.batchMaxItems ?? null,
requestsPerMinute:
tenant.contract.requestsPerMinute ?? PLAN_LIMITS.enterprise.requestsPerMinute,
allowOverage: tenant.contract.allowOverage ?? false,
overageRates: tenant.contract.overageRates ?? {},
};
}
const base = PLAN_LIMITS[tenant.plan] ?? PLAN_LIMITS.free;
return { ...base, allowOverage: false, overageRates: {} };
}

View file

@ -0,0 +1,102 @@
/**
* @fileoverview Per-tenant usage metering counters.
* @module store/usage
*/
import { getStore, persist } from './db.js';
import { getTenant, resolveLimits } from './tenants.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
/**
* @returns {string} YYYY-MM period key
*/
function currentPeriod() {
const d = new Date();
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
/**
* @param {string} tenantId
* @returns {{ period: string, timestamps: number, verifications: number, statusChecks: number, batchTimestamps: number, overage: object }}
*/
function emptyUsage(tenantId) {
return {
tenantId,
period: currentPeriod(),
timestamps: 0,
verifications: 0,
statusChecks: 0,
batchTimestamps: 0,
overage: { timestamps: 0, verifications: 0 },
};
}
/**
* Get usage for tenant, rolling period if month changed.
* @param {string} tenantId
* @returns {ReturnType<typeof emptyUsage>}
*/
export function getUsage(tenantId) {
const store = getStore();
let usage = store.usage[tenantId];
const period = currentPeriod();
if (!usage || usage.period !== period) {
usage = emptyUsage(tenantId);
store.usage[tenantId] = usage;
}
return usage;
}
/**
* Public summary for /auth/me.
* @param {string} tenantId
* @returns {object}
*/
export function getUsageSummary(tenantId) {
const usage = getUsage(tenantId);
const tenant = getTenant(tenantId);
const limits = tenant ? resolveLimits(tenant) : null;
return {
period: usage.period,
timestamps: usage.timestamps,
verifications: usage.verifications,
statusChecks: usage.statusChecks,
batchTimestamps: usage.batchTimestamps,
limits: limits
? {
timestamps: limits.timestamps,
verifications: limits.verifications,
batch: limits.batch,
batchMaxItems: limits.batchMaxItems,
}
: null,
};
}
/**
* Increment a usage metric.
*
* @param {string} tenantId
* @param {string} metric - e.g. `timestamps`, `verifications`, `statusChecks`, `batchTimestamps`, `overage.timestamps`
* @param {number} [amount=1]
* @returns {void}
*/
export function incrementUsage(tenantId, metric, amount = 1) {
const usage = getUsage(tenantId);
if (metric.startsWith('overage.')) {
const key = metric.slice('overage.'.length);
usage.overage[key] = (usage.overage[key] ?? 0) + amount;
} else {
usage[metric] = (usage[metric] ?? 0) + amount;
}
getStore().usage[tenantId] = usage;
persist();
log.debug('usage incremented', { tenantId, metric, amount, value: usage[metric] ?? usage.overage });
}

View file

@ -0,0 +1,91 @@
/**
* @fileoverview REST Hook subscription storage.
* @module store/webhooks
*/
import { randomUUID } from 'node:crypto';
import { getStore, persist } from './db.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('webhooks');
/**
* @typedef {Object} Webhook
* @property {string} id
* @property {string} tenantId
* @property {string} targetUrl
* @property {string} event
* @property {string} createdAt
*/
/**
* @param {object} params
* @param {string} params.tenantId
* @param {string} params.targetUrl
* @param {string} params.event
* @returns {Webhook}
*/
export function createWebhook({ tenantId, targetUrl, event }) {
/** @type {Webhook} */
const hook = {
id: randomUUID(),
tenantId,
targetUrl,
event,
createdAt: new Date().toISOString(),
};
const store = getStore();
store.webhooks.push(hook);
persist();
log.info('webhook created', { hookId: hook.id, tenantId, event });
return hook;
}
/**
* Delete a webhook for a tenant by id and/or targetUrl.
*
* @param {object} params
* @param {string} params.tenantId
* @param {string} [params.hookId]
* @param {string} [params.targetUrl]
* @returns {boolean} True if at least one webhook was removed.
*/
export function deleteWebhook({ tenantId, hookId, targetUrl }) {
if (!hookId && !targetUrl) return false;
const store = getStore();
const before = store.webhooks.length;
store.webhooks = store.webhooks.filter((hook) => {
if (hook.tenantId !== tenantId) return true;
if (hookId && hook.id === hookId) return false;
if (!hookId && targetUrl && hook.targetUrl === targetUrl) return false;
return true;
});
const removedCount = before - store.webhooks.length;
if (removedCount > 0) {
persist();
log.info('webhook deleted', { tenantId, hookId, removedCount });
return true;
}
return false;
}
/**
* @param {string} tenantId
* @param {string} event
* @returns {Webhook[]}
*/
export function getActiveWebhooks(tenantId, event) {
return getStore().webhooks.filter((h) => h.tenantId === tenantId && h.event === event);
}
/**
* @param {string} tenantId
* @returns {Webhook[]}
*/
export function listWebhooksForTenant(tenantId) {
return getStore().webhooks.filter((h) => h.tenantId === tenantId);
}

View file

@ -0,0 +1,108 @@
/**
* @fileoverview In-process job poller when NATS_ENABLED=false.
* @module workers/inProcessJobPoller
*/
import { config } from '../config.js';
import { veraeClient } from '../clients/veraeClient.js';
import {
listPendingJobs,
updateJobWatcher,
removeJobWatcher,
} from '../store/jobWatchers.js';
import { getActiveWebhooks } from '../store/webhooks.js';
import { deliverWebhook } from '../services/webhookService.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
let timer = null;
let running = false;
/**
* @param {import('../store/jobWatchers.js').JobWatcher} job
*/
async function processJob(job) {
const attempts = job.attempts + 1;
updateJobWatcher(job.id, { attempts });
if (attempts > config.jobPollMaxAttempts) {
updateJobWatcher(job.id, { status: 'timeout' });
removeJobWatcher(job.id);
log.warn('job timeout', { jobId: job.jobId });
return;
}
let status;
try {
status = await veraeClient.getStatus(job.veraeToken, job.jobId);
} catch (err) {
log.debug('poll error', { jobId: job.jobId, error: err.message });
return;
}
if (status.status === 'pending') {
return;
}
const event = status.status === 'completed' ? 'timestamp.completed' : 'timestamp.failed';
const hooks = getActiveWebhooks(job.tenantId, event);
for (const hook of hooks) {
try {
await deliverWebhook(hook.targetUrl, {
event,
jobId: job.jobId,
tenantId: job.tenantId,
status,
});
} catch (err) {
log.error('webhook deliver failed', { hookId: hook.id, error: err.message });
}
}
updateJobWatcher(job.id, { status: status.status });
removeJobWatcher(job.id);
log.debug('job terminal', { jobId: job.jobId, status: status.status, hooks: hooks.length });
}
async function tick() {
if (running) return;
running = true;
try {
const jobs = listPendingJobs();
await Promise.all(jobs.map((job) => processJob(job)));
} finally {
running = false;
}
}
/**
* Start interval poller (no-op if already started or NATS enabled).
* @returns {void}
*/
export function startInProcessJobPoller() {
if (config.natsEnabled) {
log.info('in-process poller skipped (NATS_ENABLED=true)');
return;
}
if (timer) return;
const interval = config.jobPollIntervalMs;
timer = setInterval(() => {
tick().catch((err) => log.error('poller tick failed', { error: err.message }));
}, interval);
log.info('in-process job poller started', { intervalMs: interval });
}
/**
* Stop interval poller.
* @returns {void}
*/
export function stopInProcessJobPoller() {
if (!timer) return;
clearInterval(timer);
timer = null;
log.info('in-process job poller stopped');
}

View file

@ -0,0 +1,158 @@
/**
* @fileoverview JetStream consumer that polls Verae job status.
* @module workers/jobPollerWorker
*/
import { createDebugger } from '../debug/logger.js';
import { config } from '../config.js';
import { SUBJECTS, CONSUMERS, STREAMS } from '../nats/subjects.js';
import { connectNats, ensureStreams } from '../nats/connection.js';
import { publishJobEvent } from '../nats/publishers.js';
import { veraeClient } from '../clients/veraeClient.js';
import { getTenant } from '../store/tenants.js';
import { withTrace } from '../debug/trace.js';
const log = createDebugger('jobs');
let running = false;
/** @type {AbortController|null} */
let abort = null;
/**
* Resolve a Verae token for polling (re-login via tenant if needed).
* @param {object} msg
* @returns {Promise<string>}
*/
async function resolveVeraeToken(msg) {
if (msg.veraeToken) return msg.veraeToken;
const tenant = getTenant(msg.tenantId);
if (!tenant?.veraeUsername) {
throw new Error(`Cannot resolve token for tenant ${msg.tenantId}`);
}
const login = await veraeClient.login({
username: tenant.veraeUsername,
password: tenant.veraePassword,
});
return login.token;
}
/**
* Process one watch message.
* @param {object} data
* @param {{ ack: () => Promise<void>, nak: (delay?: number) => Promise<void> }} ctrl
*/
async function handleWatch(data, ctrl) {
await withTrace({ traceId: data.traceId, span: 'job-poll' }, async () => {
const attempt = (data.attempt ?? 0) + 1;
const maxAttempts = data.maxAttempts ?? config.jobPollMaxAttempts;
if (attempt > maxAttempts) {
await publishJobEvent({
event: 'timestamp.timeout',
tenantId: data.tenantId,
jobId: data.jobId,
status: { id: data.jobId, status: 'timeout' },
traceId: data.traceId,
});
await ctrl.ack();
return;
}
const token = await resolveVeraeToken(data);
let status;
try {
status = await veraeClient.getStatus(token, data.jobId);
} catch (err) {
log.debug('poll error, nak', { jobId: data.jobId, error: err.message });
await ctrl.nak(config.jobPollIntervalMs);
return;
}
log.debug('poll status', { jobId: data.jobId, status: status.status, attempt });
if (status.status === 'pending') {
await ctrl.nak(config.jobPollIntervalMs);
return;
}
const event =
status.status === 'completed' ? 'timestamp.completed' : 'timestamp.failed';
await publishJobEvent({
event,
tenantId: data.tenantId,
jobId: data.jobId,
status,
traceId: data.traceId,
});
await ctrl.ack();
});
}
/**
* Start the durable job-poller worker loop.
* @returns {Promise<{ stop: () => Promise<void> }>}
*/
export async function startJobPollerWorker() {
if (running) {
return { stop: async () => stopJobPollerWorker() };
}
const { nc, js, jsm } = await connectNats();
await ensureStreams(jsm);
// Ensure durable consumer (workqueue-style via filter + durable name)
try {
await jsm.consumers.add(STREAMS.ZAPIER_JOBS, {
durable_name: CONSUMERS.JOB_POLLER,
ack_policy: 'explicit',
filter_subject: SUBJECTS.JOBS_WATCH,
max_deliver: config.jobPollMaxAttempts + 5,
ack_wait: 30_000_000_000, // 30s ns
});
} catch (err) {
// already exists
log.debug('consumer may exist', { error: err.message });
}
const consumer = await js.consumers.get(STREAMS.ZAPIER_JOBS, CONSUMERS.JOB_POLLER);
abort = new AbortController();
running = true;
log.info('job poller worker started', { consumer: CONSUMERS.JOB_POLLER });
(async () => {
const messages = await consumer.consume({ max_messages: 10 });
for await (const msg of messages) {
if (abort?.signal.aborted) break;
try {
const data = JSON.parse(msg.string());
await handleWatch(data, {
ack: () => msg.ack(),
nak: (delayMs = 1000) => msg.nak(delayMs),
});
} catch (err) {
log.error('job poller handle failed', { error: err.message });
try {
msg.nak(1000);
} catch {
/* ignore */
}
}
}
})().catch((err) => log.error('job poller loop failed', { error: err.message }));
return {
stop: async () => stopJobPollerWorker(),
};
}
/**
* @returns {Promise<void>}
*/
export async function stopJobPollerWorker() {
abort?.abort();
abort = null;
running = false;
log.info('job poller worker stopped');
}

View file

@ -0,0 +1,138 @@
/**
* @fileoverview JetStream consumer that POSTs Zapier REST Hook payloads.
* @module workers/webhookWorker
*/
import { createDebugger } from '../debug/logger.js';
import { SUBJECTS, CONSUMERS, STREAMS } from '../nats/subjects.js';
import { connectNats, ensureStreams } from '../nats/connection.js';
import { deliverWebhook } from '../services/webhookService.js';
import { getActiveWebhooks } from '../store/webhooks.js';
import { withTrace } from '../debug/trace.js';
const log = createDebugger('webhooks');
let running = false;
/** @type {AbortController|null} */
let abort = null;
/**
* Route job events per-hook deliver messages (inline or via re-publish).
* Also handles direct deliver subjects.
*
* @param {object} data
* @param {{ ack: () => Promise<void>, nak: (d?: number) => Promise<void> }} ctrl
*/
async function handleDeliver(data, ctrl) {
await withTrace({ traceId: data.traceId, span: 'webhook-deliver' }, async () => {
// Event router path: expand tenant hooks
if (data.event && data.jobId && !data.targetUrl) {
const hooks = getActiveWebhooks(data.tenantId, data.event);
for (const hook of hooks) {
await deliverWebhook(hook.targetUrl, {
event: data.event,
jobId: data.jobId,
tenantId: data.tenantId,
status: data.status,
});
}
await ctrl.ack();
return;
}
if (!data.targetUrl) {
log.warn('deliver missing targetUrl', { dataKeys: Object.keys(data) });
await ctrl.ack();
return;
}
const result = await deliverWebhook(data.targetUrl, data.payload ?? data);
if (result.ok) {
await ctrl.ack();
} else {
log.debug('deliver non-2xx, nak', { status: result.status });
await ctrl.nak(2000);
}
});
}
/**
* Start webhook delivery worker (consumes WEBHOOKS stream + optional events).
* @returns {Promise<{ stop: () => Promise<void> }>}
*/
export async function startWebhookWorker() {
if (running) {
return { stop: async () => stopWebhookWorker() };
}
const { js, jsm } = await connectNats();
await ensureStreams(jsm);
// Events consumer → deliver
try {
await jsm.consumers.add(STREAMS.ZAPIER_EVENTS, {
durable_name: CONSUMERS.EVENT_WEBHOOK_ROUTER,
ack_policy: 'explicit',
filter_subject: SUBJECTS.JOBS_EVENTS,
max_deliver: 10,
});
} catch (err) {
log.debug('events consumer may exist', { error: err.message });
}
try {
await jsm.consumers.add(STREAMS.ZAPIER_WEBHOOKS, {
durable_name: CONSUMERS.WEBHOOK_DELIVER,
ack_policy: 'explicit',
filter_subject: SUBJECTS.WEBHOOKS_DELIVER,
max_deliver: 10,
});
} catch (err) {
log.debug('webhook consumer may exist', { error: err.message });
}
abort = new AbortController();
running = true;
log.info('webhook worker started');
const runConsumer = async (stream, durable) => {
const consumer = await js.consumers.get(stream, durable);
const messages = await consumer.consume({ max_messages: 10 });
for await (const msg of messages) {
if (abort?.signal.aborted) break;
try {
const data = JSON.parse(msg.string());
await handleDeliver(data, {
ack: () => msg.ack(),
nak: (d = 1000) => msg.nak(d),
});
} catch (err) {
log.error('webhook handle failed', { error: err.message });
try {
msg.nak(1000);
} catch {
/* ignore */
}
}
}
};
runConsumer(STREAMS.ZAPIER_EVENTS, CONSUMERS.EVENT_WEBHOOK_ROUTER).catch((err) =>
log.error('events consumer failed', { error: err.message }),
);
runConsumer(STREAMS.ZAPIER_WEBHOOKS, CONSUMERS.WEBHOOK_DELIVER).catch((err) =>
log.error('webhooks consumer failed', { error: err.message }),
);
return { stop: async () => stopWebhookWorker() };
}
/**
* @returns {Promise<void>}
*/
export async function stopWebhookWorker() {
abort?.abort();
abort = null;
running = false;
log.info('webhook worker stopped');
}

View file

@ -0,0 +1,93 @@
/**
* Shared test helpers isolated store + seed tenants.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setStoreForTests, loadStore, emptyStore, persist, getStore } from '../src/store/db.js';
import { createTenant } from '../src/store/tenants.js';
import { config } from '../src/config.js';
/**
* Point store at a temp file and reset memory.
* @returns {{ dir: string, storePath: string, cleanup: () => void }}
*/
export function useTempStore() {
const dir = mkdtempSync(join(tmpdir(), 'verae-mw-'));
const storePath = join(dir, 'store.json');
config.storePath = storePath;
setStoreForTests(null);
loadStore(storePath);
// ensure empty
setStoreForTests(emptyStore());
persist(storePath);
return {
dir,
storePath,
cleanup: () => {
setStoreForTests(null);
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
},
};
}
/**
* Seed a free-plan tenant with known credentials (mock Verae).
* @param {object} [overrides]
* @returns {{ tenant: object, apiKey: string }}
*/
export function seedFreeTenant(overrides = {}) {
return createTenant({
id: overrides.id ?? 'tenant-test-free',
name: overrides.name ?? 'Test Free',
plan: 'free',
veraeUsername: overrides.veraeUsername ?? 'zapuser',
veraePassword: overrides.veraePassword ?? 'zappass',
metadata: { audience: 'test' },
...overrides,
});
}
/**
* Seed a pro tenant (batch allowed).
*/
export function seedProTenant() {
return createTenant({
id: 'tenant-test-pro',
name: 'Test Pro',
plan: 'pro',
veraeUsername: 'prouser',
veraePassword: 'propass',
metadata: { audience: 'test' },
});
}
/**
* Seed enterprise with contract.
*/
export function seedEnterpriseTenant() {
return createTenant({
id: 'tenant-test-ent',
name: 'Test Enterprise',
plan: 'enterprise',
veraeUsername: 'entuser',
veraePassword: 'entpass',
contract: {
includedTimestamps: 10,
includedVerifications: 10,
batch: true,
batchMaxItems: 5,
requestsPerMinute: 100,
allowOverage: false,
},
metadata: { audience: 'enterprise' },
});
}
export { getStore, config };

View file

@ -0,0 +1,76 @@
/**
* GATE 5 Auth + entitlement HTTP tests
*/
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../../src/app.js';
import { useTempStore, seedFreeTenant } from '../helpers.js';
import { incrementUsage } from '../../src/store/usage.js';
import { PLAN_LIMITS } from '../../src/config.js';
import { checkEntitlement } from '../../src/services/entitlementService.js';
import { AppError } from '../../src/errors.js';
describe('auth + entitlements HTTP', () => {
/** @type {ReturnType<typeof useTempStore>} */
let ctx;
/** @type {import('http').Server} */
let server;
/** @type {number} */
let port;
/** @type {string} */
let apiKey;
before(() => {
ctx = useTempStore();
const seeded = seedFreeTenant();
apiKey = seeded.apiKey;
const app = createApp({ load: false });
return new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
resolve();
});
});
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
ctx.cleanup();
});
it('GET /zapier/v1/auth/me with valid API key', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/auth/me`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.valid, true);
assert.equal(body.plan, 'free');
assert.ok(body.tenantId);
assert.ok(body.usage);
});
it('invalid key returns 401', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/auth/me`, {
headers: { Authorization: 'Bearer zmw_invalidkeyxxxxxxxxxxxxxxxx' },
});
assert.equal(res.status, 401);
const body = await res.json();
assert.equal(body.code, 'UNAUTHORIZED');
});
it('exceeding free timestamp quota throws 402 QUOTA_EXCEEDED', () => {
const tenantId = 'tenant-test-free';
const limit = PLAN_LIMITS.free.timestamps;
// force usage to limit
for (let i = 0; i < limit; i += 1) {
incrementUsage(tenantId, 'timestamps', 1);
}
assert.throws(
() => checkEntitlement(tenantId, 'timestamp'),
(err) => err instanceof AppError && err.status === 402 && err.code === 'QUOTA_EXCEEDED',
);
});
});

View file

@ -0,0 +1,164 @@
/**
* GATE 6 Full HTTP middleware (NATS off, MOCK_VERAE on)
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createApp } from '../../src/app.js';
import { useTempStore, seedProTenant } from '../helpers.js';
import { config } from '../../src/config.js';
import { startInProcessJobPoller, stopInProcessJobPoller } from '../../src/workers/inProcessJobPoller.js';
import { getActiveWebhooks } from '../../src/store/webhooks.js';
describe('HTTP API (mock verae, nats off)', () => {
/** @type {ReturnType<typeof useTempStore>} */
let ctx;
/** @type {import('http').Server} */
let server;
/** @type {number} */
let port;
/** @type {string} */
let apiKey;
/** @type {object[]} */
let webhookDeliveries;
/** @type {import('http').Server} */
let hookServer;
/** @type {number} */
let hookPort;
before(async () => {
assert.equal(config.mockVerae, true);
assert.equal(config.natsEnabled, false);
// Fast poll for wait path
config.jobPollIntervalMs = 20;
config.jobPollMaxAttempts = 50;
ctx = useTempStore();
const seeded = seedProTenant();
apiKey = seeded.apiKey;
webhookDeliveries = [];
await new Promise((resolve) => {
hookServer = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => {
body += c;
});
req.on('end', () => {
webhookDeliveries.push(JSON.parse(body || '{}'));
res.writeHead(200);
res.end('ok');
});
});
hookServer.listen(0, '127.0.0.1', () => {
hookPort = hookServer.address().port;
resolve();
});
});
const app = createApp({ load: false });
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
resolve();
});
});
startInProcessJobPoller();
});
after(async () => {
stopInProcessJobPoller();
await new Promise((resolve) => server.close(resolve));
await new Promise((resolve) => hookServer.close(resolve));
ctx.cleanup();
});
function authHeaders() {
return {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
}
it('POST /zapier/v1/timestamp → 202 + jobId', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/timestamp`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ data: 'payload-async' }),
});
assert.equal(res.status, 202);
const body = await res.json();
assert.ok(body.jobId);
});
it('POST /zapier/v1/timestamp/wait → completed status', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/timestamp/wait`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ data: 'payload-wait', hashAlg: 'SHA256' }),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.status, 'completed');
assert.ok(body.result);
assert.ok(body.metadata?.certificate || body.result);
});
it('POST /zapier/v1/verify → valid true', async () => {
const waitRes = await fetch(`http://127.0.0.1:${port}/zapier/v1/timestamp/wait`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ data: 'to-verify' }),
});
const done = await waitRes.json();
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/verify`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ certificate: done.result }),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.valid, true);
});
it('webhook subscribe stores targetUrl; complete delivers', async () => {
const sub = await fetch(`http://127.0.0.1:${port}/zapier/v1/webhooks/subscribe`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
targetUrl: `http://127.0.0.1:${hookPort}/hook`,
event: 'timestamp.completed',
}),
});
assert.equal(sub.status, 201);
const hook = await sub.json();
assert.ok(hook.id);
const hooks = getActiveWebhooks('tenant-test-pro', 'timestamp.completed');
assert.ok(hooks.some((h) => h.id === hook.id));
webhookDeliveries.length = 0;
const create = await fetch(`http://127.0.0.1:${port}/zapier/v1/timestamp`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ data: 'webhook-trigger-me' }),
});
const { jobId } = await create.json();
assert.ok(jobId);
// Wait for in-process poller to deliver
const deadline = Date.now() + 5000;
while (webhookDeliveries.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
}
assert.ok(webhookDeliveries.length >= 1, 'expected webhook delivery');
assert.equal(webhookDeliveries[0].event, 'timestamp.completed');
assert.equal(webhookDeliveries[0].jobId, jobId);
});
});

View file

@ -0,0 +1,108 @@
/**
* GATE 8 Job poller worker + webhook via NATS events
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { config } from '../../src/config.js';
import { useTempStore, seedProTenant } from '../helpers.js';
import { connectNats, ensureStreams, closeNats } from '../../src/nats/connection.js';
import { enqueueWatch } from '../../src/nats/publishers.js';
import { startJobPollerWorker, stopJobPollerWorker } from '../../src/workers/jobPollerWorker.js';
import { startWebhookWorker, stopWebhookWorker } from '../../src/workers/webhookWorker.js';
import { createWebhook } from '../../src/store/webhooks.js';
import { veraeClient, clearMockJobs } from '../../src/clients/veraeClient.js';
describe('NATS workers', () => {
/** @type {ReturnType<typeof useTempStore>} */
let ctx;
/** @type {object[]} */
let deliveries;
/** @type {import('http').Server} */
let hookServer;
/** @type {number} */
let hookPort;
/** @type {string} */
let tenantId;
before(async () => {
assert.equal(config.mockVerae, true);
config.natsEnabled = true;
process.env.NATS_FORCE_CONNECT = '1';
config.natsUrl = process.env.NATS_URL || 'nats://127.0.0.1:4222';
config.jobPollIntervalMs = 50;
config.jobPollMaxAttempts = 40;
clearMockJobs();
ctx = useTempStore();
const { tenant } = seedProTenant();
tenantId = tenant.id;
deliveries = [];
await new Promise((resolve) => {
hookServer = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => {
body += c;
});
req.on('end', () => {
deliveries.push(JSON.parse(body || '{}'));
res.writeHead(200);
res.end('ok');
});
});
hookServer.listen(0, '127.0.0.1', () => {
hookPort = hookServer.address().port;
resolve();
});
});
createWebhook({
tenantId,
targetUrl: `http://127.0.0.1:${hookPort}/hook`,
event: 'timestamp.completed',
});
await connectNats(config.natsUrl);
await ensureStreams();
await startJobPollerWorker();
await startWebhookWorker();
});
after(async () => {
await stopJobPollerWorker();
await stopWebhookWorker();
await closeNats();
await new Promise((r) => hookServer.close(r));
ctx.cleanup();
process.env.NATS_FORCE_CONNECT = '';
});
it('watch → poll → event → webhook delivery', async () => {
const login = await veraeClient.login({
username: 'prouser',
password: 'propass',
});
const { jobId } = await veraeClient.createTimestamp(login.token, {
data: 'nats-worker-test',
});
await enqueueWatch({
tenantId,
jobId,
veraeToken: login.token,
maxAttempts: 40,
intervalMs: 50,
});
const deadline = Date.now() + 8000;
while (deliveries.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
}
assert.ok(deliveries.length >= 1, 'expected webhook from NATS path');
assert.equal(deliveries[0].event, 'timestamp.completed');
assert.equal(deliveries[0].jobId, jobId);
});
});

View file

@ -0,0 +1,117 @@
/**
* GATE 7 NATS streams + publish/consume
* Requires nats-server with JetStream on NATS_URL (default 127.0.0.1:4222)
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { config } from '../../src/config.js';
import { SUBJECTS, STREAMS } from '../../src/nats/subjects.js';
import {
connectNats,
ensureStreams,
closeNats,
isNatsConnected,
} from '../../src/nats/connection.js';
import { enqueueWatch, publishJobEvent } from '../../src/nats/publishers.js';
describe('NATS infrastructure', () => {
before(async () => {
// Force connect even if NATS_ENABLED was false at boot — re-set for this process
config.natsEnabled = true;
process.env.NATS_FORCE_CONNECT = '1';
config.natsUrl = process.env.NATS_URL || 'nats://127.0.0.1:4222';
try {
await connectNats(config.natsUrl);
await ensureStreams();
} catch (err) {
assert.fail(
`NATS not available at ${config.natsUrl}: ${err.message}. Start: nats-server -js -p 4222`,
);
}
});
after(async () => {
await closeNats();
process.env.NATS_FORCE_CONNECT = '';
});
it('connects and reports connected', () => {
assert.equal(isNatsConnected(), true);
});
it('ensures streams exist (idempotent)', async () => {
await ensureStreams();
const { jsm } = await connectNats();
for (const name of [STREAMS.ZAPIER_JOBS, STREAMS.ZAPIER_EVENTS, STREAMS.ZAPIER_WEBHOOKS]) {
const info = await jsm.streams.info(name);
assert.equal(info.config.name, name);
}
});
it('publish + pull consume one watch message', async () => {
const { js, jsm } = await connectNats();
// Avoid consuming leftover messages from prior runs
await jsm.streams.purge(STREAMS.ZAPIER_JOBS);
const durable = `test-pull-${Date.now()}`;
await jsm.consumers.add(STREAMS.ZAPIER_JOBS, {
durable_name: durable,
ack_policy: 'explicit',
filter_subject: SUBJECTS.JOBS_WATCH,
deliver_policy: 'all',
});
const jobId = `job-${Date.now()}`;
const pub = await enqueueWatch({
tenantId: 'tenant-nats-test',
jobId,
maxAttempts: 5,
intervalMs: 100,
});
assert.ok(pub.seq >= 0);
const consumer = await js.consumers.get(STREAMS.ZAPIER_JOBS, durable);
const messages = await consumer.fetch({ max_messages: 5, expires: 5000 });
let got = null;
for await (const msg of messages) {
const data = JSON.parse(msg.string());
msg.ack();
if (data.jobId === jobId) {
got = data;
break;
}
}
assert.ok(got, 'expected a message for our jobId');
assert.equal(got.jobId, jobId);
assert.equal(got.tenantId, 'tenant-nats-test');
});
it('publishJobEvent works', async () => {
const result = await publishJobEvent({
event: 'timestamp.completed',
tenantId: 't1',
jobId: 'j1',
status: { id: 'j1', status: 'completed' },
});
assert.ok(result.seq >= 0);
});
});
describe('NATS disabled path', () => {
it('connectNats throws when disabled and not forced', async () => {
const prev = config.natsEnabled;
const force = process.env.NATS_FORCE_CONNECT;
config.natsEnabled = false;
process.env.NATS_FORCE_CONNECT = '';
// close existing so we hit the disabled check on a fresh call path
// Note: if already connected, connectNats returns cached — test isolation via disabled only when no cache
// This tests the disabled branch of a new process conceptually; here we only assert flag behavior:
assert.equal(config.natsEnabled, false);
config.natsEnabled = prev;
process.env.NATS_FORCE_CONNECT = force;
});
});

View file

@ -0,0 +1,93 @@
/**
* GATE 10 Signup and admin provision
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../../src/app.js';
import { useTempStore } from '../helpers.js';
import { config } from '../../src/config.js';
describe('tenancy', () => {
let ctx;
let server;
let port;
before(async () => {
ctx = useTempStore();
const app = createApp({ load: false });
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
resolve();
});
});
});
after(async () => {
await new Promise((r) => server.close(r));
ctx.cleanup();
});
it('POST /zapier/v1/signup returns free plan + apiKey', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/signup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
name: 'Self Serve Co',
veraeUsername: 'selfuser',
veraePassword: 'selfpass',
}),
});
assert.equal(res.status, 201);
const body = await res.json();
assert.equal(body.tenant.plan, 'free');
assert.ok(body.apiKey.startsWith('zmw_'));
});
it('enterprise without contract rejected', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/admin/tenants`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-admin-secret': config.adminSecret,
},
body: JSON.stringify({
name: 'Ent Co',
plan: 'enterprise',
veraeUsername: 'e',
veraePassword: 'p',
}),
});
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.code, 'VALIDATION_ERROR');
});
it('admin list does not leak passwords', async () => {
const create = await fetch(`http://127.0.0.1:${port}/zapier/v1/admin/tenants`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-admin-secret': config.adminSecret,
},
body: JSON.stringify({
name: 'Pro Co',
plan: 'pro',
veraeUsername: 'puser',
veraePassword: 'ppass',
}),
});
assert.equal(create.status, 201);
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/admin/tenants`, {
headers: { 'x-admin-secret': config.adminSecret },
});
assert.equal(res.status, 200);
const body = await res.json();
const json = JSON.stringify(body);
assert.doesNotMatch(json, /ppass/);
assert.doesNotMatch(json, /veraePassword/);
});
});

View file

@ -0,0 +1,46 @@
/**
* GATE 2 (partial) HTTP shell health endpoint.
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../../src/app.js';
describe('createApp', () => {
/** @type {import('http').Server} */
let server;
/** @type {number} */
let port;
before(async () => {
const app = createApp();
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', resolve);
});
port = server.address().port;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
it('GET /health returns ok', async () => {
const res = await fetch(`http://127.0.0.1:${port}/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.status, 'ok');
assert.equal(body.service, 'verae-zapier-middleware');
assert.ok(res.headers.get('x-trace-id'));
});
it('protected /zapier path requires auth', async () => {
const res = await fetch(`http://127.0.0.1:${port}/zapier/v1/timestamp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
});
assert.equal(res.status, 401);
const body = await res.json();
assert.equal(body.code, 'UNAUTHORIZED');
});
});

View file

@ -0,0 +1,33 @@
/**
* GATE 2 (partial) config exports.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { config, PLAN_LIMITS } from '../../src/config.js';
describe('config', () => {
it('exposes required keys for HTTP and NATS', () => {
for (const key of [
'port',
'host',
'veraeApiBaseUrl',
'mockVerae',
'natsEnabled',
'natsUrl',
'tokenSecret',
'jobPollIntervalMs',
'jobPollMaxAttempts',
'storePath',
]) {
assert.notEqual(config[key], undefined, `missing config.${key}`);
}
});
it('defines plan limits for free through enterprise', () => {
for (const plan of ['free', 'starter', 'pro', 'enterprise']) {
assert.ok(PLAN_LIMITS[plan], plan);
assert.equal(typeof PLAN_LIMITS[plan].requestsPerMinute, 'number');
}
});
});

View file

@ -0,0 +1,165 @@
/**
* GATE 1 Debug facility unit tests.
* Must pass before Phase 2.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
parseDebugVeraeEnv,
loadDebugConfig,
shouldLog,
createDebugger,
setDebugTestSink,
redact,
withTrace,
getTraceId,
generateTraceId,
} from '../../src/debug/index.js';
describe('parseDebugVeraeEnv', () => {
it('disables when unset or empty', () => {
assert.equal(parseDebugVeraeEnv(undefined).enabled, false);
assert.equal(parseDebugVeraeEnv('').enabled, false);
assert.equal(parseDebugVeraeEnv('off').enabled, false);
});
it('enables all namespaces for 1 or *', () => {
const a = parseDebugVeraeEnv('1');
assert.equal(a.enabled, true);
assert.equal(a.namespaces, null);
const b = parseDebugVeraeEnv('*');
assert.equal(b.enabled, true);
assert.equal(b.namespaces, null);
});
it('parses comma-separated namespaces', () => {
const { enabled, namespaces } = parseDebugVeraeEnv('auth, NATS, jobs');
assert.equal(enabled, true);
assert.ok(namespaces.has('auth'));
assert.ok(namespaces.has('nats'));
assert.ok(namespaces.has('jobs'));
assert.equal(namespaces.has('webhooks'), false);
});
});
describe('shouldLog', () => {
it('respects level thresholds', () => {
const config = {
enabled: true,
namespaces: null,
level: 'warn',
filePath: null,
};
assert.equal(shouldLog(config, 'app', 'debug'), false);
assert.equal(shouldLog(config, 'app', 'warn'), true);
assert.equal(shouldLog(config, 'app', 'error'), true);
});
it('filters by namespace', () => {
const config = {
enabled: true,
namespaces: new Set(['auth']),
level: 'debug',
filePath: null,
};
assert.equal(shouldLog(config, 'auth', 'debug'), true);
assert.equal(shouldLog(config, 'nats', 'debug'), false);
});
});
describe('redact', () => {
it('redacts sensitive keys and token-like strings', () => {
const out = redact({
password: 'secret',
apiKey: 'zmw_abc123def456ghi789jkl',
jobId: 'keep-me',
authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.aaa.bbb',
nested: { veraeToken: 'zmt_payload.sig' },
});
assert.equal(out.password, '[REDACTED]');
assert.equal(out.apiKey, '[REDACTED]');
assert.equal(out.jobId, 'keep-me');
assert.equal(out.authorization, '[REDACTED]');
assert.equal(out.nested.veraeToken, '[REDACTED]');
});
});
describe('createDebugger', () => {
/** @type {string[]} */
let lines;
const prev = { ...process.env };
beforeEach(() => {
lines = [];
setDebugTestSink((line) => lines.push(line));
});
afterEach(() => {
setDebugTestSink(null);
for (const key of Object.keys(process.env)) {
if (!(key in prev)) delete process.env[key];
}
Object.assign(process.env, prev);
});
it('is silent when DEBUG_VERAE is unset', () => {
delete process.env.DEBUG_VERAE;
const log = createDebugger('auth');
log.debug('should not appear', { x: 1 });
assert.equal(lines.length, 0);
});
it('emits only selected namespaces', () => {
process.env.DEBUG_VERAE = 'auth';
createDebugger('auth').debug('auth-line');
createDebugger('nats').debug('nats-line');
assert.equal(lines.length, 1);
assert.match(lines[0], /auth-line/);
assert.doesNotMatch(lines[0], /nats-line/);
});
it('redacts secrets in meta', () => {
process.env.DEBUG_VERAE = 'auth';
createDebugger('auth').debug('login', {
password: 'hunter2',
token: 'zmw_supersecretvaluehere12',
});
assert.equal(lines.length, 1);
assert.doesNotMatch(lines[0], /hunter2/);
assert.doesNotMatch(lines[0], /zmw_supersecret/);
assert.match(lines[0], /REDACTED/);
});
});
describe('withTrace', () => {
it('propagates traceId to nested async work', async () => {
const outerId = generateTraceId();
let innerId = null;
await withTrace({ traceId: outerId, span: 'outer' }, async () => {
assert.equal(getTraceId(), outerId);
await withTrace({ span: 'inner' }, async () => {
innerId = getTraceId();
});
});
assert.equal(innerId, outerId);
assert.equal(getTraceId(), null);
});
});
describe('loadDebugConfig', () => {
it('reads level and file from env', () => {
process.env.DEBUG_VERAE = '1';
process.env.DEBUG_VERAE_LEVEL = 'error';
process.env.DEBUG_VERAE_FILE = '/tmp/verae-debug.log';
const cfg = loadDebugConfig(process.env);
assert.equal(cfg.enabled, true);
assert.equal(cfg.level, 'error');
assert.equal(cfg.filePath, '/tmp/verae-debug.log');
});
});

View file

@ -0,0 +1,105 @@
/**
* GATE 3 Store unit tests
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
useTempStore,
seedFreeTenant,
seedEnterpriseTenant,
} from '../helpers.js';
import { getTenantByApiKey, resolveLimits, getTenant } from '../../src/store/tenants.js';
import {
createWebhook,
getActiveWebhooks,
listWebhooksForTenant,
deleteWebhook,
} from '../../src/store/webhooks.js';
import { getUsage, incrementUsage } from '../../src/store/usage.js';
import { reloadStore, getStore } from '../../src/store/db.js';
describe('stores', () => {
/** @type {ReturnType<typeof useTempStore>} */
let ctx;
beforeEach(() => {
ctx = useTempStore();
});
afterEach(() => {
ctx.cleanup();
});
it('create tenant → API key resolves to same tenant', () => {
const { tenant, apiKey } = seedFreeTenant();
const found = getTenantByApiKey(apiKey);
assert.ok(found);
assert.equal(found.id, tenant.id);
assert.equal(found.plan, 'free');
});
it('free plan limits applied; enterprise contract overrides', () => {
const { tenant: free } = seedFreeTenant();
const freeLimits = resolveLimits(free);
assert.equal(freeLimits.timestamps, 50);
assert.equal(freeLimits.batch, false);
const { tenant: ent } = seedEnterpriseTenant();
const entLimits = resolveLimits(ent);
assert.equal(entLimits.timestamps, 10);
assert.equal(entLimits.batch, true);
assert.equal(entLimits.batchMaxItems, 5);
});
it('webhook isolation per tenant', () => {
seedFreeTenant({ id: 'a' });
seedFreeTenant({ id: 'b', veraeUsername: 'u2' });
createWebhook({
tenantId: 'a',
targetUrl: 'https://hooks.example/a',
event: 'timestamp.completed',
});
createWebhook({
tenantId: 'b',
targetUrl: 'https://hooks.example/b',
event: 'timestamp.completed',
});
const aHooks = listWebhooksForTenant('a');
const bHooks = getActiveWebhooks('b', 'timestamp.completed');
assert.equal(aHooks.length, 1);
assert.equal(aHooks[0].targetUrl, 'https://hooks.example/a');
assert.equal(bHooks.length, 1);
assert.equal(bHooks[0].tenantId, 'b');
});
it('persist + reload round-trip preserves data', () => {
const { tenant, apiKey } = seedFreeTenant({ id: 'persist-me' });
incrementUsage(tenant.id, 'timestamps', 3);
createWebhook({
tenantId: tenant.id,
targetUrl: 'https://hooks.example/x',
event: 'timestamp.completed',
});
reloadStore(ctx.storePath);
assert.equal(getTenant('persist-me')?.name, tenant.name);
assert.equal(getTenantByApiKey(apiKey)?.id, 'persist-me');
assert.equal(getUsage('persist-me').timestamps, 3);
assert.equal(getStore().webhooks.length, 1);
});
it('deleteWebhook removes by id', () => {
seedFreeTenant({ id: 'w' });
const hook = createWebhook({
tenantId: 'w',
targetUrl: 'https://hooks.example/w',
event: 'timestamp.completed',
});
assert.equal(deleteWebhook({ tenantId: 'w', hookId: hook.id }), true);
assert.equal(listWebhooksForTenant('w').length, 0);
});
});

View file

@ -0,0 +1,55 @@
/**
* GATE 4 Token tests
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
issueSessionToken,
parseSessionToken,
generateApiKey,
isApiKey,
extractBearerToken,
} from '../../src/lib/tokens.js';
import { config } from '../../src/config.js';
describe('tokens', () => {
it('issues and parses session tokens', () => {
const token = issueSessionToken({
tenantId: 't1',
veraeToken: 'mock-jwt-user',
expiresAt: '2099-01-01T00:00:00Z',
});
assert.ok(token.startsWith('zmt_'));
const parsed = parseSessionToken(token);
assert.equal(parsed.tenantId, 't1');
assert.equal(parsed.veraeToken, 'mock-jwt-user');
});
it('rejects forged session tokens', () => {
const token = issueSessionToken({
tenantId: 't1',
veraeToken: 'secret',
});
const forged = token.slice(0, -4) + 'xxxx';
assert.equal(parseSessionToken(forged), null);
});
it('rejects tokens signed with wrong secret', () => {
const token = issueSessionToken({
tenantId: 't1',
veraeToken: 'secret',
});
const original = config.tokenSecret;
config.tokenSecret = 'other-secret';
assert.equal(parseSessionToken(token), null);
config.tokenSecret = original;
});
it('generates api keys and extracts bearer', () => {
const key = generateApiKey();
assert.ok(isApiKey(key));
assert.equal(extractBearerToken(`Bearer ${key}`), key);
assert.equal(extractBearerToken(undefined), null);
});
});

View file

@ -0,0 +1,64 @@
/**
* GATE 4 Mock Verae client lifecycle
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { veraeClient, clearMockJobs } from '../../src/clients/veraeClient.js';
import { config } from '../../src/config.js';
import { setDebugTestSink, createDebugger } from '../../src/debug/index.js';
describe('veraeClient mock', () => {
beforeEach(() => {
clearMockJobs();
// ensure mock mode (set at process start via env in gate script)
assert.equal(config.mockVerae, true, 'MOCK_VERAE must be true for unit tests');
});
it('create → wait → completed', async () => {
const login = await veraeClient.login({ username: 'u', password: 'p' });
assert.ok(login.token.startsWith('mock-jwt-'));
const { jobId } = await veraeClient.createTimestamp(login.token, {
data: 'hello',
hashAlg: 'SHA256',
});
assert.ok(jobId);
const status = await veraeClient.waitForJob(login.token, jobId, {
maxAttempts: 40,
intervalMs: 20,
});
assert.equal(status.status, 'completed');
assert.ok(status.result.startsWith('mock-cert-'));
});
it('verify accepts mock certificates', async () => {
const login = await veraeClient.login({ username: 'u', password: 'p' });
const { jobId } = await veraeClient.createTimestamp(login.token, { data: 'x' });
const status = await veraeClient.waitForJob(login.token, jobId, {
maxAttempts: 40,
intervalMs: 20,
});
const result = await veraeClient.verify(login.token, { certificate: status.result });
assert.equal(result.valid, true);
});
it('debug http logs do not include Authorization values', () => {
const lines = [];
setDebugTestSink((line) => lines.push(line));
process.env.DEBUG_VERAE = 'http';
const log = createDebugger('http');
log.debug('verae request', {
method: 'POST',
path: '/api/timestamp',
authorization: 'Bearer super-secret-token-value',
hasToken: true,
});
setDebugTestSink(null);
delete process.env.DEBUG_VERAE;
assert.equal(lines.length, 1);
assert.doesNotMatch(lines[0], /super-secret-token-value/);
assert.match(lines[0], /REDACTED|hasToken/);
});
});

View file

@ -0,0 +1,34 @@
# verae-zapier
Zapier Platform CLI app for Verae. **Phase 11** in [../TODO.md](../TODO.md).
## Role
Runs on **Zapiers servers**. Calls only the middleware HTTPS API (`MIDDLEWARE_BASE_URL`), never NATS and never `api.veraetime.net` directly.
## Planned modules
| File | Purpose |
|------|---------|
| `authentication.js` | Custom API key auth → `GET /zapier/v1/auth/me` |
| `index.js` | App definition, beforeRequest, afterResponse error mapping |
| `creates/timestamp_and_wait.js` | Primary action |
| `creates/create_timestamp.js` | Async jobId action |
| `creates/verify_timestamp.js` | Verify certificate |
| `creates/batch_timestamp.js` | Batch create |
| `searches/job_status.js` | Lookup by jobId |
| `triggers/timestamp_completed.js` | REST Hook |
See [../docs/developer/modules/function-reference.md](../docs/developer/modules/function-reference.md) for I/O contracts.
## Env
```bash
export MIDDLEWARE_BASE_URL=https://your-middleware.example.com
```
## Gate
```bash
npm run gate:11 # from monorepo root, after Phase 11 implementation
```

View file

@ -0,0 +1,38 @@
/**
* Custom API key auth against Verae middleware.
* @module authentication
*/
const middlewareBase = () =>
process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
/**
* Zapier connection test validates API key.
* @param {object} z
* @param {object} bundle
*/
const testAuth = async (z, bundle) => {
const response = await z.request({
url: `${middlewareBase()}/zapier/v1/auth/me`,
headers: {
Authorization: `Bearer ${bundle.authData.api_key}`,
},
});
return response.data;
};
module.exports = {
type: 'custom',
fields: [
{
key: 'api_key',
type: 'string',
required: true,
label: 'API Key',
helpText:
'Your Verae API key (zmw_…) from signup or admin provisioning.',
},
],
test: testAuth,
connectionLabel: '{{tenantId}} ({{plan}})',
};

View file

@ -0,0 +1,39 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
const items = (bundle.inputData.items || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.map((data) => ({ data }));
const response = await z.request({
method: 'POST',
url: `${base()}/zapier/v1/timestamp/batch`,
body: { items },
});
return response.data;
};
module.exports = {
key: 'batch_timestamp',
noun: 'Timestamp',
display: {
label: 'Create Batch Timestamps',
description: 'Submit multiple data lines for timestamping (paid plans).',
},
operation: {
inputFields: [
{
key: 'items',
label: 'Data Lines',
type: 'text',
required: true,
helpText: 'One payload per line.',
},
],
perform,
sample: { jobIds: ['id-1', 'id-2'] },
outputFields: [{ key: 'jobIds', label: 'Job IDs' }],
},
};

View file

@ -0,0 +1,37 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
const response = await z.request({
method: 'POST',
url: `${base()}/zapier/v1/timestamp`,
body: {
data: bundle.inputData.data,
hashAlg: bundle.inputData.hashAlg || undefined,
},
});
return response.data;
};
module.exports = {
key: 'create_timestamp',
noun: 'Timestamp',
display: {
label: 'Create Timestamp (Async)',
description: 'Submits data for timestamping and returns a job ID.',
},
operation: {
inputFields: [
{ key: 'data', label: 'Data', type: 'string', required: true },
{
key: 'hashAlg',
label: 'Hash Algorithm',
type: 'string',
required: false,
default: 'SHA256',
},
],
perform,
sample: { jobId: '550e8400-e29b-41d4-a716-446655440000' },
outputFields: [{ key: 'jobId', label: 'Job ID' }],
},
};

View file

@ -0,0 +1,54 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
const response = await z.request({
method: 'POST',
url: `${base()}/zapier/v1/timestamp/wait`,
body: {
data: bundle.inputData.data,
hashAlg: bundle.inputData.hashAlg || undefined,
},
});
return response.data;
};
module.exports = {
key: 'timestamp_and_wait',
noun: 'Timestamp',
display: {
label: 'Create Timestamp and Wait',
description:
'Submits data for blockchain timestamping and waits for the certificate.',
},
operation: {
inputFields: [
{
key: 'data',
label: 'Data',
type: 'string',
required: true,
helpText: 'Content to timestamp on the blockchain.',
},
{
key: 'hashAlg',
label: 'Hash Algorithm',
type: 'string',
required: false,
default: 'SHA256',
},
],
perform,
sample: {
id: '550e8400-e29b-41d4-a716-446655440000',
status: 'completed',
result: 'mock-cert-example',
completedAt: '2023-01-01T12:05:00Z',
},
outputFields: [
{ key: 'id', label: 'Job ID' },
{ key: 'status', label: 'Status' },
{ key: 'result', label: 'Certificate' },
{ key: 'completedAt', label: 'Completed At', type: 'datetime' },
],
},
};

View file

@ -0,0 +1,36 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
const response = await z.request({
method: 'POST',
url: `${base()}/zapier/v1/verify`,
body: { certificate: bundle.inputData.certificate },
});
return response.data;
};
module.exports = {
key: 'verify_timestamp',
noun: 'Verification',
display: {
label: 'Verify Timestamp',
description: 'Verify a timestamp certificate.',
},
operation: {
inputFields: [
{
key: 'certificate',
label: 'Certificate',
type: 'text',
required: true,
},
],
perform,
sample: { valid: true, timestamp: '2023-01-01T12:00:00Z', blockIndex: 42 },
outputFields: [
{ key: 'valid', label: 'Valid', type: 'boolean' },
{ key: 'timestamp', label: 'Timestamp', type: 'datetime' },
{ key: 'blockIndex', label: 'Block Index', type: 'integer' },
],
},
};

View file

@ -0,0 +1,76 @@
/**
* Verae Zapier Platform app definition.
* @module index
*/
const authentication = require('./authentication');
const timestampAndWait = require('./creates/timestamp_and_wait');
const createTimestamp = require('./creates/create_timestamp');
const verifyTimestamp = require('./creates/verify_timestamp');
const batchTimestamp = require('./creates/batch_timestamp');
const jobStatus = require('./searches/job_status');
const timestampCompleted = require('./triggers/timestamp_completed');
/**
* Attach middleware API key to every outbound request.
* @param {object} request
* @param {object} _z
* @param {object} bundle
*/
const addApiKey = (request, _z, bundle) => {
request.headers = request.headers || {};
request.headers.Authorization = `Bearer ${bundle.authData.api_key}`;
return request;
};
/**
* Map middleware billing errors to Zapier errors.
* @param {object} response
* @param {object} z
*/
const mapMiddlewareErrors = (response, z) => {
if (response.status === 402) {
throw new z.errors.Error(
`${response.data?.error ?? 'Quota exceeded'}. Upgrade at ${response.data?.details?.upgradeUrl ?? response.data?.upgradeUrl ?? 'your billing portal'}.`,
'QuotaExceeded',
402,
);
}
if (response.status === 403 && response.data?.code === 'PLAN_UPGRADE_REQUIRED') {
throw new z.errors.Error(
response.data?.error ?? 'This action requires a paid plan.',
'PlanUpgradeRequired',
403,
);
}
return response;
};
let platformVersion = '15.19.0';
try {
platformVersion = require('zapier-platform-core').version;
} catch {
// optional for unit tests without full install
}
module.exports = {
version: require('./package.json').version,
platformVersion,
authentication,
beforeRequest: [addApiKey],
afterResponse: [mapMiddlewareErrors],
triggers: {
[timestampCompleted.key]: timestampCompleted,
},
creates: {
[timestampAndWait.key]: timestampAndWait,
[createTimestamp.key]: createTimestamp,
[verifyTimestamp.key]: verifyTimestamp,
[batchTimestamp.key]: batchTimestamp,
},
searches: {
[jobStatus.key]: jobStatus,
},
};

View file

@ -0,0 +1,17 @@
{
"name": "verae-zapier",
"version": "1.0.0",
"description": "Zapier CLI app for Verae Timestamping via middleware",
"main": "index.js",
"scripts": {
"test": "node --test test/**/*.test.js"
},
"engines": {
"node": ">=18",
"npm": ">=5.6.0"
},
"dependencies": {
"zapier-platform-core": "15.19.0"
},
"private": true
}

View file

@ -0,0 +1,33 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
const response = await z.request({
url: `${base()}/zapier/v1/status/${encodeURIComponent(bundle.inputData.jobId)}`,
});
return [response.data];
};
module.exports = {
key: 'job_status',
noun: 'Job',
display: {
label: 'Find Job Status',
description: 'Look up a timestamp job by ID.',
},
operation: {
inputFields: [
{ key: 'jobId', label: 'Job ID', type: 'string', required: true },
],
perform,
sample: {
id: '550e8400-e29b-41d4-a716-446655440000',
status: 'completed',
result: 'mock-cert',
},
outputFields: [
{ key: 'id', label: 'Job ID' },
{ key: 'status', label: 'Status' },
{ key: 'result', label: 'Result' },
],
},
};

View file

@ -0,0 +1,92 @@
/**
* GATE 11 Zapier package shape tests (no live Zapier CLI required)
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const App = require('../index');
const authentication = require('../authentication');
const timestampAndWait = require('../creates/timestamp_and_wait');
const createTimestamp = require('../creates/create_timestamp');
const verifyTimestamp = require('../creates/verify_timestamp');
const batchTimestamp = require('../creates/batch_timestamp');
const jobStatus = require('../searches/job_status');
const timestampCompleted = require('../triggers/timestamp_completed');
describe('verae-zapier app definition', () => {
it('exports authentication with api_key field', () => {
assert.equal(authentication.type, 'custom');
assert.ok(authentication.fields.some((f) => f.key === 'api_key'));
});
it('wires creates, searches, triggers', () => {
assert.ok(App.creates.timestamp_and_wait);
assert.ok(App.creates.create_timestamp);
assert.ok(App.creates.verify_timestamp);
assert.ok(App.creates.batch_timestamp);
assert.ok(App.searches.job_status);
assert.ok(App.triggers.timestamp_completed);
});
it('create actions target middleware paths', () => {
process.env.MIDDLEWARE_BASE_URL = 'https://mw.example.com';
const urls = [];
const z = {
request: async (opts) => {
urls.push(opts.url);
return { data: { ok: true } };
},
};
const bundle = {
authData: { api_key: 'zmw_test' },
inputData: { data: 'x', certificate: 'c', items: 'a\nb', jobId: 'j1' },
};
return Promise.all([
timestampAndWait.operation.perform(z, bundle),
createTimestamp.operation.perform(z, bundle),
verifyTimestamp.operation.perform(z, bundle),
batchTimestamp.operation.perform(z, bundle),
jobStatus.operation.perform(z, bundle),
]).then(() => {
assert.ok(urls.some((u) => u.endsWith('/zapier/v1/timestamp/wait')));
assert.ok(urls.some((u) => u.endsWith('/zapier/v1/timestamp')));
assert.ok(urls.some((u) => u.endsWith('/zapier/v1/verify')));
assert.ok(urls.some((u) => u.endsWith('/zapier/v1/timestamp/batch')));
assert.ok(urls.some((u) => u.includes('/zapier/v1/status/')));
});
});
it('trigger subscribe/unsubscribe shapes', async () => {
process.env.MIDDLEWARE_BASE_URL = 'https://mw.example.com';
const calls = [];
const z = {
request: async (opts) => {
calls.push(opts);
return { data: { id: 'hook-1' } };
},
};
const id = await timestampCompleted.operation.performSubscribe(z, {
targetUrl: 'https://hooks.zapier.com/x',
authData: { api_key: 'k' },
});
assert.equal(id, 'hook-1');
assert.equal(calls[0].method, 'POST');
assert.match(calls[0].url, /webhooks\/subscribe$/);
assert.equal(calls[0].body.event, 'timestamp.completed');
await timestampCompleted.operation.performUnsubscribe(z, {
subscribeData: 'hook-1',
authData: { api_key: 'k' },
});
assert.equal(calls[1].method, 'DELETE');
assert.match(calls[1].url, /webhooks\/unsubscribe$/);
});
it('beforeRequest adds Authorization bearer', () => {
const req = App.beforeRequest[0]({}, null, { authData: { api_key: 'zmw_abc' } });
assert.equal(req.headers.Authorization, 'Bearer zmw_abc');
});
});

View file

@ -0,0 +1,53 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const subscribeHook = async (z, bundle) => {
const response = await z.request({
method: 'POST',
url: `${base()}/zapier/v1/webhooks/subscribe`,
body: {
targetUrl: bundle.targetUrl,
event: 'timestamp.completed',
},
});
return response.data.id;
};
const unsubscribeHook = async (z, bundle) => {
await z.request({
method: 'DELETE',
url: `${base()}/zapier/v1/webhooks/unsubscribe`,
body: {
hookId: bundle.subscribeData,
},
});
return {};
};
const perform = async (z, bundle) => [bundle.cleanedRequest];
const performList = async () => [];
module.exports = {
key: 'timestamp_completed',
noun: 'Timestamp',
display: {
label: 'Timestamp Completed',
description: 'Triggers when a blockchain timestamp job completes.',
},
operation: {
type: 'hook',
perform,
performList,
performSubscribe: subscribeHook,
performUnsubscribe: unsubscribeHook,
sample: {
event: 'timestamp.completed',
jobId: '550e8400-e29b-41d4-a716-446655440000',
status: { id: '550e8400-e29b-41d4-a716-446655440000', status: 'completed' },
},
outputFields: [
{ key: 'event', label: 'Event' },
{ key: 'jobId', label: 'Job ID' },
],
},
};

View file

@ -0,0 +1,5 @@
# Copy to .env (gitignored) and fill in your real Stripe secret key.
# The billing job (src/jobs/report-usage.ts) loads this via dotenv.
STRIPE_SECRET_KEY=sk_test_replace_me
# Optional: override the SQLite database location (default: zappier.db)
# ZAPPIER_DB=/absolute/path/to/zappier.db

5
packages/zappier/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
zappier.db
zappier.db-journal

134
packages/zappier/README.md Normal file
View file

@ -0,0 +1,134 @@
# Zappier
Metered API platform: per-endpoint pricing, customer types with multipliers and
monthly credits, a usage ledger, Stripe metered billing, purchase-order
invoicing, a company admin console, a self-service customer portal, and a
Zapier integration.
## Documentation
- **[docs/USER-MANUAL.md](docs/USER-MANUAL.md)** — operations & usage manual
- **[docs/ACCOUNTING.md](docs/ACCOUNTING.md)** — company accounting: invoices, PO billing, reports, CSV
- **[docs/USER-MANAGEMENT.md](docs/USER-MANAGEMENT.md)** — pricing, customer types, customer accounts
- **[docs/CUSTOMER-PORTAL.md](docs/CUSTOMER-PORTAL.md)** — end-user portal: signup, 2FA, reloads, invoices
- **[docs/DEVELOPER.md](docs/DEVELOPER.md)** — full developer documentation
- **[docs/WALKTHROUGH.md](docs/WALKTHROUGH.md)** — original step-by-step pricing walkthrough
## Surfaces
| Surface | URL | Audience |
|---|---|---|
| Public API | `/v1/*` | API customers (`x-api-key`) |
| Interactive API docs | `/docs` | Integrating developers |
| Admin console | `/admin` | Company ops & accounting |
| Customer portal | `/portal` | End-user customers (signup, 2FA, billing) |
| Zapier app | `zapier-app/` | No-code users via Zapier |
## Pricing model
`openapi.yaml` defines the API surface; each `operationId` is a rate-card key.
Endpoints carry **list prices** (seed: `src/pricing.ts``DEFAULT_RATE_CARD`).
Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a
`monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card.
Individual customers can carry a `multiplierOverride`.
Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free.
Pricing is editable at runtime in the admin console.
### Seed rate card (list prices, cents per call)
| Endpoint | Model | List price |
| -------------- | -------- | -------------------------------------------- |
| `status` | free | 0 |
| `storage-list` | free | 0 |
| `transform` | fixed | 4 |
| `storage` | variable | 10 + 1 per KB metadata + 50 per MB attached |
### Seed customer types
| Tier | Multiplier | Monthly credit | Default rule (unlisted endpoints) |
| ---------- | ---------- | -------------- | --------------------------------- |
| `free` | 1.0 | 100 cents | none — call rejected with 403 |
| `pro` | 0.5 | 1000 cents | fixed 8 list → 4 billed |
| `business` | 0.25 | 10000 cents | fixed 8 list → 2 billed |
Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI.
Adding a customer type = create it in the admin UI. Variable pricing = base per call +
metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier.
## Quickstart
```sh
npm install
npm run dev
```
The server starts on port 3000. API docs at
[http://localhost:3000/docs](http://localhost:3000/docs), admin console at
`/admin`, customer portal at `/portal`.
## Deploying
```sh
npm ci && npm run build
node dist/index.js # runs from ANY working directory
```
All runtime paths (SQLite default, `.env`, OpenAPI spec, static assets)
resolve from the installation root, so the compiled server works under
systemd, Docker, or cron regardless of cwd. `PORT` and `ZAPPIER_DB` remain
environment-overridable.
## Environment variables
| Variable | Default | Purpose |
| ------------------- | -------------- | --------------------------------------------------- |
| `PORT` | `3000` | HTTP port the server listens on |
| `ZAPPIER_DB` | `<root>/zappier.db` | SQLite database file path |
| `ADMIN_KEY` | `admin-dev-key`| Admin UI / admin API key — **set a real secret in production** |
| `ADMIN_USER` | `admin` | Admin UI primary login username |
| `DEMO_ADMIN_USER` | `demo` | Admin UI demo login username |
| `DEMO_ADMIN_PASSWORD` | `$$$Adm1n###` | Demo login password — **override in production** |
| `STRIPE_SECRET_KEY` | _(none)_ | Stripe secret key — billing job and portal reloads |
## Billing
Usage is reported to Stripe by a job (loads `STRIPE_SECRET_KEY` from `.env`):
```sh
npx ts-node src/jobs/report-usage.ts
```
The job sums each customer's usage since the first of the current month (UTC),
applies the tier's monthly credit, and reports only the **delta** above what was
already reported — re-runs are safe. Idempotency comes from three layers: a
`billing_reports` ledger (cumulative cents per customer per month), an atomic
`job_locks` run guard (1 h TTL), and a deterministic Stripe event `identifier`
(`customer:period:billable`) that dedupes crash retries. It requires a Stripe
meter named `zappier.api_cents` with Sum aggregation over the `value` field.
A Kimi cron job ("Zappier billing · report usage to Stripe") runs it daily at
06:17 America/New_York with a completion notification.
Purchase-order customers are invoiced manually from the admin console
(**Invoices** tab); prepaid balances from the customer portal are drawn down
automatically at invoice issue. See `docs/ACCOUNTING.md`.
## Zapier app
The companion Zapier integration lives in `zapier-app/`:
```sh
cd zapier-app
npm install
npm test
```
To deploy it, create a Zapier developer account, run `zapier login`, then
`zapier push` from the `zapier-app/` directory.
## Testing
```sh
npm test # root API/service suite (jest, 165 tests)
cd zapier-app && npm test # Zapier integration suite (mocha, 4 tests)
```

View file

@ -0,0 +1,672 @@
const state = { pricing: null, customers: [], invoices: [], report: null, trend: null, system: null, users: [] };
const TOKEN_KEY = 'zappier-admin-token';
/* ---------------- auth ---------------- */
function token() {
return localStorage.getItem(TOKEN_KEY);
}
function showLogin(message = '') {
document.getElementById('shell').classList.remove('on');
document.getElementById('login').style.display = 'grid';
document.getElementById('login-error').textContent = message;
}
function showShell() {
document.getElementById('login').style.display = 'none';
document.getElementById('shell').classList.add('on');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/admin/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error((await res.json()).error || 'Login failed');
const { token: t } = await res.json();
localStorage.setItem(TOKEN_KEY, t);
showShell();
load().catch((err) => say(err.message, true));
} catch (err) {
showLogin(err.message);
}
});
document.getElementById('logout').addEventListener('click', () => {
localStorage.removeItem(TOKEN_KEY);
location.reload();
});
/* ---------------- api + status ---------------- */
async function api(path, options = {}) {
const res = await fetch(`/admin/api${path}`, {
...options,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` },
});
if (res.status === 403) {
localStorage.removeItem(TOKEN_KEY);
showLogin('Session expired — sign in again.');
throw new Error('Session expired.');
}
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
return res.json();
}
let statusTimer;
function say(msg, isError = false) {
const el = document.getElementById('status');
el.textContent = msg;
el.classList.toggle('error', isError);
el.classList.add('show');
clearTimeout(statusTimer);
statusTimer = setTimeout(() => el.classList.remove('show'), 4000);
}
/* ---------------- shared helpers ---------------- */
const fmt = (cents) =>
(cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2);
const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
const customerName = (id) => state.customers.find((c) => c.id === id)?.name ?? id;
function customerOptions(selected, includeAll = false) {
const all = includeAll ? `<option value="">All customers</option>` : '';
return (
all +
state.customers
.map((c) => `<option value="${c.id}" ${c.id === selected ? 'selected' : ''}>${c.name} (${c.id})</option>`)
.join('')
);
}
function currentPeriod() {
return new Date().toISOString().slice(0, 7);
}
async function load() {
state.pricing = await api('/pricing');
state.customers = (await api('/customers')).customers;
state.invoices = (await api('/invoices')).invoices;
state.users = (await api('/users')).users;
renderEndpoints();
renderTiers();
renderCustomers();
renderInvoices();
renderReports();
renderSystem();
renderUsers();
}
/* ---------------- rate card ---------------- */
function ruleInputs(id, rule) {
const fields =
rule.kind === 'fixed'
? { fixedCents: rule.fixedCents }
: rule.kind === 'variable'
? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents }
: {};
return Object.entries(fields)
.map(
([k, v]) =>
`<label class="field"><span>${k}</span><input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6"></label>`,
)
.join('');
}
function renderEndpoints() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(
([id, rule]) => `<tr>
<td class="id">${id}</td>
<td><span class="pill ${rule.kind}">${rule.kind}</span></td>
<td><select data-endpoint-kind="${id}">
${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
</select></td>
<td>${ruleInputs(id, rule)}</td>
<td class="row-actions">
<button class="btn" onclick="saveEndpoint('${id}')">Save</button>
<button class="btn ghost" onclick="deleteEndpoint('${id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('endpoints').innerHTML = `
<h2>Rate card</h2>
<p class="lede">Per-endpoint list prices, in cents. Changes apply to the next API call no restart.</p>
<div class="card"><table>
<thead><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Set kind</th><th>Prices (cents)</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add endpoint</h3>
<input id="new-endpoint-id" placeholder="operationId">
<select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
<button class="btn" onclick="addEndpoint()">Add</button>
<p class="hint">The operationId must match an operation in openapi.yaml.</p>
</div>`;
}
async function saveEndpoint(id) {
const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value;
const rule = { kind };
document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => {
rule[el.dataset.field] = Number(el.value);
});
if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0;
if (kind === 'variable') {
rule.baseCents = rule.baseCents ?? 0;
rule.perKbCents = rule.perKbCents ?? 0;
rule.perMbCents = rule.perMbCents ?? 0;
}
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
say(`Saved ${id}.`);
await load();
}
async function deleteEndpoint(id) {
await api(`/endpoints/${id}`, { method: 'DELETE' });
say(`Deleted ${id} — calls to it now get 403 unless a tier has a default rule.`);
await load();
}
async function addEndpoint() {
const id = document.getElementById('new-endpoint-id').value.trim();
const kind = document.getElementById('new-endpoint-kind').value;
if (!id) return say('Endpoint id required.', true);
const rule =
kind === 'free'
? { kind }
: kind === 'fixed'
? { kind, fixedCents: 0 }
: { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 };
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
say(`Added ${id}.`);
await load();
}
/* ---------------- customer types ---------------- */
function renderTiers() {
const rows = state.pricing.tiers
.map(
(t) => `<tr>
<td class="id">${t.id}</td>
<td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
<td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
<td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
<td class="row-actions">
<button class="btn" onclick="saveTier('${t.id}')">Save</button>
<button class="btn ghost" onclick="deleteTier('${t.id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('tiers').innerHTML = `
<h2>Customer types</h2>
<p class="lede">Multiplier scales every list price (0.5 = 50%). Monthly credit is free included usage, in cents.</p>
<div class="card"><table>
<thead><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add customer type</h3>
<input id="new-tier-id" placeholder="id">
<input id="new-tier-name" placeholder="name">
<input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
<button class="btn" onclick="addTier()">Add</button>
<p class="hint">New types start with 0 monthly credit edit after adding.</p>
</div>`;
}
async function saveTier(id) {
const body = { id };
document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => {
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
const existing = state.pricing.tiers.find((t) => t.id === id);
if (existing?.defaultRule) body.defaultRule = existing.defaultRule;
await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved tier ${id}.`);
await load();
}
async function deleteTier(id) {
await api(`/tiers/${id}`, { method: 'DELETE' });
say(`Deleted tier ${id}.`);
await load();
}
async function addTier() {
const id = document.getElementById('new-tier-id').value.trim();
const name = document.getElementById('new-tier-name').value.trim();
const multiplier = Number(document.getElementById('new-tier-multiplier').value);
if (!id || !name) return say('Tier id and name required.', true);
await api(`/tiers/${id}`, {
method: 'PUT',
body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }),
});
say(`Added tier ${id}.`);
await load();
}
/* ---------------- customers ---------------- */
function renderCustomers() {
const tierOptions = (selected) =>
state.pricing.tiers
.map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
.join('');
const btOptions = (selected) =>
['stripe', 'purchase_order']
.map((b) => `<option value="${b}" ${b === (selected ?? 'stripe') ? 'selected' : ''}>${b === 'stripe' ? 'Stripe' : 'Purchase order'}</option>`)
.join('');
const rows = state.customers
.map(
(c) => `<tr>
<td class="id">${c.id}</td>
<td>${c.name}</td>
<td><input data-customer="${c.id}" data-field="email" type="email" size="18" value="${c.email ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="billingType">${btOptions(c.billingType)}</select></td>
<td class="row-actions"><button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
</tr>`,
)
.join('');
document.getElementById('customers').innerHTML = `
<h2>Customers</h2>
<p class="lede">Assign types, billing method, and per-customer deals. A multiplier override replaces the type multiplier for that customer.</p>
<div class="card"><table>
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Multiplier override</th><th>Billing</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add customer</h3>
<input id="new-customer-name" placeholder="name">
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
<button class="btn" onclick="addCustomer()">Create</button>
<p class="hint">The new customer's API key is shown once in the notification copy it immediately. Set email and billing method after creating.</p>
</div>`;
}
async function saveCustomer(id) {
const body = {};
document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
if (el.value === '') return;
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved customer ${id}.`);
await load();
}
async function addCustomer() {
const name = document.getElementById('new-customer-name').value.trim();
const tierId = document.getElementById('new-customer-tier').value;
if (!name) return say('Customer name required.', true);
const created = await api('/customers', {
method: 'POST',
body: JSON.stringify({ name, tierId }),
});
say(`Created ${created.id} — API key: ${created.apiKey}`);
await load();
}
/* ---------------- invoices ---------------- */
function renderInvoices() {
const rows = state.invoices
.slice()
.sort((a, b) => b.id.localeCompare(a.id))
.map((inv) => {
const actions = [];
actions.push(`<button class="btn ghost" onclick="viewInvoice('${inv.id}')">View</button>`);
if (inv.status === 'draft')
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','issue')">Issue</button>`);
if (inv.status === 'issued')
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','paid')">Mark paid</button>`);
return `<tr>
<td class="id">${inv.id}</td>
<td>${customerName(inv.customerId)}</td>
<td>${inv.period}</td>
<td><span class="pill ${inv.status}">${inv.status}</span></td>
<td><span class="pill ${inv.billingType}">${inv.billingType === 'stripe' ? 'Stripe' : 'PO'}</span>${inv.poNumber ? ` <span class="id">${inv.poNumber}</span>` : ''}</td>
<td class="money">${fmt(inv.totalCents)}</td>
<td class="money">${fmt(inv.creditCents)}</td>
<td class="money"><b>${fmt(inv.billableCents)}</b></td>
<td>${fmtDate(inv.dueAtMs)}</td>
<td class="row-actions">${actions.join('')}</td>
</tr>`;
})
.join('');
document.getElementById('invoices').innerHTML = `
<h2>Invoices</h2>
<p class="lede">Generate monthly invoices from metered usage, then issue and collect. Regenerating a period replaces drafts and skips issued/paid invoices.</p>
<div class="card">
<h3>Generate invoices</h3>
<div class="filterbar">
<label><span>Period</span><input id="gen-period" type="month" value="${currentPeriod()}"></label>
<label><span>Customer</span><select id="gen-customer">${customerOptions('', true)}</select></label>
<label><span>PO number (optional)</span><input id="gen-po" placeholder="PO-1234" size="12"></label>
<button class="btn" onclick="generateInvoices()">Generate</button>
</div>
<div id="gen-result"></div>
</div>
<div class="card">
<div class="filterbar">
<label><span>Customer</span><select id="inv-filter-customer" onchange="refreshInvoices()">${customerOptions('', true)}</select></label>
<label><span>Period</span><input id="inv-filter-period" type="month" onchange="refreshInvoices()"></label>
<label><span>Status</span><select id="inv-filter-status" onchange="refreshInvoices()">
<option value="">Any</option><option>draft</option><option>issued</option><option>paid</option>
</select></label>
<button class="btn ghost" onclick="refreshInvoices()">Refresh</button>
</div>
<table id="inv-table">
<thead><tr><th>Invoice</th><th>Customer</th><th>Period</th><th>Status</th><th>Billing</th><th>Total</th><th>Credit</th><th>Due amount</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="10" style="color:var(--muted)">No invoices yet — generate a period above.</td></tr>'}</tbody>
</table>
</div>`;
}
async function generateInvoices() {
const period = document.getElementById('gen-period').value;
const customerId = document.getElementById('gen-customer').value;
const poNumber = document.getElementById('gen-po').value.trim();
if (!period) return say('Pick a period first.', true);
const result = await api('/invoices/generate', {
method: 'POST',
body: JSON.stringify({
period,
...(customerId ? { customerId } : {}),
...(poNumber ? { poNumber } : {}),
}),
});
const skips = result.skipped
.map((s) => `<li>${customerName(s.customerId)}: ${s.reason}</li>`)
.join('');
document.getElementById('gen-result').innerHTML =
`<p class="hint">Generated ${result.generated.length}: ${result.generated.join(', ') || '—'}</p>` +
(skips ? `<ul class="skip-list">${skips}</ul>` : '');
say(`Generated ${result.generated.length} invoice(s), skipped ${result.skipped.length}.`);
await load();
}
async function refreshInvoices() {
const params = new URLSearchParams();
const customerId = document.getElementById('inv-filter-customer').value;
const period = document.getElementById('inv-filter-period').value;
const status = document.getElementById('inv-filter-status').value;
if (customerId) params.set('customerId', customerId);
if (period) params.set('period', period);
if (status) params.set('status', status);
state.invoices = (await api(`/invoices?${params}`)).invoices;
renderInvoices();
}
async function invoiceAction(id, action) {
await api(`/invoices/${id}/${action}`, { method: 'POST', body: '{}' });
say(action === 'issue' ? `Issued ${id}.` : `Marked ${id} paid.`);
await load();
}
async function viewInvoice(id) {
const res = await fetch(`/admin/api/invoices/${id}?format=html`, {
headers: { authorization: `Bearer ${token()}` },
});
if (!res.ok) return say(`Could not load ${id}.`, true);
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
}
/* ---------------- reports ---------------- */
function renderReports() {
const periodStart = `${currentPeriod()}-01`;
document.getElementById('reports').innerHTML = `
<h2>Reports</h2>
<p class="lede">Billing and usage analytics across customers. All amounts in USD, converted from integer cents.</p>
<div class="card">
<h3>Billing report</h3>
<div class="filterbar">
<label><span>From</span><input id="rep-from" type="date" value="${periodStart}"></label>
<label><span>To</span><input id="rep-to" type="date"></label>
<label><span>Customer</span><select id="rep-customer">${customerOptions('', true)}</select></label>
<label><span>Billing type</span><select id="rep-billing-type">
<option value="">Any</option><option value="stripe">Stripe</option><option value="purchase_order">Purchase order</option>
</select></label>
<button class="btn" onclick="runReport()">Run</button>
<button class="btn ghost" onclick="downloadCsv()">Download CSV</button>
</div>
<div id="rep-summary"></div>
<table id="rep-table"></table>
</div>
<div class="card">
<h3>Usage trend</h3>
<div class="filterbar">
<label><span>Bucket</span><select id="trend-bucket" onchange="runTrend()">
<option value="day">Daily</option><option value="week">Weekly</option>
</select></label>
</div>
<div id="trend-chart"></div>
</div>`;
runReport().catch((err) => say(err.message, true));
runTrend().catch((err) => say(err.message, true));
}
function reportQuery() {
const params = new URLSearchParams();
const from = document.getElementById('rep-from').value;
const to = document.getElementById('rep-to').value;
const customerId = document.getElementById('rep-customer').value;
const billingType = document.getElementById('rep-billing-type').value;
if (from) params.set('from', from);
if (to) params.set('to', to);
if (customerId) params.set('customerId', customerId);
if (billingType) params.set('billingType', billingType);
return params;
}
async function runReport() {
const { rows } = await api(`/reports/billing?${reportQuery()}`);
state.report = rows;
const totals = rows.reduce(
(acc, r) => ({
calls: acc.calls + r.calls,
totalCents: acc.totalCents + r.totalCents,
creditCents: acc.creditCents + r.creditCents,
billableCents: acc.billableCents + r.billableCents,
}),
{ calls: 0, totalCents: 0, creditCents: 0, billableCents: 0 },
);
document.getElementById('rep-summary').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Calls</div><div class="v">${totals.calls.toLocaleString()}</div></div>
<div class="stat"><div class="k">Gross usage</div><div class="v">${fmt(totals.totalCents)}</div></div>
<div class="stat"><div class="k">Credits applied</div><div class="v">${fmt(totals.creditCents)}</div></div>
<div class="stat"><div class="k">Billable</div><div class="v">${fmt(totals.billableCents)}</div></div>
</div>`;
document.getElementById('rep-table').innerHTML = `
<thead><tr><th>Customer</th><th>Billing</th><th>Calls</th><th>Gross</th><th>Credit</th><th>Billable</th></tr></thead>
<tbody>${
rows
.map(
(r) => `<tr>
<td>${r.name} <span class="id">${r.customerId}</span></td>
<td><span class="pill ${r.billingType}">${r.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
<td class="money">${r.calls.toLocaleString()}</td>
<td class="money">${fmt(r.totalCents)}</td>
<td class="money">${fmt(r.creditCents)}</td>
<td class="money"><b>${fmt(r.billableCents)}</b></td>
</tr>`,
)
.join('') || '<tr><td colspan="6" style="color:var(--muted)">No usage in range.</td></tr>'
}</tbody>`;
}
async function downloadCsv() {
const params = reportQuery();
params.set('format', 'csv');
const res = await fetch(`/admin/api/reports/billing?${params}`, {
headers: { authorization: `Bearer ${token()}` },
});
if (!res.ok) return say('CSV download failed.', true);
const url = URL.createObjectURL(await res.blob());
const a = document.createElement('a');
a.href = url;
a.download = 'billing-report.csv';
a.click();
URL.revokeObjectURL(url);
say('CSV downloaded.');
}
async function runTrend() {
const bucket = document.getElementById('trend-bucket').value;
const params = reportQuery();
params.delete('billingType');
params.set('bucket', bucket);
const { points } = await api(`/reports/usage-trend?${params}`);
state.trend = points;
document.getElementById('trend-chart').innerHTML = points.length
? trendChart(points)
: '<p class="hint">No usage in range.</p>';
}
function trendChart(points) {
const W = 920;
const H = 220;
const padL = 8;
const padB = 34;
const padT = 10;
const max = Math.max(...points.map((p) => p.cents), 1);
const band = (W - padL) / points.length;
const barW = Math.max(4, Math.min(48, band * 0.62));
const bars = points
.map((p, i) => {
const h = ((H - padB - padT) * p.cents) / max;
const x = padL + i * band + (band - barW) / 2;
const y = H - padB - h;
const label =
points.length <= 31 || i % Math.ceil(points.length / 31) === 0
? `<text x="${x + barW / 2}" y="${H - padB + 13}" text-anchor="middle">${p.bucket.slice(5)}</text>`
: '';
return `<rect class="bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(h, p.cents > 0 ? 2 : 0).toFixed(1)}"><title>${p.bucket}: ${p.calls} calls, ${fmt(p.cents)}</title></rect>${label}`;
})
.join('');
return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Usage trend">${bars}</svg>
<p class="hint">Hover a bar for exact calls and amount. Peak: ${fmt(max)}.</p>`;
}
/* ---------------- system ---------------- */
function renderSystem() {
document.getElementById('system').innerHTML = `
<h2>System</h2>
<p class="lede">Integration health and current-period billing snapshot.</p>
<div class="card"><h3>Zapier integration</h3><div id="sys-zapier"><p class="hint">Loading</p></div></div>
<div class="card"><h3>Current period (${currentPeriod()})</h3><div id="sys-period"><p class="hint">Loading</p></div></div>`;
loadSystem().catch((err) => say(err.message, true));
}
async function loadSystem() {
const status = await api('/zapier/status');
state.system = status;
document.getElementById('sys-zapier').innerHTML = `
<dl class="kv">
<dt>App directory</dt><dd>${status.appDirPresent ? '✓ zapier-app/ found' : ' not found'}</dd>
<dt>Version</dt><dd>${status.version ?? ''}</dd>
<dt>Triggers</dt><dd>${status.triggers.length ? status.triggers.join(', ') : ''}</dd>
<dt>Creates</dt><dd>${status.creates.length ? status.creates.join(', ') : ''}</dd>
</dl>`;
const params = new URLSearchParams({ from: `${currentPeriod()}-01` });
const { rows } = await api(`/reports/billing?${params}`);
const totals = rows.reduce(
(acc, r) => ({ calls: acc.calls + r.calls, billableCents: acc.billableCents + r.billableCents }),
{ calls: 0, billableCents: 0 },
);
const unpaid = state.invoices.filter((i) => i.status === 'issued');
document.getElementById('sys-period').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Calls this period</div><div class="v">${totals.calls.toLocaleString()}</div></div>
<div class="stat"><div class="k">Billable this period</div><div class="v">${fmt(totals.billableCents)}</div></div>
<div class="stat"><div class="k">Open invoices</div><div class="v">${unpaid.length}</div></div>
<div class="stat"><div class="k">Open amount</div><div class="v">${fmt(unpaid.reduce((s, i) => s + i.billableCents, 0))}</div></div>
</div>`;
}
/* ---------------- admin users ---------------- */
function renderUsers() {
const rows = state.users
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
.map(
(u) => `<tr>
<td class="id">${u.username}</td>
<td><span class="pill ${u.active ? 'paid' : 'draft'}">${u.active ? 'active' : 'inactive'}</span></td>
<td>${fmtDate(u.createdMs)}</td>
<td class="row-actions">
<button class="btn ${u.active ? 'ghost' : ''}" onclick="toggleUser('${u.username}', ${!u.active})">${u.active ? 'Deactivate' : 'Activate'}</button>
</td>
</tr>`,
)
.join('');
document.getElementById('users').innerHTML = `
<h2>Admin users</h2>
<p class="lede">Accounts that can sign in to this console. Passwords are stored as scrypt hashes never in plain text. The last active admin cannot be deactivated.</p>
<div class="card"><table>
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add admin user</h3>
<input id="new-user-name" placeholder="username" autocomplete="off">
<input id="new-user-password" type="password" placeholder="password (min 8 chars)" autocomplete="new-password">
<button class="btn" onclick="addUser()">Create</button>
<p class="hint">Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.</p>
</div>`;
}
async function addUser() {
const username = document.getElementById('new-user-name').value.trim();
const password = document.getElementById('new-user-password').value;
if (!username || !password) return say('Username and password required.', true);
await api('/users', { method: 'POST', body: JSON.stringify({ username, password }) });
say(`Created admin user ${username}.`);
await load();
}
async function toggleUser(username, activate) {
await api(`/users/${username}/${activate ? 'activate' : 'deactivate'}`, {
method: 'POST',
body: '{}',
});
say(`${activate ? 'Activated' : 'Deactivated'} ${username}.`);
await load();
}
/* ---------------- tabs + boot ---------------- */
document.querySelectorAll('nav button').forEach((btn) =>
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('main section').forEach((s) => (s.hidden = true));
document.getElementById(btn.dataset.tab).hidden = false;
}),
);
if (token()) {
showShell();
load().catch((err) => say(err.message, true));
} else {
showLogin();
}

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