portal-mojo 0.2.18 → 0.2.20

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 (56) hide show
  1. package/README.md +12 -0
  2. package/docs/README.md +1 -1
  3. package/docs/admin-assistant.md +152 -1
  4. package/docs/admin-credentials.md +38 -0
  5. package/docs/admin-dns-certificates.md +160 -0
  6. package/docs/admin-dns.md +16 -0
  7. package/docs/admin-fleet-configuration.md +98 -2
  8. package/docs/admin-identity-directories.md +12 -0
  9. package/docs/admin-identity-users.md +10 -0
  10. package/docs/admin-modals.md +28 -0
  11. package/docs/auth-pages.md +18 -6
  12. package/docs/auth.md +16 -2
  13. package/docs/collection-select.md +14 -0
  14. package/docs/grouping-and-fmt.md +1 -1
  15. package/package.json +1 -1
  16. package/src/account/PasskeyList.tsx +1 -1
  17. package/src/account/credential-models.ts +2 -1
  18. package/src/admin/assistant/AssistantSetupPage.tsx +196 -0
  19. package/src/admin/assistant/index.ts +2 -0
  20. package/src/admin/assistant/setup.ts +174 -0
  21. package/src/admin/core/index.ts +14 -4
  22. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  23. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  24. package/src/admin/credentials/group-api-keys.tsx +5 -3
  25. package/src/admin/dns/CertificateDetail.tsx +8 -3
  26. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  27. package/src/admin/dns/DomainDetail.tsx +7 -1
  28. package/src/admin/dns/DomainsPage.tsx +6 -2
  29. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  30. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  31. package/src/admin/dns/shared-validation-api.ts +90 -0
  32. package/src/admin/dns/shared-validation-data.ts +106 -0
  33. package/src/admin/domains/assistant.ts +1 -0
  34. package/src/admin/domains/operations.ts +6 -4
  35. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  36. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  37. package/src/admin/fleet-configuration/data.ts +4 -0
  38. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  39. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  40. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  41. package/src/admin/identity/groups/models.ts +2 -0
  42. package/src/admin/identity/users/UserDetail.tsx +1 -0
  43. package/src/admin/identity/users/sections/SecuritySection.tsx +4 -0
  44. package/src/admin/identity/users/sections/actions.tsx +75 -2
  45. package/src/admin/identity/users/sections/shared.tsx +18 -7
  46. package/src/client/auth.ts +77 -25
  47. package/src/client/fleet-mock.ts +73 -1
  48. package/src/client/index.ts +4 -2
  49. package/src/client/mock.ts +427 -14
  50. package/src/styles/components/admin-assistant.css +22 -0
  51. package/src/styles/components/admin-dns-certificates.css +33 -0
  52. package/src/ui/CollectionSelect.tsx +30 -8
  53. package/src/ui/date/fns.ts +32 -0
  54. package/src/ui/format.ts +26 -1
  55. package/src/ui/grouping.ts +31 -16
  56. package/src/ui/menu-registry.ts +3 -0
@@ -124,12 +124,18 @@ Client surface (`portal-mojo/client`):
124
124
  - `withFreshAuth(fn)` — run fn; on a 440 prompt, then retry **once** after a
125
125
  successful re-login; a dismissed prompt rejects with the ORIGINAL error.
126
126
 
127
- The modal re-prompts credentials OVER the current screen — page state
128
- survives. It pins the identity to the current session's email (step-up is
129
- "prove you are still you"), offers password + passkey + the MFA step, and
130
- keeps the session in its original storage (`sessionIsPersistent()` →
131
- `remember`). Dev affordance: `__mojo.requestFreshAuth()` in the console
132
- exercises the modal without a 440.
127
+ In hosted mode (including packaged Admin), the native dialog offers Cancel
128
+ and Continue to sign-in. Continue opens the existing configured sign-in page
129
+ with `force_reauth=1`, so Google-only deployments can use Google. It preserves
130
+ the exact return route and original storage choice. The pending challenge
131
+ resolves false before unloading: after returning, re-read and deliberately
132
+ retry the action. Picking another account starts a new session and screen;
133
+ the former account's operation is never carried over.
134
+
135
+ In explicit in-app/mock mode the modal re-prompts credentials over the current
136
+ screen, pins the current email when available, and keeps the existing
137
+ password/passkey/MFA and storage behavior. Dev affordance:
138
+ `__mojo.requestFreshAuth()` exercises the modal without a 440.
133
139
 
134
140
  Call-site pattern:
135
141
 
@@ -172,6 +178,12 @@ bouncer-enforcing deployments.
172
178
 
173
179
  ## Mock affordances
174
180
 
181
+ `npm run verify:hosted-reauth` covers the mounted hosted confirmation and
182
+ return/storage paths. For native dialog, responsive and theme checks, run
183
+ `node scripts/serve-hosted-reauth.mjs` and open its printed local URL. The
184
+ fixture uses the actual Portal host/bridge and a simulated Google-only sign-in
185
+ page; it does not prove a live OAuth provider exchange.
186
+
175
187
  Under the mock transport the pages show inline dev hints: password `mojo`
176
188
  for any seeded user (e.g. `ian@mojoverify.com`); Maya (seeded user 2) requires
177
189
  MFA; TOTP `123456`, recovery `mock-recovery`, and SMS `654321`; reset code
package/docs/auth.md CHANGED
@@ -102,11 +102,25 @@ its bot-check interstitial). Flow:
102
102
  `<origin>/auth?redirect=<HASH-FREE current url>`. Hash-free is
103
103
  load-bearing: the page string-appends `?auth_code=<code>` to the
104
104
  redirect, and a `#` in it would swallow the code.
105
- 2. User signs in on the hosted page; it mints a single-use 60s handoff code
105
+ 2. User signs in using that page's configured methods (Google, password,
106
+ passkey, etc.). Same-origin return uses its existing stored session;
107
+ a cross-origin return mints a single-use 60s handoff code
106
108
  (`POST /api/auth/handoff`) and navigates back with `?auth_code=`.
107
109
  3. `handleAuthCodeFromURL()` (boot) exchanges it (`POST /api/auth/exchange`),
108
110
  scrubs the code from the URL synchronously, restores the stashed route.
109
- Handles a hash-embedded `#/route?auth_code=` landing too.
111
+ It also restores same-origin returns without a code. Handles a
112
+ hash-embedded `#/route?auth_code=` landing too. Await this at boot before
113
+ creating the router.
114
+
115
+ `redirectToHostedAuth({forceReauth: true})` uses the same page with
116
+ `force_reauth=1`, bypassing its automatic existing-session check. It retains
117
+ the initiating persistent/session-only choice, including when the hosted
118
+ page writes localStorage. `remember` may explicitly supply the choice captured
119
+ when the challenge opened. Tab-local return metadata contains only the route,
120
+ destination, time and storage choice; it is consumed once, expires after 30
121
+ minutes, and applies only at the matching origin/path/query. It never carries
122
+ an operation or establishes freshness. The server still enforces `auth_time`.
123
+ When storage cannot retain the choice, step-up navigation returns false.
110
124
 
111
125
  `hostedAuthUrl()` returns null under the mock transport — there is no page
112
126
  to host; the UI should fall back to a dev affordance (`__mojo.login(...)`).
