shraga 0.1.112 → 0.1.114

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 (73) hide show
  1. package/README.md +10 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/scripts/agent-once.ts +2 -0
  4. package/defaults/security/policy.example.json +27 -0
  5. package/defaults/skills/mcp-server.md +15 -14
  6. package/defaults/skills/platform.md +3 -3
  7. package/defaults/system-prompt.md +1 -1
  8. package/dist/client/assets/index-CFxKc8dH.css +10 -0
  9. package/dist/client/assets/index-DwmlZAEF.js +2009 -0
  10. package/dist/client/index.html +2 -2
  11. package/package.json +3 -2
  12. package/src/client/App.tsx +33 -8
  13. package/src/client/components/BackendStatusBanner.tsx +62 -0
  14. package/src/client/components/ConfigPanel.tsx +83 -15
  15. package/src/client/components/ConversationHeader.tsx +5 -1
  16. package/src/client/components/McpManager.tsx +26 -9
  17. package/src/client/components/Sidebar.tsx +16 -1
  18. package/src/client/components/SkillsManager.tsx +48 -27
  19. package/src/client/components/owner/ApiKeysTab.tsx +87 -0
  20. package/src/client/components/owner/AuditTab.tsx +110 -0
  21. package/src/client/components/owner/BindingsTab.tsx +110 -0
  22. package/src/client/components/owner/BlocklistTab.tsx +96 -0
  23. package/src/client/components/owner/OwnerConsole.tsx +87 -0
  24. package/src/client/components/owner/PrincipalsTab.tsx +55 -0
  25. package/src/client/components/owner/RolesTab.tsx +132 -0
  26. package/src/client/components/owner/shared.tsx +84 -0
  27. package/src/client/hooks/useAuth.ts +11 -2
  28. package/src/client/hooks/useIsOwner.ts +24 -0
  29. package/src/client/hooks/useModules.ts +5 -1
  30. package/src/client/hooks/useOwner.ts +20 -0
  31. package/src/client/lib/api.ts +23 -7
  32. package/src/client/lib/backendHealth.ts +230 -0
  33. package/src/client/lib/debug.ts +48 -0
  34. package/src/client/lib/sessionApi.ts +24 -8
  35. package/src/client/lib/ws.ts +21 -13
  36. package/src/index.ts +10 -2
  37. package/src/scripts/harden-audit.sh +55 -0
  38. package/src/server/agent-config.ts +34 -0
  39. package/src/server/api-key-routes.ts +66 -0
  40. package/src/server/api-keys.ts +181 -43
  41. package/src/server/auth.ts +113 -47
  42. package/src/server/boot.ts +161 -104
  43. package/src/server/claude.ts +102 -26
  44. package/src/server/data-sync.ts +56 -6
  45. package/src/server/directives.ts +2 -4
  46. package/src/server/engine/claude-code.ts +78 -17
  47. package/src/server/engine/types.ts +7 -0
  48. package/src/server/hooks.ts +19 -0
  49. package/src/server/mcp-oauth.ts +24 -5
  50. package/src/server/mcp-server.ts +55 -25
  51. package/src/server/modules/routes.ts +2 -6
  52. package/src/server/notify-owners.ts +5 -17
  53. package/src/server/owners.ts +14 -0
  54. package/src/server/scheduler/builtins.ts +3 -1
  55. package/src/server/scheduler/runner.ts +3 -0
  56. package/src/server/security/audit.ts +505 -0
  57. package/src/server/security/enforce.ts +343 -0
  58. package/src/server/security/escalate.ts +194 -0
  59. package/src/server/security/guard.ts +337 -0
  60. package/src/server/security/owner-only.ts +34 -0
  61. package/src/server/security/owner-routes.ts +285 -0
  62. package/src/server/security/policy.ts +416 -0
  63. package/src/server/security/principal.ts +80 -0
  64. package/src/server/security/revocation.ts +50 -0
  65. package/src/server/security/runtime.ts +226 -0
  66. package/src/server/sessions.ts +38 -0
  67. package/src/server/shraga-config.ts +13 -0
  68. package/src/server/slack/bot.ts +44 -11
  69. package/src/server/slack/context-cache.ts +40 -7
  70. package/src/server/webhook-lane/feature.ts +17 -6
  71. package/src/shared/models.ts +11 -0
  72. package/dist/client/assets/index-DIMte_k6.css +0 -10
  73. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -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
  }
@@ -28,7 +28,25 @@ const _origGetKey = (JwtHelper as any)['getGooglePublicKey'].bind(JwtHelper);
28
28
  return p;
