nofax 0.2.0 → 0.2.1
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 -21
- package/README.md +349 -351
- package/SECURITY.md +144 -144
- package/bin/nofax.mjs +4 -4
- package/package.json +56 -56
- package/src/adapters/claude.mjs +47 -47
- package/src/adapters/codex.mjs +47 -47
- package/src/adapters/gemini.mjs +57 -57
- package/src/cli.mjs +191 -191
- package/src/config.mjs +91 -91
- package/src/index.mjs +8 -8
- package/src/mcp-server.mjs +141 -141
- package/src/mcp-tools.mjs +240 -240
- package/src/ntfy.mjs +259 -259
- package/src/protocol.mjs +108 -108
- package/src/requests.mjs +120 -120
package/src/protocol.mjs
CHANGED
|
@@ -1,108 +1,108 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
const SECRET_KEY = /(?:^|[_-])(token|password|passwd|secret|api[_-]?key|authorization|cookie|credential|private[_-]?key)(?:$|[_-])/i;
|
|
4
|
-
const MAX_STRING = 500;
|
|
5
|
-
const MAX_DEPTH = 5;
|
|
6
|
-
const MAX_ARRAY = 20;
|
|
7
|
-
const MAX_OBJECT_KEYS = 30;
|
|
8
|
-
const MAX_SUMMARY = 2200;
|
|
9
|
-
const MAX_RESPONSE_TEXT = 2000;
|
|
10
|
-
|
|
11
|
-
function randomBase64Url(bytes) {
|
|
12
|
-
return randomBytes(bytes).toString('base64url');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function createRequestId() {
|
|
16
|
-
return `nfx_${randomBase64Url(18)}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function createResponseTopic() {
|
|
20
|
-
return `nofax_r_${randomBase64Url(24)}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function createPhoneTopic() {
|
|
24
|
-
return `nofax_${randomBase64Url(24)}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function boundString(value) {
|
|
28
|
-
if (value.length <= MAX_STRING) return value;
|
|
29
|
-
return `${value.slice(0, MAX_STRING)}…[truncated]`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function redactAndBound(value, options = {}) {
|
|
33
|
-
const seen = options.seen ?? new WeakSet();
|
|
34
|
-
const depth = options.depth ?? 0;
|
|
35
|
-
|
|
36
|
-
if (value === null || value === undefined) return value;
|
|
37
|
-
if (typeof value === 'string') return boundString(value);
|
|
38
|
-
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
39
|
-
if (typeof value === 'bigint') return value.toString();
|
|
40
|
-
if (typeof value === 'function' || typeof value === 'symbol') return `[${typeof value}]`;
|
|
41
|
-
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
|
|
42
|
-
if (typeof value !== 'object') return boundString(String(value));
|
|
43
|
-
|
|
44
|
-
if (seen.has(value)) return '[CIRCULAR]';
|
|
45
|
-
seen.add(value);
|
|
46
|
-
|
|
47
|
-
if (Array.isArray(value)) {
|
|
48
|
-
return value.slice(0, MAX_ARRAY).map((item) => redactAndBound(item, { seen, depth: depth + 1 }));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const out = {};
|
|
52
|
-
for (const [key, child] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
53
|
-
out[key] = SECRET_KEY.test(key)
|
|
54
|
-
? '[REDACTED]'
|
|
55
|
-
: redactAndBound(child, { seen, depth: depth + 1 });
|
|
56
|
-
}
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function serializeBounded(value) {
|
|
61
|
-
let serialized;
|
|
62
|
-
try {
|
|
63
|
-
serialized = JSON.stringify(redactAndBound(value), null, 2);
|
|
64
|
-
} catch {
|
|
65
|
-
serialized = '[unserializable]';
|
|
66
|
-
}
|
|
67
|
-
if (serialized.length <= 1400) return serialized;
|
|
68
|
-
return `${serialized.slice(0, 1400)}\n…[truncated]`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function buildAgentSummary({ source, toolName, cwd, toolInput, message }) {
|
|
72
|
-
const lines = [];
|
|
73
|
-
if (source) lines.push(`Source: ${boundString(String(source))}`);
|
|
74
|
-
if (toolName) lines.push(`Tool: ${boundString(String(toolName))}`);
|
|
75
|
-
if (cwd) lines.push(`Working directory: ${boundString(String(cwd))}`);
|
|
76
|
-
if (message) lines.push('', boundString(String(message)));
|
|
77
|
-
if (toolInput !== undefined) lines.push('', 'Request:', serializeBounded(toolInput));
|
|
78
|
-
const result = lines.join('\n').trim();
|
|
79
|
-
if (result.length <= MAX_SUMMARY) return result;
|
|
80
|
-
return `${result.slice(0, MAX_SUMMARY - 14)}\n…[truncated]`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function parseResponseMessage(message, { requestId, allowed }) {
|
|
84
|
-
if (typeof message !== 'string') return null;
|
|
85
|
-
let parsed;
|
|
86
|
-
try {
|
|
87
|
-
parsed = JSON.parse(message);
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
if (!parsed || parsed.v !== 1 || parsed.requestId !== requestId || typeof parsed.decision !== 'string') {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
if (!allowed.includes(parsed.decision)) return null;
|
|
95
|
-
|
|
96
|
-
if (parsed.decision === 'refine') {
|
|
97
|
-
if (typeof parsed.text !== 'string') return null;
|
|
98
|
-
const text = parsed.text.trim();
|
|
99
|
-
if (!text) return null;
|
|
100
|
-
return { decision: 'refine', text: boundString(text).slice(0, MAX_RESPONSE_TEXT) };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return { decision: parsed.decision };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function parseDecisionMessage(message, { requestId, allowed }) {
|
|
107
|
-
return parseResponseMessage(message, { requestId, allowed })?.decision ?? null;
|
|
108
|
-
}
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const SECRET_KEY = /(?:^|[_-])(token|password|passwd|secret|api[_-]?key|authorization|cookie|credential|private[_-]?key)(?:$|[_-])/i;
|
|
4
|
+
const MAX_STRING = 500;
|
|
5
|
+
const MAX_DEPTH = 5;
|
|
6
|
+
const MAX_ARRAY = 20;
|
|
7
|
+
const MAX_OBJECT_KEYS = 30;
|
|
8
|
+
const MAX_SUMMARY = 2200;
|
|
9
|
+
const MAX_RESPONSE_TEXT = 2000;
|
|
10
|
+
|
|
11
|
+
function randomBase64Url(bytes) {
|
|
12
|
+
return randomBytes(bytes).toString('base64url');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createRequestId() {
|
|
16
|
+
return `nfx_${randomBase64Url(18)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createResponseTopic() {
|
|
20
|
+
return `nofax_r_${randomBase64Url(24)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createPhoneTopic() {
|
|
24
|
+
return `nofax_${randomBase64Url(24)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundString(value) {
|
|
28
|
+
if (value.length <= MAX_STRING) return value;
|
|
29
|
+
return `${value.slice(0, MAX_STRING)}…[truncated]`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function redactAndBound(value, options = {}) {
|
|
33
|
+
const seen = options.seen ?? new WeakSet();
|
|
34
|
+
const depth = options.depth ?? 0;
|
|
35
|
+
|
|
36
|
+
if (value === null || value === undefined) return value;
|
|
37
|
+
if (typeof value === 'string') return boundString(value);
|
|
38
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
39
|
+
if (typeof value === 'bigint') return value.toString();
|
|
40
|
+
if (typeof value === 'function' || typeof value === 'symbol') return `[${typeof value}]`;
|
|
41
|
+
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
|
|
42
|
+
if (typeof value !== 'object') return boundString(String(value));
|
|
43
|
+
|
|
44
|
+
if (seen.has(value)) return '[CIRCULAR]';
|
|
45
|
+
seen.add(value);
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.slice(0, MAX_ARRAY).map((item) => redactAndBound(item, { seen, depth: depth + 1 }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [key, child] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
53
|
+
out[key] = SECRET_KEY.test(key)
|
|
54
|
+
? '[REDACTED]'
|
|
55
|
+
: redactAndBound(child, { seen, depth: depth + 1 });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function serializeBounded(value) {
|
|
61
|
+
let serialized;
|
|
62
|
+
try {
|
|
63
|
+
serialized = JSON.stringify(redactAndBound(value), null, 2);
|
|
64
|
+
} catch {
|
|
65
|
+
serialized = '[unserializable]';
|
|
66
|
+
}
|
|
67
|
+
if (serialized.length <= 1400) return serialized;
|
|
68
|
+
return `${serialized.slice(0, 1400)}\n…[truncated]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildAgentSummary({ source, toolName, cwd, toolInput, message }) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
if (source) lines.push(`Source: ${boundString(String(source))}`);
|
|
74
|
+
if (toolName) lines.push(`Tool: ${boundString(String(toolName))}`);
|
|
75
|
+
if (cwd) lines.push(`Working directory: ${boundString(String(cwd))}`);
|
|
76
|
+
if (message) lines.push('', boundString(String(message)));
|
|
77
|
+
if (toolInput !== undefined) lines.push('', 'Request:', serializeBounded(toolInput));
|
|
78
|
+
const result = lines.join('\n').trim();
|
|
79
|
+
if (result.length <= MAX_SUMMARY) return result;
|
|
80
|
+
return `${result.slice(0, MAX_SUMMARY - 14)}\n…[truncated]`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseResponseMessage(message, { requestId, allowed }) {
|
|
84
|
+
if (typeof message !== 'string') return null;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(message);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (!parsed || parsed.v !== 1 || parsed.requestId !== requestId || typeof parsed.decision !== 'string') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!allowed.includes(parsed.decision)) return null;
|
|
95
|
+
|
|
96
|
+
if (parsed.decision === 'refine') {
|
|
97
|
+
if (typeof parsed.text !== 'string') return null;
|
|
98
|
+
const text = parsed.text.trim();
|
|
99
|
+
if (!text) return null;
|
|
100
|
+
return { decision: 'refine', text: boundString(text).slice(0, MAX_RESPONSE_TEXT) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { decision: parsed.decision };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseDecisionMessage(message, { requestId, allowed }) {
|
|
107
|
+
return parseResponseMessage(message, { requestId, allowed })?.decision ?? null;
|
|
108
|
+
}
|
package/src/requests.mjs
CHANGED
|
@@ -1,120 +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
|
-
}
|
|
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
|
+
}
|