From 5b37842fee7095ab028759ff4952684e18f23202 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Mon, 5 Oct 2026 13:50:26 -0400 Subject: [PATCH 1/6] sync: import live /opt/verae/zappier-edge state (2026-10-05) into git --- README.md | 36 +- admin/index.html | 1 + admin/sales-rates.html | 49 + admin/sales-rates.js | 88 ++ client/README.md | 23 + client/hasher/datacube_hasher.d.ts | 100 ++ client/hasher/datacube_hasher.js | 566 ++++++++ client/hasher/datacube_hasher_bg.wasm | Bin 0 -> 48921 bytes client/hasher/index.html | 49 + client/hasher/verae_sha256.d.ts | 8 + client/hasher/verae_sha256.js | 224 ++++ client/hasher/verae_sha256_bg.wasm | Bin 0 -> 24937 bytes client/zapier-app/authentication.js | 23 + client/zapier-app/creates/store_data.js | 54 + client/zapier-app/creates/timestamp.js | 106 ++ client/zapier-app/creates/timestamps_batch.js | 98 ++ client/zapier-app/index.js | 23 + client/zapier-app/package.json | 16 + client/zapier-app/triggers/new_item.js | 24 + docs/ACCOUNTING.md | 5 +- docs/BILLING-DB.md | 26 + docs/CHANNEL-BILLING.md | 27 + docs/DEVELOPER.md | 7 +- docs/ENV-MATRIX.md | 15 + docs/LEDGER-CHAIN.md | 31 + docs/PEERGOS-BILLING.md | 74 ++ docs/USER-MANUAL.md | 7 +- docs/VERAE-BATCH-TIMESTAMP.md | 60 + docs/ZAPIER-AND-HASHER.md | 82 ++ ...026-10-03-libsql-multihost-architecture.md | 295 +++++ docs/verae-time-openapi.yaml | 1169 +++++++++++++++++ openapi.yaml | 235 +++- package-lock.json | 270 ++++ package.json | 3 + portal/app.js | 580 +++++++- portal/index.html | 84 +- src/accounts.ts | 6 + src/admin.ts | 236 +++- src/app.ts | 613 ++++++++- src/auth.ts | 105 +- src/billing-channel-resolve.ts | 27 + src/billing-channels.ts | 108 ++ src/billing-headers.ts | 49 + src/credits.ts | 40 +- src/db/api-token-repo.ts | 196 +++ src/db/business-store-adapters.ts | 86 ++ src/db/business-store.ts | 28 + src/db/channel-rate-store.ts | 114 ++ src/db/credit-adjustments.ts | 80 ++ src/db/customer-repo.ts | 121 +- src/db/db-paths.ts | 12 + src/db/ledger-chain.ts | 187 +++ src/db/libsql-business-store.ts | 543 ++++++++ src/db/migrate-billing-split.ts | 97 ++ src/db/migrate-to-libsql.ts | 356 +++++ src/db/open-business-store.ts | 127 ++ src/db/open-sqlite.ts | 18 + src/db/pricing-store.ts | 51 +- src/db/session-repo.ts | 4 + src/db/sqlite-file-business-store.ts | 120 ++ src/db/usage-repo.ts | 56 +- src/index.ts | 92 +- src/jobs/report-usage.ts | 13 +- src/mailer.ts | 78 ++ src/meter.ts | 218 ++- src/password-reset.ts | 101 ++ src/peergos-admin.ts | 432 ++++++ src/peergos-billing.ts | 189 +++ src/portal.ts | 265 +++- src/pricing.ts | 491 ++++++- src/registration-pdf.ts | 154 +++ src/seo.ts | 336 +++++ src/upstream.ts | 75 +- src/usage.ts | 9 +- tests/admin-accounting.test.ts | 12 +- tests/api-tokens.test.ts | 81 ++ tests/billing-headers.test.ts | 46 + tests/business-store.contract.test.ts | 106 ++ tests/channel-meter.test.ts | 41 + tests/channel-rates.test.ts | 31 + tests/credit-ledger-sqlite.test.ts | 59 + tests/db-customer-peergos.test.ts | 52 + tests/health-proxy-credits.test.ts | 65 +- tests/ledger-chain.test.ts | 89 ++ tests/meter-peergos.test.ts | 71 + tests/meter.test.ts | 2 +- tests/migrate-billing-split.test.ts | 97 ++ tests/migrate-to-libsql.test.ts | 238 ++++ tests/open-business-store.test.ts | 96 ++ tests/open-sqlite-wal.test.ts | 22 + tests/password-reset.test.ts | 154 +++ tests/peergos-billing.test.ts | 217 +++ tests/portal.test.ts | 14 +- tests/seo.test.ts | 47 + tests/timestamps-batch.test.ts | 35 + tests/upstream-middleware-key.test.ts | 80 ++ 96 files changed, 11616 insertions(+), 330 deletions(-) create mode 100644 admin/sales-rates.html create mode 100644 admin/sales-rates.js create mode 100644 client/README.md create mode 100644 client/hasher/datacube_hasher.d.ts create mode 100644 client/hasher/datacube_hasher.js create mode 100644 client/hasher/datacube_hasher_bg.wasm create mode 100644 client/hasher/index.html create mode 100644 client/hasher/verae_sha256.d.ts create mode 100644 client/hasher/verae_sha256.js create mode 100644 client/hasher/verae_sha256_bg.wasm create mode 100644 client/zapier-app/authentication.js create mode 100644 client/zapier-app/creates/store_data.js create mode 100644 client/zapier-app/creates/timestamp.js create mode 100644 client/zapier-app/creates/timestamps_batch.js create mode 100644 client/zapier-app/index.js create mode 100644 client/zapier-app/package.json create mode 100644 client/zapier-app/triggers/new_item.js create mode 100644 docs/BILLING-DB.md create mode 100644 docs/CHANNEL-BILLING.md create mode 100644 docs/ENV-MATRIX.md create mode 100644 docs/LEDGER-CHAIN.md create mode 100644 docs/PEERGOS-BILLING.md create mode 100644 docs/VERAE-BATCH-TIMESTAMP.md create mode 100644 docs/ZAPIER-AND-HASHER.md create mode 100644 docs/superpowers/plans/2026-10-03-libsql-multihost-architecture.md create mode 100644 docs/verae-time-openapi.yaml create mode 100644 src/billing-channel-resolve.ts create mode 100644 src/billing-channels.ts create mode 100644 src/billing-headers.ts create mode 100644 src/db/api-token-repo.ts create mode 100644 src/db/business-store-adapters.ts create mode 100644 src/db/business-store.ts create mode 100644 src/db/channel-rate-store.ts create mode 100644 src/db/credit-adjustments.ts create mode 100644 src/db/db-paths.ts create mode 100644 src/db/ledger-chain.ts create mode 100644 src/db/libsql-business-store.ts create mode 100644 src/db/migrate-billing-split.ts create mode 100644 src/db/migrate-to-libsql.ts create mode 100644 src/db/open-business-store.ts create mode 100644 src/db/open-sqlite.ts create mode 100644 src/db/sqlite-file-business-store.ts create mode 100644 src/mailer.ts create mode 100644 src/password-reset.ts create mode 100644 src/peergos-admin.ts create mode 100644 src/peergos-billing.ts create mode 100644 src/registration-pdf.ts create mode 100644 src/seo.ts create mode 100644 tests/api-tokens.test.ts create mode 100644 tests/billing-headers.test.ts create mode 100644 tests/business-store.contract.test.ts create mode 100644 tests/channel-meter.test.ts create mode 100644 tests/channel-rates.test.ts create mode 100644 tests/credit-ledger-sqlite.test.ts create mode 100644 tests/db-customer-peergos.test.ts create mode 100644 tests/ledger-chain.test.ts create mode 100644 tests/meter-peergos.test.ts create mode 100644 tests/migrate-billing-split.test.ts create mode 100644 tests/migrate-to-libsql.test.ts create mode 100644 tests/open-business-store.test.ts create mode 100644 tests/open-sqlite-wal.test.ts create mode 100644 tests/password-reset.test.ts create mode 100644 tests/peergos-billing.test.ts create mode 100644 tests/seo.test.ts create mode 100644 tests/timestamps-batch.test.ts create mode 100644 tests/upstream-middleware-key.test.ts diff --git a/README.md b/README.md index 691f6dc..a9f0763 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Zappier -[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) - -Metered API platform: per-endpoint pricing, customer types with multipliers and monthly credits, a usage ledger,... +Metered API platform: per-endpoint pricing, customer types with multipliers and +monthly credits, a usage ledger, Stripe metered billing, purchase-order +invoicing, a company admin console, a self-service customer portal, and a +Zapier integration. This is the **billing and user platform** (originally the standalone `zappier` git tree at `~/zappier`). Forgejo name: **zappier-edge**. It owns signup, TOTP, API keys, rate card, Stripe meter, PO invoices, admin users, and the customer portal. Verae middleware does not replace this. @@ -139,32 +140,3 @@ To deploy it, create a Zapier developer account, run `zapier login`, then npm test # root API/service suite (jest, 165 tests) cd zapier-app && npm test # Zapier integration suite (mocha, 4 tests) ``` - -## Table of Contents - -- [Install](#install) -- [Usage](#usage) -- [Contributing](#contributing) -- [License](#license) - -## Install - -```sh -git clone ssh://git@git.georgelambert.org:2223/marchon/zappier-edge.git -cd zappier-edge -``` - -## Usage - -```sh -npm install -npm start -``` - -## Contributing - -Questions: open an issue at https://git.georgelambert.org/marchon/zappier-edge/issues. Pull requests are accepted. - -## License - -UNLICENSED © George Lambert diff --git a/admin/index.html b/admin/index.html index 2653b4f..f8fd17a 100644 --- a/admin/index.html +++ b/admin/index.html @@ -235,6 +235,7 @@ +

Sales channel rates (Zapier · MCP · API)

diff --git a/admin/sales-rates.html b/admin/sales-rates.html new file mode 100644 index 0000000..ff19cd1 --- /dev/null +++ b/admin/sales-rates.html @@ -0,0 +1,49 @@ + + + + + Sales channel rates — Verae + + + +

← Admin

+

Sales: Zapier · MCP · API rates

+

+ Segment sets credit rates per billing channel. + Tier (free/pro/business) only sets included stamps/GB — edit that under Pricing. + Ledger unit is credits. +

+
+ + + + +
+ + + +
SKUCredits
+ +