29
29
  };
30
30
  import { dataPath } from './paths.ts';
31
- import { validateApiKey } from './api-keys.ts';
31
+ import { apiKeyPrincipal, validateApiKey } from './api-keys.ts';
32
+ import { isOwnerEmail } from './owners.ts';
33
+ import { fromAuthUser, fromInternal, type Principal } from './security/principal.ts';
34
+ import { security } from './security/runtime.ts';
35
+ import { firebaseIssuedAt, tokenRevoked } from './security/revocation.ts';
36
+
37
+ // Issued-at (`iat`) + revocation: every token format below carries `iat` for NEW tokens, in a shape the pre-iat
38
+ // verifier REJECTS (never mis-parses into a different identity) — so for these tokens a rollback only forces re-auth.
39
+ // API keys are NOT covered: pre-hashing code breaks on hashed entries, so a rollback invalidates every key (see the
40
+ // Rollback note in api-keys.ts). Old tokens
41
+ // without iat still verify here, with an implied iat (see security/revocation.ts). Checked against
42
+ // policy.tokensValidAfter for the token's principal: an in-memory lookup, no I/O.
43
+ const nowSec = () => Math.floor(Date.now() / 1000);
44
+ const hmacEq = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
45
+ const userPrincipalId = (uid: string, email: string) => fromAuthUser({ uid, email }).id;
46
+
47
+ /** Fixed TTLs (seconds). Legacy tokens without iat are treated as issued at `exp - TTL`. */
48
+ export const MCP_TOKEN_TTL = { access: 3600, refresh: 60 * 60 * 24 * 30 } as const;
49
+ export const LOCAL_TOKEN_TTL = 30 * 24 * 3600;
32
50
 
33
51
  /** Server secret for signing scoped internal tokens — stable per startup. */
34
52
  const INTERNAL_SECRET = process.env.INTERNAL_API_TOKEN || randomBytes(32).toString('hex');
@@ -36,23 +54,36 @@ process.env.INTERNAL_API_TOKEN = INTERNAL_SECRET;
36
54
  const tmpDir = join(dataPath('..'), '.tmp');
37
55
  try { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, '.internal-token'), INTERNAL_SECRET); } catch (e: any) { console.error('[auth] failed to write .internal-token:', e.message); }
38
56
 
39
- /** Sign a scoped internal token embedding user identity. Agent subprocess uses this as INTERNAL_API_TOKEN — single env var carries both auth + identity. */
57
+ /** Sign a scoped internal token embedding user identity. Agent subprocess uses this as INTERNAL_API_TOKEN — single env var carries both auth + identity.
58
+ * Format `<sig64>.<iat>:<uid>:<email>`, sig = HMAC("v2:<iat>:<uid>:<email>"). (Legacy: `<sig64>:<uid>:<email>`, no iat.) */
40
59
  export function signInternalToken(uid: string, email: string): string {
41
- const payload = `${uid}:${email}`;
42
- const sig = createHmac('sha256', INTERNAL_SECRET).update(payload).digest('hex');
43
- return `${sig}:${payload}`;
60
+ const rest = `${nowSec()}:${uid}:${email}`;
61
+ const sig = createHmac('sha256', INTERNAL_SECRET).update(`v2:${rest}`).digest('hex');
62
+ return `${sig}.${rest}`;
44
63
  }
45
64
 