@@ -130,3 +130,17 @@ too — there is no parallel fetch path.
130
130
  handed a picker that says to pick a group they can't pick there.
131
131
  - Don't wire `onChange` to a per-keystroke autosave expectation — it fires
132
132
  on commit (that is the point; see the B3 autosave contract).
133
+
134
+ ### Optional ingress projection
135
+
136
+ `sanitizeRow={(row) => publicProjection(row)}` projects list and one-record
137
+ responses before Query cache storage. Such pickers use a separate cache keyed
138
+ by the sanitizer identity, auth UID and session revision; they never scan or
139
+ reuse raw endpoint caches. Keep the callback stable. Default consumers retain
140
+ the existing shared model keys and behavior. The callback must return only the
141
+ approved fields or reject malformed input. Full selected objects also need to
142
+ be projected by the caller before passing them in.
143
+
144
+ An endpoint with query-based hydration should hydrate through its own safe
145
+ `?id=` helper and pass the full selected object; do not pass a bare id that
146
+ would invent a `/<id>` record route.
@@ -45,7 +45,7 @@ the type checker and was the source's silent-failure vector) and the
45
45
 
46
46
  | Fn | Notes |
47
47
  |---|---|
48
- | `fmt.date(v, fallback='—')` / `fmt.datetime(v, fallback='—')` | `MM/DD/YYYY` / `Mon D, YYYY h:mm` — accepts **epoch seconds** (the django-mojo wire shape), ms, ISO, Date |
48
+ | `fmt.date(v, fallback='—')` / `fmt.datetime(v, fallback='—')` | `MM/DD/YYYY` / `Mon D, YYYY h:mm` — accepts **epoch seconds** (the django-mojo wire shape), ms, ISO, Date. A **date-only** value (`'YYYY-MM-DD'`, `'YYYY-MM'`, `'YYYY'`) shows its own calendar day at its own precision in every browser zone, with no time: `10/17/2026` / `Oct 17, 2026`, `10/2026` / `Oct 2026`, `2026` / `2026`; `groupByDay`/`groupByRecency` bucket it under that same day |
49
49
  | `fmt.relative(v, fallback='Never')` | "3 weeks ago" style |
50
50
  | `fmt.relativeShort(v, fallback='—')` | compact age for chips: `now` / `5h` / `3d` / `3w` / `2mo` / `1y`. **Magnitude only** — a future date reads the same as a past one (use `relative` when direction matters). Source boundaries kept (`>`, not `>=`): 7d is `7d`, 8d is `1w` |
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -34,7 +34,7 @@ export function shortDate(epochSeconds: number | null | undefined): string {
34
34
  }
35
35
 