+ + + diff --git a/admin/sales-rates.js b/admin/sales-rates.js new file mode 100644 index 0000000..901a8d6 --- /dev/null +++ b/admin/sales-rates.js @@ -0,0 +1,88 @@ +/* global fetch */ +(async function () { + const status = document.getElementById('status'); + const channelEl = document.getElementById('channel'); + const segmentEl = document.getElementById('segment'); + const rowsEl = document.getElementById('rows'); + const overageEl = document.getElementById('overage'); + const prepaidEl = document.getElementById('prepaid'); + + function authHeaders() { + const token = localStorage.getItem('adminToken') || sessionStorage.getItem('adminToken'); + const key = localStorage.getItem('adminKey') || ''; + const h = { 'content-type': 'application/json' }; + if (token) h.authorization = `Bearer ${token}`; + if (key) h['x-admin-key'] = key; + return h; + } + + let table = null; + let skus = []; + + function renderRows() { + if (!table) return; + const ch = channelEl.value; + const seg = segmentEl.value; + const rates = (table.rates[ch] && table.rates[ch][seg]) || {}; + rowsEl.innerHTML = skus + .map( + (sku) => + `${sku}`, + ) + .join(''); + } + + async function load() { + status.textContent = 'Loading…'; + const res = await fetch('/admin/api/channel-rates', { headers: authHeaders() }); + if (!res.ok) { + status.className = 'err'; + status.textContent = `Load failed (${res.status}). Sign in at /admin/ first.`; + return; + } + const data = await res.json(); + table = data.table; + skus = data.skus; + channelEl.innerHTML = data.channels.map((c) => ``).join(''); + segmentEl.innerHTML = data.segments.map((s) => ``).join(''); + overageEl.value = table.overageMultiplier; + prepaidEl.value = table.prepaidOverageMultiplier; + renderRows(); + status.className = 'ok'; + status.textContent = data.note || 'Loaded.'; + } + + channelEl.addEventListener('change', renderRows); + segmentEl.addEventListener('change', renderRows); + + document.getElementById('save').addEventListener('click', async () => { + if (!table) return; + const ch = channelEl.value; + const seg = segmentEl.value; + if (!table.rates[ch]) table.rates[ch] = {}; + if (!table.rates[ch][seg]) table.rates[ch][seg] = {}; + rowsEl.querySelectorAll('input[data-sku]').forEach((inp) => { + table.rates[ch][seg][inp.getAttribute('data-sku')] = Number(inp.value) || 0; + }); + table.overageMultiplier = Number(overageEl.value) || 1.5; + table.prepaidOverageMultiplier = Number(prepaidEl.value) || 0.75; + const res = await fetch('/admin/api/channel-rates', { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify(table), + }); + if (!res.ok) { + status.className = 'err'; + status.textContent = `Save failed (${res.status})`; + return; + } + const data = await res.json(); + table = data.table; + status.className = 'ok'; + status.textContent = `Saved ${ch} × ${seg}.`; + }); + + load(); +})(); diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..f42d226 --- /dev/null +++ b/client/README.md @@ -0,0 +1,23 @@ +# Client packs (Zapier + hashing) + +## Rapid SHA-256 WASM + +Served at `/client/hasher/` on zap.verae.ai (after deploy). + +| File | Use | +|------|-----| +| `verae_sha256.js` + `verae_sha256_bg.wasm` | Verae hasher (`sha256_hex_js`, `Sha256Stream`) | +| `verae_sha256.d.ts` | TypeScript types | +| `datacube_hasher.js` + `.wasm` + `.d.ts` | DataCube streaming/batch hasher | +| `index.html` | Browser demo | + +Pattern: hash in the client → `POST https://zap.verae.ai/v1/timestamp` with `{ "sha256": "" }` and header `x-api-key`. + +## Zapier Platform app + +Source: repo `zapier-app/` (also under `/Users/marchon/zappier/zapier-app`). + +Auth fields: **API Base URL** = `https://zap.verae.ai`, **API Key** = customer `key-…`. +Sends `x-api-key` and `x-billing-channel: zapier` on every request. + +See [docs/ZAPIER-AND-HASHER.md](../docs/ZAPIER-AND-HASHER.md). diff --git a/client/hasher/datacube_hasher.d.ts b/client/hasher/datacube_hasher.d.ts new file mode 100644 index 0000000..429fd14 --- /dev/null +++ b/client/hasher/datacube_hasher.d.ts @@ -0,0 +1,100 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Streaming hash for very large files (chunked processing) + */ +export class StreamingHasher { + free(): void; + [Symbol.dispose](): void; + finalize(): string; + constructor(); + reset(): void; + update(chunk: Uint8Array): void; +} + +/** + * Compute SHA256 hash of a file (async for large files) + */ +export function compute_sha256(file: File): Promise; + +/** + * Batch hash multiple files (returns JSON array) + */ +export function compute_sha256_batch(files: Array): Promise; + +/** + * Compute SHA256 hash of bytes directly + */ +export function compute_sha256_bytes(bytes: Uint8Array): string; + +/** + * Compute SHA256 hash of a string (for block verification) + */ +export function compute_sha256_string(input: string): string; + +/** + * Test function to verify WASM is working + */ +export function ping(): string; + +/** + * Initialize the WASM module + */ +export function start(): void; + +/** + * Verify a hash against expected value + */ +export function verify_hash(computed: string, expected: string): boolean; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_streaminghasher_free: (a: number, b: number) => void; + readonly compute_sha256: (a: any) => any; + readonly compute_sha256_batch: (a: any) => any; + readonly compute_sha256_bytes: (a: number, b: number) => [number, number]; + readonly ping: () => [number, number]; + readonly streaminghasher_finalize: (a: number) => [number, number]; + readonly streaminghasher_new: () => number; + readonly streaminghasher_reset: (a: number) => void; + readonly streaminghasher_update: (a: number, b: number, c: number) => void; + readonly verify_hash: (a: number, b: number, c: number, d: number) => number; + readonly start: () => void; + readonly compute_sha256_string: (a: number, b: number) => [number, number]; + readonly wasm_bindgen__closure__destroy__h21c7a480edf193b0: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__he384f79bbef4be39: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__h1708fce7cc4bbb73: (a: number, b: number, c: any, d: any) => void; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/client/hasher/datacube_hasher.js b/client/hasher/datacube_hasher.js new file mode 100644 index 0000000..beae157 --- /dev/null +++ b/client/hasher/datacube_hasher.js @@ -0,0 +1,566 @@ +/* @ts-self-types="./datacube_hasher.d.ts" */ + +/** + * Streaming hash for very large files (chunked processing) + */ +export class StreamingHasher { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + StreamingHasherFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_streaminghasher_free(ptr, 0); + } + /** + * @returns {string} + */ + finalize() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.streaminghasher_finalize(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + constructor() { + const ret = wasm.streaminghasher_new(); + this.__wbg_ptr = ret >>> 0; + StreamingHasherFinalization.register(this, this.__wbg_ptr, this); + return this; + } + reset() { + wasm.streaminghasher_reset(this.__wbg_ptr); + } + /** + * @param {Uint8Array} chunk + */ + update(chunk) { + const ptr0 = passArray8ToWasm0(chunk, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + wasm.streaminghasher_update(this.__wbg_ptr, ptr0, len0); + } +} +if (Symbol.dispose) StreamingHasher.prototype[Symbol.dispose] = StreamingHasher.prototype.free; + +/** + * Compute SHA256 hash of a file (async for large files) + * @param {File} file + * @returns {Promise} + */ +export function compute_sha256(file) { + const ret = wasm.compute_sha256(file); + return ret; +} + +/** + * Batch hash multiple files (returns JSON array) + * @param {Array} files + * @returns {Promise} + */ +export function compute_sha256_batch(files) { + const ret = wasm.compute_sha256_batch(files); + return ret; +} + +/** + * Compute SHA256 hash of bytes directly + * @param {Uint8Array} bytes + * @returns {string} + */ +export function compute_sha256_bytes(bytes) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.compute_sha256_bytes(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} + +/** + * Compute SHA256 hash of a string (for block verification) + * @param {string} input + * @returns {string} + */ +export function compute_sha256_string(input) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.compute_sha256_string(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} + +/** + * Test function to verify WASM is working + * @returns {string} + */ +export function ping() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.ping(); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } +} + +/** + * Initialize the WASM module + */ +export function start() { + wasm.start(); +} + +/** + * Verify a hash against expected value + * @param {string} computed + * @param {string} expected + * @returns {boolean} + */ +export function verify_hash(computed, expected) { + const ptr0 = passStringToWasm0(computed, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(expected, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.verify_hash(ptr0, len0, ptr1, len1); + return ret !== 0; +} + +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_is_function_18bea6e84080c016: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_undefined_4a711ea9d2e1ef93: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_throw_df03e93053e0f4bc: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_9f02ce912168c354: function(arg0) { + arg0._wbg_cb_unref(); + }, + __wbg_arrayBuffer_c8de821015d9714a: function(arg0) { + const ret = arg0.arrayBuffer(); + return ret; + }, + __wbg_call_85e5437fa1ab109d: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); }, + __wbg_get_c40e2c3262995a8e: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_instanceof_ArrayBuffer_d8e4e51f1cf7287a: function(arg0) { + let result; + try { + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_File_30fee53bbe19ff32: function(arg0) { + let result; + try { + result = arg0 instanceof File; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_length_00dd7227fd4626ad: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_5e07cf181b2745fb: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_log_91f1dd1dfd5a4ae8: function(arg0) { + console.log(arg0); + }, + __wbg_name_dc065b5e6132c2f5: function(arg0, arg1) { + const ret = arg1.name; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_new_a0479da6258a0d71: function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }, + __wbg_new_typed_893dbec5fe999814: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h1708fce7cc4bbb73(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = state0.b = 0; + } + }, + __wbg_prototypesetcall_d1a7133bc8d83aa9: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_queueMicrotask_622e69f0935dfab2: function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }, + __wbg_queueMicrotask_d0528786d26e067c: function(arg0) { + queueMicrotask(arg0); + }, + __wbg_resolve_d170483d75a2c8a1: function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }, + __wbg_static_accessor_GLOBAL_THIS_6614f2f4998e3c4c: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_d8e8a2fefe80bc1d: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_e29eaf7c465526b1: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_66e7ca3eef30585a: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_then_1170ade08ea65bc7: function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }, + __wbg_then_fdc17de424bf508a: function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 18, function: Function { arguments: [Externref], shim_idx: 19, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h21c7a480edf193b0, wasm_bindgen__convert__closures_____invoke__he384f79bbef4be39); + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./datacube_hasher_bg.js": import0, + }; +} + +function wasm_bindgen__convert__closures_____invoke__he384f79bbef4be39(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__he384f79bbef4be39(arg0, arg1, arg2); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + +function wasm_bindgen__convert__closures_____invoke__h1708fce7cc4bbb73(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h1708fce7cc4bbb73(arg0, arg1, arg2, arg3); +} + +const StreamingHasherFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_streaminghasher_free(ptr >>> 0, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => state.dtor(state.a, state.b)); + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + state.dtor(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('datacube_hasher_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/client/hasher/datacube_hasher_bg.wasm b/client/hasher/datacube_hasher_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..8fe1b991e4fd4a7ba7c86a8ca55cc8fa602f8f8e GIT binary patch literal 48921 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 + + + + Verae rapid SHA-256 (WASM) + + + +

Verae SHA-256 WASM

+

Client-side rapid hashing for Zapier Code steps, browser apps, and Peergos. Hash locally, then send only the hex to POST /v1/timestamp.

+ +
Pick a file…
+

TS / JS import

+
import init, { sha256_hex_js, Sha256Stream } from './verae_sha256.js';
+await init();
+const hex = sha256_hex_js(new TextEncoder().encode('hello'));
+// streaming:
+const s = new Sha256Stream();
+s.update(chunk); const digest = s.finish();
+

Also available: datacube_hasher.js (compute_sha256, StreamingHasher).

