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
@@ -4,6 +4,7 @@ import { Button } from './ui/button';
4
4
  import { Input } from './ui/input';
5
5
  import { Textarea } from './ui/textarea';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
7
+ import { useIsOwner } from '@/hooks/useIsOwner';
7
8
 
8
9
  type DefaultSkillEntry = string | { name: string; capped?: boolean | number };
9
10
 
@@ -43,8 +44,21 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
43
44
  const [dirty, setDirty] = useState(false);
44
45
  const [action, setAction] = useState<SidebarAction>(null);
45
46
  const [actionName, setActionName] = useState('');
47
+ const [error, setError] = useState<string | null>(null);
48
+ const isOwner = useIsOwner(getToken, open);
49
+ const canEdit = isOwner === true;
46
50
 
47
51
  const isSelectedBuiltin = selected ? builtins.includes(selected) : false;
52
+ const readOnly = isSelectedBuiltin || !canEdit;
53
+
54
+ /** True if the mutation succeeded; otherwise surfaces the server's error and returns false. */
55
+ const ok = async (res: Response, what: string) => {
56
+ if (res.ok) { setError(null); return true; }
57
+ const msg = (await res.json().catch((e) => { console.warn(`[SkillsManager] ${what} error response not JSON`, res.status, e); return {}; })).error || `HTTP ${res.status}`;
58
+ console.warn(`[SkillsManager] ${what} failed`, msg);
59
+ setError(`${what} failed: ${msg}`);
60
+ return false;
61
+ };
48
62
 
49
63
  const loadList = async () => {
50
64
  const token = await getToken();
@@ -72,11 +86,11 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
72
86
  };
73
87
 
74
88
  const save = async () => {
75
- if (!selected || isSelectedBuiltin) return;
89
+ if (!selected || readOnly) return;
76
90
  const token = await getToken();
77
91
  if (!token) return;
78
- await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
79
- setDirty(false);
92
+ const res = await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
93
+ if (await ok(res, 'Save')) setDirty(false);
80
94
  };
81
95
 