36
36
  function passkeyIcon(row: PasskeyRow): string {
37
- const transports = (row.transports ?? '').split(',');
37
+ const transports = Array.isArray(row.transports) ? row.transports : [];
38
38
  if (transports.includes('usb') || transports.includes('nfc')) return 'bi-usb-drive';
39
39
  const name = (row.friendly_name ?? '').toLowerCase();
40
40
  if (/iphone|android|phone/.test(name)) return 'bi-phone';
@@ -20,7 +20,8 @@ export interface PasskeyRow {
20
20
  rp_id: string;
21
21
  is_enabled: boolean;
22
22
  sign_count: number;
23
- transports: string | null;
23
+ /** WebAuthn transport hints as django-mojo stores them: a JSON list, `[]` when none. */
24
+ transports: string[];
24
25
  aaguid: string | null;
25
26
  last_used: number | null;
26
27
  created: number;
@@ -0,0 +1,196 @@
1
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useAuthSnapshot, useMe, type Me } from '../../client/me';
4
+ import { getAuthSessionRevision, getAuthSnapshot, isReauthRequired, requestFreshAuth, subscribeAuth } from '../../client/auth';
5
+ import {
6
+ assistantSetupCredentialRefusal, assistantSetupReadiness, assistantSetupSave, getAssistantSetup, mutateAssistantSetup,
7
+ type AssistantSetupState, type SetupAction, type SetupKey, type SetupCheck, type SetupTarget,
8
+ } from './setup';
9
+
10
+ type Operation = { controller: AbortController; revision: number };
11
+ /** Also gates direct component consumers, independently of navigation and lazy-route admission. */
12
+ export function AssistantSetupPage() {
13
+ const { data: me } = useMe();
14
+ const auth = useAuthSnapshot();
15
+ const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
16
+ if (!auth.authenticated || me?.is_superuser !== true || String(me.id) !== auth.uid) return <div className="panel panel-pad" role="alert"><h2>Owner access required</h2><p>Assistant setup requires a literal installation superuser signed in as a person.</p></div>;
17
+ return <OwnerSetup key={`${auth.uid}:${revision}`} ownerId={me.id} revision={revision} />;
18
+ }
19
+ function KeySummary({ label, value, check }: { label: string; value: SetupKey; check: SetupCheck }) {
20
+ return <div className="assistant-setup-key-status"><p><strong>{label} source:</strong> {value.source === 'admin' ? 'Protected Admin storage' : value.source === 'deployment' ? 'Deployment configuration' : 'Not configured'}{value.hint && <span> · server hint: <code>{value.hint}</code></span>}</p><p className="dim">Last stored-key check: {check.ok === null ? 'Not checked' : check.ok ? 'Accepted' : 'Failed'}{check.at && ` · ${check.at}`} · {check.message}</p></div>;
21
+ }
22
+ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }) {
23
+ const queryClient = useQueryClient();
24
+ const [state, setState] = useState<AssistantSetupState | null>(null);
25
+ const stateRef = useRef(state); stateRef.current = state;
26
+ const [draft, setDraft] = useState(''); const [handlerDraft, setHandlerDraft] = useState('');
27
+ const [modelDraft, setModelDraft] = useState('');
28
+ const [modeDraft, setModeDraft] = useState<'auto' | 'pinned' | ''>('');
29
+ const [before, setBefore] = useState(''); const [limit, setLimit] = useState('25');
30
+ const [pending, setPending] = useState(''); const [notice, setNotice] = useState(''); const [error, setError] = useState('');
31
+ const [confirmation, setConfirmation] = useState<{ message: string; accept: () => void; cancel: () => void } | null>(null);
32
+ const live = useRef(false); const operation = useRef<Operation | null>(null);
33
+ const platform = useRef<HTMLDetailsElement>(null); const platformTitle = useRef<HTMLElement>(null);
34
+ const validOwner = () => {
35
+ const auth = getAuthSnapshot();
36
+ const me = queryClient.getQueryData<Me>(['me', auth.uid]);
37
+ return live.current && getAuthSessionRevision() === revision && auth.authenticated && auth.uid === String(ownerId) && me?.id === ownerId && me.is_superuser === true;
38
+ };
39
+ const clearDrafts = () => { setDraft(''); setHandlerDraft(''); setBefore(''); setLimit('25'); setModelDraft(''); setModeDraft(''); };
40
+ const dispose = () => {
41
+ operation.current?.controller.abort(); operation.current = null;
42
+ clearDrafts(); setConfirmation(null); setPending(''); setState(null); stateRef.current = null; setNotice(''); setError('');
43
+ };
44
+ const guard = (op: Operation) => {
45
+ if (!validOwner() || operation.current !== op || op.controller.signal.aborted) throw new DOMException('Setup scope ended', 'AbortError');
46
+ };
47
+ const options = (op: Operation) => ({ signal: op.controller.signal, beforeSend: () => guard(op) });
48
+ const apply = (next: AssistantSetupState, op: Operation) => { guard(op); stateRef.current = next; setState(next); setModelDraft(next.model.capability?.mode === 'pinned' ? next.model.selected : ''); setModeDraft(next.model.capability?.mode === 'auto' || next.model.capability?.mode === 'pinned' ? next.model.capability.mode : ''); };
49
+ const begin = (label: string): Operation | null => {
50
+ if (!validOwner() || operation.current) return null;
51
+ const op = { controller: new AbortController(), revision }; operation.current = op; setPending(label); setNotice(''); setError(''); return op;
52
+ };
53
+ const finish = (op: Operation) => { if (operation.current === op) { operation.current = null; setPending(''); setConfirmation(null); } };
54
+ async function failure(err: unknown, op: Operation, mutation: boolean) {
55
+ if (!validOwner() || operation.current !== op || op.controller.signal.aborted) return;
56
+ if (isReauthRequired(err)) {
57
+ clearDrafts(); setConfirmation(null); setNotice('Sign in again, then re-enter any key and explicitly retry. Nothing is automatically replayed.');
58
+ // Step-up replaces the session. Never close over a secret for retry.
59
+ await requestFreshAuth(); return;
60
+ }
61
+ setError(assistantSetupCredentialRefusal(err) ?? (mutation ? 'The operation was refused or its outcome is unknown. Refreshing current configuration; no operation will be retried automatically.' : 'Assistant setup could not be loaded. Try Refresh setup.'));
62
+ if (mutation) {
63
+ try { apply(await getAssistantSetup(options(op)), op); }
64
+ catch { if (validOwner() && operation.current === op) { setState(null); stateRef.current = null; } }
65
+ }
66
+ }
67
+ async function load(probe?: 'models' | 'discovery') {
68
+ const op = begin(probe ? 'Checking…' : 'Loading setup…'); if (!op) return;
69
+ try {
70
+ const next = await getAssistantSetup(options(op), probe); apply(next, op);
71
+ if (probe === 'models') {
72
+ guard(op);
73
+ const refresh = next.model.capability?.refresh;
74
+ if (refresh?.ok === true) setNotice('Provider catalogue refreshed. Review the resolved runtime model below.');
75
+ else setError(refresh?.code === 'refresh_in_progress' ? 'A catalogue refresh is already in progress. Returned configuration is unchanged; try again later.' : refresh?.code === 'refresh_throttled' ? 'Catalogue refresh is temporarily limited. Returned configuration is unchanged; try again later.' : 'Provider catalogue refresh was not confirmed. Any returned cached model keeps its reported freshness; no newer model is confirmed.');
76
+ } else if (probe === 'discovery') { guard(op); setNotice('Discovery check completed. Review its verdict below.'); }
77
+ }
78
+ catch (err) { await failure(err, op, false); } finally { finish(op); }
79
+ }
80
+ function change(make: (current: AssistantSetupState) => SetupAction, label: string, confirm?: string, credential?: SetupTarget) {
81
+ if (!stateRef.current) return;
82
+ const op = begin(label); if (!op) return;
83
+ const run = async () => {
84
+ let action: SetupAction | null = null;
85
+ try {
86
+ guard(op); setConfirmation(null);
87
+ if (!stateRef.current) throw new Error('Reload setup.');
88
+ action = make(stateRef.current);
89
+ const result = await mutateAssistantSetup(action, options(op));
90
+ guard(op); apply(result.state, op);
91
+ if (credential === 'assistant') setDraft(''); if (credential === 'handler') setHandlerDraft('');
92
+ if (result.result) {
93
+ if (result.result.ok === true) setNotice(result.result.message);
94
+ else setError(result.result.message || 'The credential check failed.');
95
+ } else setNotice(result.queued !== undefined ? `Historical triage queued: ${result.queued}.` : result.revoked !== undefined ? `Connections disconnected: ${result.revoked}.` : result.reset !== undefined ? `Breakers reset: ${result.reset}. Emergency stops are unchanged.` : label === 'Activating policy…' ? 'Deployed policy acknowledged on this node. Fleet-wide agreement has not been established.' : 'Configuration saved.');
96
+ } catch (err) {
97
+ // Drop request-local candidate references before showing any fresh-auth UI.
98
+ if (action && 'api_key' in action) delete action.api_key;
99
+ if (action && 'handler_api_key' in action) delete action.handler_api_key;
100
+ action = null;
101
+ await failure(err, op, true);
102
+ } finally {
103
+ if (action && 'api_key' in action) delete action.api_key;
104
+ if (action && 'handler_api_key' in action) delete action.handler_api_key;
105
+ finish(op);
106
+ }
107
+ };
108
+ if (confirm) setConfirmation({ message: confirm, accept: () => { void run(); }, cancel: () => finish(op) });
109
+ else void run();
110
+ }
111
+ useEffect(() => {
112
+ live.current = true;
113
+ const revoke = () => { if (!validOwner()) dispose(); };
114
+ const offAuth = subscribeAuth(revoke); const offQuery = queryClient.getQueryCache().subscribe(revoke);
115
+ void load();
116
+ return () => { live.current = false; operation.current?.controller.abort(); operation.current = null; offAuth(); offQuery(); };
117
+ // The keyed boundary owns identity/revision. Ordinary token renewal preserves this instance.
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, []);
120
+ const locked = pending !== '';
121
+ const capability = state?.model.capability;
122
+ const modelEditable = capability?.editable === true && capability.policy_model === 'auto:sonnet' && capability.mode !== 'fixed' && (capability.restriction === '' || capability.restriction === 'deployment_pin');
123
+ const autoAvailable = modelEditable && capability?.restriction !== 'deployment_pin';
124
+ const restriction = !capability ? 'Runtime model selection is unavailable on this server. Upgrade the backend to select Automatic or a manual pin.'
125
+ : capability.restriction === 'fixed_policy' ? 'The deployed policy fixes the model. Model selection is read-only.'
126
+ : capability.restriction === 'provider_unsupported' ? 'This provider uses a deployment-owned model. Automatic Sonnet and manual selection are unavailable.'
127
+ : capability.restriction === 'policy_unavailable' ? 'The deployed model policy is unavailable. Model selection is read-only.'
128
+ : capability.restriction === 'deployment_pin' ? 'A deployment pin is active beneath owner overrides. You may save a manual pin; remove the deployment pin before choosing Automatic.'
129
+ : !modelEditable ? 'Runtime model selection is read-only for this deployment.' : '';
130
+
131
+ const saveKey = (target: SetupTarget) => change((current) => assistantSetupSave(current, target === 'assistant' ? { api_key: draft } : { handler_api_key: handlerDraft }), 'Checking and saving key…', undefined, target);
132
+ const clearKey = (target: SetupTarget) => change((current) => assistantSetupSave(current, target === 'assistant' ? { clear_api_key: true } : { clear_handler_api_key: true }), 'Clearing protected key…', 'Clear the protected Admin credential? A deployment credential may become active. Review the returned source after saving.', target);
133
+ const openPlatform = () => { if (platform.current) platform.current.open = true; platformTitle.current?.focus(); };
134
+ return <main className="assistant-setup" aria-labelledby="assistant-setup-title">
135
+ <header className="assistant-setup-header"><div><div className="eyebrow">Installation owner</div><h1 id="assistant-setup-title">AI Assistant setup</h1><p className="dim">Set up your AI Assistant and review the controls that keep it safe.</p></div><button type="button" className="btn" disabled={locked} onClick={() => void load()}>Refresh setup</button></header>
136
+ <div role="status" aria-live="polite">{notice || (pending && !confirmation ? pending : '')}</div>
137
+ {error && <div className="assistant-setup-warning" role="alert">{error}</div>}
138
+ {confirmation && <section className="panel panel-pad assistant-setup-confirm" role="group" aria-label="Confirm setup operation"><p>{confirmation.message}</p><div className="assistant-action-row"><button className="btn" type="button" autoFocus onClick={confirmation.cancel}>Cancel</button><button className="btn btn-primary" type="button" onClick={confirmation.accept}>Confirm operation</button></div></section>}
139
+ {state && <>
140
+ <section className="panel panel-pad"><div className="eyebrow">Basic setup</div><h2>{state.enabled && state.route.ready && !state.emergency_stop && state.assistant_installed && state.realtime_installed ? 'Ready to use' : 'Needs attention'}</h2><p>{assistantSetupReadiness(state)}</p>
141
+ <dl className="assistant-setup-facts"><div><dt>Provider</dt><dd>{state.route.provider === 'anthropic' ? 'Anthropic' : state.route.provider === 'bedrock' ? 'AWS Bedrock' : 'Unavailable'}</dd></div><div><dt>Resolved runtime model</dt><dd><code>{state.model.effective || state.route.model || 'Unavailable'}</code></dd></div><div><dt>Selected credential</dt><dd>{state.route.credential === 'admin' ? 'Assistant key' : state.route.credential === 'handler' ? 'Platform key' : state.route.credential === 'aws' ? 'AWS identity' : 'Unavailable'}</dd></div><div><dt>Deployment configuration</dt><dd>{state.route.ready ? 'Ready' : 'Needs attention'}</dd></div></dl>
142
+ <section className="assistant-setup-model" aria-labelledby="assistant-model-title">
143
+ <h3 id="assistant-model-title">Model selection</h3>
144
+ <p>Selection mode: <strong>{capability?.mode === 'auto' ? 'Automatic' : capability?.mode === 'pinned' ? 'Manual pin' : capability?.mode === 'fixed' ? 'Fixed by deployment' : 'Unavailable'}</strong> · source: {capability ? state.model.source : state.model.source || 'Unavailable'}</p>
145
+ {restriction && <p className="assistant-setup-warning" id="assistant-model-restriction">{restriction}</p>}
146
+ {modelEditable && <>
147
+ <label className="assistant-setup-field" htmlFor="assistant-model-mode">Selection method<select className="input" id="assistant-model-mode" value={modeDraft} disabled={locked} onChange={(event) => setModeDraft(event.target.value as 'auto' | 'pinned')} aria-describedby="assistant-model-help">
148
+ {!modeDraft && <option value="" disabled>Choose selection method</option>}<option value="auto" disabled={!autoAvailable}>Automatic — latest Sonnet</option><option value="pinned">Manual pin</option>
149
+ </select></label>
150
+ {modeDraft === 'pinned' && <label className="assistant-setup-field" htmlFor="assistant-model-pin">Manual model identifier<input className="input" id="assistant-model-pin" value={modelDraft} onChange={(event) => setModelDraft(event.target.value)} disabled={locked} list="assistant-model-choices" maxLength={81} autoComplete="off" spellCheck={false} /></label>}
151
+ <datalist id="assistant-model-choices">{state.model.choices.map((choice) => <option key={choice.id} value={choice.id}>{choice.label}</option>)}</datalist>
152
+ <p className="dim" id="assistant-model-help">Automatic lets the backend resolve a compatible Sonnet from its provider catalogue. A manual pin takes effect only after saving. Refreshing cannot replace a manual pin.</p>
153
+ <div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !modeDraft || (modeDraft === 'auto' ? !autoAvailable : !/^[a-z0-9][a-z0-9.\-]{0,80}$/.test(modelDraft))} onClick={() => change(() => modeDraft === 'auto' ? { action: 'set_model_selection', mode: 'auto' } : { action: 'set_model_selection', mode: 'pinned', model: modelDraft }, 'Saving model selection…')}>Save model selection</button><button className="btn" type="button" disabled={locked} onClick={() => void load('models')}>Refresh provider catalogue</button></div>
154
+ </>}
155
+ {capability && <div className="assistant-setup-catalogue" aria-label="Provider catalogue status">
156
+ <p>Catalogue: <strong>{capability.discovery.status === 'fresh' ? 'Fresh cache' : capability.discovery.status === 'stale' ? 'Stale cache — cached resolution only' : capability.discovery.status === 'unavailable' ? 'Unavailable — no usable automatic resolution' : 'Not applicable to the selected mode'}</strong>{capability.discovery.error_code && ' · The backend reports a discovery problem.'}</p>
157
+ <dl className="assistant-setup-facts"><div><dt>Last successful fetch</dt><dd>{capability.discovery.fetched_at || 'None reported'}</dd></div><div><dt>Last attempt</dt><dd>{capability.discovery.attempted_at || 'None reported'}</dd></div><div><dt>Fresh until</dt><dd>{capability.discovery.expires_at || 'Not applicable'}</dd></div><div><dt>Usable cached limit</dt><dd>{capability.discovery.max_stale_at || 'Not applicable'}</dd></div></dl>
158
+ </div>}
159
+ </section>
160
+ {state.route.credential === 'handler' && <div className="assistant-setup-warning"><p>This deployed route uses the Platform credential. An Assistant key will not supply it.</p><button className="btn" type="button" onClick={openPlatform}>Open Platform credential</button></div>}
161
+ {state.route.credential === 'aws' && <p>The Bedrock route uses deployment AWS identity. Browser key fields do not configure that identity.</p>}
162
+ <KeySummary label="Assistant key" value={state.key} check={state.verify} />
163
+ <label className="assistant-setup-field" htmlFor="assistant-replacement-key">Replacement Assistant key<input id="assistant-replacement-key" className="input" type="password" value={draft} onChange={(event) => setDraft(event.target.value)} autoComplete="new-password" spellCheck={false} maxLength={4096} disabled={locked} aria-describedby="assistant-key-help" /></label>
164
+ <p className="dim" id="assistant-key-help">Write-only replacement. The server checks a new key before storing it. Nothing saves while you type.</p>
165
+ <div className="assistant-action-row"><button type="button" className="btn btn-primary" disabled={locked || !draft.trim()} onClick={() => saveKey('assistant')}>Check and save key</button><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'verify', target: 'assistant' }), 'Checking current key…')}>Check current key</button><button type="button" className="btn" disabled={locked || state.key.source !== 'admin'} onClick={() => clearKey('assistant')}>Clear stored Assistant key</button></div>
166
+ <div className="assistant-setup-switch"><span>Assistant is <strong>{state.enabled ? 'enabled' : 'disabled'}</strong>. Enabling does not override deployment policy or emergency stops.</span><button className="btn" type="button" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { enabled: !current.enabled }), 'Saving Assistant switch…')}>{state.enabled ? 'Disable Assistant' : 'Enable Assistant'}</button></div>
167
+ </section>
168
+ <p className="dim">Advanced controls · deployment, safety, remote access and incident processing</p>
169
+ <details className="panel assistant-setup-advanced" ref={platform}><summary ref={platformTitle} tabIndex={0}>Platform credential</summary><div className="panel-pad">
170
+ <p>Used only by deployed routes selecting <code>handler</code>. Credentials never substitute for one another.</p><KeySummary label="Platform key" value={state.handler_key} check={state.handler_verify} />
171
+ <label className="assistant-setup-field" htmlFor="handler-replacement-key">Replacement Platform key<input id="handler-replacement-key" className="input" type="password" value={handlerDraft} onChange={(event) => setHandlerDraft(event.target.value)} autoComplete="new-password" spellCheck={false} maxLength={4096} disabled={locked} /></label>
172
+ <div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !handlerDraft.trim()} onClick={() => saveKey('handler')}>Check and save Platform key</button><button className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'verify', target: 'handler' }), 'Checking Platform key…')}>Check current Platform key</button><button className="btn" type="button" disabled={locked || state.handler_key.source !== 'admin'} onClick={() => clearKey('handler')}>Clear stored Platform key</button></div>
173
+ </div></details>
174
+ <details className="panel assistant-setup-advanced"><summary>Deployed policy and safety</summary><div className="panel-pad">
175
+ {capability && <p className="dim">Deployed policy model: <code>{capability.policy_model || 'Unavailable'}</code></p>}
176
+ {state.route.can_invoke !== undefined && <dl className="assistant-setup-facts"><div><dt>Guarded request admission</dt><dd>{state.route.can_invoke ? 'Allowed to attempt' : 'Blocked'} · separate from configuration readiness</dd></div></dl>}
177
+ <p>Policy status: {state.route.error || 'Ready'}. Deployment owns policy content and the runtime route. Activation acknowledges the policy on this node; it cannot establish fleet-wide agreement.</p><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'activate_policy' }), 'Activating policy…', 'Acknowledge the currently deployed policy on this node? This does not edit policy content, check other nodes or release an emergency stop.')}>Acknowledge deployed policy</button>
178
+ <dl className="assistant-setup-facts"><div><dt>Deployment stop (read-only)</dt><dd>{state.emergency_stop_static ? 'On' : 'Off'}</dd></div><div><dt>Database stop</dt><dd>{state.emergency_stop_database ? 'On' : 'Off'}</dd></div><div><dt>Effective stop</dt><dd>{state.emergency_stop ? 'On' : 'Off'}</dd></div></dl>
179
+ <button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { emergency_stop: !current.emergency_stop_database }), 'Saving database stop…', 'Change only the database emergency stop? A deployment stop remains authoritative.')}>{state.emergency_stop_database ? 'Release database stop' : 'Set database stop'}</button>
180
+ <h3>Requests · last {state.safety.hours || 24} hours</h3>{state.safety.error && <p role="alert">Safety statistics unavailable.</p>}
181
+ <div className="assistant-table-scroll"><table><caption>Guarded request aggregates</caption><thead><tr><th>Provider / feature</th><th>Status</th><th>Requests</th><th>Tokens in / out</th></tr></thead><tbody>{state.safety.requests.map((row, i) => <tr key={i}><td>{row.provider} / {row.feature}</td><td>{row.status}</td><td>{row.requests}</td><td>{row.input_tokens} / {row.output_tokens}</td></tr>)}</tbody></table></div>
182
+ <h3>Circuit breakers</h3><div className="assistant-table-scroll"><table><caption>Breaker state by provider</caption><thead><tr><th>Provider</th><th>State</th><th>Error</th><th>Count</th></tr></thead><tbody>{state.safety.breakers.map((row, i) => <tr key={i}><td>{row.provider}</td><td>{row.state}</td><td>{row.error_code || 'None'}</td><td>{row.count}</td></tr>)}</tbody></table></div>
183
+ <div className="assistant-action-row">{(['all', 'anthropic', 'bedrock'] as const).map((provider) => <button key={provider} type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'reset_breaker', ...(provider === 'all' ? {} : { provider }) }), 'Resetting breakers…', `Reset ${provider} circuit breakers? Requests may try again; policy and emergency stops remain enforced.`)}>Reset {provider} breakers</button>)}</div>
184
+ </div></details>
185
+ <details className="panel assistant-setup-advanced"><summary>Remote agent access and connections</summary><div className="panel-pad"><p>Remote agents can receive Tools, Full API or both kinds of access according to their grants. Full API access carries the signed-in user’s API authority. Turning access off pauses connections without revoking them.</p>
186
+ <p>Remote access is <strong>{state.mcp.enabled ? 'on' : 'off'}</strong>.</p><button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { mcp_enabled: !current.mcp.enabled }), 'Saving remote access…', 'Change remote agent access? Existing grants are retained; Disconnect revokes them.')}>{state.mcp.enabled ? 'Pause remote access' : 'Enable remote access'}</button>
187
+ <label className="assistant-setup-field" htmlFor="assistant-connect-address">Connect address (select to copy)<input id="assistant-connect-address" className="input" value={state.mcp.url} readOnly onFocus={(event) => event.target.select()} /></label><p className="dim">{state.mcp.discovery_url || 'No public discovery address configured.'}</p>
188
+ <button type="button" className="btn" disabled={locked} onClick={() => void load('discovery')}>Check public discovery</button><p role={state.mcp.discovery.ok === false ? 'alert' : undefined}>Discovery: {state.mcp.discovery.ok === null ? 'Not checked' : state.mcp.discovery.ok ? 'Reachable' : 'Failed'} · {state.mcp.discovery.detail}{state.mcp.discovery.checked_at && ` · ${state.mcp.discovery.checked_at}`}</p>
189
+ <h3>Connected agents · {state.mcp.grant_count} total</h3><p className="dim">Showing {state.mcp.grants.length} connections, capped at 200. Disconnect all spans Tools and Full API grants for every user.</p><div className="assistant-table-scroll"><table><caption>Remote connections</caption><thead><tr><th>Client / user</th><th>Access</th><th>Last used / expires</th><th>Connection</th></tr></thead><tbody>{state.mcp.grants.map((row) => <tr key={row.id}><td>{row.client.name || row.client.client_id}<br />{row.user.display_name || row.user.email}</td><td>{row.access === 'api' ? 'Full API' : row.access === 'both' ? 'Tools and Full API' : 'Tools'}</td><td>{row.last_used || 'Not used'}<br />{row.expires}</td><td><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'revoke_grant', grant_id: row.id }), 'Disconnecting agent…', `Disconnect ${row.client.name || 'this agent'} for ${row.user.display_name || row.user.email}? A new authorization is required to reconnect.`)}>Disconnect #{row.id}</button></td></tr>)}</tbody></table></div><button type="button" className="btn" disabled={locked || state.mcp.grant_count === 0} onClick={() => change(() => ({ action: 'revoke_all_grants' }), 'Disconnecting all agents…', 'Disconnect every remote Tools and Full API grant, for every user? New authorizations will be required.')}>Disconnect all agents</button>
190
+ </div></details>
191
+ <details className="panel assistant-setup-advanced"><summary>Incident triage</summary><div className="panel-pad"><p>Autonomous incident triage is <strong>{state.autonomous_triage ? 'on' : 'off'}</strong>. Enabling establishes a no-history watermark; older incidents need an explicit historical run.</p><p>Watermark: {state.autonomous_triage_activated_at || 'Not activated'}</p><button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { autonomous_triage: !current.autonomous_triage }), 'Saving autonomous triage…', 'Change autonomous incident triage? New activation establishes a watermark; it does not queue older incidents.')}>{state.autonomous_triage ? 'Disable autonomous triage' : 'Enable autonomous triage'}</button>
192
+ <h3>Historical incident triage</h3><label className="assistant-setup-field" htmlFor="assistant-triage-before">Incidents before (local time)<input className="input" id="assistant-triage-before" type="datetime-local" value={before} onChange={(event) => setBefore(event.target.value)} disabled={locked} /></label><label className="assistant-setup-field" htmlFor="assistant-triage-limit">Maximum incidents (1–100)<input className="input" id="assistant-triage-limit" type="number" min={1} max={100} step={1} value={limit} onChange={(event) => setLimit(event.target.value)} disabled={locked} /></label><button type="button" className="btn" disabled={locked || !before || !Number.isInteger(Number(limit)) || Number(limit) < 1 || Number(limit) > 100} onClick={() => change(() => ({ action: 'historical_triage', before: new Date(before).toISOString(), limit: Number(limit) }), 'Queueing historical triage…', `Queue up to ${limit} incidents before ${before}? This explicitly processes historical incidents and can incur provider cost.`)}>Queue historical triage</button>
193
+ </div></details>
194
+ </>}
195
+ </main>;
196
+ }
@@ -9,3 +9,5 @@ export * from './pages';
9
9
  export * from './streaming';
