Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e9183c5c | ||
|
|
0e41be62ca | ||
|
|
0895f6bdda | ||
| b1dc6e5093 | |||
|
|
cdb4298414 | ||
| 1ac4e5539e | |||
| 3d40fdeb09 | |||
| ac8b801c7f | |||
| 7abb9d6c36 | |||
| e64a9322ac | |||
| 2f524deea0 | |||
| 1107272d38 | |||
| 5b37842fee |
121 changed files with 16425 additions and 438 deletions
39
.env.example
39
.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://<host>
|
||||
# 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/
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -1,8 +1,9 @@
|
|||
# Zappier
|
||||
|
||||
[](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
|
||||
|
|
|
|||
329
admin/app.js
329
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() {
|
|||
}</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 & 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: '{}',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
49
admin/sales-rates.html
Normal 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
88
admin/sales-rates.js
Normal 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
23
client/README.md
Normal 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
100
client/hasher/datacube_hasher.d.ts
vendored
Normal 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>;
|
||||
566
client/hasher/datacube_hasher.js
Normal file
566
client/hasher/datacube_hasher.js
Normal 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 };
|
||||
BIN
client/hasher/datacube_hasher_bg.wasm
Normal file
BIN
client/hasher/datacube_hasher_bg.wasm
Normal file
Binary file not shown.
49
client/hasher/index.html
Normal file
49
client/hasher/index.html
Normal 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
8
client/hasher/verae_sha256.d.ts
vendored
Normal 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>;
|
||||
224
client/hasher/verae_sha256.js
Normal file
224
client/hasher/verae_sha256.js
Normal 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 };
|
||||
BIN
client/hasher/verae_sha256_bg.wasm
Normal file
BIN
client/hasher/verae_sha256_bg.wasm
Normal file
Binary file not shown.
23
client/zapier-app/authentication.js
Normal file
23
client/zapier-app/authentication.js
Normal 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}}',
|
||||
};
|
||||
54
client/zapier-app/creates/store_data.js
Normal file
54
client/zapier-app/creates/store_data.js
Normal 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' },
|
||||
},
|
||||
};
|
||||
106
client/zapier-app/creates/timestamp.js
Normal file
106
client/zapier-app/creates/timestamp.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
98
client/zapier-app/creates/timestamps_batch.js
Normal file
98
client/zapier-app/creates/timestamps_batch.js
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
23
client/zapier-app/index.js
Normal file
23
client/zapier-app/index.js
Normal 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 },
|
||||
};
|
||||
16
client/zapier-app/package.json
Normal file
16
client/zapier-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue