cortena-ui 1.14.0 → 1.16.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 (38) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +205 -0
  3. package/dist/app.d.ts +6 -0
  4. package/dist/app.js +5 -0
  5. package/dist/components/admin-permissions/index.js +11 -0
  6. package/dist/components/app/agent-mount.d.ts +53 -0
  7. package/dist/components/app/agent-mount.js +40 -0
  8. package/dist/components/app/agent-mount.js.map +1 -0
  9. package/dist/components/app/cortena-app.d.ts +204 -0
  10. package/dist/components/app/cortena-app.js +426 -0
  11. package/dist/components/app/cortena-app.js.map +1 -0
  12. package/dist/components/app/launch.d.ts +128 -0
  13. package/dist/components/app/launch.js +149 -0
  14. package/dist/components/app/launch.js.map +1 -0
  15. package/dist/components/app/settings.d.ts +132 -0
  16. package/dist/components/app/settings.js +424 -0
  17. package/dist/components/app/settings.js.map +1 -0
  18. package/dist/components/badge.d.ts +1 -1
  19. package/dist/components/data-table/use-data-table.js +1 -19
  20. package/dist/components/data-table/use-data-table.js.map +1 -1
  21. package/dist/components/login-screen.d.ts +58 -2
  22. package/dist/components/login-screen.js +316 -12
  23. package/dist/components/login-screen.js.map +1 -1
  24. package/dist/components/toast.d.ts +1 -1
  25. package/dist/core.d.ts +3 -3
  26. package/dist/index.d.ts +5 -5
  27. package/dist/lib/dev.js +44 -0
  28. package/dist/lib/dev.js.map +1 -0
  29. package/package.json +5 -1
  30. package/src/components/app/agent-mount.tsx +103 -0
  31. package/src/components/app/cortena-app.tsx +811 -0
  32. package/src/components/app/launch.ts +227 -0
  33. package/src/components/app/settings.tsx +669 -0
  34. package/src/components/data-table/use-data-table.ts +1 -19
  35. package/src/components/login-screen.tsx +532 -16
  36. package/src/entries/app.ts +73 -0
  37. package/src/index.ts +12 -5
  38. package/src/lib/dev.ts +48 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,204 @@
3
3
  Notable changes per release. Versions before 1.6.0 are recorded in the git log
4
4
  and in `../../CONSUMING.md`; this file starts where the changelog does.
5
5
 
