diff --git a/.env.example b/.env.example index 3c18a62..08225a0 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,42 @@ # Copy to .env (gitignored) and fill in your real Stripe secret key. # The billing job (src/jobs/report-usage.ts) loads this via dotenv. STRIPE_SECRET_KEY=sk_test_replace_me -# Optional: override the SQLite database location (default: zappier.db) +# Optional: override SQLite locations # ZAPPIER_DB=/absolute/path/to/zappier.db +# BILLING_DB=/absolute/path/to/billing.db + +# BusinessStore driver (D006 / WP-L). Default: sqlite-file (BILLING_DB). +# BUSINESS_STORE=sqlite-file +# BUSINESS_STORE=libsql +# Required when BUSINESS_STORE=libsql (fail closed if missing): +# LIBSQL_URL=file:./tmp-business.db +# LIBSQL_URL=libsql:// +# LIBSQL_AUTH_TOKEN= + +# Password-reset email (POST /portal/api/password/forgot). Gmail: STARTTLS on 587 +# with an app password. Leave SMTP_USER/SMTP_PASS unset to disable (endpoint 503s +# with error=email_unavailable; capabilities.emailAvailable=false). +# On .138 these live in /etc/verae/zappier-edge-smtp.env (root 0600, systemd drop-in). +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USER=veraetimestamp@gmail.com +# SMTP_FROM=veraetimestamp@gmail.com +# SMTP_FROM_NAME=Verae +# SMTP_PASS= + +# Verae timestamp upstream (production: middleware on loopback). +# When set, edge fail-closes on non-2xx — never invents mock-cert-* receipts. +# ZAPPIER_UPSTREAM=http://127.0.0.1:3130 +# Optional middleware API key forwarded as x-api-key to upstream. +# ZAPPIER_MIDDLEWARE_API_KEY= + +# Dev-only: allow local mock-cert-* receipts when ZAPPIER_UPSTREAM is unset. +# Never enable in production. +# ALLOW_MOCK_CERTS=true + +# New-customer signup credit in cents (portal signup + admin create). 0 disables. +# SIGNUP_CREDIT_CENTS=5000 + +# Daily overdue job (dist/jobs/overdue-balances.js); see docs/NEGATIVE-BALANCE.md +# OVERDUE_THRESHOLD_CENTS=0 +# PORTAL_URL=https://zap.verae.ai/portal/ 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/app.js b/admin/app.js index 1e024ae..6d9e81f 100644 --- a/admin/app.js +++ b/admin/app.js @@ -22,15 +22,24 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; + const totpCode = document.getElementById('login-totp').value.trim(); try { const res = await fetch('/admin/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, password, ...(totpCode ? { totpCode } : {}) }), }); - if (!res.ok) throw new Error((await res.json()).error || 'Login failed'); - const { token: t } = await res.json(); - localStorage.setItem(TOKEN_KEY, t); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (data.error === 'totp_required') { + document.getElementById('login-totp-group').style.display = 'block'; + document.getElementById('login-totp').focus(); + throw new Error(totpCode ? 'Authenticator code is not right — try the current one.' : 'Enter the code from your authenticator app.'); + } + throw new Error(data.error || 'Login failed'); + } + localStorage.setItem(TOKEN_KEY, data.token); + document.getElementById('login-totp').value = ''; showShell(); load().catch((err) => say(err.message, true)); } catch (err) { @@ -38,6 +47,78 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { } }); +/* ---------------- forgot / reset admin password (emailed code) ---------------- */ + +let adminResetStep = 1; +const RESET_ERRORS = { + invalid_code: 'That code is not right — check the latest email.', + code_expired: 'That code expired — send a new one.', + too_many_attempts: 'Too many wrong codes — send a new one.', + totp_required: 'This account has 2FA on — enter your authenticator code too.', +}; + +function showAdminReset(show) { + document.getElementById('login-form').style.display = show ? 'none' : 'block'; + document.getElementById('admin-reset-form').style.display = show ? 'block' : 'none'; + document.getElementById('admin-reset-error').textContent = ''; + if (show) { + adminResetStep = 1; + document.getElementById('admin-reset-step2').style.display = 'none'; + document.getElementById('admin-reset-submit').textContent = 'Send code'; + document.getElementById('admin-reset-ident').value = document.getElementById('login-username').value.trim(); + document.getElementById('admin-reset-ident').focus(); + } +} +document.getElementById('admin-forgot-link').addEventListener('click', () => showAdminReset(true)); +document.getElementById('admin-reset-back').addEventListener('click', () => showAdminReset(false)); + +document.getElementById('admin-reset-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errEl = document.getElementById('admin-reset-error'); + errEl.textContent = ''; + const ident = document.getElementById('admin-reset-ident').value.trim(); + if (!ident) return (errEl.textContent = 'Enter your email.'); + const idBody = ident.includes('@') ? { email: ident } : { username: ident }; + try { + if (adminResetStep === 1) { + const res = await fetch('/admin/api/password/forgot', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(idBody), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message || data.error || `HTTP ${res.status}`); + adminResetStep = 2; + document.getElementById('admin-reset-step2').style.display = 'block'; + document.getElementById('admin-reset-submit').textContent = 'Set new password'; + document.getElementById('admin-reset-sub').textContent = + 'If that account has an email on file, a 6-digit code is on its way (check spam). It expires in 15 minutes.'; + document.getElementById('admin-reset-code').focus(); + return; + } + const code = document.getElementById('admin-reset-code').value.trim(); + const password = document.getElementById('admin-reset-password').value; + const totpCode = document.getElementById('admin-reset-totp').value.trim(); + 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 !== document.getElementById('admin-reset-password2').value) throw new Error('Passwords do not match.'); + const res = await fetch('/admin/api/password/reset', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...idBody, code, password, ...(totpCode ? { totpCode } : {}) }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(RESET_ERRORS[data.error] || data.error || `HTTP ${res.status}`); + showAdminReset(false); + document.getElementById('login-username').value = ident; + document.getElementById('login-error').textContent = ''; + document.getElementById('login-password').focus(); + showLogin('Password updated — sign in with your new password.'); + } catch (err) { + errEl.textContent = err.message; + } +}); + document.getElementById('logout').addEventListener('click', () => { localStorage.removeItem(TOKEN_KEY); location.reload(); @@ -127,6 +208,7 @@ async function load() { state.customers = can('admin.customers') ? (await api('/customers')).customers : []; state.invoices = can('admin.invoices') ? (await api('/invoices')).invoices : []; state.users = can('iam.users.read') || !state.me?.iam ? (await api('/users')).users : []; + state.account = !state.me?.iam ? await api('/account').catch(() => null) : null; renderEndpoints(); renderTiers(); renderCustomers(); @@ -325,10 +407,11 @@ function renderCustomers() { }

Add customer

- + + -

The new customer's API key is shown once in the notification — copy it immediately. Set email and billing method in the edit drawer.

+

The customer's email is their sign-in name: they claim the account at the portal with Create an account using this same email. The API key is shown once in the notification — copy it immediately. Set billing method in the edit drawer.

`; } @@ -433,12 +516,13 @@ async function reviewCustomer(id) { } async function addCustomer() { - const name = document.getElementById('new-customer-name').value.trim(); + const email = document.getElementById('new-customer-email').value.trim().toLowerCase(); + const name = document.getElementById('new-customer-name').value.trim() || email; const tierId = document.getElementById('new-customer-tier').value; - if (!name) return say('Customer name required.', true); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return say('Customer email required (it is their sign-in name).', true); const created = await api('/customers', { method: 'POST', - body: JSON.stringify({ name, tierId }), + body: JSON.stringify({ name, tierId, email }), }); say(`Created ${created.id} — API key: ${created.apiKey}`); await load(); @@ -743,47 +827,252 @@ function renderUsers() { `; return; } + const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`); const rows = state.users .slice() .sort((a, b) => a.username.localeCompare(b.username)) .map( (u) => ` - ${u.username} + ${esc(u.username)} + ${u.email ? esc(u.email) : '— none (cannot reset by email)'} ${u.active ? 'active' : 'inactive'} + ${u.totpEnabled ? '' : 'email only'} ${fmtDate(u.createdMs)} - + + `, ) .join(''); document.getElementById('users').innerHTML = `

Admin users

-

Accounts that can sign in to this console. Passwords are stored as scrypt hashes — never in plain text. The last active admin cannot be deactivated.

+ ${renderMyAccount()} +

Accounts that can sign in to this console. Email is the sign-in name (legacy usernames still work) and receives password reset / change codes. Passwords are stored as scrypt hashes — never in plain text. The last active admin cannot be deactivated.

- + ${rows}
UsernameStatusCreated
Sign-in nameEmail (codes)Status2FACreated

Add admin user

- + -

Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.

+

The email becomes the sign-in name and receives password codes. They can change their password under Users → My account, or use "Forgot password?" on the sign-in screen. Deactivated users are blocked (and signed out) immediately.

`; } +function renderMyAccount() { + const a = state.account; + if (!a) { + return `

My account

Sign in with an admin email/username (not the x-admin-key) to change your password here.

`; + } + const emailRow = a.email + ? `Codes go to ${a.email}.` + : `No email on file — add one to enable password change and "Forgot password?".`; + return `
+

My account — ${a.username}

+

${emailRow} ${a.emailAvailable ? '' : 'Server email is not configured.'}

+ +

Change password (code by email${a.totpEnabled ? ' + authenticator' : ''})

+
+ + +
+ + +

${a.email ? 'Change' : 'Add'} email

+
+ + + ${a.totpEnabled ? '' : ''} + +
+ + +

Authenticator app (optional) ${a.totpEnabled ? 'enabled' : 'off'}

+ ${ + a.totpEnabled + ? `
` + : `
` + } +

