portal-mojo 0.1.0 → 0.2.0

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 (208) hide show
  1. package/README.md +42 -1
  2. package/docs/DetailView.md +24 -3
  3. package/docs/ModelTable.md +44 -0
  4. package/docs/README.md +1 -0
  5. package/docs/admin-assistant.md +14 -7
  6. package/docs/charts.md +7 -0
  7. package/docs/client.md +109 -0
  8. package/docs/dataview.md +6 -3
  9. package/docs/defineModel.md +49 -2
  10. package/docs/grouping-and-fmt.md +17 -1
  11. package/docs/image-editor.md +13 -0
  12. package/docs/menus-and-access.md +57 -0
  13. package/docs/params.md +12 -0
  14. package/docs/personas.md +212 -0
  15. package/docs/realtime.md +44 -2
  16. package/docs/route-error.md +64 -0
  17. package/package.json +15 -2
  18. package/src/admin/assistant/AssistantLauncher.tsx +23 -0
  19. package/src/admin/assistant/AssistantPanel.tsx +3 -2
  20. package/src/admin/assistant/api.ts +1 -1
  21. package/src/admin/assistant/index.ts +1 -11
  22. package/src/admin/assistant/launchers.tsx +5 -11
  23. package/src/admin/assistant/pages.tsx +1 -1
  24. package/src/admin/assistant/permissions.ts +1 -0
  25. package/src/admin/assistant/streaming.ts +69 -41
  26. package/src/admin/bouncer/index.ts +1 -28
  27. package/src/admin/bouncer/models.ts +1 -1
  28. package/src/admin/bouncer/signatures.tsx +1 -1
  29. package/src/admin/cloudwatch/CloudWatchChart.tsx +1 -1
  30. package/src/admin/cloudwatch/data.ts +1 -1
  31. package/src/admin/cloudwatch/index.ts +1 -11
  32. package/src/admin/core/index.ts +230 -0
  33. package/src/admin/credentials/group-api-keys.tsx +1 -1
  34. package/src/admin/credentials/models.ts +1 -1
  35. package/src/admin/credentials/webhook-subscriptions.tsx +1 -1
  36. package/src/admin/dashboard/AdminDashboardPage.tsx +9 -18
  37. package/src/admin/dashboard/data.ts +1 -1
  38. package/src/admin/dashboard/index.ts +1 -0
  39. package/src/admin/dns/CertificateDetail.tsx +1 -1
  40. package/src/admin/dns/CertificateLifecyclePoller.tsx +1 -1
  41. package/src/admin/dns/CertificatesPage.tsx +1 -1
  42. package/src/admin/dns/DnsRecordsPanel.tsx +1 -1
  43. package/src/admin/dns/DomainCertificatesSection.tsx +1 -1
  44. package/src/admin/dns/DomainDetail.tsx +1 -1
  45. package/src/admin/dns/DomainOnboardingDialog.tsx +1 -1
  46. package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
  47. package/src/admin/dns/ProviderCredentialsPage.tsx +1 -1
  48. package/src/admin/dns/RegistrantContactPage.tsx +1 -1
  49. package/src/admin/dns/api.ts +1 -1
  50. package/src/admin/dns/index.ts +2 -59
  51. package/src/admin/dns/models.ts +1 -1
  52. package/src/admin/dns/setup.ts +27 -0
  53. package/src/admin/domains/assistant.ts +13 -0
  54. package/src/admin/domains/communications.ts +35 -0
  55. package/src/admin/domains/identity.ts +24 -0
  56. package/src/admin/domains/infrastructure.ts +24 -0
  57. package/src/admin/domains/observability.ts +28 -0
  58. package/src/admin/domains/operations.ts +18 -0
  59. package/src/admin/domains/security.ts +55 -0
  60. package/src/admin/identity/members/GroupMembersPanel.tsx +1 -1
  61. package/src/admin/identity/members/MemberDetail.tsx +1 -1
  62. package/src/admin/identity/members/MembersPage.tsx +1 -1
  63. package/src/admin/identity/members/index.ts +1 -17
  64. package/src/admin/identity/members/member-flows.tsx +1 -1
  65. package/src/admin/identity/members/models.ts +1 -1
  66. package/src/admin/identity/users/UserDetail.tsx +1 -1
  67. package/src/admin/identity/users/UsersPage.tsx +1 -1
  68. package/src/admin/identity/users/index.ts +1 -16
  69. package/src/admin/identity/users/models.ts +1 -1
  70. package/src/admin/identity/users/sections/LoginsSection.tsx +1 -1
  71. package/src/admin/identity/users/sections/NotificationsSection.tsx +1 -1
  72. package/src/admin/identity/users/sections/PersonalSection.tsx +1 -1
  73. package/src/admin/identity/users/sections/actions.tsx +15 -6
  74. package/src/admin/identity/users/sections/permission-catalog.ts +1 -1
  75. package/src/admin/identity/users/sections/queries.ts +1 -1
  76. package/src/admin/identity/users/sections/shared.tsx +1 -1
  77. package/src/admin/incidents/EventsPage.tsx +1 -1
  78. package/src/admin/incidents/IncidentDetail.tsx +1 -1
  79. package/src/admin/incidents/IncidentsPage.tsx +1 -1
  80. package/src/admin/incidents/index.ts +1 -8
  81. package/src/admin/incidents/models.ts +1 -1
  82. package/src/admin/index.ts +5 -364
  83. package/src/admin/jobs/JobDetail.tsx +5 -3
  84. package/src/admin/jobs/JobsTablePage.tsx +1 -1
  85. package/src/admin/jobs/RunnerDetail.tsx +2 -2
  86. package/src/admin/jobs/ScheduledTaskDetail.tsx +1 -1
  87. package/src/admin/jobs/ScheduledTasksPage.tsx +1 -1
  88. package/src/admin/jobs/columns.tsx +1 -1
  89. package/src/admin/jobs/control.ts +1 -1
  90. package/src/admin/jobs/index.ts +1 -39
  91. package/src/admin/jobs/models.ts +1 -1
  92. package/src/admin/jobs/queries.ts +1 -1
  93. package/src/admin/jobs/sections/JobChannelsPanel.tsx +1 -1
  94. package/src/admin/jobs/sections/JobOperationsSection.tsx +1 -1
  95. package/src/admin/messaging/EmailDomainWizard.tsx +1 -1
  96. package/src/admin/messaging/PublicMessagesPage.tsx +1 -1
  97. package/src/admin/messaging/api.ts +1 -1
  98. package/src/admin/messaging/data.ts +1 -1
  99. package/src/admin/messaging/index.ts +1 -9
  100. package/src/admin/messaging/models.ts +1 -1
  101. package/src/admin/messaging/push/PushPage.tsx +1 -1
  102. package/src/admin/messaging/push/api.ts +1 -1
  103. package/src/admin/messaging/push/index.ts +1 -4
  104. package/src/admin/messaging/push/models.ts +1 -1
  105. package/src/admin/monitoring/MetricsExplorerPage.tsx +1 -1
  106. package/src/admin/monitoring/MetricsSourcePicker.tsx +1 -1
  107. package/src/admin/monitoring/index.ts +1 -41
  108. package/src/admin/monitoring/metrics-explorer-client.ts +2 -2
  109. package/src/admin/monitoring/models.ts +1 -1
  110. package/src/admin/network/BlockedIPsPage.tsx +10 -7
  111. package/src/admin/network/BlockedIpDetail.tsx +1 -1
  112. package/src/admin/network/FirewallLogPage.tsx +3 -1
  113. package/src/admin/network/IPSetDetail.tsx +1 -1
  114. package/src/admin/network/IPSetsPage.tsx +1 -1
  115. package/src/admin/network/geofence/BlocksTab.tsx +1 -1
  116. package/src/admin/network/geofence/ExemptionsTab.tsx +1 -1
  117. package/src/admin/network/geofence/GeofencingPage.tsx +1 -1
  118. package/src/admin/network/geofence/RulesTab.tsx +1 -1
  119. package/src/admin/network/geofence/geofence-data.ts +3 -21
  120. package/src/admin/network/geofence/permissions.ts +13 -0
  121. package/src/admin/network/index.ts +1 -41
  122. package/src/admin/network/models.ts +3 -9
  123. package/src/admin/network/permissions.ts +8 -0
  124. package/src/admin/phonehub/PhoneHubPage.tsx +3 -3
  125. package/src/admin/phonehub/api.ts +4 -1
  126. package/src/admin/phonehub/data.ts +1 -1
  127. package/src/admin/phonehub/index.ts +1 -4
  128. package/src/admin/phonehub/models.ts +1 -1
  129. package/src/admin/public/assistant-launcher.ts +1 -0
  130. package/src/admin/public/assistant.ts +2 -0
  131. package/src/admin/public/communications.ts +4 -0
  132. package/src/admin/public/identity.ts +3 -0
  133. package/src/admin/public/infrastructure.ts +4 -0
  134. package/src/admin/public/observability.ts +4 -0
  135. package/src/admin/public/operations.ts +3 -0
  136. package/src/admin/public/security.ts +7 -0
  137. package/src/admin/registry.ts +40 -0
  138. package/src/admin/rules/RuleSetDetailPage.tsx +1 -1
  139. package/src/admin/rules/RuleSetsPage.tsx +1 -1
  140. package/src/admin/rules/index.ts +1 -6
  141. package/src/admin/rules/models.ts +1 -1
  142. package/src/admin/security/devices/LoginEventDetail.tsx +1 -1
  143. package/src/admin/security/devices/LoginLocationsPage.tsx +1 -1
  144. package/src/admin/security/devices/UserDeviceDetail.tsx +1 -1
  145. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +1 -1
  146. package/src/admin/security/devices/index.ts +1 -22
  147. package/src/admin/security/devices/models.ts +1 -1
  148. package/src/admin/security/geoip/GeoIpCachePage.tsx +1 -1
  149. package/src/admin/security/geoip/GeoIpDossier.tsx +1 -1
  150. package/src/admin/security/geoip/geoip-forms.ts +1 -1
  151. package/src/admin/security/geoip/index.ts +1 -17
  152. package/src/admin/security/geoip/models.ts +1 -1
  153. package/src/admin/security/index.ts +1 -23
  154. package/src/admin/security/models.ts +1 -1
  155. package/src/admin/security/tickets.tsx +1 -1
  156. package/src/admin/settings/SettingDetail.tsx +1 -1
  157. package/src/admin/settings/SettingEditor.tsx +1 -1
  158. package/src/admin/settings/index.ts +1 -13
  159. package/src/admin/settings/model.ts +1 -1
  160. package/src/admin/shortlinks/index.ts +1 -12
  161. package/src/admin/shortlinks/models.ts +1 -1
  162. package/src/admin/storage/BackendsPage.tsx +1 -1
  163. package/src/admin/storage/FileManagerDetail.tsx +1 -1
  164. package/src/admin/storage/FileView.tsx +1 -1
  165. package/src/admin/storage/FilesPage.tsx +1 -1
  166. package/src/admin/storage/index.ts +1 -18
  167. package/src/admin/storage/models.ts +9 -4
  168. package/src/admin/storage/permissions.ts +3 -0
  169. package/src/admin/storage/storage-dialogs.tsx +1 -1
  170. package/src/client/action-result.ts +95 -0
  171. package/src/client/active-group.ts +29 -0
  172. package/src/client/client.ts +15 -1
  173. package/src/client/endpoint-scope.ts +79 -0
  174. package/src/client/group.tsx +9 -0
  175. package/src/client/index.ts +5 -0
  176. package/src/client/mock.ts +17 -12
  177. package/src/client/model.ts +26 -2
  178. package/src/client/params.ts +16 -4
  179. package/src/client/persist.ts +54 -0
  180. package/src/client/realtime-mock.ts +17 -13
  181. package/src/client/realtime.ts +18 -4
  182. package/src/client/runtime.ts +21 -0
  183. package/src/client/scoped.ts +93 -0
  184. package/src/client/upload.ts +6 -3
  185. package/src/personas/PersonaProvider.tsx +151 -0
  186. package/src/personas/audit.ts +73 -0
  187. package/src/personas/index.ts +13 -0
  188. package/src/personas/presets.ts +72 -0
  189. package/src/personas/registry.ts +111 -0
  190. package/src/personas/sections.tsx +202 -0
  191. package/src/ui/DetailView.tsx +30 -6
  192. package/src/ui/ModelTable.tsx +75 -9
  193. package/src/ui/RouteError.tsx +73 -0
  194. package/src/ui/SidebarNav.tsx +36 -5
  195. package/src/ui/active-persona.ts +34 -0
  196. package/src/ui/detail/DataView.tsx +10 -4
  197. package/src/ui/detail/KnownFieldsCard.tsx +13 -1
  198. package/src/ui/fixed-params.ts +58 -0
  199. package/src/ui/format.ts +36 -11
  200. package/src/ui/image-editor/ImageEditor.tsx +21 -22
  201. package/src/ui/image-editor/canvas.ts +82 -11
  202. package/src/ui/image-editor/math.ts +78 -0
  203. package/src/ui/index.ts +3 -0
  204. package/src/ui/menu-registry.ts +128 -9
  205. package/src/ui/route-error.ts +60 -0
  206. package/src/ui/safe-node.tsx +88 -0
  207. package/src/ui/shell.ts +13 -0
  208. package/src/ui/warn-once.ts +12 -0
