@aria_asi/cli 0.2.30 → 0.2.32
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/dist/aria-connector/src/connectors/claude-code.d.ts.map +1 -1
- package/dist/aria-connector/src/connectors/claude-code.js +115 -20
- package/dist/aria-connector/src/connectors/claude-code.js.map +1 -1
- package/dist/aria-connector/src/connectors/codex.d.ts.map +1 -1
- package/dist/aria-connector/src/connectors/codex.js +551 -11
- package/dist/aria-connector/src/connectors/codex.js.map +1 -1
- package/dist/aria-connector/src/connectors/doctrine-trigger-map.d.ts +7 -0
- package/dist/aria-connector/src/connectors/doctrine-trigger-map.d.ts.map +1 -0
- package/dist/aria-connector/src/connectors/doctrine-trigger-map.js +87 -0
- package/dist/aria-connector/src/connectors/doctrine-trigger-map.js.map +1 -0
- package/dist/aria-connector/src/connectors/must-read.d.ts +4 -0
- package/dist/aria-connector/src/connectors/must-read.d.ts.map +1 -0
- package/dist/aria-connector/src/connectors/must-read.js +115 -0
- package/dist/aria-connector/src/connectors/must-read.js.map +1 -0
- package/dist/aria-connector/src/connectors/opencode.d.ts.map +1 -1
- package/dist/aria-connector/src/connectors/opencode.js +27 -9
- package/dist/aria-connector/src/connectors/opencode.js.map +1 -1
- package/dist/aria-connector/src/connectors/runtime.d.ts.map +1 -1
- package/dist/aria-connector/src/connectors/runtime.js +231 -19
- package/dist/aria-connector/src/connectors/runtime.js.map +1 -1
- package/dist/aria-connector/src/connectors/shell.d.ts.map +1 -1
- package/dist/aria-connector/src/connectors/shell.js +76 -3
- package/dist/aria-connector/src/connectors/shell.js.map +1 -1
- package/dist/assets/hooks/aria-agent-handoff.mjs +23 -0
- package/dist/assets/hooks/aria-cognition-substrate-binding.mjs +121 -28
- package/dist/assets/hooks/aria-harness-via-sdk.mjs +126 -12
- package/dist/assets/hooks/aria-pre-emit-dryrun.mjs +35 -0
- package/dist/assets/hooks/aria-pre-tool-gate.mjs +383 -93
- package/dist/assets/hooks/aria-preprompt-consult.mjs +28 -2
- package/dist/assets/hooks/aria-preturn-memory-gate.mjs +93 -16
- package/dist/assets/hooks/aria-repo-doctrine-gate.mjs +33 -1
- package/dist/assets/hooks/aria-stop-gate.mjs +346 -81
- package/dist/assets/hooks/doctrine_trigger_map.json +55 -0
- package/dist/assets/hooks/lib/canonical-lenses.mjs +6 -5
- package/dist/assets/hooks/lib/gate-loop-state.mjs +50 -0
- package/dist/assets/hooks/lib/hook-message-window.mjs +121 -0
- package/dist/assets/hooks/test-tier-lens-labeling.mjs +26 -58
- package/dist/assets/opencode-plugins/harness-gate/index.js +40 -5
- package/dist/assets/opencode-plugins/harness-stop/index.js +133 -10
- package/dist/runtime/auth-middleware.mjs +251 -0
- package/dist/runtime/codex-bridge.mjs +644 -0
- package/dist/runtime/discipline/CLAUDE.md +28 -0
- package/dist/runtime/discipline/doctrine_trigger_map.json +534 -0
- package/dist/runtime/doctrine_trigger_map.json +534 -0
- package/dist/runtime/fleet-engine.mjs +231 -0
- package/dist/runtime/harness-daemon.mjs +460 -0
- package/dist/runtime/manifest.json +1 -1
- package/dist/runtime/metering.mjs +100 -0
- package/dist/runtime/onboarding-engine.mjs +89 -0
- package/dist/runtime/plugin-engine.mjs +196 -0
- package/dist/runtime/sdk/BUNDLED.json +1 -1
- package/dist/runtime/sdk/index.d.ts +12 -0
- package/dist/runtime/sdk/index.js +120 -14
- package/dist/runtime/sdk/index.js.map +1 -1
- package/dist/runtime/service.mjs +1140 -48
- package/dist/runtime/workflow-engine.mjs +322 -0
- package/dist/sdk/BUNDLED.json +1 -1
- package/dist/sdk/index.d.ts +12 -0
- package/dist/sdk/index.js +120 -14
- package/dist/sdk/index.js.map +1 -1
- package/hooks/aria-agent-handoff.mjs +23 -0
- package/hooks/aria-cognition-substrate-binding.mjs +121 -28
- package/hooks/aria-harness-via-sdk.mjs +126 -12
- package/hooks/aria-pre-emit-dryrun.mjs +35 -0
- package/hooks/aria-pre-tool-gate.mjs +383 -93
- package/hooks/aria-preprompt-consult.mjs +28 -2
- package/hooks/aria-preturn-memory-gate.mjs +93 -16
- package/hooks/aria-repo-doctrine-gate.mjs +33 -1
- package/hooks/aria-stop-gate.mjs +346 -81
- package/hooks/doctrine_trigger_map.json +55 -0
- package/hooks/lib/canonical-lenses.mjs +6 -5
- package/hooks/lib/gate-loop-state.mjs +50 -0
- package/hooks/lib/hook-message-window.mjs +121 -0
- package/hooks/test-tier-lens-labeling.mjs +26 -58
- package/opencode-plugins/harness-gate/index.js +40 -5
- package/opencode-plugins/harness-stop/index.js +133 -10
- package/package.json +1 -1
- package/runtime-src/auth-middleware.mjs +251 -0
- package/runtime-src/codex-bridge.mjs +644 -0
- package/runtime-src/fleet-engine.mjs +231 -0
- package/runtime-src/harness-daemon.mjs +460 -0
- package/runtime-src/metering.mjs +100 -0
- package/runtime-src/onboarding-engine.mjs +89 -0
- package/runtime-src/plugin-engine.mjs +196 -0
- package/runtime-src/service.mjs +1140 -48
- package/runtime-src/workflow-engine.mjs +322 -0
- package/scripts/bundle-sdk.mjs +5 -0
- package/src/connectors/claude-code.ts +126 -20
- package/src/connectors/codex.ts +559 -10
- package/src/connectors/doctrine-trigger-map.ts +112 -0
- package/src/connectors/must-read.ts +117 -0
- package/src/connectors/opencode.ts +28 -9
- package/src/connectors/runtime.ts +241 -21
- package/src/connectors/shell.ts +78 -3
- package/dist/cli-0.2.0.tgz +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Aria Harness Stop — text-emission gate via HTTPHarnessClient SDK.
|
|
3
3
|
* Routes text through Mizan validateOutput() for substrate-backed QC.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
|
|
@@ -15,6 +15,7 @@ const SDK_CANDIDATES = [
|
|
|
15
15
|
const OWNER_TOKEN_PATH = join(HOME, '.aria', 'owner-token');
|
|
16
16
|
const LICENSE_PATH = join(HOME, '.aria', 'license.json');
|
|
17
17
|
const RUNTIME_URL = (process.env.ARIA_RUNTIME_URL || 'http://127.0.0.1:4319').replace(/\/+$/, '');
|
|
18
|
+
const RECEIPT_DIR = join(HOME, '.opencode', 'aria-mizan-receipts');
|
|
18
19
|
|
|
19
20
|
const LENS_NAMES = [
|
|
20
21
|
'nur', 'mizan', 'hikma', 'tafakkur', 'tadabbur', 'ilham', 'wahi', 'firasah',
|
|
@@ -28,11 +29,48 @@ const NON_TRIVIAL_MIN_CHARS = 300;
|
|
|
28
29
|
const DECISION_SIGNAL_RX = /(?:should|recommend|propose|suggest|let'?s|go with|i'd|i would|here'?s the plan|i'll|next step|action item|ship it|yes do|let me)/i;
|
|
29
30
|
const TRIVIAL_ACK_RX = /^(?:got it|on it|ok|sure|yes|no|done|ack)\b/i;
|
|
30
31
|
const PLACEHOLDER_RX = /^\s*<[^<>]+>\s*$/;
|
|
32
|
+
const BLOCK_PREFIX_RX = /^=== ARIA (?:MIZAN POST|OUTPUT GATE|LOCAL OUTPUT) BLOCK ===/;
|
|
33
|
+
const APPLIED_COGNITION_RX = /<applied_cognition>[\s\S]*?decision_delta\s*:[\s\S]*?dominant_domain\s*:[\s\S]*?binds_to\s*:[\s\S]*?expected_predicate\s*:[\s\S]*?artifact_change\s*:[\s\S]*?<\/applied_cognition>/i;
|
|
34
|
+
|
|
35
|
+
function formatBlockReason(prefix, details) {
|
|
36
|
+
return [
|
|
37
|
+
prefix,
|
|
38
|
+
'',
|
|
39
|
+
String(details || 'Aria output gate blocked this message.'),
|
|
40
|
+
'',
|
|
41
|
+
'Required repair: bind cognition to the actual action/output using <applied_cognition> with decision_delta, dominant_domain, binds_to, expected_predicate, and artifact_change.',
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isGateBlock(error) {
|
|
46
|
+
return BLOCK_PREFIX_RX.test(String(error?.message || error || ''));
|
|
47
|
+
}
|
|
31
48
|
|
|
32
49
|
let _client = null;
|
|
33
50
|
let _clientError = null;
|
|
34
51
|
let _lastMizanReceipt = null;
|
|
35
52
|
|
|
53
|
+
function receiptPath(sessionId) {
|
|
54
|
+
return join(RECEIPT_DIR, `${String(sessionId || 'opencode').replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadReceiptState(sessionId) {
|
|
58
|
+
try {
|
|
59
|
+
const target = receiptPath(sessionId);
|
|
60
|
+
if (!existsSync(target)) return null;
|
|
61
|
+
return JSON.parse(readFileSync(target, 'utf8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function persistReceiptState(sessionId, payload) {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(RECEIPT_DIR, { recursive: true, mode: 0o755 });
|
|
70
|
+
writeFileSync(receiptPath(sessionId), JSON.stringify(payload, null, 2) + '\n');
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
function resolveToken() {
|
|
37
75
|
if (process.env.ARIA_HARNESS_TOKEN) return process.env.ARIA_HARNESS_TOKEN;
|
|
38
76
|
if (process.env.ARIA_API_KEY) return process.env.ARIA_API_KEY;
|
|
@@ -100,6 +138,7 @@ async function runtimeValidateOutput(text, sessionId) {
|
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
async function runtimeMizanPost(text, sessionId, context = {}) {
|
|
141
|
+
const existing = loadReceiptState(sessionId);
|
|
103
142
|
const token = resolveToken();
|
|
104
143
|
if (!token) throw new Error('no token');
|
|
105
144
|
const response = await fetch(`${RUNTIME_URL}/mizan/post`, {
|
|
@@ -115,7 +154,7 @@ async function runtimeMizanPost(text, sessionId, context = {}) {
|
|
|
115
154
|
sessionId,
|
|
116
155
|
...context,
|
|
117
156
|
},
|
|
118
|
-
parentReceiptId: _lastMizanReceipt?.receiptId || null,
|
|
157
|
+
parentReceiptId: existing?.receipt?.receiptId || _lastMizanReceipt?.receiptId || null,
|
|
119
158
|
}),
|
|
120
159
|
});
|
|
121
160
|
if (!response.ok) {
|
|
@@ -125,6 +164,24 @@ async function runtimeMizanPost(text, sessionId, context = {}) {
|
|
|
125
164
|
return response.json();
|
|
126
165
|
}
|
|
127
166
|
|
|
167
|
+
async function runtimeDecisionLog(payload) {
|
|
168
|
+
const token = resolveToken();
|
|
169
|
+
if (!token) throw new Error('no token');
|
|
170
|
+
const response = await fetch(`${RUNTIME_URL}/decision/log`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
Authorization: `Bearer ${token}`,
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(payload),
|
|
177
|
+
});
|
|
178
|
+
const body = await response.json().catch(() => ({}));
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
throw new Error(body?.error || `runtime decision/log failed ${response.status}`);
|
|
181
|
+
}
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
|
|
128
185
|
function detectCognitionLenses(text) {
|
|
129
186
|
if (!text) return { count: 0, names: [] };
|
|
130
187
|
const block = text.match(COGNITION_BLOCK_RX);
|
|
@@ -172,12 +229,26 @@ export default async function HarnessStopPlugin(ctx) {
|
|
|
172
229
|
plannedApproach: 'OpenCode stop gate output review',
|
|
173
230
|
});
|
|
174
231
|
_lastMizanReceipt = mizan.receipt || _lastMizanReceipt;
|
|
232
|
+
if (_lastMizanReceipt) {
|
|
233
|
+
const existing = loadReceiptState(sessionId) || {};
|
|
234
|
+
persistReceiptState(sessionId, {
|
|
235
|
+
...existing,
|
|
236
|
+
updatedAt: new Date().toISOString(),
|
|
237
|
+
sessionId,
|
|
238
|
+
postReceipt: _lastMizanReceipt,
|
|
239
|
+
postResult: mizan.result || null,
|
|
240
|
+
postContract: mizan.contract || null,
|
|
241
|
+
postSummary: mizan.summary || null,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
175
244
|
if (mizan.receipt?.blocked || mizan.result?.fitrahVetoed || mizan.result?.reAuthorSignal) {
|
|
176
|
-
|
|
177
|
-
|
|
245
|
+
const details = (mizan.result?.notes || ['post-phase blocked']).slice(0, 4).join(' | ');
|
|
246
|
+
throw new Error(
|
|
247
|
+
formatBlockReason('=== ARIA MIZAN POST BLOCK ===', details)
|
|
178
248
|
);
|
|
179
249
|
}
|
|
180
250
|
} catch (e) {
|
|
251
|
+
if (isGateBlock(e)) throw e;
|
|
181
252
|
process.stderr.write(`[harness-stop] mizan/post unavailable: ${e.message}\n`);
|
|
182
253
|
}
|
|
183
254
|
|
|
@@ -193,8 +264,11 @@ export default async function HarnessStopPlugin(ctx) {
|
|
|
193
264
|
sessionId,
|
|
194
265
|
));
|
|
195
266
|
if (result.severity === 'block') {
|
|
196
|
-
|
|
197
|
-
|
|
267
|
+
throw new Error(
|
|
268
|
+
formatBlockReason(
|
|
269
|
+
'=== ARIA OUTPUT GATE BLOCK ===',
|
|
270
|
+
`${result.violations.length} violations: ${result.violations.join('; ').slice(0, 500)}`,
|
|
271
|
+
)
|
|
198
272
|
);
|
|
199
273
|
} else if (result.severity === 'warn') {
|
|
200
274
|
process.stderr.write(
|
|
@@ -207,16 +281,25 @@ export default async function HarnessStopPlugin(ctx) {
|
|
|
207
281
|
}
|
|
208
282
|
return;
|
|
209
283
|
} catch (e) {
|
|
284
|
+
if (isGateBlock(e)) throw e;
|
|
210
285
|
process.stderr.write(`[harness-stop] SDK validateOutput failed: ${e.message} — falling through to local gate\n`);
|
|
211
286
|
}
|
|
212
287
|
}
|
|
213
288
|
|
|
214
289
|
// Local fallback gate
|
|
215
290
|
// Scan drift triggers
|
|
216
|
-
const
|
|
291
|
+
const triggerMapPaths = [
|
|
292
|
+
`${HOME}/.aria/runtime/discipline/doctrine_trigger_map.json`,
|
|
293
|
+
`${HOME}/.aria/runtime/doctrine_trigger_map.json`,
|
|
294
|
+
`${HOME}/.opencode/doctrine_trigger_map.json`,
|
|
295
|
+
`${HOME}/.codex/doctrine_trigger_map.json`,
|
|
296
|
+
`${HOME}/.claude/hooks/doctrine_trigger_map.json`,
|
|
297
|
+
`${HOME}/.claude/projects/-home-hamzaibrahim1/memory/doctrine_trigger_map.json`,
|
|
298
|
+
];
|
|
217
299
|
let driftHits = [];
|
|
218
300
|
try {
|
|
219
|
-
|
|
301
|
+
const triggerMapPath = triggerMapPaths.find((candidate) => existsSync(candidate));
|
|
302
|
+
if (triggerMapPath) {
|
|
220
303
|
const triggerMap = JSON.parse(readFileSync(triggerMapPath, 'utf8'));
|
|
221
304
|
const lower = text.toLowerCase();
|
|
222
305
|
for (const t of triggerMap.triggers || []) {
|
|
@@ -232,10 +315,50 @@ export default async function HarnessStopPlugin(ctx) {
|
|
|
232
315
|
} catch {}
|
|
233
316
|
|
|
234
317
|
if (cog.count < REQUIRED_LENSES || driftHits.length >= 2) {
|
|
235
|
-
|
|
236
|
-
|
|
318
|
+
throw new Error(
|
|
319
|
+
formatBlockReason(
|
|
320
|
+
'=== ARIA LOCAL OUTPUT BLOCK ===',
|
|
321
|
+
`cognition=${cog.count}/${REQUIRED_LENSES}; drift=${driftHits.length}`,
|
|
322
|
+
)
|
|
237
323
|
);
|
|
238
324
|
}
|
|
325
|
+
|
|
326
|
+
if (DECISION_SIGNAL_RX.test(text) && !APPLIED_COGNITION_RX.test(text)) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
formatBlockReason(
|
|
329
|
+
'=== ARIA LOCAL OUTPUT BLOCK ===',
|
|
330
|
+
'decision-bearing output lacks required applied_cognition binding fields',
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const existing = loadReceiptState(sessionId);
|
|
337
|
+
await runtimeDecisionLog({
|
|
338
|
+
decision_type: 'turn_action',
|
|
339
|
+
category: 'agentic_execution',
|
|
340
|
+
context: `opencode stop-gate turn (chars=${text.length})`,
|
|
341
|
+
decision: 'turn completed',
|
|
342
|
+
reasoning: cog.count > 0
|
|
343
|
+
? `Cognition lenses applied: ${cog.names.join(', ')}.`
|
|
344
|
+
: 'No explicit cognition block in turn.',
|
|
345
|
+
outcome: 'pending',
|
|
346
|
+
outcome_details: {
|
|
347
|
+
expected: null,
|
|
348
|
+
immediate_actual: null,
|
|
349
|
+
anchors: [],
|
|
350
|
+
},
|
|
351
|
+
expected_outcome: null,
|
|
352
|
+
metadata: {
|
|
353
|
+
pre_receipt_id: existing?.receipt?.receiptId || null,
|
|
354
|
+
post_receipt_id: _lastMizanReceipt?.receiptId || null,
|
|
355
|
+
},
|
|
356
|
+
source: 'opencode-stop-gate-runtime',
|
|
357
|
+
model_used: process.env.OPENCODE_MODEL || 'opencode',
|
|
358
|
+
});
|
|
359
|
+
} catch (e) {
|
|
360
|
+
process.stderr.write(`[harness-stop] decision/log unavailable: ${e.message}\n`);
|
|
361
|
+
}
|
|
239
362
|
},
|
|
240
363
|
};
|
|
241
364
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const STATE_DIR = join(__dirname, '..', 'state');
|
|
8
|
+
const KEYS_PATH = join(STATE_DIR, 'api-keys.json');
|
|
9
|
+
const USERS_PATH = join(STATE_DIR, 'users.json');
|
|
10
|
+
const SESSIONS_PATH = join(STATE_DIR, 'sessions.json');
|
|
11
|
+
const SCRYPT_KEY_LENGTH = 32;
|
|
12
|
+
const SCRYPT_COST = 16384;
|
|
13
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
const OWNER_USERNAME = 'admin';
|
|
16
|
+
const OWNER_PASSWORD_HASH = hashPassword('Loveala613#');
|
|
17
|
+
|
|
18
|
+
function ensureDir(p) {
|
|
19
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadJson(path, fallback = []) {
|
|
23
|
+
if (!existsSync(path)) return fallback;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function saveJson(path, data) {
|
|
32
|
+
ensureDir(dirname(path));
|
|
33
|
+
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hashPassword(password) {
|
|
37
|
+
return scryptSync(password, 'aria-hq-user-salt-v2', SCRYPT_COST, 8, 1, SCRYPT_KEY_LENGTH).toString('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hashKey(key) {
|
|
41
|
+
return scryptSync(key, 'aria-hq-api-key-salt', SCRYPT_COST, 8, 1, SCRYPT_KEY_LENGTH).toString('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── API Key Management ───────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function loadKeys() {
|
|
47
|
+
return loadJson(KEYS_PATH, []);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveKeys(keys) {
|
|
51
|
+
saveJson(KEYS_PATH, keys);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function generateApiKey(tenantId) {
|
|
55
|
+
const raw = randomBytes(32).toString('hex');
|
|
56
|
+
const key = `aria_${raw.slice(0, 8)}_${raw.slice(8)}`;
|
|
57
|
+
const keyHash = hashKey(key);
|
|
58
|
+
const keys = loadKeys();
|
|
59
|
+
const existing = keys.findIndex(k => k.tenantId === tenantId);
|
|
60
|
+
const entry = {
|
|
61
|
+
tenantId,
|
|
62
|
+
keyHash,
|
|
63
|
+
keyPrefix: key.slice(0, 15),
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
if (existing >= 0) keys[existing] = entry; else keys.push(entry);
|
|
67
|
+
saveKeys(keys);
|
|
68
|
+
return key;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateApiKey(key) {
|
|
72
|
+
if (!key || typeof key !== 'string') return { valid: false, tenantId: null };
|
|
73
|
+
const keys = loadKeys();
|
|
74
|
+
const keyHash = hashKey(key);
|
|
75
|
+
for (const entry of keys) {
|
|
76
|
+
try {
|
|
77
|
+
if (timingSafeEqual(Buffer.from(keyHash), Buffer.from(entry.keyHash))) {
|
|
78
|
+
return { valid: true, tenantId: entry.tenantId };
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { valid: false, tenantId: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function rotateApiKey(tenantId) {
|
|
88
|
+
return generateApiKey(tenantId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function revokeApiKey(tenantId) {
|
|
92
|
+
const keys = loadKeys();
|
|
93
|
+
const filtered = keys.filter(k => k.tenantId !== tenantId);
|
|
94
|
+
saveKeys(filtered);
|
|
95
|
+
return filtered.length < keys.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── User Management ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function registerUser(email, password, tenantId) {
|
|
101
|
+
if (!email || !password || !tenantId) {
|
|
102
|
+
return { ok: false, error: 'email, password, and tenantId required' };
|
|
103
|
+
}
|
|
104
|
+
const users = loadJson(USERS_PATH, []);
|
|
105
|
+
if (users.find(u => u.email === email.toLowerCase().trim())) {
|
|
106
|
+
return { ok: false, error: 'User already exists' };
|
|
107
|
+
}
|
|
108
|
+
if (users.find(u => u.tenantId === tenantId)) {
|
|
109
|
+
return { ok: false, error: 'Tenant already has a registered user' };
|
|
110
|
+
}
|
|
111
|
+
const user = {
|
|
112
|
+
id: randomBytes(16).toString('hex'),
|
|
113
|
+
email: email.toLowerCase().trim(),
|
|
114
|
+
passwordHash: hashPassword(password),
|
|
115
|
+
role: 'client',
|
|
116
|
+
tenantId,
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
lastLogin: null,
|
|
119
|
+
};
|
|
120
|
+
users.push(user);
|
|
121
|
+
saveJson(USERS_PATH, users);
|
|
122
|
+
return { ok: true, user: { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function loginUser(usernameOrEmail, password) {
|
|
126
|
+
if (usernameOrEmail === OWNER_USERNAME) {
|
|
127
|
+
try {
|
|
128
|
+
if (timingSafeEqual(Buffer.from(hashPassword(password)), Buffer.from(OWNER_PASSWORD_HASH))) {
|
|
129
|
+
const session = createSession({ userId: 'owner', role: 'owner', tenantId: null, email: OWNER_USERNAME });
|
|
130
|
+
return { ok: true, session, user: { id: 'owner', email: OWNER_USERNAME, role: 'owner', tenantId: null } };
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, error: 'Invalid credentials' };
|
|
134
|
+
}
|
|
135
|
+
return { ok: false, error: 'Invalid credentials' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const users = loadJson(USERS_PATH, []);
|
|
139
|
+
const user = users.find(u => u.email === usernameOrEmail.toLowerCase().trim());
|
|
140
|
+
if (!user) return { ok: false, error: 'No account found with that email' };
|
|
141
|
+
try {
|
|
142
|
+
if (timingSafeEqual(Buffer.from(hashPassword(password)), Buffer.from(user.passwordHash))) {
|
|
143
|
+
const session = createSession({ userId: user.id, role: user.role, tenantId: user.tenantId, email: user.email });
|
|
144
|
+
user.lastLogin = new Date().toISOString();
|
|
145
|
+
saveJson(USERS_PATH, users);
|
|
146
|
+
return { ok: true, session, user: { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId } };
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
return { ok: false, error: 'Invalid password' };
|
|
150
|
+
}
|
|
151
|
+
return { ok: false, error: 'Invalid password' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Session Management ───────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function createSession(identity) {
|
|
157
|
+
const token = `hq_${randomBytes(32).toString('hex')}`;
|
|
158
|
+
const session = {
|
|
159
|
+
token,
|
|
160
|
+
userId: identity.userId,
|
|
161
|
+
role: identity.role,
|
|
162
|
+
tenantId: identity.tenantId,
|
|
163
|
+
email: identity.email,
|
|
164
|
+
createdAt: new Date().toISOString(),
|
|
165
|
+
expiresAt: new Date(Date.now() + SESSION_TTL_MS).toISOString(),
|
|
166
|
+
};
|
|
167
|
+
const sessions = loadJson(SESSIONS_PATH, []);
|
|
168
|
+
sessions.push(session);
|
|
169
|
+
saveJson(SESSIONS_PATH, sessions);
|
|
170
|
+
return { token, role: identity.role, tenantId: identity.tenantId, email: identity.email };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function validateSession(token) {
|
|
174
|
+
if (!token || typeof token !== 'string') return { valid: false };
|
|
175
|
+
const sessions = loadJson(SESSIONS_PATH, []);
|
|
176
|
+
const session = sessions.find(s => s.token === token);
|
|
177
|
+
if (!session) return { valid: false };
|
|
178
|
+
if (new Date(session.expiresAt) < new Date()) {
|
|
179
|
+
revokeSession(token);
|
|
180
|
+
return { valid: false };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
valid: true,
|
|
184
|
+
userId: session.userId,
|
|
185
|
+
role: session.role,
|
|
186
|
+
tenantId: session.tenantId,
|
|
187
|
+
email: session.email,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function revokeSession(token) {
|
|
192
|
+
const sessions = loadJson(SESSIONS_PATH, []);
|
|
193
|
+
const filtered = sessions.filter(s => s.token !== token);
|
|
194
|
+
saveJson(SESSIONS_PATH, filtered);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Owner Tenant Listing ─────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export function listAllTenants() {
|
|
200
|
+
const users = loadJson(USERS_PATH, []);
|
|
201
|
+
const results = [];
|
|
202
|
+
for (const user of users) {
|
|
203
|
+
if (user.role !== 'client') continue;
|
|
204
|
+
results.push({
|
|
205
|
+
tenantId: user.tenantId,
|
|
206
|
+
email: user.email,
|
|
207
|
+
createdAt: user.createdAt,
|
|
208
|
+
lastLogin: user.lastLogin,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Auth Middleware ───────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
const PUBLIC_ROUTES = new Set([
|
|
217
|
+
'/hq/auth/login',
|
|
218
|
+
'/hq/auth/register',
|
|
219
|
+
'/hq/onboarding/chat',
|
|
220
|
+
'/hq/onboarding/status',
|
|
221
|
+
'/hq/subscription/tier',
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
export function hqAuthMiddleware(pathname, req) {
|
|
225
|
+
if (PUBLIC_ROUTES.has(pathname)) return { authorized: true, tenantId: null };
|
|
226
|
+
|
|
227
|
+
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || '';
|
|
228
|
+
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
229
|
+
|
|
230
|
+
if (!token) {
|
|
231
|
+
return { authorized: false, error: 'Authentication required. Use Authorization: Bearer <token> header.', tenantId: null };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (token.startsWith('hq_')) {
|
|
235
|
+
const session = validateSession(token);
|
|
236
|
+
if (session.valid) {
|
|
237
|
+
return {
|
|
238
|
+
authorized: true,
|
|
239
|
+
tenantId: session.tenantId,
|
|
240
|
+
role: session.role,
|
|
241
|
+
userId: session.userId,
|
|
242
|
+
email: session.email,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return { authorized: false, error: 'Session expired or invalid.', tenantId: null };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const result = validateApiKey(token);
|
|
249
|
+
if (!result.valid) return { authorized: false, error: 'Invalid API key or session token.', tenantId: null };
|
|
250
|
+
return { authorized: true, tenantId: result.tenantId };
|
|
251
|
+
}
|