invites.mjs
125 lines 4.2 KB
Raw
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor ⚠ breaking 16 days ago
1 /**
2 * Phase 13 invite flow — pending invites in data/hub_invites.json.
3 * Format: { "invites": { "token": { "role": "editor", "created_at": "ISO" } } }.
4 * Tokens expire after INVITE_EXPIRY_MS (default 7 days).
5 */
6
7 import fs from 'fs';
8 import path from 'path';
9 import crypto from 'crypto';
10 import { readRolesObject, writeRolesFile } from './roles.mjs';
11
12 const INVITES_FILE = 'hub_invites.json';
13 const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
14
15 /**
16 * @param {string} dataDir
17 * @returns {{ [token: string]: { role: string, created_at: string } }}
18 */
19 export function readInvites(dataDir) {
20 if (!dataDir) return {};
21 const filePath = path.join(dataDir, INVITES_FILE);
22 try {
23 if (!fs.existsSync(filePath)) return {};
24 const raw = fs.readFileSync(filePath, 'utf8');
25 const data = JSON.parse(raw);
26 const invites = data.invites && typeof data.invites === 'object' ? data.invites : {};
27 return invites;
28 } catch (_) {
29 return {};
30 }
31 }
32
33 /**
34 * @param {string} dataDir
35 * @param {{ [token: string]: { role: string, created_at: string } }} invites
36 */
37 export function writeInvites(dataDir, invites) {
38 if (!dataDir) throw new Error('data_dir required');
39 const filePath = path.join(dataDir, INVITES_FILE);
40 const obj = {};
41 for (const [token, entry] of Object.entries(invites)) {
42 if (typeof token === 'string' && token && entry && typeof entry.role === 'string' && typeof entry.created_at === 'string') {
43 obj[token] = { role: entry.role, created_at: entry.created_at };
44 }
45 }
46 fs.writeFileSync(filePath, JSON.stringify({ invites: obj }, null, 2), 'utf8');
47 }
48
49 /**
50 * Create a new invite. Returns token and expires_at (ISO string).
51 * @param {string} dataDir
52 * @param {string} role - viewer | editor | admin | evaluator
53 * @returns {{ token: string, role: string, created_at: string, expires_at: string }}
54 */
55 export function createInvite(dataDir, role) {
56 const r = (role || 'editor').toLowerCase();
57 if (!['viewer', 'editor', 'admin', 'evaluator'].includes(r)) throw new Error('role must be viewer, editor, admin, or evaluator');
58 const invites = readInvites(dataDir);
59 const token = crypto.randomBytes(24).toString('base64url');
60 const created_at = new Date().toISOString();
61 const expires_at = new Date(Date.now() + INVITE_EXPIRY_MS).toISOString();
62 invites[token] = { role: r, created_at };
63 writeInvites(dataDir, invites);
64 return { token, role: r, created_at, expires_at };
65 }
66
67 /**
68 * Consume an invite: add sub to roles with invite's role, remove invite. Returns true if consumed.
69 * @param {string} dataDir
70 * @param {string} token
71 * @param {string} sub - e.g. "google:123"
72 * @returns {boolean}
73 */
74 export function consumeInvite(dataDir, token, sub) {
75 if (!dataDir || !token || !sub) return false;
76 const invites = readInvites(dataDir);
77 const entry = invites[token];
78 if (!entry) return false;
79 const created = new Date(entry.created_at).getTime();
80 if (Date.now() - created > INVITE_EXPIRY_MS) {
81 delete invites[token];
82 writeInvites(dataDir, invites);
83 return false;
84 }
85 const roles = readRolesObject(dataDir);
86 roles[sub] = entry.role;
87 writeRolesFile(dataDir, roles);
88 delete invites[token];
89 writeInvites(dataDir, invites);
90 return true;
91 }
92
93 /**
94 * Revoke an invite by token.
95 * @param {string} dataDir
96 * @param {string} token
97 * @returns {boolean} true if existed and was removed
98 */
99 export function revokeInvite(dataDir, token) {
100 if (!dataDir || !token) return false;
101 const invites = readInvites(dataDir);
102 if (!(token in invites)) return false;
103 delete invites[token];
104 writeInvites(dataDir, invites);
105 return true;
106 }
107
108 /**
109 * List pending invites with expiry. Filters out expired.
110 * @param {string} dataDir
111 * @returns {{ token: string, role: string, created_at: string, expires_at: string }[]}
112 */
113 export function listInvites(dataDir) {
114 const invites = readInvites(dataDir);
115 const now = Date.now();
116 const list = [];
117 for (const [token, entry] of Object.entries(invites)) {
118 const created = new Date(entry.created_at).getTime();
119 const expires_at = new Date(created + INVITE_EXPIRY_MS).toISOString();
120 if (now - created <= INVITE_EXPIRY_MS) {
121 list.push({ token, role: entry.role, created_at: entry.created_at, expires_at });
122 }
123 }
124 return list.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
125 }
File History 2 commits
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor 16 days ago