Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
e64a9322ac sync: pick up fail-closed Verae cert fix already deployed live (from fail-closed-verae-certs @1107272: src/app.ts, src/upstream.ts, tests) 2026-10-05 14:01:45 -04:00
2f524deea0 feat(auth): password change + reset with emailed 6-digit codes for portal and admin
Customer portal:
- POST /portal/api/password/change (session): currentPassword -> emailed code;
  then {code,newPassword,totpCode?}. TOTP required when enabled. Aliases
  /password/change/start|confirm. Revokes all sessions, returns a new token,
  sends a 'password was changed' notice. Security tab UI replaces the
  'handled by support' card. Capabilities: passwordChange, loginIdentifier=email.

Admin console:
- admin_users gains email (unique, case-insensitive), totp_secret, totp_enabled
  (additive migration). ADMIN_USER_EMAILS backfill env (never overwrites).
- Login by email or legacy username; optional TOTP enforced at login.
- POST /admin/api/password/forgot|reset (no enumeration, emailed code, TOTP if on).
- /admin/api/account: password change (current pw -> code -> new pw), verified
  email change (code to new address), TOTP setup/enable/disable, revoke-others.
- POST /users takes {email,password}; email becomes the username.
  PUT /users/:username/email for other admins. Deactivate revokes sessions.
- UI: Forgot password on sign-in, TOTP prompt, Users -> My account, Set email.

Email-as-username: admin customer create validates/lower-cases email (portal
sign-in name, dup -> 409) and the UI asks for it; portal labels updated.

Shared: src/email-codes.ts, src/admin-sessions.ts, src/admin-account.ts.
Docs: docs/PASSWORD-2FA.md. Tests: tests/password-change-2fa.test.ts.
2026-10-05 14:00:47 -04:00
5b37842fee sync: import live /opt/verae/zappier-edge state (2026-10-05) into git 2026-10-05 13:50:26 -04:00
107 changed files with 13558 additions and 422 deletions

View file