6
+ ## 1.16.0
7
+
8
+ ### Fixed
9
+
10
+ - **The login screen asks for the organisation** (DESIGN-126, found by the
11
+ DESIGN-125 pilot). cortena-auth's `POST /api/auth/login` takes
12
+ `{ orgId, email, password }` and rejects a body without `orgId` with a 400
13
+ before it looks at the credentials. `LoginScreen` collected email and
14
+ password only, and `CortenaApp` declared `auth.orgId` and read it nowhere —
15
+ so a fresh browser opening an extension URL directly, with no `?org=` and
16
+ nothing remembered, posted a login that could not succeed and read back a
17
+ validation error it could do nothing about. Every extension on the frame had
18
+ it. The launch path (a `?token=` from CortenaWeb) and a returning browser
19
+ were never affected, which is why it survived.
20
+
21
+ Sign-in is now the two steps cortena-auth's own hosted page asks:
22
+ the organisation, then the credentials. The organisation step is *skipped*
23
+ whenever the caller knows the answer — `CortenaApp` passes `auth.orgId`
24
+ through as `LoginScreen`'s default — so the returning browser that is the
25
+ overwhelmingly common case is never asked. Where the app does not know, the
26
+ screen asks rather than posting a request that cannot succeed.
27
+
28
+ ### Added
29
+
30
+ - **`LoginScreen`: `orgId` and `onOrg`.** `orgId` is the default for the
31
+ organisation step — the `?org=` on the launch URL, or the value this browser
32
+ used last — and supplying it skips the step. It is a default, not a value:
33
+ the user can change it from the credentials step, and a caller that learns
34
+ the organisation late (an `auth-info` call, a remembered value read after
35
+ mount) can pass it then and the screen moves on without a flash of the wrong
36
+ step. `onOrg` lets the app resolve what the user typed —
37
+ `GET /api/orgs/:orgId/auth-info` — and hand back a fresh `methods` list;
38
+ return a promise and the screen waits on it, rejecting to stay on the step
39
+ with your message. The fetch stays in the app, as `methods` always has.
40
+ - **`CortenaApp`: `auth.onOrg`**, the same seam on the frame's contract, and
41
+ `auth.orgId` now reaches the screen instead of being decorative.
42
+ - **Validation in the user's words**, and three different sentences for three
43
+ different problems, because the user's next move differs in each. An empty
44
+ organisation says "Enter your organization ID to continue." A lookup the app
45
+ refused says the app's own message, or "We could not find that organization.
46
+ Check the ID with your administrator." A lookup that never reached the
47
+ service — `fetch` rejecting with a `TypeError`, an `AbortError` — says "We
48
+ could not reach the sign-in service. Check your connection and try again.",
49
+ because a dropped connection says nothing about the organisation and sending
50
+ a user with a valid ID to their administrator over dead Wi-Fi helps nobody.
51
+ A lookup that threw a bug (a `SyntaxError` from a body that was not JSON)
52
+ says "Something went wrong looking up that organization. Try again." **and
53
+ warns in development**, so the author hears about it instead of it vanishing.
54
+ The existing `error` and `loading` props keep their meanings exactly.
55
+ - **A way out of a lookup that never comes back.** An `onOrg` that never
56
+ settles used to leave the step disabled for the rest of the session with no
57
+ control on the page that did anything. A Cancel button appears beside
58
+ Continue for the screen's own wait — not for the caller's `loading`, which is
59
+ the caller's to end.
60
+ - **The step announces itself.** A visually hidden `role="status"` line says
61
+ which of the two steps is on screen, so a screen-reader user who has just
62
+ pressed Continue hears something move.
63
+ - **The organisation on the credentials step**, with "Use a different
64
+ organization" beside it, so a user signed in to the wrong one — or turned
65
+ away by a 400 the caller surfaced through `error` — has something to do about
66
+ it rather than a dead screen. It is a real control: the button scale's own
67
+ smallest step (32px, over WCAG 2.2 AA 2.5.8's 24px floor) and underlined at
68
+ rest, so colour is not the only thing marking it as one.
69
+
70
+ ### Changed
71
+
72
+ - **`PasswordCredentials` gains `orgId`**, always non-empty and already
73
+ trimmed, and `onProvider` gains a second argument, the organisation —
74
+ cortena-auth requires `orgId` beside a provider's `idToken` exactly as it does
75
+ beside a password.
76
+
77
+ **Why this is a minor and not a major, said plainly.** Both shapes are
78
+ produced by the component and consumed by the caller, so a caller gains a
79
+ field and an argument rather than owing either; a one-argument `onProvider`
80
+ handler still satisfies the type, and a handler that reads `credentials.email`
81
+ is unaffected. There are exactly two ways to break a compile on this, and both
82
+ are producer-side: constructing a `PasswordCredentials` literal by hand (a
83
+ test double, a story fixture) or *calling* `onProvider` yourself. Neither
84
+ exists anywhere in the fleet — checked across cortena-extensions, where the
85
+ conformance fixture and the Tasks pilot both only pass handlers in. The
86
+ behavioural change is the new step, and passing `orgId` suppresses it
87
+ entirely, which `CortenaApp` now does.
88
+ - **A `methods` list is pinned to the organisation it describes.** `methods`
89
+ comes from one org's `auth-info`, and nothing in the props said which, so the
90
+ screen now keeps the pairing itself and falls back to password when the user
91
+ moves to a different organisation without a fresh list arriving. Without this
92
+ an SSO-only org's provider buttons survived onto the next org — running the
93
+ first org's Firebase project while the sign-in posted the second org's id,
94
+ with no password form either: a screen with no working way in. Where this
95
+ fallback fires and no `onOrg` was given, it says so in a development warning.
96
+ - The organisation field is a plain `<input>` with `autocomplete="organization"`
97
+ — deliberately not a Combobox. There is no list of organisations to offer, and
98
+ an empty combobox is a listbox a screen reader announces and cannot type its
99
+ way out of.
100
+
101
+ ## 1.15.0
102
+
103
+ ### Added
104
+
105
+ - **`cortena-ui/app` — `CortenaApp`, the whole frame** (DESIGN-119). Everything
106
+ around an extension's content, mounted once instead of assembled eight ways:
107
+ the launch, `LoginScreen` with the methods the org advertises, the signed-out
108
+ state with a `returnTo`, exactly one session guard, `AppShell`,
109
+ `AgentChatPopup` in `agentSlot`, the tab title, and the standard Profile /
110
+ Settings / Permissions pages. The 2026-09-14 re-audit is what it replaces:
111
+ seven local login pages across eight extensions, three of them with dummy
112
+ users; a guard mounted with a different subset of its behaviour in each; a
113
+ pop-up mounted without the user's token or the agent slug; a Settings item
114
+ greyed out in every one of the six shell swaps.
115
+ - **A `host` role.** `role="host"` leaves the agent slot empty, because
116
+ CortenaWeb *is* the chat and its own guard is the one that renews and pushes
117
+ tokens into framed extensions. Host-only navigation is `nav` and `headerSlot`
118
+ configuration, not a special case inside the composition. cortena-auth's
119
+ hosted pages are the third consumer.
120
+ - **`readCortenaLaunch({ hostOrigins })`**, and with it the rule that a framed
121
+ extension trusts the host's guard **only when the host declares it**. The
122
+ declaration — a `cortenaGuard=host` parameter beside the token, or the first
123
+ bridge message — is origin-checked against an allow-list the app supplies
124
+ from its own configuration, never from the request. Declared: no guard here,
125
+ and renewed tokens arrive through `session.onTokenRenewed`. Absent: a
126
+ countdown from the access token's own `exp`, the standard warning with no
127
+ Continue, then the signed-out state. Not framed: the full `SessionGuard`. One
128
+ of the three, ever — never two countdowns for one session, and never the old
129
+ behaviour of inferring "the host is guarding me" from the absence of a
130
+ refresh token.
131
+ - **The standard pages**, at `/me`, `/settings` and `/settings/permissions`:
132
+ Profile, then Permissions from `AdminPermissions` against the §14.3 admin
133
+ routes when the caller may administer, then the extension's own settings from
134
+ a declared zod schema — or one plain line, "Nothing to configure for <name>
135
+ yet." The avatar menu's Settings item is never disabled again. Exported
136
+ individually as `CortenaSettingsPage`, `CortenaProfilePage` and
137
+ `ProfileSection`, with `matchCortenaStandardRoute` for an app that would
138
+ rather place them under its own router.
139
+ - **`session.hostOrigins`**, and with it the bridge path for the declaration:
140
+ the composition listens for the host's first
141
+ `{ type: "cortena-session", guarded, accessToken }` and takes the declaration
142
+ and any renewal from it. Prefer this over the launch parameter —
143
+ `event.origin` is the browser's and cannot be forged from inside the sending
144
+ page, where the parameter's check falls back to `document.referrer` on a
145
+ browser with no `ancestorOrigins`. The gap is downgrade-only (a forged
146
+ declaration mounts *no* guard, never a second one) and is written down in
147
+ `launch.ts`.
148
+ - **`session.expiresAt`**, for a token whose `exp` cannot be read — an opaque
149
+ token, or one the decoder failed on. Exactly `SessionToken`'s own escape
150
+ hatch, and the way to keep such a session guarded rather than landing on the
151
+ signed-out screen.
152
+ - **A development warning** where a non-framed launch omits `session.refresh`.
153
+ Without it the guard reaches the proactive refresh at about 75% of the access
154
+ token's life, is rejected, and signs the user out — §10.4.1's half-dead
155
+ screen arriving from the inside, with nothing on screen to explain it.
156
+ - **`CortenaApp` is router- and environment-agnostic**: `pathname` in,
157
+ `onNavigate` out, real `href`s, configuration and tokens as props, no browser
158
+ API touched during a first render, and `documentTitle={false}` for a surface
159
+ that does not own the tab. That is what lets CortenaWeb adopt it inside a
160
+ Next.js root layout instead of keeping a second shell.
161
+
162
+ ### Fixed
163
+
164
+ - **A signed-out screen you could not leave.** The state was cleared only where
165
+ a *host* pushed a token, and that path exists only in a host-guarded frame.
166
+ On a standalone launch or an undeclared frame the signed-out screen
167
+ short-circuits the whole render, so a successful sign-in set the token and
168
+ the user stayed on "You were signed out" for ever with nothing on screen to
169
+ explain it. A new token now clears it wherever it comes from — the launch,
170
+ the app's own store, `session.onTokenRenewed`, or the bridge.
171
+ - **A frame with an unreadable token was silently unguarded.** A token with no
172
+ readable `exp` took the countdown branch and then counted nothing: no
173
+ warning, no expiry, no signed-out state — the exact state decision (b)
174
+ removed, arriving through the back door. It is now reported as already over,
175
+ which lands on the signed-out screen with a `returnTo`, and says why in a
176
+ development warning. Warning rather than expiring was considered and
177
+ rejected: a warning's entire content is a number there is none of, and its
178
+ only way out is Log out, which lands on the same screen anyway.
179
+ - **A rejected sign-out** left an unhandled rejection and the user on a shell
180
+ whose session was over. It is caught, and the screen stops pretending either
181
+ way — `SessionGuard` makes the same call.
182
+ - **`settings.load()` is no longer called** for a declaration with no readable
183
+ fields. The card renders one sentence; a GET behind it is a request nothing
184
+ on screen is waiting for.
185
+
186
+ ### Notes
187
+
188
+ - **The frame is light and stays light.** `cortena-ui/app` measures **285.6 kB
189
+ eager** against a 340 kB ceiling — the shell probe's 212.8 kB plus the
190
+ standard pages — with **no heavy package eager at all**. `AdminPermissions`
191
+ (TanStack Table, and exceljs behind `DataTable`'s export) and the agent mount
192
+ (`AgentChat`, react-markdown, the A2UI renderer) are both `React.lazy`, and
193
+ `test/bundle-probe.test.mjs` measures that rather than assuming it.
194
+ - **`@ag-ui/client` is still reachable from `cortena-ui/agent-chat` alone.** The
195
+ transport factory is passed in as `agent.createClient` rather than imported,
196
+ so an extension writes one named import and the composition still owns every
197
+ argument the pop-up is built with.
198
+ - **This package imports no zod.** A settings schema is typed structurally and
199
+ only ever called — `safeParse` to validate, `.shape` to derive fields — so it
200
+ works across zod 3 and 4 and costs the frame nothing.
201
+ - The favicon and the `<title>` no longer need hand-copied files: the generator
202
+ ships as a Vite plugin from `cortena-design/vite` (cortena-design 3.6.0).
203
+
6
204
  ## 1.14.0
7
205
 
8
206
  ### Added
package/README.md CHANGED
@@ -36,6 +36,7 @@ consumer's bundler getting tree-shaking right:
36
36
  | `cortena-ui/a2ui` | the A2UI catalogue and renderer | most of the package, by design |
37
37
  | `cortena-ui/sortable-list` | `SortableList`, `SortableHandle`, `arrayMove` | dnd-kit |
38
38
  | `cortena-ui/agent-chat` | `AgentChatPopup`, `AgentChat`, `createAguiAgentChatClient` | the A2UI catalogue, plus `@ag-ui/client` (rxjs, zod 3, uuid, protobuf) |
39
+ | `cortena-ui/app` | `CortenaApp` — the whole frame, and `readCortenaLaunch` | 285.6 kB eagerly; the standard pages that are heavy arrive in chunks of their own |
39
40
  | `cortena-ui/rich-text-editor` | `RichTextEditor`, `parseMarkdown`, `serializeMarkdown` | TipTap on ProseMirror, 619 kB — the heaviest entry in the package |
40
41
 
41
42
  Each component has exactly one home, so the barrel re-exports every entry
@@ -129,6 +130,210 @@ regression; `test/chart-bundle.test.tsx` says which line caused it.
129
130
  check in light, dark and system-dark; add an entry with every variant and
130
131
  size when adding a component.
131
132
 
133
+ ## The frame
134
+
135
+ `AppShell` owns what renders after sign-in. Everything around it — the sign-in
136
+ screen, the signed-out state, the session guard, the agent pop-up, the tab
137
+ title and the Profile / Settings / Permissions pages — used to be each
138
+ extension's to assemble, and a 2026-09-14 re-audit of eight of them found eight
139
+ answers: seven local login pages, three still with dummy users; a guard mounted
140
+ with a different subset of its behaviour in each; a pop-up mounted without the
141
+ user's token or the agent slug; a Settings item greyed out in every shell.
142
+
143
+ `cortena-ui/app` is the assembly (DESIGN-119):
144
+
145
+ ```tsx
146
+ // main.tsx — synchronously, before createRoot (§7.3)
147
+ import { readCortenaLaunch } from "cortena-ui/app";
148
+ const launch = readCortenaLaunch({ hostOrigins: [config.cortenaWebOrigin] });
149
+
150
+ // App.tsx
151
+ import { CortenaApp } from "cortena-ui/app";
152
+ import { createAguiAgentChatClient } from "cortena-ui/agent-chat";
153
+
154
+ <CortenaApp
155
+ extension={{ id: "tasks", name: "Tasks" }}
156
+ user={user}
157
+ auth={{ publicAuthUrl, orgId, methods, onPassword, onProvider }}
158
+ session={{ launch, getToken, refresh, signOut, onTokenRenewed }}
159
+ nav={{ groups, pathname, onNavigate: (item, e) => { e.preventDefault(); navigate(item.href); } }}
160
+ agent={{ slug: "tasks", gatewayUrl: "/api/agent", createClient: createAguiAgentChatClient, relay, tokensCss }}
161
+ permissions={{ api: adminApi, canAdminister: isAdmin }}
162
+ settings={{ schema, load, save }}
163
+ help={{ source: HELP_DOCUMENT_URL }}
164
+ pathname={pathname}
165
+ onNavigate={(href) => navigate(href)}
166
+ documentTitle={pageTitle}
167
+ >
168
+ <Outlet />
169
+ </CortenaApp>
170
+ ```
171
+
172
+ It does things in this order, and the order is the contract:
173
+
174
+ 1. the launch, from `session.launch`;
175
+ 2. no token → `LoginScreen` with the methods the org advertises;
176
+ 3. the session ended → `LoginScreen` with `signedOut` and a `returnTo`;
177
+ 4. exactly one session guard, at the top, above the shell and the pop-up alike;
178
+ 5. `AppShell`, with Profile / Settings / Log out in the avatar menu;
179
+ 6. `AgentChatPopup` in `agentSlot`, in the `extension` role only;
180
+ 7. the standard pages when `pathname` names one, your `children` otherwise.
181
+
182
+ ### It is a client component, and router-agnostic
183
+
184
+ Navigation is `pathname` in and `onNavigate` out, over real `href`s, exactly as
185
+ `AppShell` already does. Configuration and tokens are props: the composition
186
+ fetches nothing, reads no browser API during a first render, and does not
187
+ assume it owns the document — `documentTitle={false}` turns off even the tab
188
+ title. That is not style. The third consumer is CortenaWeb, in a `host` role,
189
+ inside its own Next.js root layout; without every one of those the fleet keeps
190
+ two shells.
191
+
192
+ `role="host"` leaves the agent slot empty, because the host *is* the chat, and
193
+ its own guard is the one that renews and pushes tokens into framed extensions.
194
+ Host-only navigation — an Apps list, an org switcher, platform admin — is `nav`
195
+ and `headerSlot` configuration, not a special case in here.
196
+
197
+ ### Reading the launch, and who guards the session
198
+
199
+ A framed extension cannot refresh: CortenaWeb holds the refresh token and hands
200
+ down access tokens (`refreshToken=none`). Inferring "the host is guarding me"
201
+ from that absence is trust by assumption — a frame opened by anything at all
202
+ got no warning and no signed-out state, and nothing said so. So:
203
+
204
+ | the launch | what mounts |
205
+ | --- | --- |
206
+ | framed, and the host **declared** it guards the session | no guard here; renewed tokens arrive through `session.onTokenRenewed` |
207
+ | framed, and it did not | a countdown from the access token's own `exp`, the standard warning with no Continue, then the signed-out state with a `returnTo` |
208
+ | not framed | the full `SessionGuard`: idle clock, proactive refresh, cross-tab `BroadcastChannel` |
209
+
210
+ One of the three, ever. Never two countdowns for one session.
211
+
212
+ There are two ways the declaration can arrive, and they are not equally strong.
213
+
214
+ ```tsx
215
+ // The launch parameter: ?token=…&cortenaGuard=host
216
+ const launch = readCortenaLaunch({ hostOrigins: [config.cortenaWebOrigin] });
217
+
218
+ // The first bridge message. Give the same allow-list to the composition and it
219
+ // listens for `{ type: "cortena-session", guarded: true, accessToken? }` itself.
220
+ <CortenaApp session={{ launch, hostOrigins: [config.cortenaWebOrigin], … }} />
221
+ ```
222
+
223
+ Both are checked against an allow-list **your app supplies from its own
224
+ configuration**, never against anything in the request. With no `hostOrigins`
225
+ nothing is ever trusted, which is the safe direction: the frame then runs its
226
+ own countdown rather than trusting a parent it cannot name.
227
+
228
+ **Prefer the bridge.** `readCortenaSessionMessage` checks `event.origin`, which
229
+ the browser sets from the sending document and which cannot be forged from
230
+ inside the page that sent it. The launch parameter is checked against
231
+ `ancestorOrigins`, which is WebKit/Blink only; everywhere else the input is
232
+ `document.referrer`, and an open redirect on the CortenaWeb origin can produce
233
+ one that reads as CortenaWeb's. The failure is downgrade-only — a forged
234
+ declaration mounts *no* guard, it cannot mount a second one or obtain a token —
235
+ but "no guard" is the state decision (b) exists to remove, so pass
236
+ `hostOrigins` and let the host say it on the bridge. `launch.ts` carries the
237
+ whole note.
238
+
239
+ Token renewals arrive the same way: over the bridge where `hostOrigins` is set,
240
+ or through `session.onTokenRenewed` for an app that would rather own the
241
+ listener. Either one, and a token the app itself signs in with, clears the
242
+ signed-out state.
243
+
244
+ Call it in your store's initialiser, synchronously, before `createRoot`. §7.3
245
+ is the single most common embedding failure: a token read in an effect is read
246
+ after a `ProtectedRoute` has already redirected and stripped the query string,
247
+ and the user sees a login page inside CortenaWeb with nothing to explain it.
248
+
249
+ `session.signOut` is optional, and omitting it is meaningful: in a framed launch
250
+ with no host sign-out message to send, the avatar menu's Log out is disabled
251
+ rather than silently doing nothing.
252
+
253
+ ### The standard pages
254
+
255
+ `/me`, `/settings` and `/settings/permissions` are the composition's, reachable
256
+ from the avatar menu, and the Settings item is **never** disabled:
257
+
258
+ - **Profile** — the person's own name, avatar and account link. cortena-auth
259
+ owns the user record, so the page shows it and links to it rather than
260
+ pretending an extension can edit it.
261
+ - **Permissions** — `AdminPermissions` against the §14.3 admin routes, rendered
262
+ only when `permissions.canAdminister`. Absent otherwise, not disabled: "you
263
+ may not" is not a screen. The admin tabs push
264
+ `/settings/permissions/<screen>`, so they are deep-linkable.
265
+
266
+ **`canAdminister` comes from your own permissions client, against the §14.3
267
+ routes — never from an `orgRole` claim on the JWT.** Whether somebody may
268
+ administer *your extension* is your `may()`'s answer over your own matrix
269
+ (§14.2, §14.4); cortena-auth's org role says who owns the organisation, which
270
+ is a different question and one the bootstrap already answered once. Reading
271
+ the claim looks equivalent, hides the screen from an administrator your
272
+ matrix granted, shows it to an org owner your matrix did not, and neither is
273
+ visible until somebody complains. This flag only decides whether the section
274
+ renders; the routes behind it enforce the real thing either way.
275
+ - **The extension's own settings** — whatever it declares, or one plain line:
276
+ "Nothing to configure for <name> yet." An extension that later gains settings
277
+ declares a schema rather than building a page.
278
+
279
+ The routing is your router's. The composition compares `pathname` to the three
280
+ paths and renders its own page instead of `children` when one matches, so it
281
+ works under any router at all — mount it as a layout and give the router a
282
+ route for those paths that renders nothing of its own:
283
+
284
+ ```tsx
285
+ // react-router
286
+ <Route element={<Frame />}>
287
+ <Route path="/me" element={null} />
288
+ <Route path="/settings/*" element={null} />
289
+ …
290
+ </Route>
291
+
292
+ // Next.js: app/me/page.tsx and app/settings/[[...rest]]/page.tsx, each
293
+ // rendering null. The frame is in the layout, and it is "use client".
294
+ ```
295
+
296
+ `matchCortenaStandardRoute(pathname)` is exported for an app that would rather
297
+ place `CortenaSettingsPage` and `CortenaProfilePage` itself: it is the same
298
+ rule, not a second copy of it.
299
+
300
+ ### The settings declaration
301
+
302
+ ```tsx
303
+ const settings = z.object({
304
+ alertThreshold: z.number().describe("Raise an alert when a stream falls this far behind."),
305
+ pauseOnFailure: z.boolean(),
306
+ alertChannel: z.enum(["email", "channels", "none"]),
307
+ });
308
+
309
+ <CortenaApp settings={{ schema: settings, load, save }} … />
310
+ ```
311
+
312
+ The fields are derived from the object's shape, and `safeParse` validates a
313
+ save. This package imports no zod: the schema is typed structurally and only
314
+ ever called, so it works across zod 3 and 4, whose internals differ, and costs
315
+ the frame nothing. Reading the shape is deliberately tolerant — a schema it
316
+ cannot read gives no fields, which draws the "nothing to configure" line rather
317
+ than throwing on a settings page.
318
+
319
+ Pass `fields` where the schema alone does not say enough — a select's options,
320
+ a longer description, a textarea. `load` and `save` are the extension's own
321
+ `/settings` route; the composition never stores anything.
322
+
323
+ ### What it does not do
324
+
325
+ There is **no consent route**. cortena-auth is the authorisation server and
326
+ renders consent; the content comes from the `scopes` section of the extension's
327
+ own `x-cortena` identity block, which cortena-auth reads from the registered
328
+ in-cluster address. An extension ships no consent screen.
329
+
330
+ And `@ag-ui/client` is reachable from `cortena-ui/agent-chat` and nowhere else,
331
+ including here. The transport factory is a prop — `agent.createClient` — rather
332
+ than an import, which is one named import for the extension and leaves the
333
+ composition owning every argument the pop-up is built with: the user's token,
334
+ read fresh on every request, and the agent slug. Those are the two an extension
335
+ shipped without.
336
+
132
337
  ## Shell chrome
133
338
 
134
339
  `AppShell` is the chrome every extension wears (§9 of
package/dist/app.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, CortenaLaunch, CortenaSessionMessage, ReadCortenaLaunchOptions, isHostGuardDeclared, readCortenaLaunch, readCortenaSessionMessage } from "./components/app/launch.js";
3
+ import { CortenaAppAgent } from "./components/app/agent-mount.js";
4
+ import { CortenaAppPermissions, CortenaAppUser, CortenaProfilePage, CortenaSettingsControl, CortenaSettingsDeclaration, CortenaSettingsField, CortenaSettingsPage, CortenaSettingsPageProps, CortenaSettingsSchema, ProfileSection, ProfileSectionProps, settingsFields } from "./components/app/settings.js";
5
+ import { CORTENA_STANDARD_ROUTES, CortenaApp, CortenaAppAuth, CortenaAppExtension, CortenaAppProps, CortenaAppRoutes, CortenaSessionAdapter, CortenaStandardRoute, matchCortenaStandardRoute } from "./components/app/cortena-app.js";
6
+ export { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, CORTENA_STANDARD_ROUTES, CortenaApp, type CortenaAppAgent, type CortenaAppAuth, type CortenaAppExtension, type CortenaAppPermissions, type CortenaAppProps, type CortenaAppRoutes, type CortenaAppUser, type CortenaLaunch, CortenaProfilePage, type CortenaSessionAdapter, type CortenaSessionMessage, type CortenaSettingsControl, type CortenaSettingsDeclaration, type CortenaSettingsField, CortenaSettingsPage, type CortenaSettingsPageProps, type CortenaSettingsSchema, type CortenaStandardRoute, ProfileSection, type ProfileSectionProps, type ReadCortenaLaunchOptions, isHostGuardDeclared, matchCortenaStandardRoute, readCortenaLaunch, readCortenaSessionMessage, settingsFields };
package/dist/app.js ADDED
@@ -0,0 +1,5 @@
1
+ "use client";
2
+ import { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, isHostGuardDeclared, readCortenaLaunch, readCortenaSessionMessage } from "./components/app/launch.js";
3
+ import { CortenaProfilePage, CortenaSettingsPage, ProfileSection, settingsFields } from "./components/app/settings.js";
4
+ import { CORTENA_STANDARD_ROUTES, CortenaApp, matchCortenaStandardRoute } from "./components/app/cortena-app.js";
5
+ export { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, CORTENA_STANDARD_ROUTES, CortenaApp, CortenaProfilePage, CortenaSettingsPage, ProfileSection, isHostGuardDeclared, matchCortenaStandardRoute, readCortenaLaunch, readCortenaSessionMessage, settingsFields };
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ "use client";
3
+ import { ADMIN_ROUTES, ADMIN_ROUTE_LABELS, adminErrorMessage, effectiveRolesOf, sourceGroupOf } from "./types.js";
4
+ import { AdminPermissionsProvider, useAdminPermissions } from "./context.js";
5
+ import { AdminLicenceScreen } from "./licence.js";
6
+ import { AdminMatrix } from "./matrix.js";
7
+ import { DirectRoles, EffectiveRoles, RoleAssignmentDialog } from "./role-assignment.js";
8
+ import { AdminMembers } from "./members.js";
9
+ import { AdminRoles } from "./roles.js";
10
+ import { AdminPermissions } from "./admin-permissions.js";
11
+ export { ADMIN_ROUTES, ADMIN_ROUTE_LABELS, AdminLicenceScreen, AdminMatrix, AdminMembers, AdminPermissions, AdminPermissionsProvider, AdminRoles, DirectRoles, EffectiveRoles, RoleAssignmentDialog, adminErrorMessage, effectiveRolesOf, sourceGroupOf, useAdminPermissions };
@@ -0,0 +1,53 @@
1
+ "use client";
2
+ import { AgentChatClient } from "../../agent-chat/types.js";
3
+ import { McpAppToolCaller } from "../../agent-chat/mcp-app/bridge.js";
4
+ import "react";
5
+ //#region src/components/app/agent-mount.d.ts
6
+ /**
7
+ * The agent pop-up, wired the way §19 says and not the way each extension
8
+ * remembered it.
9
+ *
10
+ * This module is loaded lazily by `CortenaApp`, and that is the whole reason
11
+ * it is a module at all: `AgentChatPopup` reaches `AgentChat`, which reaches
12
+ * `Markdown` and the A2UI renderer. An extension's first screen must not carry
13
+ * a markdown pipeline for a chat the user has not opened, so the frame imports
14
+ * this by `import()` and a bundler splits it off.
15
+ *
16
+ * `createClient` is passed in rather than imported for a different reason.
17
+ * `createAguiAgentChatClient` reaches `@ag-ui/client` — rxjs, zod 3, uuid,
18
+ * protobuf — and `test/bundle-probe.test.mjs` fails if `@ag-ui/*` is reachable
19
+ * from any entry but `cortena-ui/agent-chat`. Keeping the factory a prop keeps
20
+ * that guarantee exactly as it is, and still leaves the composition owning
21
+ * every argument the pop-up is built with: the user's token, read fresh on
22
+ * every request, and the agent slug. Both were the ones an extension shipped
23
+ * without.
24
+ */
25
+ export interface CortenaAppAgent {
26
+ /** The AgentTemplate slug. Usually the extension id; §19.1. */
27
+ slug: string;
28
+ /** This extension's own gateway, never cortenacore. Default `/api/agent`. */
29
+ gatewayUrl?: string;
30
+ /**
31
+ * `createAguiAgentChatClient` from `cortena-ui/agent-chat`, passed through.
32
+ * One named import, and the composition supplies every argument.
33
+ */
34
+ createClient: (options: {
35
+ baseUrl: string;
36
+ agentId: string;
37
+ getToken: () => string;
38
+ }) => AgentChatClient;
39
+ /**
40
+ * How a screen drawn inside the pop-up reaches this extension's own MCP
41
+ * server, with the user's credential (§19.2, step 4). Omitted, tool results
42
+ * stay ordinary cards.
43
+ */
44
+ relay?: McpAppToolCaller;
45
+ /**
46
+ * `cortena-design/tokens.css?raw`. The sandboxed document has an opaque
47
+ * origin and `default-src 'none'`, so it can fetch nothing: the tokens go in
48
+ * as text or the screen renders unstyled.
49
+ */
50
+ tokensCss?: string;
51
+ }
52
+ //#endregion
53
+ //# sourceMappingURL=agent-mount.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ "use client";
3
+ import { AgentChatPopup } from "../agent-chat-popup.js";
4
+ import { renderMcpAppToolCall } from "../../agent-chat/mcp-app/render-tool-call.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import * as React from "react";
7
+ //#region src/components/app/agent-mount.tsx
8
+ function AgentMount({ extension, agent, getToken }) {
9
+ const identity = React.useRef({
10
+ agent,
11
+ getToken
12
+ });
13
+ identity.current = {
14
+ agent,
15
+ getToken
16
+ };
17
+ const client = React.useMemo(() => identity.current.agent.createClient({
18
+ baseUrl: identity.current.agent.gatewayUrl ?? "/api/agent",
19
+ agentId: identity.current.agent.slug,
20
+ getToken: () => identity.current.getToken()
21
+ }), []);
22
+ const renderToolCall = React.useMemo(() => {
23
+ const relay = identity.current.agent.relay;
24
+ if (!relay) return;
25
+ return renderMcpAppToolCall({
26
+ extensionId: extension.id,
27
+ callTool: relay,
28
+ tokensCss: identity.current.agent.tokensCss
29
+ });
30
+ }, [extension.id]);
31
+ return /* @__PURE__ */ jsx(AgentChatPopup, {
32
+ extension,
33
+ client,
34
+ renderToolCall
35
+ });
36
+ }
37
+ //#endregion
38
+ export { AgentMount as default };
39
+
40
+ //# sourceMappingURL=agent-mount.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-mount.js","names":[],"sources":["../../../src/components/app/agent-mount.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { AgentChatPopup } from \"@/components/agent-chat-popup\";\nimport { renderMcpAppToolCall } from \"@/agent-chat/mcp-app/render-tool-call\";\nimport type { McpAppToolCaller } from \"@/agent-chat/mcp-app/bridge\";\nimport type { AgentChatClient } from \"@/agent-chat/types\";\n\n/**\n * The agent pop-up, wired the way §19 says and not the way each extension\n * remembered it.\n *\n * This module is loaded lazily by `CortenaApp`, and that is the whole reason\n * it is a module at all: `AgentChatPopup` reaches `AgentChat`, which reaches\n * `Markdown` and the A2UI renderer. An extension's first screen must not carry\n * a markdown pipeline for a chat the user has not opened, so the frame imports\n * this by `import()` and a bundler splits it off.\n *\n * `createClient` is passed in rather than imported for a different reason.\n * `createAguiAgentChatClient` reaches `@ag-ui/client` — rxjs, zod 3, uuid,\n * protobuf — and `test/bundle-probe.test.mjs` fails if `@ag-ui/*` is reachable\n * from any entry but `cortena-ui/agent-chat`. Keeping the factory a prop keeps\n * that guarantee exactly as it is, and still leaves the composition owning\n * every argument the pop-up is built with: the user's token, read fresh on\n * every request, and the agent slug. Both were the ones an extension shipped\n * without.\n */\n\nexport interface CortenaAppAgent {\n /** The AgentTemplate slug. Usually the extension id; §19.1. */\n slug: string;\n /** This extension's own gateway, never cortenacore. Default `/api/agent`. */\n gatewayUrl?: string;\n /**\n * `createAguiAgentChatClient` from `cortena-ui/agent-chat`, passed through.\n * One named import, and the composition supplies every argument.\n */\n createClient: (options: {\n baseUrl: string;\n agentId: string;\n getToken: () => string;\n }) => AgentChatClient;\n /**\n * How a screen drawn inside the pop-up reaches this extension's own MCP\n * server, with the user's credential (§19.2, step 4). Omitted, tool results\n * stay ordinary cards.\n */\n relay?: McpAppToolCaller;\n /**\n * `cortena-design/tokens.css?raw`. The sandboxed document has an opaque\n * origin and `default-src 'none'`, so it can fetch nothing: the tokens go in\n * as text or the screen renders unstyled.\n */\n tokensCss?: string;\n}\n\nexport interface AgentMountProps {\n extension: { id: string; name: string };\n agent: CortenaAppAgent;\n /** Read on every request rather than captured, so a renewal is picked up. */\n getToken: () => string;\n}\n\nfunction AgentMount({ extension, agent, getToken }: AgentMountProps) {\n // Built once for the life of the mount. The chat re-subscribes to the run\n // stream and refetches its session list whenever the client's identity\n // changes, so a client rebuilt per render is a session list refetched per\n // render. The identity is constant by construction: the slug is a string and\n // the token is read through a function rather than captured as a value.\n const identity = React.useRef({ agent, getToken });\n identity.current = { agent, getToken };\n\n const client = React.useMemo(\n () =>\n identity.current.agent.createClient({\n baseUrl: identity.current.agent.gatewayUrl ?? \"/api/agent\",\n agentId: identity.current.agent.slug,\n getToken: () => identity.current.getToken(),\n }),\n [],\n );\n\n const renderToolCall = React.useMemo(() => {\n const relay = identity.current.agent.relay;\n if (!relay) {\n return undefined;\n }\n return renderMcpAppToolCall({\n extensionId: extension.id,\n callTool: relay,\n tokensCss: identity.current.agent.tokensCss,\n // `onMessage` is left unset on purpose: the surface's own `send` takes\n // it, so a click inside a screen starts the next turn as the screen\n // rather than as a user who typed nothing (DESIGN-80).\n });\n // The extension id is fixed for the life of the app; rebuilding this would\n // reload a screen that is already running.\n }, [extension.id]);\n\n return <AgentChatPopup extension={extension} client={client} renderToolCall={renderToolCall} />;\n}\n\nexport default AgentMount;\n"],"mappings":";;;;;;;AA+DA,SAAS,WAAW,EAAE,WAAW,OAAO,YAA6B;CAMnE,MAAM,WAAW,MAAM,OAAO;EAAE;EAAO;CAAS,CAAC;CACjD,SAAS,UAAU;EAAE;EAAO;CAAS;CAErC,MAAM,SAAS,MAAM,cAEjB,SAAS,QAAQ,MAAM,aAAa;EAClC,SAAS,SAAS,QAAQ,MAAM,cAAc;EAC9C,SAAS,SAAS,QAAQ,MAAM;EAChC,gBAAgB,SAAS,QAAQ,SAAS;CAC5C,CAAC,GACH,CAAC,CACH;CAEA,MAAM,iBAAiB,MAAM,cAAc;EACzC,MAAM,QAAQ,SAAS,QAAQ,MAAM;EACrC,IAAI,CAAC,OACH;EAEF,OAAO,qBAAqB;GAC1B,aAAa,UAAU;GACvB,UAAU;GACV,WAAW,SAAS,QAAQ,MAAM;EAIpC,CAAC;CAGH,GAAG,CAAC,UAAU,EAAE,CAAC;CAEjB,OAAO,oBAAC,gBAAD;EAA2B;EAAmB;EAAwB;CAAiB,CAAA;AAChG"}