shraga 0.1.111 → 0.1.113

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 (59) hide show
  1. package/README.md +4 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +4 -1
  5. package/dist/client/assets/index-DIDtPQb-.css +10 -0
  6. package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
  7. package/dist/client/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/client/App.tsx +33 -8
  10. package/src/client/components/BackendStatusBanner.tsx +62 -0
  11. package/src/client/components/ConfigPanel.tsx +61 -15
  12. package/src/client/components/ConversationHeader.tsx +5 -1
  13. package/src/client/components/McpManager.tsx +26 -9
  14. package/src/client/components/SkillsManager.tsx +48 -27
  15. package/src/client/hooks/useAuth.ts +11 -2
  16. package/src/client/hooks/useIsOwner.ts +24 -0
  17. package/src/client/hooks/useModules.ts +5 -1
  18. package/src/client/lib/api.ts +21 -5
  19. package/src/client/lib/backendHealth.ts +230 -0
  20. package/src/client/lib/debug.ts +48 -0
  21. package/src/client/lib/sessionApi.ts +24 -8
  22. package/src/client/lib/ws.ts +21 -13
  23. package/src/scripts/harden-audit.sh +55 -0
  24. package/src/server/api-key-routes.ts +64 -0
  25. package/src/server/api-keys.ts +181 -43
  26. package/src/server/auth.ts +113 -47
  27. package/src/server/boot.ts +158 -104
  28. package/src/server/claude.ts +112 -4
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +10 -5
  31. package/src/server/engine/claude-code.ts +190 -32
  32. package/src/server/engine/claude-resume.ts +205 -0
  33. package/src/server/engine/types.ts +10 -0
  34. package/src/server/hooks.ts +19 -0
  35. package/src/server/mcp-oauth.ts +24 -5
  36. package/src/server/mcp-server.ts +55 -25
  37. package/src/server/modules/routes.ts +2 -6
  38. package/src/server/notify-owners.ts +5 -17
  39. package/src/server/owners.ts +14 -0
  40. package/src/server/scheduler/builtins.ts +3 -1
  41. package/src/server/scheduler/runner.ts +3 -0
  42. package/src/server/security/audit.ts +498 -0
  43. package/src/server/security/enforce.ts +306 -0
  44. package/src/server/security/escalate.ts +194 -0
  45. package/src/server/security/guard.ts +329 -0
  46. package/src/server/security/owner-only.ts +15 -0
  47. package/src/server/security/owner-routes.ts +43 -0
  48. package/src/server/security/policy.ts +413 -0
  49. package/src/server/security/principal.ts +80 -0
  50. package/src/server/security/revocation.ts +50 -0
  51. package/src/server/security/runtime.ts +174 -0
  52. package/src/server/sessions.ts +51 -9
  53. package/src/server/shraga-config.ts +3 -0
  54. package/src/server/slack/bot.ts +44 -11
  55. package/src/server/slack/context-cache.ts +40 -7
  56. package/src/server/webhook-lane/feature.ts +17 -6
  57. package/src/shared/models.ts +11 -0
  58. package/dist/client/assets/index-BNAh4GUs.js +0 -1949
  59. package/dist/client/assets/index-DIMte_k6.css +0 -10