82
96
  const submitAction = async () => {
@@ -86,17 +100,18 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
86
100
  if (!token) return;
87
101
 
88
102
  if (action === 'create') {
89
- await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
103
+ const res = await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
104
+ if (!await ok(res, 'Create')) return;
90
105
  await loadList();
91
106
  await loadSkill(name);
92
107
  } else if (action === 'duplicate' && selected) {
93
108
  const res = await apiFetch(`/api/skills/${selected}/duplicate`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
94
- if (!res.ok) return;
109
+ if (!await ok(res, 'Duplicate')) return;
95
110
  await loadList();
96
111
  await loadSkill(name);
97
112
  } else if (action === 'rename' && selected) {
98
113
  const res = await apiFetch(`/api/skills/${selected}/rename`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
99
- if (!res.ok) return;
114
+ if (!await ok(res, 'Rename')) return;
100
115
  await loadList();
101
116
  await loadSkill(name);
102
117
  }
@@ -109,7 +124,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
109
124
  const token = await getToken();
110
125
  if (!token) return;
111
126
  const res = await apiFetch(`/api/skills/${name}`, token, { method: 'DELETE' });
112
- if (!res.ok) return;
127
+ if (!await ok(res, 'Delete')) return;
113
128
  if (selected === name) { setSelected(null); setContent(''); }
114
129
  await loadList();
115
130
  };
@@ -120,8 +135,8 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
120
135
  const next = isDefault(defaults, name)
121
136
  ? defaults.filter((d) => entryName(d) !== name)
122
137
  : [...defaults, name];
123
- await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
124
- setDefaults(next);
138
+ const res = await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
139
+ if (await ok(res, 'Update defaults')) setDefaults(next);
125
140
  };
126
141
 
127
142
 
@@ -163,9 +178,10 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
163
178
  onClick={() => loadSkill(s)}
164
179
  >
165
180
  <button
166
- className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30 hover:text-amber-400'}`}
167
- onClick={(e) => { e.stopPropagation(); toggleDefault(s); }}
168
- title={isDefault(defaults, s) ? 'Remove from defaults' : 'Set as default (always active)'}
181
+ className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30'} ${canEdit && !isDefault(defaults, s) ? 'hover:text-amber-400' : ''}`}
182
+ disabled={!canEdit}
183
+ onClick={(e) => { e.stopPropagation(); if (canEdit) toggleDefault(s); }}
184
+ title={!canEdit ? (isDefault(defaults, s) ? 'Default (only an owner can change)' : 'Only an owner can change defaults') : isDefault(defaults, s) ? 'Remove from defaults' : 'Set as default (always active)'}
169
185
  >
170
186
  <Star className={`w-3 h-3 ${isDefault(defaults, s) ? 'fill-current' : ''}`} />
171
187
  </button>
@@ -173,7 +189,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
173
189
  {builtins.includes(s) && (
174
190
  <span title="Built-in (read-only)"><Lock className="w-2.5 h-2.5 text-muted-foreground/40 shrink-0" /></span>
175
191
  )}
176
- {!builtins.includes(s) && (
192
+ {!builtins.includes(s) && canEdit && (
177
193
  <button
178
194
  className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity shrink-0"
179
195
  onClick={(e) => { e.stopPropagation(); remove(s); }}
@@ -200,11 +216,14 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
200
216
  <Button size="icon" className="h-7 w-7 shrink-0" onClick={submitAction}><Check className="w-3 h-3" /></Button>
201
217
  <Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={cancelAction}><X className="w-3 h-3" /></Button>
202
218
  </div>
203
- ) : (
219
+ ) : canEdit ? (
204
220
  <Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => startAction('create')}>
205
221
  <Plus className="w-3 h-3 mr-1" /> New skill
206
222
  </Button>
223
+ ) : (
224
+ <p className="text-[11px] text-muted-foreground text-center">{isOwner === false ? 'Read-only — only an owner can change skills' : ''}</p>
207
225
  )}
226
+ {error && <p className="text-[11px] text-destructive mt-1 break-words">{error}</p>}
208
227
  </div>
209
228
  </div>
210
229
 
@@ -219,7 +238,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
219
238
  <span>{selected}</span>
220
239
  </div>
221
240
  <div className="flex items-center gap-1.5">
222
- {isSelectedBuiltin && (
241
+ {readOnly && (
223
242
  <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-medium flex items-center gap-0.5">
224
243
  <Lock className="w-2.5 h-2.5" /> read-only
225
244
  </span>
@@ -234,14 +253,16 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
234
253
  </span>
235
254
  </>
236
255
  )}
237
- <Button
238
- variant="ghost" size="sm" className="h-6 text-xs px-1.5"
239
- onClick={() => startAction('duplicate')}
240
- title="Duplicate"
241
- >
242
- <Copy className="w-3 h-3" />
243
- </Button>
244
- {!isSelectedBuiltin && (
256
+ {canEdit && (
257
+ <Button
258
+ variant="ghost" size="sm" className="h-6 text-xs px-1.5"
259
+ onClick={() => startAction('duplicate')}
260
+ title="Duplicate"
261
+ >
262
+ <Copy className="w-3 h-3" />
263
+ </Button>
264
+ )}
265
+ {!readOnly && (
245
266
  <Button
246
267
  variant="ghost" size="sm" className="h-6 text-xs px-1.5"
247
268
  onClick={() => startAction('rename')}
@@ -250,7 +271,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
250
271
  <Pencil className="w-3 h-3" />
251
272
  </Button>
252
273
  )}
253
- {dirty && !isSelectedBuiltin && (
274
+ {dirty && !readOnly && (
254
275
  <Button size="sm" className="h-6 text-xs" onClick={save}>
255
276
  <Check className="w-3 h-3 mr-1" /> Save
256
277
  </Button>
@@ -259,9 +280,9 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
259
280
  </div>
260
281
  <Textarea
261
282
  value={content}
262
- onChange={(e) => { if (!isSelectedBuiltin) { setContent(e.target.value); setDirty(true); } }}
263
- readOnly={isSelectedBuiltin}
264
- className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${isSelectedBuiltin ? 'opacity-70 cursor-default' : ''}`}
283
+ onChange={(e) => { if (!readOnly) { setContent(e.target.value); setDirty(true); } }}
284
+ readOnly={readOnly}
285
+ className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${readOnly ? 'opacity-70 cursor-default' : ''}`}
265
286
  placeholder="Write the skill instructions here…"
266
287
  />
267
288
  </>
@@ -0,0 +1,87 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { Copy, Loader2, Plus, Trash2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { ErrorBox, fmtTs, iconBtn, inputCls, selectCls, tdCls, thCls, useAction, type OwnerCall } from './shared';
6
+
7
+ interface Key { id: string; keyPreview: string; label: string; email: string; createdAt: number; role?: string; expiresAt?: number }
8
+ const EXPIRY: [string, number][] = [['never', 0], ['1 day', 1], ['7 days', 7], ['30 days', 30], ['90 days', 90]];
9
+
10
+ export function ApiKeysTab({ call, roles }: { call: OwnerCall; roles: string[] }) {
11
+ const [keys, setKeys] = useState<Key[] | null>(null);
12
+ const [form, setForm] = useState({ label: '', role: '', days: 0 });
13
+ const [created, setCreated] = useState<{ key: string; label: string } | null>(null);
14
+ const { busy, error, run } = useAction();
15
+ const load = useCallback(async () => setKeys((await call<{ keys: Key[] }>('/api-keys')).keys), [call]);
16
+ useEffect(() => { run('load', load); }, [load]); // eslint-disable-line react-hooks/exhaustive-deps
17
+
18
+ const create = () => run('create', async () => {
19
+ const k = await call<{ key: string; label: string }>('/api-keys', 'POST', {
20
+ label: form.label || 'Unnamed', ...(form.role ? { role: form.role } : {}),
21
+ ...(form.days ? { expiresAt: Date.now() + form.days * 86_400_000 } : {}),
22
+ });
23
+ setCreated(k);
24
+ setForm({ label: '', role: '', days: 0 });
25
+ await load();
26
+ });
27
+ const copy = (text: string) => navigator.clipboard.writeText(text).catch((e) => console.warn('[owner] clipboard write failed', e));
28
+
29
+ return (
30
+ <div className="space-y-3">
31
+ <div className="flex flex-wrap items-center gap-2">
32
+ <Input className={`${inputCls} w-48`} placeholder="label" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} />
33
+ <select className={selectCls} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} title="Role cap (never above the creator)">
34
+ <option value="">role: creator's (uncapped)</option>
35
+ {roles.map((r) => <option key={r} value={r}>role cap: {r}</option>)}
36
+ </select>
37
+ <select className={selectCls} value={form.days} onChange={(e) => setForm({ ...form, days: Number(e.target.value) })}>
38
+ {EXPIRY.map(([l, d]) => <option key={d} value={d}>expires: {l}</option>)}
39
+ </select>
40
+ <Button size="sm" className="h-7 text-xs" disabled={!!busy} onClick={create}>
41
+ {busy === 'create' ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Plus className="w-3 h-3 mr-1" />} Create key
42
+ </Button>
43
+ </div>
44
+ {created && (
45
+ <div className="border border-amber-500/50 bg-amber-500/10 rounded-lg p-2 space-y-1">
46
+ <p className="text-xs font-medium">Key "{created.label}" — copy it now, it won't be shown again:</p>
47
+ <div className="flex items-center gap-2">
48
+ <code className="text-xs font-mono break-all flex-1 select-all">{created.key}</code>
49
+ <button className={iconBtn} title="Copy" onClick={() => copy(created.key)}><Copy className="w-3.5 h-3.5" /></button>
50
+ <Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setCreated(null)}>Done</Button>
51
+ </div>
52
+ </div>
53
+ )}
54
+ <ErrorBox error={error} />
55
+ <div className="border rounded-lg overflow-x-auto">
56
+ <table className="w-full text-xs">
57
+ <thead className="bg-muted/40"><tr>
58
+ <th className={thCls}>Label</th><th className={thCls}>Key</th><th className={thCls}>Creator</th><th className={thCls}>Role</th>
59
+ <th className={thCls}>Created</th><th className={thCls}>Expires</th><th className={thCls} />
60
+ </tr></thead>
61
+ <tbody className="divide-y">
62
+ {keys?.map((k) => {
63
+ const expired = k.expiresAt !== undefined && k.expiresAt <= Date.now();
64
+ return (
65
+ <tr key={k.id} className={expired ? 'opacity-50' : ''}>
66
+ <td className={tdCls}>{k.label}</td>
67
+ <td className={`${tdCls} font-mono`}>{k.keyPreview}</td>
68
+ <td className={tdCls}>{k.email}</td>
69
+ <td className={tdCls}>{k.role ?? <span className="text-muted-foreground">creator's</span>}</td>
70
+ <td className={`${tdCls} whitespace-nowrap`}>{fmtTs(k.createdAt)}</td>
71
+ <td className={`${tdCls} whitespace-nowrap`}>{k.expiresAt ? `${fmtTs(k.expiresAt)}${expired ? ' (expired)' : ''}` : 'never'}</td>
72
+ <td className={`${tdCls} text-right`}>
73
+ <button className={`${iconBtn} hover:text-destructive`} title="Revoke" disabled={!!busy}
74
+ onClick={() => { if (confirm(`Revoke key "${k.label}" (${k.keyPreview})?`)) run(`rm-${k.id}`, async () => { await call(`/api-keys/${k.id}`, 'DELETE'); await load(); }); }}>
75
+ <Trash2 className="w-3.5 h-3.5" />
76
+ </button>
77
+ </td>
78
+ </tr>
79
+ );
80
+ })}
81
+ {keys && !keys.length && <tr><td colSpan={7} className="text-center text-muted-foreground py-6">No API keys.</td></tr>}
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,110 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { Ban, ExternalLink, KeyRound, Loader2, RefreshCw, ShieldAlert, ShieldCheck } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { ErrorBox, fmtTs, iconBtn, inputCls, principalActions, selectCls, tdCls, thCls, useAction, type OwnerCall } from './shared';
6
+
7
+ interface Rec { ts: string; type: string; principal?: string; role?: string; sessionId?: string; target?: string; reason?: string; hash: string }
8
+ interface Verify { ok: boolean; lines: number; brokenAt?: { file: string; line: number; reason: string }; planted?: string[] }
9
+ /** A failed verify: the real break (file:line, or just the file for a whole-file reason) plus any planted month entries. */
10
+ const verifyText = ({ brokenAt: b, planted }: Verify) => [
11
+ b && !(b.reason === 'not-regular-file' && planted?.length) ? `chain broken at ${b.file || 'audit dir'}${b.line ? `:${b.line}` : ''} (${b.reason})` : '',
12
+ planted?.length ? `planted entries (not regular files): ${planted.join(', ')}` : '',
13
+ ].filter(Boolean).join(' · ') || 'chain verify failed';
14
+ const TYPES = ['auth.allow', 'auth.deny', 'role.resolve', 'turn.start', 'turn.end', 'tool.allow', 'tool.deny', 'guard.limit', 'guard.block',
15
+ 'escalate', 'policy.change', 'policy.tamper', 'key.create', 'key.revoke', 'token.revoke', 'session.delete'];
16
+ const PAGE = 100;
17
+
18
+ export function AuditTab({ call, onOpenSession, onPolicyChange, ownerIds }: { call: OwnerCall; onOpenSession: (sessionId: string) => void | Promise<void>; onPolicyChange: () => Promise<void>; ownerIds: string[] }) {
19
+ const [f, setF] = useState({ principal: '', type: '', from: '', to: '' });
20
+ const [items, setItems] = useState<Rec[]>([]);
21
+ const [cursor, setCursor] = useState<string | undefined>();
22
+ const [verify, setVerify] = useState<Verify | null>(null);
23
+ const [deleted, setDeleted] = useState<ReadonlySet<string>>(new Set());
24
+ const { busy, error, run } = useAction();
25
+ const actions = principalActions(call, onPolicyChange, ownerIds);
26
+ /** Run a per-conversation action; a 404 means it's gone (removed by hand on the box): hide its row actions and say so. */
27
+ const onSession = (key: string, id: string, fn: () => void | Promise<void>) => run(key, async () => {
28
+ try { await fn(); } catch (e: any) {
29
+ if (e?.status !== 404) throw e;
30
+ setDeleted((s) => new Set(s).add(id));
31
+ throw new Error('Conversation no longer exists');
32
+ }
33
+ });
34
+
35
+ const query = (after?: string) => {
36
+ const q = new URLSearchParams({ limit: String(PAGE) });
37
+ if (f.principal.trim()) q.set('principal', f.principal.trim());
38
+ if (f.type) q.set('type', f.type);
39
+ if (f.from) q.set('from', new Date(f.from).toISOString());
40
+ if (f.to) q.set('to', new Date(f.to).toISOString());
41
+ if (after) q.set('cursor', after);
42
+ return call<{ items: Rec[]; nextCursor?: string }>(`/audit?${q}`);
43
+ };
44
+ const search = useCallback(() => run('load', async () => { const p = await query(); setItems(p.items); setCursor(p.nextCursor); }), [call, f]); // eslint-disable-line react-hooks/exhaustive-deps
45
+ const more = () => run('more', async () => { const p = await query(cursor); setItems((x) => [...x, ...p.items]); setCursor(p.nextCursor); });
46
+ const check = () => run('verify', async () => setVerify(await call<Verify>('/audit/verify')));
47
+ useEffect(() => { search(); check(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
48
+
49
+ return (
50
+ <div className="space-y-2">
51
+ <div className="flex flex-wrap items-center gap-2">
52
+ <Input className={`${inputCls} w-56`} placeholder="principal (exact id)" value={f.principal} onChange={(e) => setF({ ...f, principal: e.target.value })} onKeyDown={(e) => e.key === 'Enter' && search()} />
53
+ <select className={selectCls} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })}>
54
+ <option value="">all types</option>
55
+ {TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
56
+ </select>
57
+ <label className="text-[10px] text-muted-foreground">from</label>
58
+ <input type="datetime-local" className={selectCls} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
59
+ <label className="text-[10px] text-muted-foreground">to</label>
60
+ <input type="datetime-local" className={selectCls} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
61
+ <Button size="sm" className="h-7 text-xs" disabled={!!busy} onClick={search}>
62
+ {busy === 'load' ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />} Search
63
+ </Button>
64
+ <div className="flex-1" />
65
+ {verify && (
66
+ <button onClick={check} title="Re-verify the hash chain"
67
+ className={`flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${verify.ok ? 'border-green-500/50 text-green-600 dark:text-green-400' : 'border-destructive text-destructive'}`}>
68
+ {verify.ok ? <ShieldCheck className="w-3.5 h-3.5" /> : <ShieldAlert className="w-3.5 h-3.5" />}
69
+ {verify.ok ? `chain verified · ${verify.lines} lines` : verifyText(verify)}
70
+ </button>
71
+ )}
72
+ </div>
73
+ <ErrorBox error={error} />
74
+ <div className="border rounded-lg overflow-x-auto">
75
+ <table className="w-full text-xs">
76
+ <thead className="bg-muted/40"><tr>
77
+ <th className={thCls}>Time</th><th className={thCls}>Type</th><th className={thCls}>Principal</th><th className={thCls}>Role</th>
78
+ <th className={thCls}>Target / reason</th><th className={thCls} />
79
+ </tr></thead>
80
+ <tbody className="divide-y">
81
+ {items.map((r) => (
82
+ <tr key={r.hash}>
83
+ <td className={`${tdCls} whitespace-nowrap`} title={r.ts}>{fmtTs(r.ts)}</td>
84
+ <td className={`${tdCls} font-mono whitespace-nowrap ${/deny|block|tamper/.test(r.type) ? 'text-destructive' : ''}`}>{r.type}</td>
85
+ <td className={`${tdCls} font-mono break-all`}>
86
+ {r.principal ? <button className="hover:underline text-left" title="Filter by this principal" onClick={() => setF({ ...f, principal: r.principal! })}>{r.principal}</button> : '—'}
87
+ </td>
88
+ <td className={tdCls}>{r.role ?? '—'}</td>
89
+ <td className={`${tdCls} break-all`}>{[r.target, r.reason].filter(Boolean).join(' · ') || '—'}</td>
90
+ <td className={`${tdCls} whitespace-nowrap text-right`}>
91
+ {r.sessionId && !deleted.has(r.sessionId) && <button className={`${iconBtn} mr-2`} title="Open session" disabled={!!busy} onClick={() => onSession('open', r.sessionId!, () => onOpenSession(r.sessionId!))}><ExternalLink className="w-3.5 h-3.5" /></button>}
92
+ {r.principal && actions.canRevoke(r.principal) && (
93
+ <button className={`${iconBtn} mr-2`} title="Revoke tokens" disabled={!!busy} onClick={() => run('rv', () => actions.revoke(r.principal!))}><KeyRound className="w-3.5 h-3.5" /></button>
94
+ )}
95
+ {r.principal && actions.canBlock(r.principal) && <button className={`${iconBtn} hover:text-destructive`} title="Block principal" disabled={!!busy} onClick={() => run('bl', () => actions.block(r.principal!))}><Ban className="w-3.5 h-3.5" /></button>}
96
+ </td>
97
+ </tr>
98
+ ))}
99
+ {!items.length && busy !== 'load' && <tr><td colSpan={6} className="text-center text-muted-foreground py-6">No audit records match.</td></tr>}
100
+ </tbody>
101
+ </table>
102
+ </div>
103
+ {cursor && (
104
+ <Button size="sm" variant="outline" className="h-7 text-xs" disabled={!!busy} onClick={more}>
105
+ {busy === 'more' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />} Load older
106
+ </Button>
107
+ )}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,110 @@
1
+ import { useState } from 'react';
2
+ import { ArrowDown, ArrowUp, FlaskConical, Loader2, Plus, Trash2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { usePolicyDraft } from './RolesTab';
6
+ import { CsvInput, ErrorBox, iconBtn, inputCls, KINDS, selectCls, useAction, type Match, type OwnerCall, type Policy, type PolicyDoc, type SavePolicy } from './shared';
7
+
8
+ const KNOWN = new Set(['kind', 'id', 'emailIn', 'domain', 'verified']);
9
+
10
+ export function BindingsTab({ doc, save, call, onDirty }: { doc: PolicyDoc; save: SavePolicy; call: OwnerCall; onDirty: (dirty: boolean) => void }) {
11
+ const { draft, setDraft, error, saveBar } = usePolicyDraft(doc, save, onDirty);
12
+ const roles = Object.keys(draft.roles).filter((r) => r !== 'owner');
13
+
14
+ const update = (fn: (b: Policy['bindings']) => Policy['bindings']) => setDraft((d) => ({ ...d, bindings: fn([...d.bindings]) }));
15
+ /** Empty values are dropped from the match (absent = "any"); unknown match keys are preserved untouched. */
16
+ const setMatch = (i: number, key: string, v: unknown) => update((b) => {
17
+ const m: Match = { ...b[i].match };
18
+ if (v === undefined || v === '' || (Array.isArray(v) && !v.length)) delete m[key]; else m[key] = v;
19
+ b[i] = { ...b[i], match: m };
20
+ return b;
21
+ });
22
+ const move = (i: number, to: number) => update((b) => { const [x] = b.splice(i, 1); b.splice(to, 0, x); return b; });
23
+
24
+ return (
25
+ <div className="space-y-4">
26
+ <ErrorBox error={error} />
27
+ <p className="text-[11px] text-muted-foreground">Ordered — the first matching binding decides the role; no match → default role <b>{draft.default}</b>.</p>
28
+ <div className="space-y-2">
29
+ {draft.bindings.map((b, i) => {
30
+ const extra = Object.entries(b.match).filter(([k]) => !KNOWN.has(k));
31
+ return (
32
+ <div key={i} className="border rounded-lg p-2 space-y-1.5">
33
+ <div className="flex items-center gap-2">
34
+ <span className="text-[10px] font-mono text-muted-foreground w-5">#{i + 1}</span>
35
+ <label className="text-[10px] text-muted-foreground">role</label>
36
+ <select className={selectCls} value={b.role} onChange={(e) => update((x) => { x[i] = { ...x[i], role: e.target.value }; return x; })}>
37
+ {!roles.includes(b.role) && <option value={b.role}>{b.role} (invalid)</option>}
38
+ {roles.map((r) => <option key={r} value={r}>{r}</option>)}
39
+ </select>
40
+ <div className="flex-1" />
41
+ <button className={iconBtn} title="Move up" disabled={i === 0} onClick={() => move(i, i - 1)}><ArrowUp className="w-3.5 h-3.5" /></button>
42
+ <button className={iconBtn} title="Move down" disabled={i === draft.bindings.length - 1} onClick={() => move(i, i + 1)}><ArrowDown className="w-3.5 h-3.5" /></button>
43
+ <button className={`${iconBtn} hover:text-destructive`} title="Remove" onClick={() => update((x) => x.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></button>
44
+ </div>
45
+ <div className="flex flex-wrap items-center gap-2">
46
+ <select className={selectCls} title="kind" value={b.match.kind ?? ''} onChange={(e) => setMatch(i, 'kind', e.target.value)}>
47
+ <option value="">any kind</option>
48
+ {KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
49
+ </select>
50
+ <select className={selectCls} title="verified" value={b.match.verified === undefined ? '' : String(b.match.verified)} onChange={(e) => setMatch(i, 'verified', e.target.value === '' ? undefined : e.target.value === 'true')}>
51
+ <option value="">verified: any</option><option value="true">verified</option><option value="false">unverified</option>
52
+ </select>
53
+ <Input className={`${inputCls} w-40`} placeholder="domain" value={b.match.domain ?? ''} onChange={(e) => setMatch(i, 'domain', e.target.value.trim())} />
54
+ <Input className={`${inputCls} w-48`} placeholder="id (e.g. slack:U123)" value={b.match.id ?? ''} onChange={(e) => setMatch(i, 'id', e.target.value.trim())} />
55
+ <div className="flex-1 min-w-48"><CsvInput value={b.match.emailIn} onChange={(v) => setMatch(i, 'emailIn', v)} placeholder="emails (comma-separated)" /></div>
56
+ </div>
57
+ {extra.length > 0 && <p className="text-[10px] font-mono text-muted-foreground">also matches: {JSON.stringify(Object.fromEntries(extra))}</p>}
58
+ </div>
59
+ );
60
+ })}
61
+ <Button size="sm" variant="outline" className="h-7 text-xs" disabled={!roles.length}
62
+ onClick={() => update((b) => [...b, { match: { kind: 'user' }, role: roles.includes('guest') ? 'guest' : roles[roles.length - 1] }])}>
63
+ <Plus className="w-3 h-3 mr-1" /> Add binding
64
+ </Button>
65
+ </div>
66
+ {saveBar}
67
+ <TestPrincipal call={call} />
68
+ </div>
69
+ );
70
+ }
71
+
72
+ interface TestResult { principal: string; role: string; rank: number; profile: string; blocked: { reason?: string } | null }
73
+
74
+ function TestPrincipal({ call }: { call: OwnerCall }) {
75
+ const [d, setD] = useState({ kind: 'user', email: '', id: '', verified: true, lane: '', role: '' });
76
+ const [result, setResult] = useState<TestResult | null>(null);
77
+ const { busy, error, run } = useAction();
78
+ const set = (patch: Partial<typeof d>) => { setD((x) => ({ ...x, ...patch })); setResult(null); };
79
+ const test = () => run('test', async () => {
80
+ setResult(await call<TestResult>('/policy/test', 'POST', {
81
+ kind: d.kind, verified: d.verified, ...(d.email ? { email: d.email } : {}), ...(d.id ? { id: d.id } : {}),
82
+ ...(d.lane ? { lane: d.lane } : {}), ...(d.role ? { role: d.role } : {}),
83
+ }));
84
+ });
85
+ return (
86
+ <section className="border rounded-lg p-3 space-y-2 bg-muted/20">
87
+ <h3 className="text-xs font-medium flex items-center gap-1.5"><FlaskConical className="w-3.5 h-3.5" /> Test a principal <span className="text-muted-foreground font-normal">(against the SAVED policy)</span></h3>
88
+ <div className="flex flex-wrap items-center gap-2">
89
+ <select className={selectCls} value={d.kind} onChange={(e) => set({ kind: e.target.value })}>
90
+ {KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
91
+ </select>
92
+ <Input className={`${inputCls} w-52`} placeholder={d.kind === 'apikey' ? 'creator email' : 'email'} value={d.email} onChange={(e) => set({ email: e.target.value.trim() })} />
93
+ <Input className={`${inputCls} w-32`} placeholder="id (optional)" value={d.id} onChange={(e) => set({ id: e.target.value.trim() })} />
94
+ {d.kind === 'internal' && <Input className={`${inputCls} w-28`} placeholder="lane" value={d.lane} onChange={(e) => set({ lane: e.target.value.trim() })} />}
95
+ {d.kind === 'apikey' && <Input className={`${inputCls} w-28`} placeholder="key role cap" value={d.role} onChange={(e) => set({ role: e.target.value.trim() })} />}
96
+ <label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={d.verified} onChange={(e) => set({ verified: e.target.checked })} /> verified</label>
97
+ <Button size="sm" className="h-7 text-xs" disabled={!!busy} onClick={test}>
98
+ {busy && <Loader2 className="w-3 h-3 mr-1 animate-spin" />} Resolve
99
+ </Button>
100
+ </div>
101
+ <ErrorBox error={error} />
102
+ {result && (
103
+ <p className="text-xs font-mono">
104
+ {result.principal} → role <b>{result.role}</b> · rank {result.rank} · profile <b>{result.profile}</b>
105
+ {result.blocked && <span className="text-destructive"> · BLOCKED{result.blocked.reason ? ` (${result.blocked.reason})` : ''}</span>}
106
+ </p>
107
+ )}
108
+ </section>
109
+ );
110
+ }
@@ -0,0 +1,96 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { ErrorBox, fmtTs, iconBtn, inputCls, matchLabel, selectCls, tdCls, thCls, useAction, type Match, type OwnerCall } from './shared';
6
+
7
+ interface Blocks {
8
+ manual: { index: number; match: Match; until: number | null; reason?: string }[];
9
+ auto: { key: string; until: number; reason: string; at: number }[];
10
+ }
11
+ const TTL: [string, number][] = [['permanent', 0], ['1 hour', 3600], ['24 hours', 86_400], ['7 days', 604_800], ['30 days', 2_592_000]];
12
+
13
+ export function BlocklistTab({ call, onPolicyChange }: { call: OwnerCall; onPolicyChange: () => Promise<void> }) {
14
+ const [blocks, setBlocks] = useState<Blocks | null>(null);
15
+ const [form, setForm] = useState({ field: 'id', value: '', ttl: 0, reason: '' });
16
+ const { busy, error, run } = useAction();
17
+ const load = useCallback(async () => setBlocks(await call<Blocks>('/blocks')), [call]);
18
+ useEffect(() => { run('load', load); }, [load]); // eslint-disable-line react-hooks/exhaustive-deps
19
+
20
+ const add = () => run('add', async () => {
21
+ const v = form.value.trim();
22
+ const match: Match = form.field === 'emailIn' ? { emailIn: [v] } : { [form.field]: v };
23
+ await call('/blocks', 'POST', { match, until: form.ttl ? Math.floor(Date.now() / 1000) + form.ttl : null, reason: form.reason });
24
+ setForm({ ...form, value: '', reason: '' });
25
+ await Promise.all([load(), onPolicyChange()]);
26
+ });
27
+ const remove = (body: object) => run('rm', async () => { await call('/blocks', 'DELETE', body); await Promise.all([load(), onPolicyChange()]); });
28
+
29
+ return (
30
+ <div className="space-y-4">
31
+ <div className="flex flex-wrap items-center gap-2">
32
+ <select className={selectCls} value={form.field} onChange={(e) => setForm({ ...form, field: e.target.value })}>
33
+ <option value="id">principal id</option><option value="emailIn">email</option><option value="domain">domain</option>
34
+ </select>
35
+ <Input className={`${inputCls} w-56`} placeholder={form.field === 'id' ? 'user:x@y.com / slack:U123' : form.field === 'domain' ? 'spam.tld' : 'x@y.com'} value={form.value} onChange={(e) => setForm({ ...form, value: e.target.value })} />
36
+ <select className={selectCls} value={form.ttl} onChange={(e) => setForm({ ...form, ttl: Number(e.target.value) })}>
37
+ {TTL.map(([l, s]) => <option key={s} value={s}>{l}</option>)}
38
+ </select>
39
+ <Input className={`${inputCls} w-40`} placeholder="reason" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />
40
+ <Button size="sm" className="h-7 text-xs" disabled={!!busy || !form.value.trim()} onClick={add}>
41
+ {busy === 'add' ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Plus className="w-3 h-3 mr-1" />} Block
42
+ </Button>
43
+ <button className={iconBtn} title="Refresh" onClick={() => run('load', load)}><RefreshCw className={`w-3.5 h-3.5 ${busy === 'load' ? 'animate-spin' : ''}`} /></button>
44
+ </div>
45
+ <ErrorBox error={error} />
46
+
47
+ <section className="space-y-1">
48
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Manual (policy)</h3>
49
+ <div className="border rounded-lg overflow-x-auto">
50
+ <table className="w-full text-xs">
51
+ <thead className="bg-muted/40"><tr><th className={thCls}>Match</th><th className={thCls}>Until</th><th className={thCls}>Reason</th><th className={thCls} /></tr></thead>
52
+ <tbody className="divide-y">
53
+ {blocks?.manual.map((b) => {
54
+ const expired = b.until !== null && b.until * 1000 <= Date.now();
55
+ return (
56
+ <tr key={b.index} className={expired ? 'opacity-50' : ''}>
57
+ <td className={`${tdCls} font-mono break-all`}>{matchLabel(b.match)}</td>
58
+ <td className={`${tdCls} whitespace-nowrap`}>{b.until === null ? 'permanent' : `${fmtTs(b.until * 1000)}${expired ? ' (expired)' : ''}`}</td>
59
+ <td className={tdCls}>{b.reason || '—'}</td>
60
+ <td className={`${tdCls} text-right`}>
61
+ <button className={`${iconBtn} hover:text-destructive`} title="Remove" disabled={!!busy} onClick={() => remove({ source: 'manual', index: b.index, match: b.match })}><Trash2 className="w-3.5 h-3.5" /></button>
62
+ </td>
63
+ </tr>
64
+ );
65
+ })}
66
+ {blocks && !blocks.manual.length && <tr><td colSpan={4} className="text-center text-muted-foreground py-4">No manual blocks.</td></tr>}
67
+ </tbody>
68
+ </table>
69
+ </div>
70
+ </section>
71
+
72
+ <section className="space-y-1">
73
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Automatic (guard)</h3>
74
+ <div className="border rounded-lg overflow-x-auto">
75
+ <table className="w-full text-xs">
76
+ <thead className="bg-muted/40"><tr><th className={thCls}>Key</th><th className={thCls}>Since</th><th className={thCls}>Until</th><th className={thCls}>Reason</th><th className={thCls} /></tr></thead>
77
+ <tbody className="divide-y">
78
+ {blocks?.auto.map((b) => (
79
+ <tr key={b.key}>
80
+ <td className={`${tdCls} font-mono break-all`}>{b.key}</td>
81
+ <td className={`${tdCls} whitespace-nowrap`}>{fmtTs(b.at)}</td>
82
+ <td className={`${tdCls} whitespace-nowrap`}>{fmtTs(b.until)}</td>
83
+ <td className={tdCls}>{b.reason}</td>
84
+ <td className={`${tdCls} text-right`}>
85
+ <button className={`${iconBtn} hover:text-destructive`} title="Clear" disabled={!!busy} onClick={() => remove({ source: 'auto', key: b.key })}><Trash2 className="w-3.5 h-3.5" /></button>
86
+ </td>
87
+ </tr>
88
+ ))}
89
+ {blocks && !blocks.auto.length && <tr><td colSpan={5} className="text-center text-muted-foreground py-4">No active auto-blocks.</td></tr>}
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+ </section>
94
+ </div>
95
+ );
96
+ }