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
@@ -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
+ }
@@ -0,0 +1,55 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { Ban, KeyRound, RefreshCw } from 'lucide-react';
3
+ import { ErrorBox, fmtTs, iconBtn, principalActions, selectCls, tdCls, thCls, useAction, type OwnerCall } from './shared';
4
+
5
+ interface Row { id: string; kind: string; lastRole?: string; lastSeen: string; turns: number; denies: number }
6
+
7
+ export function PrincipalsTab({ call, onPolicyChange, ownerIds }: { call: OwnerCall; onPolicyChange: () => Promise<void>; ownerIds: string[] }) {
8
+ const [days, setDays] = useState(7);
9
+ const [data, setData] = useState<{ principals: Row[]; truncated: boolean } | null>(null);
10
+ const { busy, error, run } = useAction();
11
+ const actions = principalActions(call, onPolicyChange, ownerIds);
12
+ const load = useCallback(() => run('load', async () => setData(await call(`/principals?days=${days}`))), [call, days]); // eslint-disable-line react-hooks/exhaustive-deps
13
+ useEffect(() => { load(); }, [load]);
14
+
15
+ return (
16
+ <div className="space-y-2">
17
+ <div className="flex items-center gap-2">
18
+ <span className="text-xs text-muted-foreground">Seen in the audit, last</span>
19
+ <select className={selectCls} value={days} onChange={(e) => setDays(Number(e.target.value))}>
20
+ {[1, 7, 30, 90].map((d) => <option key={d} value={d}>{d} day{d > 1 ? 's' : ''}</option>)}
21
+ </select>
22
+ <button className={iconBtn} title="Refresh" onClick={load}><RefreshCw className={`w-3.5 h-3.5 ${busy === 'load' ? 'animate-spin' : ''}`} /></button>
23
+ {data?.truncated && <span className="text-[10px] text-amber-500">scan capped — older activity omitted</span>}
24
+ </div>
25
+ <ErrorBox error={error} />
26
+ <div className="border rounded-lg overflow-x-auto">
27
+ <table className="w-full text-xs">
28
+ <thead className="bg-muted/40"><tr>
29
+ <th className={thCls}>Principal</th><th className={thCls}>Kind</th><th className={thCls}>Last role</th>
30
+ <th className={thCls}>Last seen</th><th className={thCls}>Turns</th><th className={thCls} title="Includes shadow (would-deny) verdicts">Denies</th><th className={thCls} />
31
+ </tr></thead>
32
+ <tbody className="divide-y">
33
+ {data?.principals.map((p) => (
34
+ <tr key={p.id}>
35
+ <td className={`${tdCls} font-mono break-all`}>{p.id}</td>
36
+ <td className={tdCls}>{p.kind}</td>
37
+ <td className={tdCls}>{p.lastRole ?? '—'}</td>
38
+ <td className={`${tdCls} whitespace-nowrap`}>{fmtTs(p.lastSeen)}</td>
39
+ <td className={tdCls}>{p.turns}</td>
40
+ <td className={`${tdCls} ${p.denies ? 'text-destructive' : ''}`}>{p.denies}</td>
41
+ <td className={`${tdCls} whitespace-nowrap text-right`}>
42
+ {actions.canRevoke(p.id) && (
43
+ <button className={`${iconBtn} mr-2`} title="Revoke tokens" disabled={!!busy} onClick={() => run(`rv-${p.id}`, () => actions.revoke(p.id))}><KeyRound className="w-3.5 h-3.5" /></button>
44
+ )}
45
+ {actions.canBlock(p.id) &&<button className={`${iconBtn} hover:text-destructive`} title="Block" disabled={!!busy} onClick={() => run(`bl-${p.id}`, () => actions.block(p.id))}><Ban className="w-3.5 h-3.5" /></button>}
46
+ </td>
47
+ </tr>
48
+ ))}
49
+ {data && !data.principals.length && <tr><td colSpan={7} className="text-center text-muted-foreground py-6">No principals in this window.</td></tr>}
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Check, Loader2, Plus, Trash2, Undo2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { CsvInput, ErrorBox, iconBtn, inputCls, selectCls, useAction, type Policy, type PolicyDoc, type Profile, type SavePolicy } from './shared';
6
+
7
+ /** The part of the policy a PUT writes (blocklist/tokensValidAfter have their own routes; the server keeps them). */
8
+ const editable = (p: Policy) => { const { blocklist: _b, tokensValidAfter: _t, ...rest } = p; return JSON.stringify(rest); };
9
+
10
+ /** A whole-document policy draft: edit locally, Save = PUT with the version the draft started from (server validates;
11
+ * errors stay on screen). A refresh never wipes edits: a clean draft follows it; a dirty one adopts the new version only
12
+ * when the editable part is unchanged (a block/revoke meanwhile) — otherwise Save still 409s instead of overwriting. */
13
+ export function usePolicyDraft(doc: PolicyDoc, save: SavePolicy, onDirty?: (dirty: boolean) => void) {
14
+ const [base, setBase] = useState(doc);
15
+ const [draft, setDraft] = useState<Policy>(() => structuredClone(doc.policy));
16
+ const dirty = editable(draft) !== editable(base.policy);
17
+ useEffect(() => {
18
+ if (!dirty || editable(doc.policy) === editable(draft)) { setBase(doc); setDraft(structuredClone(doc.policy)); }
19
+ else if (editable(doc.policy) === editable(base.policy)) setBase(doc);
20
+ }, [doc]); // eslint-disable-line react-hooks/exhaustive-deps
21
+ useEffect(() => { onDirty?.(dirty); }, [dirty, onDirty]);
22
+ useEffect(() => () => onDirty?.(false), [onDirty]);
23
+ const action = useAction();
24
+ const saveBar = (
25
+ <div className="flex items-center gap-2 sticky -bottom-3 z-10 -mx-3 -mb-3 px-3 pt-2 pb-3 bg-background border-t">
26
+ <Button size="sm" className="h-7 text-xs" disabled={!dirty || !!action.busy} onClick={() => action.run('save', () => save(draft, base.version))}>
27
+ {action.busy ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Check className="w-3 h-3 mr-1" />} Save policy
28
+ </Button>
29
+ <Button size="sm" variant="ghost" className="h-7 text-xs" disabled={!dirty} onClick={() => { setBase(doc); setDraft(structuredClone(doc.policy)); }}>
30
+ <Undo2 className="w-3 h-3 mr-1" /> Discard
31
+ </Button>
32
+ {dirty && <span className="text-[10px] text-amber-500">unsaved changes</span>}
33
+ </div>
34
+ );
35
+ return { draft, setDraft, error: action.error, saveBar };
36
+ }
37
+
38
+ export function RolesTab({ doc, save, onDirty }: { doc: PolicyDoc; save: SavePolicy; onDirty: (dirty: boolean) => void }) {
39
+ const { draft, setDraft, error, saveBar } = usePolicyDraft(doc, save, onDirty);
40
+ const [newRole, setNewRole] = useState('');
41
+ const [newProfile, setNewProfile] = useState('');
42
+ const profileNames = Object.keys(draft.profiles);
43
+
44
+ const setRole = (name: string, patch: Partial<Policy['roles'][string]>) =>
45
+ setDraft((d) => ({ ...d, roles: { ...d.roles, [name]: { ...d.roles[name], ...patch } } }));
46
+ const setProfile = (name: string, patch: Partial<Profile>) =>
47
+ setDraft((d) => ({ ...d, profiles: { ...d.profiles, [name]: { ...d.profiles[name], ...patch } } }));
48
+ const without = <T,>(o: Record<string, T>, k: string) => Object.fromEntries(Object.entries(o).filter(([n]) => n !== k));
49
+
50
+ return (
51
+ <div className="space-y-4">
52
+ <ErrorBox error={error} />
53
+ <section className="space-y-2">
54
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Roles</h3>
55
+ <p className="text-[11px] text-muted-foreground">Owners come only from the OWNERS env; this row only picks their rank and profile.</p>
56
+ <div className="border rounded-lg divide-y">
57
+ {Object.entries(draft.roles).sort((a, b) => b[1].rank - a[1].rank).map(([name, r]) => (
58
+ <div key={name} className="flex items-center gap-2 p-2">
59
+ <span className="text-sm font-medium w-28 truncate">{name}</span>
60
+ <label className="text-[10px] text-muted-foreground">rank</label>
61
+ <Input type="number" className={`${inputCls} w-20`} value={Number.isFinite(r.rank) ? r.rank : ''} onChange={(e) => setRole(name, { rank: e.target.value === '' ? NaN : Number(e.target.value) })} />
62
+ <label className="text-[10px] text-muted-foreground">profile</label>
63
+ <select className={selectCls} value={r.profile} onChange={(e) => setRole(name, { profile: e.target.value })}>
64
+ {!profileNames.includes(r.profile) && <option value={r.profile}>{r.profile} (missing)</option>}
65
+ {profileNames.map((p) => <option key={p} value={p}>{p}</option>)}
66
+ </select>
67
+ <div className="flex-1" />
68
+ {name !== 'owner' && (
69
+ <button className={`${iconBtn} hover:text-destructive`} title="Remove role" onClick={() => setDraft((d) => ({ ...d, roles: without(d.roles, name) }))}>
70
+ <Trash2 className="w-3.5 h-3.5" />
71
+ </button>
72
+ )}
73
+ </div>
74
+ ))}
75
+ </div>
76
+ <div className="flex items-center gap-2">
77
+ <Input className={`${inputCls} w-40`} placeholder="new role name" value={newRole} onChange={(e) => setNewRole(e.target.value.trim())} />
78
+ <Button size="sm" variant="outline" className="h-7 text-xs" disabled={!newRole || newRole in draft.roles}
79
+ onClick={() => { setDraft((d) => ({ ...d, roles: { ...d.roles, [newRole]: { rank: 10, profile: profileNames.includes('none') ? 'none' : profileNames[0] } } })); setNewRole(''); }}>
80
+ <Plus className="w-3 h-3 mr-1" /> Add role
81
+ </Button>
82
+ <div className="flex-1" />
83
+ <label className="text-[10px] text-muted-foreground">default role</label>
84
+ <select className={selectCls} value={draft.default} onChange={(e) => setDraft((d) => ({ ...d, default: e.target.value }))}>
85
+ {Object.keys(draft.roles).filter((n) => n !== 'owner').map((n) => <option key={n} value={n}>{n}</option>)}
86
+ </select>
87
+ </div>
88
+ </section>
89
+
90
+ <section className="space-y-2">
91
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Profiles</h3>
92
+ {Object.entries(draft.profiles).map(([name, p]) => (
93
+ <div key={name} className="border rounded-lg p-3 space-y-2">
94
+ <div className="flex items-center gap-2">
95
+ <span className="text-sm font-medium">{name}</span>
96
+ <div className="flex-1" />
97
+ <button className={`${iconBtn} hover:text-destructive`} title="Remove profile" onClick={() => setDraft((d) => ({ ...d, profiles: without(d.profiles, name) }))}>
98
+ <Trash2 className="w-3.5 h-3.5" />
99
+ </button>
100
+ </div>
101
+ <div className="grid grid-cols-[4.5rem_1fr] gap-x-2 gap-y-1.5 items-center">
102
+ {(['tools', 'mcps', 'env'] as const).map((f) => (
103
+ <FieldRow key={f} label={f}><CsvInput value={p[f]} onChange={(v) => setProfile(name, { [f]: v })} placeholder='comma-separated, "*" = all' /></FieldRow>
104
+ ))}
105
+ <FieldRow label="rate"><Input className={`${inputCls} w-28`} value={p.rate} placeholder="120/h" onChange={(e) => setProfile(name, { rate: e.target.value.trim() })} /></FieldRow>
106
+ <FieldRow label="readScope">
107
+ <select className={`${selectCls} w-28`} value={p.readScope} onChange={(e) => setProfile(name, { readScope: e.target.value as Profile['readScope'] })}>
108
+ {['all', 'own', 'none'].map((s) => <option key={s} value={s}>{s}</option>)}
109
+ </select>
110
+ </FieldRow>
111
+ <FieldRow label="outbound">
112
+ <input type="checkbox" className="h-4 w-4 justify-self-start" checked={p.outbound} onChange={(e) => setProfile(name, { outbound: e.target.checked })} />
113
+ </FieldRow>
114
+ </div>
115
+ </div>
116
+ ))}
117
+ <div className="flex items-center gap-2">
118
+ <Input className={`${inputCls} w-40`} placeholder="new profile name" value={newProfile} onChange={(e) => setNewProfile(e.target.value.trim())} />
119
+ <Button size="sm" variant="outline" className="h-7 text-xs" disabled={!newProfile || newProfile in draft.profiles}
120
+ onClick={() => { setDraft((d) => ({ ...d, profiles: { ...d.profiles, [newProfile]: { tools: [], mcps: [], env: [], outbound: false, readScope: 'none', rate: '0' } } })); setNewProfile(''); }}>
121
+ <Plus className="w-3 h-3 mr-1" /> Add profile
122
+ </Button>
123
+ </div>
124
+ </section>
125
+ {saveBar}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
131
+ return (<><label className="text-[11px] text-muted-foreground">{label}</label>{children}</>);
132
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Input } from '@/components/ui/input';
3
+ import type { OwnerCall } from '@/hooks/useOwner';
4
+
5
+ export type { OwnerCall };
6
+ export interface Profile { tools: string[]; mcps: string[]; env: string[]; outbound: boolean; readScope: 'all' | 'own' | 'none'; rate: string }
7
+ export interface Match { kind?: string; id?: string; emailIn?: string[]; domain?: string; verified?: boolean; [k: string]: unknown }
8
+ export interface Policy {
9
+ roles: Record<string, { rank: number; profile: string }>;
10
+ profiles: Record<string, Profile>;
11
+ bindings: { match: Match; role: string }[];
12
+ default: string;
13
+ blocklist: { match: Match; until: number | null; reason?: string }[];
14
+ tokensValidAfter: Record<string, number>;
15
+ }
16
+ export interface PolicyDoc { policy: Policy; version: string; valid: boolean; ownerIds?: string[] }
17
+ /** PUT the whole policy, guarded by the version the edit started from. */
18
+ export type SavePolicy = (policy: Policy, version: string) => Promise<void>;
19
+
20
+ export const KINDS = ['user', 'email', 'slack', 'apikey', 'internal', 'anonymous'];
21
+ export const selectCls = 'h-7 rounded-md border border-input bg-background px-2 text-xs';
22
+ export const inputCls = 'h-7 text-xs';
23
+ export const thCls = 'text-left font-medium text-muted-foreground px-2 py-1 whitespace-nowrap';
24
+ export const tdCls = 'px-2 py-1 align-top';
25
+ export const iconBtn = 'text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors';
26
+
27
+ export const fmtTs = (v: string | number) => new Date(v).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
28
+ export const matchLabel = (m: Match) => Object.entries(m).map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('|') : String(v)}`).join(' ');
29
+
30
+ /** One action at a time, with its error kept visible (useOwner's call already logged it). */
31
+ export function useAction() {
32
+ const [busy, setBusy] = useState<string | null>(null);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const run = async (key: string, fn: () => Promise<void>) => {
35
+ if (busy) return;
36
+ setBusy(key);
37
+ setError(null);
38
+ try { await fn(); } catch (e: any) { setError(e?.message ?? String(e)); } finally { setBusy(null); }
39
+ };
40
+ return { busy, error, run };
41
+ }
42
+
43
+ /** Server error; a policy validation error ("Invalid policy: a; b") renders as a list. */
44
+ export function ErrorBox({ error }: { error: string | null }) {
45
+ if (!error) return null;
46
+ const m = /^Invalid policy: (.*)$/s.exec(error);
47
+ return (
48
+ <div className="text-xs text-destructive border border-destructive/40 rounded-md p-2">
49
+ {m ? (<><p className="font-medium">Invalid policy — not saved:</p><ul className="list-disc pl-4">{m[1].split('; ').map((e, i) => <li key={i}>{e}</li>)}</ul></>) : error}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ /** Comma-separated list input; commits on blur so typing "a, " isn't normalized away mid-edit. */
55
+ export function CsvInput({ value, onChange, placeholder }: { value: string[] | undefined; onChange: (v: string[]) => void; placeholder?: string }) {
56
+ const joined = (value ?? []).join(', ');
57
+ const [text, setText] = useState(joined);
58
+ useEffect(() => setText(joined), [joined]);
59
+ return (
60
+ <Input className={inputCls} value={text} placeholder={placeholder} onChange={(e) => setText(e.target.value)}
61
+ onBlur={() => onChange(text.split(',').map((s) => s.trim()).filter(Boolean))} />
62
+ );
63
+ }
64
+
65
+ /** Row actions shared by Principals and Audit. Only `user:`/`internal:` principals carry revocable tokens. Both actions
66
+ * change the policy document, so `onPolicyChange` refreshes its version (else the next Save would 409). */
67
+ export function principalActions(call: OwnerCall, onPolicyChange: () => Promise<void>, ownerIds: string[] = []) {
68
+ return {
69
+ canRevoke: (id: string) => /^(user|internal):/.test(id) && id !== 'internal:agent-internal',
70
+ /** UI hint only (ownerIds from GET /policy) — the server refuses a block that matches an owner. */
71
+ canBlock: (id: string) => !ownerIds.includes(id),
72
+ revoke: async (id: string) => {
73
+ if (!confirm(`Revoke every token issued to ${id}? They must sign in again.`)) return;
74
+ await call('/tokens/revoke', 'POST', { principalId: id });
75
+ await onPolicyChange();
76
+ },
77
+ block: async (id: string) => {
78
+ const reason = prompt(`Block ${id}? Reason (optional):`);
79
+ if (reason === null) return;
80
+ await call('/blocks', 'POST', { match: { id }, until: null, reason });
81
+ await onPolicyChange();
82
+ },
83
+ };
84
+ }
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { onAuth, signOutUser, hasFirebase } from '@/lib/firebase';
3
+ import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
4
+ import { logger } from '@/lib/debug';
5
+
6
+ const log = logger.forComponent('useAuth');
3
7
 
4
8
  export interface AuthUser {
5
9
  uid: string;
@@ -47,13 +51,18 @@ export function useAuth(): AuthState {
47
51
  let m: 'local' | 'firebase' = hasFirebase ? 'firebase' : 'local';
48
52
  try {
49
53
  const r = await fetch('/api/auth/mode');
54
+ // The ONLY unauthenticated request the app makes, so it is the one chance to detect a hijacked
55
+ // backend BEFORE login. Without this a port-hijack just renders the login page forever with no
56
+ // explanation — the sign-in never succeeds and nothing on screen says why.
57
+ reportApiResponse('/api/auth/mode', r);
50
58
  if (r.ok) {
51
59
  const d = await r.json();
52
60
  m = d.provider === 'firebase' ? 'firebase' : 'local';
53
61
  setNeedsSetup(!!d.needsSetup);
54
62
  }
55
- } catch {
56
- /* fall back to firebase-presence heuristic */
63
+ } catch (err) {
64
+ reportApiFailure('/api/auth/mode', err);
65
+ log.warn('auth-mode probe failed — falling back to the firebase-presence heuristic', err);
57
66
  }
58
67
  setMode(m);
59
68
 
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Whether the signed-in caller is an owner — `isOwner` on `GET /api/config` (derived server-side, never persisted).
5
+ * `undefined` until known. A UI hint only: the server enforces owner-only mutations regardless.
6
+ */
7
+ export function useIsOwner(getToken: () => Promise<string | null>, enabled = true): boolean | undefined {
8
+ const [isOwner, setIsOwner] = useState<boolean>();
9
+
10
+ useEffect(() => {
11
+ if (!enabled) return;
12
+ let alive = true;
13
+ getToken().then((token) => {
14
+ if (!token || !alive) return;
15
+ fetch('/api/config', { headers: { Authorization: `Bearer ${token}` } })
16
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
17
+ .then((c: { isOwner?: boolean }) => { if (alive) setIsOwner(!!c.isOwner); })
18
+ .catch((e) => console.warn('[isOwner] load failed', e));
19
+ });
20
+ return () => { alive = false; };
21
+ }, [enabled, getToken]);
22
+
23
+ return isOwner;
24
+ }
@@ -42,7 +42,11 @@ export function useModules(getToken: () => Promise<string | null>, enabled: bool
42
42
  setLoading(true);
43
43
  setError(null);
44
44
  try {
45
- const data = await api<{ installed: InstalledModule[]; available: AvailableModule[] }>('/api/modules', getToken);
45
+ const data = await api<{ installed: InstalledModule[]; available: AvailableModule[] }>('/api/modules', getToken, {
46
+ // A 404 here is the documented "server predates /api/modules" probe below — a handled outcome,
47
+ // not a backend fault, so it must not raise the health banner.
48
+ expect: [404],
49
+ });
46
50
  setInstalled(data.installed ?? []);
47
51
  setAvailable(data.available ?? []);
48
52
  setUnsupported(false);
@@ -0,0 +1,20 @@
1
+ import { useCallback } from 'react';
2
+ import { api } from '@/lib/api';
3
+ import { useIsOwner } from './useIsOwner';
4
+
5
+ /** `/api/owner/*` caller: throws the server's `error` (validation text included), logged before it reaches the UI. */
6
+ export type OwnerCall = <T = any>(path: string, method?: string, body?: unknown) => Promise<T>;
7
+
8
+ /** Owner Console access: `isOwner` (UI hint — the server enforces) + an authenticated caller for `/api/owner/*`. */
9
+ export function useOwner(getToken: () => Promise<string | null>) {
10
+ const isOwner = useIsOwner(getToken);
11
+ const call = useCallback<OwnerCall>(async (path, method = 'GET', body) => {
12
+ try {
13
+ return await api(`/api/owner${path}`, getToken, { method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
14
+ } catch (e: any) {
15
+ console.warn(`[owner] ${method} ${path} failed: ${e?.message ?? e}`);
16
+ throw e;
17
+ }
18
+ }, [getToken]);
19
+ return { isOwner, call };
20
+ }
@@ -1,13 +1,29 @@
1
- /** Minimal authenticated JSON fetch helper. Throws `Error("<status> <statusText>")` or the server's `error` field. */
2
- export async function api<T>(path: string, getToken: () => Promise<string | null>, init?: RequestInit): Promise<T> {
1
+ import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
2
+
3
+ /** Minimal authenticated JSON fetch helper. Throws `Error("<status> <statusText>")` or the server's `error` field, with `status` set. */
4
+ export async function api<T>(
5
+ path: string,
6
+ getToken: () => Promise<string | null>,
7
+ /** `expect`: statuses this call site handles as normal — see `apiFetch`. Still throws; it only keeps
8
+ * the backend-health banner from flagging a by-design non-2xx. */
9
+ init?: RequestInit & { expect?: readonly number[] },
10
+ ): Promise<T> {
3
11
  const token = await getToken();
4
- const res = await fetch(path, {
5
- ...init,
6
- headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json', ...init?.headers },
7
- });
12
+ let res: Response;
13
+ try {
14
+ res = await fetch(path, {
15
+ ...init,
16
+ headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json', ...init?.headers },
17
+ });
18
+ } catch (err) {
19
+ // Single choke point: every caller of api() gets backend-fault surfacing with no call-site change.
20
+ reportApiFailure(path, err);
21
+ throw err;
22
+ }
23
+ reportApiResponse(path, res, init?.expect);
8
24
  if (!res.ok) {
9
25
  const body = await res.json().catch(() => ({}));
10
- throw new Error(body.error || `${res.status} ${res.statusText}`);
26
+ throw Object.assign(new Error(body.error || `${res.status} ${res.statusText}`), { status: res.status });
11
27
  }
12
28
  return res.json();
13
29
  }