@@ -1,3 +1,8 @@
1
+ import { reportWsDown, reportWsUp } from '@/lib/backendHealth';
2
+ import { logger } from '@/lib/debug';
3
+
4
+ const log = logger.forComponent('ws');
5
+
1
6
  /** Subprotocol marker used to carry a bearer token through a WebSocket handshake: open the socket as
2
7
  * `new WebSocket(url, [WS_AUTH_PROTOCOL, token])`. Browsers can't set headers on a WS handshake, and the
3
8
  * subprotocol list is the one field they can — used by the sidecar WS proxy (see authenticateWsUpgrade
@@ -72,7 +77,7 @@ export class AgentSocket {
72
77
  this.intentionalClose = false;
73
78
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
74
79
  const url = `${proto}://${location.host}/ws`;
75
- console.log('[ws] connecting…');
80
+ log.debug('connecting…');
76
81
  this.ws = new WebSocket(url);
77
82
 
78
83
  this.ws.onopen = async () => {
@@ -81,11 +86,12 @@ export class AgentSocket {
81
86
  try {
82
87
  token = await this.tokenProvider();
83
88
  } catch (err) {
84
- console.warn('[ws] token fetch failed', err);
89
+ log.warn('token fetch failed', err);
85
90
  }
86
91
  if (this.ws?.readyState !== WebSocket.OPEN) return;
87
- console.log('[ws] open, sending auth');
92
+ log.debug('open, sending auth');
88
93
  this.ws.send(JSON.stringify({ type: 'auth', token }));
94
+ reportWsUp();
89
95
  if (this.reconnecting) {
90
96
  this.reconnecting = false;
91
97
  this.listeners.forEach((l) => l({ type: 'reconnected' }));
@@ -95,7 +101,7 @@ export class AgentSocket {
95
101
  this.ws.onmessage = (e) => {
96
102
  try {
97
103
  const data = JSON.parse(e.data) as ServerEvent;
98
- if (data.type !== 'text_delta') console.log('[ws] ←', data.type);
104
+ if (data.type !== 'text_delta') log.verbose('←', data.type);
99
105
  if (data.type === 'auth_ok') {
100
106
  this.authRetries = 0;
101
107
  this.flushPending();
@@ -113,37 +119,38 @@ export class AgentSocket {
113
119
  this.intentionalClose = true;
114
120
  } else {
115
121
  this.authRetries++;
116
- console.log(`[ws] auth failed (attempt ${this.authRetries}), will retry with fresh token`);
122
+ log.warn(`auth failed (attempt ${this.authRetries}), will retry with fresh token`);
117
123
  }
118
124
  }
119
125
  this.listeners.forEach((l) => l(data));
120
126
  } catch (err) {
121
- console.warn('[ws] bad frame', err);
127
+ log.warn('bad frame', err);
122
128
  }
123
129
  };
124
130
 
125
131
  this.ws.onclose = (e) => {
126
- console.log(`[ws] closed code=${e.code} intentional=${this.intentionalClose}`);
132
+ log.debug(`closed code=${e.code} intentional=${this.intentionalClose}`);
127
133
  if (!this.intentionalClose) {
128
134
  this.reconnecting = true;
129
135
  this.connectAttempts++;
136
+ reportWsDown(e.code);
130
137
  this.listeners.forEach((l) => l({ type: 'disconnected' }));
131
138
  const base = this.authRetries > 0 ? Math.min(2000 * this.authRetries, 10000) : Math.min(1000 * 2 ** this.connectAttempts, 30000);
132
139
  const jitter = Math.random() * 1000;
133
140
  const delay = base + jitter;
134
- console.log(`[ws] reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.connectAttempts})`);
141
+ log.debug(`reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.connectAttempts})`);
135
142
  setTimeout(() => this.connect(this.tokenProvider), delay);
136
143
  }
137
144
  };
138
145
 
139
- this.ws.onerror = (e) => console.warn('[ws] error', e);
146
+ this.ws.onerror = (e) => log.warn('error', e);
140
147
  }
141
148
 
142
149
  private flushPending() {
143
150
  if (this.pendingMessage) {
144
151
  const msg = this.pendingMessage;
145
152
  this.pendingMessage = null;
146
- console.log('[ws] flushing pending message after reconnect');
153
+ log.debug('flushing pending message after reconnect');
147
154
  if (!this.send(msg)) {
148
155
  this.pendingMessage = msg;
149
156
  }
@@ -152,24 +159,25 @@ export class AgentSocket {
152
159
 
153
160
  disconnect() {
154
161
  this.intentionalClose = true;
162
+ reportWsUp(); // an intentional close is not a fault — don't leave a stale banner behind
155
163
  this.ws?.close();
156
164
  this.ws = null;
157
165
  }
158
166
 
159
167
  send(msg: object): boolean {
160
168
  if (this.ws?.readyState === WebSocket.OPEN) {
161
- console.log('[ws] →', (msg as any).type);
169
+ log.verbose('→', (msg as any).type);
162
170
  this.ws.send(JSON.stringify(msg));
163
171
  return true;
164
172
  }
165
- console.warn('[ws] send failed, readyState=', this.ws?.readyState);
173
+ log.warn('send failed, readyState=', this.ws?.readyState);
166
174
  return false;
167
175
  }
168
176
 
169
177
  sendOrQueue(msg: object): boolean {
170
178
  if (this.send(msg)) return true;
171
179
  this.pendingMessage = msg;
172
- console.log('[ws] message queued for reconnect');
180
+ log.debug('message queued for reconnect');
173
181
  return false;
174
182
  }
175
183
 
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # harden-audit.sh — make shraga's audit log append-only at the OS level. Linux + root. Idempotent: run it from cron.
3
+ #
4
+ # sudo harden-audit.sh <DATA_DIR> (or DATA_DIR=<dir> sudo -E harden-audit.sh)
5
+ # root crontab, hourly: 17 * * * * /app/node_modules/shraga/src/scripts/harden-audit.sh /app/data-prod
6
+ #
7
+ # `chattr +a` on <DATA_DIR>/audit/: the server user can still create month files and append, but can't delete or
8
+ # rename any entry. `chattr +a` on each YYYY-MM.jsonl: it opens for append only — no truncate, no rewrite.
9
+ # New files do NOT inherit +a (it is not in ext4's EXT4_FL_INHERITED nor XFS's inherited flags), so a new month's
10
+ # file stays truncatable until the next run — hence hourly cron. Retention/rotation is a root-only op (chattr -a).
11
+ # +a can't stop the server user from CREATING a month entry as a symlink/directory (planted). Those are never hardened:
12
+ # they're reported and the script exits non-zero — after +a is applied to every valid file. The server refuses to
13
+ # append through them and alerts owners. Remove one as root: chattr -a "$audit"; rm -r <entry>; re-run.
14
+ set -euo pipefail
15
+
16
+ [ "$(uname -s)" = Linux ] || { echo "harden-audit: Linux only (chattr) — nothing done" >&2; exit 1; }
17
+ [ "$(id -u)" -eq 0 ] || { echo "harden-audit: must run as root (chattr +a needs CAP_LINUX_IMMUTABLE)" >&2; exit 1; }
18
+ data="${1:-${DATA_DIR:-}}"
19
+ [ -n "$data" ] || { echo "usage: $0 <DATA_DIR>" >&2; exit 2; }
20
+ audit="$(realpath "$data")/audit"
21
+ [ -d "$audit" ] && [ ! -L "$audit" ] || { echo "harden-audit: $audit is not a directory (start the server once first)" >&2; exit 1; }
22
+ # Builds before tamper protection lock INSIDE the audit dir; in an append-only dir that lock can never be released.
23
+ if [ -e "$audit/.lock" ]; then
24
+ {
25
+ echo "harden-audit: $audit/.lock exists — left by a pre-tamper-protection shraga (it locks inside the audit dir)."
26
+ echo " 1. Verify no old build is running against $data (e.g. ps -eo pid,args | grep shraga); upgrade or stop it."
27
+ if lsattr -d "$audit" 2>/dev/null | awk '{print $1}' | grep -q a; then
28
+ echo " 2. $audit is already append-only, so first: chattr -a '$audit'"
29
+ echo " 3. As root: rmdir '$audit/.lock'"
30
+ else
31
+ echo " 2. As root: rmdir '$audit/.lock'"
32
+ fi
33
+ echo " Then re-run: $0 $data"
34
+ } >&2
35
+ exit 1
36
+ fi
37
+
38
+ chattr +a "$audit"
39
+ shopt -s nullglob
40
+ files=() bad=()
41
+ for f in "$audit"/*.jsonl; do
42
+ if [ -f "$f" ] && [ ! -L "$f" ]; then files+=("$f"); else bad+=("$f"); fi
43
+ done
44
+ for f in "${files[@]}"; do chattr +a "$f"; done
45
+
46
+ lsattr -d "$audit"
47
+ [ ${#files[@]} -eq 0 ] || lsattr "${files[@]}"
48
+ echo "harden-audit: $audit append-only (+${#files[@]} month files) — server can append, not truncate/delete/rename"
49
+
50
+ if [ ${#bad[@]} -gt 0 ]; then
51
+ for f in "${bad[@]}"; do
52
+ echo "harden-audit: ERROR — $f is not a regular file ($(stat -c %F "$f" 2>/dev/null || echo unknown)$([ -L "$f" ] && echo " -> $(readlink "$f")")); possibly PLANTED to divert or disable auditing. NOT hardened. Inspect, then as root: chattr -a '$audit' && rm -r '$f' && $0 $data" >&2
53
+ done
54
+ exit 1
55
+ fi
@@ -0,0 +1,64 @@
1
+ // API key list/create/delete, ONE implementation for both mounts: `/api/api-keys` (self) and `/api/owner/api-keys`
2
+ // (owner console). Gates run per route, so mounting the router never gates unrelated requests.
3
+ // Audit actor is always the caller's principal id. Store refusals (validation 400, PASSIVE 409) map to their status.
4
+ import { Router, type Request, type RequestHandler, type Response } from 'express';
5
+ import type { AuthUser } from './auth.ts';
6
+ import { apiKeyStore, ApiKeyStoreError } from './api-keys.ts';
7
+ import { security } from './security/runtime.ts';
8
+
9
+ export class ApiKeyRoutesOptions {
10
+ base: string = '/api/api-keys';
11
+ gate: RequestHandler[] = [];
12
+ /** Owner console: sees/deletes every key and may set `role`/`expiresAt`. Self: own keys (all when the caller is an
13
+ * owner, as before) and label only. */
14
+ asOwner: boolean = false;
15
+ }
16
+
17
+ const userOf = (req: Request) => (req as any).user as AuthUser;
18
+
19
+ function fail(res: Response, e: any): void {
20
+ const status = e instanceof ApiKeyStoreError ? e.status : 400;
21
+ console.warn(`[api-keys] ${e?.message ?? e}`);
22
+ res.status(status).json({ error: e?.message ?? String(e) });
23
+ }
24
+
25
+ export function apiKeyRouter(options?: Partial<ApiKeyRoutesOptions>): Router {
26
+ const { base, gate, asOwner } = { ...new ApiKeyRoutesOptions(), ...options };
27
+ const router = Router();
28
+ const isOwner = (u: AuthUser) => asOwner || u.isOwner;
29
+
30
+ router.get(base, ...gate, (req, res) => {
31
+ const user = userOf(req);
32
+ res.json({ keys: apiKeyStore().list({ uid: user.uid, isOwner: isOwner(user) }) });
33
+ });
34
+
35
+ /** Body: { label?, role?, expiresAt? (epoch ms) } — role/expiresAt honored on the owner mount only. Plaintext returned once. */
36
+ router.post(base, ...gate, (req, res) => {
37
+ const user = userOf(req);
38
+ // Minting a key is minting a credential AS this identity — only an interactive login may (same rule as OAuth
39
+ // consent). Otherwise a role-capped or expiring key could mint itself an uncapped, non-expiring one.
40
+ const kind = user.principal?.kind ?? 'unknown';
41
+ if (kind !== 'user') {
42
+ console.warn(`[api-keys] create refused: non-interactive credential (${kind})`);
43
+ security()?.authDeny(`http:${base}`, `non-interactive:${kind}`, req.ip);
44
+ return void res.status(403).json({ error: 'Creating an API key requires an interactive login' });
45
+ }
46
+ const { label, role, expiresAt } = (req.body ?? {}) as { label?: string; role?: string; expiresAt?: number };
47
+ try {
48
+ const opts = asOwner ? { role, expiresAt } : {};
49
+ res.json(apiKeyStore().create(user.uid, user.email, label || 'Unnamed', { ...opts, actor: user.principal.id }));
50
+ } catch (e: any) { fail(res, e); }
51
+ });
52
+
53
+ router.delete(`${base}/:id`, ...gate, (req: Request<{ id: string }>, res) => {
54
+ const user = userOf(req);
55
+ try {
56
+ const r = apiKeyStore().delete(req.params.id, user.uid, isOwner(user), user.principal.id);
57
+ if (r === 'not_found') return void res.status(404).json({ error: 'Key not found' });
58
+ if (r === 'forbidden') return void res.status(403).json({ error: 'Cannot delete another user\'s key' });
59
+ res.json({ ok: true });
60
+ } catch (e: any) { fail(res, e); }
61
+ });
62
+
63
+ return router;
64
+ }
@@ -1,63 +1,201 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
- import { randomBytes, timingSafeEqual } from 'node:crypto';
1
+ // API keys (`uck_…`). Stored as sha256(key) + a display preview — the plaintext is returned ONCE, by create().
2
+ //
3
+ // Migration (one-time, idempotent, at load): a file still holding plaintext `key` entries is hashed in place —
4
+ // the original bytes are kept at `<path>.bak` (mode 600, written only if absent), then the hashed file is written
5
+ // atomically (tmp + rename, mode 600). Only the ACTIVE instance migrates: a PASSIVE standby sharing DATA_DIR hashes
6
+ // in memory and serves the same keys without writing. A file with no plaintext entries is never rewritten.
7
+ // A failed migration is retried only after the file changes or `retryMs` passes (logged once per failing file state).
8
+ // Every store write (create/delete) is refused while PASSIVE.
9
+ //
10
+ // Rollback: code from before hashing expects a plaintext `key` on every entry and breaks on hashed ones, so rolling
11
+ // back invalidates EVERY API key. Before rolling back, restore `api-keys.json.bak` (keys created after the migration
12
+ // are lost), or re-issue the keys afterwards.
13
+ import { createHash, randomBytes } from 'node:crypto';
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
15
+ import path from 'node:path';
3
16
  import { dataPath } from './paths.ts';
