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.
Files changed (57) hide show
  1. package/README.md +2 -0
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +1 -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 +98 -3
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +2 -4
  31. package/src/server/engine/claude-code.ts +44 -14
  32. package/src/server/engine/types.ts +7 -0
  33. package/src/server/hooks.ts +19 -0
  34. package/src/server/mcp-oauth.ts +24 -5
  35. package/src/server/mcp-server.ts +55 -25
  36. package/src/server/modules/routes.ts +2 -6
  37. package/src/server/notify-owners.ts +5 -17
  38. package/src/server/owners.ts +14 -0
  39. package/src/server/scheduler/builtins.ts +3 -1
  40. package/src/server/scheduler/runner.ts +3 -0
  41. package/src/server/security/audit.ts +498 -0
  42. package/src/server/security/enforce.ts +306 -0
  43. package/src/server/security/escalate.ts +194 -0
  44. package/src/server/security/guard.ts +329 -0
  45. package/src/server/security/owner-only.ts +15 -0
  46. package/src/server/security/owner-routes.ts +43 -0
  47. package/src/server/security/policy.ts +413 -0
  48. package/src/server/security/principal.ts +80 -0
  49. package/src/server/security/revocation.ts +50 -0
  50. package/src/server/security/runtime.ts +174 -0
  51. package/src/server/sessions.ts +35 -0
  52. package/src/server/slack/bot.ts +44 -11
  53. package/src/server/slack/context-cache.ts +40 -7
  54. package/src/server/webhook-lane/feature.ts +17 -6
  55. package/src/shared/models.ts +11 -0
  56. package/dist/client/assets/index-DIMte_k6.css +0 -10
  57. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -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
  }