nofax 0.2.0-oidc-test.0
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/LICENSE +21 -0
- package/README.md +351 -0
- package/SECURITY.md +144 -0
- package/bin/nofax.mjs +4 -0
- package/package.json +56 -0
- package/src/adapters/claude.mjs +47 -0
- package/src/adapters/codex.mjs +47 -0
- package/src/adapters/gemini.mjs +57 -0
- package/src/cli.mjs +191 -0
- package/src/config.mjs +91 -0
- package/src/index.mjs +8 -0
- package/src/mcp-server.mjs +141 -0
- package/src/mcp-tools.mjs +240 -0
- package/src/ntfy.mjs +259 -0
- package/src/protocol.mjs +108 -0
- package/src/requests.mjs +120 -0
package/src/requests.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { resolveNofaxHome } from './config.mjs';
|
|
4
|
+
|
|
5
|
+
const REQUEST_ID = /^nfx_[A-Za-z0-9_-]{20,80}$/;
|
|
6
|
+
const RESPONSE_TOPIC = /^nofax_r_[A-Za-z0-9_-]{20,120}$/;
|
|
7
|
+
const KINDS = new Set(['approval', 'choice', 'refinement']);
|
|
8
|
+
const STATUSES = new Set(['pending', 'resolved']);
|
|
9
|
+
|
|
10
|
+
function validateRequestId(value) {
|
|
11
|
+
if (typeof value !== 'string' || !REQUEST_ID.test(value)) throw new Error('NOFAX_REQUEST_ID_INVALID');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateRequest(input) {
|
|
16
|
+
if (!input || input.version !== 1) throw new Error('NOFAX_REQUEST_VERSION');
|
|
17
|
+
const requestId = validateRequestId(input.requestId);
|
|
18
|
+
if (!KINDS.has(input.kind)) throw new Error('NOFAX_REQUEST_KIND_INVALID');
|
|
19
|
+
if (typeof input.responseTopic !== 'string' || !RESPONSE_TOPIC.test(input.responseTopic)) throw new Error('NOFAX_RESPONSE_TOPIC_INVALID');
|
|
20
|
+
if (!Array.isArray(input.allowed) || input.allowed.length < 1 || input.allowed.length > 3 || input.allowed.some((value) => typeof value !== 'string' || !value || value.length > 80)) {
|
|
21
|
+
throw new Error('NOFAX_REQUEST_ALLOWED_INVALID');
|
|
22
|
+
}
|
|
23
|
+
if (!STATUSES.has(input.status)) throw new Error('NOFAX_REQUEST_STATUS_INVALID');
|
|
24
|
+
if (typeof input.createdAt !== 'string' || Number.isNaN(Date.parse(input.createdAt))) throw new Error('NOFAX_REQUEST_CREATED_AT_INVALID');
|
|
25
|
+
const base = {
|
|
26
|
+
version: 1,
|
|
27
|
+
requestId,
|
|
28
|
+
kind: input.kind,
|
|
29
|
+
responseTopic: input.responseTopic,
|
|
30
|
+
allowed: [...input.allowed],
|
|
31
|
+
status: input.status,
|
|
32
|
+
createdAt: input.createdAt
|
|
33
|
+
};
|
|
34
|
+
if (input.status === 'resolved') {
|
|
35
|
+
if (typeof input.resolvedAt !== 'string' || Number.isNaN(Date.parse(input.resolvedAt))) throw new Error('NOFAX_REQUEST_RESOLVED_AT_INVALID');
|
|
36
|
+
if (typeof input.decision !== 'string' || !input.allowed.includes(input.decision)) throw new Error('NOFAX_REQUEST_DECISION_INVALID');
|
|
37
|
+
base.resolvedAt = input.resolvedAt;
|
|
38
|
+
base.decision = input.decision;
|
|
39
|
+
if (input.text !== undefined) {
|
|
40
|
+
if (typeof input.text !== 'string' || !input.text.trim()) throw new Error('NOFAX_REQUEST_TEXT_INVALID');
|
|
41
|
+
base.text = input.text.trim().slice(0, 2000);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return base;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function paths({ home, env, requestId }) {
|
|
48
|
+
const root = resolveNofaxHome({ home, env });
|
|
49
|
+
const requestsRoot = join(root, 'requests');
|
|
50
|
+
return { requestsRoot, file: join(requestsRoot, `${validateRequestId(requestId)}.json`) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function atomicWrite(path, value) {
|
|
54
|
+
const temp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
55
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
56
|
+
try { await chmod(temp, 0o600); } catch {}
|
|
57
|
+
await rename(temp, path);
|
|
58
|
+
try { await chmod(path, 0o600); } catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function savePendingRequest({ home, env, request }) {
|
|
62
|
+
const normalized = validateRequest(request);
|
|
63
|
+
if (normalized.status !== 'pending') throw new Error('NOFAX_REQUEST_NOT_PENDING');
|
|
64
|
+
const { requestsRoot, file } = paths({ home, env, requestId: normalized.requestId });
|
|
65
|
+
await mkdir(requestsRoot, { recursive: true, mode: 0o700 });
|
|
66
|
+
await atomicWrite(file, normalized);
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadRequest({ home, env, requestId }) {
|
|
71
|
+
const { file } = paths({ home, env, requestId });
|
|
72
|
+
try {
|
|
73
|
+
return validateRequest(JSON.parse(await readFile(file, 'utf8')));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code === 'ENOENT') throw new Error('NOFAX_REQUEST_NOT_FOUND');
|
|
76
|
+
if (error instanceof SyntaxError) throw new Error('NOFAX_REQUEST_INVALID_JSON');
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function resolveRequest({ home, env, requestId, response, resolvedAt = new Date().toISOString() }) {
|
|
82
|
+
const current = await loadRequest({ home, env, requestId });
|
|
83
|
+
if (current.status === 'resolved') return current;
|
|
84
|
+
if (!response || typeof response.decision !== 'string' || !current.allowed.includes(response.decision)) throw new Error('NOFAX_REQUEST_DECISION_INVALID');
|
|
85
|
+
const resolved = validateRequest({
|
|
86
|
+
...current,
|
|
87
|
+
status: 'resolved',
|
|
88
|
+
resolvedAt,
|
|
89
|
+
decision: response.decision,
|
|
90
|
+
...(response.text === undefined ? {} : { text: response.text })
|
|
91
|
+
});
|
|
92
|
+
const { file } = paths({ home, env, requestId });
|
|
93
|
+
await atomicWrite(file, resolved);
|
|
94
|
+
return resolved;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function listPendingRequests({ home, env, limit = 20 } = {}) {
|
|
98
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('NOFAX_REQUEST_LIMIT_INVALID');
|
|
99
|
+
const root = resolveNofaxHome({ home, env });
|
|
100
|
+
const requestsRoot = join(root, 'requests');
|
|
101
|
+
let names;
|
|
102
|
+
try {
|
|
103
|
+
names = await readdir(requestsRoot);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error?.code === 'ENOENT') return [];
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
const results = [];
|
|
109
|
+
for (const name of names.sort().reverse()) {
|
|
110
|
+
if (!name.endsWith('.json')) continue;
|
|
111
|
+
const requestId = name.slice(0, -5);
|
|
112
|
+
if (!REQUEST_ID.test(requestId)) continue;
|
|
113
|
+
try {
|
|
114
|
+
const request = await loadRequest({ home: root, requestId });
|
|
115
|
+
if (request.status === 'pending') results.push(request);
|
|
116
|
+
} catch {}
|
|
117
|
+
if (results.length >= limit) break;
|
|
118
|
+
}
|
|
119
|
+
return results;
|
|
120
|
+
}
|