shraga 0.1.113 → 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 (40) hide show
  1. package/README.md +8 -1
  2. package/defaults/scripts/agent-once.ts +2 -0
  3. package/defaults/security/policy.example.json +27 -0
  4. package/defaults/skills/mcp-server.md +1 -7
  5. package/defaults/skills/platform.md +2 -2
  6. package/defaults/system-prompt.md +1 -1
  7. package/dist/client/assets/index-CFxKc8dH.css +10 -0
  8. package/dist/client/assets/index-DwmlZAEF.js +2009 -0
  9. package/dist/client/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/client/components/ConfigPanel.tsx +22 -0
  12. package/src/client/components/Sidebar.tsx +16 -1
  13. package/src/client/components/owner/ApiKeysTab.tsx +87 -0
  14. package/src/client/components/owner/AuditTab.tsx +110 -0
  15. package/src/client/components/owner/BindingsTab.tsx +110 -0
  16. package/src/client/components/owner/BlocklistTab.tsx +96 -0
  17. package/src/client/components/owner/OwnerConsole.tsx +87 -0
  18. package/src/client/components/owner/PrincipalsTab.tsx +55 -0
  19. package/src/client/components/owner/RolesTab.tsx +132 -0
  20. package/src/client/components/owner/shared.tsx +84 -0
  21. package/src/client/hooks/useOwner.ts +20 -0
  22. package/src/client/lib/api.ts +2 -2
  23. package/src/index.ts +10 -2
  24. package/src/server/agent-config.ts +34 -0
  25. package/src/server/api-key-routes.ts +5 -3
  26. package/src/server/boot.ts +4 -1
  27. package/src/server/claude.ts +4 -23
  28. package/src/server/data-sync.ts +1 -0
  29. package/src/server/engine/claude-code.ts +34 -3
  30. package/src/server/security/audit.ts +12 -5
  31. package/src/server/security/enforce.ts +43 -6
  32. package/src/server/security/guard.ts +8 -0
  33. package/src/server/security/owner-only.ts +21 -2
  34. package/src/server/security/owner-routes.ts +246 -4
  35. package/src/server/security/policy.ts +4 -1
  36. package/src/server/security/runtime.ts +53 -1
  37. package/src/server/sessions.ts +3 -0
  38. package/src/server/shraga-config.ts +13 -0
  39. package/dist/client/assets/index-DIDtPQb-.css +0 -10
  40. package/dist/client/assets/index-DJ0AgGIu.js +0 -1969
@@ -13,8 +13,8 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-DJ0AgGIu.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DIDtPQb-.css">
16
+ <script type="module" crossorigin src="/assets/index-DwmlZAEF.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-CFxKc8dH.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -19,6 +19,7 @@ interface AgentConfig {
19
19
  skillDiscovery?: boolean;
20
20
  thinking?: 'adaptive' | 'enabled' | 'disabled';
21
21
  effort?: 'low' | 'medium' | 'high' | 'max';
22
+ allowUntrustedReplies?: boolean;
22
23
  }
23
24
 
24
25
  const FALLBACK_MODELS: EngineModel[] = [
@@ -343,6 +344,27 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
343
344
  </button>
344
345
  </div>
345
346
 
347
+ <div className="flex items-center justify-between">
348
+ <div className="pr-4">
349
+ <label className="text-sm font-medium">Reply to untrusted senders</label>
350
+ <p className="text-xs text-muted-foreground">
351
+ Off: inbound messages from unverified or unknown senders are still processed and escalated to
352
+ owners, but the agent sends them no automatic reply.
353
+ </p>
354
+ </div>
355
+ <button
356
+ type="button"
357
+ role="switch"
358
+ disabled={!canGlobal}
359
+ aria-checked={config.allowUntrustedReplies === true}
360
+ aria-label="Reply to untrusted senders"
361
+ onClick={() => setConfig((c) => ({ ...c, allowUntrustedReplies: c.allowUntrustedReplies === true ? false : true }))}
362
+ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${config.allowUntrustedReplies === true ? 'bg-primary' : 'bg-muted'}`}
363
+ >
364
+ <span className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${config.allowUntrustedReplies === true ? 'translate-x-5' : 'translate-x-0'}`} />
365
+ </button>
366
+ </div>
367
+
346
368
  <div>