4
-
5
- const KEYS_PATH = dataPath('api-keys.json');
17
+ import { fromApiKey, fromAuthUser, type Principal } from './security/principal.ts';
18
+ import { OWNER_ROLE } from './security/policy.ts';
19
+ import { security } from './security/runtime.ts';
6
20
 
7
21
  export interface ApiKey {
8
22
  id: string;
9
- key: string;
23
+ /** sha256 hex of the full key. */
24
+ hash: string;
25
+ keyPreview: string;
10
26
  label: string;
11
27
  uid: string;
12
28
  email: string;
13
29
  createdAt: number;
30
+ /** Policy role name the key's principal carries (never owner). */
31
+ role?: string;
32
+ /** Epoch ms; the key is rejected from then on. */
33
+ expiresAt?: number;
14
34
  }
35
+ export type ApiKeyView = Omit<ApiKey, 'hash'>;
36
+ export interface ApiKeyIdentity { id: string; uid: string; email: string; role?: string }
37
+ export interface CreateApiKeyOptions { role?: string; expiresAt?: number; /** Principal id of the creator, for the audit. */ actor?: string }
15
38
 
16
- function load(): ApiKey[] {
17
- if (!existsSync(KEYS_PATH)) return [];
18
- try { return JSON.parse(readFileSync(KEYS_PATH, 'utf-8')); } catch { return []; }
39
+ /** A refused store operation; `status` is the HTTP status a route should answer with. */
40
+ export class ApiKeyStoreError extends Error {
41
+ public constructor(message: string, public status = 400) { super(message); }
19
42
  }
20
43
 
21
- function save(keys: ApiKey[]) {
22
- mkdirSync(dataPath(''), { recursive: true });
23
- writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2));
24
- }
44
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
45
+ const previewOf = (key: string) => `${key.slice(0, 8)}…`;
46
+ const view = ({ hash: _h, ...rest }: ApiKey): ApiKeyView => rest;
25
47
 
