// Help by DeltaBreen for your server: sign the token that says who is logged in to your site, and answer the calls // Help makes to your URL (the side panel, buttons, webhooks), checked against the signature first. // No dependencies, and Web Crypto only, so it runs on Node 22 and later, Next.js, Hono, Cloudflare Workers, Bun and Deno. // The contract is docs/integrations.md and docs/embed.md; docs/signature-vectors.json and docs/identity-vectors.json // hold this file to it, as they hold Help's own server. export const VERSION = '0.1.0' export const SIGNATURE_HEADER = 'x-deltabreen-signature' const REPLAY_WINDOW_SECS = 300 const MAX_BODY_BYTES = 1024 * 1024 const MAX_ANSWER_BYTES = 256 * 1024 const ACTION_KEPT_SECS = 24 * 3600 const EVENT_KEPT_SECS = 48 * 3600 const BUSY_SECS = 60 const MAX_LIFETIME_SECS = 24 * 3600 const encoder = new TextEncoder() /** Something we refused before anything was signed or answered, by a code that says which. */ export class HelpError extends Error { constructor(code, message) { super(message) this.name = 'HelpError' this.code = code } } const env = (name) => globalThis.process?.env?.[name] const bytesOf = (body) => (typeof body === 'string' ? encoder.encode(body) : body instanceof Uint8Array ? body : new Uint8Array(body ?? [])) const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') // Unicode's control characters (C0, DEL and C1), as Help's server refuses them, and the two line separators. const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/ const length = (s) => [...s].length function join(...parts) { const all = parts.map(bytesOf) const out = new Uint8Array(all.reduce((n, p) => n + p.length, 0)) let at = 0 for (const p of all) { out.set(p, at) at += p.length } return out } async function mac(secret, ...parts) { const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) return new Uint8Array(await crypto.subtle.sign('HMAC', key, join(...parts))) } // Compared in time that does not depend on where two texts first differ. function same(a, b) { if (typeof a !== 'string' || a.length !== b.length) return false let diff = 0 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) return diff === 0 } const secretsOf = (secrets) => [secrets].flat().filter((s) => typeof s === 'string' && s.length > 0) /** * Sign the token that tells Help who is logged in, on your server and never in the page. * `user`: `{ id, email?, facts? }`. Options: `secret` (else HELP_IDENTITY_SECRET), `kid`, `expiresIn` (seconds, 600), * `once` (true: the token works once), `now` and `jti` for tests. */ export async function signIdentity(user, options = {}) { const secret = options.secret ?? env('HELP_IDENTITY_SECRET') if (typeof secret !== 'string' || !/^hsk_(live|test)_./.test(secret)) throw new HelpError('bad_secret', 'The secret is the one your site shows under Recognise logged-in customers, starting hsk_live_ or hsk_test_.') const raw = user?.id const id = typeof raw === 'string' ? raw.trim() : Number.isSafeInteger(raw) ? String(raw) : '' if (!id || length(id) > 128 || CONTROL.test(id)) throw new HelpError('bad_user', "The user's id is your own id for them: text or a whole number, 1 to 128 characters.") const email = typeof user.email === 'string' ? user.email.trim() : '' if (user.email != null && typeof user.email !== 'string') throw new HelpError('bad_email', "The user's email is an address, or nothing.") if (email && (email.length > 254 || !/^[^\s@]+@[^\s@]+$/.test(email))) throw new HelpError('bad_email', "The user's email is an address, or nothing.") const facts = user.facts ?? {} if (typeof facts !== 'object' || Array.isArray(facts)) throw new HelpError('bad_facts', 'Facts are an object of names and values.') const names = Object.keys(facts).sort() if (names.length > 10) throw new HelpError('bad_facts', 'At most ten facts.') for (const name of names) { const v = facts[name] const ok = typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== '' && length(v) <= 100 && !CONTROL.test(v)) if (!/^[a-z0-9_]{1,40}$/.test(name) || !ok) throw new HelpError('bad_facts', `The fact "${name}" is a name of lowercase letters, digits and _ (up to 40), with text up to 100 characters, a number, or true or false.`) } const expiresIn = options.expiresIn ?? 600 if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > MAX_LIFETIME_SECS) throw new HelpError('bad_expiry', 'A token lasts from a second to a day; ten minutes is plenty.') const now = options.now ?? Math.floor(Date.now() / 1000) const jti = options.once === false ? null : (options.jti ?? b64url(crypto.getRandomValues(new Uint8Array(16)))) const header = `{"alg":"HS256","typ":"JWT"${options.kid ? `,"kid":${JSON.stringify(options.kid)}` : ''}}` const claims = [`"sub":${JSON.stringify(id)}`] if (email) claims.push(`"email":${JSON.stringify(email)}`) claims.push(`"iat":${now}`, `"exp":${now + expiresIn}`) if (jti) claims.push(`"jti":${JSON.stringify(jti)}`) if (names.length) claims.push(`"user":{${names.map((n) => `${JSON.stringify(n)}:${JSON.stringify(facts[n])}`).join(',')}}`) const input = `${b64url(encoder.encode(header))}.${b64url(encoder.encode(`{${claims.join(',')}}`))}` return `${input}.${b64url(await mac(secret, input))}` } /** What your identity URL answers: 200 and `{ token }` for a logged-in user, 204 for anyone else, never cached. */ export async function identityResponse(user, options = {}) { const headers = { 'cache-control': 'no-store' } if (!user) return new Response(null, { status: 204, headers }) return Response.json({ token: await signIdentity(user, options) }, { headers }) } /** The secret a call to your URL was signed with, of those given, or null: check this before reading the body. */ export async function verifySignature(header, rawBody, secrets, now = Math.floor(Date.now() / 1000)) { if (typeof header !== 'string') return null const parts = header.split(',').map((p) => p.trim()) const t = parts.find((p) => p.startsWith('t='))?.slice(2) if (!t || !/^\d+$/.test(t) || Math.abs(now - Number(t)) > REPLAY_WINDOW_SECS) return null const sent = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3)) for (const secret of secretsOf(secrets)) { const want = hex(await mac(secret, `${t}.`, bytesOf(rawBody))) if (sent.some((v) => same(v, want))) return secret } return null } /** The answer to Help's connection test: the challenge's HMAC under your secret, never the challenge itself. */ export async function handshakeAnswer(secret, challenge) { return hex(await mac(secret, challenge)) } /** Remembers what was done for a press or an event in this process, which is enough for one copy of your server. */ export class MemoryStore { constructor(options = {}) { this.max = options.max ?? 10_000 this.held = new Map() } async claim(key, ttlSeconds) { const now = Date.now() const had = this.held.get(key) if (had && had.until > now) return had.result === undefined ? { state: 'busy' } : { state: 'done', result: had.result } while (this.held.size >= this.max) this.held.delete(this.held.keys().next().value) this.held.set(key, { until: now + Math.min(ttlSeconds, BUSY_SECS) * 1000, result: undefined }) return { state: 'new' } } async finish(key, result, ttlSeconds) { this.held.set(key, { until: Date.now() + ttlSeconds * 1000, result }) } async release(key) { this.held.delete(key) } } const json = (status, value) => ({ status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }) function headerOf(headers, name) { if (!headers) return undefined if (typeof headers.get === 'function') return headers.get(name) ?? undefined const found = Object.keys(headers).find((k) => k.toLowerCase() === name) const v = found === undefined ? undefined : headers[found] return Array.isArray(v) ? v.join(',') : v } function callOf(e) { const call = { id: e.id, type: e.type, mode: e.mode, product: e.product, sentAt: e.sent_at, user: { id: String(e.user?.id ?? '') }, ticket: e.ticket ?? null, agent: e.agent ?? null } if (e.type === 'action') call.action = { key: e.action?.key, params: e.action?.params ?? {}, idempotencyKey: e.action?.idempotency_key } return call } function resultOf(r) { const out = { ok: r?.ok === true } if (typeof r?.message === 'string' && r.message) out.message = r.message if (typeof r?.reference === 'string' && r.reference) out.reference = r.reference return out } /** * Answers Help at your URL: checks the signature, answers the connection test, and calls `hydrate` for the side * panel, `action` for a button (once per press), and `event` for a webhook's event (once per event). * Options: `secrets` (else HELP_CONNECTOR_SECRET and HELP_WEBHOOK_SECRET), `hydrate`, `action`, `event`, `mode` * ('live' or 'test': answer only calls about that key's tickets), `store`, `now` and `log`. */ export function createHandler(options = {}) { const secrets = secretsOf(options.secrets ?? [env('HELP_CONNECTOR_SECRET'), env('HELP_WEBHOOK_SECRET')]) if (!secrets.length) throw new HelpError('no_secret', "Give the integration's secret (hcs_…) or the webhook's (whs_…), or set HELP_CONNECTOR_SECRET.") const store = options.store ?? new MemoryStore() const now = options.now ?? (() => Math.floor(Date.now() / 1000)) const log = options.log ?? ((line) => console.warn(line)) const answer = (status, value) => { const a = json(status, value) if (encoder.encode(a.body).length <= MAX_ANSWER_BYTES) return a log('[help] an answer was over 256 KB, which Help does not take; send less in the side panel.') return json(500, { error: 'answer_too_large' }) } // Once per key: a repeat is answered as the first was, and what threw is not kept, so a press can be tried again. async function once(key, ttl, run) { const claimed = await store.claim(key, ttl) if (claimed.state === 'done') return { status: 200, headers: { 'content-type': 'application/json' }, body: claimed.result } if (claimed.state === 'busy') return json(409, { error: 'in_progress' }) try { const a = await run() await store.finish(key, a.body, ttl) return a } catch (e) { await store.release(key) log(`[help] ${key.split(':')[0]} failed: ${e instanceof Error ? e.name : 'error'}`) return json(500, { error: 'failed' }) } } async function handle(rawBody, headers) { const body = bytesOf(rawBody) if (body.length > MAX_BODY_BYTES) return json(413, { error: 'too_large' }) const secret = await verifySignature(headerOf(headers, SIGNATURE_HEADER), body, secrets, now()) if (!secret) return json(401, { error: 'bad_signature' }) let e try { e = JSON.parse(new TextDecoder().decode(body)) } catch { return json(400, { error: 'bad_json' }) } if (!e || typeof e !== 'object' || Array.isArray(e)) return json(400, { error: 'bad_json' }) const otherMode = options.mode && e.mode !== options.mode switch (e.type) { case 'handshake': if (typeof e.challenge !== 'string') return json(400, { error: 'no_challenge' }) return json(200, { challenge_response: await handshakeAnswer(secret, e.challenge) }) case 'hydrate': { if (!options.hydrate) return json(400, { error: 'not_handled' }) if (otherMode) return json(404, { error: 'not_found' }) const shown = await options.hydrate(callOf(e)) if (shown == null) return json(404, { error: 'not_found' }) if (typeof shown !== 'object' || Array.isArray(shown)) return json(500, { error: 'answer_not_an_object' }) return answer(200, shown) } case 'action': { if (!options.action) return json(400, { error: 'not_handled' }) const key = e.action?.idempotency_key if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{16,64}$/.test(key)) return json(400, { error: 'bad_idempotency_key' }) if (otherMode) return json(200, { ok: false, message: `This is a ${e.mode} ticket; this URL acts for ${options.mode} ones only.` }) return once(`action:${key}`, ACTION_KEPT_SECS, async () => answer(200, resultOf(await options.action(callOf(e))))) } default: { const event = e.type === 'ping' || (typeof e.type === 'string' && e.type.startsWith('ticket.')) if (!event) return json(400, { error: 'unknown_type' }) if (e.type === 'ping') return json(200, { ok: true }) if (!options.event) return json(400, { error: 'not_handled' }) if (typeof e.id !== 'string' || !e.id) return json(400, { error: 'no_id' }) return once(`event:${e.id}`, EVENT_KEPT_SECS, async () => { await options.event(e) return json(200, { ok: true }) }) } } } return { handle, /** For Next.js route handlers, Hono, Cloudflare Workers, Bun and Deno: a Request in, a Response out. */ async fetch(request) { const said = Number(request.headers.get('content-length') ?? 0) if (said > MAX_BODY_BYTES) return new Response('{"error":"too_large"}', { status: 413, headers: { 'content-type': 'application/json' } }) const a = await handle(new Uint8Array(await request.arrayBuffer()), request.headers) return new Response(a.body, { status: a.status, headers: a.headers }) }, /** For Node's http server, and Express with `express.raw({ type: '*\/*' })` on this route. */ node(req, res) { const send = (a) => { res.writeHead(a.status, a.headers) res.end(a.body) } const given = req.body if (given !== undefined && !(given instanceof Uint8Array) && typeof given !== 'string') { log('[help] the body was parsed before it reached us; give this route express.raw({ type: "*/*" }) so its signature can be checked.') return send(json(500, { error: 'body_already_parsed' })) } if (given !== undefined) return void handle(given, req.headers).then(send) const chunks = [] let size = 0 req.on('data', (c) => { size += c.length if (size <= MAX_BODY_BYTES) chunks.push(c) }) req.on('end', () => { if (size > MAX_BODY_BYTES) return send(json(413, { error: 'too_large' })) void handle(join(...chunks), req.headers).then(send) }) }, } }