347
369
  <label className="text-sm font-medium mb-1.5 block">System Prompt (optional)</label>
348
370
  <Textarea
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
- import { Plus, MessageSquare, Hash, AtSign, MessageCircle } from 'lucide-react';
2
+ import { Plus, MessageSquare, Hash, AtSign, MessageCircle, ShieldCheck } from 'lucide-react';
3
3
  import { Button } from './ui/button';
4
4
  import { ScrollArea } from './ui/scroll-area';
5
5
  import { cn } from '@/lib/utils';
@@ -10,6 +10,9 @@ import type { UnreadSession } from '@/hooks/useUnread';
10
10
  import type { AgentSocket } from '@/lib/ws';
11
11
  import { useSessionList, type SessionRow as Session, type ChatsFilter } from '@/hooks/useSessionList';
12
12
  import { CLIENT_BUILD_VERSION } from '@/lib/build-version';
13
+ import { useOwner } from '@/hooks/useOwner';
14
+ import { api } from '@/lib/api';
15
+ import { OwnerConsole } from './owner/OwnerConsole';
13
16
 
14
17
  interface Props {
15
18
  getToken: () => Promise<string | null>;
@@ -75,6 +78,7 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
75
78
  const [filter, setFilter] = useState<ChatsFilter>(() => (localStorage.getItem(FILTER_KEY) as ChatsFilter) || 'mine');
76
79
  const [unreadOnly, setUnreadOnly] = useState(() => localStorage.getItem(UNREAD_FILTER_KEY) === 'true');
77
80
  const [version, setVersion] = useState<string>('');
81
+ const { isOwner, call: ownerCall } = useOwner(getToken);
78
82
  const activeRef = useRef<HTMLButtonElement | null>(null);
79
83
 
80
84
  function changeFilter(f: ChatsFilter) {
@@ -242,6 +246,17 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
242
246
  </ScrollArea>
243
247
 
244
248
  <div className="px-4 py-2 flex flex-col items-center gap-1">
249
+ {isOwner && (
250
+ <OwnerConsole
251
+ call={ownerCall}
252
+ onOpenSession={async (id) => { await api(`/api/sessions/${encodeURIComponent(id)}/meta`, getToken); onSelect(id); }}
253
+ trigger={
254
+ <button className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors" title="Roles, bindings, keys, blocklist, audit">
255
+ <ShieldCheck className="w-3.5 h-3.5" /> Owner Console
256
+ </button>
257
+ }
258
+ />
259
+ )}
245
260
  <MachineStats socket={socket ?? null} getToken={getToken} />
246
261
  {slots.sidebarExtras?.()}
247
262
  {version && (
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ import { useCallback, useEffect, useState, type ReactNode } from 'react';
2
+ import { RefreshCw, ShieldCheck } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
5
+ import { cn } from '@/lib/utils';
6
+ import { ApiKeysTab } from './ApiKeysTab';
7
+ import { AuditTab } from './AuditTab';
8
+ import { BindingsTab } from './BindingsTab';
9
+ import { BlocklistTab } from './BlocklistTab';
10
+ import { PrincipalsTab } from './PrincipalsTab';
11
+ import { RolesTab } from './RolesTab';
12
+ import { ErrorBox, type OwnerCall, type Policy, type PolicyDoc } from './shared';
13
+
14
+ const TABS = [['roles', 'Roles & Profiles'], ['bindings', 'Bindings'], ['principals', 'Principals'], ['keys', 'API keys'], ['blocks', 'Blocklist'], ['audit', 'Audit']] as const;
15
+ type Tab = (typeof TABS)[number][0];
16
+ const TAB_KEY = 'owner-console-tab';
17
+
18
+ function storedTab(): Tab {
19
+ try {
20
+ const t = localStorage.getItem(TAB_KEY);
21
+ return TABS.some(([id]) => id === t) ? (t as Tab) : 'roles';
22
+ } catch { return 'roles'; }
23
+ }
24
+
25
+ /** Owner-only security console. Every write goes through `/api/owner/*` (validated + audited server-side). */
26
+ export function OwnerConsole({ call, onOpenSession, trigger }: { call: OwnerCall; onOpenSession: (sessionId: string) => void | Promise<void>; trigger: ReactNode }) {
27
+ const [open, setOpen] = useState(false);
28
+ const [tab, setTab] = useState<Tab>(storedTab);
29
+ const [doc, setDoc] = useState<PolicyDoc | null>(null);
30
+ const [loadError, setLoadError] = useState<string | null>(null);
31
+
32
+ const loadPolicy = useCallback(async () => {
33
+ try { setDoc(await call<PolicyDoc>('/policy')); setLoadError(null); } catch (e: any) { setLoadError(e?.message ?? String(e)); }
34
+ }, [call]);
35
+ useEffect(() => { if (open) loadPolicy(); }, [open, loadPolicy]);
36
+
37
+ const savePolicy = async (policy: Policy, version: string) => {
38
+ await call('/policy', 'PUT', { policy, version });
39
+ await loadPolicy();
40
+ };
41
+ /** Unsaved Roles/Bindings edits live in the mounted tab — leaving it discards them, so ask first. */
42
+ const [dirty, setDirty] = useState(false);
43
+ const leaveOk = () => !dirty || confirm('You have unsaved policy edits. Discard them?');
44
+ const selectTab = (t: Tab) => {
45
+ if (t === tab || !leaveOk()) return;
46
+ setTab(t);
47
+ try { localStorage.setItem(TAB_KEY, t); } catch (e) { console.warn('[owner] could not persist tab', e); }
48
+ };
49
+ const roles = doc ? Object.keys(doc.policy.roles).filter((r) => r !== 'owner') : [];
50
+
51
+ return (
52
+ <Dialog open={open} onOpenChange={(o) => { if (o || leaveOk()) setOpen(o); }}>
53
+ <DialogTrigger asChild>{trigger}</DialogTrigger>
54
+ <DialogContent className="max-w-[95vw] sm:max-w-5xl h-[85dvh] flex flex-col gap-0 p-0">
55
+ <DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
56
+ <DialogTitle className="flex items-center gap-2">
57
+ <ShieldCheck className="w-4 h-4" /> Owner Console
58
+ <Button variant="ghost" size="icon" className="h-6 w-6 ml-1" title="Reload policy" onClick={loadPolicy}><RefreshCw className="w-3.5 h-3.5" /></Button>
59
+ </DialogTitle>
60
+ <div className="flex gap-1 overflow-x-auto pt-2" role="tablist">
61
+ {TABS.map(([id, label]) => (
62
+ <button key={id} role="tab" aria-selected={tab === id} onClick={() => selectTab(id)}
63
+ className={cn('text-xs px-2.5 py-1 rounded-md whitespace-nowrap transition-colors', tab === id ? 'bg-accent text-foreground font-medium' : 'text-muted-foreground hover:text-foreground')}>
64
+ {label}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ </DialogHeader>
69
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
70
+ <ErrorBox error={loadError} />
71
+ {doc && !doc.valid && (
72
+ <p className="text-xs text-destructive border border-destructive/40 rounded-md p-2">
73
+ The policy file is invalid — the server is failing closed (owners only). Saving a valid policy restores it.
74
+ </p>
75
+ )}
76
+ {(tab === 'roles' || tab === 'bindings') && !doc && !loadError && <p className="text-sm text-muted-foreground text-center py-8">Loading…</p>}
77
+ {tab === 'roles' && doc && <RolesTab doc={doc} save={savePolicy} onDirty={setDirty} />}
78
+ {tab === 'bindings' && doc && <BindingsTab doc={doc} save={savePolicy} call={call} onDirty={setDirty} />}
79
+ {tab === 'principals' && <PrincipalsTab call={call} onPolicyChange={loadPolicy} ownerIds={doc?.ownerIds ?? []} />}
80
+ {tab === 'keys' && <ApiKeysTab call={call} roles={roles} />}
81
+ {tab === 'blocks' && <BlocklistTab call={call} onPolicyChange={loadPolicy} />}
82
+ {tab === 'audit' && <AuditTab call={call} onPolicyChange={loadPolicy} ownerIds={doc?.ownerIds ?? []} onOpenSession={async (id) => { await onOpenSession(id); setOpen(false); }} />}
83
+ </div>
84
+ </DialogContent>
85
+ </Dialog>
86
+ );
87
+ }