26
- export function createApiKey(uid: string, email: string, label: string): ApiKey {
27
- const keys = load();
28
- const entry: ApiKey = {
29
- id: randomBytes(8).toString('hex'),
30
- key: `uck_${randomBytes(32).toString('hex')}`,
31
- label,
32
- uid,
33
- email,
34
- createdAt: Date.now(),
35
- };
36
- keys.push(entry);
37
- save(keys);
38
- return entry;
48
+ export class ApiKeyStoreOptions {
49
+ path: string = dataPath('api-keys.json');
50
+ /** Migration writes only while true (PASSIVE standby ⇒ false). Default: the security runtime's flag. */
51
+ isActive: () => boolean = () => security()?.options.isActive() ?? true;
52
+ clock: () => number = Date.now;
53
+ /** After a failed migration, don't retry until the file changes or this many ms pass. */
54
+ retryMs: number = 60_000;
55
+ log: Pick<Console, 'info' | 'warn' | 'error'> = console;
39
56
  }
40
57
 
41
- export function deleteApiKey(id: string, callerUid: string, isOwner: boolean): 'ok' | 'not_found' | 'forbidden' {
42
- const keys = load();
43
- const idx = keys.findIndex(k => k.id === id);
44
- if (idx < 0) return 'not_found';
45
- if (keys[idx].uid !== callerUid && !isOwner) return 'forbidden';
46
- keys.splice(idx, 1);
47
- save(keys);
48
- return 'ok';
49
- }
58
+ interface Snapshot { mtimeMs: number; size: number; keys: ApiKey[]; byHash: Map<string, ApiKey>; legacy: boolean; failedAt?: number }
50
59
 
51
- export function listApiKeys() {
52
- return load().map(({ key, ...rest }) => ({ ...rest, keyPreview: `${key.slice(0, 8)}…` }));
53
- }
60
+ export class ApiKeyStore {
61
+ public options: ApiKeyStoreOptions;
62
+ private snap?: Snapshot;
63
+
64
+ public constructor(options?: Partial<ApiKeyStoreOptions>) {
65
+ this.options = { ...new ApiKeyStoreOptions(), ...options };
66
+ }
67
+
68
+ private active(): boolean {
69
+ try { return this.options.isActive(); }
70
+ catch (e: any) { this.options.log.error(`[api-keys] isActive threw — treating as PASSIVE: ${e.message}`); return false; }
71
+ }
54
72
 
55
- export function validateApiKey(key: string): { uid: string; email: string } | null {
56
- const keys = load();
57
- for (const k of keys) {
58
- if (k.key.length === key.length && timingSafeEqual(Buffer.from(k.key), Buffer.from(key))) {
59
- return { uid: k.uid, email: k.email };
73
+ /** Current keys; re-parses only when the file changed (stat), migrating plaintext entries on the active instance. */
74
+ private read(): Snapshot {
75
+ const p = this.options.path;
76
+ const st = existsSync(p) ? statSync(p) : null;
77
+ const s = this.snap;
78
+ const unchanged = !!(s && st && s.mtimeMs === st.mtimeMs && s.size === st.size);
79
+ const backingOff = s?.failedAt !== undefined && this.options.clock() - s.failedAt < this.options.retryMs;
80
+ if (unchanged && !(s!.legacy && this.active() && !backingOff)) return s!;
81
+ if (!st) return (this.snap = { mtimeMs: 0, size: -1, keys: [], byHash: new Map(), legacy: false });
82
+ const raw = readFileSync(p, 'utf8');
83
+ let parsed: unknown;
84
+ try { parsed = JSON.parse(raw); } catch (e: any) {
85
+ this.options.log.error(`[api-keys] ${p} unparseable — no keys valid until fixed: ${e.message}`);
86
+ parsed = [];
60
87
  }
88
+ let legacy = false;
89
+ const keys: ApiKey[] = (Array.isArray(parsed) ? parsed : []).map((k: any) => {
90
+ if (typeof k?.key !== 'string') return k as ApiKey;
91
+ legacy = true;
92
+ const { key, ...rest } = k;
93
+ return { ...rest, hash: sha256(key), keyPreview: previewOf(key) };
94
+ });
95
+ if (legacy && this.active()) return this.migrate(raw, keys, st, unchanged && s?.failedAt !== undefined);
96
+ return (this.snap = { mtimeMs: st.mtimeMs, size: st.size, keys, byHash: this.index(keys), legacy });
61
97
  }
62
- return null;
98
+
99
+ private index(keys: ApiKey[]): Map<string, ApiKey> {
100
+ return new Map(keys.filter(k => typeof k?.hash === 'string').map(k => [k.hash, k]));
101
+ }
102
+
103
+ /** `retry` = the same file state already failed once (don't log again). */
104
+ private migrate(raw: string, keys: ApiKey[], st: { mtimeMs: number; size: number }, retry: boolean): Snapshot {
105
+ const bak = `${this.options.path}.bak`;
106
+ try {
107
+ if (existsSync(bak)) this.options.log.warn(`[api-keys] ${bak} exists — keeping it, not overwriting`);
108
+ else writeFileSync(bak, raw, { mode: 0o600, flag: 'wx' });
109
+ const snap = this.write(keys);
110
+ this.options.log.info(`[api-keys] migrated ${keys.length} key(s) to hashed storage (backup: ${bak})`);
111
+ return snap;
112
+ } catch (e: any) {
113
+ if (!retry) this.options.log.error(`[api-keys] migration failed — serving hashed in memory, retrying when the file changes or every ${this.options.retryMs}ms: ${e.message}`);
114
+ return (this.snap = { mtimeMs: st.mtimeMs, size: st.size, keys, byHash: this.index(keys), legacy: true, failedAt: this.options.clock() });
115
+ }
116
+ }
117
+
118
+ /** Atomic write (tmp + rename, mode 600); refreshes the snapshot from what landed. */
119
+ private write(keys: ApiKey[]): Snapshot {
120
+ const p = this.options.path;
121
+ mkdirSync(path.dirname(p), { recursive: true });
122
+ const tmp = `${p}.${process.pid}.tmp`;
123
+ writeFileSync(tmp, JSON.stringify(keys, null, 2), { mode: 0o600 });
124
+ renameSync(tmp, p);
125
+ const st = statSync(p);
126
+ return (this.snap = { mtimeMs: st.mtimeMs, size: st.size, keys, byHash: this.index(keys), legacy: false });
127
+ }
128
+
129
+ /** Mint a key. Returns its public view plus the plaintext `key` — the only time it is ever available. */
130
+ public create(uid: string, email: string, label: string, opts: CreateApiKeyOptions = {}): ApiKeyView & { key: string } {
131
+ this.assertWritable();
132
+ const { role, expiresAt, actor } = opts;
133
+ if (role !== undefined) {
134
+ if (typeof role !== 'string' || !role) throw new Error('role must be a non-empty string');
135
+ if (role === OWNER_ROLE) throw new Error('an API key cannot carry the owner role');
136
+ const policy = security()?.policy;
137
+ if (policy && !policy.current.roles[role]) throw new Error(`role "${role}" is not defined in the policy`);
138
+ }
139
+ if (expiresAt !== undefined && !(Number.isFinite(expiresAt) && expiresAt > this.options.clock())) {
140
+ throw new Error('expiresAt must be a future epoch (ms)');
141
+ }
142
+ const key = `uck_${randomBytes(32).toString('hex')}`;
143
+ const entry: ApiKey = {
144
+ id: randomBytes(8).toString('hex'), hash: sha256(key), keyPreview: previewOf(key), label, uid, email,
145
+ createdAt: this.options.clock(), ...(role ? { role } : {}), ...(expiresAt !== undefined ? { expiresAt } : {}),
146
+ };
147
+ this.write([...this.read().keys, entry]);
148
+ security()?.record({
149
+ type: 'key.create', principal: actor ?? fromAuthUser({ uid, email }).id, target: `apikey:${entry.id}`, role,
150
+ meta: { uid, ...(expiresAt !== undefined ? { expiresAt } : {}) },
151
+ });
152
+ return { ...view(entry), key };
153
+ }
154
+
155
+ /** `actor` = the caller's principal id (`req.user.principal.id`), for the audit. */
156
+ public delete(id: string, callerUid: string, isOwner: boolean, actor: string): 'ok' | 'not_found' | 'forbidden' {
157
+ this.assertWritable();
158
+ const keys = this.read().keys;
159
+ const k = keys.find(x => x.id === id);
160
+ if (!k) return 'not_found';
161
+ if (k.uid !== callerUid && !isOwner) return 'forbidden';
162
+ this.write(keys.filter(x => x !== k));
163
+ security()?.record({ type: 'key.revoke', principal: actor, target: `apikey:${id}`, meta: { uid: k.uid } });
164
+ return 'ok';
165
+ }
166
+
167
+ /** PASSIVE standby shares DATA_DIR: it must never write the key file (nor migrate it as a side effect of a write). */
168
+ private assertWritable(): void {
169
+ if (!this.active()) throw new ApiKeyStoreError('API keys are read-only on a PASSIVE standby — use the active instance', 409);
170
+ }
171
+
172
+ /** Keys visible to the caller: an owner sees every key, anyone else only their own. Never hash or plaintext. */
173
+ public list(caller: { uid: string; isOwner: boolean }): ApiKeyView[] {
174
+ return this.read().keys.filter(k => caller.isOwner || k.uid === caller.uid).map(view);
175
+ }
176
+
177
+ /** Hash lookup (the Map key is sha256 of the secret, so timing reveals nothing usable); expired ⇒ null. */
178
+ public validate(key: string): ApiKeyIdentity | null {
179
+ if (typeof key !== 'string' || !key.startsWith('uck_')) return null;
180
+ const k = this.read().byHash.get(sha256(key));
181
+ if (!k) return null;
182
+ if (k.expiresAt !== undefined && this.options.clock() >= k.expiresAt) return null;
183
+ return { id: k.id, uid: k.uid, email: k.email, ...(k.role ? { role: k.role } : {}) };
184
+ }
185
+ }
186
+
187
+ let store: ApiKeyStore | undefined;
188
+ /** The process-wide store over data/api-keys.json. */
189
+ export const apiKeyStore = (): ApiKeyStore => (store ??= new ApiKeyStore());
190
+
191
+ export const createApiKey = (uid: string, email: string, label: string, opts?: CreateApiKeyOptions) => apiKeyStore().create(uid, email, label, opts);
192
+ export const deleteApiKey = (id: string, callerUid: string, isOwner: boolean, actor: string) => apiKeyStore().delete(id, callerUid, isOwner, actor);
193
+ export const listApiKeys = (caller: { uid: string; isOwner: boolean }) => apiKeyStore().list(caller);
194
+ export const validateApiKey = (key: string) => apiKeyStore().validate(key);
195
+
196
+ /** Principal for a validated key. The key's role rides in `attrs.role` (principal.ts's fromApiKey takes no attrs). */
197
+ export function apiKeyPrincipal(k: ApiKeyIdentity): Principal {
198
+ const p = fromApiKey(k);
199
+ if (k.role) p.attrs.role = k.role;
200
+ return p;
63
201
  }