@ -1,8 +1,9 @@
# Zappier
[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
Metered API platform: per-endpoint pricing, customer types with multipliers and monthly credits, a usage ledger,...
Metered API platform: per-endpoint pricing, customer types with multipliers and
monthly credits, a usage ledger, Stripe metered billing, purchase-order
invoicing, a company admin console, a self-service customer portal, and a
Zapier integration.
This is the **billing and user platform** (originally the standalone `zappier` git tree at `~/zappier`). Forgejo name: **zappier-edge**. It owns signup, TOTP, API keys, rate card, Stripe meter, PO invoices, admin users, and the customer portal. Verae middleware does not replace this.
@ -139,32 +140,3 @@ To deploy it, create a Zapier developer account, run `zapier login`, then
npm test # root API/service suite (jest, 165 tests)
cd zapier-app && npm test # Zapier integration suite (mocha, 4 tests)
```
## Table of Contents
- [Install](#install)
- [Usage](#usage)
- [Contributing](#contributing)
- [License](#license)
## Install
```sh
git clone ssh://git@git.georgelambert.org:2223/marchon/zappier-edge.git
cd zappier-edge
```
## Usage
```sh
npm install
npm start
```
## Contributing
Questions: open an issue at https://git.georgelambert.org/marchon/zappier-edge/issues. Pull requests are accepted.
## License
UNLICENSED © George Lambert

View file

@ -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() {
}</div>
<div class="card">
<h3>Add customer</h3>
<input id="new-customer-name" placeholder="name">
<input id="new-customer-email" type="email" placeholder="customer email (portal sign-in)" autocomplete="off">
<input id="new-customer-name" placeholder="name / organization">
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
<button class="btn" onclick="addCustomer()">Create</button>
<p class="hint">The new customer's API key is shown once in the notification — copy it immediately. Set email and billing method in the edit drawer.</p>
<p class="hint">The customer's <b>email is their sign-in name</b>: they claim the account at the portal with <i>Create an account</i> using this same email. The API key is shown once in the notification — copy it immediately. Set billing method in the edit drawer.</p>
</div>`;
}
@ -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() {
</div>`;
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) => `<tr>
<td class="id">${u.username}</td>
<td class="id">${esc(u.username)}</td>
<td>${u.email ? esc(u.email) : '<span class="hint">— none (cannot reset by email)</span>'}</td>
<td><span class="pill ${u.active ? 'paid' : 'draft'}">${u.active ? 'active' : 'inactive'}</span></td>
<td>${u.totpEnabled ? '<span class="pill paid">TOTP</span>' : '<span class="pill draft">email only</span>'}</td>
<td>${fmtDate(u.createdMs)}</td>
<td class="row-actions">
<button class="btn ${u.active ? 'ghost' : ''}" onclick="toggleUser('${u.username}', ${!u.active})">${u.active ? 'Deactivate' : 'Activate'}</button>
<button class="btn ghost" onclick="setUserEmail('${esc(u.username)}')">Set email</button>
<button class="btn ${u.active ? 'ghost' : ''}" onclick="toggleUser('${esc(u.username)}', ${!u.active})">${u.active ? 'Deactivate' : 'Activate'}</button>
</td>
</tr>`,
)
.join('');
document.getElementById('users').innerHTML = `
<h2>Admin users</h2>
<p class="lede">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.</p>
${renderMyAccount()}
<p class="lede">Accounts that can sign in to this console. <b>Email is the sign-in name</b> (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.</p>
<div class="card"><table>
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th></th></tr></thead>
<thead><tr><th>Sign-in name</th><th>Email (codes)</th><th>Status</th><th>2FA</th><th>Created</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add admin user</h3>
<input id="new-user-name" placeholder="username" autocomplete="off">
<input id="new-user-email" type="email" placeholder="email (sign-in name)" autocomplete="off">
<input id="new-user-password" type="password" placeholder="password (min 8 chars)" autocomplete="new-password">
<button class="btn" onclick="addUser()">Create</button>
<p class="hint">Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.</p>
<p class="hint">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.</p>
</div>`;
}
function renderMyAccount() {
const a = state.account;
if (!a) {
return `<div class="card"><h3>My account</h3><p class="hint" style="margin:0">Sign in with an admin email/username (not the x-admin-key) to change your password here.</p></div>`;
}
const emailRow = a.email
? `Codes go to <b>${a.email}</b>.`
: `<b style="color:var(--danger)">No email on file</b> — add one to enable password change and "Forgot password?".`;
return `<div class="card" id="my-account">
<h3>My account — ${a.username}</h3>
<p class="hint" style="margin-top:0">${emailRow} ${a.emailAvailable ? '' : '<b>Server email is not configured.</b>'}</p>
<h4 style="margin:1rem 0 0.4rem">Change password (code by email${a.totpEnabled ? ' + authenticator' : ''})</h4>
<div id="acct-pw-step1" class="filterbar">
<label><span>Current password</span><input id="acct-pw-current" type="password" autocomplete="current-password"></label>
<button class="btn" onclick="acctStartPw()" ${a.email ? '' : 'disabled'}>Email me a code</button>
</div>
<div id="acct-pw-step2" hidden>
<p class="hint" id="acct-pw-sent" style="margin-top:0"></p>
<div class="filterbar">
<label><span>6-digit code</span><input id="acct-pw-code" inputmode="numeric" maxlength="6" placeholder="123456"></label>
${a.totpEnabled ? '<label><span>Authenticator code</span><input id="acct-pw-totp" inputmode="numeric" maxlength="6" placeholder="123456"></label>' : ''}
<label><span>New password (min 8)</span><input id="acct-pw-new" type="password" autocomplete="new-password"></label>
<label><span>Confirm</span><input id="acct-pw-new2" type="password" autocomplete="new-password"></label>
<button class="btn" onclick="acctConfirmPw()">Change password</button>
</div>
</div>
<h4 style="margin:1rem 0 0.4rem">${a.email ? 'Change' : 'Add'} email</h4>
<div class="filterbar">
<label><span>New email</span><input id="acct-email-new" type="email" placeholder="you@company.com"></label>
<label><span>Current password</span><input id="acct-email-pw" type="password" autocomplete="current-password"></label>
${a.totpEnabled ? '<label><span>Authenticator code</span><input id="acct-email-totp" inputmode="numeric" maxlength="6"></label>' : ''}
<button class="btn ghost" onclick="acctStartEmail()">Send verification code</button>
</div>
<div id="acct-email-step2" class="filterbar" hidden>
<label><span>Code sent to the new address</span><input id="acct-email-code" inputmode="numeric" maxlength="6" placeholder="123456"></label>
<button class="btn" onclick="acctConfirmEmail()">Verify &amp; save email</button>
</div>
<h4 style="margin:1rem 0 0.4rem">Authenticator app (optional) <span class="pill ${a.totpEnabled ? 'paid' : 'draft'}">${a.totpEnabled ? 'enabled' : 'off'}</span></h4>
${
a.totpEnabled
? `<div class="filterbar"><label><span>Authenticator code</span><input id="acct-totp-disable" inputmode="numeric" maxlength="6"></label><button class="btn ghost" onclick="acctDisableTotp()">Disable</button></div>`
: `<button class="btn ghost" onclick="acctSetupTotp()">Set up authenticator</button><div id="acct-totp-setup"></div>`
}
<p class="hint" id="acct-error" style="color:var(--danger)"></p>
</div>`;
}
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 = `
<div style="display:flex;gap:1rem;align-items:center;margin-top:0.6rem">
<img src="${r.qr}" alt="2FA QR code" width="160" height="160">
<div>
<p class="hint" style="margin:0">Scan with your authenticator app, or enter: <code>${r.secret}</code></p>
<div class="filterbar"><label><span>Current code</span><input id="acct-totp-enable" inputmode="numeric" maxlength="6"></label>
<button class="btn" onclick="acctEnableTotp()">Enable</button></div>
</div>
</div>`;
} 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: '{}',
});

View file

@ -6,6 +6,7 @@
<title>Zappier Admin</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
[hidden] { display: none !important; }
:root {
--bg: #f4f5fb;
--panel: #ffffff;
@ -67,7 +68,8 @@
cursor: pointer;
}
#login button:hover { filter: brightness(1.08); }
#login-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 0.6rem 0 0; }
#login-error, #admin-reset-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 0.6rem 0 0; }
#login .auth-link { display: block; margin: 0.5rem 0 0; font-size: 0.8rem; color: var(--accent); cursor: pointer; text-align: right; text-decoration: none; }
/* ---------- Shell ---------- */
#shell { display: none; min-height: 100vh; }
@ -235,18 +237,43 @@
</style>
</head>
<body>
<p style="padding:0.5rem 1rem"><a href="/admin/sales-rates.html">Sales channel rates (Zapier · MCP · API)</a></p>
<a class="skip" href="#main">Skip to content</a>
<section id="login">
<form class="card" id="login-form">
<h1>Zappier Admin</h1>
<p class="sub">Sign in to manage pricing, customer types, and customers.</p>
<label for="login-username">Username</label>
<input id="login-username" autocomplete="username" required />
<label for="login-username">Email</label>
<input id="login-username" autocomplete="username email" placeholder="you@company.com (legacy usernames still work)" required />
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" required />
<a id="admin-forgot-link" class="auth-link">Forgot password?</a>
<div id="login-totp-group" style="display:none">
<label for="login-totp">Authenticator code</label>
<input id="login-totp" inputmode="numeric" maxlength="6" placeholder="123456" />
</div>
<p id="login-error"></p>
<button type="submit">Sign in</button>
</form>
<form class="card" id="admin-reset-form" style="display:none" novalidate>
<h1>Reset admin password</h1>
<p class="sub" id="admin-reset-sub">Enter your admin email (or legacy username). We'll email a 6-digit code to the address on file.</p>
<label for="admin-reset-ident">Email</label>
<input id="admin-reset-ident" autocomplete="username email" />
<div id="admin-reset-step2" style="display:none">
<label for="admin-reset-code">6-digit code from the email</label>
<input id="admin-reset-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="123456" />
<label for="admin-reset-totp">Authenticator code (only if 2FA is on)</label>
<input id="admin-reset-totp" inputmode="numeric" maxlength="6" placeholder="optional" />
<label for="admin-reset-password">New password</label>
<input id="admin-reset-password" type="password" autocomplete="new-password" placeholder="At least 8 characters" />
<label for="admin-reset-password2">Confirm new password</label>
<input id="admin-reset-password2" type="password" autocomplete="new-password" />
</div>
<p id="admin-reset-error"></p>
<button type="submit" id="admin-reset-submit">Send code</button>
<a id="admin-reset-back" class="auth-link" style="text-align:center">Back to sign in</a>
</form>
</section>
<div id="shell">

49
admin/sales-rates.html Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sales channel rates — Verae</title>
<style>
:root { font-family: system-ui, sans-serif; color: #0f172a; }
body { margin: 1.5rem; max-width: 1100px; }
h1 { font-size: 1.25rem; }
.note { background: #eef2ff; padding: .75rem 1rem; border-radius: 8px; margin: 1rem 0; }
label { display: inline-block; margin-right: 1rem; }
select, input[type=number] { padding: .35rem .5rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border: 1px solid #cbd5e1; padding: .4rem .55rem; text-align: left; }
th { background: #f1f5f9; }
input.sku { width: 5.5rem; }
button { margin-top: 1rem; padding: .5rem 1rem; cursor: pointer; }
.ok { color: #047857; }
.err { color: #b91c1c; }
a { color: #3730a3; }
</style>
</head>
<body>
<p><a href="/admin/">← Admin</a></p>
<h1>Sales: Zapier · MCP · API rates</h1>
<p class="note">
<strong>Segment</strong> sets credit rates per billing channel.
<strong>Tier</strong> (free/pro/business) only sets included stamps/GB — edit that under Pricing.
Ledger unit is <strong>credits</strong>.
</p>
<div>
<label>Channel
<select id="channel"></select>
</label>
<label>Segment
<select id="segment"></select>
</label>
<label>Overage × <input id="overage" type="number" step="0.05" min="0" class="sku" /></label>
<label>Prepaid overage × <input id="prepaid" type="number" step="0.05" min="0" class="sku" /></label>
</div>
<table>
<thead><tr><th>SKU</th><th>Credits</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<button id="save" type="button">Save channel × segment</button>
<p id="status"></p>
<script src="/admin/sales-rates.js"></script>
</body>
</html>

88
admin/sales-rates.js Normal file
View file

@ -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) =>
`<tr><td><code>${sku}</code></td><td><input class="sku" data-sku="${sku}" type="number" min="0" step="1" value="${
rates[sku] ?? 0
}" /></td></tr>`,
)
.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) => `<option value="${c}">${c}</option>`).join('');
segmentEl.innerHTML = data.segments.map((s) => `<option value="${s}">${s}</option>`).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();
})();