@@ -0,0 +1,212 @@
1
+ # personas — hats/roles a signed-in user operates under (presentation only)
2
+
3
+ ```ts
4
+ import {
5
+ definePersonas, getPersonas, getPersona, availablePersonas, personaHome,
6
+ PersonaProvider, usePersona, usePersonaFollowsRoute,
7
+ personaSectionRoutes, personaSectionsMenu, personasOwningPath,
8
+ personaPresets, registerPersonaMemberPermissions,
9
+ auditPersonas,
10
+ type PersonaDef, type PersonaSection, type PersonaSectionChild,
11
+ type PersonaSectionOwnership, type PersonaPreset, type PersonaContextValue,
12
+ } from 'portal-mojo/personas';
13
+ import { readPersisted, writePersisted, clearPersisted } from 'portal-mojo/client';
14
+ ```
15
+
16
+ A **persona** is a working identity the user operates the portal AS — a
17
+ support agent, a brand owner, a compliance officer. Switching persona
18
+ reshapes the sidebar, the shell density, and "home". It changes NOTHING about
19
+ what the user may do.
20
+
21
+ ## The one invariant that matters
22
+
23
+ **A persona is presentation ONLY. `<Guarded>` / `hasPermission` are the
24
+ security boundary.** `hasPermission` has no persona input by design (the
25
+ verify script greps for this); `sys.*` stays system-pinned and untouched.
26
+ The worst an over-optimistic persona can produce is an empty shell — every
27
+ menu item still permission-filters fail-closed, every route is still Guarded.
28
+
29
+ ## Hats vs roles
30
+
31
+ - **Hat** — `gate` absent: available to every authenticated user. A pure
32
+ view preference ("show me the ops layout").
33
+ - **Role** — `gate` (any-of `PermSpec`): the persona appears only for users
34
+ holding one of its gate keys. Gate on **signature keys** — keys held by
35
+ exactly ONE preset. Gating on shared capability keys leaks portals
36
+ (`players.view` means "may read player records", not "is a support agent" —
37
+ the wmx 2026-08-09 audit found exactly this class). `auditPersonas`
38
+ mechanizes the check; strictness is opt-in.
39
+
40
+ ## Declaring personas
41
+
42
+ ```ts
43
+ // boot.ts — categories FIRST, then personas that reference them by name
44
+ registerPermissionCategories({ support: ['support.queue', 'support.moderation'] });
45
+ definePersonas([
46
+ { slug: 'support', label: 'Support', icon: 'bi-headset', home: '/support',
47
+ density: 'simple', gate: ['support.queue'],
48
+ grants: { categories: ['support'], keys: ['players.view'] } },
49
+ { slug: 'explorer', label: 'Explorer', home: '/' }, // a hat: no gate, no grants
50
+ ]);
51
+ ```
52
+
53
+ `definePersonas` REPLACES the registry (declare once at boot; HMR re-runs are
54
+ safe). Duplicate slugs are ignored WITH a console.warn — first wins.
55
+ `availablePersonas(me)`: `null` me → `[]`; hats always included for an
56
+ authenticated user; roles via `hasPermission(me, gate)` (user-level — member
57
+ context is not consulted).
58
+
59
+ ## PersonaProvider — the single owner
60
+
61
+ ```tsx
62
+ <PersonaProvider fallback="support">
63
+ <AppShell />
64
+ </PersonaProvider>
65
+ ```
66
+
67
+ Owns the active persona and fans it out to:
68
+
69
+ 1. **React context** — `usePersona()` → `{ persona, def, personas, available,
70
+ setPersona }` (throws outside the provider).
71
+ 2. **The menu-resolution signal** (`ui/active-persona.ts`) — how
72
+ `resolveActiveMenu`/`SidebarNav` receive the persona without importing this
73
+ module.
74
+ 3. **Root attributes** — `<html data-persona="support" data-density="simple">`
75
+ for persona-scoped styling. Unmount clears both signal and attributes.
76
+
77
+ Lifecycle: initial = valid persisted slug (`mojo:persona` via
78
+ `readPersisted`, validated against defined slugs — garbage is cleared, never
79
+ cast) → else a valid `fallback` prop → else the first defined persona. Once
80
+ `me` resolves and at least one persona is available, a persona the user
81
+ doesn't hold **snaps** to `available[0]` (state-only; the stored choice
82
+ survives for when grants return). Zero-available keeps the current defined
83
+ persona — safe because menus/routes stay fail-closed.
84
+
85
+ ### Root styling law
86
+
87
+ Persona-scoped CSS variables MUST carry `:root` defaults:
88
+
89
+ ```css
90
+ :root { --queue-accent: var(--accent); } /* the default is mandatory */
91
+ :root[data-persona='support'] { --queue-accent: var(--good); }
92
+ ```
93
+
94
+ Cautionary example (wmx): a variable defined ONLY under
95
+ `[data-wmx-view='support']` rendered as the literal fallback off-lens — every
96
+ surface styled per-persona was broken for every other persona. Pre-auth the
97
+ attribute may also briefly be absent entirely. Default at `:root`, override
98
+ under `data-persona`. Same for `data-density` (`'simple' | 'dense'`; the
99
+ provider stamps `dense` when a persona doesn't say).
100
+
101
+ ## Menus: persona is a resolution INPUT
102
+
103
+ `MenuConfig.personas?: string[]` scopes a menu to persona slugs. Menus are
104
+ **declared ONCE** at import/boot via `registerMenus` — never re-registered to
105
+ switch personas (that was wmx's `applyViewMenu` mutation; the primitive
106
+ replaces it). Resolution (`ui/menu-registry.ts`):
107
+
108
+ 1. First registered **eligible** menu whose visible items contain the route —
109
+ persona-scoped menus are eligible only while their persona is active, so
110
+ two personas may both carry `/players` and the active persona's menu wins.
111
+ 2. **2a.** The active persona's own first menu (preferring one with visible
112
+ items) — beats `defaultMenu`.
113
+ 3. 2b. `defaultMenu`, then the agnostic fallback chain.
114
+
115
+ Apps that never touch personas are unaffected: the signal stays `null`,
116
+ persona-scoped menus are simply never eligible, agnostic menus resolve
117
+ exactly as before.
118
+
119
+ ## PersonaSection — routes + sidebar from one declaration
120
+
121
+ ```tsx
122
+ const SECTIONS: PersonaSection[] = [{
123
+ id: 'wmx:support', label: 'Support Console', icon: 'bi-headset',
124
+ basePath: 'support', permissions: ['players.view', 'support.queue'],
125
+ groupScoped: true,
126
+ personas: { support: {}, owner: { label: 'Support (read)', navGroup: 'Oversight' } },
127
+ element: <ConsoleHome />,
128
+ children: [
129
+ { path: 'redemptions', label: 'Redemptions', icon: 'bi-cash-coin',
130
+ navGroup: 'Work queues', permissions: ['redemptions.view', 'sys.admin'],
131
+ element: <RedemptionsQueuePage /> },
132
+ { path: 'player/:id', label: 'Player', hidden: true,
133
+ permissions: ['players.view'], element: <PlayerView /> }, // route, no nav row
134
+ ],
135
+ }];
136
+
137
+ const router = createHashRouter([{ element: <AppShell />, children: [
138
+ ...personaSectionRoutes(SECTIONS), /* … */
139
+ ]}]);
140
+ registerMenus(personaSectionsMenu(SECTIONS)); // ONCE, at boot
141
+ ```
142
+
143
+ - `personaSectionRoutes` — every element wrapped in fail-closed
144
+ `<Guarded permission={…} fallback={null}>`; `groupScoped` adds
145
+ `<RequiresGroup>` INSIDE the guard. Deliberately **not** admin/core's
146
+ `guardedElement`: that wraps `AdminGlobalScope`, which blanks group/member —
147
+ right for the framework admin, wrong for product surfaces where member
148
+ grants count. Hidden children still route.
149
+ - `personaSectionsMenu` — ONE `MenuConfig` per persona slug
150
+ (`name: 'persona:<slug>'`, `personas: [slug]`); items are that persona's
151
+ sections with per-persona `label`/`navGroup`/`order` overrides applied;
152
+ `navGroup` renders as side-label divider rows; every item carries the
153
+ section/child permissions so `itemVisible` filters fail-closed.
154
+ - `usePersonaFollowsRoute()` — call once in the shell: when navigation lands
155
+ on a route owned by other personas' sections and the user HOLDS an owner,
156
+ the persona converges to it. Never navigates. (Owners derive from the
157
+ sections passed to the two builders above — wire routes/menus first.)
158
+ - `personaHome(slug)` — the persona's landing route (unknown slug → warn + `/`).
159
+
160
+ ## Phase B — presets as data
161
+
162
+ `grants` on a persona is its preset: `keys` (granulars) plus `categories`
163
+ (**names** of categories the app already registered via
164
+ `registerPermissionCategories` — register categories first; an unknown name
165
+ expands to nothing with a warn).
166
+
167
+ - `personaPresets()` → `{slug, label, keys}[]` — deduped union per persona;
168
+ hats without `grants` are omitted.
169
+ - `registerPersonaMemberPermissions({ tooltipBySlug? })` — feeds the union of
170
+ preset keys into the admin members catalog (`registerMemberPermissions`) so
171
+ team UIs render switches. `sys.*`/non-editable keys are dropped up front —
172
+ system-pinned keys can never be member grants.
173
+ - The **"apply preset"** button (check a preset's boxes on a member form) is
174
+ app-side UI over this data; the server stays authoritative on every write.
175
+
176
+ ## auditPersonas
177
+
178
+ ```ts
179
+ auditPersonas(getPersonas(), personaPresets()) // leak report
180
+ auditPersonas(getPersonas(), personaPresets(), { strict: 'signature-diagonal' })
181
+ ```
182
+
183
+ Default findings: a persona's gate key held by ANOTHER persona's preset (that
184
+ preset's holders see a portal that isn't theirs). Strict adds: every gate key
185
+ held by exactly one preset, and each persona's same-slug preset satisfies its
186
+ own gate. Hats are skipped. Run it in a boot-time dev assert or a verify
187
+ script; `findings: []` is the pass state.
188
+
189
+ ## Persistence helper (`portal-mojo/client`)
190
+
191
+ `readPersisted(key, validate)` / `writePersisted(key, value)` /
192
+ `clearPersisted(key)` — the params.ts localStorage discipline for single
193
+ values: feature-detected storage, validate-don't-cast, invalid/unparseable
194
+ entries **cleared and null**, silent no-op writes. JSON canonical; a legacy
195
+ bare-string value is offered to the validator raw and self-heals to JSON on
196
+ the next write.
197
+
198
+ ## Pitfalls
199
+
200
+ - **Menus are declared once.** Re-registering per persona reintroduces
201
+ mutable menu state; the persona input + `MenuConfig.personas` replaces it.
202
+ - **Pre-auth optimism.** Before `me` resolves the stored persona is applied;
203
+ its menu may render with zero visible items until grants load. That is the
204
+ fail-closed contract working, not a bug — don't "fix" it by widening
205
+ `itemVisible`.
206
+ - **localStorage is hostile.** Never `getItem(...) as Persona` — always the
207
+ validate-then-clear read (`readPersisted`); the provider already does this
208
+ for `mojo:persona`.
209
+ - **Don't gate on shared capability keys** (role personas) — signature keys
210
+ only; `auditPersonas` strict mode enforces it.
211
+ - **Don't reach for AdminSection** for persona surfaces — its guard blanks
212
+ the active group, so member grants stop counting.
package/docs/realtime.md CHANGED
@@ -28,8 +28,50 @@ disable this shared provider.
28
28
  separately and is never explicitly subscribed by the client. That automatic
29
29
  topic is a delivery scope, not a presence feed.
30
30
  - Framework messages, one wrapped `{type:'message',data,topic?,timestamp?}`
31
- layer, and direct application events share one dispatch boundary. Malformed
32
- frames drop. No generic Query invalidation is performed.
31
+ layer, and direct application events share one dispatch boundary. Every
32
+ projected event carries `source: 'wrapped' | 'direct'` so consumers can keep
33
+ capability-bearing protocols on their exact wire source. Malformed frames
34
+ drop. No generic Query invalidation is performed.
35
+
36
+ ## Error frames
37
+
38
+ A top-level `{type:'error'}` frame is attributed in order:
39
+
40
+ 1. **During authentication** — auth failure: the client closes and suspends
41
+ until the token value changes. Surfaces through the lifecycle status only;
42
+ no `error` event is dispatched.
43
+ 2. **While a subscribe/unsubscribe is pending** — that operation's denial
44
+ (acknowledgements are uncorrelated, so denial is a generic `error`); the
45
+ topic reports `denied`. Surfaces through the topic status only; no `error`
46
+ event is dispatched.
47
+ 3. **Otherwise** — a backend answer to an application frame (chat rate limit,
48
+ room rules, moderation block). Dispatched as an observable `error` event
49
+ with `source: 'direct'`.
50
+
51
+ Application error frames carry the message in `error` and may carry a
52
+ `reasons` array: `{type:'error', error:'Rate limit exceeded'}`,
53
+ `{type:'error', error:'Message blocked by moderation', reasons:[...]}`.
54
+
55
+ Known ambiguity, inherent to the uncorrelated wire: an application error
56
+ landing while a subscribe/unsubscribe is pending is still read as that
57
+ operation's denial.
58
+
59
+ Consumer pattern — a chat composer listens for `error` alongside its ack event
60
+ and surfaces the backend's message instead of guessing from a timeout:
61
+
62
+ ```tsx
63
+ const chatError = defineRealtimeEvent('error', (value) => {
64
+ const frame = value as {error?: unknown; reasons?: unknown};
65
+ return typeof frame?.error === 'string'
66
+ ? {error: frame.error, reasons: Array.isArray(frame.reasons) ? frame.reasons : undefined}
67
+ : null;
68
+ });
69
+
70
+ useRealtimeEvent(chatError, ({data}) => showSendFailure(data.error, data.reasons));
71
+ ```
72
+
73
+ The may-not-have-sent timeout heuristic remains the right fallback for the
74
+ no-frame-at-all case — a socket drop mid-send produces no error frame at all.
33
75
 
34
76
  The transport does not invent application events. Current django-mojo has no
35
77
  canonical record-created/updated/deleted stream and no canonical presence
@@ -0,0 +1,64 @@
1
+ # RouteError — the route-level last-resort error card
2
+
3
+ ```ts
4
+ import { RouteError, RouteErrorCard, describeRouteError, isChunkLoadError, isNotFoundError } from 'portal-mojo/ui';
5
+ // also exported from 'portal-mojo/ui/shell'
6
+ ```
7
+
8
+ Any error that reaches the router without this renders React Router's raw
9
+ default — "Unexpected Application Error!" on a white page. `RouteError` is
10
+ the styled replacement: real message + stack for `Error` throws, a
11
+ status/body/JSON dump for object throws (rejected mojo envelopes — never
12
+ `[object Object]`), a "Page not found" branch for 404s, and Reload +
13
+ Back-to-home actions. Token classes only; renders correctly in both themes,
14
+ inside the shell or standalone.
15
+
16
+ ## The error-handling ladder
17
+
18
+ 1. **`safeNode` / `RenderGuard`** (#1602) — per-site: table cells, detail
19
+ sections. Degrade to a marker; the page survives.
20
+ 2. **`LazyPageBoundary`** (#1557, narrowed by #1604) — **chunk-load failures
21
+ only** (stale deploy): the "Retry" card. Any other error is rethrown —
22
+ a real page crash must not be mislabeled "bundle unavailable".
23
+ 3. **Child-level `errorElement`** — a pathless wrapper around the app's
24
+ routes: a page crash renders the card **inside the app shell**
25
+ (Sidebar/TopNav stay alive).
26
+ 4. **Root-level `errorElement`** — 404s and shell crashes. By router
27
+ semantics it *replaces* the root element, so the card renders bare and
28
+ is styled to stand alone.
29
+
30
+ ## Wiring (the portal scaffold ships this)
31
+
32
+ ```tsx
33
+ const router = createHashRouter([
34
+ {
35
+ path: '/',
36
+ element: <RequireAuth><App /></RequireAuth>,
37
+ errorElement: <RouteError />, // 404s + shell crashes (bare)
38
+ children: [
39
+ { errorElement: <RouteError />, children: [...adminRoutes] }, // in-shell page crashes
40
+ ],
41
+ },
42
+ { path: '/auth', element: <AuthLayout />, errorElement: <RouteError />, children: [/* lazy pages */] },
43
+ ]);
44
+ ```
45
+
46
+ ## API
47
+
48
+ | Export | What |
49
+ |---|---|
50
+ | `RouteError({ homeTo? })` | Data-router wrapper: reads `useRouteError()`. Use as `errorElement`. |
51
+ | `RouteErrorCard({ error, homeTo?, onReload? })` | Presentational card. Use outside data routers (`useRouteError` THROWS there — the showcase's plain `<HashRouter>` is why this split exists). `onReload` replaces the hard `window.location.reload()` on both actions (demos/tests). |
52
+ | `describeRouteError(err)` | Total stringifier: Error → message+stack; object → status/statusText head + string body + JSON dump (circular-safe); else `String(err)`. |
53
+ | `isNotFoundError(err)` | Structural router-ErrorResponse check with `status === 404`. |
54
+ | `isChunkLoadError(err)` | Vite dynamic-import failure predicate. When in doubt it returns `false` — a rethrown real crash with the true message beats a wrong Retry card. |
55
+
56
+ ## Caveats
57
+
58
+ - **Unauthenticated 404 deep links** see "Page not found" instead of the
59
+ login redirect: the root `errorElement` replaces `RequireAuth`. Deliberate
60
+ — Back-to-home re-enters through the auth guard.
61
+ - The 404 branch suppresses the JSON dump (noise); the crash branch shows
62
+ everything a developer needs. The crash still logs to the console.
63
+ - The card never itself crashes: no queries, no app context,
64
+ `describeRouteError` is total.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -26,9 +26,22 @@
26
26
  },
27
27
  "exports": {
28
28
  "./client": "./src/client/index.ts",
29
+ "./client/runtime": "./src/client/runtime.ts",
29
30
  "./ui": "./src/ui/index.ts",
31
+ "./ui/shell": "./src/ui/shell.ts",
30
32
  "./charts": "./src/charts/index.ts",
31
- "./admin": "./src/admin/index.ts"
33
+ "./personas": "./src/personas/index.ts",
34
+ "./admin": "./src/admin/index.ts",
35
+ "./admin/core": "./src/admin/core/index.ts",
36
+ "./admin/registry": "./src/admin/registry.ts",
37
+ "./admin/identity": "./src/admin/public/identity.ts",
38
+ "./admin/security": "./src/admin/public/security.ts",
39
+ "./admin/observability": "./src/admin/public/observability.ts",
40
+ "./admin/operations": "./src/admin/public/operations.ts",
41
+ "./admin/infrastructure": "./src/admin/public/infrastructure.ts",
42
+ "./admin/communications": "./src/admin/public/communications.ts",
43
+ "./admin/assistant": "./src/admin/public/assistant.ts",
44
+ "./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts"
32
45
  },
33
46
  "scripts": {
34
47
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState, type MouseEvent } from 'react';
2
+ import { useCan } from '../../client/runtime';
3
+ import { useRightPanel } from '../../ui/RightPanel';
4
+ import { ASSISTANT_PERMISSIONS } from './permissions';
5
+
6
+ export function AssistantLauncher() {
7
+ const { can } = useCan(ASSISTANT_PERMISSIONS);
8
+ const panel = useRightPanel();
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState('');
11
+ useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
12
+ if (!can) return null;
13
+ const open = async (event: MouseEvent<HTMLButtonElement>) => {
14
+ const launcher = event.currentTarget;
15
+ setLoading(true); setError('');
16
+ try {
17
+ const { AssistantPanel } = await import('./AssistantPanel');
18
+ panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, launcher);
19
+ } catch { setError('Assistant could not load. Click to retry.'); }
20
+ finally { setLoading(false); }
21
+ };
22
+ return <button type="button" className="btn-icon" title={error || 'Open Assistant'} aria-label={error || 'Open Assistant'} aria-pressed={panel.descriptor?.key === 'assistant:new'} disabled={loading} onClick={(event) => void open(event)}><i className="bi bi-stars" aria-hidden="true" /></button>;
23
+ }
@@ -1,13 +1,14 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
2
+ import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
3
3
  import type { RightPanelRenderContext } from '../../ui';
4
4
  import { AssistantFeed } from './AssistantFeed';
5
5
  import { sendAssistantMessage } from './api';
6
6
  import type { AssistantConversation, AssistantMessage } from './types';
7
7
  import type { FileReference } from '../../client/record-feed';
8
8
  import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeProgress, type AssistantRealtimeTurn } from './streaming';
9
+ import { ASSISTANT_PERMISSIONS } from './permissions';
9
10
 
10
- export const ASSISTANT_PERMISSIONS = ['sys.view_admin', 'sys.assistant'];
11
+ export { ASSISTANT_PERMISSIONS } from './permissions';
11
12
 
12
13
  export function AssistantPanel({ context, initialConversation = null }: { context: RightPanelRenderContext; initialConversation?: AssistantConversation | null }) {
13
14
  const { can } = useCan(ASSISTANT_PERMISSIONS);
@@ -1,4 +1,4 @@
1
- import { mojoCall, mojoDelete, mojoList, type Params } from '../../client';
1
+ import { mojoCall, mojoDelete, mojoList, type Params } from '../../client/runtime';
2
2
  import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
3
3
  import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
4
4
  import type { FileReference } from '../../client/record-feed';
@@ -1,6 +1,3 @@
1
- import type { AdminSection } from '../index';
2
- import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
3
- import { ConversationsPage, MemoriesPage, SkillsPage } from './pages';
4
1
 
5
2
  export * from './types';
6
3
  export * from './data';
@@ -11,11 +8,4 @@ export * from './launchers';
11
8
  export * from './pages';
12
9
  export * from './streaming';
13
10
 
14
- export const ASSISTANT_ADMIN_SECTION: AdminSection = {
15
- id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
16
- routes: [
17
- { path: 'conversations', label: 'Conversations', component: ConversationsPage, permissions: ASSISTANT_PERMISSIONS },
18
- { path: 'skills', label: 'Skills', component: SkillsPage, permissions: ASSISTANT_PERMISSIONS },
19
- { path: 'memories', label: 'Memory', component: MemoriesPage, permissions: ['sys.assistant'] },
20
- ],
21
- };
11
+ export { ASSISTANT_ADMIN_SECTION } from '../domains/assistant';
@@ -1,17 +1,11 @@
1
1
  import { useEffect, useRef, useState, type MouseEvent } from 'react';
2
- import { useCan } from '../../client';
3
- import { useRightPanel } from '../../ui';
2
+ import { useCan } from '../../client/runtime';
3
+ import { useRightPanel } from '../../ui/RightPanel';
4
4
  import { SECURITY_VIEW_PERMS } from '../security-permissions';
5
- import { AssistantPanel, ASSISTANT_PERMISSIONS } from './AssistantPanel';
5
+ import { ASSISTANT_PERMISSIONS } from './permissions';
6
6
  import { createAssistantContext } from './api';
7
7
 
8
- export function AssistantLauncher() {
9
- const { can } = useCan(ASSISTANT_PERMISSIONS);
10
- const panel = useRightPanel();
11
- useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
12
- if (!can) return null;
13
- return <button type="button" className="btn-icon" title="Open Assistant" aria-label="Open Assistant" aria-pressed={panel.descriptor?.key === 'assistant:new'} onClick={(event) => panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, event.currentTarget)}><i className="bi bi-stars" aria-hidden="true" /></button>;
14
- }
8
+ export { AssistantLauncher } from './AssistantLauncher';
15
9
 
16
10
  export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }: { model: 'incident.Incident' | 'incident.Ticket'; pk: number; label?: string }) {
17
11
  const assistant = useCan(ASSISTANT_PERMISSIONS);
@@ -25,7 +19,7 @@ export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }:
25
19
  if (!assistant.can || !security.can) return null;
26
20
  const launch = async (event: MouseEvent<HTMLButtonElement>) => {
27
21
  const launcher = event.currentTarget; const run = ++generation.current; setLoading(true); setError('');
28
- try { const conversation = await createAssistantContext(model, pk); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
22
+ try { const [conversation, { AssistantPanel }] = await Promise.all([createAssistantContext(model, pk), import('./AssistantPanel')]); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
29
23
  catch (cause) { if (generation.current === run) setError(cause instanceof Error ? cause.message : 'Could not create context'); }
30
24
  finally { if (generation.current === run) setLoading(false); }
31
25
  };
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
2
+ import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
3
3
  import { Badge, fmt, modal, toast } from '../../ui';
4
4
  import { AssistantFeed } from './AssistantFeed';
5
5
  import { deleteAssistantConversation, deleteAssistantMemory, deleteAssistantSkill, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
@@ -0,0 +1 @@
1
+ export const ASSISTANT_PERMISSIONS = ['sys.view_admin', 'sys.assistant'];