Initial import of verae-middleware from zapier monorepo
This commit is contained in:
commit
0c807706f3
67 changed files with 6630 additions and 0 deletions
35
.env.example
Normal file
35
.env.example
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# HTTP
|
||||
PORT=3100
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Upstream Verae
|
||||
VERAE_API_BASE_URL=https://api.veraetime.net
|
||||
MOCK_VERAE=false
|
||||
|
||||
# NATS (Phase 7+)
|
||||
# NS1 JetStream is 127.0.0.1:4222 on 70.88.205.138 (not public).
|
||||
# Tunnel: ssh -fN -L 14222:127.0.0.1:4222 marchon@70.88.205.138
|
||||
# then NATS_URL=nats://127.0.0.1:14222
|
||||
NATS_ENABLED=false
|
||||
NATS_URL=nats://127.0.0.1:4222
|
||||
WAIT_TIMEOUT_MS=25000
|
||||
MOCK_JOB_COMPLETE_MS=150
|
||||
|
||||
# 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
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal 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"]
|
||||
9
NATS.md
Normal file
9
NATS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# NATS — verae-middleware
|
||||
|
||||
| Direction | Address | Peer | Body |
|
||||
|-----------|---------|------|------|
|
||||
| OUT | `verae.zapier.jobs.watch` | job-poller | job watch payload |
|
||||
| IN | `verae.zapier.jobs.events` | waiter | terminal status |
|
||||
| OUT | (via aggregator) `verae.archive.query` | WORM | attached lookup |
|
||||
|
||||
HTTP remains the only Zapier-facing surface.
|
||||
40
README.md
Normal file
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# verae-middleware
|
||||
|
||||
Zapier-facing HTTPS adapter (`/zapier/v1/*`) plus NATS workers. Zapier never speaks NATS or `api.veraetime.net`; this process does.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/verae-middleware
|
||||
**Clone:** `ssh://git@git.georgelambert.org:2223/marchon/verae-middleware.git`
|
||||
**Catalog README (PDF):** https://zapier.georgelambert.org/packages/verae-zapier-middleware/README.pdf
|
||||
**NATS contract:** https://zapier.georgelambert.org/docs-master/modules/verae-zapier-middleware/NATS.pdf
|
||||
**Install / Docker / Proxmox:** https://zapier.georgelambert.org/packages/verae-ops/README.pdf
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd packages/verae-zapier-middleware
|
||||
npm install
|
||||
MOCK_VERAE=true NATS_ENABLED=false npm test
|
||||
PORT=3100 MOCK_VERAE=true npm start # /health, /zapier/v1
|
||||
```
|
||||
|
||||
Docker image: `Dockerfile` (Node 22, port 3100). Full stack: `verae-ops` compose.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `PORT` | `3100` | HTTP bind |
|
||||
| `VERAE_API_BASE_URL` | `http://localhost:8080` | Chain HTTPS |
|
||||
| `MOCK_VERAE` | `false` | In-process mock chain |
|
||||
| `NATS_ENABLED` | `false` | JetStream vs in-process poller |
|
||||
| `NATS_URL` | `nats://127.0.0.1:4222` | Cluster URL (loopback / docker DNS) |
|
||||
| `WAIT_TIMEOUT_MS` | — | Sync wait on `jobs.events` |
|
||||
|
||||
## Depends on
|
||||
|
||||
- **Upstream HTTPS:** zappier-edge (`x-api-key` already checked) or a lab client
|
||||
- **Chain:** `api.veraetime.net` or `MOCK_VERAE=true`
|
||||
- **NATS:** `jobs.watch` out, `jobs.events` in; optional `archive.put` / aggregator wait
|
||||
- **Optional:** request-splitter (in-process), archive-worm, tree-node, webhook-deliver
|
||||
|
||||
Does **not** bind NATS publicly. See [verae-ops linking](https://zapier.georgelambert.org/packages/verae-ops/06-linking-services.pdf).
|
||||
15
SUMMARY.md
Normal file
15
SUMMARY.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# verae-middleware (HTTP edge)
|
||||
|
||||
**Job:** Public `/zapier/v1/*`. Auth, entitlements, timestamp/wait, hash lookup, objects, receipts. Publishes NATS jobs; waits on events.
|
||||
|
||||
**Expects HTTPS from:** zappier-edge (or Zapier if pointed here).
|
||||
|
||||
**Sends:**
|
||||
- HTTPS to verae-chain-client (`/api/timestamp`, `/api/status`, `/api/verify`)
|
||||
- `verae.zapier.jobs.watch` after create
|
||||
- waits `verae.zapier.jobs.events`
|
||||
- splitter + aggregator when attachments / `includeAttached`
|
||||
|
||||
**Returns to requestor:** 202 `{ jobId }` or wait JSON (completed / pending+jobId).
|
||||
|
||||
**NATS:** never exposed to Zapier.
|
||||
864
package-lock.json
generated
Normal file
864
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"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:gate9": "MOCK_VERAE=true NATS_ENABLED=true NATS_FORCE_CONNECT=1 NATS_URL=nats://127.0.0.1:4222 JOB_POLL_INTERVAL_MS=40 WAIT_TIMEOUT_MS=8000 node --test test/integration/wait-nats.test.js",
|
||||
"test:gate10": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/tenants.test.js",
|
||||
"test:hash": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/hash.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"nats": "^2.28.2"
|
||||
},
|
||||
"keywords": [
|
||||
"verae",
|
||||
"zapier",
|
||||
"middleware",
|
||||
"nats",
|
||||
"timestamping"
|
||||
],
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
55
src/app.js
Normal file
55
src/app.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* @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';
|
||||
import { config } from './config.js';
|
||||
import { isNatsConnected } from './nats/connection.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: '12mb' }));
|
||||
app.use(traceMiddleware);
|
||||
|
||||
/**
|
||||
* Liveness probe.
|
||||
*/
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'verae-zapier-middleware',
|
||||
mockVerae: config.mockVerae,
|
||||
natsEnabled: config.natsEnabled,
|
||||
natsConnected: isNatsConnected(),
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/zapier', apiRoutes);
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
sendError(res, err);
|
||||
});
|
||||
|
||||
log.info('express app created');
|
||||
return app;
|
||||
}
|
||||
351
src/clients/veraeClient.js
Normal file
351
src/clients/veraeClient.js
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* @fileoverview HTTP client for api.veraetime.net (with mock mode).
|
||||
* @module clients/veraeClient
|
||||
*/
|
||||
|
||||
import { createHash, 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();
|
||||
/** @type {Map<string, { jobId: string, sha256: string, timestamp?: string, publicMetadata?: object, privateMetadata?: object }>} */
|
||||
const mockHashes = new Map();
|
||||
|
||||
/**
|
||||
* @param {string} data
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sha256Hex(data) {
|
||||
return createHash('sha256').update(String(data), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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, sha256, publicMetadata, privateMetadata }) {
|
||||
if (!data && !sha256) {
|
||||
throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' });
|
||||
}
|
||||
const key = (sha256 || sha256Hex(data)).toLowerCase();
|
||||
const existing = mockHashes.get(key);
|
||||
if (existing) {
|
||||
const job = mockJobs.get(existing.jobId);
|
||||
return {
|
||||
jobId: existing.jobId,
|
||||
sha256: key,
|
||||
existing: true,
|
||||
timestamp: job?.completedAt ?? existing.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
mockJobs.set(jobId, {
|
||||
id: jobId,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
data: data ?? key,
|
||||
hashAlg: hashAlg ?? 'SHA256',
|
||||
sha256: key,
|
||||
publicMetadata: publicMetadata ?? {},
|
||||
privateMetadata: privateMetadata ?? {},
|
||||
});
|
||||
mockHashes.set(key, { jobId, sha256: key, publicMetadata, privateMetadata });
|
||||
|
||||
const delayMs = config.mockJobCompleteMs ?? 150;
|
||||
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,
|
||||
sha256: key,
|
||||
publicMetadata: job.publicMetadata,
|
||||
};
|
||||
const rec = mockHashes.get(key);
|
||||
if (rec) rec.timestamp = job.completedAt;
|
||||
}, delayMs);
|
||||
|
||||
return { jobId, sha256: key, existing: false };
|
||||
}
|
||||
|
||||
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,
|
||||
privateMetadata: job.privateMetadata,
|
||||
error: job.error,
|
||||
sha256: job.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockLookupHash(sha256) {
|
||||
const key = String(sha256 || '').toLowerCase();
|
||||
const rec = mockHashes.get(key);
|
||||
if (!rec) {
|
||||
throw new AppError('Hash not found', { status: 404, code: 'NOT_FOUND' });
|
||||
}
|
||||
const job = mockJobs.get(rec.jobId);
|
||||
return {
|
||||
sha256: key,
|
||||
exists: true,
|
||||
jobId: rec.jobId,
|
||||
timestamp: rec.timestamp ?? job?.completedAt,
|
||||
status: job?.status,
|
||||
publicMetadata: job?.publicMetadata ?? rec.publicMetadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const liveBody = { data: body.data, hashAlg: body.hashAlg };
|
||||
return request('/api/timestamp', { method: 'POST', token, body: liveBody });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
* @param {string} sha256
|
||||
*/
|
||||
async lookupHash(token, sha256) {
|
||||
if (config.mockVerae) return mockLookupHash(sha256);
|
||||
throw new AppError('Hash lookup is not on the live Verae OpenAPI', {
|
||||
status: 501,
|
||||
code: 'NOT_IMPLEMENTED',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @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();
|
||||
mockHashes.clear();
|
||||
}
|
||||
144
src/config.js
Normal file
144
src/config.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* @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),
|
||||
waitTimeoutMs: int(process.env.WAIT_TIMEOUT_MS, 25000),
|
||||
mockJobCompleteMs: int(process.env.MOCK_JOB_COMPLETE_MS, 150),
|
||||
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,
|
||||
});
|
||||
106
src/debug/config.js
Normal file
106
src/debug/config.js
Normal 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 };
|
||||
24
src/debug/index.js
Normal file
24
src/debug/index.js
Normal 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';
|
||||
121
src/debug/logger.js
Normal file
121
src/debug/logger.js
Normal 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),
|
||||
};
|
||||
}
|
||||
93
src/debug/redact.js
Normal file
93
src/debug/redact.js
Normal 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);
|
||||
}
|
||||
39
src/debug/trace-context.js
Normal file
39
src/debug/trace-context.js
Normal 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;
|
||||
}
|
||||
95
src/debug/trace.js
Normal file
95
src/debug/trace.js
Normal 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();
|
||||
});
|
||||
}
|
||||
75
src/errors.js
Normal file
75
src/errors.js
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
51
src/index.js
Normal file
51
src/index.js
Normal 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();
|
||||
}
|
||||
64
src/lib/receiptPdf.js
Normal file
64
src/lib/receiptPdf.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Minimal PDF builder for retrieval receipts (no native deps).
|
||||
* @module lib/receiptPdf
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
function pdfEscape(s) {
|
||||
return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} receipt
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function buildReceiptPdf(receipt) {
|
||||
const lines = [
|
||||
'Verae Time — certified retrieval receipt',
|
||||
`Type: ${receipt.type}`,
|
||||
`Job ID: ${receipt.jobId}`,
|
||||
`SHA256: ${receipt.sha256 ?? ''}`,
|
||||
`Original timestamp: ${receipt.timestamp ?? ''}`,
|
||||
`Retrieved at: ${receipt.extraSeal?.retrievedAt ?? ''}`,
|
||||
`Tenant: ${receipt.extraSeal?.tenantId ?? ''}`,
|
||||
`Seal event: ${receipt.extraSeal?.event ?? ''}`,
|
||||
`Certificate: ${String(receipt.certificate ?? '').slice(0, 80)}`,
|
||||
];
|
||||
|
||||
const commands = lines
|
||||
.map((line, i) => {
|
||||
const y = 720 - i * 18;
|
||||
return `BT /F1 11 Tf 50 ${y} Td (${pdfEscape(line)}) Tj ET`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const stream = `${commands}\n`;
|
||||
const objects = [
|
||||
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
|
||||
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',
|
||||
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj',
|
||||
`4 0 obj << /Length ${Buffer.byteLength(stream)} >> stream\n${stream}endstream endobj`,
|
||||
'5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj',
|
||||
];
|
||||
|
||||
let offset = '%PDF-1.4\n'.length;
|
||||
const xref = [0];
|
||||
let body = '%PDF-1.4\n';
|
||||
for (const obj of objects) {
|
||||
xref.push(offset);
|
||||
const chunk = `${obj}\n`;
|
||||
body += chunk;
|
||||
offset += Buffer.byteLength(chunk);
|
||||
}
|
||||
const xrefStart = offset;
|
||||
let xrefTable = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i < xref.length; i += 1) {
|
||||
xrefTable += `${String(xref[i]).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
body += xrefTable;
|
||||
body += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`;
|
||||
return Buffer.from(body, 'utf8');
|
||||
}
|
||||
97
src/lib/tokens.js
Normal file
97
src/lib/tokens.js
Normal 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;
|
||||
}
|
||||
27
src/middleware/authenticate.js
Normal file
27
src/middleware/authenticate.js
Normal 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();
|
||||
});
|
||||
56
src/middleware/rateLimit.js
Normal file
56
src/middleware/rateLimit.js
Normal 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();
|
||||
});
|
||||
116
src/nats/connection.js
Normal file
116
src/nats/connection.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* @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 };
|
||||
}
|
||||
|
||||
/** @returns {import('nats').NatsConnection|null} */
|
||||
export function getNatsConnection() {
|
||||
return nc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
95
src/nats/publishers.js
Normal file
95
src/nats/publishers.js
Normal 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) };
|
||||
}
|
||||
47
src/nats/subjects.js
Normal file
47
src/nats/subjects.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* @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',
|
||||
/** Off-chain WORM / tree-node puts */
|
||||
ARCHIVE_PUT: 'verae.archive.put',
|
||||
/** Broadcast hash lookup to every WORM and tree node (no queue group) */
|
||||
ARCHIVE_QUERY: 'verae.archive.query',
|
||||
});
|
||||
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
59
src/nats/wait.js
Normal file
59
src/nats/wait.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Wait for a terminal job event on NATS JOBS_EVENTS.
|
||||
* @module nats/wait
|
||||
*/
|
||||
|
||||
import { createDebugger } from '../debug/logger.js';
|
||||
import { getTraceId } from '../debug/trace-context.js';
|
||||
import { SUBJECTS } from './subjects.js';
|
||||
import { connectNats, getNatsConnection } from './connection.js';
|
||||
|
||||
const log = createDebugger('jobs');
|
||||
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<object|null>} event payload or null on timeout
|
||||
*/
|
||||
export async function waitForJobEvent(jobId, timeoutMs) {
|
||||
const handles = getNatsConnection() ? { nc: getNatsConnection() } : await connectNats();
|
||||
const nc = handles.nc;
|
||||
const sub = nc.subscribe(SUBJECTS.JOBS_EVENTS);
|
||||
const traceId = getTraceId();
|
||||
log.debug('waitForJobEvent subscribe', { jobId, timeoutMs, traceId });
|
||||
|
||||
const timeout = new Promise((resolve) => {
|
||||
setTimeout(() => resolve(null), timeoutMs);
|
||||
});
|
||||
|
||||
const firstMatch = (async () => {
|
||||
for await (const msg of sub) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(typeof msg.string === 'function' ? msg.string() : msg.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (data.jobId !== jobId) continue;
|
||||
const event = data.event;
|
||||
if (
|
||||
event === 'timestamp.completed' ||
|
||||
event === 'timestamp.failed' ||
|
||||
event === 'timestamp.timeout'
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await Promise.race([firstMatch, timeout]);
|
||||
} finally {
|
||||
try {
|
||||
sub.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/routes/authRoutes.js
Normal file
57
src/routes/authRoutes.js
Normal 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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
23
src/routes/hashRoutes.js
Normal file
23
src/routes/hashRoutes.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @module routes/hashRoutes
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler, AppError } from '../errors.js';
|
||||
import { lookupHash } from '../services/timestampService.js';
|
||||
|
||||
export const hashRoutes = Router();
|
||||
|
||||
hashRoutes.get(
|
||||
'/:sha256',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sha256 } = req.params;
|
||||
if (!sha256 || !/^[a-fA-F0-9]{64}$/.test(sha256)) {
|
||||
throw new AppError('sha256 must be 64 hex chars', { status: 400, code: 'VALIDATION_ERROR' });
|
||||
}
|
||||
const includeAttached = req.query.includeAttached === 'true' || req.query.includeAttached === '1';
|
||||
const includeTree = req.query.includeTree === 'true' || req.query.includeTree === '1';
|
||||
const result = await lookupHash(req.auth, sha256, { includeAttached, includeTree });
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
40
src/routes/index.js
Normal file
40
src/routes/index.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @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 { hashRoutes } from './hashRoutes.js';
|
||||
import { receiptRoutes } from './receiptRoutes.js';
|
||||
import { objectRoutes, publicShareRoutes } from './objectRoutes.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);
|
||||
apiRoutes.use('/v1/shares', publicShareRoutes);
|
||||
|
||||
// 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);
|
||||
protectedRoutes.use('/hashes', hashRoutes);
|
||||
protectedRoutes.use('/receipts', receiptRoutes);
|
||||
protectedRoutes.use('/objects', objectRoutes);
|
||||
|
||||
apiRoutes.use('/v1', protectedRoutes);
|
||||
125
src/routes/objectRoutes.js
Normal file
125
src/routes/objectRoutes.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Encrypted LTS mock + share grants.
|
||||
* @module routes/objectRoutes
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler, AppError } from '../errors.js';
|
||||
import {
|
||||
putBlob,
|
||||
readBlobPlaintext,
|
||||
createShare,
|
||||
redeemShare,
|
||||
putTree,
|
||||
createTreeShare,
|
||||
} from '../store/blobs.js';
|
||||
import { createTimestamp } from '../services/timestampService.js';
|
||||
|
||||
export const objectRoutes = Router();
|
||||
|
||||
objectRoutes.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { filename, contentBase64, sha256, publicMetadata, privateMetadata, data } = req.body ?? {};
|
||||
if (!contentBase64 && !data) {
|
||||
throw new AppError('contentBase64 or data is required', { status: 400, code: 'VALIDATION_ERROR' });
|
||||
}
|
||||
const bytes = Buffer.from(String(contentBase64 ?? data), contentBase64 ? 'base64' : 'utf8');
|
||||
const stamp = await createTimestamp(req.auth, {
|
||||
data: data ?? bytes.toString('utf8').slice(0, 1024),
|
||||
sha256,
|
||||
publicMetadata,
|
||||
privateMetadata,
|
||||
});
|
||||
const stored = putBlob({
|
||||
tenantId: req.auth.tenantId,
|
||||
sha256: stamp.sha256 ?? sha256 ?? null,
|
||||
filename,
|
||||
bytes,
|
||||
publicMetadata,
|
||||
privateMetadata,
|
||||
});
|
||||
res.status(201).json({ ...stored, jobId: stamp.jobId, existing: stamp.existing });
|
||||
}),
|
||||
);
|
||||
|
||||
objectRoutes.get(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const rec = readBlobPlaintext(req.params.id, req.auth.tenantId);
|
||||
if (!rec) throw new AppError('Object not found', { status: 404, code: 'NOT_FOUND' });
|
||||
res.json({
|
||||
id: rec.id,
|
||||
filename: rec.filename,
|
||||
size: rec.size,
|
||||
sha256: rec.sha256,
|
||||
publicMetadata: rec.publicMetadata,
|
||||
privateMetadata: rec.privateMetadata,
|
||||
contentBase64: rec.bytes.toString('base64'),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'FORBIDDEN') {
|
||||
throw new AppError('Forbidden', { status: 403, code: 'FORBIDDEN' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
objectRoutes.post(
|
||||
'/trees',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { prefix, files } = req.body ?? {};
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
throw new AppError('files array is required', { status: 400, code: 'VALIDATION_ERROR' });
|
||||
}
|
||||
const tree = putTree({
|
||||
tenantId: req.auth.tenantId,
|
||||
prefix: prefix || '/',
|
||||
files: files.map((f) => ({
|
||||
path: String(f.path || f.filename || 'file.bin'),
|
||||
bytes: Buffer.from(String(f.contentBase64 || f.data || ''), f.contentBase64 ? 'base64' : 'utf8'),
|
||||
sha256: f.sha256,
|
||||
})),
|
||||
});
|
||||
res.status(201).json(tree);
|
||||
}),
|
||||
);
|
||||
|
||||
objectRoutes.post(
|
||||
'/trees/:id/share',
|
||||
asyncHandler(async (req, res) => {
|
||||
const share = createTreeShare({
|
||||
treeId: req.params.id,
|
||||
fromTenantId: req.auth.tenantId,
|
||||
label: req.body?.label,
|
||||
});
|
||||
if (!share) throw new AppError('Tree not found', { status: 404, code: 'NOT_FOUND' });
|
||||
res.status(201).json(share);
|
||||
}),
|
||||
);
|
||||
|
||||
objectRoutes.post(
|
||||
'/:id/share',
|
||||
asyncHandler(async (req, res) => {
|
||||
const share = createShare({
|
||||
blobId: req.params.id,
|
||||
fromTenantId: req.auth.tenantId,
|
||||
label: req.body?.label,
|
||||
});
|
||||
if (!share) throw new AppError('Object not found', { status: 404, code: 'NOT_FOUND' });
|
||||
res.status(201).json(share);
|
||||
}),
|
||||
);
|
||||
|
||||
export const publicShareRoutes = Router();
|
||||
|
||||
publicShareRoutes.get(
|
||||
'/:token',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = redeemShare(req.params.token);
|
||||
if (!payload) throw new AppError('Share not found', { status: 404, code: 'NOT_FOUND' });
|
||||
res.json(payload);
|
||||
}),
|
||||
);
|
||||
45
src/routes/receiptRoutes.js
Normal file
45
src/routes/receiptRoutes.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Mock certified retrieval receipt (JSON). Not a payment invoice.
|
||||
* @module routes/receiptRoutes
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler, AppError } from '../errors.js';
|
||||
import { getJobStatus } from '../services/timestampService.js';
|
||||
import { buildReceiptPdf } from '../lib/receiptPdf.js';
|
||||
|
||||
export const receiptRoutes = Router();
|
||||
|
||||
receiptRoutes.get(
|
||||
'/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { jobId } = req.params;
|
||||
const format = String(req.query.format || 'json').toLowerCase();
|
||||
const status = await getJobStatus(req.auth, jobId);
|
||||
if (status.status !== 'completed') {
|
||||
throw new AppError('Receipt only for completed jobs', { status: 409, code: 'NOT_COMPLETE' });
|
||||
}
|
||||
const retrievedAt = new Date().toISOString();
|
||||
const receipt = {
|
||||
type: 'verae.retrieval-receipt',
|
||||
format: 'json',
|
||||
jobId,
|
||||
sha256: status.sha256 ?? status.metadata?.sha256,
|
||||
timestamp: status.completedAt ?? status.metadata?.timestamp,
|
||||
certificate: status.result,
|
||||
extraSeal: {
|
||||
event: 'document.retrieved',
|
||||
retrievedAt,
|
||||
tenantId: req.auth.tenantId,
|
||||
},
|
||||
};
|
||||
if (format === 'pdf') {
|
||||
const pdf = buildReceiptPdf({ ...receipt, format: 'pdf' });
|
||||
res.setHeader('content-type', 'application/pdf');
|
||||
res.setHeader('content-disposition', `attachment; filename="verae-receipt-${jobId}.pdf"`);
|
||||
res.send(pdf);
|
||||
return;
|
||||
}
|
||||
res.json(receipt);
|
||||
}),
|
||||
);
|
||||
51
src/routes/statusRoutes.js
Normal file
51
src/routes/statusRoutes.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* @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);
|
||||
if (req.query.scope === 'public' && result.metadata) {
|
||||
const { publicMetadata, ...restMeta } = result.metadata;
|
||||
result.metadata = { ...restMeta, publicMetadata };
|
||||
delete result.privateMetadata;
|
||||
}
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
46
src/routes/tenantRoutes.js
Normal file
46
src/routes/tenantRoutes.js
Normal 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() });
|
||||
}),
|
||||
);
|
||||
61
src/routes/timestampRoutes.js
Normal file
61
src/routes/timestampRoutes.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @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, sha256, publicMetadata, privateMetadata } = req.body ?? {};
|
||||
if (!data && !sha256) {
|
||||
throw new AppError('data or sha256 is required', { status: 400, code: 'VALIDATION_ERROR' });
|
||||
}
|
||||
const result = await createTimestamp(req.auth, {
|
||||
data,
|
||||
hashAlg,
|
||||
sha256,
|
||||
publicMetadata,
|
||||
privateMetadata,
|
||||
});
|
||||
res.status(202).json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
timestampRoutes.post(
|
||||
'/wait',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { data, hashAlg, sha256, publicMetadata, privateMetadata } = req.body ?? {};
|
||||
if (!data && !sha256) {
|
||||
throw new AppError('data or sha256 is required', { status: 400, code: 'VALIDATION_ERROR' });
|
||||
}
|
||||
const result = await createTimestampAndWait(req.auth, {
|
||||
data,
|
||||
hashAlg,
|
||||
sha256,
|
||||
publicMetadata,
|
||||
privateMetadata,
|
||||
});
|
||||
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);
|
||||
}),
|
||||
);
|
||||
36
src/routes/verifyRoutes.js
Normal file
36
src/routes/verifyRoutes.js
Normal 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);
|
||||
}),
|
||||
);
|
||||
44
src/routes/webhookRoutes.js
Normal file
44
src/routes/webhookRoutes.js
Normal 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 });
|
||||
}),
|
||||
);
|
||||
141
src/services/authService.js
Normal file
141
src/services/authService.js
Normal 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,
|
||||
};
|
||||
}
|
||||
160
src/services/entitlementService.js
Normal file
160
src/services/entitlementService.js
Normal 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 });
|
||||
}
|
||||
137
src/services/tenantService.js
Normal file
137
src/services/tenantService.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
169
src/services/timestampService.js
Normal file
169
src/services/timestampService.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* @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, sha256?: string, publicMetadata?: object, privateMetadata?: object }} body
|
||||
* @returns {Promise<{ jobId: string, sha256?: string, existing?: boolean }>}
|
||||
*/
|
||||
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);
|
||||
|
||||
if (config.natsEnabled) {
|
||||
const { waitForJobEvent } = await import('../nats/wait.js');
|
||||
const event = await waitForJobEvent(created.jobId, config.waitTimeoutMs);
|
||||
if (event?.status) {
|
||||
recordUsage(ctx.tenantId, 'status');
|
||||
log.debug('wait via NATS event', {
|
||||
jobId: created.jobId,
|
||||
event: event.event,
|
||||
traceId: event.traceId,
|
||||
});
|
||||
return event.status;
|
||||
}
|
||||
const status = await veraeClient.getStatus(ctx.veraeToken, created.jobId);
|
||||
recordUsage(ctx.tenantId, 'status');
|
||||
if (status.status === 'pending') {
|
||||
return { id: created.jobId, jobId: created.jobId, status: 'pending' };
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await veraeClient.waitForJob(ctx.veraeToken, created.jobId, {
|
||||
maxAttempts: config.jobPollMaxAttempts,
|
||||
intervalMs: config.jobPollIntervalMs,
|
||||
});
|
||||
recordUsage(ctx.tenantId, 'status');
|
||||
return status;
|
||||
} catch {
|
||||
const status = await veraeClient.getStatus(ctx.veraeToken, created.jobId);
|
||||
recordUsage(ctx.tenantId, 'status');
|
||||
return { id: created.jobId, jobId: created.jobId, status: status.status ?? 'pending' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {string} sha256
|
||||
* @param {{ includeAttached?: boolean, includeTree?: boolean }} [opts]
|
||||
*/
|
||||
export async function lookupHash(ctx, sha256, opts = {}) {
|
||||
const result = await veraeClient.lookupHash(ctx.veraeToken, sha256);
|
||||
recordUsage(ctx.tenantId, 'status');
|
||||
if (!opts.includeAttached && !opts.includeTree) return result;
|
||||
return {
|
||||
...result,
|
||||
includeAttached: Boolean(opts.includeAttached),
|
||||
includeTree: Boolean(opts.includeTree),
|
||||
note:
|
||||
opts.includeTree && !result?.exists
|
||||
? 'Chain miss: middleware should broadcast verae.archive.query kinds=["tree"] (wired in simulator; live aggregator hook TBD).'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
34
src/services/verifyService.js
Normal file
34
src/services/verifyService.js
Normal 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;
|
||||
}
|
||||
70
src/services/webhookService.js
Normal file
70
src/services/webhookService.js
Normal 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 };
|
||||
}
|
||||
208
src/store/blobs.js
Normal file
208
src/store/blobs.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Mock encrypted long-term object store + share grants.
|
||||
* @module store/blobs
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { getStore, persist } from './db.js';
|
||||
|
||||
const ALGO = 'aes-256-gcm';
|
||||
|
||||
function ensure() {
|
||||
const store = getStore();
|
||||
store.blobs ??= {};
|
||||
store.shares ??= {};
|
||||
store.trees ??= {};
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} plaintext
|
||||
* @param {Buffer} key 32 bytes
|
||||
*/
|
||||
function encrypt(plaintext, key) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return { iv: iv.toString('base64'), tag: tag.toString('base64'), ciphertext: ciphertext.toString('base64') };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ iv: string, tag: string, ciphertext: string }} enc
|
||||
* @param {Buffer} key
|
||||
*/
|
||||
export function decryptBlob(enc, key) {
|
||||
const decipher = createDecipheriv(ALGO, key, Buffer.from(enc.iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(enc.tag, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(enc.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.tenantId
|
||||
* @param {string} [params.sha256]
|
||||
* @param {string} [params.filename]
|
||||
* @param {Buffer} params.bytes
|
||||
* @param {object} [params.publicMetadata]
|
||||
* @param {object} [params.privateMetadata]
|
||||
*/
|
||||
export function putBlob({ tenantId, sha256, filename, bytes, publicMetadata = {}, privateMetadata = {} }) {
|
||||
const store = ensure();
|
||||
const id = randomUUID();
|
||||
const key = randomBytes(32);
|
||||
const enc = encrypt(bytes, key);
|
||||
store.blobs[id] = {
|
||||
id,
|
||||
tenantId,
|
||||
sha256: sha256 ?? null,
|
||||
filename: filename ?? 'object.bin',
|
||||
size: bytes.length,
|
||||
enc,
|
||||
dek: key.toString('base64'),
|
||||
publicMetadata,
|
||||
privateMetadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
persist();
|
||||
return { id, sha256: store.blobs[id].sha256, size: bytes.length, filename: store.blobs[id].filename };
|
||||
}
|
||||
|
||||
export function getBlobRecord(id) {
|
||||
return ensure().blobs[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt for owner tenant only.
|
||||
* @param {string} id
|
||||
* @param {string} tenantId
|
||||
*/
|
||||
export function readBlobPlaintext(id, tenantId) {
|
||||
const rec = getBlobRecord(id);
|
||||
if (!rec) return null;
|
||||
if (rec.tenantId !== tenantId) {
|
||||
const err = new Error('Forbidden');
|
||||
err.code = 'FORBIDDEN';
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
...rec,
|
||||
bytes: decryptBlob(rec.enc, Buffer.from(rec.dek, 'base64')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ blobId: string, fromTenantId: string, label?: string }} params
|
||||
*/
|
||||
export function createShare({ blobId, fromTenantId, label }) {
|
||||
const rec = getBlobRecord(blobId);
|
||||
if (!rec || rec.tenantId !== fromTenantId) return null;
|
||||
const token = `shr_${randomBytes(16).toString('hex')}`;
|
||||
const store = ensure();
|
||||
store.shares[token] = {
|
||||
token,
|
||||
kind: 'file',
|
||||
blobId,
|
||||
fromTenantId,
|
||||
label: label ?? rec.filename,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
persist();
|
||||
return { token, blobId, label: store.shares[token].label };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a share token: ciphertext + wrapped key for the recipient.
|
||||
* @param {string} token
|
||||
*/
|
||||
export function redeemShare(token) {
|
||||
const store = ensure();
|
||||
const share = store.shares[token];
|
||||
if (!share) return null;
|
||||
if (share.kind === 'tree') {
|
||||
const tree = store.trees[share.treeId];
|
||||
if (!tree) return null;
|
||||
return {
|
||||
token,
|
||||
kind: 'tree',
|
||||
prefix: tree.prefix,
|
||||
files: tree.entries.map((e) => {
|
||||
const rec = store.blobs[e.blobId];
|
||||
return rec
|
||||
? {
|
||||
path: e.path,
|
||||
blobId: rec.id,
|
||||
filename: rec.filename,
|
||||
size: rec.size,
|
||||
sha256: rec.sha256,
|
||||
enc: rec.enc,
|
||||
dek: rec.dek,
|
||||
}
|
||||
: { path: e.path, missing: true };
|
||||
}),
|
||||
note: 'Mock tree share: each file includes dek. Production must wrap per-file deks to recipient pubkey.',
|
||||
};
|
||||
}
|
||||
const rec = store.blobs[share.blobId];
|
||||
if (!rec) return null;
|
||||
return {
|
||||
token,
|
||||
kind: 'file',
|
||||
blobId: rec.id,
|
||||
filename: rec.filename,
|
||||
size: rec.size,
|
||||
sha256: rec.sha256,
|
||||
publicMetadata: rec.publicMetadata ?? {},
|
||||
enc: rec.enc,
|
||||
dek: rec.dek,
|
||||
note: 'Mock share: dek is included so the recipient can decrypt. Production must wrap dek to recipient pubkey.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ tenantId: string, prefix: string, files: Array<{ path: string, bytes: Buffer, sha256?: string }> }} params
|
||||
*/
|
||||
export function putTree({ tenantId, prefix, files }) {
|
||||
const store = ensure();
|
||||
const id = randomUUID();
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
const stored = putBlob({
|
||||
tenantId,
|
||||
sha256: file.sha256,
|
||||
filename: file.path.split('/').pop(),
|
||||
bytes: file.bytes,
|
||||
});
|
||||
entries.push({ path: file.path, blobId: stored.id });
|
||||
}
|
||||
store.trees[id] = { id, tenantId, prefix: prefix || '/', entries, createdAt: new Date().toISOString() };
|
||||
persist();
|
||||
return { id, prefix: store.trees[id].prefix, fileCount: entries.length };
|
||||
}
|
||||
|
||||
export function getTree(id) {
|
||||
return ensure().trees[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ treeId: string, fromTenantId: string, label?: string }} params
|
||||
*/
|
||||
export function createTreeShare({ treeId, fromTenantId, label }) {
|
||||
const tree = getTree(treeId);
|
||||
if (!tree || tree.tenantId !== fromTenantId) return null;
|
||||
const token = `shr_${randomBytes(16).toString('hex')}`;
|
||||
const store = ensure();
|
||||
store.shares[token] = {
|
||||
token,
|
||||
kind: 'tree',
|
||||
treeId,
|
||||
fromTenantId,
|
||||
label: label ?? tree.prefix,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
persist();
|
||||
return { token, kind: 'tree', treeId, label: store.shares[token].label, fileCount: tree.entries.length };
|
||||
}
|
||||
117
src/store/db.js
Normal file
117
src/store/db.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* @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: [],
|
||||
blobs: {},
|
||||
shares: {},
|
||||
trees: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
89
src/store/jobWatchers.js
Normal file
89
src/store/jobWatchers.js
Normal 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 });
|
||||
}
|
||||
137
src/store/tenants.js
Normal file
137
src/store/tenants.js
Normal 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: {} };
|
||||
}
|
||||
102
src/store/usage.js
Normal file
102
src/store/usage.js
Normal 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 });
|
||||
}
|
||||
91
src/store/webhooks.js
Normal file
91
src/store/webhooks.js
Normal 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);
|
||||
}
|
||||
108
src/workers/inProcessJobPoller.js
Normal file
108
src/workers/inProcessJobPoller.js
Normal 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');
|
||||
}
|
||||
158
src/workers/jobPollerWorker.js
Normal file
158
src/workers/jobPollerWorker.js
Normal 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');
|
||||
}
|
||||
138
src/workers/webhookWorker.js
Normal file
138
src/workers/webhookWorker.js
Normal 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');
|
||||
}
|
||||
93
test/helpers.js
Normal file
93
test/helpers.js
Normal 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 };
|
||||
76
test/integration/auth.test.js
Normal file
76
test/integration/auth.test.js
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
97
test/integration/hash.test.js
Normal file
97
test/integration/hash.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Hash idempotent register + lookup (mock Verae)
|
||||
*/
|
||||
|
||||
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 { clearMockJobs, sha256Hex } from '../../src/clients/veraeClient.js';
|
||||
import { config } from '../../src/config.js';
|
||||
|
||||
describe('hash register + lookup', () => {
|
||||
let ctx;
|
||||
/** @type {import('http').Server} */
|
||||
let server;
|
||||
let port;
|
||||
let apiKey;
|
||||
|
||||
before(async () => {
|
||||
config.mockVerae = true;
|
||||
config.natsEnabled = false;
|
||||
clearMockJobs();
|
||||
ctx = useTempStore();
|
||||
const seeded = seedProTenant();
|
||||
apiKey = seeded.apiKey;
|
||||
const app = createApp();
|
||||
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('registers sha256, second create returns original jobId', async () => {
|
||||
const data = 'hello-hash';
|
||||
const sha = sha256Hex(data);
|
||||
const r1 = await json(port, 'POST', '/zapier/v1/timestamp', apiKey, { data });
|
||||
assert.equal(r1.status, 202);
|
||||
const r2 = await json(port, 'POST', '/zapier/v1/timestamp', apiKey, { sha256: sha });
|
||||
assert.equal(r2.status, 202);
|
||||
assert.equal(r2.body.jobId, r1.body.jobId);
|
||||
assert.equal(r2.body.existing, true);
|
||||
|
||||
const look = await json(port, 'GET', `/zapier/v1/hashes/${sha}`, apiKey);
|
||||
assert.equal(look.status, 200);
|
||||
assert.equal(look.body.exists, true);
|
||||
assert.equal(look.body.jobId, r1.body.jobId);
|
||||
});
|
||||
|
||||
it('lookup miss is 404', async () => {
|
||||
const miss = 'a'.repeat(64);
|
||||
const look = await json(port, 'GET', `/zapier/v1/hashes/${miss}`, apiKey);
|
||||
assert.equal(look.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
function json(port, method, path, apiKey, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => {
|
||||
data += c;
|
||||
});
|
||||
res.on('end', () => {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = data ? JSON.parse(data) : {};
|
||||
} catch {
|
||||
parsed = { raw: data };
|
||||
}
|
||||
resolve({ status: res.statusCode, body: parsed });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
164
test/integration/http-api.test.js
Normal file
164
test/integration/http-api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
108
test/integration/nats-workers.test.js
Normal file
108
test/integration/nats-workers.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
117
test/integration/nats.test.js
Normal file
117
test/integration/nats.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
159
test/integration/objects.test.js
Normal file
159
test/integration/objects.test.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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, seedFreeTenant } from '../helpers.js';
|
||||
import { clearMockJobs } from '../../src/clients/veraeClient.js';
|
||||
import { config } from '../../src/config.js';
|
||||
|
||||
describe('encrypted objects + share + pdf receipt', () => {
|
||||
let ctx;
|
||||
let server;
|
||||
let port;
|
||||
let ownerKey;
|
||||
let otherKey;
|
||||
|
||||
before(async () => {
|
||||
config.mockVerae = true;
|
||||
config.natsEnabled = false;
|
||||
config.mockJobCompleteMs = 40;
|
||||
clearMockJobs();
|
||||
ctx = useTempStore();
|
||||
ownerKey = seedProTenant().apiKey;
|
||||
otherKey = seedFreeTenant({ id: 'tenant-other', veraeUsername: 'other', veraePassword: 'otherpass' }).apiKey;
|
||||
const app = createApp();
|
||||
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('stores encrypted blob, hides it from other tenants, shares via token', async () => {
|
||||
const created = await call(port, 'POST', '/zapier/v1/objects', ownerKey, {
|
||||
filename: 'secret.txt',
|
||||
contentBase64: Buffer.from('top-secret').toString('base64'),
|
||||
publicMetadata: { title: 'pub' },
|
||||
privateMetadata: { ssn: '000' },
|
||||
});
|
||||
assert.equal(created.status, 201);
|
||||
const id = created.body.id;
|
||||
|
||||
const mine = await call(port, 'GET', `/zapier/v1/objects/${id}`, ownerKey);
|
||||
assert.equal(mine.status, 200);
|
||||
assert.equal(Buffer.from(mine.body.contentBase64, 'base64').toString(), 'top-secret');
|
||||
assert.equal(mine.body.privateMetadata.ssn, '000');
|
||||
|
||||
const theirs = await call(port, 'GET', `/zapier/v1/objects/${id}`, otherKey);
|
||||
assert.equal(theirs.status, 403);
|
||||
|
||||
const share = await call(port, 'POST', `/zapier/v1/objects/${id}/share`, ownerKey, { label: 'for-other' });
|
||||
assert.equal(share.status, 201);
|
||||
const redeemed = await call(port, 'GET', `/zapier/v1/shares/${share.body.token}`, otherKey);
|
||||
assert.equal(redeemed.status, 200);
|
||||
assert.equal(redeemed.body.filename, 'secret.txt');
|
||||
assert.ok(redeemed.body.dek);
|
||||
assert.equal(redeemed.body.publicMetadata.title, 'pub');
|
||||
});
|
||||
|
||||
it('shares a directory tree of encrypted files', async () => {
|
||||
const tree = await call(port, 'POST', '/zapier/v1/objects/trees', ownerKey, {
|
||||
prefix: 'docs/',
|
||||
files: [
|
||||
{ path: 'docs/a.txt', data: 'alpha' },
|
||||
{ path: 'docs/b.txt', data: 'beta' },
|
||||
],
|
||||
});
|
||||
assert.equal(tree.status, 201);
|
||||
assert.equal(tree.body.fileCount, 2);
|
||||
const share = await call(port, 'POST', `/zapier/v1/objects/trees/${tree.body.id}/share`, ownerKey, {});
|
||||
assert.equal(share.status, 201);
|
||||
assert.equal(share.body.kind, 'tree');
|
||||
const redeemed = await call(port, 'GET', `/zapier/v1/shares/${share.body.token}`, otherKey);
|
||||
assert.equal(redeemed.status, 200);
|
||||
assert.equal(redeemed.body.kind, 'tree');
|
||||
assert.equal(redeemed.body.files.length, 2);
|
||||
assert.ok(redeemed.body.files[0].dek);
|
||||
});
|
||||
|
||||
it('hash lookup never includes privateMetadata', async () => {
|
||||
const created = await call(port, 'POST', '/zapier/v1/timestamp', ownerKey, {
|
||||
data: 'meta-private',
|
||||
publicMetadata: { vis: 'yes' },
|
||||
privateMetadata: { secret: 'nope' },
|
||||
});
|
||||
const sha = created.body.sha256;
|
||||
const look = await call(port, 'GET', `/zapier/v1/hashes/${sha}`, ownerKey);
|
||||
assert.equal(look.status, 200);
|
||||
assert.equal(look.body.publicMetadata.vis, 'yes');
|
||||
assert.equal(look.body.privateMetadata, undefined);
|
||||
const st = await call(port, 'GET', `/zapier/v1/status/${created.body.jobId}`, ownerKey);
|
||||
assert.equal(st.body.privateMetadata.secret, 'nope');
|
||||
});
|
||||
|
||||
it('returns a PDF receipt starting with %PDF', async () => {
|
||||
const wait = await call(port, 'POST', '/zapier/v1/timestamp/wait', ownerKey, { data: 'pdf-receipt' });
|
||||
const jobId = wait.body.id;
|
||||
const rec = await raw(port, 'GET', `/zapier/v1/receipts/${jobId}?format=pdf`, ownerKey);
|
||||
assert.equal(rec.status, 200);
|
||||
assert.match(rec.body.toString('latin1').slice(0, 5), /%PDF-/);
|
||||
});
|
||||
});
|
||||
|
||||
function call(port, method, path, apiKey, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method,
|
||||
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = buf.length ? JSON.parse(buf.toString()) : {};
|
||||
} catch {
|
||||
parsed = { raw: buf.toString() };
|
||||
}
|
||||
resolve({ status: res.statusCode, body: parsed });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function raw(port, method, path, apiKey) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method,
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
72
test/integration/receipt.test.js
Normal file
72
test/integration/receipt.test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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 { clearMockJobs } from '../../src/clients/veraeClient.js';
|
||||
import { config } from '../../src/config.js';
|
||||
|
||||
describe('retrieval receipt JSON', () => {
|
||||
let ctx;
|
||||
let server;
|
||||
let port;
|
||||
let apiKey;
|
||||
|
||||
before(async () => {
|
||||
config.mockVerae = true;
|
||||
config.natsEnabled = false;
|
||||
config.mockJobCompleteMs = 50;
|
||||
clearMockJobs();
|
||||
ctx = useTempStore();
|
||||
apiKey = seedProTenant().apiKey;
|
||||
const app = createApp();
|
||||
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('issues a JSON extra-seal receipt for a completed job', async () => {
|
||||
const created = await call(port, 'POST', '/zapier/v1/timestamp/wait', apiKey, { data: 'receipt-me' });
|
||||
assert.equal(created.body.status, 'completed');
|
||||
const jobId = created.body.id;
|
||||
const rec = await call(port, 'GET', `/zapier/v1/receipts/${jobId}?format=json`, apiKey);
|
||||
assert.equal(rec.status, 200);
|
||||
assert.equal(rec.body.type, 'verae.retrieval-receipt');
|
||||
assert.equal(rec.body.extraSeal.event, 'document.retrieved');
|
||||
assert.equal(rec.body.jobId, jobId);
|
||||
});
|
||||
});
|
||||
|
||||
function call(port, method, path, apiKey, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method,
|
||||
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => {
|
||||
data += c;
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode, body: data ? JSON.parse(data) : {} });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
93
test/integration/tenants.test.js
Normal file
93
test/integration/tenants.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
67
test/integration/wait-nats.test.js
Normal file
67
test/integration/wait-nats.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* GATE 9 — /timestamp/wait via NATS events (timeout returns pending + jobId)
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { config } from '../../src/config.js';
|
||||
import { useTempStore, seedProTenant } from '../helpers.js';
|
||||
import { connectNats, ensureStreams, closeNats } from '../../src/nats/connection.js';
|
||||
import { startJobPollerWorker, stopJobPollerWorker } from '../../src/workers/jobPollerWorker.js';
|
||||
import { createTimestampAndWait } from '../../src/services/timestampService.js';
|
||||
import { resolveAuthContext } from '../../src/services/authService.js';
|
||||
import { clearMockJobs } from '../../src/clients/veraeClient.js';
|
||||
|
||||
describe('GATE 9 wait via NATS', () => {
|
||||
/** @type {ReturnType<typeof useTempStore>} */
|
||||
let ctx;
|
||||
let authCtx;
|
||||
|
||||
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 = 40;
|
||||
config.jobPollMaxAttempts = 40;
|
||||
config.waitTimeoutMs = 8000;
|
||||
config.mockJobCompleteMs = 80;
|
||||
|
||||
clearMockJobs();
|
||||
ctx = useTempStore();
|
||||
const seeded = seedProTenant();
|
||||
authCtx = await resolveAuthContext(seeded.apiKey);
|
||||
|
||||
await connectNats(config.natsUrl);
|
||||
await ensureStreams();
|
||||
await startJobPollerWorker();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopJobPollerWorker();
|
||||
await closeNats();
|
||||
ctx.cleanup();
|
||||
process.env.NATS_FORCE_CONNECT = '';
|
||||
});
|
||||
|
||||
it('wait returns completed when worker finishes before timeout', async () => {
|
||||
const status = await createTimestampAndWait(authCtx, { data: 'wait-fast' });
|
||||
assert.equal(status.status, 'completed');
|
||||
assert.ok(status.id || status.jobId);
|
||||
});
|
||||
|
||||
it('wait returns pending + jobId on timeout', async () => {
|
||||
const prevWait = config.waitTimeoutMs;
|
||||
const prevDelay = config.mockJobCompleteMs;
|
||||
config.waitTimeoutMs = 60;
|
||||
config.mockJobCompleteMs = 30_000;
|
||||
try {
|
||||
const status = await createTimestampAndWait(authCtx, { data: 'wait-slow' });
|
||||
assert.equal(status.status, 'pending');
|
||||
assert.ok(status.jobId || status.id);
|
||||
} finally {
|
||||
config.waitTimeoutMs = prevWait;
|
||||
config.mockJobCompleteMs = prevDelay;
|
||||
}
|
||||
});
|
||||
});
|
||||
49
test/unit/app.test.js
Normal file
49
test/unit/app.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* 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.equal(typeof body.mockVerae, 'boolean');
|
||||
assert.equal(typeof body.natsEnabled, 'boolean');
|
||||
assert.equal(typeof body.natsConnected, 'boolean');
|
||||
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');
|
||||
});
|
||||
});
|
||||
34
test/unit/config.test.js
Normal file
34
test/unit/config.test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 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',
|
||||
'waitTimeoutMs',
|
||||
'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');
|
||||
}
|
||||
});
|
||||
});
|
||||
165
test/unit/debug.test.js
Normal file
165
test/unit/debug.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
105
test/unit/store.test.js
Normal file
105
test/unit/store.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
55
test/unit/tokens.test.js
Normal file
55
test/unit/tokens.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
64
test/unit/veraeClient.test.js
Normal file
64
test/unit/veraeClient.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue