@passioncode-ai/passioncode 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/LICENSE +22 -0
- package/README.md +69 -0
- package/SECURITY.md +42 -0
- package/bin/passioncode.js +65 -0
- package/family.json +45 -0
- package/lib/launcher.js +361 -0
- package/package.json +48 -0
- package/payload/.claude-plugin/marketplace.json +47 -0
- package/payload/manifest.json +77 -0
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +24 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +175 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +71 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-bundle.md +62 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +55 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +537 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +126 -0
- package/payload/plugins/observatory-log/.claude-plugin/plugin.json +19 -0
- package/payload/plugins/observatory-log/hooks/ask-why.py +98 -0
- package/payload/plugins/observatory-log/hooks/hooks.json +31 -0
- package/payload/plugins/observatory-log/hooks/record-turn.sh +81 -0
- package/payload/plugins/observatory-log/hooks/session-start.sh +27 -0
- package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +111 -0
- package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +115 -0
- package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +87 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +13 -0
- package/payload/plugins/passioncode/hooks/hooks.json +10 -0
- package/payload/plugins/passioncode/hooks/probe.js +23 -0
- package/payload/plugins/passioncode/hooks/session-start.js +45 -0
- package/payload/plugins/passioncode/hooks/update-check.js +126 -0
- package/payload/plugins/passioncode/trust.json +6 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Reference kit for the fabric-service/0.1 local service extension — Node.js 20+, no dependencies.
|
|
2
|
+
// The Node twin of fabric_service.py: same rules, same file formats, interoperable locks.
|
|
3
|
+
// Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015).
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const PROTOCOL = 'fabric-service/0.1';
|
|
12
|
+
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
|
|
13
|
+
export const EXIT_ALREADY_RUNNING = 75;
|
|
14
|
+
export const LEVELS = ['info', 'notice', 'warning', 'error'];
|
|
15
|
+
export const STATUSES = ['starting', 'ready', 'degraded', 'stopping'];
|
|
16
|
+
export const EVENTS_DEFAULT_LIMIT = 50;
|
|
17
|
+
export const EVENTS_MAX_LIMIT = 200;
|
|
18
|
+
export const SESSION_COOKIE = 'fabric_session';
|
|
19
|
+
|
|
20
|
+
const ID = /^[a-z][a-z0-9-]{1,62}$/;
|
|
21
|
+
const INSTANCE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
22
|
+
const KIND = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$/;
|
|
23
|
+
const ORIGIN = /^http:\/\/127\.0\.0\.1:([0-9]{3,5})$/;
|
|
24
|
+
const CODE = /^[A-Za-z0-9_-]{16,256}$/;
|
|
25
|
+
const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
|
|
26
|
+
|
|
27
|
+
export class ServiceError extends Error {}
|
|
28
|
+
export class AlreadyRunning extends ServiceError {
|
|
29
|
+
constructor(holderPid, lockPath) {
|
|
30
|
+
super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'another process'} holds ${lockPath}).`);
|
|
31
|
+
this.holderPid = holderPid;
|
|
32
|
+
this.lockPath = lockPath;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
37
|
+
|
|
38
|
+
export function expand(p) {
|
|
39
|
+
const out = p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
|
|
40
|
+
if (!path.isAbsolute(out)) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function servicesDir() {
|
|
45
|
+
if (process.env.FABRIC_SERVICES_DIR) return expand(process.env.FABRIC_SERVICES_DIR);
|
|
46
|
+
const home = os.homedir();
|
|
47
|
+
if (process.platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
|
|
48
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric/services');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function serviceDirs(id) {
|
|
52
|
+
if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
|
|
53
|
+
const home = os.homedir();
|
|
54
|
+
if (process.platform === 'darwin') {
|
|
55
|
+
return {
|
|
56
|
+
data: path.join(home, 'Library/Application Support', id),
|
|
57
|
+
logs: path.join(home, 'Library/Logs', id),
|
|
58
|
+
cache: path.join(home, 'Library/Caches', id),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
data: path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), id),
|
|
63
|
+
logs: path.join(process.env.XDG_STATE_HOME || path.join(home, '.local/state'), id, 'logs'),
|
|
64
|
+
cache: path.join(process.env.XDG_CACHE_HOME || path.join(home, '.cache'), id),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ensurePrivateDir(dir) {
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
fs.chmodSync(dir, 0o700);
|
|
71
|
+
return dir;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function atomicWrite(file, data, mode = 0o600) {
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
|
|
77
|
+
const fd = fs.openSync(tmp, 'wx', mode);
|
|
78
|
+
try {
|
|
79
|
+
fs.writeSync(fd, data);
|
|
80
|
+
fs.fsyncSync(fd);
|
|
81
|
+
} finally {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
fs.chmodSync(tmp, mode);
|
|
86
|
+
fs.renameSync(tmp, file);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
fs.rmSync(tmp, { force: true });
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- one copy: taken BEFORE any side effect --------------------------------------
|
|
94
|
+
|
|
95
|
+
export class InstanceLock {
|
|
96
|
+
constructor(dataDir) {
|
|
97
|
+
this.path = path.join(dataDir, 'service.lock');
|
|
98
|
+
this.fd = null;
|
|
99
|
+
this.server = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async acquire() {
|
|
103
|
+
ensurePrivateDir(path.dirname(this.path));
|
|
104
|
+
if (process.platform === 'darwin') {
|
|
105
|
+
const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
|
|
106
|
+
try {
|
|
107
|
+
this.fd = fs.openSync(this.path, flags, 0o600);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
fs.ftruncateSync(this.fd, 0);
|
|
113
|
+
fs.writeSync(this.fd, String(process.pid));
|
|
114
|
+
fs.fsyncSync(this.fd);
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
// Linux: an abstract unix socket is exclusive, has no file, and dies with the process.
|
|
118
|
+
const name = `\0fabric-service-${crypto.createHash('sha256').update(path.resolve(this.path)).digest('hex').slice(0, 32)}`;
|
|
119
|
+
this.server = net.createServer();
|
|
120
|
+
await new Promise((resolve, reject) => {
|
|
121
|
+
this.server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new AlreadyRunning(readPid(this.path), this.path) : error));
|
|
122
|
+
this.server.listen(name, resolve);
|
|
123
|
+
});
|
|
124
|
+
this.server.unref();
|
|
125
|
+
atomicWrite(this.path, String(process.pid));
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
release() {
|
|
130
|
+
if (this.fd !== null) fs.closeSync(this.fd);
|
|
131
|
+
if (this.server) this.server.close();
|
|
132
|
+
this.fd = null;
|
|
133
|
+
this.server = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readPid(file) {
|
|
138
|
+
try {
|
|
139
|
+
const text = fs.readFileSync(file, 'utf8').trim();
|
|
140
|
+
return /^\d+$/.test(text) ? Number(text) : null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function holdSingleInstance(dataDir) {
|
|
147
|
+
try {
|
|
148
|
+
return await new InstanceLock(dataDir).acquire();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (!(error instanceof AlreadyRunning)) throw error;
|
|
151
|
+
process.stderr.write(`${error.message}\n`);
|
|
152
|
+
process.exit(EXIT_ALREADY_RUNNING);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- token and network guard ------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export function ensureToken(file) {
|
|
159
|
+
if (fs.existsSync(file)) return readToken(file);
|
|
160
|
+
ensurePrivateDir(path.dirname(file));
|
|
161
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
162
|
+
atomicWrite(file, token, 0o600);
|
|
163
|
+
return token;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function readToken(file) {
|
|
167
|
+
const info = fs.lstatSync(file);
|
|
168
|
+
if (info.isSymbolicLink()) throw new ServiceError(`Token file ${file} is a symlink; refusing it.`);
|
|
169
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
|
|
170
|
+
if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
|
|
171
|
+
const token = fs.readFileSync(file, 'utf8').trim();
|
|
172
|
+
if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
|
|
173
|
+
return token;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function tokenMatches(presented, token, scheme = 'Bearer') {
|
|
177
|
+
if (!presented) return false;
|
|
178
|
+
let value = presented;
|
|
179
|
+
if (scheme === 'Bearer') {
|
|
180
|
+
if (!presented.startsWith('Bearer ')) return false;
|
|
181
|
+
value = presented.slice(7);
|
|
182
|
+
}
|
|
183
|
+
const a = Buffer.from(value.trim());
|
|
184
|
+
const b = Buffer.from(token);
|
|
185
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function checkRequest(port, host, origin, secFetchSite) {
|
|
189
|
+
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
190
|
+
if (!hosts.has(host)) return `Host ${host} is not this service.`;
|
|
191
|
+
if (origin !== undefined && origin !== null && ![...hosts].some((h) => origin === `http://${h}`)) return `Origin ${origin} is not this service.`;
|
|
192
|
+
if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- descriptor (installer only) -----------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export const descriptorPath = (id, instance = 'default', dir = servicesDir()) => path.join(dir, `${id}.${instance}.json`);
|
|
199
|
+
|
|
200
|
+
export function readDescriptors(dir = servicesDir()) {
|
|
201
|
+
if (!fs.existsSync(dir)) return [];
|
|
202
|
+
return fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort().flatMap((n) => {
|
|
203
|
+
try {
|
|
204
|
+
return [[path.join(dir, n), JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))]];
|
|
205
|
+
} catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function validateDescriptor(d) {
|
|
212
|
+
const problems = [];
|
|
213
|
+
for (const key of ['protocol', 'id', 'instance', 'name', 'origin', 'auth', 'lifecycle', 'paths', 'installedAt', 'installedBy']) {
|
|
214
|
+
if (!(key in d)) problems.push(`missing ${key}`);
|
|
215
|
+
}
|
|
216
|
+
if (problems.length) return problems;
|
|
217
|
+
if (d.protocol !== PROTOCOL) problems.push(`protocol must be ${PROTOCOL}`);
|
|
218
|
+
if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
|
|
219
|
+
if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
|
|
220
|
+
if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
221
|
+
if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
|
|
222
|
+
if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
|
|
223
|
+
problems.push('a custom auth header carries the raw token: scheme must be none');
|
|
224
|
+
}
|
|
225
|
+
if (!['launchd', 'none'].includes(d.lifecycle?.manager)) problems.push('lifecycle.manager must be launchd or none');
|
|
226
|
+
if (d.lifecycle?.manager === 'launchd' && !(d.lifecycle.label && String(d.lifecycle.plist ?? '').endsWith('.plist'))) {
|
|
227
|
+
problems.push('a launchd service declares label and plist');
|
|
228
|
+
}
|
|
229
|
+
for (const [name, argv] of Object.entries(d.commands ?? {})) {
|
|
230
|
+
if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
|
|
231
|
+
else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
|
|
232
|
+
else if (!/^(~\/|\/)/.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
|
|
233
|
+
}
|
|
234
|
+
return problems;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function portOf(origin) {
|
|
238
|
+
const m = ORIGIN.exec(origin);
|
|
239
|
+
if (!m) throw new ServiceError(`Origin ${origin} is not http://127.0.0.1:<port>.`);
|
|
240
|
+
return Number(m[1]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function writeDescriptor(d, dir = servicesDir()) {
|
|
244
|
+
const problems = validateDescriptor(d);
|
|
245
|
+
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
246
|
+
const me = `${d.id}.${d.instance}`;
|
|
247
|
+
const port = portOf(d.origin);
|
|
248
|
+
for (const [file, other] of readDescriptors(dir)) {
|
|
249
|
+
const key = `${other.id}.${other.instance ?? 'default'}`;
|
|
250
|
+
if (key === me) continue;
|
|
251
|
+
let otherPort = null;
|
|
252
|
+
try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
|
|
253
|
+
if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
|
|
254
|
+
}
|
|
255
|
+
ensurePrivateDir(dir);
|
|
256
|
+
const target = descriptorPath(d.id, d.instance, dir);
|
|
257
|
+
atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
|
|
258
|
+
return target;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function removeDescriptor(id, instance = 'default', dir = servicesDir()) {
|
|
262
|
+
try { fs.unlinkSync(descriptorPath(id, instance, dir)); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- well-known and events ------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid }) {
|
|
268
|
+
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
269
|
+
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
270
|
+
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
271
|
+
const doc = {
|
|
272
|
+
protocol: PROTOCOL,
|
|
273
|
+
service: { id, instance, name, version, build },
|
|
274
|
+
process: { pid, startedAt },
|
|
275
|
+
status: status === 'ready' && degraded.length ? 'degraded' : status,
|
|
276
|
+
degraded: [...degraded],
|
|
277
|
+
surfaces,
|
|
278
|
+
update: { available: updateAvailable },
|
|
279
|
+
};
|
|
280
|
+
if (summary?.length) doc.summary = summary.slice(0, 6);
|
|
281
|
+
return doc;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function makeEvent(id, at, kind, level, text, { subject, link, notify } = {}) {
|
|
285
|
+
if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
|
|
286
|
+
if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
|
|
287
|
+
const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
288
|
+
if (!sentence) throw new ServiceError('An event needs a sentence.');
|
|
289
|
+
if (link !== undefined && (!link.startsWith('/') || link.startsWith('//'))) throw new ServiceError('link must be a path on this service.');
|
|
290
|
+
const event = { id: String(id), at, kind, level, text: sentence.slice(0, 500) };
|
|
291
|
+
if (subject) event.subject = subject;
|
|
292
|
+
if (link) event.link = link;
|
|
293
|
+
if (notify) event.notify = true;
|
|
294
|
+
return event;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function parseLimit(raw) {
|
|
298
|
+
if (raw === undefined || raw === null || raw === '') return EVENTS_DEFAULT_LIMIT;
|
|
299
|
+
const n = Number(raw);
|
|
300
|
+
if (!Number.isInteger(n)) throw new ServiceError('limit must be an integer.');
|
|
301
|
+
return Math.max(1, Math.min(EVENTS_MAX_LIMIT, n));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// fetch(after, limit) returns events with ids greater than `after`, ascending
|
|
305
|
+
// (or the newest `limit`, ascending, when after is null) — a view over the log you already keep.
|
|
306
|
+
export async function eventsPage(fetch, after, limit) {
|
|
307
|
+
const events = (await fetch(after ?? null, limit)).slice(0, limit);
|
|
308
|
+
return { events, cursor: events.length ? events.at(-1).id : (after ?? null) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- operator login -------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
export class LoginCodes {
|
|
314
|
+
constructor(stateDir, ttlSeconds = 120) {
|
|
315
|
+
this.dir = ensurePrivateDir(stateDir);
|
|
316
|
+
this.ttl = Math.min(ttlSeconds, 120);
|
|
317
|
+
this.keyPath = path.join(this.dir, 'session.key');
|
|
318
|
+
this.codesPath = path.join(this.dir, 'login-codes.json');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
key() {
|
|
322
|
+
if (!fs.existsSync(this.keyPath)) atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
323
|
+
return fs.readFileSync(this.keyPath);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
load() {
|
|
327
|
+
try { return JSON.parse(fs.readFileSync(this.codesPath, 'utf8')); } catch { return {}; }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
save(codes) {
|
|
331
|
+
const horizon = Date.now() / 1000 - 3600;
|
|
332
|
+
const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
|
|
333
|
+
atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
issue() {
|
|
337
|
+
const code = crypto.randomBytes(24).toString('base64url');
|
|
338
|
+
const expires = Date.now() / 1000 + this.ttl;
|
|
339
|
+
const codes = this.load();
|
|
340
|
+
codes[crypto.createHash('sha256').update(code).digest('hex')] = { expires, used: false };
|
|
341
|
+
this.save(codes);
|
|
342
|
+
return { url: `/fabric/v1/login?code=${code}`, expiresAt: new Date(expires * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z') };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The code is recorded as used BEFORE it is honoured, and burned even when expired.
|
|
346
|
+
redeem(code) {
|
|
347
|
+
if (!code || !CODE.test(code)) return null;
|
|
348
|
+
const digest = crypto.createHash('sha256').update(code).digest('hex');
|
|
349
|
+
const codes = this.load();
|
|
350
|
+
const entry = codes[digest];
|
|
351
|
+
if (!entry || entry.used) return null;
|
|
352
|
+
entry.used = true;
|
|
353
|
+
this.save(codes);
|
|
354
|
+
if (entry.expires < Date.now() / 1000) return null;
|
|
355
|
+
const session = crypto.randomBytes(18).toString('base64url');
|
|
356
|
+
return `${session}.${crypto.createHmac('sha256', this.key()).update(session).digest('hex')}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
sessionValid(value) {
|
|
360
|
+
if (!value || !value.includes('.')) return false;
|
|
361
|
+
const i = value.lastIndexOf('.');
|
|
362
|
+
const expected = crypto.createHmac('sha256', this.key()).update(value.slice(0, i)).digest('hex');
|
|
363
|
+
const mac = value.slice(i + 1);
|
|
364
|
+
return mac.length === expected.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
revokeAll() {
|
|
368
|
+
atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export const sessionCookieHeader = (value, maxAge = 30 * 86400) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
373
|
+
|
|
374
|
+
export function cookieValue(header, name = SESSION_COOKIE) {
|
|
375
|
+
for (const part of String(header ?? '').split(';')) {
|
|
376
|
+
const [k, ...rest] = part.trim().split('=');
|
|
377
|
+
if (k === name) return rest.join('=');
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|