commit d422bb263998e6420ad1b70eacff25e6b9c50cfb Author: George Lambert Date: Fri Sep 11 17:17:12 2026 -0400 Initial import of zappier-identity from zapier monorepo diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..d7f3166 --- /dev/null +++ b/NATS.md @@ -0,0 +1,3 @@ +# NATS — zappier-identity + +Queue `identity`. Subjects `verae.identity.bind`, `verae.identity.lookup`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..80c438f --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# zappier-identity + +Mailbox for **Verae user id bind/lookup**. Public credentials stay zappier API keys. JWTs never live here. + +**Forgejo:** https://git.georgelambert.org/marchon/zappier-identity + +| Address | Kind | +|---------|------| +| `verae.identity.bind` | request-reply `{ email, customerId }` | +| `verae.identity.lookup` | request-reply `{ customerId }` or `{ veraeUserId }` | + +HTTP `:3026` `POST /bind` `GET /lookup/:customerId`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa4e29a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "zappier-identity", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Mailbox: bind and lookup veraeUserId (not JWTs)", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "nats": "^2.28.2" + } +} diff --git a/src/ids.js b/src/ids.js new file mode 100644 index 0000000..bf9d46f --- /dev/null +++ b/src/ids.js @@ -0,0 +1,15 @@ +import { createHash } from 'node:crypto'; + +export function stableVeraeUserId(username) { + const n = String(username || '') + .trim() + .toLowerCase(); + return `vu_${createHash('sha256').update(n).digest('hex').slice(0, 16)}`; +} + +export function bindEmail(email) { + const veraeUsername = String(email || '') + .trim() + .toLowerCase(); + return { veraeUserId: stableVeraeUserId(veraeUsername), veraeUsername, bound: true }; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..4f1138e --- /dev/null +++ b/src/server.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** Mailbox: bind/lookup veraeUserId. JWT never stored here. */ +import http from 'node:http'; +import { bindEmail } from './ids.js'; +import { load, put } from './store.js'; +import { SUBJECTS } from './subjects.js'; + +const PORT = Number(process.env.PORT || 3026); +const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0'; +const db = load(); + +function handleBind(p) { + const email = p.email || p.veraeUsername; + const bound = bindEmail(email); + const customerId = p.customerId || bound.veraeUsername; + put(db, { customerId, ...bound }); + return { ...bound, customerId }; +} + +function handleLookup(p) { + if (p.customerId && db.byCustomer[p.customerId]) return db.byCustomer[p.customerId]; + if (p.veraeUserId && db.byVerae[p.veraeUserId]) return db.byVerae[p.veraeUserId]; + if (p.email) return bindEmail(p.email); + return { error: 'not found' }; +} + +async function startNats() { + const url = process.env.NATS_URL; + if (!url) return; + const { connect, StringCodec } = await import('nats'); + const nc = await connect({ servers: url.split(','), name: 'zappier-identity' }); + const sc = StringCodec(); + for (const subj of [SUBJECTS.BIND, SUBJECTS.LOOKUP]) { + (async () => { + for await (const m of nc.subscribe(subj, { queue: SUBJECTS.QUEUE })) { + let p = {}; + try { + p = JSON.parse(sc.decode(m.data) || '{}'); + } catch { + p = {}; + } + const out = subj === SUBJECTS.BIND ? handleBind(p) : handleLookup(p); + if (m.reply) m.respond(sc.encode(JSON.stringify(out))); + } + })(); + } +} + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); + } catch { + resolve({}); + } + }); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'zappier-identity', subjects: SUBJECTS }); + } + if (req.method === 'POST' && url.pathname === '/bind') { + return json(200, handleBind(await readBody(req))); + } + const who = url.pathname.match(/^\/lookup\/([^/]+)$/); + if (req.method === 'GET' && who) { + return json(200, handleLookup({ customerId: who[1] })); + } + json(404, { error: 'not found' }); + } catch (err) { + json(500, { error: err.message }); + } +}); + +server.listen(PORT, BIND, () => { + process.stdout.write(`zappier-identity http://${BIND}:${PORT}/\n`); +}); +startNats().catch((err) => process.stderr.write(`nats optional: ${err.message}\n`)); diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..6371ae8 --- /dev/null +++ b/src/store.js @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const file = () => + process.env.IDENTITY_PATH || + path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'identity.json'); + +export function load() { + const p = file(); + if (!fs.existsSync(p)) return { byCustomer: {}, byVerae: {} }; + try { + return { byCustomer: {}, byVerae: {}, ...JSON.parse(fs.readFileSync(p, 'utf8')) }; + } catch { + return { byCustomer: {}, byVerae: {} }; + } +} + +export function save(db) { + const p = file(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(db, null, 2)); +} + +export function put(db, { customerId, veraeUserId, veraeUsername }) { + if (!customerId || !veraeUserId) return db; + db.byCustomer[customerId] = { veraeUserId, veraeUsername }; + db.byVerae[veraeUserId] = { customerId, veraeUsername }; + save(db); + return db; +} diff --git a/src/subjects.js b/src/subjects.js new file mode 100644 index 0000000..821e7c2 --- /dev/null +++ b/src/subjects.js @@ -0,0 +1,5 @@ +export const SUBJECTS = { + BIND: 'verae.identity.bind', + LOOKUP: 'verae.identity.lookup', + QUEUE: 'identity', +}; diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..2c810f8 --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stableVeraeUserId } from '../src/ids.js'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('identity bind is stable and not a JWT', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'id-')); + const port = 18026; + const child = spawn(process.execPath, ['src/server.js'], { + cwd: root, + env: { ...process.env, PORT: String(port), IDENTITY_PATH: path.join(dir, 'id.json') }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 400)); + try { + const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json(); + assert.equal(h.role, 'zappier-identity'); + const b = await ( + await fetch(`http://127.0.0.1:${port}/bind`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'Ada@Example.com', customerId: 'cust_x' }), + }) + ).json(); + assert.equal(b.veraeUserId, stableVeraeUserId('ada@example.com')); + assert.doesNotMatch(JSON.stringify(b), /eyJ|mock-jwt/); + } finally { + child.kill('SIGTERM'); + } +});