+ + + diff --git a/client/hasher/verae_sha256.d.ts b/client/hasher/verae_sha256.d.ts new file mode 100644 index 0000000..9e185a7 --- /dev/null +++ b/client/hasher/verae_sha256.d.ts @@ -0,0 +1,8 @@ +export class Sha256Stream { + free(): void; + finish(): string; + constructor(); + update(bytes: Uint8Array): void; +} +export function sha256_hex_js(bytes: Uint8Array): string; +export default function init(input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module): Promise; diff --git a/client/hasher/verae_sha256.js b/client/hasher/verae_sha256.js new file mode 100644 index 0000000..408bfce --- /dev/null +++ b/client/hasher/verae_sha256.js @@ -0,0 +1,224 @@ +/* @ts-self-types="./verae_sha256.d.ts" */ + +/** + * Streaming hasher so a worker can hash a file without holding all bytes. + */ +export class Sha256Stream { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + Sha256StreamFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_sha256stream_free(ptr, 0); + } + /** + * @returns {string} + */ + finish() { + let deferred1_0; + let deferred1_1; + try { + const ptr = this.__destroy_into_raw(); + const ret = wasm.sha256stream_finish(ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + constructor() { + const ret = wasm.sha256stream_new(); + this.__wbg_ptr = ret; + Sha256StreamFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @param {Uint8Array} bytes + */ + update(bytes) { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + wasm.sha256stream_update(this.__wbg_ptr, ptr0, len0); + } +} +if (Symbol.dispose) Sha256Stream.prototype[Symbol.dispose] = Sha256Stream.prototype.free; + +/** + * @param {Uint8Array} bytes + * @returns {string} + */ +export function sha256_hex_js(bytes) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sha256_hex_js(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_5d9e815e6fdf150f: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./verae_sha256_bg.js": import0, + }; +} + +const Sha256StreamFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_sha256stream_free(ptr, 1)); + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (!module.ok) { + throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`); + } + + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('verae_sha256_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/client/hasher/verae_sha256_bg.wasm b/client/hasher/verae_sha256_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..194fb4d5c2e5cb275f7c99c34f97663c5a21d780 GIT binary patch literal 24937 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 literal 0 HcmV?d00001 diff --git a/client/zapier-app/authentication.js b/client/zapier-app/authentication.js new file mode 100644 index 0000000..ec93e22 --- /dev/null +++ b/client/zapier-app/authentication.js @@ -0,0 +1,23 @@ +module.exports = { + type: 'custom', + test: (z, bundle) => + z.request({ url: `${bundle.authData.baseUrl}/v1/status` }).then((r) => r.data), + fields: [ + { + key: 'baseUrl', + label: 'API Base URL', + type: 'string', + required: true, + default: 'https://zap.verae.ai', + helpText: 'Production: https://zap.verae.ai — OpenAPI at /docs', + }, + { + key: 'apiKey', + label: 'API Key', + type: 'password', + required: true, + helpText: 'Your Zappier customer API key.', + }, + ], + connectionLabel: '{{bundle.authData.baseUrl}}', +}; diff --git a/client/zapier-app/creates/store_data.js b/client/zapier-app/creates/store_data.js new file mode 100644 index 0000000..fb8f07c --- /dev/null +++ b/client/zapier-app/creates/store_data.js @@ -0,0 +1,54 @@ +const FormData = require('form-data'); + +const perform = async (z, bundle) => { + const form = new FormData(); + form.append( + 'metadata', + JSON.stringify({ title: bundle.inputData.title, note: bundle.inputData.note }), + ); + + if (bundle.inputData.file) { + const fileResponse = await z.request({ + url: bundle.inputData.file, + raw: true, + redirect: 'follow', + }); + form.append('attachments', fileResponse.body, { + filename: bundle.inputData.filename || 'attachment.bin', + }); + } + + const response = await z.request({ + url: `${bundle.authData.baseUrl}/v1/storage`, + method: 'POST', + body: form, + headers: form.getHeaders(), + }); + return response.data; +}; + +module.exports = { + key: 'store_data', + noun: 'Stored Item', + display: { + label: 'Store Data', + description: + 'Stores metadata and an optional file attachment. Priced per call plus metadata KB and attachment MB on your plan.', + }, + operation: { + inputFields: [ + { key: 'title', label: 'Title', type: 'string', required: true }, + { key: 'note', label: 'Note', type: 'text', required: false }, + { + key: 'file', + label: 'Attachment', + type: 'file', + required: false, + helpText: 'Optional file. Attachment size is billed per MB on your plan.', + }, + { key: 'filename', label: 'Filename', type: 'string', required: false }, + ], + perform, + sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }, + }, +}; diff --git a/client/zapier-app/creates/timestamp.js b/client/zapier-app/creates/timestamp.js new file mode 100644 index 0000000..ee9a46e --- /dev/null +++ b/client/zapier-app/creates/timestamp.js @@ -0,0 +1,106 @@ +const perform = async (z, bundle) => { + const body = {}; + if (bundle.inputData.sha256) body.sha256 = String(bundle.inputData.sha256).toLowerCase(); + if (bundle.inputData.data) body.data = bundle.inputData.data; + if (bundle.inputData.documentName) body.documentName = String(bundle.inputData.documentName); + if (bundle.inputData.metadata) { + try { + body.metadata = + typeof bundle.inputData.metadata === 'string' + ? JSON.parse(bundle.inputData.metadata) + : bundle.inputData.metadata; + } catch (e) { + throw new z.errors.Error('metadata must be JSON', 'InvalidData', 400); + } + } + // Opt-in registration PDF (Verae Timestamp Registration Document). + if (bundle.inputData.attachPdf) { + body.attachPdf = true; + body.formats = ['json', 'pdf']; + } + // Always wait for Verae /api/timestamp completion (MCP-style) unless disabled. + body.wait = bundle.inputData.wait === false || bundle.inputData.wait === 'false' ? false : true; + + const response = await z.request({ + url: `${bundle.authData.baseUrl}/v1/timestamp`, + method: 'POST', + body, + headers: { + 'content-type': 'application/json', + 'x-billing-channel': 'zapier', + }, + }); + return response.data; +}; + +module.exports = { + key: 'timestamp', + noun: 'Timestamp', + display: { + label: 'Create Timestamp', + description: + 'Registers a SHA-256 timestamp (waits for Verae /api/timestamp completion). Prefer hashing client-side (WASM) and pass sha256 only. Optional registration PDF.', + }, + operation: { + inputFields: [ + { + key: 'sha256', + label: 'SHA-256 hex', + type: 'string', + required: false, + helpText: 'Preferred. Hash locally with Verae WASM (/client/hasher/) then paste hex.', + }, + { + key: 'data', + label: 'Raw data', + type: 'text', + required: false, + helpText: 'If sha256 omitted, server hashes this UTF-8 string.', + }, + { + key: 'documentName', + label: 'Document name', + type: 'string', + required: false, + helpText: 'Shown on the registration PDF / text export when attach PDF is enabled.', + }, + { + key: 'metadata', + label: 'Metadata JSON', + type: 'text', + required: false, + helpText: 'Optional JSON object. Stamp+metadata uses metadata credit SKU.', + }, + { + key: 'attachPdf', + label: 'Attach registration PDF', + type: 'boolean', + required: false, + default: 'false', + helpText: + 'If true, response includes pdfBase64 + pdfFilename (Verae Timestamp Registration Document, same style as mcp.verae.ai/register.html). Off by default.', + }, + { + key: 'wait', + label: 'Wait for completion', + type: 'boolean', + required: false, + default: 'true', + helpText: 'Wait for Verae job completion and return receipt_id / timestamp (recommended).', + }, + ], + perform, + sample: { + jobId: 'job_sample', + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + existing: false, + status: 'completed', + receipt_id: 'QmSampleCid', + created_at: '2026-10-03T00:00:00.000Z', + timestamp: '2026-10-03T00:00:00.000Z', + merkle: '113198', + lookup_url: + 'https://mcp.verae.ai/register.html?sha256=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824&receipt=QmSampleCid', + }, + }, +}; diff --git a/client/zapier-app/creates/timestamps_batch.js b/client/zapier-app/creates/timestamps_batch.js new file mode 100644 index 0000000..e35f574 --- /dev/null +++ b/client/zapier-app/creates/timestamps_batch.js @@ -0,0 +1,98 @@ +/** + * One Zapier task → many timestamps. Prefer client-side WASM hashing, then pass hex list. + * Waits for Verae POST /api/batch/timestamp completion (MCP-style). PDF is opt-in. + */ +const perform = async (z, bundle) => { + let sha256s = bundle.inputData.sha256s; + if (typeof sha256s === 'string') { + sha256s = sha256s + .split(/[\n,]+/) + .map((s) => s.trim().toLowerCase().replace(/^sha256:/, '')) + .filter(Boolean); + } + if (!Array.isArray(sha256s) || sha256s.length === 0) { + throw new z.errors.Error('Provide one or more SHA-256 hex digests', 'InvalidData', 400); + } + if (sha256s.length > 500) { + throw new z.errors.Error('Max 500 hashes per Zap', 'InvalidData', 400); + } + + const body = { + sha256s, + wait: bundle.inputData.wait === false || bundle.inputData.wait === 'false' ? false : true, + }; + if (bundle.inputData.attachPdf) { + body.attachPdf = true; + body.formats = ['json', 'pdf']; + } + + const response = await z.request({ + url: `${bundle.authData.baseUrl}/v1/timestamps`, + method: 'POST', + body, + headers: { + 'content-type': 'application/json', + 'x-billing-channel': 'zapier', + }, + }); + return response.data; +}; + +module.exports = { + key: 'timestamps_batch', + noun: 'Timestamps', + display: { + label: 'Create Timestamps (Batch)', + description: + 'Register many SHA-256 timestamps via Verae /api/batch/timestamp (waits for completion). Hash with WASM first. Optional per-result registration PDF.', + }, + operation: { + inputFields: [ + { + key: 'sha256s', + label: 'SHA-256 hex list', + type: 'text', + required: true, + helpText: + 'One hex digest per line (or comma-separated), max 500. Hash locally via WASM; only digests are sent (Verae POST /api/batch/timestamp).', + }, + { + key: 'attachPdf', + label: 'Attach registration PDF', + type: 'boolean', + required: false, + default: 'false', + helpText: + 'If true, each result may include pdfBase64 + pdfFilename. Off by default (larger payloads).', + }, + { + key: 'wait', + label: 'Wait for completion', + type: 'boolean', + required: false, + default: 'true', + helpText: 'Wait for each Verae job and return receipt_id / timestamp (recommended).', + }, + ], + perform, + sample: { + channel: 'zapier', + count: 2, + created: 1, + existing: 1, + totalCredits: 10, + results: [ + { + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + jobId: 'job_1', + existing: false, + status: 'completed', + receipt_id: 'QmSampleCid', + timestamp: '2026-10-03T00:00:00.000Z', + created_at: '2026-10-03T00:00:00.000Z', + credits: 10, + }, + ], + }, + }, +}; diff --git a/client/zapier-app/index.js b/client/zapier-app/index.js new file mode 100644 index 0000000..03095f5 --- /dev/null +++ b/client/zapier-app/index.js @@ -0,0 +1,23 @@ +const authentication = require('./authentication'); +const newItem = require('./triggers/new_item'); +const storeData = require('./creates/store_data'); +const timestamp = require('./creates/timestamp'); +const timestampsBatch = require('./creates/timestamps_batch'); + +const addApiKeyHeader = (request, z, bundle) => { + request.headers = request.headers || {}; + if (request.url && request.url.startsWith(bundle.authData.baseUrl)) { + request.headers['x-api-key'] = bundle.authData.apiKey; + request.headers['x-billing-channel'] = 'zapier'; + } + return request; +}; + +module.exports = { + version: require('./package.json').version, + platformVersion: require('zapier-platform-core').version, + authentication, + beforeRequest: [addApiKeyHeader], + triggers: { [newItem.key]: newItem }, + creates: { [storeData.key]: storeData, [timestamp.key]: timestamp, [timestampsBatch.key]: timestampsBatch }, +}; diff --git a/client/zapier-app/package.json b/client/zapier-app/package.json new file mode 100644 index 0000000..bde80b3 --- /dev/null +++ b/client/zapier-app/package.json @@ -0,0 +1,16 @@ +{ + "name": "zappier", + "version": "1.0.0", + "description": "Store data and files through the metered Zappier API.", + "main": "index.js", + "scripts": { + "test": "mocha --recursive --timeout 10000" + }, + "dependencies": { + "form-data": "^4.0.6", + "zapier-platform-core": "^19.0.0" + }, + "devDependencies": { + "mocha": "^11.7.6" + } +} diff --git a/client/zapier-app/triggers/new_item.js b/client/zapier-app/triggers/new_item.js new file mode 100644 index 0000000..171c600 --- /dev/null +++ b/client/zapier-app/triggers/new_item.js @@ -0,0 +1,24 @@ +const perform = async (z, bundle) => { + const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage` }); + return response.data.items; +}; + +module.exports = { + key: 'new_item', + noun: 'Stored Item', + display: { + label: 'New Stored Item', + description: 'Triggers when a new item is stored through the Zappier API.', + }, + operation: { + type: 'polling', + perform, + sample: { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + customerId: 'cust_1', + metadata: { title: 'example' }, + attachments: [], + createdAt: '2026-07-27T10:00:00.000Z', + }, + }, +}; diff --git a/docs/ACCOUNTING.md b/docs/ACCOUNTING.md index f0ef0de..197907a 100644 --- a/docs/ACCOUNTING.md +++ b/docs/ACCOUNTING.md @@ -112,7 +112,8 @@ Environment (`.env` at the project root): ``` STRIPE_SECRET_KEY=sk_live_or_test_... -ZAPPIER_DB=/absolute/path/to/zappier.db # optional +BILLING_DB=/absolute/path/to/billing.db # optional (default: /billing.db) +ZAPPIER_DB=/absolute/path/to/zappier.db # optional auth/sessions only ``` --- @@ -120,7 +121,7 @@ ZAPPIER_DB=/absolute/path/to/zappier.db # optional ## Data notes - Money is integer **cents** everywhere internally; the UI formats dollars. -- All accounting data lives in the SQLite database (`zappier.db` by default): +- All accounting data lives in the billing SQLite database (`billing.db` by default): `invoices`, `invoice_lines`, `billing_ledger`, `customers`. - The Stripe billing job and the admin console can run from any working directory — all paths resolve from the installation root. diff --git a/docs/BILLING-DB.md b/docs/BILLING-DB.md new file mode 100644 index 0000000..09f00cb --- /dev/null +++ b/docs/BILLING-DB.md @@ -0,0 +1,26 @@ +# billing.db vs zappier.db + +**Direction (D006):** on a **3–5 machine** launch, all billing, customer, pricing, +accounting, and related business data live in **shared libSQL**. JetStream is +only for multi-host work queues. The files below are the **single-host lab +stand-in** on 138 until that cutover. + +| File | Env | Tables | +|------|-----|--------| +| `billing.db` | `BILLING_DB` | `customers`, `usage_entries` (+ hash chain), `channel_rates`, `channel_rate_meta`, `invoices`, `invoice_lines`, `price_endpoints`, `tiers`, `billing_reports`, `job_locks` | +| `zappier.db` | `ZAPPIER_DB` | `admin_users`, `portal_sessions` | + +Lab opens go through `openSqlite()` (**WAL** required). Production target: one +libSQL server (or Turso), repositories behind a small interface. + +## Migration (lab) + +On boot, `openSplitSqliteDbs()` copies billing tables from a legacy monolith +`zappier.db` into `billing.db` (when `billing.db` has no customers yet), then +drops those tables from `zappier.db`. + +## Deploy + +Never rsync `billing.db*` or `zappier.db*` onto the live host; exclude `*.db*`. + +See [zapier-decisions D006](https://git.georgelambert.org/marchon/zapier-decisions/src/branch/main/decisions/D006-libsql-business-jetstream-work.md). diff --git a/docs/CHANNEL-BILLING.md b/docs/CHANNEL-BILLING.md new file mode 100644 index 0000000..9d10663 --- /dev/null +++ b/docs/CHANNEL-BILLING.md @@ -0,0 +1,27 @@ +# Channel billing (Zapier · MCP · API) + +Ledger unit: **credits**. + +## Separation + +| Channel | Traffic | +|---------|---------| +| `zapier` | Zapier app + `/v1/*` OpenAPI on zappier-edge | +| `mcp` | Verae MCP timestamping tools | +| `api` | Direct REST/SDK and Peergos connectors declaring `channel=api` | + +Rates are **not** shared across channels unless sales copies them. + +## Segment vs tier + +| Dimension | Controls | +|-----------|----------| +| **Segment** (`individual` · `team` · `enterprise` · `purchase_order`) | SKU credit rates per channel (sales UI) | +| **Tier** (`free` · `pro` · `business`) | Included stamps + hosted GB only | + +## Sales UI + +`/admin/sales-rates.html` → `GET/PUT /admin/api/channel-rates` + +Annual package: **volume** = monthly × 12; **price** = monthly package credits × 12 × **0.8**. +Overage × **1.5**; prepaid overage × **0.75**. diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 5786e7f..e1cc23a 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -179,7 +179,7 @@ usage and applies the tier credit via `applyMonthlyCredit`. ## 5. Persistence (SQLite) -Single database file (`ZAPPIER_DB`, default `zappier.db`), WAL-agnostic +Two WAL SQLite files: `billing.db` (`BILLING_DB`) for ledger/billing and `zappier.db` (`ZAPPIER_DB`) for auth/sessions. Boot migrates legacy monolith `zappier.db` → `billing.db` once. better-sqlite3, all tables created with `CREATE TABLE IF NOT EXISTS` in the repo constructors. **Seeding rule:** `customers` and pricing tables seed from `DEFAULT_CUSTOMERS` / `DEFAULT_RATE_CARD` / `DEFAULT_TIERS` only when empty. @@ -360,7 +360,8 @@ Conventions: | Env var | Default | Used by | |---|---|---| | `PORT` | `3000` | `src/index.ts` | -| `ZAPPIER_DB` | `/zappier.db` | `src/index.ts`, billing job | +| `BILLING_DB` | `/billing.db` | Billing/ledger (customers, usage chain, rates, invoices) | +| `ZAPPIER_DB` | `/zappier.db` | Auth/sessions only (`admin_users`, `portal_sessions`) | | `ADMIN_KEY` | `admin-dev-key` | `adminAuth()` | | `ADMIN_USER` | `admin` | Admin login | | `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | `demo` / `$$$Adm1n###` | Demo admin login | @@ -371,7 +372,7 @@ root (`src/paths.ts` `PROJECT_ROOT`) — never from the process cwd — so the compiled server and the job run from any working directory. `.env` is gitignored (`chmod 600`); `.env.example` documents the shape. Git identity is configured repo-local; `.gitignore` covers `node_modules/`, -`dist/`, `.env`, `zappier.db*`. +`dist/`, `.env`, `zappier.db*`, `billing.db*`. ## 11. Extending the system diff --git a/docs/ENV-MATRIX.md b/docs/ENV-MATRIX.md new file mode 100644 index 0000000..9801db3 --- /dev/null +++ b/docs/ENV-MATRIX.md @@ -0,0 +1,15 @@ +# Live lab env matrix (NS1 70.88.205.138) + +| Service | Bind | Role | +|---------|------|------| +| zappier-edge | `127.0.0.1:3040` (`zap.verae.ai`) | Meter, portal/admin loopback, `billing.db` + `zappier.db` until libSQL cutover | +| middleware product | `127.0.0.1:3130` | `ZAPPIER_UPSTREAM`; mockVerae=false; natsConnected=true | +| middleware mock | `127.0.0.1:3100` | MOCK path; do not use for product smoke | +| account-balance | `127.0.0.1:3010` | **Facade / legacy** — not prepaid SoT after D006; do not dual-adjust | +| access planes | `:3021–3025` | Public doors | +| NATS JetStream | `10.10.10.21–23:4222` | Work queues only | +| libSQL (target) | private `LIBSQL_URL` | Business SoT (D006) | + +**BusinessStore (WP-L / WP-V2):** `BUSINESS_STORE=sqlite-file` (default) uses `BILLING_DB`; `BUSINESS_STORE=libsql` requires `LIBSQL_URL` (fail closed). Meter/customer/usage go through BusinessStore adapters. Pricing, channel rates, and invoices also come from the BusinessStore path (`SqliteFileBusinessStore` getters on the same `BILLING_DB`); libsql driver creates matching schema but dual-runs those sync repos on `BILLING_DB` until async libsql repos land. + +**Dual-writer freeze (2026-10-03):** Meter and balance **writes** go only through zappier-edge → BusinessStore (`billing.db` today, libSQL after cutover). Do not call `verae.billing.balance.adjust` on `:3010` for production adjusts until it is a libSQL-backed facade. diff --git a/docs/LEDGER-CHAIN.md b/docs/LEDGER-CHAIN.md new file mode 100644 index 0000000..f7f1682 --- /dev/null +++ b/docs/LEDGER-CHAIN.md @@ -0,0 +1,31 @@ +# Edge usage ledger chain + +Append-only **blockchain sequencing** for metered events in **`billing.db`** +(`usage_entries`). Auth/sessions stay in `zappier.db`. This is separate from +Timestamping-svc Go blockchain storage. + +## Model + +Each `usage_entries` row is a block: + +| Column | Meaning | +|--------|---------| +| `id` | Sequence index (AUTOINCREMENT) | +| `prev_hash` | SHA-256 hex of the previous block (`0…0` for genesis) | +| `row_hash` | SHA-256 of `prev=\n` + canonical payload | + +Canonical payload fields (newline-separated): `id`, `customer_id`, `endpoint_id`, +`cents`, `metadata_bytes`, `attachment_bytes`, `timestamp_ms`. + +## Code + +- `src/db/ledger-chain.ts` — hash, backfill, verify +- `src/db/usage-repo.ts` — every `record()` appends the next block under `BEGIN IMMEDIATE` + +On open, `SqliteUsageRepo` migrates columns and backfills legacy rows. + +## Verify + +```ts +repo.verifyChain(); // { ok, length, tipHash, brokenAtId?, reason? } +``` diff --git a/docs/PEERGOS-BILLING.md b/docs/PEERGOS-BILLING.md new file mode 100644 index 0000000..69d79d5 --- /dev/null +++ b/docs/PEERGOS-BILLING.md @@ -0,0 +1,74 @@ +# Peergos ↔ zappier-edge billing + +Maps Peergos people onto the Zapier metering stack on host 138 +(`/opt/verae/zappier-edge`, service `zappier-edge.service`). + +**Ledger unit: credits** (product language). SQLite columns historically named +`cents` store the same integer (**1 credit = 1 ledger unit**). + +## Account model + +| Kind | Id shape | When | +|------|----------|------| +| Shared platform parent | `cust-platform` | Default for Peergos users on **shared** hosted S3 | +| Distinct-S3 org | `cust-NN` / `cust_` | Only when the customer needs a **distinct S3 endpoint** | +| Peergos user (child) | `peergos:` | Sub-account; usage rolls up to parent | + +- `billingType`: `stripe` or `purchase_order` +- `storageMode`: `hosted` or `byo` + +## Timestamp SKUs (credits) + +| Kind | Credits | +|------|---------| +| Plain stamp | **10** | +| Stamp + metadata / metadata update | **15** | +| Stamp + binary attachments | **25** + **DataCharge** (storage + retrieval) | +| Re-timestamp (exists) | **5** | +| Verify / hash-lookup / receipt | **0 (free)** | + +Included stamp units (billable stamps; verify excluded): + +| Tier | Monthly | Annual (`×10`) | +|------|---------|---------------------| +| Free | **20** | 240 | +| Pro | **250** | 3,000 | +| Business | **2,500** | 30,000 | + +## Storage (credits) + +| Mode | Rate | +|------|------| +| Hosted, standard | Included GB (free **1** / pro **50** / business **500**) then overage at **AWS S3 Standard × 1.20** | +| **BYO** | **1 credit / GB-month** | +| Compliance `verae_compliance` | All retained GB × increment at **≥ 1.75 × AWS pin** | +| Compliance `amazon_passthrough` | Amazon fees × **1.35** (pass-through), expressed as credits | + +## Admin API (staff) + +Under `/admin/api`, auth via `x-admin-key` or staff Bearer. + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/peergos/orgs` | Provision parent (`cust-platform` or distinct-S3 `cust-NN`) | +| POST | `/peergos/users` | Provision `peergos:` (default parent `cust-platform`) | +| GET | `/peergos/orgs/:id/billing-summary` | Cost sheet in credits | +| POST | `/peergos/storage-snapshot` | Meter GB-month / retention increment | +| POST | `/peergos/usage` | Meter timestamps or storage bytes→GB | + +## Ops + +Work tree: `/home/marchon/zappier-edge-work` +Live: `/opt/verae/zappier-edge` · `127.0.0.1:3040` + +## Overage credits + +| Mode | Multiplier on list SKU credits | +|------|--------------------------------| +| Within included plan | ×1.0 | +| Overage (past plan) | ×**1.5** | +| Prepaid overage credits | ×**0.75** | + + +Annual **price** = (monthly package credits × 12) × **0.8** (reduced price on the full year). +Annual **included volume** = monthly × 12 (not reduced). diff --git a/docs/USER-MANUAL.md b/docs/USER-MANUAL.md index babaccb..dd869c3 100644 --- a/docs/USER-MANUAL.md +++ b/docs/USER-MANUAL.md @@ -40,7 +40,8 @@ Environment variables (all optional except `STRIPE_SECRET_KEY` for billing): | Variable | Default | Purpose | |---|---|---| | `PORT` | `3000` | HTTP port for the API server | -| `ZAPPIER_DB` | `/zappier.db` | SQLite database file location | +| `BILLING_DB` | `/billing.db` | Billing/ledger SQLite (customers, usage, rates, invoices) | +| `ZAPPIER_DB` | `/zappier.db` | Auth/sessions SQLite (`admin_users`, `portal_sessions`) | | `ADMIN_KEY` | `admin-dev-key` | Key for the admin console and admin API | | `ADMIN_USER` | `admin` | Admin console primary username | | `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | `demo` / `$$$Adm1n###` | Demo sign-in — override in production | @@ -321,9 +322,9 @@ marketplace. | Export accounting data | Reports tab → filters → Download CSV | | Give a customer a deal | Customers tab → multiplier override, or a new customer type | | Turn an endpoint off | Rate card → Delete (calls get 403) | -| Check a customer's usage | Their portal dashboard, `GET /v1/usage` with their key, or query `zappier.db` | +| Check a customer's usage | Their portal dashboard, `GET /v1/usage` with their key, or query `billing.db` | | Check billing ran | Kimi notification after each 06:17 run; or run the job manually | -| Backup | Copy `zappier.db` (SQLite, single file) | +| Backup | Copy `billing.db` and `zappier.db` (both WAL SQLite files) | | Update dependencies | `npm outdated`, then `npm test` must stay green (165 tests) | --- diff --git a/docs/VERAE-BATCH-TIMESTAMP.md b/docs/VERAE-BATCH-TIMESTAMP.md new file mode 100644 index 0000000..dfcbdef --- /dev/null +++ b/docs/VERAE-BATCH-TIMESTAMP.md @@ -0,0 +1,60 @@ +# Verae Time multihash submit + +Source of truth for digests-only stamping: + +- MCP: https://mcp.verae.ai/ — *Hash locally. Stamp remotely.* Tool `stamp` `{ sha256 }` +- Swagger: https://api.veraetime.net/docs/swagger/index.html +- Spec: https://api.veraetime.net/docs/swagger/openapi.yaml + +## `POST /api/batch/timestamp` (multihash submit) + +Auth: Bearer JWT (`POST /auth/login`). + +Submit **SHA-256 digests only**. File bytes never upload. Same item shape as MCP global stamp / `POST /api/timestamp`. + +### Request (`BatchTimestampRequest`) + +```json +{ + "items": [ + { + "data": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "digest": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "hashAlg": "SHA256", + "restamp": false + }, + { + "data": "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d", + "digest": "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d", + "hashAlg": "SHA256", + "restamp": false + } + ] +} +``` + +- `data` / `digest`: 64-hex SHA-256 (optional `sha256:` prefix on `digest`). Prefer both set to the same digest. +- `hashAlg`: defaults to `SHA256`. +- `restamp`: `false` reuses the first CID when the digest was already stamped. + +### Response 202 (`BatchTimestampResponse`) + +```json +{ + "jobIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "650e8400-e29b-41d4-a716-446655440001" + ] +} +``` + +Related: `POST /api/batch/status` `{ "jobIds": [...] }`, `POST /api/batch/verify`. + +## Edge mapping (`zap.verae.ai`) + +| Client → edge | Edge → Verae `POST /api/batch/timestamp` | +|---------------|------------------------------------------| +| `POST /v1/timestamps` `{ sha256s: ["abc…"] }` | `items: [{ data, digest, hashAlg: SHA256, restamp: false }]` | +| `POST /v1/timestamps` `{ items: [{ digest\|sha256\|data }] }` | same digests-only item shape | + +Metering + `X-Verae-Credits-*` headers stay on the edge (Zapier/MCP/API channel rates). diff --git a/docs/ZAPIER-AND-HASHER.md b/docs/ZAPIER-AND-HASHER.md new file mode 100644 index 0000000..90ae96a --- /dev/null +++ b/docs/ZAPIER-AND-HASHER.md @@ -0,0 +1,82 @@ +# Zapier client + rapid SHA-256 WASM + +## Configure Zapier for customers + +1. Platform app source: `/client/zapier-app/` (also `/Users/marchon/zappier/zapier-app`). +2. In Zapier CLI / Platform UI, set connection: + - **API Base URL:** `https://zap.verae.ai` + - **API Key:** customer key from portal/admin (`key-…`) +3. App sends on every request: + - `x-api-key: ` + - `x-billing-channel: zapier` → sales **Zapier** rate card for that customer’s **segment** +4. Actions: **Store Data**, **Create Timestamp** (pass `sha256` preferred). +5. Explore API: `https://zap.verae.ai/docs` + +Push with Zapier CLI from `zapier-app/`: + +```bash +cd zapier-app +npm install +zapier login +zapier push +``` + +## Rapid hashing (HTML + TS + WASM) + +Hosted after deploy: + +| URL | Purpose | +|-----|---------| +| https://zap.verae.ai/client/hasher/ | Demo page | +| https://zap.verae.ai/client/hasher/verae_sha256.js | JS glue | +| https://zap.verae.ai/client/hasher/verae_sha256_bg.wasm | WASM | +| https://zap.verae.ai/client/hasher/verae_sha256.d.ts | TypeScript types | +| https://zap.verae.ai/client/hasher/datacube_hasher.* | Alternate streaming/batch hasher | + +```ts +import init, { sha256_hex_js, Sha256Stream } from './verae_sha256.js'; +await init(); +const hex = sha256_hex_js(new TextEncoder().encode('payload')); +// then POST /v1/timestamp { sha256: hex } with x-api-key +``` + +## Channel attribution + +| Header / signal | Channel | +|-----------------|---------| +| `x-billing-channel: zapier` | Zapier sales rates | +| `x-billing-channel: mcp` | MCP sales rates | +| `x-billing-channel: api` | Direct API sales rates | +| Zapier User-Agent | zapier | +| default | api | + +Sales edits rates at `/admin/sales-rates.html`. + +## Efficiency for Zapier (minimal calls) + +**Best path (what we ship):** + +1. Hash locally with WASM (`/client/hasher/`) — digests only; file bytes never leave the device (same model as https://mcp.verae.ai/). +2. Call **`POST /v1/timestamps`** once with up to **500** hex digests (Zapier **Create Timestamps (Batch)** → Verae `POST /api/batch/timestamp` multihash submit). +3. Re-submits of the same hashes are **free** (idempotent; `restamp: false`). + +| Approach | Verae HTTP calls | Zapier tasks | Notes | +|----------|------------------|--------------|-------| +| Loop “Create Timestamp” N times | N | N | Worst | +| Single stamp + WASM | 1 per file | 1 per file | Good for one-offs | +| **Batch + WASM** | **1** for N hashes | **1** | **Best** | + +Single `/v1/timestamp` remains for one-offs. Prefer batch whenever Zapier has a list of files/hashes. + +## Account headers on every transaction + +Every metered `/v1/*` response (and batch `/v1/timestamps`) includes: + +| Header | Meaning | +|--------|---------| +| `X-Verae-Credits-Spend` | Credits charged on **this** request | +| `X-Verae-Credits-Balance` | Prepaid wallet balance (credits) | +| `X-Verae-Credits-Period-Spend` | Period usage total for the billing parent | +| `X-Verae-Billing-Channel` | `zapier` · `mcp` · `api` | + +Exposed to browsers via `Access-Control-Expose-Headers`. Zapier Code steps can read `response.getHeader(...)`. diff --git a/docs/superpowers/plans/2026-10-03-libsql-multihost-architecture.md b/docs/superpowers/plans/2026-10-03-libsql-multihost-architecture.md new file mode 100644 index 0000000..861a07b --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-libsql-multihost-architecture.md @@ -0,0 +1,295 @@ +# Verae × Zapier Multi-Host Architecture & Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (or executing-plans) task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Run the Verae × Zapier commercial stack across **3–5 machines** with **libSQL** as the system of record for all business/billing/customer/pricing/accounting data, **JetStream only for work queues**, and proven end-to-end userflows under continuous QA. + +**Architecture:** Public HTTPS doors → zappier-edge (meter) → middleware (stamp) → `api.veraetime.net`; private NATS for workers; shared libSQL for money & CRM; Go timestamping DBs stay local to timestamping-svc. + +**Tech Stack:** Node (edge, middleware, access planes, CS/sales/acct), NATS JetStream (3-node lab → production), libSQL / go-libsql or `@libsql/client`, better-sqlite3 only as local/dev adapter, Litestream optional for libSQL file primary backup, Jest + gate scripts + E2E userflow harness. + +**Spec / SoT docs:** +- Catalog: https://zapier.georgelambert.org/ +- Map: `/tmp/zapier-system-docs-map.md` +- Decisions: D001–D006 (`packages/zapier-decisions/decisions/`) +- Composition: `docs/02-architecture/composition.md` +- NATS: `docs/02-architecture/nats-subjects.md`, `overview/03-nats-cluster.md` +- Ops: `packages/verae-ops/GETTING-STARTED.md` +- Digests-only stamps: mcp.verae.ai + D006 note + live swagger + +**Forgejo org:** https://git.georgelambert.org/marchon/ (modules listed in §2) + +## Global Constraints + +- **D006:** Business/billing/customers/pricing/accounting/usage ledger → **libSQL only** (shared). +- **JetStream:** work queues as necessary (`ZAPIER_JOBS`, `ZAPIER_WEBHOOKS`, archive put/query). Not money SoT. +- **Core NATS only** for `verae.ts.completed` / `verae.ts.receipt` (no JetStream on that path). +- **D001 refined:** single prepaid **writer path**; durable store is libSQL (not per-host `books.json` / `billing.db`). +- **D002:** public credential = zappier `x-api-key`; `veraeUserId` join; JWT = `tokenRef` server-side. +- **D003:** only access planes are public HTTP; edge loopback (except intentional public `zap.verae.ai` edge). +- **D004:** leaf must not touch billing. +- **Digests only** for stamp submit (`POST /api/batch/timestamp` multihash; MCP shape). +- **Never** modify Go timestamping-svc SQLite (`blockchain.db`, etc.) as part of this plan. +- **WAL** (or libSQL equivalent durability) on all SQL stores. +- Credits language in product copy; ledger may keep `*_cents` column names as credit units. +- Deploy never rsyncs live `*.db*` over production data. +- **ZAPIER_USAGE** / `verae.zapier.usage` is telemetry or export only — never usage/money SoT. +- Litestream (if used) backs up a **single** libSQL primary; never multi-writer fabric. +- **Single meter/prepaid writer** process owns `usage_entries` append + balance adjust; other hosts call that API. +- Dual-run: libSQL primary for writes; file/SQLite fallback is **read-only** (or kill-switched). +- Auth (`admin_users`, `portal_sessions`): v1 remains edge-local `zappier.db`; not in `verae_business`. +- Product edge bind on 138: `127.0.0.1:3040` (`zap.verae.ai`); `ZAPPIER_UPSTREAM` defaults to `http://127.0.0.1:3130` for live lab smoke (not `:3100` mock unless intentional). +- Catalog docs that call account-balance “prepaid SoT” are stale after D006; Phase 0 patches them. + +--- + +## 1. System map (from catalog + live lab) + +```text +Internet + ├─ Zapier cloud ──HTTPS──► access-zapier :3024 ──► zappier-edge (meter) ──► middleware :3100 + ├─ Browsers ────HTTPS──► access-web :3021 /portal + ├─ Customer API ─HTTPS──► access-api :3022 + ├─ Leaf S2S ────HTTPS──► access-leaf :3023 (no billing) + └─ Staff ───────HTTPS──► access-staff :3025 + IAM :3028 + +Private + ├─ NATS JetStream 10.10.10.21–23 (jobs.watch, webhooks.deliver, archive.*) + ├─ Core NATS (verae.ts.completed, verae.ts.receipt, billing request-reply) + ├─ libSQL (TARGET) ← customers, pricing, usage chain, invoices, balances + ├─ Workers job-poller, webhook-deliver, archive-worm×N, tree-node×N + └─ api.veraetime.net / MOCK_VERAE +``` + +### Critical userflows (must pass QA) + +1. **Signup → API key → Statement** (portal via access-web). +2. **Stamp digests (single + batch)** via zap door → edge meter → middleware → Verae → 202 jobIds → credits headers. +3. **Async complete → Zapier REST Hook** (`jobs.watch` → poll → `jobs.events` → `webhooks.deliver`). +4. **CS credit adjust → balance visible** on portal/admin (single prepaid writer). +5. **Sales channel×segment quote → meter charge** matches rate card. +6. **Accounting export** sees payments/usage consistent with libSQL. +7. **Leaf plane** can stamp/authz but **cannot** adjust billing (D004). + +--- + +## 2. Forgejo source elements (implementation clones) + +| Concern | Repo | Notes | +|---------|------|-------| +| Billing edge | `marchon/zappier-edge` | Live `/opt/verae/zappier-edge` on 138; also catalog `packages/zappier` | +| Prepaid mailbox (legacy) | `marchon/zappier-account-balance` | `books.json` → migrate into libSQL | +| CS / Sales / Acct | `zappier-customer-service`, `zappier-sales-pricing`, `zappier-accounting-export` | | +| Identity | `zappier-identity` | | +| Middleware | `marchon/verae-middleware` (catalog: verae-zapier-middleware) | | +| Zapier apps | `verae-zapier-app`, `verae-activate` | | +| Access planes | `verae-access-{web,api,leaf,zapier,staff,authz}` | | +| Jobs/archive | `verae-jobs-events`, `verae-archive-*`, `verae-tree-node`, `verae-request-splitter` | | +| Ops | `verae-ops`, `verae-bootstrap`, `verae-fleet`, `verae-keep`, `verae-nats-cluster` | | +| Decisions | `zapier-decisions` | D001–D006 | +| Monorepo mirror | `master-zapier-plan-draft` | docs + packages composition | + +--- + +## 3. Conflict register (resolve before/with build) + +| ID | Conflict | Resolution (locked) | +|----|----------|---------------------| +| C1 | D001 `books.json` SoT vs live `billing.db` vs D006 libSQL | **libSQL is SoT**; migrate both books + billing.db schema; account-balance becomes libSQL-backed service or folds into billing API | +| C2 | README “imported, not yet wired” vs STATUS lab up | Treat STATUS + live doors as operational truth; update README composition status in Phase 0 docs | +| C3 | overview `zmw_` Bearer vs composition `x-api-key` | **Public = x-api-key** (D002/composition); fix overview/models | +| C4 | Edge ports `:3000` / `:13000` / live `:3040` (`zap.verae.ai`) | Document env matrix; lab doors proxy to loopback; zap.verae.ai may remain public edge | +| C5 | JetStream `jobs.events` vs ban on JetStream for job-complete | **Different subjects:** Zapier job lifecycle may use JetStream `verae.zapier.jobs.events`; Verae chain notify `verae.ts.*` stays **core NATS** | +| C6 | TimestampRequest “Hello world” data vs digests-only | Digests-only + multihash batch (already fixed on live swagger; sync catalog models) | +| C7 | Dual prepaid writers (edge SQLite + account-balance) | One writer API on libSQL; edge caches only | +| C8 | Per-host SQLite on 3–5 machines | Forbidden for business data; libSQL shared | +| C9 | Go `blockchain.db` in scope creep | Out of scope — do not touch | +| C10 | `ZAPIER_USAGE` vs libSQL usage ledger | Demote to telemetry/export only; never money SoT | +| C11 | Dual middleware `:3100` mock vs `:3130` product upstream | Env matrix; product smoke uses **3130** | +| C12 | D001 file still says books.json SoT | D001 addendum → libSQL (done with this amend) | +| C13 | Auth store edge-local vs libSQL | **v1 edge-local** `zappier.db` | +| C14 | Concurrent hash-chain append across hosts | Single meter writer / serialized append | +| C15 | GETTING-STARTED calls account-balance SoT | Phase 0 doc patch → libSQL | +| C16 | Live `:3010` + `billing.db` dual writers | Cutover runbook: one authoritative adjust path | +| C17 | Litestream multi-writer temptation | Litestream = backup of one primary only | +| C18 | Digests-only vs upload-file stamp docs | Client hasher + digests-only only | + +--- + +## 4. Target data ownership (libSQL) + +**Database:** `verae_business` (name TBD) on shared libSQL. + +| Domain | Tables / collections | Writer | +|--------|----------------------|--------| +| Customers / identity bind | `customers`, identity map | billing API + identity service | +| Pricing | `tiers`, `price_endpoints`, `channel_rates`, `channel_rate_meta` | admin / sales-pricing | +| Metered ledger | `usage_entries` (+ `prev_hash`/`row_hash` chain) | **single** meter writer | +| Prepaid | balances, credits, payments (from books.json) | billing adjust API | +| Invoices | `invoices`, `invoice_lines` | admin / Stripe jobs | +| Auth (optional split) | `admin_users`, `portal_sessions` | may stay edge-local or move to libSQL `auth` schema | + + +### 4.1 Launch topology (3–5 machines) + +- One libSQL primary (dedicated host or named primary among the 3–5). +- Replicas if any are read-only; writers always target primary URL (`LIBSQL_URL`). +- Backup: filesystem snapshot and/or Litestream of the **primary only**; tested restore before Phase 4. +- Fail closed: metered stamp paths return **503** if BusinessStore write unavailable (no silent local SQLite fallback in production). +- Network: libSQL on private fabric only (same class as NATS `10.10.10.0/24`); not public. +- Schema: `verae_business`; auth stays out for v1 (C13). + +**Interface:** `BusinessStore` (TS) with drivers: `LibsqlBusinessStore` (prod), `SqliteFileBusinessStore` (dev/test). + +--- + +## 5. NATS usage matrix + +| Subject / stream | Transport | Purpose | +|------------------|-----------|---------| +| `verae.zapier.jobs.watch` | JetStream | Poll Verae status | +| `verae.zapier.jobs.events` | JetStream | Terminal Zapier-pipeline job events | +| `verae.zapier.usage` / `ZAPIER_USAGE` | JetStream optional | **Telemetry/export only; NOT ledger SoT** | +| `verae.zapier.webhooks.deliver` | JetStream | Hook delivery retries | +| `verae.archive.*` | JetStream / fanout | WORM/tree | +| `verae.billing.*` | Core request-reply (+ optional pub) | Statement/balance; eventually thin facade over libSQL | +| `verae.ts.completed`, `verae.ts.receipt` | **Core NATS only** | Chain job-complete notify | +| `verae.access.*`, `verae.identity.*` | Core / as designed | Authz & bind | + +--- + +## 6. Constraint (TOC) + +> The system constraint is **shared business SoT cutover (libSQL + single prepaid writer)** because every multi-host userflow (meter, CS credit, sales quote, portal statement, accounting export) blocks on one consistent customer/balance/usage store; JetStream and doors already exist in the lab. + +Critical chain: **Conflict freeze → BusinessStore+libSQL → Migrate data → Rewire edge/CS/sales/acct → E2E userflow tests → QA whattochange loop**. + +Feeding chains (parallel): digests-only docs sync; NATS subject clarification; access-plane smoke; Forgejo clone inventory. + +--- + +## 7. Phased implementation (PM DAG) + +### Phase 0 — Docs & conflict freeze +- [ ] Publish this plan to catalog `docs/plans/` + `zapier-decisions/plans/` +- [ ] Patch conflict C2/C3/C5/C6 in overview + models + README status blurb +- [ ] Architecture review sign-off artifact: `plans/reviews/2026-10-03-arch-review.md` + +### Phase 1 — BusinessStore + libSQL skeleton (TDD) +- [ ] Define `BusinessStore` interface (customers, usage.record+verifyChain, rates, invoices, balances) +- [ ] Failing tests against interface +- [ ] `LibsqlBusinessStore` + docker/libsql-server or `:memory:`/file for CI +- [ ] Keep `SqliteFileBusinessStore` wrapping current repos for lab + +### Phase 2 — Migrate lab data +- [ ] One-shot migrator: `billing.db` + `books.json` → libSQL +- [ ] Preserve usage hash-chain tip integrity (verify before/after); refuse cutover if verifyChain fails +- [ ] Dual-run: writes libSQL only when `BUSINESS_STORE=libsql`; file fallback **read-only** +- [ ] Account-balance becomes libSQL-backed facade for `verae.billing.*` **or** is stopped after HTTP BusinessStore cutover (pick one in Phase 3) + +### Phase 3 — Rewire writers +- [ ] zappier-edge meter → BusinessStore only +- [ ] account-balance service reads/writes libSQL (or deprecate mailbox in favor of HTTP billing API) +- [ ] CS / sales / accounting-export point at same store +- [ ] Remove second writer paths (C7) + +### Phase 4 — Multi-host topology +- [ ] Deploy libSQL on dedicated host (or primary of 3–5) +- [ ] Edge/middleware/CS on separate hosts with `LIBSQL_URL` +- [ ] JetStream cluster already present — verify workers only +- [ ] Confirm leaf cannot call billing adjust + +### Phase 5 — Userflow E2E + gates +- [ ] Automate flows 1–7 (§1) in `harness/` or `packages/verae-zapier-simulator` extensions +- [ ] Jest unit + integration + gate scripts green +- [ ] Live smoke on 138 with MOCK_VERAE then optional live Verae + +### Phase 6 — QA loop (repeat until pass) +- [ ] QA finds bugs → `/whattochange` pack (a/b/c/d + todos) +- [ ] Engineers implement; PM reorders PreReqs +- [ ] Re-run full suite + userflows +- [ ] Exit when all critical userflows pass twice consecutively + +--- + +## 8. Test requirements (“tests for everything”) + +| Layer | Must cover | +|-------|------------| +| Unit | BusinessStore ops, hash-chain append/tamper, channel rate resolve, digests-only batch mapping, D004 leaf deny | +| Integration | edge→middleware mock stamp; billing adjust→statement; JetStream watch→event (testcontainers/nats) | +| Contract | OpenAPI digests-only examples; NATS payload schemas | +| E2E userflow | Flows 1–7 against lab compose or NS1 | +| Regression | Existing zappier-edge Jest suite must stay green during driver swap | +| Non-goals | Go timestamping-svc blockchain tests | + +--- + +## 9. Roles (agent orchestration) + +| Role | Responsibility | Artifact | +|------|----------------|----------| +| **Architect** | Review plan, expand conflict register, approve SoT | `plans/reviews/*-arch-review.md` | +| **PM** | DAG, task assignment, merge gates, buffer | `plans/reviews/*-pm-board.md` | +| **Engineers** | Implement Phases 1–5 in worktrees | PRs / commits citing this plan | +| **QA** | Break userflows; whattochange packs; retest | `plans/qa/*` + NotImplemented leftovers | +| **Orchestrator (this session)** | Launch agents, merge gates, enforce D006/JetStream rules | STATUS updates | + +--- + +## 10. Acceptance criteria (project buffer) + +1. One libSQL URL serves customers/pricing/usage/invoices/balances for all commercial services. +2. No production business writes to per-host `billing.db` / `books.json`. +3. JetStream used only for work/archive streams listed in §5. +4. `verae.ts.completed` / `verae.ts.receipt` remain core NATS. +5. Digests-only multihash batch path green with credit headers. +6. Userflows 1–7 pass twice in a row under QA. +7. Go timestamping DBs untouched. +8. Catalog docs match runtime (conflicts C2–C6, C10–C18 closed or owned). +9. `ZAPIER_USAGE` is non-SoT; no service writes authoritative balances there. +10. Production metered paths fail closed if BusinessStore writer is down. +11. Concurrent append contract test proves single-writer (or serialized) hash-chain safety. +12. Live env matrix documents `:3040` edge, `:3130` product upstream, `:3100` mock, `:3010` facade/retire. +13. D001 file contains D006 addendum; GETTING-STARTED / 01-system no longer call books.json SoT. + +--- + +## 11. Out of scope + +- Replacing api.veraetime.net / Go chain storage +- Zapier Platform public directory listing (private push may remain operator-gated) +- Peergos Seaweed fleet redesign (consume existing billing APIs only) +- lan-134 enablement (D005) +- Any JetStream binding for `verae.ts.completed` / `verae.ts.receipt` +- Editing `/var/lib/timestamping-svc/data/blockchain.db` or timestamping-svc test `results.db` +- Multi-primary libSQL or Litestream multi-writer +- Moving portal auth to libSQL in the same cutover as billing (defer) + +### Architecture review +See `plans/reviews/2026-10-03-arch-review.md` (**approve-with-changes**; A1–A8 merged herein). + +--- + +## Task checklist (engineer entry) + +### Task 1: BusinessStore interface + failing tests +**Files:** Create under zappier-edge `src/db/business-store.ts`, `tests/business-store.contract.test.ts` +**Produces:** Interface used by later tasks +- [ ] Write contract tests for recordUsage/verifyChain/findCustomer/getBalance/adjustBalance +- [ ] Run tests — expect fail +- [ ] Minimal SqliteFile adapter pass +- [ ] Commit citing this plan + +### Task 2: LibsqlBusinessStore +- [ ] Add `@libsql/client` +- [ ] Implement same contract +- [ ] CI uses file or embedded libsql +- [ ] Commit + +### Task 3: Migrator billing.db + books.json → libSQL +- [ ] Script + tests with fixtures +- [ ] Chain verify pre/post +- [ ] Commit + +*(PM expands Tasks 4+ after arch review.)* diff --git a/docs/verae-time-openapi.yaml b/docs/verae-time-openapi.yaml new file mode 100644 index 0000000..db93a43 --- /dev/null +++ b/docs/verae-time-openapi.yaml @@ -0,0 +1,1169 @@ +openapi: 3.0.0 +info: + title: Timestamping Service API + description: API for creating and verifying timestamps on a blockchain + version: 1.0.0 + contact: + name: Timestamping Service Team + email: support@timestamping-service.example.com + url: https://timestamping-service.example.com + +servers: + - url: / + description: Default server + +tags: + - name: API + description: API operations for timestamping and status + - name: Admin + description: Administrative operations + - name: Auth + description: Authentication and user management operations + +paths: + /api/timestamp: + post: + security: + - BearerAuth: [] + tags: + - API + summary: Create a new timestamp + description: | + Submit a SHA-256 digest to be timestamped on the blockchain. + Hash locally; send digests only (file bytes never upload). Same wire as + MCP `stamp` with `scope=global`: `data` + `digest` (both the 64-hex digest), + `hashAlg: SHA256`, `restamp: false` (reuse first CID when already stamped). + operationId: createTimestamp + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TimestampRequest' + responses: + '202': + description: Accepted - Timestamp job submitted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TimestampResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/batch/timestamp: + post: + security: + - BearerAuth: [] + tags: + - API + summary: Create multiple timestamps in a batch (multihash submit) + description: | + Submit multiple SHA-256 digests in one call (`items[]`). + Hash locally; send digests only. Each item uses the same digests-only shape + as `POST /api/timestamp` (`data` + `digest` + `hashAlg` + `restamp`). + operationId: createBatchTimestamp + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchTimestampRequest' + responses: + '202': + description: Accepted - Batch timestamp jobs submitted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BatchTimestampResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/verify: + post: + security: + - BearerAuth: [] + tags: + - API + summary: Verify a timestamp certificate + description: Verify the authenticity of a timestamp certificate + operationId: verifyTimestamp + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyRequest' + responses: + '200': + description: OK - Verification result + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/batch/verify: + post: + security: + - BearerAuth: [] + tags: + - API + summary: Verify multiple timestamp certificates in a batch + description: Verify the authenticity of multiple timestamp certificates + operationId: verifyBatchTimestamp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - certificates + properties: + certificates: + type: array + description: Array of timestamp certificates to verify + items: + type: string + example: + certificates: + - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + responses: + '200': + description: OK - Batch verification results + content: + application/json: + schema: + $ref: '#/components/schemas/BatchVerifyResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/status/{jobId}: + get: + security: + - BearerAuth: [] + tags: + - API + summary: Get the status of a timestamp job + description: Retrieve the current status of a timestamp job by its ID + operationId: getJobStatus + parameters: + - name: jobId + in: path + description: ID of the job to get status for + required: true + schema: + type: string + responses: + '200': + description: OK - Job status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '400': + description: Bad Request - Missing job ID + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found - Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/verify/{jobId}: + get: + security: + - BearerAuth: [] + tags: + - API + summary: Get verification details for a timestamp job + description: Retrieve comprehensive block details and verification information for a specific timestamp job + operationId: getJobVerification + parameters: + - name: jobId + in: path + description: ID of the job to get verification details for + required: true + schema: + type: string + responses: + '200': + description: OK - Job verification details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '400': + description: Bad Request - Missing job ID + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found - Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/batch/status: + post: + security: + - BearerAuth: [] + tags: + - API + summary: Get the status of multiple timestamp jobs + description: Retrieve the current status of multiple timestamp jobs by their IDs + operationId: getBatchJobStatus + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchStatusRequest' + responses: + '200': + description: OK - Batch job statuses retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BatchStatusResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /admin/dashboard: + get: + security: + - BearerAuth: [] + tags: + - Admin + summary: Admin dashboard + description: HTML dashboard for administrators + operationId: getAdminDashboard + responses: + '200': + description: OK - Admin dashboard HTML + content: + text/html: + schema: + type: string + '405': + description: Method Not Allowed + + /admin/metrics: + get: + security: + - BearerAuth: [] + tags: + - Admin + summary: Get system metrics + description: Retrieve system metrics including blockchain height and queue length + operationId: getMetrics + responses: + '200': + description: OK - System metrics + content: + application/json: + schema: + $ref: '#/components/schemas/MetricsResponse' + '405': + description: Method Not Allowed + + /admin/blockchain: + get: + security: + - BearerAuth: [] + tags: + - Admin + summary: Get blockchain information + description: Retrieve information about the blockchain + operationId: getBlockchainInfo + responses: + '200': + description: OK - Blockchain information + content: + application/json: + schema: + $ref: '#/components/schemas/BlockchainInfoResponse' + '405': + description: Method Not Allowed + + /admin/queue: + get: + security: + - BearerAuth: [] + tags: + - Admin + summary: Get queue information + description: Retrieve information about the job queue + operationId: getQueueInfo + responses: + '200': + description: OK - Queue information + content: + application/json: + schema: + $ref: '#/components/schemas/QueueInfoResponse' + '405': + description: Method Not Allowed + + /admin/timestamps: + get: + security: + - BearerAuth: [] + tags: + - Admin + summary: Get recent timestamps + description: Retrieve the most recent timestamps (up to 10) + operationId: getRecentTimestamps + responses: + '200': + description: OK - Recent timestamps + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BlockInfo' + '405': + description: Method Not Allowed + + /admin/hash: + get: + security: + - BearerAuth: [] + tags: + - Admin + summary: Get block by hash + description: Retrieve a block by its hash + operationId: getBlockByHash + parameters: + - name: hash + in: query + description: Hash of the block to retrieve + required: true + schema: + type: string + - name: format + in: query + description: Format of the hash (base64 or raw) + required: false + schema: + type: string + enum: [base64, raw] + default: base64 + responses: + '200': + description: OK - Block information + content: + application/json: + schema: + $ref: '#/components/schemas/BlockInfo' + '400': + description: Bad Request - Missing or invalid hash + '404': + description: Not Found - Block not found + '405': + description: Method Not Allowed + + /auth/login: + post: + tags: + - Auth + summary: Login to the system + description: Authenticate with username and password to receive a JWT token + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: OK - Login successful + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: Unauthorized - Invalid credentials + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/validate: + get: + tags: + - Auth + summary: Validate a token + description: Validate a JWT token and get user information + operationId: validateToken + parameters: + - name: Authorization + in: header + description: | + JWT token with Bearer prefix. Example: + `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNjcyNjYxNjAwLCJpYXQiOjE2NzI1NzUyMDAsIm5iZiI6MTY3MjU3NTIwMCwiaXNzIjoidGltZXN0YW1waW5nLXNlcnZpY2UifQ.8yF71oHxDCQ5-OW2c-KR7q-PJxbaHvmDGmSlOFREIQY` + required: false + schema: + type: string + example: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNjcyNjYxNjAwLCJpYXQiOjE2NzI1NzUyMDAsIm5iZiI6MTY3MjU3NTIwMCwiaXNzIjoidGltZXN0YW1waW5nLXNlcnZpY2UifQ.8yF71oHxDCQ5-OW2c-KR7q-PJxbaHvmDGmSlOFREIQY" + - name: token + in: query + description: | + JWT token (alternative to Authorization header). Example: + `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNjcyNjYxNjAwLCJpYXQiOjE2NzI1NzUyMDAsIm5iZiI6MTY3MjU3NTIwMCwiaXNzIjoidGltZXN0YW1waW5nLXNlcnZpY2UifQ.8yF71oHxDCQ5-OW2c-KR7q-PJxbaHvmDGmSlOFREIQY` + required: false + schema: + type: string + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNjcyNjYxNjAwLCJpYXQiOjE2NzI1NzUyMDAsIm5iZiI6MTY3MjU3NTIwMCwiaXNzIjoidGltZXN0YW1waW5nLXNlcnZpY2UifQ.8yF71oHxDCQ5-OW2c-KR7q-PJxbaHvmDGmSlOFREIQY" + responses: + '200': + description: OK - Token is valid + content: + application/json: + schema: + $ref: '#/components/schemas/TokenValidationResponse' + '401': + description: Unauthorized - Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/users: + get: + tags: + - Auth + summary: List all users + description: Get a list of all users (admin only) + operationId: listUsers + security: + - BearerAuth: [] + responses: + '200': + description: OK - List of users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserResponse' + '401': + description: Unauthorized - Invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Auth + summary: Create a new user + description: Create a new user (admin only) + operationId: createUser + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: Created - User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Conflict - User already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/users/{username}: + get: + tags: + - Auth + summary: Get a user + description: Get a specific user by username (admin only) + operationId: getUser + security: + - BearerAuth: [] + parameters: + - name: username + in: path + description: Username of the user to get + required: true + schema: + type: string + responses: + '200': + description: OK - User details + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '401': + description: Unauthorized - Invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found - User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Auth + summary: Update a user + description: Update a specific user by username (admin only) + operationId: updateUser + security: + - BearerAuth: [] + parameters: + - name: username + in: path + description: Username of the user to update + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: OK - User updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '400': + description: Bad Request - Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found - User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Auth + summary: Delete a user + description: Delete a specific user by username (admin only) + operationId: deleteUser + security: + - BearerAuth: [] + parameters: + - name: username + in: path + description: Username of the user to delete + required: true + schema: + type: string + responses: + '204': + description: No Content - User deleted successfully + '401': + description: Unauthorized - Invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not Found - User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + JWT token obtained from the /auth/login endpoint. + + To use this token: + 1. First call the /auth/login endpoint with your username and password + 2. Copy the token value from the response + 3. Click the "Authorize" button at the top of this page + 4. In the authorization popup, paste the token value (without "Bearer " prefix) + 5. Click "Authorize" to apply the token to all secured endpoints + + The token must be included in the Authorization header as: + `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNjcyNjYxNjAwLCJpYXQiOjE2NzI1NzUyMDAsIm5iZiI6MTY3MjU3NTIwMCwiaXNzIjoidGltZXN0YW1waW5nLXNlcnZpY2UifQ.8yF71oHxDCQ5-OW2c-KR7q-PJxbaHvmDGmSlOFREIQY` + schemas: + TimestampRequest: + type: object + required: + - data + properties: + data: + type: string + description: | + SHA-256 digest to timestamp (64 hex). Prefer sending the digest here + and in `digest`. If `data` is not 64-hex, the server hashes it with + `hashAlg` (legacy); digests-only clients should always send hex. + digest: + type: string + description: | + Explicit SHA-256 digest (64 hex; optional `sha256:` prefix). When set, + this value is stamped and `data` is not re-hashed. Matches MCP global stamp. + hashAlg: + type: string + description: Hash algorithm (defaults to SHA256). Used only when `digest` is absent and `data` is not already 64-hex. + default: SHA256 + restamp: + type: boolean + description: | + When false (default / recommended), reuse the first certificate (CID) + if this digest was already stamped. Set true only to force a new stamp. + default: false + example: + data: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + digest: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + hashAlg: "SHA256" + restamp: false + + TimestampResponse: + type: object + properties: + jobId: + type: string + description: ID of the submitted timestamp job + existing: + type: boolean + description: True when an existing certificate was reused (`restamp: false`) + certificate: + type: string + description: Certificate / CID when available on the response + restampOf: + type: string + description: Prior certificate id when this is a restamp + example: + jobId: "550e8400-e29b-41d4-a716-446655440000" + + BatchTimestampRequest: + type: object + required: + - items + properties: + items: + type: array + description: Array of digests to timestamp (multihash submit) + items: + $ref: '#/components/schemas/TimestampRequest' + example: + items: + - data: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + digest: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + hashAlg: "SHA256" + restamp: false + - data: "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d" + digest: "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d" + hashAlg: "SHA256" + restamp: false + + BatchTimestampResponse: + type: object + properties: + jobIds: + type: array + description: Array of job IDs for the submitted timestamp jobs + items: + type: string + example: + jobIds: + - "550e8400-e29b-41d4-a716-446655440000" + - "650e8400-e29b-41d4-a716-446655440001" + + VerifyRequest: + type: object + required: + - certificate + properties: + certificate: + type: string + description: Timestamp certificate to verify + example: + certificate: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + + VerifyResponse: + type: object + properties: + valid: + type: boolean + description: Whether the certificate is valid + timestamp: + type: string + format: date-time + description: Timestamp when the data was recorded (only present if valid) + blockIndex: + type: integer + format: uint64 + description: Index of the block containing the timestamp (only present if valid) + example: + valid: true + timestamp: "2023-01-01T12:00:00Z" + blockIndex: 42 + + BatchVerifyResponse: + type: object + properties: + results: + type: array + description: Array of verification results + items: + $ref: '#/components/schemas/VerifyResponse' + example: + results: + - valid: true + timestamp: "2023-01-01T12:00:00Z" + blockIndex: 42 + - valid: false + + BatchStatusRequest: + type: object + required: + - jobIds + properties: + jobIds: + type: array + description: Array of job IDs to get status for + items: + type: string + example: + jobIds: + - "550e8400-e29b-41d4-a716-446655440000" + - "650e8400-e29b-41d4-a716-446655440001" + + BatchStatusResponse: + type: object + properties: + results: + type: object + description: Map of job IDs to status responses + additionalProperties: + $ref: '#/components/schemas/StatusResponse' + example: + results: + "550e8400-e29b-41d4-a716-446655440000": + id: "550e8400-e29b-41d4-a716-446655440000" + status: "completed" + result: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + completedAt: "2023-01-01T12:05:00Z" + "650e8400-e29b-41d4-a716-446655440001": + id: "650e8400-e29b-41d4-a716-446655440001" + status: "pending" + + StatusResponse: + type: object + properties: + id: + type: string + description: Job ID + status: + type: string + description: Job status (pending, completed, failed) + error: + type: string + description: Error message (only present if status is failed) + result: + type: string + description: Job result (only present if status is completed) + completedAt: + type: string + format: date-time + description: Timestamp when the job was completed (only present if status is completed or failed) + metadata: + type: object + description: Additional metadata about the job + example: + id: "550e8400-e29b-41d4-a716-446655440000" + status: "completed" + result: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + completedAt: "2023-01-01T12:05:00Z" + metadata: + blockIndex: 42 + timestamp: "2023-01-01T12:00:00Z" + certificate: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + block: + index: 42 + timestamp: "2023-01-01T12:00:00Z" + hash: "QmFzZTY0IGVuY29kZWQgaGFzaA==" + previousHash: "UHJldmlvdXMgYmxvY2sgaGFzaA==" + hashAlgorithm: "SHA256" + transactionCount: 1 + transactions: + - id: "block-42" + timestamp: "2023-01-01T12:00:00Z" + type: "block" + dataHash: "SGFzaCBvZiB0aGUgZGF0YQ==" + chain: + height: 1024 + lastBlockTime: "2023-01-01T12:05:00Z" + + ErrorResponse: + type: object + properties: + error: + type: string + description: Error message + code: + type: string + description: Error code + example: + error: "Invalid input data" + code: "VALIDATION_ERROR" + + MetricsResponse: + type: object + properties: + blockchainHeight: + type: integer + format: uint64 + description: Current height of the blockchain + queueLength: + type: integer + description: Current length of the job queue + example: + blockchainHeight: 1024 + queueLength: 5 + + BlockchainInfoResponse: + type: object + properties: + height: + type: integer + format: uint64 + description: Current height of the blockchain + example: + height: 1024 + + QueueInfoResponse: + type: object + properties: + length: + type: integer + description: Current length of the job queue + example: + length: 5 + + BlockInfo: + type: object + properties: + index: + type: integer + format: uint64 + description: Block index + timestamp: + type: string + description: Block timestamp + data: + type: string + format: byte + description: Block data + hash: + type: string + format: byte + description: Block hash + example: + index: 42 + timestamp: "2023-01-01T12:00:00Z" + data: "SGVsbG8sIHdvcmxkIQ==" + hash: "QmFzZTY0IGVuY29kZWQgaGFzaA==" + + LoginRequest: + type: object + required: + - username + - password + properties: + username: + type: string + description: Username for authentication + password: + type: string + description: Password for authentication + format: password + example: + username: "admin" + password: "admin123" + + LoginResponse: + type: object + properties: + token: + type: string + description: JWT token for authentication + expiresAt: + type: string + format: date-time + description: Token expiration time + user: + type: object + properties: + id: + type: string + description: User ID + username: + type: string + description: Username + role: + type: string + description: User role (admin, user, etc.) + example: + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNjcyNjYxNjAwLCJpYXQiOjE2NzI1NzUyMDAsIm5iZiI6MTY3MjU3NTIwMCwiaXNzIjoidGltZXN0YW1waW5nLXNlcnZpY2UifQ.8yF71oHxDCQ5-OW2c-KR7q-PJxbaHvmDGmSlOFREIQY" + expiresAt: "2023-01-02T12:00:00Z" + user: + id: "550e8400-e29b-41d4-a716-446655440000" + username: "admin" + role: "admin" + + TokenValidationResponse: + type: object + properties: + valid: + type: boolean + description: Whether the token is valid + userId: + type: string + description: User ID (only present if valid) + username: + type: string + description: Username (only present if valid) + role: + type: string + description: User role (only present if valid) + expiresAt: + type: string + format: date-time + description: Token expiration time (only present if valid) + example: + valid: true + userId: "550e8400-e29b-41d4-a716-446655440000" + username: "admin" + role: "admin" + expiresAt: "2023-01-02T12:00:00Z" + + UserResponse: + type: object + properties: + id: + type: string + description: User ID + username: + type: string + description: Username + role: + type: string + description: User role (admin, user, etc.) + example: + id: "550e8400-e29b-41d4-a716-446655440000" + username: "admin" + role: "admin" + + CreateUserRequest: + type: object + required: + - username + - password + - role + properties: + username: + type: string + description: Username for the new user + password: + type: string + description: Password for the new user + format: password + role: + type: string + description: Role for the new user (admin, user, etc.) + example: + username: "newuser" + password: "password123" + role: "user" + + UpdateUserRequest: + type: object + properties: + password: + type: string + description: New password (optional) + format: password + role: + type: string + description: New role (optional) + example: + password: "newpassword123" + role: "admin" \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml index f1ac451..c38ece9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,10 +1,30 @@ openapi: 3.0.3 info: - title: Zappier API - version: 0.1.0 - description: Metered data API. Every priced call returns its quote. + title: Zappier API (zap.verae.ai) + version: 0.2.0 + description: | + Metered data API for Zapier and direct API clients. + + **Timestamping (v1):** digests-only. Edge meters the call, then waits on + middleware `POST /api/v1/timestamp` (legacy alias `POST /api/timestamp`) + which calls Verae Time `POST /api/timestamp` — same path MCP uses on + api.veraetime.net. Responses include `apiVersion: v1`, `receipt_id` (CID), + `created_at`, and `merkle` when `wait` is true (default). + + **Back-compat:** unversioned middleware `/api/*` aliases remain. Upstream + Verae Time and MCP keep `/api/timestamp` unchanged. + + **Optional exports:** set `attachPdf: true` or `formats: ["pdf"]` (also + `csv`, `text`) for registration-document fields like mcp.verae.ai/register.html. + PDF is off by default. + + **Auth:** prefer long-lived `xVerae-…` tokens from the customer portal + (`/portal` → Security). Send as `x-api-key` or `Authorization: Bearer`. + Legacy `key-…` customer keys remain valid. Create/list/revoke via + `/portal/api/tokens` (portal session required). security: - apiKey: [] + - bearerToken: [] paths: /v1/status: get: @@ -58,10 +78,75 @@ paths: responses: '200': description: OK + + /v1/timestamps: + post: + operationId: timestampsBatch + summary: Batch register digests (wait + receipts; Verae /api/batch/timestamp) + description: | + Digests only — hash locally (same as https://mcp.verae.ai/). + Edge calls middleware `POST /api/v1/batch/timestamp` (legacy `/api/batch/timestamp`), + which uses Verae Time `POST /api/batch/timestamp` when the plan allows, otherwise + sequential `POST /api/timestamp` + wait (MCP-style). Max 500. + Default `wait: true` returns `receipt_id` / `created_at` / `merkle` per result. + `attachPdf` is opt-in. + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + required: [items] + properties: + items: + type: array + maxItems: 500 + items: + $ref: '#/components/schemas/DigestStampItem' + wait: + type: boolean + default: true + attachPdf: + type: boolean + default: false + description: Opt-in registration PDF (pdfBase64) per result + formats: + $ref: '#/components/schemas/ResultFormats' + - type: object + required: [sha256s] + properties: + sha256s: + type: array + maxItems: 500 + items: + type: string + description: 64-hex SHA-256 digest + wait: + type: boolean + default: true + attachPdf: + type: boolean + default: false + formats: + $ref: '#/components/schemas/ResultFormats' + responses: + '200': + description: Completed (wait=true) — results with receipt_id / created_at + content: + application/json: + schema: + $ref: '#/components/schemas/BatchStampResponse' + '202': + description: Accepted async (wait=false) — jobIds /v1/timestamp: post: operationId: timestamp - summary: Register a timestamp (proxies middleware or in-process mock) + summary: Register one digest (wait + receipt; Verae /api/timestamp) + description: | + Digests only. Proxies middleware `POST /api/v1/timestamp` (legacy `/api/timestamp`) + → Verae Time `POST /api/timestamp` (identical path MCP uses). Waits by default + and returns MCP-shaped receipt fields. Set `attachPdf: true` for the registration PDF. requestBody: required: true content: @@ -71,13 +156,40 @@ paths: properties: data: type: string + description: Raw UTF-8 (hashed server-side) or 64-hex digest sha256: type: string + description: Preferred 64-hex SHA-256 + digest: + type: string + description: Alias of sha256 hashAlg: type: string + default: SHA256 + documentName: + type: string + description: Shown on registration PDF / text export + wait: + type: boolean + default: true + attachPdf: + type: boolean + default: false + description: Opt-in Verae Timestamp Registration Document (pdfBase64) + formats: + $ref: '#/components/schemas/ResultFormats' + restamp: + type: boolean + default: false responses: + '200': + description: Completed — receipt_id (CID), created_at, merkle; pdfBase64 if attachPdf + content: + application/json: + schema: + $ref: '#/components/schemas/StampReceipt' '202': - description: Accepted + description: Accepted async (wait=false) /v1/hashes/{sha256}: get: operationId: hash-lookup @@ -142,3 +254,116 @@ components: type: apiKey in: header name: x-api-key + description: Legacy `key-…` or long-lived `xVerae-…` token from the portal + bearerToken: + type: http + scheme: bearer + description: Same `xVerae-…` token as Bearer (LLM-key style) + schemas: + ResultFormats: + description: | + Export formats (register.html-style). Default is JSON body only. + Include `pdf` or set attachPdf=true for the registration PDF. + oneOf: + - type: string + enum: [json, csv, text, pdf, all] + - type: array + items: + type: string + enum: [json, csv, text, pdf] + DigestStampItem: + type: object + properties: + data: + type: string + description: 64-hex SHA-256 digest + digest: + type: string + sha256: + type: string + hashAlg: + type: string + default: SHA256 + restamp: + type: boolean + default: false + documentName: + type: string + StampReceipt: + type: object + properties: + apiVersion: + type: string + example: v1 + jobId: + type: string + format: uuid + sha256: + type: string + status: + type: string + example: completed + existing: + type: boolean + receipt_id: + type: string + description: Certificate CID (MCP receipt_id) + certificate: + type: string + description: Alias of receipt_id + created_at: + type: string + format: date-time + timestamp: + type: string + format: date-time + merkle: + type: string + description: Block index / merkle hint from Verae status metadata + scope: + type: string + example: global + lookup_url: + type: string + format: uri + documentName: + type: string + pdfBase64: + type: string + description: Present only when attachPdf=true or formats includes pdf + pdfFilename: + type: string + pdfContentType: + type: string + example: application/pdf + text: + type: string + csv: + type: string + quote: + type: object + BatchStampResponse: + type: object + properties: + apiVersion: + type: string + example: v1 + mode: + type: string + enum: [batch, sequential] + jobIds: + type: array + items: + type: string + count: + type: integer + created: + type: integer + existing: + type: integer + totalCredits: + type: number + results: + type: array + items: + $ref: '#/components/schemas/StampReceipt' diff --git a/package-lock.json b/package-lock.json index 567b4f0..c339bd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "zappier", "version": "0.1.0", "dependencies": { + "@libsql/client": "^0.18.0", "@types/qrcode": "^1.5.6", "better-sqlite3": "^12.11.1", "dotenv": "^17.4.2", "express": "^4.19.2", "express-openapi-validator": "^5.3.0", "nats": "^2.29.3", + "nodemailer": "^6.10.1", "qrcode": "^1.5.4", "stripe": "^16.0.0", "swagger-ui-express": "^5.0.0", @@ -26,6 +28,7 @@ "@types/jest": "^29.5.12", "@types/multer": "^1.4.11", "@types/node": "^20.14.0", + "@types/nodemailer": "^6.4.24", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/yamljs": "^0.2.34", @@ -1023,6 +1026,171 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.18.0.tgz", + "integrity": "sha512-zMCCo58vv7w27j7+cnt/GW+X6/Rih6cHZ86PKi0AxCMOqMfrVgG9OZ5lW2bDMJhSeOjsv2szH2nJF3A9l36UmA==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.18.0", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.18.0.tgz", + "integrity": "sha512-N8RR2CIpcgtbKZnXs2KVpw2lJQfinUj3I56e7qRDRpK18Hty0Rr8nr00VmFb4R/DsqOanPB/TaP3+QAQLZeXMg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1316,6 +1484,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.24", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", + "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -1409,6 +1587,15 @@ "@types/serve-static": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yamljs": { "version": "0.2.34", "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.34.tgz", @@ -4041,6 +4228,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-base64": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz", + "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4129,6 +4322,47 @@ "node": ">=6" } }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4487,6 +4721,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4796,6 +5039,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6101,6 +6350,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 5932a26..2948fc7 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "test": "jest" }, "dependencies": { + "@libsql/client": "^0.18.0", "@types/qrcode": "^1.5.6", "better-sqlite3": "^12.11.1", "dotenv": "^17.4.2", "express": "^4.19.2", "express-openapi-validator": "^5.3.0", "nats": "^2.29.3", + "nodemailer": "^6.10.1", "qrcode": "^1.5.4", "stripe": "^16.0.0", "swagger-ui-express": "^5.0.0", @@ -27,6 +29,7 @@ "@types/jest": "^29.5.12", "@types/multer": "^1.4.11", "@types/node": "^20.14.0", + "@types/nodemailer": "^6.4.24", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/yamljs": "^0.2.34", diff --git a/portal/app.js b/portal/app.js index 82d9bbb..f8b8986 100644 --- a/portal/app.js +++ b/portal/app.js @@ -2,6 +2,184 @@ const state = { me: null, usage: null, invoices: [], pricing: null, statement: n const TOKEN_KEY = 'zappier-portal-token'; let authMode = 'login'; // 'login' | 'signup' +/* ---------------- hostname surfaces ---------------- */ +/* zap.verae.ai → Zapier customers; customer.verae.ai → MCP/API/PDF/DataCube */ + +const SURFACES = { + zap: { + id: 'zap', + host: 'zap.verae.ai', + origin: 'https://zap.verae.ai', + title: 'Zappier Portal — Verae timestamping for Zapier', + brand: 'Zappier', + brandLetter: 'Z', + siteName: 'Zappier by Verae', + description: + 'Customer portal for Verae digests-only timestamping on Zapier. Stamp SHA-256 digests via the Zapier Platform app or REST API, optional registration PDF, prepaid billing and xVerae- API tokens.', + keywords: + 'Zapier, timestamping, SHA-256, digests-only, Verae, xVerae, registration PDF, blockchain timestamp', + applicationCategory: 'BusinessApplication', + authSubLogin: 'Sign in to manage Zapier stamps, usage, and API keys.', + authSubSignup: 'Create your Zapier customer account — you get an API key immediately.', + docsNav: 'Zapier & pricing', + docsTitle: 'Zapier, API & pricing', + docsLede: + 'Stamp digests from Zapier or the client hasher. Prefer long-lived xVerae- tokens (Security) as x-api-key or Bearer. Interactive reference: /docs.', + offeringsHtml: ` +
+

Zapier offerings

+
    +
  • Zapier Platform app — creates for single and batch timestamp; set API Base URL to https://zap.verae.ai and your key.
  • +
  • Client hasher — hash files in the browser at /client/hasher/, then stamp digests only (no file upload).
  • +
  • REST — POST /v1/timestamp and POST /v1/timestamps with digests; wait-by-default; optional attachPdf.
  • +
+

Billing channel for Zapier Zaps: x-billing-channel: zapier. MCP / DataCube / direct Verae Time customers: use customer.verae.ai.

+
`, + securityHint: + 'Shown once after create — copy into Zapier, the hasher, or the save field below. Use as x-api-key or Bearer on zap.verae.ai.', + dashboardExtra: + '

Zapier docs and hasher live on this host. For MCP, PDF registration, and DataCube instructions see customer.verae.ai.

', + }, + customer: { + id: 'customer', + host: 'customer.verae.ai', + origin: 'https://customer.verae.ai', + title: 'Verae Customer Portal — MCP, API, PDF & DataCube', + brand: 'Verae', + brandLetter: 'V', + siteName: 'Verae Customer Portal', + description: + 'Account portal for Verae MCP, REST API, registration PDF, and DataCube customers. Digests-only timestamping, long-lived xVerae- tokens, prepaid billing, and usage instructions for agents and apps.', + keywords: + 'Verae, MCP, timestamping, DataCube, Peergos, registration PDF, xVerae, digests-only, SHA-256, API', + applicationCategory: 'DeveloperApplication', + authSubLogin: 'Sign in for MCP, API, PDF registration, and DataCube account tools.', + authSubSignup: 'Create your Verae customer account — mint an xVerae- token under Security after sign-in.', + docsNav: 'API, MCP & pricing', + docsTitle: 'API, MCP, PDF & pricing', + docsLede: + 'Digests-only stamps for apps, MCP, and DataCubes. Prefer xVerae- tokens (Security) as x-api-key or Bearer on the edge. OpenAPI: /docs. MCP app client: application-client.yaml.', + offeringsHtml: ` +
+

MCP · API · PDF · DataCube

+
    +
  • MCP — stamp from agents via mcp.verae.ai; digests-only; hash locally before stamp.
  • +
  • Direct API — POST /v1/timestamp and POST /v1/timestamps on this account’s edge (zap.verae.ai API host) with wait-by-default and optional attachPdf:true for a registration PDF.
  • +
  • PDF registration — opt-in PDF on completed stamps for print / archive; JSON receipt is the default.
  • +
  • DataCube — Peergos / DataCube outbox stamps use the same digests-only contract; store xVerae- in the app secret store.
  • +
+

Zapier Platform customers: use zap.verae.ai for Zap-oriented setup. Upstream Verae Time (api.veraetime.net) still uses MCP JWT until it accepts xVerae- natively.

+
`, + securityHint: + 'Shown once after create — copy into MCP clients, apps, or DataCube config. Use as x-api-key or Bearer on the edge API host. Keep MCP JWT for direct api.veraetime.net until that service accepts xVerae-.', + dashboardExtra: + '

Account shared with Zapier billing. Zap-specific install steps: zap.verae.ai.

', + }, +}; + +function resolveSurface() { + const host = (location.hostname || '').toLowerCase(); + if (host === 'zap.verae.ai' || host.startsWith('zap.')) return SURFACES.zap; + return SURFACES.customer; +} + +const surface = resolveSurface(); + +function setMeta(nameOrProp, content, attr = 'name') { + if (!content) return; + let el = document.querySelector(`meta[${attr}="${nameOrProp}"]`); + if (!el) { + el = document.createElement('meta'); + el.setAttribute(attr, nameOrProp); + document.head.appendChild(el); + } + el.setAttribute('content', content); +} + +function schemaOrgForSurface(s) { + const origin = s.origin; + return { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Organization', + '@id': `${origin}/#organization`, + name: 'Verae', + url: 'https://verae.ai/', + sameAs: ['https://zap.verae.ai/', 'https://customer.verae.ai/', 'https://mcp.verae.ai/'], + }, + { + '@type': 'WebSite', + '@id': `${origin}/#website`, + url: `${origin}/`, + name: s.siteName, + description: s.description, + publisher: { '@id': `${origin}/#organization` }, + inLanguage: 'en-US', + }, + { + '@type': 'WebPage', + '@id': `${origin}/portal/#webpage`, + url: `${origin}/portal/`, + name: s.title, + description: s.description, + isPartOf: { '@id': `${origin}/#website` }, + about: { '@id': `${origin}/#organization` }, + inLanguage: 'en-US', + }, + { + '@type': 'SoftwareApplication', + '@id': `${origin}/#app`, + name: s.siteName, + applicationCategory: s.applicationCategory, + operatingSystem: 'Web', + url: `${origin}/portal/`, + description: s.description, + offers: { + '@type': 'Offer', + price: '0', + priceCurrency: 'USD', + description: 'Prepaid credit plans; free tier available', + }, + provider: { '@id': `${origin}/#organization` }, + }, + ], + }; +} + +function applySurfaceChrome() { + const canonical = `${surface.origin}/portal/`; + document.title = surface.title; + const authTitle = document.getElementById('auth-title'); + // Short brand title on the login card + if (authTitle) authTitle.textContent = surface.siteName || surface.brand; + const brandDot = document.getElementById('brand-dot'); + const brandName = document.getElementById('brand-name'); + if (brandDot) brandDot.textContent = surface.brandLetter; + if (brandName) brandName.textContent = surface.brand; + const navDocs = document.getElementById('nav-docs'); + if (navDocs) navDocs.innerHTML = `▤  ${surface.docsNav}`; + + // Google / social SEO + setMeta('description', surface.description); + setMeta('keywords', surface.keywords); + setMeta('og:site_name', surface.siteName, 'property'); + setMeta('og:title', surface.title, 'property'); + setMeta('og:description', surface.description, 'property'); + setMeta('og:url', canonical, 'property'); + setMeta('twitter:title', surface.title); + setMeta('twitter:description', surface.description); + const linkCanon = document.getElementById('link-canonical'); + if (linkCanon) linkCanon.setAttribute('href', canonical); + + // schema.org JSON-LD + const schemaEl = document.getElementById('schema-org'); + if (schemaEl) schemaEl.textContent = JSON.stringify(schemaOrgForSurface(surface)); +} + +applySurfaceChrome(); +document.getElementById('auth-sub').textContent = surface.authSubLogin; + /* ---------------- auth ---------------- */ function token() { @@ -24,9 +202,10 @@ function setAuthMode(mode) { const isSignup = mode === 'signup'; document.getElementById('name-group').style.display = isSignup ? 'block' : 'none'; document.getElementById('totp-group').style.display = 'none'; + document.getElementById('forgot-link').style.display = isSignup ? 'none' : 'block'; document.getElementById('auth-sub').textContent = isSignup - ? 'Create your customer account — you get an API key immediately.' - : 'Sign in to your customer account.'; + ? surface.authSubSignup + : surface.authSubLogin; document.getElementById('auth-submit').textContent = isSignup ? 'Create account' : 'Sign in'; document.getElementById('auth-switch').innerHTML = isSignup ? 'Already have an account? Sign in' @@ -72,6 +251,108 @@ document.getElementById('auth-form').addEventListener('submit', async (e) => { } }); +/* ---------------- forgot / reset password ---------------- */ + +let resetStep = 1; + +function showReset(show) { + document.getElementById('auth-form').style.display = show ? 'none' : ''; + document.getElementById('reset-form').style.display = show ? 'block' : 'none'; + document.getElementById('reset-error').textContent = ''; + if (show) { + resetStep = 1; + document.getElementById('reset-step2').style.display = 'none'; + document.getElementById('reset-submit').textContent = 'Send code'; + document.getElementById('reset-sub').textContent = + "Enter your account email and we'll send you a 6-digit code."; + document.getElementById('reset-email').value = document.getElementById('auth-email').value.trim(); + document.getElementById('reset-email').focus(); + } +} + +async function requestResetCode(email) { + const res = await fetch('/portal/api/password/forgot', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (res.status === 202 || res.ok) return; + let data = {}; + try { + data = await res.json(); + } catch { + /* non-JSON */ + } + if (data.error === 'email_unavailable') { + throw new Error(data.message || 'Password reset email is not available right now.'); + } + throw new Error(data.error || `Could not send a code (HTTP ${res.status})`); +} + +document.getElementById('forgot-link').addEventListener('click', () => showReset(true)); +document.getElementById('reset-back').addEventListener('click', () => showReset(false)); + +document.getElementById('reset-resend').addEventListener('click', async () => { + const errEl = document.getElementById('reset-error'); + try { + await requestResetCode(document.getElementById('reset-email').value.trim()); + errEl.textContent = 'A new code is on its way.'; + } catch (err) { + errEl.textContent = err.message; + } +}); + +document.getElementById('reset-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errEl = document.getElementById('reset-error'); + errEl.textContent = ''; + const email = document.getElementById('reset-email').value.trim(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + errEl.textContent = 'Enter a valid email address.'; + return; + } + try { + if (resetStep === 1) { + await requestResetCode(email); + resetStep = 2; + document.getElementById('reset-step2').style.display = 'block'; + document.getElementById('reset-submit').textContent = 'Set new password'; + document.getElementById('reset-sub').textContent = + `If an account exists for ${email}, we've emailed it a 6-digit code (valid 15 minutes).`; + document.getElementById('reset-code').focus(); + return; + } + const code = document.getElementById('reset-code').value.trim(); + const password = document.getElementById('reset-password').value; + const password2 = document.getElementById('reset-password2').value; + if (!/^\d{6}$/.test(code)) throw new Error('Enter the 6-digit code from the email.'); + if (password.length < 8) throw new Error('Password must be at least 8 characters.'); + if (password !== password2) throw new Error('The passwords do not match.'); + const res = await fetch('/portal/api/password/reset', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, code, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const map = { + invalid_code: 'That code is not right. Check the email and try again.', + code_expired: 'That code has expired. Send a new code.', + too_many_attempts: 'Too many wrong codes. Send a new code.', + }; + throw new Error(map[data.error] || data.error || `Reset failed (HTTP ${res.status})`); + } + showReset(false); + document.getElementById('auth-email').value = email; + document.getElementById('auth-password').value = ''; + document.getElementById('auth-error').textContent = + 'Password updated. Sign in with your new password.'; + document.getElementById('auth-password').focus(); + } catch (err) { + errEl.textContent = err.message; + } +}); + document.getElementById('logout').addEventListener('click', async () => { try { await api('/logout', { method: 'POST', body: '{}' }); @@ -125,6 +406,7 @@ async function load() { state.invoices = (await api('/invoices')).invoices; state.pricing = await api('/pricing'); state.statement = await api('/statement'); + state.tokens = (await api('/tokens')).tokens || []; renderDashboard(); renderInvoices(); renderStatement(); @@ -141,7 +423,8 @@ function renderDashboard() { const tier = state.pricing.tiers.find((t) => t.id === me.tierId); document.getElementById('dashboard').innerHTML = `

Welcome, ${me.name}

-

${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'}

+

${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'} · ${surface.host}

+ ${surface.dashboardExtra}
Usage this month
${fmt(u.totalCents)}
Included credit
${fmt(u.includedCents)}
@@ -149,14 +432,20 @@ function renderDashboard() {
Prepaid balance
${fmt(me.balanceCents)}
-

Your API key

+

Legacy API key

${maskKey(me.apiKey)}
-

Send it as the x-api-key header. The key stays masked on screen. Copy puts the full key on the clipboard. Regenerating invalidates the old key immediately. Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.

+

Still works as x-api-key. Prefer long-lived xVerae- tokens below (create under Security). Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.

+
+
+

Long-lived tokens (xVerae-)

+

LLM-style keys for apps and services. Create under Security. Use as x-api-key or Authorization: Bearer on https://zap.verae.ai.

+

Active tokens: ${(state.tokens || []).length}

+
`; } @@ -270,6 +559,32 @@ function renderStatement() { /* ---------------- billing ---------------- */ +let billingConfig = null; // { stripePublishableKey, mode } +let stripePromise = null; +let activeStripe = null; +let activeElements = null; + +async function fetchBillingConfig() { + if (billingConfig) return billingConfig; + const res = await fetch('/portal/api/billing/config'); + billingConfig = await res.json(); + return billingConfig; +} + +function loadStripeJs() { + if (window.Stripe) return Promise.resolve(window.Stripe); + if (stripePromise) return stripePromise; + stripePromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'https://js.stripe.com/v3/'; + s.async = true; + s.onload = () => (window.Stripe ? resolve(window.Stripe) : reject(new Error('Stripe.js failed to load'))); + s.onerror = () => reject(new Error('Stripe.js failed to load')); + document.head.appendChild(s); + }); + return stripePromise; +} + function renderBilling() { const me = state.me; document.getElementById('billing').innerHTML = ` @@ -279,9 +594,17 @@ function renderBilling() {

Reload balance ${fmt(me.balanceCents)}

- +
-

Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured you’ll complete payment securely.

+ +

Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured you’ll complete payment securely in-page.

Email invoicing

@@ -291,16 +614,131 @@ function renderBilling() { Email me new invoices
`; + activeStripe = null; + activeElements = null; } async function reload() { const dollars = Number(document.getElementById('reload-amount').value); const amountCents = Math.round(dollars * 100); - const res = await api('/reload', { method: 'POST', body: JSON.stringify({ amountCents }) }); - state.me.balanceCents = res.balanceCents; - renderBilling(); - if (res.mode === 'dev') say(`Balance reloaded — new balance ${fmt(res.balanceCents)}.`); - else say('Payment started — your balance updates when the payment confirms.'); + const btn = document.getElementById('reload-btn'); + if (btn) btn.disabled = true; + try { + const res = await api('/reload', { method: 'POST', body: JSON.stringify({ amountCents }) }); + if (res.mode === 'dev') { + state.me.balanceCents = res.balanceCents; + renderBilling(); + say(`Balance reloaded — new balance ${fmt(res.balanceCents)}.`); + return; + } + if (res.mode === 'stripe' && res.clientSecret) { + await mountStripePayment(res.clientSecret); + say('Enter your card details to complete payment.'); + return; + } + say('Payment started — your balance updates when the payment confirms.'); + } catch (err) { + say(err.message || String(err), true); + } finally { + if (btn) btn.disabled = false; + } +} + +async function mountStripePayment(clientSecret) { + const cfg = await fetchBillingConfig(); + const pk = (cfg.stripePublishableKey || '').trim(); + if (!pk) { + say('Stripe publishable key is not configured on this server.', true); + return; + } + const StripeCtor = await loadStripeJs(); + activeStripe = StripeCtor(pk); + activeElements = activeStripe.elements({ clientSecret, appearance: { theme: 'stripe' } }); + const paymentElement = activeElements.create('payment'); + const box = document.getElementById('stripe-payment-box'); + const mount = document.getElementById('stripe-payment-element'); + const errEl = document.getElementById('stripe-payment-error'); + if (errEl) { + errEl.style.display = 'none'; + errEl.textContent = ''; + } + if (box) box.style.display = 'block'; + if (mount) { + mount.innerHTML = ''; + paymentElement.mount('#stripe-payment-element'); + } +} + +function cancelStripePayment() { + activeStripe = null; + activeElements = null; + const box = document.getElementById('stripe-payment-box'); + if (box) box.style.display = 'none'; + const mount = document.getElementById('stripe-payment-element'); + if (mount) mount.innerHTML = ''; +} + +async function confirmStripePayment() { + if (!activeStripe || !activeElements) { + say('Payment form is not ready.', true); + return; + } + const payBtn = document.getElementById('stripe-pay-btn'); + const errEl = document.getElementById('stripe-payment-error'); + if (payBtn) payBtn.disabled = true; + if (errEl) { + errEl.style.display = 'none'; + errEl.textContent = ''; + } + try { + const { error, paymentIntent } = await activeStripe.confirmPayment({ + elements: activeElements, + redirect: 'if_required', + }); + if (error) { + if (errEl) { + errEl.textContent = error.message || 'Payment failed'; + errEl.style.display = 'block'; + } + say(error.message || 'Payment failed', true); + return; + } + if (paymentIntent && (paymentIntent.status === 'succeeded' || paymentIntent.status === 'processing')) { + say('Payment received — updating balance…'); + cancelStripePayment(); + await pollBalanceAfterPayment(state.me.balanceCents ?? 0); + } else { + say(`Payment status: ${paymentIntent ? paymentIntent.status : 'unknown'}`, true); + } + } catch (err) { + say(err.message || String(err), true); + } finally { + if (payBtn) payBtn.disabled = false; + } +} + +async function pollBalanceAfterPayment(previousCents) { + const started = Date.now(); + while (Date.now() - started < 30000) { + try { + const me = await api('/me'); + state.me = me; + if ((me.balanceCents ?? 0) > previousCents) { + renderBilling(); + say(`Balance updated — new balance ${fmt(me.balanceCents)}.`); + return; + } + } catch (_) { + /* keep polling */ + } + await new Promise((r) => setTimeout(r, 1500)); + } + // Refresh once more even if webhook is slow + try { + state.me = await api('/me'); + renderBilling(); + } catch (_) {} + say('Payment submitted. Balance will update when the webhook confirms — refresh shortly.'); } async function toggleEmailInvoicing(enabled) { @@ -311,11 +749,67 @@ async function toggleEmailInvoicing(enabled) { /* ---------------- security ---------------- */ +const SAVED_XVERAE_KEY = 'verae.xVeraeToken'; + +function savedXVeraeToken() { + return localStorage.getItem(SAVED_XVERAE_KEY) || ''; +} + function renderSecurity() { const me = state.me; + const tokens = state.tokens || []; + const tokenRows = tokens.length + ? tokens + .map( + (t) => ` + ${t.name} + ${t.prefix} + ${t.createdAt ? t.createdAt.slice(0, 10) : '—'} + ${t.expiresAt ? t.expiresAt.slice(0, 10) : 'never'} + ${t.lastUsedAt ? t.lastUsedAt.slice(0, 10) : '—'} + + `, + ) + .join('') + : `No xVerae- tokens yet.`; + const saved = savedXVeraeToken(); document.getElementById('security').innerHTML = `

Security

-

Two-factor authentication protects your account with a time-based code from an authenticator app.

+

Long-lived xVerae- tokens for apps (like an LLM API key), plus 2FA for portal login.

+ +
+

Create xVerae- token

+

${surface.securityHint}

+
+ + + +
+ + + + ${tokenRows} +
NamePrefixCreatedExpiresLast used
+
+ +
+

Save token once (this browser)

+

Paste an xVerae- token here. It is stored in localStorage under verae.xVeraeToken for client apps / print dialogs on this device. Clear on shared computers.

+
+ + + +
+

Saved: ${saved ? maskKey(saved) : '(none)'}

+
+

Two-factor authentication ${me.totpEnabled ? 'enabled' : 'disabled'}

@@ -338,6 +832,61 @@ function renderSecurity() {
`; } +async function createToken() { + const name = document.getElementById('token-name').value.trim() || 'default'; + const daysRaw = document.getElementById('token-days').value.trim(); + const body = { name }; + if (daysRaw) body.expiresInDays = Number(daysRaw); + const res = await api('/tokens', { method: 'POST', body: JSON.stringify(body) }); + state.tokens = (await api('/tokens')).tokens || []; + renderSecurity(); + const box = document.getElementById('token-once'); + const val = document.getElementById('token-once-value'); + box.hidden = false; + val.textContent = res.token; + val.dataset.token = res.token; + say('Token created — copy it now.'); +} + +function copyOnceToken() { + const t = document.getElementById('token-once-value').dataset.token; + navigator.clipboard.writeText(t); + say('xVerae- token copied.'); +} + +function saveOnceToken() { + const t = document.getElementById('token-once-value').dataset.token; + if (!t) return; + localStorage.setItem(SAVED_XVERAE_KEY, t); + renderSecurity(); + say('Token saved in this browser (verae.xVeraeToken).'); +} + +function savePastedToken() { + const t = document.getElementById('token-paste').value.trim(); + if (!t.startsWith('xVerae-')) { + say('Token must start with xVerae-', true); + return; + } + localStorage.setItem(SAVED_XVERAE_KEY, t); + document.getElementById('token-paste').value = ''; + renderSecurity(); + say('Token saved in this browser.'); +} + +function clearSavedToken() { + localStorage.removeItem(SAVED_XVERAE_KEY); + renderSecurity(); + say('Cleared saved token from this browser.'); +} + +async function revokeToken(id) { + await api('/tokens/' + encodeURIComponent(id), { method: 'DELETE', body: '{}' }); + state.tokens = (await api('/tokens')).tokens || []; + renderSecurity(); + say('Token revoked.'); +} + async function setupTotp() { const res = await api('/2fa/setup', { method: 'POST', body: '{}' }); document.getElementById('totp-setup').innerHTML = ` @@ -392,8 +941,9 @@ function renderDocs() { ) .join(''); document.getElementById('docs').innerHTML = ` -

API & pricing

-

Interactive API reference lives at /docs. That page is stock Swagger UI — not a designed customer surface. Use your API key as x-api-key.

+

${surface.docsTitle}

+

${surface.docsLede} Legacy key-… keys still work.

+ ${surface.offeringsHtml}

Rate card (list prices)

${rows}
EndpointKindList price
diff --git a/portal/index.html b/portal/index.html index 602ce9b..681c8de 100644 --- a/portal/index.html +++ b/portal/index.html @@ -3,7 +3,57 @@ - Zappier Portal + Verae Customer Portal + + + + + + + + + + + + + + + +