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/NATS.md b/NATS.md index 81f603f..77050bc 100644 --- a/NATS.md +++ b/NATS.md @@ -1,4 +1,4 @@ -# NATS — zappier-edge +# NATS — verae-edge-billing This process **does not subscribe or publish** on NATS. diff --git a/README.md b/README.md index 691f6dc..cc7ea28 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ -# Zappier +# verae-edge-billing (formerly zappier-edge) -[![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, Stripe metered billing, purchase-order +invoicing, a company admin console, a self-service customer portal, and a +Zapier integration. -Metered API platform: per-endpoint pricing, customer types with multipliers and monthly credits, a usage ledger,... +This is the **billing and user platform** (originally the standalone `zappier` git tree at `~/zappier`). Forgejo name: **verae-edge-billing**. It owns signup, TOTP, API keys, rate card, Stripe meter, PO invoices, admin users, and the customer portal. Verae middleware does not replace this. -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. - -**Forgejo:** https://git.georgelambert.org/marchon/zappier-edge +**Forgejo:** https://git.georgelambert.org/marchon/verae-edge-billing +**Renamed 2026-10-06** from `zappier-edge`. Old Forgejo URLs redirect. On ns1 the service is +`verae-edge-billing.service` (alias `zappier-edge.service`), installed in `/opt/verae/verae-edge-billing` +(with a compatibility symlink at `/opt/verae/zappier-edge`). The env files are `verae-edge-billing*.env`, and the +old names are symlinks to them. Some names were deliberately kept: the customer-facing "Zappier" product brand +(portal and admin titles, TOTP issuer), the `x-zappier-api-key` upstream header, the `ZAPPIER_*` env var +names, the `zappier.db` auth DB file, the Stripe meter event `zappier.api_cents`, the export filenames, and +the Zapier platform app package (`zapier-app`). **Catalog README:** https://zapier.georgelambert.org/packages/zappier/README.pdf **Bring the whole system online:** https://zapier.georgelambert.org/packages/verae-ops/GETTING-STARTED.pdf @@ -139,32 +147,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/SUMMARY.md b/SUMMARY.md index fdf0ba3..a4de7e1 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -1,4 +1,4 @@ -# zappier-edge +# verae-edge-billing **Job:** Metered commercial API, portal, admin, Stripe. Zapier’s only public HTTPS hop. 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/verae-edge-billing-overdue.service b/deploy/systemd/verae-edge-billing-overdue.service new file mode 100644 index 0000000..b8473ec --- /dev/null +++ b/deploy/systemd/verae-edge-billing-overdue.service @@ -0,0 +1,17 @@ +[Unit] +Description=verae-edge-billing 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/verae-edge-billing +Environment=NODE_ENV=production +EnvironmentFile=-/SSD2/secrets/zap-verae/verae-edge-billing.env +EnvironmentFile=-/etc/verae/verae-edge-billing-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/verae-edge-billing-overdue.timer b/deploy/systemd/verae-edge-billing-overdue.timer new file mode 100644 index 0000000..cd2fef5 --- /dev/null +++ b/deploy/systemd/verae-edge-billing-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..57f4f55 --- /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 verae-edge-billing | +| `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..10a1286 --- /dev/null +++ b/docs/ENV-MATRIX.md @@ -0,0 +1,27 @@ +# Live lab env matrix (NS1 70.88.205.138) + +| Service | Bind | Role | +|---------|------|------| +| verae-edge-billing | `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 verae-edge-billing → 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. + +## Peergos quota hook (2026-10-06) + +| Variable | Default | Meaning | +|---|---|---| +| `PEERGOS_URL` | `https://peergos.verae.ai` | Owner and key lookups | +| `PEERGOS_INTERNAL_USERS` | `peergos,verae-admin,verae-ops` | Internal accounts (internal quota when unlinked) | +| `PEERGOS_INTERNAL_PREFIXES` | `grokbot` | Username prefixes treated as internal/test | +| `PEERGOS_INTERNAL_QUOTA_BYTES` | 5368709120 | Quota for unlinked internal users | +| `PEERGOS_UNLINKED_QUOTA_BYTES` | 1073741824 | Quota for other unlinked users | +| `PEERGOS_LINK_GRACE_DAYS` | 30 | Reported link grace period | +| `PEERGOS_MAX_USERS` | 100000 | Signup cap (replaces Peergos `-max-users` in paid mode) | 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..d93463b --- /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) | verae-edge-billing | 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/verae-edge-billing +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/verae-edge-billing \ + -p EnvironmentFile=/SSD2/secrets/zap-verae/verae-edge-billing.env -p EnvironmentFile=/etc/verae/verae-edge-billing-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/verae-edge-billing-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/verae-edge-billing-overdue.{service,timer}` (09:00 America/New_York daily, **dry run** by default). + +```bash +sudo cp deploy/systemd/verae-edge-billing-overdue.* /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now verae-edge-billing-overdue.timer +journalctl -u verae-edge-billing-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/verae-edge-billing.env; . /etc/verae/verae-edge-billing-smtp.env; set +a; cd /opt/verae/verae-edge-billing && 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..22a520b --- /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/verae-edge-billing-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..ad9e33a --- /dev/null +++ b/docs/PEERGOS-BILLING.md @@ -0,0 +1,242 @@ +# Peergos ↔ verae-edge-billing billing + +Maps Peergos people onto the Zapier metering stack on host 138 +(`/opt/verae/verae-edge-billing`, service `verae-edge-billing.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/verae-edge-billing-work` +Live: `/opt/verae/verae-edge-billing` · `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. **Implemented 2026-10-06**: see *Account linking* below. Accounts created before then stay unlinked until their owner links them, with a 1 GiB quota during a 30-day grace period. | +| 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**. **Implemented 2026-10-06** (`POST /portal/api/peergos/signup`, Free quota from the quota hook). 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 blocks reads as well as writes** (revised by George on 2026-10-06; it replaces the earlier quota-pin plan). A delinquent customer's Peergos users cannot log in, read, download, write, or serve public or secret links until they pay. Enforcement is the access gate below, which is **implemented and live**. The app shows "Access is paused until this invoice is paid" with Pay now. | +| 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. | + + +## Peergos access gate (D11, live since 2026-10-06) + +**What is blocked.** Caddy on ns2 sends every `peergos.verae.ai` request that names a Peergos user +to `GET https://zap.verae.ai/peergos-gate/check` as a `forward_auth` subrequest. A request names a +user when it has an `owner=` query parameter (blocks, pointers, champ, transactions, social), a +`username=` parameter (login, BATs), or a `/public//` path. The service answers **200** +to let the request through or **402** to block it, with `{"error":"payment_required","message":"Access is paused until this +invoice is paid","payUrl":…}`. Reads and writes are blocked alike. So are logins and anything other +people fetch from the delinquent user (shared files, public and secret links). Requests that name no +user, such as static web assets and `core/getPublicKey`, are not gated. + +**Who is delinquent.** A customer is delinquent when it is a top-level customer with linked +`peergos:` users and any of these is true: +* it has an open **billing hold**: `payment_failed` (set by a declined pay-now) or `manual` (set by staff); +* it has an **issued invoice with billable > 0 that is past its due date**. There is no grace period; + access pauses on the day after the due date; +* `PEERGOS_BLOCK_ON_UNPAID=1` is set and it has any issued unpaid invoice, even before the due date. + This is off by default because it would block PO customers during their net terms. + +Peergos users who are not linked to a Verae customer are never blocked. + +**Getting access back.** zap.verae.ai (portal, `/portal/api/billing/*`, payment methods, pay-now) +is not gated, so customers can always pay. A successful pay-now confirmation marks the invoice paid, +clears `payment_failed` holds and rebuilds the gate index immediately. Access returns on the next +request. Other changes, such as an admin marking an invoice paid, take effect within 10 s (the index +TTL). Staff hold routes rebuild the index at once. + +**Owner keys.** Peergos clients send the owner public-key hash, not the username. The service +resolves `username → owner` with Peergos `POST /peergos/v0/core/getPublicKey` and caches the result +in `peergos_owner_keys`. + +**Staff routes** (`/admin/api`): +* `GET /customers/:id/holds` +* `POST /customers/:id/holds {reason?: manual|payment_failed, note?}` +* `POST /customers/:id/holds/clear {reason?}` +* `GET /peergos/gate[?username=]` lists blocked users. + +**Caddy.** +* ns2 `/etc/caddy/Caddyfile`, `peergos.verae.ai`: `forward_auth` to `https://70.88.205.138` (SNI and Host `zap.verae.ai`). +* Fail-open: if the gate cannot be reached (502/503/504), `handle_errors` proxies the request to Peergos anyway, so a billing outage does not take storage down. To fail closed instead, delete the `handle_errors` block. +* ns1 `zap.verae.ai.caddy` answers 403 on `/peergos-gate/*` unless the caller is ns2 (70.88.205.137) or loopback. + +**Limits.** +* **Device caches:** data already downloaded, synced or cached by a client stays readable offline. The gate only stops new requests. +* **Honest clients only:** `owner=` is supplied by the client. Peergos authorizes block reads with BATs, not with `owner`, so a modified client could fetch blocks it already knows how to address while omitting or faking `owner`. The official web app, Flutter app and rust client always send it. A server-side check inside Peergos would be needed to close this. +* **Peer-to-peer and S3:** + * IPFS/p2p block exchange and any direct S3 access bypass Caddy. ns2 currently has no authed-s3-reads or direct-s3-writes. + * `s3.peergos.verae.ai` (SeaweedFS S3) is a separate vhost and is not gated; it needs S3 credentials. +* **Links:** public and secret links are blocked by default, as George asked, because they fetch through the same owner-tagged API. There is no per-link exemption. +* **Stripe-side failures:** there is no Stripe webhook yet, so a failed subscription renewal or card charge outside pay-now does not create a hold. Only overdue invoices and pay-now failures do. + + +## Account linking (D3, live since 2026-10-06) + +The app's signup has a required "Your Verae account" step. It creates a Free-plan customer +(no card) or signs in to an existing one, and links the new Peergos username to it as the +`peergos:` sub-account (`parent_id` = the customer). + +**Ownership proof.** Nobody can link a username they cannot sign in to: +1. `POST /portal/api/peergos/challenge {peergosUsername, purpose: "signup"|"link"}` returns + `{challengeId, message, expiresAt}`. The challenge is single use, lasts 10 minutes and is tied + to that username and purpose. The username must exist on our Peergos server: it must be in the + quota hook's user table (see *Quotas*). +2. The app signs `message` with the user's **Peergos identity key** (`crypto_sign`: signature then + message, hex). The key is only available after a Peergos login. The Rust bridge function is + `sign_billing_link_challenge(session, message)`. +3. The server resolves `username → owner` (`core/getPublicKey`, cached in `peergos_owner_keys`). It + takes the Ed25519 key from the owner CID itself; Peergos owners are identity-multihash CIDs that + embed the key block. For sha2-256 owner CIDs it fetches the block and checks the hash. It then + verifies the signature and requires the signed text to equal the challenge exactly. +4. A username already linked to a different customer is refused (`409 + peergos_user_linked_to_another_account`). Only staff can unlink. + +The message format is `verae-billing-link/v1\nusername=\npurpose=

