Initial import of zappier-identity from zapier monorepo
This commit is contained in:
commit
d422bb2639
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