+
`; +} + +const ACCT_ERRORS = { + invalid_code: 'That code is not right — check the latest email.', + code_expired: 'That code expired — request a new one.', + too_many_attempts: 'Too many wrong codes — request a new one.', + totp_required: 'Enter the current code from your authenticator app.', + no_email: 'Add an email to your account first.', + email_unavailable: 'Server email is not configured.', + email_send_failed: 'Could not send the email — try again in a minute.', +}; + +async function acctPost(path, body) { + const res = await fetch(`/admin/api/account${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` }, + body: JSON.stringify(body || {}), + }); + const data = await res.json().catch(() => ({})); + if (res.status === 403) { + localStorage.removeItem(TOKEN_KEY); + showLogin('Session expired — sign in again.'); + throw new Error('Session expired.'); + } + if (!res.ok) throw new Error(ACCT_ERRORS[data.error] || data.message || data.error || `HTTP ${res.status}`); + return data; +} + +function acctErr(msg) { + const el = document.getElementById('acct-error'); + if (el) el.textContent = msg || ''; +} + +async function acctStartPw() { + acctErr(); + try { + const r = await acctPost('/password/change/start', { + currentPassword: document.getElementById('acct-pw-current').value, + }); + document.getElementById('acct-pw-step1').hidden = true; + document.getElementById('acct-pw-step2').hidden = false; + document.getElementById('acct-pw-sent').textContent = `Code sent to ${r.sentTo}. It expires in 15 minutes.`; + document.getElementById('acct-pw-code').focus(); + } catch (err) { + acctErr(err.message); + } +} + +async function acctConfirmPw() { + acctErr(); + const code = document.getElementById('acct-pw-code').value.trim(); + const newPassword = document.getElementById('acct-pw-new').value; + if (newPassword.length < 8) return acctErr('New password must be at least 8 characters.'); + if (newPassword !== document.getElementById('acct-pw-new2').value) return acctErr('Passwords do not match.'); + const totpEl = document.getElementById('acct-pw-totp'); + try { + const r = await acctPost('/password/change/confirm', { + code, + newPassword, + ...(totpEl ? { totpCode: totpEl.value.trim() } : {}), + }); + if (r.token) localStorage.setItem(TOKEN_KEY, r.token); + say('Password changed. Your other console sessions were signed out.'); + await load(); + } catch (err) { + acctErr(err.message); + } +} + +async function acctStartEmail() { + acctErr(); + const totpEl = document.getElementById('acct-email-totp'); + try { + const r = await acctPost('/email', { + email: document.getElementById('acct-email-new').value.trim(), + currentPassword: document.getElementById('acct-email-pw').value, + ...(totpEl ? { totpCode: totpEl.value.trim() } : {}), + }); + document.getElementById('acct-email-step2').hidden = false; + say(`Verification code sent to ${r.sentTo}.`); + document.getElementById('acct-email-code').focus(); + } catch (err) { + acctErr(err.message); + } +} + +async function acctConfirmEmail() { + acctErr(); + try { + const r = await acctPost('/email', { + email: document.getElementById('acct-email-new').value.trim(), + code: document.getElementById('acct-email-code').value.trim(), + }); + say(`Email set to ${r.email}.`); + await load(); + } catch (err) { + acctErr(err.message); + } +} + +async function acctSetupTotp() { + acctErr(); + try { + const r = await acctPost('/2fa/setup'); + document.getElementById('acct-totp-setup').innerHTML = ` +
+ 2FA QR code +
+

Scan with your authenticator app, or enter: ${r.secret}

+
+
+
+
`; + } catch (err) { + acctErr(err.message); + } +} + +async function acctEnableTotp() { + acctErr(); + try { + await acctPost('/2fa/enable', { code: document.getElementById('acct-totp-enable').value.trim() }); + say('Authenticator 2FA enabled — it is now required at sign-in, reset, and password change.'); + await load(); + } catch (err) { + acctErr(err.message); + } +} + +async function acctDisableTotp() { + acctErr(); + try { + await acctPost('/2fa/disable', { code: document.getElementById('acct-totp-disable').value.trim() }); + say('Authenticator 2FA disabled.'); + await load(); + } catch (err) { + acctErr(err.message); + } +} + async function addUser() { - const username = document.getElementById('new-user-name').value.trim(); + const email = document.getElementById('new-user-email').value.trim().toLowerCase(); const password = document.getElementById('new-user-password').value; - if (!username || !password) return say('Username and password required.', true); - await api('/users', { method: 'POST', body: JSON.stringify({ username, password }) }); - say(`Created admin user ${username}.`); + if (!email || !password) return say('Email and password required.', true); + await api('/users', { method: 'POST', body: JSON.stringify({ email, password }) }); + say(`Created admin user ${email}.`); + await load(); +} + +async function setUserEmail(username) { + const current = state.users.find((u) => u.username === username)?.email || ''; + const email = prompt(`Email for ${username} (receives password codes; blank to clear):`, current); + if (email === null) return; + await api(`/users/${encodeURIComponent(username)}/email`, { + method: 'PUT', + body: JSON.stringify({ email: email.trim() }), + }); + say(`Updated email for ${username}.`); await load(); } async function toggleUser(username, activate) { - await api(`/users/${username}/${activate ? 'activate' : 'deactivate'}`, { + await api(`/users/${encodeURIComponent(username)}/${activate ? 'activate' : 'deactivate'}`, { method: 'POST', body: '{}', }); diff --git a/admin/index.html b/admin/index.html index 2653b4f..8c8c214 100644 --- a/admin/index.html +++ b/admin/index.html @@ -6,6 +6,7 @@ Zappier Admin +

Sales channel rates (Zapier · MCP · API)

Zappier Admin

Sign in to manage pricing, customer types, and customers.

- - + + + Forgot password? +

+
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 0000000..8fe1b99 Binary files /dev/null and b/client/hasher/datacube_hasher_bg.wasm differ diff --git a/client/hasher/index.html b/client/hasher/index.html new file mode 100644 index 0000000..8cac9f4 --- /dev/null +++ b/client/hasher/index.html @@ -0,0 +1,49 @@ + + + + + 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 0000000..194fb4d Binary files /dev/null and b/client/hasher/verae_sha256_bg.wasm differ 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/deploy/systemd/zappier-edge-overdue.service b/deploy/systemd/zappier-edge-overdue.service new file mode 100644 index 0000000..11ff5ad --- /dev/null +++ b/deploy/systemd/zappier-edge-overdue.service @@ -0,0 +1,17 @@ +[Unit] +Description=Zappier edge daily overdue-balance report (payment requests from SMTP_FROM) +After=network-online.target + +[Service] +Type=oneshot +User=verae-zapier +Group=verae-zapier +WorkingDirectory=/opt/verae/zappier-edge +Environment=NODE_ENV=production +EnvironmentFile=-/SSD2/secrets/zap-verae/zappier-edge.env +EnvironmentFile=-/etc/verae/zappier-edge-smtp.env +# Dry run by default (prints to journal). Add --send once payment-request emails are approved: +# ExecStart=/usr/bin/node dist/jobs/overdue-balances.js --send +ExecStart=/usr/bin/node dist/jobs/overdue-balances.js +NoNewPrivileges=true +PrivateTmp=true diff --git a/deploy/systemd/zappier-edge-overdue.timer b/deploy/systemd/zappier-edge-overdue.timer new file mode 100644 index 0000000..cd2fef5 --- /dev/null +++ b/deploy/systemd/zappier-edge-overdue.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Daily overdue-balance report for zap.verae.ai customers + +[Timer] +OnCalendar=*-*-* 09:00:00 America/New_York +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/ACCOUNTING.md b/docs/ACCOUNTING.md index f0ef0de..184e1bb 100644 --- a/docs/ACCOUNTING.md +++ b/docs/ACCOUNTING.md @@ -25,6 +25,11 @@ Before invoicing, each customer needs a **billing type** and an **email** — se on the **Customers** tab: - **Stripe** — metered usage is reported to Stripe by the daily billing job. + *Decided 2026-10-06, not yet implemented:* Stripe customers are collected + through a **Stripe subscription**. For Peergos storage that means a base price + item plus a metered overage item. Stripe issues and collects those invoices, + after any prepaid balance is applied first (D7, hybrid). See + [PEERGOS-BILLING.md](PEERGOS-BILLING.md) ("Storage billing decisions"). - **Purchase order** — invoiced manually with a PO number; issued PO invoices get a 30-day due date automatically. @@ -35,6 +40,12 @@ portal). When an invoice is issued and the balance fully covers the billable amount, the balance is drawn down and the invoice goes straight to **paid**. Partial coverage is left untouched — there are no partial payments. +> **Note (decision D1, 2026-10-06):** prepaid drawdown at issue describes the +> current code. Once Stripe subscriptions are live, Stripe-billed customers pay +> through their subscription and PO customers through the invoice run below. +> Prepaid balances stay: they are applied first to +> the Stripe invoice, and the subscription bills the remainder (D7). + --- ## 3. Generate invoices @@ -112,7 +123,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 +132,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/CUSTOMER-PORTAL.md b/docs/CUSTOMER-PORTAL.md index 10d5632..8dfa57c 100644 --- a/docs/CUSTOMER-PORTAL.md +++ b/docs/CUSTOMER-PORTAL.md @@ -69,6 +69,13 @@ Only your own invoices are visible; other customers' ids return "not found". due, the invoice is paid instantly. - **Email invoicing** — receive a copy of each new invoice by email. +> **Planned change (decision D1, 2026-10-06):** card-billed (Stripe) customers +> will pay through a Stripe subscription, which for Peergos storage means a base +> plan plus metered overage, prorated for partial months. Reload stays available: +> the prepaid balance is applied first and the subscription bills the remainder +> (D7). Free-tier users need no card; unpaid invoices restrict storage access +> until paid (D8, D11). + ## 6. Security: two-factor authentication On the **Security** tab, click **Set up 2FA**: 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/NEGATIVE-BALANCE.md b/docs/NEGATIVE-BALANCE.md new file mode 100644 index 0000000..fcf7760 --- /dev/null +++ b/docs/NEGATIVE-BALANCE.md @@ -0,0 +1,93 @@ +# Negative balances, signup credit, and the Verae adapter quota + +## TL;DR + +| What blocks / returns 402? | Where | Scope | Changed here? | +|---|---|---|---| +| `402 QUOTA_EXCEEDED` "Monthly timestamp quota exceeded" (`used 51 / limit 50`) | **verae-zapier-middleware** `src/services/entitlementService.js` (`127.0.0.1:3130`, `/opt/verae/verae-zapier-middleware`) | The **single shared middleware tenant** `tenant-zap-edge-verae-local-7bcee67c` ("Zap Edge Service", plan `free` = 50 stamps / month, `PLAN_LIMITS` in `src/config.js`). Every zap.verae.ai customer stamps through this one tenant (edge sends `ZAPPIER_MIDDLEWARE_API_KEY`), so once the platform hits 50 stamps in a calendar month **all** customers get 402. | **No.** It's a platform/adapter quota, not a customer balance. See "Raising the Verae adapter quota" below. | +| Customer prepaid balance (`customers.balance_cents` on billing.db) | zappier-edge | Per customer | **There's no gate.** The meter (`src/meter.ts`) quotes and records usage and reports the balance in `X-Verae-Credits-Balance`, but never rejects a request because of balance. Locked in by `tests/negative-balance.test.ts`. | + +The edge passes the middleware 402 straight through as +`{ "error": "upstream_timestamp_failed", "upstreamStatus": 402, ... }`. +It never invents a `mock-cert-*` receipt (fail-closed, commit 1107272). + +## Policy: stamp first, collect later + +* Customers can keep timestamping when their prepaid / effective balance is zero or negative. +* Invoice issue (`POST /admin/api/invoices/:id/issue`) still draws down prepaid only when it fully covers the invoice. Otherwise the invoice stays `issued` (unpaid) and shows up in the overdue report. +* The daily overdue job (below) finds the customers who need a payment request: **only customers whose effective balance is negative** (strictly below $0.00). Zero and positive balances are never listed or emailed. + +### Effective balance + +``` +effective = balance_cents (prepaid) + − Σ billableCents of invoices with status=issued (unpaid) + − Σ billable of usage in UTC months with no issued/paid invoice + (per month: usage − tier monthlyCreditCents, same math as buildInvoice) +``` + +## Daily payment-request job + +`src/jobs/overdue-balances.ts` → `dist/jobs/overdue-balances.js` + +```bash +cd /opt/verae/zappier-edge +sudo -u verae-zapier node dist/jobs/overdue-balances.js # dry run table +sudo -u verae-zapier node dist/jobs/overdue-balances.js --json # machine-readable +sudo -u verae-zapier node dist/jobs/overdue-balances.js --threshold-cents -500 # only owing more than $5 +# Sends one email per overdue customer through the existing mailer (src/mailer.ts): +sudo systemd-run --uid=verae-zapier --gid=verae-zapier -p WorkingDirectory=/opt/verae/zappier-edge \ + -p EnvironmentFile=/SSD2/secrets/zap-verae/zappier-edge.env -p EnvironmentFile=/etc/verae/zappier-edge-smtp.env \ + --wait --pipe /usr/bin/node dist/jobs/overdue-balances.js --send --to-override you@example.com # test to yourself +``` + +* **Negative balances only.** A customer is targeted only when `effective < 0` (and `effective < threshold`). `--threshold-cents` / `OVERDUE_THRESHOLD_CENTS` can only narrow the list: a positive value is clamped to `0` with a warning on stderr, so zero/positive balances can never be included. `sendPaymentRequests` also skips any row that isn't negative (`skipped: balance not negative`). Locked in by `tests/negative-balance.test.ts`. +* From: `SMTP_FROM` / `SMTP_FROM_NAME` in `/etc/verae/zappier-edge-smtp.env` (`veraetimestamp@gmail.com`). +* Skips Peergos sub-accounts (they bill to their parent) and `cust-platform`. +* Customers with no email are listed with `skipped: no email on file`. +* Env: `BILLING_DB`, `OVERDUE_THRESHOLD_CENTS` (default 0, must be ≤ 0), `PORTAL_URL` (default `https://zap.verae.ai/portal/`). +* Nothing is emailed without `--send`. + +### Cron / systemd timer + +Unit files: `deploy/systemd/zappier-edge-overdue.{service,timer}` (09:00 America/New_York daily, **dry run** by default). + +```bash +sudo cp deploy/systemd/zappier-edge-overdue.* /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now zappier-edge-overdue.timer +journalctl -u zappier-edge-overdue -n 50 +# When approved, edit ExecStart to add --send, then daemon-reload. +``` + +Plain cron equivalent (root crontab, so it can read the env files): + +``` +0 9 * * * set -a; . /SSD2/secrets/zap-verae/zappier-edge.env; . /etc/verae/zappier-edge-smtp.env; set +a; cd /opt/verae/zappier-edge && runuser -u verae-zapier -- /usr/bin/node dist/jobs/overdue-balances.js >> /var/log/zappier-overdue.log 2>&1 +``` + +## $50 new-customer signup credit + +`src/billing/signup-credit.ts`: `SIGNUP_CREDIT_CENTS` (default `5000`, `0` disables). + +* **Portal signup** (`POST /portal/api/signup`) grants it to brand-new accounts. Claiming an existing (admin-created or legacy) account doesn't grant it again. +* **Admin create** (`POST /admin/api/customers`) grants it to the new customer. Pass `{"signupCredit": false}` for internal or test accounts. Creating an id that already exists now returns 409 instead of silently overwriting it and wiping its balance. +* It's written exactly like a customer-service credit: a `credit_adjustments` row (reason `signup credit`, so Statement → credits shows it), `ledgerAdjust` to account-balance books when NATS/`ACCOUNT_BALANCE_URL` is set, `verae.billing.credit.applied` on NATS, and `balance_cents += 5000`. It's idempotent per customer id, and Peergos sub-accounts never get it. + +## Raising the Verae adapter quota (separate, not done by this change) + +The 50/month limit belongs to the middleware tenant, not to api.veraetime.net customers. Pick one of these: + +1. **Move the shared tenant to an unlimited plan.** Set `"plan": "enterprise"` and a `contract`. Example contract: `{"timestamps": null, "verifications": null, "batch": true, "batchMaxItems": null, "requestsPerMinute": 3000, "allowOverage": true}`. Make the change on `tenant-zap-edge-verae-local-7bcee67c` in `/var/lib/verae-zapier/middleware-store.json`, back up the file first, then `sudo systemctl restart verae-zapier-middleware`. You can also go through the middleware admin tenant API with `ADMIN_SECRET`. +2. Or change `PLAN_LIMITS.free.timestamps` in `verae-zapier-middleware/src/config.js`. This is broader because it affects every free tenant. +3. Or set the tenant `contract.allowOverage: true`. Usage over the limit is then recorded as `overage.timestamps` instead of returning 402. + +The monthly counter resets automatically when the period rolls over (`usage.period`, e.g. `2026-10` → `2026-11`). + +## Charge only after a successful stamp + +`POST /v1/timestamp` uses deferred metering (`meter(..., { deferRecord: true })`): + +* Usage (and the NATS `verae.billing.usage.recorded` event) is written only via `commitMeteredUsage` after a 2xx upstream stamp (or a local mock when `ALLOW_MOCK_CERTS=true`). +* Upstream failures (402, 5xx, unconfigured) call `abandonMeteredUsage`, which records nothing and sets `X-Verae-Credits-Spend: 0`. +* Batch `POST /v1/timestamps` already recorded per successful item only; unchanged. diff --git a/docs/PASSWORD-2FA.md b/docs/PASSWORD-2FA.md new file mode 100644 index 0000000..cc7577c --- /dev/null +++ b/docs/PASSWORD-2FA.md @@ -0,0 +1,53 @@ +# Password change, reset, and 2FA by email (portal + admin) + +Codes are 6 digits, emailed from the SMTP account in `/etc/verae/zappier-edge-smtp.env` +(`SMTP_FROM`, currently the Verae Gmail sender). Only an HMAC of each code is kept in memory, +15-minute expiry, 5 wrong tries voids it, one-time use; a restart voids outstanding codes. +Requests are rate-limited per account and per IP. Every successful change/reset signs out the +account's other sessions and sends a "your password was changed" notice. + +**Email is the sign-in name everywhere.** Customers sign in with email. New admin users are +created with an email that becomes their username; legacy admin usernames (`admin`, `demo`, +`billing`) still work, and can also sign in with their email once one is set. + +## Customer portal (`/portal/api`, Bearer session) + +| Step | Request | Notes | +|---|---|---| +| Forgot (signed out) | `POST /password/forgot {email}` | always 202 | +| Reset (signed out) | `POST /password/reset {email, code, password}` | | +| Change 1 | `POST /password/change {currentPassword}` (alias `/password/change/start`) | emails code; returns `{sentTo, totpRequired}` | +| Change 2 | `POST /password/change {code, newPassword, totpCode?}` (alias `/password/change/confirm`) | `totpCode` required when authenticator 2FA is on; returns new session `token` | + +UI: **Security → Change password**. + +## Admin console (`/admin/api`) + +Signed out: + +| Request | Notes | +|---|---| +| `POST /login {username or email, password, totpCode?}` | `401 {error:"totp_required"}` when the account has TOTP | +| `GET /auth/capabilities` | `{emailAvailable, passwordReset, iam}` | +| `POST /password/forgot {email}` or `{username}` | always 202; sends only if the account has an email on file | +| `POST /password/reset {email or username, code, password, totpCode?}` | TOTP required if enabled | + +Signed in (Bearer token from `/login`; `x-admin-key` cannot use these): + +| Request | Notes | +|---|---| +| `GET /account` | own username, email, totpEnabled | +| `POST /account/password/change {currentPassword}` → `{code, newPassword, totpCode?}` | same two-step as the portal; returns new `token` | +| `POST /account/email {email, currentPassword, totpCode?}` → `{email, code}` | code goes to the **new** address | +| `POST /account/2fa/setup` / `enable {code}` / `disable {code}` | optional authenticator TOTP | +| `POST /users {email, password}` | email becomes the username | +| `PUT /users/:username/email {email}` | set/clear another admin's email (`iam.users.write`) | + +UI: sign-in screen **Forgot password?**; **Users → My account** (change password, add/change +email, authenticator); Users table **Set email** per account. + +Migration: `admin_users` gains `email` (unique, case-insensitive), `totp_secret`, `totp_enabled` +on first start (additive `ALTER TABLE`). Optional env `ADMIN_USER_EMAILS="billing=a@b.com,…"` +backfills emails on accounts that have none (never overwrites). + +When `STAFF_IAM_URL` is set, Staff IAM owns staff passwords and these admin flows are disabled. diff --git a/docs/PEERGOS-BILLING.md b/docs/PEERGOS-BILLING.md new file mode 100644 index 0000000..8b9cc60 --- /dev/null +++ b/docs/PEERGOS-BILLING.md @@ -0,0 +1,98 @@ +# 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` | Current code default for Peergos users on **shared** hosted S3 (see decision D3 below: every Peergos user must belong to their own Verae customer, so this default is to be retired) | +| 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.25** (25% markup, `STORAGE_OVERAGE_MARKUP_BPS=2500`; 2875 millicents = $0.02875 / GB-month at the default $0.023 pin) | +| **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:` (current code defaults the parent to `cust-platform`; per D3 the parent should be the user's own Verae customer) | +| 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 (timestamp SKUs) + +These multipliers apply to **timestamp SKU credits only**. Storage overage is +priced per GB-month at the hosted rate above (AWS × 1.25), not with these multipliers. + +| 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). + +## Storage billing decisions (George, 2026-10-06), not yet implemented + +These are decided but **not implemented in this code yet**. The design is in +[peergos-flutter `docs/STORAGE_BILLING_DESIGN.md`](https://git.georgelambert.org/marchon/peergos-flutter/src/branch/main/docs/STORAGE_BILLING_DESIGN.md). + +| # | Decision | +|---|----------| +| D1 | **Stripe customers** pay for storage through a **Stripe subscription**: a storage base price item plus a metered storage overage item. Any prepaid balance is applied first (D7). **PO customers** stay on the existing invoice run (Generate → Issue with PO number and 30-day due date). | +| D2 | The timestamp tier's **monthly credit counts against storage charges** (base and overage), as `buildInvoice` already does for all lines. | +| D3 | **Every Peergos user must have a Verae (zap.verae.ai) customer account.** App signup creates or links one. There are no unlinked users, so the `cust-platform` default above is to be retired. | +| D4 | **Partial months are prorated** (storage base fee and included GB). | +| D5 | **Storage overage markup is 25% (×1.25)**, matching `DEFAULT_STORAGE_OVERAGE_MARKUP_BPS = 2500`. | +| D6 | **Hitting an overage level never blocks storage** for accounts in good standing (D11 covers non-payment). Usage continues and overage is billed at month end. Peergos quotas are raised automatically ahead of usage. | +| D7 | **Hybrid prepaid + subscription.** Keep both. The prepaid balance (`balance_cents`) is applied first, as a negative item on the Stripe draft invoice keyed by invoice id. The subscription bills only the remainder, with no double billing. Portal Reload stays. | +| D8 | **New app signups start on the Free tier** (1 GB included, $1.00 monthly credit, 20 stamps), with **no card required**. Overage is invoiced at month end. Access is restricted while an invoice is unpaid (D11). | +| D9 | **Restarting the ns2 (137) Peergos container is allowed at any time until George says real customers are live.** This is recorded only. | +| D10 | **Runaway usage triggers alerts only, with no hard ceiling.** Alerts go to George by default, through the admin portal and email (`STORAGE_ALERT_EMAILS`). The thresholds are in the design doc §3.12. | +| D11 | **Failed or unpaid payment:** quota auto-raise pauses and file access is restricted until the invoice is paid. Enforcement pins the Peergos quota to current usage: no new writes, while deletes and downloads still work. The app shows a pay-now screen. See design doc §3.11. | +| D12 | **`src/pricing.ts` is the authoritative rate card.** The old veraetimestamps.com prices ($0.50/stamp, $49 plan) were placeholders. The site now mirrors this file. | + diff --git a/docs/USAGE-BILLING-API.md b/docs/USAGE-BILLING-API.md new file mode 100644 index 0000000..7e82853 --- /dev/null +++ b/docs/USAGE-BILLING-API.md @@ -0,0 +1,49 @@ +# Usage & Billing API (`/portal/api/billing`) + +This API backs the **Usage & Billing** screen in the peergos-flutter app. The customer portal can use it too. + +**Auth.** Send the portal session as `Authorization: Bearer `, using the token from `POST /portal/api/login` or `/signup`. The signed-in Verae customer is the billing parent. A Peergos user is mapped to that customer through its `peergos:` child account (`parentId` = customer id). Passing `peergosUsername` scopes a request to one linked user; an unlinked user returns `404 peergos_user_not_linked`. + +**Existing portal routes, reused as they are:** +* `GET /portal/api/me`: profile, `billingType`, `balanceCents`. +* `GET /portal/api/invoices/:id?format=html`: printable invoice, with print-to-PDF in the app. +* `POST /portal/api/reload`: prepaid top-up. + +## Customer routes + +| Method | Path | Returns / does | +|--------|------|----------------| +| GET | `/billing/summary` | Customer (billing type, prepaid balance), plan/tier (credit, included GB and stamps), `storageOverageCentsPerGb` (2.875 = AWS 2.3¢ × 1.25), period, `standing` (`good`/`past_due`/`restricted`), `stripeConfigured`, `publishableKey` | +| GET | `/billing/usage[?peergosUsername=]` | Storage: pooled bytes across the customer's Peergos users, % of included GB, per-user latest sample, and a daily pooled-peak series for the chart. Stamps: used this month vs the tier allowance, plus cents by endpoint | +| GET | `/billing/estimate` | Month-to-date estimate, prorated by day: base (0 until storage plans exist), storage overage (average of daily peaks over included GB at 2875 millicents/GB-month), stamps, minus the plan credit (D2). Also prepaid applied first (D7), amount due, and month-end projection | +| GET | `/billing/invoices` | Non-draft invoices with `status` (`paid`/`unpaid`/`overdue`), dates, amounts, `htmlPath`, and `payable` | +| POST | `/billing/invoices/:id/pay` | Stripe customers: creates a PaymentIntent for the amount due (charged only when the customer confirms in PaymentSheet/Checkout). Returns `clientSecret`, `ephemeralKeySecret`, `stripeCustomerId` | +| POST | `/billing/invoices/:id/pay/confirm` `{paymentIntentId}` | Re-reads the PaymentIntent from Stripe. Marks the invoice paid only if it `succeeded` for this customer and invoice and covers the amount | +| GET | `/billing/payment-methods` | Saved cards plus `defaultPaymentMethodId` | +| POST | `/billing/payment-methods/setup-intent` | Creates the Stripe Customer if needed, then a SetupIntent and an ephemeral key for flutter_stripe PaymentSheet | +| POST | `/billing/payment-methods/checkout-session` | Stripe Checkout in `setup` mode, for desktop (webview/browser). Returns `{url}` | +| DELETE | `/billing/payment-methods/:id` | Detaches the card (only if it belongs to this customer) | +| POST | `/billing/payment-methods/:id/default` | Sets `invoice_settings.default_payment_method` | +| GET | `/billing/po` | Purchase-order customers: current PO (number, ceiling, invoiced, remaining, expiry, net terms) and PO history | + +Card routes return **503 `stripe_unavailable`** when no real `STRIPE_SECRET_KEY` is configured, and **409 `purchase_order_customer`** for PO accounts. `STRIPE_PUBLISHABLE_KEY` (optional env) is passed through for the app. + +## Staff routes (`/admin/api`, admin auth) + +| Method | Path | Does | +|--------|------|------| +| POST | `/peergos/storage/samples` `{samples:[{peergosUsername, bytes, sampledAt?}]}` | Stores usage samples from the ns2 sampler (design §3.2). Unlinked usernames are reported back, not stored | +| GET | `/customers/:id/purchase-orders` | PO list | +| POST | `/customers/:id/purchase-orders` `{poNumber, amountCents?, netTermsDays?, startsAt?, expiresAt?, note?}` | Attaches a PO; any active PO becomes `replaced` | +| POST | `/customers/:id/purchase-orders/:poId/renew` `{expiresAt?, amountCents?}` | Extends or raises a PO | + +## Storage (additive) +`billing.db` gains two tables, created with `CREATE TABLE IF NOT EXISTS` at startup: +* `storage_samples(id, customer_id, peergos_username, bytes, sampled_at_ms)` +* `purchase_orders(id, customer_id, po_number, amount_cents, starts_at_ms, expires_at_ms, net_terms_days, status, created_at_ms, created_by, note)` + +Nothing existing is altered or dropped. + +## Not yet included +* There is no Stripe webhook. Pay-now marks the invoice paid through the `/pay/confirm` server-side check instead. +* `standing` is derived from overdue invoices: `restricted` after 7 days overdue, the N10 proposal. Nothing enforces it on Peergos yet; see design §3.11. 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..8fab016 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,19 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "dev": "ts-node src/index.ts", - "test": "jest" + "test": "jest", + "overdue": "node dist/jobs/overdue-balances.js", + "send-invoices": "node dist/jobs/send-invoices.js" }, "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 +31,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..ec34bed 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 — your email is your sign-in name, and 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 — your email is your sign-in name. 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'}

@@ -332,12 +826,148 @@ function renderSecurity() { }
-
-

Password

-

Password changes are handled by support for now — sign out everywhere by signing back in (old sessions expire after 7 days).

+
+

Change password

+

Two-step check: confirm your current password and we email a 6-digit code to ${me.email || 'your account email'}${me.totpEnabled ? ', then enter it together with your authenticator code' : ''}. Changing your password signs out your other sessions.

+
+ + +
+ +

`; } +const PW_ERRORS = { + invalid_code: 'That code is not right — check the latest email.', + code_expired: 'That code expired — request a new one.', + too_many_attempts: 'Too many wrong codes — request a new one.', + totp_required: 'Enter the current code from your authenticator app.', + email_unavailable: 'Email is not available on the server right now — contact support.', + email_send_failed: 'Could not send the email — try again in a minute.', + no_email: 'No email address on this account — contact support.', +}; + +async function pwPost(path, body) { + const res = await fetch(`/portal/api${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (res.status === 401) { + localStorage.removeItem(TOKEN_KEY); + showAuth('Session expired — sign in again.'); + throw new Error('Session expired.'); + } + if (!res.ok) throw new Error(PW_ERRORS[data.error] || data.message || data.error || `HTTP ${res.status}`); + return data; +} + +async function startPasswordChange() { + const errEl = document.getElementById('pw-error'); + errEl.textContent = ''; + const currentPassword = document.getElementById('pw-current').value; + if (!currentPassword) return (errEl.textContent = 'Enter your current password.'); + try { + const r = await pwPost('/password/change/start', { currentPassword }); + document.getElementById('pw-step1').hidden = true; + document.getElementById('pw-step2').hidden = false; + document.getElementById('pw-sent').textContent = `Code sent to ${r.sentTo}. It expires in 15 minutes.`; + document.getElementById('pw-code').focus(); + } catch (err) { + errEl.textContent = err.message; + } +} + +async function confirmPasswordChange() { + const errEl = document.getElementById('pw-error'); + errEl.textContent = ''; + const code = document.getElementById('pw-code').value.trim(); + const newPassword = document.getElementById('pw-new').value; + const totpEl = document.getElementById('pw-totp'); + if (!/^\d{6}$/.test(code)) return (errEl.textContent = 'Enter the 6-digit code from the email.'); + if (newPassword.length < 8) return (errEl.textContent = 'New password must be at least 8 characters.'); + if (newPassword !== document.getElementById('pw-new2').value) return (errEl.textContent = 'Passwords do not match.'); + const body = { code, newPassword }; + if (totpEl) body.totpCode = totpEl.value.trim(); + try { + const r = await pwPost('/password/change/confirm', body); + if (r.token) localStorage.setItem(TOKEN_KEY, r.token); + renderSecurity(); + say('Password changed. Other sessions were signed out.'); + } catch (err) { + errEl.textContent = err.message; + } +} + +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 +1022,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
@@ -407,18 +1038,49 @@ function renderDocs() { /* ---------------- tabs + boot ---------------- */ +function activateTab(tabId) { + const btn = document.querySelector(`nav button[data-tab="${tabId}"]`); + if (!btn) return false; + document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('main section').forEach((s) => (s.hidden = true)); + const section = document.getElementById(tabId); + if (section) section.hidden = false; + return true; +} + +function tabFromHash() { + const raw = (location.hash || '').replace(/^#/, '').toLowerCase(); + if (raw === 'billing' || raw === 'reload') return 'billing'; + if (raw === 'invoices' || raw === 'statement' || raw === 'security' || raw === 'docs' || raw === 'dashboard') return raw; + return null; +} + +function applyHashTab() { + const tab = tabFromHash(); + if (tab) activateTab(tab); +} + document.querySelectorAll('nav button').forEach((btn) => btn.addEventListener('click', () => { - document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - document.querySelectorAll('main section').forEach((s) => (s.hidden = true)); - document.getElementById(btn.dataset.tab).hidden = false; + activateTab(btn.dataset.tab); + if (btn.dataset.tab && location.hash) { + // keep deep-link in sync when user clicks Billing after arriving via #billing + const h = btn.dataset.tab === 'billing' ? '#billing' : `#${btn.dataset.tab}`; + if (location.hash.toLowerCase() !== h) history.replaceState(null, '', h); + } }), ); +window.addEventListener('hashchange', () => { + if (token()) applyHashTab(); +}); + if (token()) { showShell(); - load().catch((err) => say(err.message, true)); + load() + .then(() => applyHashTab()) + .catch((err) => say(err.message, true)); } else { showAuth(); } diff --git a/portal/index.html b/portal/index.html index 602ce9b..62ef0d5 100644 --- a/portal/index.html +++ b/portal/index.html @@ -3,9 +3,60 @@ - Zappier Portal + Verae Customer Portal + + + + + + + + + + + + + + + +