10
10
 
11
11
  export { ASSISTANT_ADMIN_SECTION } from '../domains/assistant';
12
+ export * from './setup';
13
+ export * from './AssistantSetupPage';
@@ -0,0 +1,174 @@
1
+ /** Owner-only, imperative setup protocol. Never put these operations in Query/Mutation caches. */
2
+ import { apiOrigin, mojoCall, usingMockTransport, type FetchOpts } from '../../client/client';
3
+
4
+ export const ASSISTANT_SETUP_ENDPOINT = '/api/account/admin/assistant';
5
+ export type SetupTarget = 'assistant' | 'handler';
6
+ export interface SetupKey { configured: boolean; source: 'admin' | 'deployment' | 'none'; hint: string }
7
+ export interface SetupCheck { ok: boolean | null; code: string; message: string; at: string }
8
+ export interface SetupGrant {
9
+ id: number; client: { id: number; client_id: string; name: string };
10
+ user: { id: number; email: string; display_name: string }; resource: string; scopes: string[];
11
+ access: 'tools' | 'api' | 'both'; created: string; last_used: string; expires: string; is_active: boolean; revoked_reason: string;
12
+ }
13
+ export interface AssistantSetupState {
14
+ schema_version: 2; enabled: boolean; key: SetupKey; handler_key: SetupKey;
15
+ model: { selected: string; effective: string; source: string; choices: Array<{ id: string; label: string }>; capability?: {
16
+ mode: 'auto' | 'pinned' | 'fixed'; editable: boolean; policy_model: string;
17
+ restriction: '' | 'fixed_policy' | 'provider_unsupported' | 'deployment_pin' | 'policy_unavailable';
18
+ discovery: { status: 'fresh' | 'stale' | 'unavailable' | 'not_applicable'; fetched_at: string | null; attempted_at: string | null; expires_at: string | null; max_stale_at: string | null; error_code: string };
19
+ refresh: { attempted: boolean; ok: boolean | null; code: string };
20
+ } };
21
+ verify: SetupCheck; handler_verify: SetupCheck;
22
+ emergency_stop: boolean; emergency_stop_static: boolean; emergency_stop_database: boolean;
23
+ route: { feature: string; provider: string; model: string; credential: string; credential_configured: boolean; ready: boolean; can_invoke?: boolean; error: string };
24
+ autonomous_triage: boolean; autonomous_triage_activated_at: string;
25
+ assistant_installed: boolean; realtime_installed: boolean;
26
+ mcp: { enabled: boolean; path: string; url: string; discovery_url: string;
27
+ discovery: { ok: boolean | null; code: string; detail: string; checked_at: string }; grants: SetupGrant[]; grant_count: number };
28
+ safety: { hours: number; error: string; requests: Array<{ provider: string; feature: string; status: string; requests: number; input_tokens: number; output_tokens: number }>;
29
+ breakers: Array<{ provider: string; state: string; error_code: string; count: number }> };
30
+ }
31
+ export interface SetupSave {
32
+ action: 'save'; enabled: boolean; model?: string; api_key?: string; clear_api_key?: boolean;
33
+ handler_api_key?: string; clear_handler_api_key?: boolean; mcp_enabled?: boolean; emergency_stop?: boolean; autonomous_triage?: boolean;
34
+ }
35
+ export type SetupAction = SetupSave
36
+ | { action: 'set_model_selection'; mode: 'auto'; model?: never }
37
+ | { action: 'set_model_selection'; mode: 'pinned'; model: string }
38
+ | { action: 'verify'; target: SetupTarget; api_key?: string }
39
+ | { action: 'activate_policy' }
40
+ | { action: 'reset_breaker'; provider?: 'anthropic' | 'bedrock' }
41
+ | { action: 'historical_triage'; before: string; limit: number }
42
+ | { action: 'revoke_grant'; grant_id: number }
43
+ | { action: 'revoke_all_grants' };
44
+ export interface SetupActionResult { state: AssistantSetupState; result?: SetupCheck; saved?: boolean; verified?: boolean; activated?: boolean; reset?: number; revoked?: number; queued?: number }
45
+ const object = (v: unknown): Record<string, unknown> => v != null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {};
46
+ const text = (v: unknown, max = 160): string => typeof v === 'string' ? v.slice(0, max) : '';
47
+ const number = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) ? Math.max(0, v) : 0;
48
+ const verdict = (v: unknown): boolean | null => typeof v === 'boolean' ? v : null;
49
+ const rows = (v: unknown, max: number): Record<string, unknown>[] => Array.isArray(v) ? v.slice(0, max).map(object) : [];
50
+ const identifier = (v: unknown): string => /^[a-z0-9][a-z0-9.\-]{0,80}$/.test(text(v, 82)) ? text(v, 81) : '';
51
+ // The additive wire capability is all-or-nothing; malformed fields never enable a writer.
52
+ const runtimeIdentifier = (v: unknown): string => typeof v === 'string' && v.length <= 200 && /^[^\s\x00-\x1f\x7f]+$/.test(v) ? v : '';
53
+ const safeCode = (v: unknown): string => typeof v === 'string' && /^[a-z0-9_]{0,64}$/.test(v) ? v : '';
54
+ function projectModelCapability(model: Record<string, unknown>): AssistantSetupState['model']['capability'] {
55
+ const d = object(model.discovery), r = object(model.refresh);
56
+ const timestamp = (v: unknown) => v === null || (typeof v === 'string' && v.length <= 40 && /^\d{4}-\d\d-\d\dT/.test(v) && Number.isFinite(Date.parse(v)));
57
+ if (typeof model.mode !== 'string' || typeof model.restriction !== 'string' || typeof model.source !== 'string' || typeof d.status !== 'string' || !['auto', 'pinned', 'fixed'].includes(model.mode) || typeof model.editable !== 'boolean'
58
+ || typeof model.policy_model !== 'string' || model.policy_model.length > 200
59
+ || !['', 'fixed_policy', 'provider_unsupported', 'deployment_pin', 'policy_unavailable'].includes(model.restriction)
60
+ || !['automatic', 'admin', 'deployment', 'policy'].includes(model.source)
61
+ || !['fresh', 'stale', 'unavailable', 'not_applicable'].includes(d.status)
62
+ || !['fetched_at', 'attempted_at', 'expires_at', 'max_stale_at'].every((key) => timestamp(d[key]))
63
+ || typeof d.error_code !== 'string' || safeCode(d.error_code) !== d.error_code
64
+ || typeof r.attempted !== 'boolean' || (r.ok !== null && typeof r.ok !== 'boolean')
65
+ || typeof r.code !== 'string' || safeCode(r.code) !== r.code) return undefined;
66
+ if (model.mode === 'fixed' ? model.editable !== false || !['fixed_policy', 'provider_unsupported', 'policy_unavailable'].includes(model.restriction)
67
+ : model.editable !== true || model.policy_model !== 'auto:sonnet' || !['', 'deployment_pin'].includes(model.restriction)
68
+ || (model.mode === 'auto' ? model.source !== 'automatic' || model.restriction !== '' : !['admin', 'deployment'].includes(model.source) || !identifier(model.selected))) return undefined;
69
+ return {
70
+ mode: model.mode as 'auto' | 'pinned' | 'fixed', editable: model.editable, policy_model: model.policy_model,
71
+ restriction: model.restriction as NonNullable<AssistantSetupState['model']['capability']>['restriction'],
72
+ discovery: { status: d.status as 'fresh' | 'stale' | 'unavailable' | 'not_applicable', fetched_at: d.fetched_at as string | null, attempted_at: d.attempted_at as string | null, expires_at: d.expires_at as string | null, max_stale_at: d.max_stale_at as string | null, error_code: d.error_code },
73
+ refresh: { attempted: r.attempted, ok: r.ok as boolean | null, code: r.code },
74
+ };
75
+ }
76
+ const CHECK_MESSAGES: Record<string, string> = { verified: 'Anthropic accepted this key.', invalid_key: 'Anthropic rejected this key.', unreachable: 'Anthropic could not be reached to check this key.', not_configured: 'No API key is configured to check.' };
77
+ function projectCheck(raw: unknown): SetupCheck {
78
+ const v = object(raw); const code = text(v.code, 64);
79
+ return { ok: verdict(v.ok), code, message: CHECK_MESSAGES[code] ?? 'No stored check is available.', at: text(v.at, 40) };
80
+ }
81
+ function projectKey(raw: unknown): SetupKey {
82
+ const v = object(raw);
83
+ return { configured: v.configured === true, source: v.source === 'admin' || v.source === 'deployment' ? v.source : 'none', hint: text(v.hint, 32) };
84
+ }
85
+ /** Positive bounded schema projection. Raw response objects never enter component state. */
86
+ export function projectAssistantSetup(raw: unknown): AssistantSetupState {
87
+ const v = object(raw);
88
+ if (v.schema_version !== 2) throw new Error('Assistant setup schema is unavailable.');
89
+ const model = object(v.model), route = object(v.route), mcp = object(v.mcp), discovery = object(mcp.discovery), safety = object(v.safety);
90
+ const capability = projectModelCapability(model);
91
+ return {
92
+ schema_version: 2, enabled: v.enabled === true, key: projectKey(v.key), handler_key: projectKey(v.handler_key),
93
+ model: { selected: identifier(model.selected), effective: text(model.effective, 200), source: ['automatic', 'admin', 'deployment', 'policy', 'setting', 'default'].includes(String(model.source)) ? String(model.source) : '', choices: rows(model.choices, 200).map((row) => ({ id: runtimeIdentifier(row.id), label: text(row.label) })).filter((row) => row.id !== ''), ...(capability ? { capability } : {}) },
94
+ verify: projectCheck(v.verify), handler_verify: projectCheck(v.handler_verify),
95
+ emergency_stop: v.emergency_stop !== false, emergency_stop_static: v.emergency_stop_static !== false, emergency_stop_database: v.emergency_stop_database !== false,
96
+ route: { feature: text(route.feature, 64), provider: ['anthropic', 'bedrock'].includes(String(route.provider)) ? String(route.provider) : '', model: text(route.model, 200), credential: ['admin', 'handler', 'aws'].includes(String(route.credential)) ? String(route.credential) : '', credential_configured: route.credential_configured === true, ready: route.ready === true, ...(typeof route.can_invoke === 'boolean' ? { can_invoke: route.can_invoke } : {}), error: text(route.error, 64) },
97
+ autonomous_triage: v.autonomous_triage === true, autonomous_triage_activated_at: text(v.autonomous_triage_activated_at, 40),
98
+ assistant_installed: v.assistant_installed === true, realtime_installed: v.realtime_installed === true,
99
+ mcp: { enabled: mcp.enabled === true, path: text(mcp.path, 500), url: text(mcp.url, 2000), discovery_url: text(mcp.discovery_url, 2000),
100
+ discovery: { ok: verdict(discovery.ok), code: text(discovery.code, 64), detail: text(discovery.detail, 500), checked_at: text(discovery.checked_at, 40) }, grant_count: number(mcp.grant_count),
101
+ grants: rows(mcp.grants, 200).map((row): SetupGrant => { const client = object(row.client), user = object(row.user); return {
102
+ id: number(row.id), client: { id: number(client.id), client_id: text(client.client_id), name: text(client.name) },
103
+ user: { id: number(user.id), email: text(user.email, 254), display_name: text(user.display_name) }, resource: text(row.resource, 2000),
104
+ scopes: Array.isArray(row.scopes) ? row.scopes.filter((s): s is string => typeof s === 'string').slice(0, 30).map((s) => s.slice(0, 64)) : [],
105
+ access: row.access === 'api' || row.access === 'both' ? row.access : 'tools', created: text(row.created, 40), last_used: text(row.last_used, 40), expires: text(row.expires, 40), is_active: row.is_active === true, revoked_reason: text(row.revoked_reason, 160),
106
+ }; }).filter((row) => Number.isSafeInteger(row.id) && row.id > 0) },
107
+ safety: { hours: number(safety.hours), error: text(safety.error, 64),
108
+ requests: rows(safety.requests, 200).map((row) => ({ provider: text(row.provider, 64), feature: text(row.feature, 64), status: text(row.status, 64), requests: number(row.requests), input_tokens: number(row.input_tokens), output_tokens: number(row.output_tokens) })),
109
+ breakers: rows(safety.breakers, 100).map((row) => ({ provider: text(row.provider, 64), state: text(row.state, 64), error_code: text(row.error_code, 64), count: number(row.count) })) },
110
+ };
111
+ }
112
+ export function assistantSetupReadiness(state: AssistantSetupState): string {
113
+ if (!state.assistant_installed) return 'The Assistant application is not installed.';
114
+ if (!state.realtime_installed) return 'The realtime application is not installed. Install it before using the Assistant.';
115
+ if (state.emergency_stop) return 'An emergency stop is blocking Assistant requests.';
116
+ if (state.model.capability?.mode === 'auto' && state.route.provider && state.route.credential && !state.model.effective) return 'Automatic model resolution is unavailable. Refresh the provider catalogue and review its reported freshness. Request admission does not prove that a usable model is ready.';
117
+ if (!state.route.provider || !state.route.model || !state.route.credential) return 'The deployment’s model and key selection are unavailable. Review the deployed policy and its selected credential in advanced controls; adding an Assistant key alone may not resolve this.';
118
+ if (!state.route.ready) return 'The deployment configuration needs attention. Review its selected key and policy.';
119
+ if (!state.enabled) return 'The deployment configuration is ready. Enable the Assistant when you are ready.';
120
+ return 'Assistant configuration is ready. Provider availability has not been checked.';
121
+ }
122
+ export function assertAssistantSetupOrigin(): void {
123
+ if (usingMockTransport()) return;
124
+ if (new URL(apiOrigin(), window.location.origin).origin !== window.location.origin) throw new Error('Assistant setup actions require the same origin.');
125
+ }
126
+ export type SetupRequestOptions = Pick<FetchOpts, 'signal' | 'beforeSend'>;
127
+ export async function getAssistantSetup(options: SetupRequestOptions = {}, probe?: 'models' | 'discovery'): Promise<AssistantSetupState> {
128
+ const body = await mojoCall(ASSISTANT_SETUP_ENDPOINT, { ...options, params: probe === 'models' ? { refresh: 'models' } : probe === 'discovery' ? { check: 'discovery' } : undefined });
129
+ return projectAssistantSetup(body.data);
130
+ }
131
+ const ACTION_KEYS: Record<SetupAction['action'], readonly string[]> = {
132
+ save: ['action', 'enabled', 'model', 'api_key', 'clear_api_key', 'handler_api_key', 'clear_handler_api_key', 'mcp_enabled', 'emergency_stop', 'autonomous_triage'],
133
+ set_model_selection: ['action', 'mode', 'model'],
134
+ verify: ['action', 'target', 'api_key'], activate_policy: ['action'], reset_breaker: ['action', 'provider'], historical_triage: ['action', 'before', 'limit'], revoke_grant: ['action', 'grant_id'], revoke_all_grants: ['action'],
135
+ };
136
+ export function validateAssistantSetupAction(action: SetupAction): void {
137
+ if (!ACTION_KEYS[action.action] || Object.keys(action).some((key) => !ACTION_KEYS[action.action].includes(key))) throw new Error('Unsupported setup action fields.');
138
+ const v = action as unknown as Record<string, unknown>;
139
+ for (const key of ['api_key', 'handler_api_key']) if (v[key] !== undefined && (typeof v[key] !== 'string' || (v[key] as string).length > 4096 || /\s/.test(v[key] as string))) throw new Error('Enter a valid key without spaces.');
140
+ if (action.action === 'save') {
141
+ if (typeof action.enabled !== 'boolean' || (action.model !== undefined && (typeof action.model !== 'string' || (action.model !== '' && identifier(action.model) !== action.model)))) throw new Error('Enabled and a valid optional legacy model are required.');
142
+ for (const key of ['clear_api_key', 'clear_handler_api_key', 'mcp_enabled', 'emergency_stop', 'autonomous_triage']) if (v[key] !== undefined && typeof v[key] !== 'boolean') throw new Error('Setup switches must be boolean.');
143
+ if ((action.api_key && action.clear_api_key) || (action.handler_api_key && action.clear_handler_api_key)) throw new Error('Replace and clear are separate operations.');
144
+ }
145
+ if (action.action === 'set_model_selection' && (action.mode === 'auto' ? Object.hasOwn(action, 'model') : action.mode !== 'pinned' || !action.model || identifier(action.model) !== action.model)) throw new Error('Choose Automatic without a model or a valid manual pin.');
146
+ if (action.action === 'verify' && action.target !== 'assistant' && action.target !== 'handler') throw new Error('Select a credential target.');
147
+ if (action.action === 'reset_breaker' && action.provider !== undefined && !['anthropic', 'bedrock'].includes(action.provider)) throw new Error('Unsupported provider.');
148
+ if (action.action === 'historical_triage' && (!/^\d{4}-\d\d-\d\dT/.test(action.before) || !Number.isFinite(Date.parse(action.before)) || !Number.isInteger(action.limit) || action.limit < 1 || action.limit > 100)) throw new Error('Choose an ISO cutoff and a limit from 1 through 100.');
149
+ if (action.action === 'revoke_grant' && (!Number.isSafeInteger(action.grant_id) || action.grant_id <= 0)) throw new Error('Select a valid connection.');
150
+ }
151
+ export async function mutateAssistantSetup(action: SetupAction, options: SetupRequestOptions = {}): Promise<SetupActionResult> {
152
+ validateAssistantSetupAction(action); assertAssistantSetupOrigin();
153
+ const body = await mojoCall(ASSISTANT_SETUP_ENDPOINT, { method: 'POST', body: { ...action }, signal: options.signal, beforeSend: () => { assertAssistantSetupOrigin(); options.beforeSend?.(); } });
154
+ const raw = object(body.data);
155
+ if (raw.schema_version !== 2) throw new Error('Assistant setup schema is unavailable.');
156
+ return { state: projectAssistantSetup(raw.state), ...(action.action === 'verify' ? { result: projectCheck(raw.result) } : {}),
157
+ ...(typeof raw.saved === 'boolean' ? { saved: raw.saved } : {}), ...(typeof raw.verified === 'boolean' ? { verified: raw.verified } : {}), ...(typeof raw.activated === 'boolean' ? { activated: raw.activated } : {}),
158
+ ...(typeof raw.reset === 'number' ? { reset: number(raw.reset) } : {}), ...(typeof raw.revoked === 'number' ? { revoked: number(raw.revoked) } : {}), ...(typeof raw.queued === 'number' ? { queued: number(raw.queued) } : {}) };
159
+ }
160
+ /** Preserve old-server baselines; capable servers own model selection through its dedicated action. */
161
+ export function assistantSetupSave(state: AssistantSetupState, changes: Omit<SetupSave, 'action' | 'enabled' | 'model'> & { enabled?: boolean; model?: string }): SetupSave {
162
+ const { model, ...other } = changes;
163
+ return { action: 'save', enabled: state.enabled, ...(!state.model.capability ? { model: model ?? state.model.selected } : {}), ...other };
164
+ }
165
+
166
+ /** Only backend fixed pre-write credential refusals earn a definite no-write message. */
167
+ export function assistantSetupCredentialRefusal(error: unknown): string | null {
168
+ if (!(error instanceof Error)) return null;
169
+ for (const [wire, label] of [['api key', 'Assistant key'], ['handler api key', 'Platform key']] as const) {
170
+ if (error.message === `The ${wire} was not accepted. Anthropic rejected this key.`) return `The ${label} was rejected. Check the replacement and try again. Nothing was stored.`;
171
+ if (error.message === `The ${wire} was not accepted. Anthropic could not be reached to check this key.`) return `The ${label} could not be checked because Anthropic was unreachable. Nothing was stored; try again when the provider is available.`;
172
+ }
173
+ return null;
174
+ }