commit 7fe9f616fa8ffa2296c328f670c91ca025a254b0 Author: George Lambert Date: Fri Sep 11 13:37:20 2026 -0400 Initial import of verae-middleware from zapier monorepo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b7dc81e --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5bbafaf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..abe42c8 --- /dev/null +++ b/NATS.md @@ -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. diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..7b275df --- /dev/null +++ b/SUMMARY.md @@ -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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e38f17d --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f89495 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..2048b9b --- /dev/null +++ b/src/app.js @@ -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; +} diff --git a/src/clients/veraeClient.js b/src/clients/veraeClient.js new file mode 100644 index 0000000..b27c644 --- /dev/null +++ b/src/clients/veraeClient.js @@ -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} */ +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} + */ +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} + */ +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(); +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..c52e160 --- /dev/null +++ b/src/config.js @@ -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} + */ +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, +}); diff --git a/src/debug/config.js b/src/debug/config.js new file mode 100644 index 0000000..d9c8594 --- /dev/null +++ b/src/debug/config.js @@ -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|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|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 }; diff --git a/src/debug/index.js b/src/debug/index.js new file mode 100644 index 0000000..a476ed2 --- /dev/null +++ b/src/debug/index.js @@ -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'; diff --git a/src/debug/logger.js b/src/debug/logger.js new file mode 100644 index 0000000..2fcd35c --- /dev/null +++ b/src/debug/logger.js @@ -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) => void} debug + * @property {(msg: string, meta?: Record) => void} info + * @property {(msg: string, meta?: Record) => void} warn + * @property {(msg: string, meta?: Record) => 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|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} [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), + }; +} diff --git a/src/debug/redact.js b/src/debug/redact.js new file mode 100644 index 0000000..14be1d0 --- /dev/null +++ b/src/debug/redact.js @@ -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} */ + 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); +} diff --git a/src/debug/trace-context.js b/src/debug/trace-context.js new file mode 100644 index 0000000..0abce6c --- /dev/null +++ b/src/debug/trace-context.js @@ -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} */ +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; +} diff --git a/src/debug/trace.js b/src/debug/trace.js new file mode 100644 index 0000000..4e4ba8e --- /dev/null +++ b/src/debug/trace.js @@ -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)} 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} [fn] - Work to execute inside the context when options is an object. + * @returns {T|Promise} 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(); + }); +} diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..001f714 --- /dev/null +++ b/src/errors.js @@ -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} 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 } : {}), + }); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4d94e12 --- /dev/null +++ b/src/index.js @@ -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(); +} diff --git a/src/lib/receiptPdf.js b/src/lib/receiptPdf.js new file mode 100644 index 0000000..5a64c7b --- /dev/null +++ b/src/lib/receiptPdf.js @@ -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'); +} diff --git a/src/lib/tokens.js b/src/lib/tokens.js new file mode 100644 index 0000000..b70729d --- /dev/null +++ b/src/lib/tokens.js @@ -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; +} diff --git a/src/middleware/authenticate.js b/src/middleware/authenticate.js new file mode 100644 index 0000000..87ee129 --- /dev/null +++ b/src/middleware/authenticate.js @@ -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(); +}); diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..7c22766 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -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} */ +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(); +}); diff --git a/src/nats/connection.js b/src/nats/connection.js new file mode 100644 index 0000000..5630b38 --- /dev/null +++ b/src/nats/connection.js @@ -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} + */ +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} + */ +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()); +} diff --git a/src/nats/publishers.js b/src/nats/publishers.js new file mode 100644 index 0000000..0d1ac26 --- /dev/null +++ b/src/nats/publishers.js @@ -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} + */ +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) }; +} diff --git a/src/nats/subjects.js b/src/nats/subjects.js new file mode 100644 index 0000000..b308b7f --- /dev/null +++ b/src/nats/subjects.js @@ -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', +}); diff --git a/src/nats/wait.js b/src/nats/wait.js new file mode 100644 index 0000000..4dd9e6c --- /dev/null +++ b/src/nats/wait.js @@ -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} 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 */ + } + } +} diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js new file mode 100644 index 0000000..6cafbc7 --- /dev/null +++ b/src/routes/authRoutes.js @@ -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, + }); + }), +); diff --git a/src/routes/hashRoutes.js b/src/routes/hashRoutes.js new file mode 100644 index 0000000..2ec8a48 --- /dev/null +++ b/src/routes/hashRoutes.js @@ -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); + }), +); diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..abed398 --- /dev/null +++ b/src/routes/index.js @@ -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); diff --git a/src/routes/objectRoutes.js b/src/routes/objectRoutes.js new file mode 100644 index 0000000..35a7b9a --- /dev/null +++ b/src/routes/objectRoutes.js @@ -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); + }), +); diff --git a/src/routes/receiptRoutes.js b/src/routes/receiptRoutes.js new file mode 100644 index 0000000..2685ba1 --- /dev/null +++ b/src/routes/receiptRoutes.js @@ -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); + }), +); diff --git a/src/routes/statusRoutes.js b/src/routes/statusRoutes.js new file mode 100644 index 0000000..84d3997 --- /dev/null +++ b/src/routes/statusRoutes.js @@ -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); + }), +); diff --git a/src/routes/tenantRoutes.js b/src/routes/tenantRoutes.js new file mode 100644 index 0000000..21f174f --- /dev/null +++ b/src/routes/tenantRoutes.js @@ -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() }); + }), +); diff --git a/src/routes/timestampRoutes.js b/src/routes/timestampRoutes.js new file mode 100644 index 0000000..ca4dc87 --- /dev/null +++ b/src/routes/timestampRoutes.js @@ -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); + }), +); diff --git a/src/routes/verifyRoutes.js b/src/routes/verifyRoutes.js new file mode 100644 index 0000000..800c50a --- /dev/null +++ b/src/routes/verifyRoutes.js @@ -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); + }), +); diff --git a/src/routes/webhookRoutes.js b/src/routes/webhookRoutes.js new file mode 100644 index 0000000..6a8cca9 --- /dev/null +++ b/src/routes/webhookRoutes.js @@ -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 }); + }), +); diff --git a/src/services/authService.js b/src/services/authService.js new file mode 100644 index 0000000..5e3a6e2 --- /dev/null +++ b/src/services/authService.js @@ -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>>} + */ +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} + */ +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, + }; +} diff --git a/src/services/entitlementService.js b/src/services/entitlementService.js new file mode 100644 index 0000000..a543aa2 --- /dev/null +++ b/src/services/entitlementService.js @@ -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 }); +} diff --git a/src/services/tenantService.js b/src/services/tenantService.js new file mode 100644 index 0000000..5e96b69 --- /dev/null +++ b/src/services/tenantService.js @@ -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} + */ +export function listProvisionedTenants() { + return listTenants().map((tenant) => ({ + id: tenant.id, + name: tenant.name, + plan: tenant.plan, + audience: tenant.metadata?.audience ?? 'unknown', + createdAt: tenant.createdAt, + })); +} diff --git a/src/services/timestampService.js b/src/services/timestampService.js new file mode 100644 index 0000000..bf110af --- /dev/null +++ b/src/services/timestampService.js @@ -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} 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, + }; +} diff --git a/src/services/verifyService.js b/src/services/verifyService.js new file mode 100644 index 0000000..352d1b1 --- /dev/null +++ b/src/services/verifyService.js @@ -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; +} diff --git a/src/services/webhookService.js b/src/services/webhookService.js new file mode 100644 index 0000000..f975fd5 --- /dev/null +++ b/src/services/webhookService.js @@ -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 }; +} diff --git a/src/store/blobs.js b/src/store/blobs.js new file mode 100644 index 0000000..d24dfe0 --- /dev/null +++ b/src/store/blobs.js @@ -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 }; +} diff --git a/src/store/db.js b/src/store/db.js new file mode 100644 index 0000000..3f1fd3a --- /dev/null +++ b/src/store/db.js @@ -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} tenants + * @property {Record} apiKeys - apiKey → tenantId + * @property {Record} 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); +} diff --git a/src/store/jobWatchers.js b/src/store/jobWatchers.js new file mode 100644 index 0000000..915c637 --- /dev/null +++ b/src/store/jobWatchers.js @@ -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} 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 }); +} diff --git a/src/store/tenants.js b/src/store/tenants.js new file mode 100644 index 0000000..09558bc --- /dev/null +++ b/src/store/tenants.js @@ -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: {} }; +} diff --git a/src/store/usage.js b/src/store/usage.js new file mode 100644 index 0000000..328d2f5 --- /dev/null +++ b/src/store/usage.js @@ -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} + */ +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 }); +} diff --git a/src/store/webhooks.js b/src/store/webhooks.js new file mode 100644 index 0000000..19a6928 --- /dev/null +++ b/src/store/webhooks.js @@ -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); +} diff --git a/src/workers/inProcessJobPoller.js b/src/workers/inProcessJobPoller.js new file mode 100644 index 0000000..beb17d8 --- /dev/null +++ b/src/workers/inProcessJobPoller.js @@ -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'); +} diff --git a/src/workers/jobPollerWorker.js b/src/workers/jobPollerWorker.js new file mode 100644 index 0000000..108c640 --- /dev/null +++ b/src/workers/jobPollerWorker.js @@ -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} + */ +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, nak: (delay?: number) => Promise }} 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 }>} + */ +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} + */ +export async function stopJobPollerWorker() { + abort?.abort(); + abort = null; + running = false; + log.info('job poller worker stopped'); +} diff --git a/src/workers/webhookWorker.js b/src/workers/webhookWorker.js new file mode 100644 index 0000000..726821d --- /dev/null +++ b/src/workers/webhookWorker.js @@ -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, nak: (d?: number) => Promise }} 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 }>} + */ +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} + */ +export async function stopWebhookWorker() { + abort?.abort(); + abort = null; + running = false; + log.info('webhook worker stopped'); +} diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..e9312be --- /dev/null +++ b/test/helpers.js @@ -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 }; diff --git a/test/integration/auth.test.js b/test/integration/auth.test.js new file mode 100644 index 0000000..4ee9a42 --- /dev/null +++ b/test/integration/auth.test.js @@ -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} */ + 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', + ); + }); +}); diff --git a/test/integration/hash.test.js b/test/integration/hash.test.js new file mode 100644 index 0000000..4b2dfed --- /dev/null +++ b/test/integration/hash.test.js @@ -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(); + }); +} diff --git a/test/integration/http-api.test.js b/test/integration/http-api.test.js new file mode 100644 index 0000000..c9f1ef0 --- /dev/null +++ b/test/integration/http-api.test.js @@ -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} */ + 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); + }); +}); diff --git a/test/integration/nats-workers.test.js b/test/integration/nats-workers.test.js new file mode 100644 index 0000000..50a15ff --- /dev/null +++ b/test/integration/nats-workers.test.js @@ -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} */ + 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); + }); +}); diff --git a/test/integration/nats.test.js b/test/integration/nats.test.js new file mode 100644 index 0000000..2e5897d --- /dev/null +++ b/test/integration/nats.test.js @@ -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; + }); +}); diff --git a/test/integration/objects.test.js b/test/integration/objects.test.js new file mode 100644 index 0000000..76a6d4d --- /dev/null +++ b/test/integration/objects.test.js @@ -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(); + }); +} diff --git a/test/integration/receipt.test.js b/test/integration/receipt.test.js new file mode 100644 index 0000000..d78b407 --- /dev/null +++ b/test/integration/receipt.test.js @@ -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(); + }); +} diff --git a/test/integration/tenants.test.js b/test/integration/tenants.test.js new file mode 100644 index 0000000..c825885 --- /dev/null +++ b/test/integration/tenants.test.js @@ -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/); + }); +}); diff --git a/test/integration/wait-nats.test.js b/test/integration/wait-nats.test.js new file mode 100644 index 0000000..eb48462 --- /dev/null +++ b/test/integration/wait-nats.test.js @@ -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} */ + 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; + } + }); +}); diff --git a/test/unit/app.test.js b/test/unit/app.test.js new file mode 100644 index 0000000..48000d7 --- /dev/null +++ b/test/unit/app.test.js @@ -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'); + }); +}); diff --git a/test/unit/config.test.js b/test/unit/config.test.js new file mode 100644 index 0000000..fed57f9 --- /dev/null +++ b/test/unit/config.test.js @@ -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'); + } + }); +}); diff --git a/test/unit/debug.test.js b/test/unit/debug.test.js new file mode 100644 index 0000000..dea09ec --- /dev/null +++ b/test/unit/debug.test.js @@ -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'); + }); +}); diff --git a/test/unit/store.test.js b/test/unit/store.test.js new file mode 100644 index 0000000..5131a21 --- /dev/null +++ b/test/unit/store.test.js @@ -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} */ + 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); + }); +}); diff --git a/test/unit/tokens.test.js b/test/unit/tokens.test.js new file mode 100644 index 0000000..8765361 --- /dev/null +++ b/test/unit/tokens.test.js @@ -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); + }); +}); diff --git a/test/unit/veraeClient.test.js b/test/unit/veraeClient.test.js new file mode 100644 index 0000000..2dd2373 --- /dev/null +++ b/test/unit/veraeClient.test.js @@ -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/); + }); +});