23
client/README.md Normal file
View file

@ -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": "<hex>" }` 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).

100
client/hasher/datacube_hasher.d.ts vendored Normal file
View file

@ -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<string>;
/**
* Batch hash multiple files (returns JSON array)
*/
export function compute_sha256_batch(files: Array<any>): Promise<string>;
/**
* 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<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View file

@ -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<string>}
*/
export function compute_sha256(file) {
const ret = wasm.compute_sha256(file);
return ret;
}
/**
* Batch hash multiple files (returns JSON array)
* @param {Array<any>} files
* @returns {Promise<string>}
*/
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 };

Binary file not shown.

49
client/hasher/index.html Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Verae rapid SHA-256 (WASM)</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
code, pre { background: #f1f5f9; padding: .2rem .4rem; border-radius: 4px; }
pre { padding: 1rem; overflow: auto; }
#out { margin-top: 1rem; word-break: break-all; }
</style>
</head>
<body>
<h1>Verae SHA-256 WASM</h1>
<p>Client-side rapid hashing for Zapier Code steps, browser apps, and Peergos. Hash locally, then send only the hex to <code>POST /v1/timestamp</code>.</p>
<input type="file" id="file" />
<pre id="out">Pick a file…</pre>
<h2>TS / JS import</h2>
<pre>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();</pre>
<p>Also available: <code>datacube_hasher.js</code> (<code>compute_sha256</code>, <code>StreamingHasher</code>).</p>
<script type="module">
import init, { sha256_hex_js, Sha256Stream } from './verae_sha256.js';
const out = document.getElementById('out');
await init();
out.textContent = 'WASM ready. sha256("hello") = ' + sha256_hex_js(new TextEncoder().encode('hello'));
document.getElementById('file').addEventListener('change', async (e) => {
const f = e.target.files?.[0];
if (!f) return;
out.textContent = `Hashing ${f.name} (${f.size} bytes)…`;
const stream = new Sha256Stream();
const reader = f.stream().getReader();
const t0 = performance.now();
while (true) {
const { done, value } = await reader.read();
if (done) break;
stream.update(value);
}
const hex = stream.finish();
const ms = (performance.now() - t0).toFixed(1);
out.textContent = `${f.name}\nsha256: ${hex}\n${ms} ms`;
});
</script>
</body>
</html>

8
client/hasher/verae_sha256.d.ts vendored Normal file
View file

@ -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<void>;

View file

@ -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 };

Binary file not shown.

View file

@ -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}}',
};

View file

@ -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' },
},
};

View file

@ -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',
},
},
};

View file

@ -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,
},
],
},
},
};

View file

@ -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 },
};

View file

@ -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"
}
}

View file

@ -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',
},
},
};

Some files were not shown because too many files have changed in this diff Show more