46
- /** Verify and extract user identity from a scoped internal token. Falls back to legacy global token check. */
65
+ /** Verify and extract user identity from a scoped internal token. Falls back to legacy global token check.
66
+ * Revocation: rejected if issued before tokensValidAfter of `internal:<uid>` or of the user it acts for. */
47
67
  function verifyInternalToken(token: string): { uid: string; email: string } | null {
68
+ const scoped = (uid: string, email: string, iat: number) =>
69
+ tokenRevoked([fromInternal({ uid }).id, userPrincipalId(uid, email)], iat) ? null : { uid, email };
70
+ if (token[64] === '.') {
71
+ const rest = token.slice(65);
72
+ if (!hmacEq(token.slice(0, 64), createHmac('sha256', INTERNAL_SECRET).update(`v2:${rest}`).digest('hex'))) return null;
73
+ const [iatStr, uid, ...emailParts] = rest.split(':');
74
+ const iat = Number(iatStr);
75
+ if (!Number.isInteger(iat) || !uid || !emailParts.length) return null;
76
+ return scoped(uid, emailParts.join(':'), iat);
77
+ }
48
78
  const firstColon = token.indexOf(':');
49
79
  if (firstColon === 64) {
50
80
  const sig = token.slice(0, 64);
51
81
  const payload = token.slice(65);
52
82
  const expected = createHmac('sha256', INTERNAL_SECRET).update(payload).digest('hex');
53
- if (sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
83
+ if (hmacEq(sig, expected)) {
54
84
  const sepIdx = payload.indexOf(':');
55
- if (sepIdx > 0) return { uid: payload.slice(0, sepIdx), email: payload.slice(sepIdx + 1) };
85
+ // Legacy scoped token: no exp, no iat ⇒ issued at 0, so any revocation of the principal kills it.
86
+ if (sepIdx > 0) return scoped(payload.slice(0, sepIdx), payload.slice(sepIdx + 1), 0);
56
87
  }
57
88
  return null;
58
89
  }
@@ -91,9 +122,10 @@ function getMcpSecret(): string {
91
122
  * Provider-agnostic: identity is established upstream by requireAuth (Firebase today,
92
123
  * email-password later) — this only carries the resulting {uid, email}. No storage needed.
93
124
  */
94
- export function signMcpToken(uid: string, email: string, kind: 'access' | 'refresh' = 'access', ttlSec = 3600): string {
95
- const exp = Math.floor(Date.now() / 1000) + ttlSec;
96
- const payload = `mcp:${kind}:${uid}:${email}:${exp}`;
125
+ /** Payload `mcp2:kind:uid:email:iat:exp`. (Legacy `mcp:kind:uid:email:exp` — the pre-iat verifier rejects `mcp2`.) */
126
+ export function signMcpToken(uid: string, email: string, kind: 'access' | 'refresh' = 'access', ttlSec: number = MCP_TOKEN_TTL[kind]): string {
127
+ const iat = nowSec();
128
+ const payload = `mcp2:${kind}:${uid}:${email}:${iat}:${iat + ttlSec}`;
97
129
  const sig = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
98
130
  return `mcp_${sig}.${Buffer.from(payload).toString('base64url')}`;
99
131
  }
@@ -107,12 +139,19 @@ export function verifyMcpToken(token: string): { uid: string; email: string; kin
107
139
  let payload: string;
108
140
  try { payload = Buffer.from(body.slice(dot + 1), 'base64url').toString(); } catch { return null; }
109
141
  const expected = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
110
- if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
111
- const parts = payload.split(':'); // mcp:kind:uid:email:exp (uid/email never contain ':')
112
- if (parts[0] !== 'mcp' || parts.length < 5) return null;
142
+ if (!hmacEq(sig, expected)) return null;
143
+ const parts = payload.split(':'); // tag:kind:uid:email[:iat]:exp (uid never contains ':')
144
+ const v2 = parts[0] === 'mcp2';
145
+ if (!(v2 || parts[0] === 'mcp') || parts.length < (v2 ? 6 : 5)) return null;
146
+ const kind = parts[1];
147
+ if (kind !== 'access' && kind !== 'refresh') return null;
113
148
  const exp = Number(parts[parts.length - 1]);
114
- if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
115
- return { kind: parts[1] as 'access' | 'refresh', uid: parts[2], email: parts.slice(3, parts.length - 1).join(':') };
149
+ if (!exp || nowSec() > exp) return null;
150
+ const iat = v2 ? Number(parts[parts.length - 2]) : exp - MCP_TOKEN_TTL[kind];
151
+ if (!Number.isInteger(iat)) return null;
152
+ const uid = parts[2], email = parts.slice(3, parts.length - (v2 ? 2 : 1)).join(':');
153
+ if (tokenRevoked(userPrincipalId(uid, email), iat)) return null;
154
+ return { kind, uid, email };
116
155
  }
117
156
 
118
157
  const WHITELIST_PATH = dataPath('whitelist.json');
@@ -131,20 +170,19 @@ export interface AuthUser {
131
170
  email: string;
132
171
  /** Owners can see all sessions/schedules across users (view-only bypass — mutations still restricted to owner of record). */
133
172
  isOwner: boolean;
173
+ /** Who is calling, per auth branch (login → user, api key → apikey, internal token → internal). */
174
+ principal: Principal;
134
175
  }
135
176
 
136
- /** Owners from env: OWNERS="email1,email2" (case-insensitive). */
137
- function loadOwners(): string[] {
138
- return (process.env.OWNERS ?? '')
139
- .split(',')
140
- .map((s) => s.trim().toLowerCase())
141
- .filter(Boolean);
142
- }
143
-
144
- function isOwnerEmail(email?: string): boolean {
145
- // Emailless tokens (e.g. anonymous Firebase users) are never owners — guard the .toLowerCase() crash.
146
- return !!email && loadOwners().includes(email.toLowerCase());
147
- }
177
+ // Owner comes from an interactive login, a server-minted scoped internal token acting for one, or an UNCAPPED API key
178
+ // (a delegated login credential — only an interactive login can mint a key, e.g. `shraga term`'s /cli-auth consent).
179
+ // A role-capped key is never owner. OWNERS is read per call, so removing the creator takes effect on the next request.
180
+ const ownerCapable = (p: Principal) => p.kind === 'user' || p.kind === 'internal' || (p.kind === 'apikey' && typeof p.attrs.role !== 'string');
181
+ const authUser = (uid: string, email: string, principal: Principal): AuthUser => ({
182
+ uid, email, principal, isOwner: ownerCapable(principal) && isOwnerEmail(email),
183
+ });
184
+ const internalUser = (t: { uid: string; email: string }) => authUser(t.uid, t.email, fromInternal(t));
185
+ const apiKeyUser = (k: { id: string; uid: string; email: string; role?: string }) => authUser(k.uid, k.email, apiKeyPrincipal(k));
148
186
 
149
187
  export async function verifyToken(token: string): Promise<AuthUser> {
150
188
  const projectId = JSON.parse(process.env.FIREBASE_CONFIG_PROD ?? process.env.VITE_FIREBASE_CONFIG_PROD ?? '{}').projectId;
@@ -161,7 +199,11 @@ export async function verifyToken(token: string): Promise<AuthUser> {
161
199
  if (whitelist.length > 0 && !whitelist.includes(payload.email)) {
162
200
  throw new Error('User not in whitelist');
163
201
  }
164
- return { uid: payload.user_id || payload.sub, email: payload.email, isOwner: isOwnerEmail(payload.email) };
202
+ const uid = payload.user_id || payload.sub;
203
+ const principal = fromAuthUser({ uid, email: payload.email });
204
+ // auth_time = sign-in time; the hourly-refreshed iat would outlive a revocation.
205
+ if (tokenRevoked(principal.id, firebaseIssuedAt(payload))) throw new Error('Token revoked — sign in again');
206
+ return authUser(uid, payload.email, principal);
165
207
  }
166
208
 
167
209
  // ── Pluggable auth provider ──────────────────────────────────────────────────
@@ -201,15 +243,17 @@ function localSecret(): string {
201
243
  } catch { _localSecret = INTERNAL_SECRET; }
202
244
  return _localSecret;
203
245
  }
204
- /** Issue a signed local session token: sha_<sig>.<base64url(email:exp)>. */
246
+ /** Issue a signed local session token: sha_<sig>.<base64url(email:iat:exp)>, sig = HMAC("v2:" + payload).
247
+ * (Legacy: payload `email:exp`, sig over the payload alone — the "v2:" domain separation makes the pre-iat verifier
248
+ * reject new tokens instead of reading `email:iat` as the email.) */
205
249
  export function localLogin(email: string, password: string): string | null {
206
250
  const rec = loadLocalUsers().find((u) => u.email === email);
207
251
  if (!rec) return null;
208
252
  const { hash } = hashPw(password, rec.salt);
209
253
  if (hash.length !== rec.hash.length || !timingSafeEqual(Buffer.from(hash), Buffer.from(rec.hash))) return null;
210
- const exp = Math.floor(Date.now() / 1000) + 30 * 24 * 3600;
211
- const payload = `${email}:${exp}`;
212
- const sig = createHmac('sha256', localSecret()).update(payload).digest('hex');
254
+ const iat = nowSec();
255
+ const payload = `${email}:${iat}:${iat + LOCAL_TOKEN_TTL}`;
256
+ const sig = createHmac('sha256', localSecret()).update(`v2:${payload}`).digest('hex');
213
257
  return `sha_${sig}.${Buffer.from(payload).toString('base64url')}`;
214
258
  }
215
259
  function verifyLocalToken(token: string): AuthUser {
@@ -219,12 +263,18 @@ function verifyLocalToken(token: string): AuthUser {
219
263
  if (dot < 0) throw new Error('Malformed token');
220
264
  const sig = body.slice(0, dot);
221
265
  const payload = Buffer.from(body.slice(dot + 1), 'base64url').toString();
222
- const expected = createHmac('sha256', localSecret()).update(payload).digest('hex');
223
- if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error('Bad token signature');
266
+ const v2 = hmacEq(sig, createHmac('sha256', localSecret()).update(`v2:${payload}`).digest('hex'));
267
+ if (!v2 && !hmacEq(sig, createHmac('sha256', localSecret()).update(payload).digest('hex'))) throw new Error('Bad token signature');
224
268
  const sep = payload.lastIndexOf(':');
225
- const email = payload.slice(0, sep);
226
- if (Math.floor(Date.now() / 1000) > Number(payload.slice(sep + 1))) throw new Error('Token expired — sign in again');
227
- return { uid: email, email, isOwner: isOwnerEmail(email) };
269
+ const exp = Number(payload.slice(sep + 1));
270
+ let head = payload.slice(0, sep), iat = exp - LOCAL_TOKEN_TTL;
271
+ if (v2) { const s = head.lastIndexOf(':'); iat = Number(head.slice(s + 1)); head = head.slice(0, s); }
272
+ const email = head;
273
+ if (!Number.isFinite(exp) || nowSec() > exp) throw new Error('Token expired — sign in again');
274
+ const principal = fromAuthUser({ uid: email, email });
275
+ if (!Number.isInteger(iat)) throw new Error('Malformed token');
276
+ if (tokenRevoked(principal.id, iat)) throw new Error('Token revoked — sign in again');
277
+ return authUser(email, email, principal);
228
278
  }
229
279
 
230
280
  /** Dispatch bearer verification to the active provider. */
@@ -239,10 +289,10 @@ export async function authenticateToken(token: string | undefined | null): Promi
239
289
  if (!token) return null;
240
290
  if (token.startsWith('uck_')) {
241
291
  const identity = validateApiKey(token);
242
- return identity ? { uid: identity.uid, email: identity.email, isOwner: isOwnerEmail(identity.email) } : null;
292
+ return identity ? apiKeyUser(identity) : null;
243
293
  }
244
294
  const internal = verifyInternalToken(token);
245
- if (internal) return { uid: internal.uid, email: internal.email, isOwner: isOwnerEmail(internal.email) };
295
+ if (internal) return internalUser(internal);
246
296
  try { return await verifyBearer(token); } catch { return null; }
247
297
  }
248
298
 
@@ -251,26 +301,42 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
251
301
  if (internalToken) {
252
302
  const identity = verifyInternalToken(internalToken);
253
303
  if (identity) {
254
- (req as any).user = { uid: identity.uid, email: identity.email, isOwner: isOwnerEmail(identity.email) } as AuthUser;
304
+ const user = internalUser(identity);
305
+ (req as any).user = user;
306
+ security()?.authAllow(user.principal, 'http:internal');
255
307
  return next();
256
308
  }
257
309
  }
258
310
 
259
311
  const token = req.headers.authorization?.replace('Bearer ', '') || (req.query.token as string);
260
- if (!token) return void res.status(401).json({ error: 'Missing token' });
312
+ if (!token) {
313
+ security()?.authDeny('http', 'missing-token', req.ip);
314
+ return void res.status(401).json({ error: 'Missing token' });
315
+ }
261
316
 
262
317
  // API key auth (uck_…)
263
318
  if (token.startsWith('uck_')) {
264
319
  const identity = validateApiKey(token);
265
- if (!identity) return void res.status(401).json({ error: 'Invalid API key' });
266
- (req as any).user = { uid: identity.uid, email: identity.email, isOwner: isOwnerEmail(identity.email) } as AuthUser;
320
+ if (!identity) {
321
+ security()?.authDeny('http:apikey', 'invalid-api-key', req.ip);
322
+ return void res.status(401).json({ error: 'Invalid API key' });
323
+ }
324
+ const user = apiKeyUser(identity);
325
+ (req as any).user = user;
326
+ security()?.authAllow(user.principal, 'http:apikey');
267
327
  return next();
268
328
  }
269
329
 
330
+ let user: AuthUser;
270
331
  try {
271
- (req as any).user = await verifyBearer(token);
272
- next();
332
+ user = await verifyBearer(token);
273
333
  } catch (err: any) {
274
- return void res.status(err?.message?.includes('whitelist') ? 403 : 401).json({ error: err.message });
334
+ const whitelisted = err?.message?.includes('whitelist');
335
+ const reason = whitelisted ? 'not-whitelisted' : err?.message?.includes('revoked') ? 'token-revoked' : 'invalid-bearer';
336
+ security()?.authDeny('http:bearer', reason, req.ip);
337
+ return void res.status(whitelisted ? 403 : 401).json({ error: err.message });
275
338
  }
339
+ (req as any).user = user;
340
+ security()?.authAllow(user.principal, 'http:bearer');
341
+ next();
276
342
  }