shraga 0.1.112 → 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.
- package/README.md +2 -0
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +1 -1
- package/dist/client/assets/index-DIDtPQb-.css +10 -0
- package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +61 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/lib/api.ts +21 -5
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/api-key-routes.ts +64 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +158 -104
- package/src/server/claude.ts +98 -3
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +44 -14
- package/src/server/engine/types.ts +7 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +498 -0
- package/src/server/security/enforce.ts +306 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +329 -0
- package/src/server/security/owner-only.ts +15 -0
- package/src/server/security/owner-routes.ts +43 -0
- package/src/server/security/policy.ts +413 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +174 -0
- package/src/server/sessions.ts +35 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-DIMte_k6.css +0 -10
- package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
package/src/client/lib/ws.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
+
log.warn('token fetch failed', err);
|
|
85
90
|
}
|
|
86
91
|
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
87
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
log.warn('bad frame', err);
|
|
122
128
|
}
|
|
123
129
|
};
|
|
124
130
|
|
|
125
131
|
this.ws.onclose = (e) => {
|
|
126
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
log.verbose('→', (msg as any).type);
|
|
162
170
|
this.ws.send(JSON.stringify(msg));
|
|
163
171
|
return true;
|
|
164
172
|
}
|
|
165
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/api-keys.ts
CHANGED
|
@@ -1,63 +1,201 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
}
|