Initial import of zappier-identity from zapier monorepo
This commit is contained in:
commit
7970750980
8 changed files with 204 additions and 0 deletions
3
NATS.md
Normal file
3
NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# NATS — zappier-identity
|
||||||
|
|
||||||
|
Queue `identity`. Subjects `verae.identity.bind`, `verae.identity.lookup`.
|
||||||
12
README.md
Normal file
12
README.md
Normal file
|
|
@ -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`.
|
||||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/ids.js
Normal file
15
src/ids.js
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
89
src/server.js
Normal file
89
src/server.js
Normal file
|
|
@ -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`));
|
||||||
30
src/store.js
Normal file
30
src/store.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
5
src/subjects.js
Normal file
5
src/subjects.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const SUBJECTS = {
|
||||||
|
BIND: 'verae.identity.bind',
|
||||||
|
LOOKUP: 'verae.identity.lookup',
|
||||||
|
QUEUE: 'identity',
|
||||||
|
};
|
||||||
36
test/health.test.js
Normal file
36
test/health.test.js
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue