@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.
Files changed (95) hide show
  1. package/dist/aria-connector/src/connectors/claude-code.d.ts.map +1 -1
  2. package/dist/aria-connector/src/connectors/claude-code.js +115 -20
  3. package/dist/aria-connector/src/connectors/claude-code.js.map +1 -1
  4. package/dist/aria-connector/src/connectors/codex.d.ts.map +1 -1
  5. package/dist/aria-connector/src/connectors/codex.js +551 -11
  6. package/dist/aria-connector/src/connectors/codex.js.map +1 -1
  7. package/dist/aria-connector/src/connectors/doctrine-trigger-map.d.ts +7 -0
  8. package/dist/aria-connector/src/connectors/doctrine-trigger-map.d.ts.map +1 -0
  9. package/dist/aria-connector/src/connectors/doctrine-trigger-map.js +87 -0
  10. package/dist/aria-connector/src/connectors/doctrine-trigger-map.js.map +1 -0
  11. package/dist/aria-connector/src/connectors/must-read.d.ts +4 -0
  12. package/dist/aria-connector/src/connectors/must-read.d.ts.map +1 -0
  13. package/dist/aria-connector/src/connectors/must-read.js +115 -0
  14. package/dist/aria-connector/src/connectors/must-read.js.map +1 -0
  15. package/dist/aria-connector/src/connectors/opencode.d.ts.map +1 -1
  16. package/dist/aria-connector/src/connectors/opencode.js +27 -9
  17. package/dist/aria-connector/src/connectors/opencode.js.map +1 -1
  18. package/dist/aria-connector/src/connectors/runtime.d.ts.map +1 -1
  19. package/dist/aria-connector/src/connectors/runtime.js +231 -19
  20. package/dist/aria-connector/src/connectors/runtime.js.map +1 -1
  21. package/dist/aria-connector/src/connectors/shell.d.ts.map +1 -1
  22. package/dist/aria-connector/src/connectors/shell.js +76 -3
  23. package/dist/aria-connector/src/connectors/shell.js.map +1 -1
  24. package/dist/assets/hooks/aria-agent-handoff.mjs +23 -0
  25. package/dist/assets/hooks/aria-cognition-substrate-binding.mjs +121 -28
  26. package/dist/assets/hooks/aria-harness-via-sdk.mjs +126 -12
  27. package/dist/assets/hooks/aria-pre-emit-dryrun.mjs +35 -0
  28. package/dist/assets/hooks/aria-pre-tool-gate.mjs +383 -93
  29. package/dist/assets/hooks/aria-preprompt-consult.mjs +28 -2
  30. package/dist/assets/hooks/aria-preturn-memory-gate.mjs +93 -16
  31. package/dist/assets/hooks/aria-repo-doctrine-gate.mjs +33 -1
  32. package/dist/assets/hooks/aria-stop-gate.mjs +346 -81
  33. package/dist/assets/hooks/doctrine_trigger_map.json +55 -0
  34. package/dist/assets/hooks/lib/canonical-lenses.mjs +6 -5
  35. package/dist/assets/hooks/lib/gate-loop-state.mjs +50 -0
  36. package/dist/assets/hooks/lib/hook-message-window.mjs +121 -0
  37. package/dist/assets/hooks/test-tier-lens-labeling.mjs +26 -58
  38. package/dist/assets/opencode-plugins/harness-gate/index.js +40 -5
  39. package/dist/assets/opencode-plugins/harness-stop/index.js +133 -10
  40. package/dist/runtime/auth-middleware.mjs +251 -0
  41. package/dist/runtime/codex-bridge.mjs +644 -0
  42. package/dist/runtime/discipline/CLAUDE.md +28 -0
  43. package/dist/runtime/discipline/doctrine_trigger_map.json +534 -0
  44. package/dist/runtime/doctrine_trigger_map.json +534 -0
  45. package/dist/runtime/fleet-engine.mjs +231 -0
  46. package/dist/runtime/harness-daemon.mjs +460 -0
  47. package/dist/runtime/manifest.json +1 -1
  48. package/dist/runtime/metering.mjs +100 -0
  49. package/dist/runtime/onboarding-engine.mjs +89 -0
  50. package/dist/runtime/plugin-engine.mjs +196 -0
  51. package/dist/runtime/sdk/BUNDLED.json +1 -1
  52. package/dist/runtime/sdk/index.d.ts +12 -0
  53. package/dist/runtime/sdk/index.js +120 -14
  54. package/dist/runtime/sdk/index.js.map +1 -1
  55. package/dist/runtime/service.mjs +1140 -48
  56. package/dist/runtime/workflow-engine.mjs +322 -0
  57. package/dist/sdk/BUNDLED.json +1 -1
  58. package/dist/sdk/index.d.ts +12 -0
  59. package/dist/sdk/index.js +120 -14
  60. package/dist/sdk/index.js.map +1 -1
  61. package/hooks/aria-agent-handoff.mjs +23 -0
  62. package/hooks/aria-cognition-substrate-binding.mjs +121 -28
  63. package/hooks/aria-harness-via-sdk.mjs +126 -12
  64. package/hooks/aria-pre-emit-dryrun.mjs +35 -0
  65. package/hooks/aria-pre-tool-gate.mjs +383 -93
  66. package/hooks/aria-preprompt-consult.mjs +28 -2
  67. package/hooks/aria-preturn-memory-gate.mjs +93 -16
  68. package/hooks/aria-repo-doctrine-gate.mjs +33 -1
  69. package/hooks/aria-stop-gate.mjs +346 -81
  70. package/hooks/doctrine_trigger_map.json +55 -0
  71. package/hooks/lib/canonical-lenses.mjs +6 -5
  72. package/hooks/lib/gate-loop-state.mjs +50 -0
  73. package/hooks/lib/hook-message-window.mjs +121 -0
  74. package/hooks/test-tier-lens-labeling.mjs +26 -58
  75. package/opencode-plugins/harness-gate/index.js +40 -5
  76. package/opencode-plugins/harness-stop/index.js +133 -10
  77. package/package.json +1 -1
  78. package/runtime-src/auth-middleware.mjs +251 -0
  79. package/runtime-src/codex-bridge.mjs +644 -0
  80. package/runtime-src/fleet-engine.mjs +231 -0
  81. package/runtime-src/harness-daemon.mjs +460 -0
  82. package/runtime-src/metering.mjs +100 -0
  83. package/runtime-src/onboarding-engine.mjs +89 -0
  84. package/runtime-src/plugin-engine.mjs +196 -0
  85. package/runtime-src/service.mjs +1140 -48
  86. package/runtime-src/workflow-engine.mjs +322 -0
  87. package/scripts/bundle-sdk.mjs +5 -0
  88. package/src/connectors/claude-code.ts +126 -20
  89. package/src/connectors/codex.ts +559 -10
  90. package/src/connectors/doctrine-trigger-map.ts +112 -0
  91. package/src/connectors/must-read.ts +117 -0
  92. package/src/connectors/opencode.ts +28 -9
  93. package/src/connectors/runtime.ts +241 -21
  94. package/src/connectors/shell.ts +78 -3
  95. 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
- process.stderr.write(
177
- `[harness-stop] MIZAN POST BLOCK — ${(mizan.result?.notes || ['post-phase blocked']).slice(0, 4).join(' | ')}\n`
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
- process.stderr.write(
197
- `[harness-stop] SDK BLOCK — ${result.violations.length} violations: ${result.violations.join('; ').slice(0, 300)}\n`
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 triggerMapPath = `${HOME}/.claude/hooks/doctrine_trigger_map.json`;
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
- if (existsSync(triggerMapPath)) {
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
- process.stderr.write(
236
- `[harness-stop] LOCAL GATE — cognition=${cog.count}/${REQUIRED_LENSES} drift=${driftHits.length}\n`
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
  }
@@ -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
+ }