\nchallenge=\nexpires=\nservice=zap.verae.ai`. + +**Routes** (`/portal/api/peergos`, mounted before the session-protected portal routes): + +| Route | Auth | What it does | +|---|---|---| +| `POST /challenge` | public, 30 per 15 min per IP | Issues a challenge (see above). Errors: `invalid_peergos_username`, `peergos_user_not_found`, `peergos_user_not_on_this_server`. | +| `POST /signup` `{name, email, password, peergosUsername, challengeId, signature}` | public, 10 per 15 min per IP | Validates the fields first (password policy, duplicate email → 409), then verifies the proof, creates a **Free** customer (no card, $50 signup credit as with portal signup), links the user and returns `{token, customer, link}`. `token` is a normal portal session. | +| `POST /link` `{peergosUsername, challengeId, signature}` | `Authorization: Bearer ` | Links the proven user to the signed-in customer. Use it after `POST /portal/api/login` (TOTP supported) for "I already have an account". | +| `GET /links` | Bearer | The customer's linked Peergos users with `quotaBytes`, `quotaBasis` and `tierId`. | +| `GET /link-status/:username` | public, 120 per 15 min per IP | `{username, linked}` only. | + +**Staff routes** (`/admin/api`; writes need `admin.customers` when IAM is on): +* `GET /peergos/links` (audit log, including unlinked rows) +* `GET /peergos/links/:username` (link view plus quota) +* `DELETE /peergos/links/:username` **unlinks**: it clears `parent_id` on `peergos:` and + stamps `unlinked_at_ms`/`unlinked_by`. The user falls back to the unlinked quota. +* `GET /customer-flags`, `GET|PUT /customers/:id/flags {test?, noInvoice?, note?}`. Invoice + generation skips customers flagged `noInvoice`, and their sub-accounts. Use this for e2e test + customers. + +Tables (additive, on billing.db): `peergos_links`, `peergos_identity_keys`, `customer_flags`. + +## Quotas: billing is the source of truth (live since 2026-10-06) + +Peergos on ns2 runs with `-quota-admin-address /ip4/70.88.205.138/tcp/3041` (upstream paid mode, +`HttpQuotaAdmin`). Peergos no longer reads its local `quotas.sql`. It asks this service for every +quota, the list of local users, whether a signup is allowed, and signup tokens. In paid mode +`-default-quota` and `-max-users` are ignored; `PEERGOS_MAX_USERS` here replaces them. + +**Quota per user** (GiB = 2^30), computed by `PeergosQuotaService` and cached for 15 s: + +| User | Quota | +|---|---| +| Linked (`peergos:` child with a parent) | The parent's tier included storage: Free 1, Pro 50, Business 500 GiB. A customer `includedStorageGbOverride` wins. | +| Internal/test, not linked (`PEERGOS_INTERNAL_USERS` = peergos, verae-admin, verae-ops; `PEERGOS_INTERNAL_PREFIXES` = grokbot) | `PEERGOS_INTERNAL_QUOTA_BYTES` (5 GiB) | +| Any other known user, not linked | `PEERGOS_UNLINKED_QUOTA_BYTES` (1 GiB). `graceEndsAt` (first seen + `PEERGOS_LINK_GRACE_DAYS`, 30) is reported in link views. The quota does **not** change after the grace period yet; freezing is open (design N5). | +| Removed (`quota-remove`) or unknown | 0 | + +Every result is floored at the user's **legacy quota** (imported from `quotas.sql` at the switch, so +no existing account lost space) and at any staff **override** (`PUT +/admin/api/peergos/quotas/:username/override {quotaBytes, reason}`, the auto-raise hook for D6). +Peergos caches quotas for up to an hour in its space check, so a tier change can take that long to +show in uploads. `get_quota` from the app asks directly. + +**`/quota-admin/*`** answers the upstream calls in CBOR: `signups`, `allowed`, `token-add|remove|list|delete`, +`quota-by-name`, `quota-by-name-time`, `quota-remove`, `usernames`, `quota` and `payment-properties` +(both check the owner's signed time against the owner key; Peergos does not check them in paid mode), +`request`, `create-paid` and `remove-desired-quota`. `request` verifies the signed SpaceRequest and +answers with the current quota plus a message pointing to Usage & Billing. Plan changes happen in +Verae, not in Peergos. `create-paid` is refused. Errors are 400 with the message in the `Trailer` +header, as `HttpQuotaAdmin` expects. Without the `X-Verae-Quota-Relay: 1` header the routes return 404. + +**Network.** ns1 Caddy listens on `http://70.88.205.138:3041`. It only accepts 70.88.205.137 (ns2) +and loopback, only serves `/quota-admin/*`, sets `X-Verae-Quota-Relay: 1` and proxies to +127.0.0.1:3040. `zap.verae.ai` answers 404 on `/quota-admin/*`. + +**Coupling.** Peergos logins, writes, signups and quota checks now depend on verae-edge-billing. +A billing restart briefly fails those calls; Peergos retries on the next request. + +**Staff quota routes:** `GET /peergos/quotas`, `GET /peergos/quotas/:username`, +`POST /peergos/quotas/import {users:[{username, quotaBytes}], tokens:[...]}` (used once at the switch; +idempotent, only raises legacy quotas), `PUT|DELETE /peergos/quotas/:username/override`. + +Tables (additive): `peergos_quota_users`, `peergos_signup_tokens`, `peergos_quota_overrides`. + +**Rollback** (back to Peergos-local quotas): remove `-quota-admin-address` from the compose +command and recreate the container. `quotas.sql` was left untouched. Users who signed up in paid mode +are not in it; add them with `GET /admin/api/peergos/quotas` and Peergos `-quotas-init-file` or the +quota CLI. diff --git a/docs/USAGE-BILLING-API.md b/docs/USAGE-BILLING-API.md new file mode 100644 index 0000000..6f07165 --- /dev/null +++ b/docs/USAGE-BILLING-API.md @@ -0,0 +1,62 @@ +# 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`/`restricted`), `accessPaused`, `pausedReasons` (`invoice_overdue`, `invoice_unpaid`, `payment_failed`, `manual`), `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 | +| GET | `/customers/:id/holds` | Billing holds and the customer's current delinquency | +| POST | `/customers/:id/holds` `{reason?: manual\|payment_failed, note?}` | Pauses Peergos access for the customer's users | +| POST | `/customers/:id/holds/clear` `{reason?}` | Clears holds; access returns immediately | +| GET | `/peergos/gate?username=` | Currently blocked Peergos users (owner key, reasons) | + +Gate endpoint: `GET /peergos-gate/check` with `X-Forwarded-Uri` returns 200 (allowed) or 402 (paused). It is called by ns2 Caddy `forward_auth`, and ns1 Caddy restricts it to ns2. See `docs/PEERGOS-BILLING.md`. + +## Peergos quota in `/usage` + +Each `storage.users[]` entry has `quotaBytes`: the user's current Peergos quota from the quota hook (see PEERGOS-BILLING.md, *Quotas*). Linking and signup routes are under `/portal/api/peergos` (PEERGOS-BILLING.md, *Account linking*). + +## 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)` +* `billing_holds(id, customer_id, reason, note, created_at_ms, created_by, cleared_at_ms, cleared_by)` +* `peergos_owner_keys(username, owner, resolved_at_ms)` + +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 `restricted` as soon as the customer is delinquent (an overdue invoice, an open hold, or optionally any unpaid invoice). It is enforced on Peergos by the access gate, which blocks reads and writes. +* A failed pay-now (`requires_payment_method` or `canceled` with a `last_payment_error`) creates a `payment_failed` hold. A successful one clears it. 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..b008670 --- /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 → verae-edge-billing (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 ──► verae-edge-billing (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/verae-edge-billing` | Live `/opt/verae/verae-edge-billing` 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 +- [ ] verae-edge-billing 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 verae-edge-billing 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 verae-edge-billing `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..79ee4cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,21 @@ { - "name": "zappier", + "name": "verae-edge-billing", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "zappier", + "name": "verae-edge-billing", "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..403a371 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,24 @@ { - "name": "zappier", + "name": "verae-edge-billing", "version": "0.1.0", "private": true, "scripts": { "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 + + + + + + + + + + + + + + + +