cortena-ui 1.15.0 → 1.16.1
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.
- package/CHANGELOG.md +110 -0
- package/dist/agent-chat/agui-client.js +16 -1
- package/dist/agent-chat/agui-client.js.map +1 -1
- package/dist/components/app/cortena-app.d.ts +21 -1
- package/dist/components/app/cortena-app.js +7 -3
- package/dist/components/app/cortena-app.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/chip.d.ts +1 -1
- package/dist/components/data-table/use-data-table.js +1 -1
- package/dist/components/login-screen.d.ts +58 -2
- package/dist/components/login-screen.js +316 -12
- package/dist/components/login-screen.js.map +1 -1
- package/package.json +2 -2
- package/src/agent-chat/agui-client.ts +16 -1
- package/src/components/app/cortena-app.tsx +31 -3
- package/src/components/login-screen.tsx +532 -16
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,116 @@
|
|
|
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.1
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **The agent pop-up's control plane called the global `fetch` with an illegal
|
|
11
|
+
receiver, and had since 1.5.0.** Every call site is `this.fetch(...)`, and the
|
|
12
|
+
getter returned the unbound `globalThis.fetch`, so a native `fetch` refused it
|
|
13
|
+
with "Failed to execute 'fetch' on 'Window': Illegal invocation". The session
|
|
14
|
+
list threw on its first call and the pop-up rendered that sentence where the
|
|
15
|
+
conversation belongs — in every extension, in every product build. It survived
|
|
16
|
+
because every test injected a `fetchImpl` stub, and a plain function does not
|
|
17
|
+
care what `this` is. The stub is what hid it. The getter now binds, and the
|
|
18
|
+
test beside it uses a global that rejects a foreign receiver the way the real
|
|
19
|
+
one does.
|
|
20
|
+
|
|
21
|
+
## 1.16.0
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **The login screen asks for the organisation** (DESIGN-126, found by the
|
|
26
|
+
DESIGN-125 pilot). cortena-auth's `POST /api/auth/login` takes
|
|
27
|
+
`{ orgId, email, password }` and rejects a body without `orgId` with a 400
|
|
28
|
+
before it looks at the credentials. `LoginScreen` collected email and
|
|
29
|
+
password only, and `CortenaApp` declared `auth.orgId` and read it nowhere —
|
|
30
|
+
so a fresh browser opening an extension URL directly, with no `?org=` and
|
|
31
|
+
nothing remembered, posted a login that could not succeed and read back a
|
|
32
|
+
validation error it could do nothing about. Every extension on the frame had
|
|
33
|
+
it. The launch path (a `?token=` from CortenaWeb) and a returning browser
|
|
34
|
+
were never affected, which is why it survived.
|
|
35
|
+
|
|
36
|
+
Sign-in is now the two steps cortena-auth's own hosted page asks:
|
|
37
|
+
the organisation, then the credentials. The organisation step is *skipped*
|
|
38
|
+
whenever the caller knows the answer — `CortenaApp` passes `auth.orgId`
|
|
39
|
+
through as `LoginScreen`'s default — so the returning browser that is the
|
|
40
|
+
overwhelmingly common case is never asked. Where the app does not know, the
|
|
41
|
+
screen asks rather than posting a request that cannot succeed.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **`LoginScreen`: `orgId` and `onOrg`.** `orgId` is the default for the
|
|
46
|
+
organisation step — the `?org=` on the launch URL, or the value this browser
|
|
47
|
+
used last — and supplying it skips the step. It is a default, not a value:
|
|
48
|
+
the user can change it from the credentials step, and a caller that learns
|
|
49
|
+
the organisation late (an `auth-info` call, a remembered value read after
|
|
50
|
+
mount) can pass it then and the screen moves on without a flash of the wrong
|
|
51
|
+
step. `onOrg` lets the app resolve what the user typed —
|
|
52
|
+
`GET /api/orgs/:orgId/auth-info` — and hand back a fresh `methods` list;
|
|
53
|
+
return a promise and the screen waits on it, rejecting to stay on the step
|
|
54
|
+
with your message. The fetch stays in the app, as `methods` always has.
|
|
55
|
+
- **`CortenaApp`: `auth.onOrg`**, the same seam on the frame's contract, and
|
|
56
|
+
`auth.orgId` now reaches the screen instead of being decorative.
|
|
57
|
+
- **Validation in the user's words**, and three different sentences for three
|
|
58
|
+
different problems, because the user's next move differs in each. An empty
|
|
59
|
+
organisation says "Enter your organization ID to continue." A lookup the app
|
|
60
|
+
refused says the app's own message, or "We could not find that organization.
|
|
61
|
+
Check the ID with your administrator." A lookup that never reached the
|
|
62
|
+
service — `fetch` rejecting with a `TypeError`, an `AbortError` — says "We
|
|
63
|
+
could not reach the sign-in service. Check your connection and try again.",
|
|
64
|
+
because a dropped connection says nothing about the organisation and sending
|
|
65
|
+
a user with a valid ID to their administrator over dead Wi-Fi helps nobody.
|
|
66
|
+
A lookup that threw a bug (a `SyntaxError` from a body that was not JSON)
|
|
67
|
+
says "Something went wrong looking up that organization. Try again." **and
|
|
68
|
+
warns in development**, so the author hears about it instead of it vanishing.
|
|
69
|
+
The existing `error` and `loading` props keep their meanings exactly.
|
|
70
|
+
- **A way out of a lookup that never comes back.** An `onOrg` that never
|
|
71
|
+
settles used to leave the step disabled for the rest of the session with no
|
|
72
|
+
control on the page that did anything. A Cancel button appears beside
|
|
73
|
+
Continue for the screen's own wait — not for the caller's `loading`, which is
|
|
74
|
+
the caller's to end.
|
|
75
|
+
- **The step announces itself.** A visually hidden `role="status"` line says
|
|
76
|
+
which of the two steps is on screen, so a screen-reader user who has just
|
|
77
|
+
pressed Continue hears something move.
|
|
78
|
+
- **The organisation on the credentials step**, with "Use a different
|
|
79
|
+
organization" beside it, so a user signed in to the wrong one — or turned
|
|
80
|
+
away by a 400 the caller surfaced through `error` — has something to do about
|
|
81
|
+
it rather than a dead screen. It is a real control: the button scale's own
|
|
82
|
+
smallest step (32px, over WCAG 2.2 AA 2.5.8's 24px floor) and underlined at
|
|
83
|
+
rest, so colour is not the only thing marking it as one.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **`PasswordCredentials` gains `orgId`**, always non-empty and already
|
|
88
|
+
trimmed, and `onProvider` gains a second argument, the organisation —
|
|
89
|
+
cortena-auth requires `orgId` beside a provider's `idToken` exactly as it does
|
|
90
|
+
beside a password.
|
|
91
|
+
|
|
92
|
+
**Why this is a minor and not a major, said plainly.** Both shapes are
|
|
93
|
+
produced by the component and consumed by the caller, so a caller gains a
|
|
94
|
+
field and an argument rather than owing either; a one-argument `onProvider`
|
|
95
|
+
handler still satisfies the type, and a handler that reads `credentials.email`
|
|
96
|
+
is unaffected. There are exactly two ways to break a compile on this, and both
|
|
97
|
+
are producer-side: constructing a `PasswordCredentials` literal by hand (a
|
|
98
|
+
test double, a story fixture) or *calling* `onProvider` yourself. Neither
|
|
99
|
+
exists anywhere in the fleet — checked across cortena-extensions, where the
|
|
100
|
+
conformance fixture and the Tasks pilot both only pass handlers in. The
|
|
101
|
+
behavioural change is the new step, and passing `orgId` suppresses it
|
|
102
|
+
entirely, which `CortenaApp` now does.
|
|
103
|
+
- **A `methods` list is pinned to the organisation it describes.** `methods`
|
|
104
|
+
comes from one org's `auth-info`, and nothing in the props said which, so the
|
|
105
|
+
screen now keeps the pairing itself and falls back to password when the user
|
|
106
|
+
moves to a different organisation without a fresh list arriving. Without this
|
|
107
|
+
an SSO-only org's provider buttons survived onto the next org — running the
|
|
108
|
+
first org's Firebase project while the sign-in posted the second org's id,
|
|
109
|
+
with no password form either: a screen with no working way in. Where this
|
|
110
|
+
fallback fires and no `onOrg` was given, it says so in a development warning.
|
|
111
|
+
- The organisation field is a plain `<input>` with `autocomplete="organization"`
|
|
112
|
+
— deliberately not a Combobox. There is no list of organisations to offer, and
|
|
113
|
+
an empty combobox is a listbox a screen reader announces and cannot type its
|
|
114
|
+
way out of.
|
|
115
|
+
|
|
6
116
|
## 1.15.0
|
|
7
117
|
|
|
8
118
|
### Added
|
|
@@ -85,8 +85,23 @@ var AguiAgentChatClient = class {
|
|
|
85
85
|
"content-type": "application/json"
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The global `fetch` is BOUND, and that is the whole point of this getter.
|
|
90
|
+
*
|
|
91
|
+
* Every call site below is `this.fetch(...)`, which invokes whatever this
|
|
92
|
+
* returns with `this` set to the client instance. A native `fetch` refuses
|
|
93
|
+
* that: "Failed to execute 'fetch' on 'Window': Illegal invocation". So the
|
|
94
|
+
* control plane — the session list, the history, abort and approval — threw
|
|
95
|
+
* on its first call in every product build, and the pop-up rendered that
|
|
96
|
+
* message where the conversation should be.
|
|
97
|
+
*
|
|
98
|
+
* It survived since 1.5.0 because the tests inject `fetchImpl`, and a plain
|
|
99
|
+
* function stub does not care what `this` is. The stub is exactly what hid
|
|
100
|
+
* the bug, so the test beside this change uses a `globalThis.fetch` that
|
|
101
|
+
* rejects a foreign `this` the way the real one does.
|
|
102
|
+
*/
|
|
88
103
|
get fetch() {
|
|
89
|
-
return this.opts.fetchImpl ?? globalThis.fetch;
|
|
104
|
+
return this.opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
90
105
|
}
|
|
91
106
|
/** `sessions.list` and `chat.history` over the REST control plane. */
|
|
92
107
|
async request(method, params) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agui-client.js","names":[],"sources":["../../src/agent-chat/agui-client.ts"],"sourcesContent":["/**\n * `AgentChatClient` over AG-UI.\n *\n * Ported from `cortena-shared/src/agui/client.ts` (Cortena monorepo,\n * DESIGN-37). `cortena-shared` is not published, so what this package needs is\n * copied rather than imported; keep the two in step.\n *\n * **This is the only module in cortena-ui that imports `@ag-ui/client`**, and\n * `cortena-ui/agent-chat` is the only entry that reaches it. `@ag-ui/client`\n * brings rxjs, zod 3, uuid and protobuf; `test/bundle-probe.test.mjs` asserts\n * none of it is reachable from `cortena-ui` or `cortena-ui/core`.\n *\n * It is an OPTIONAL peer, and the import is therefore **lazy**: a static\n * `import { HttpAgent }` made the whole `cortena-ui/agent-chat` entry fail to\n * load for a consumer that had not installed it, so an extension on another\n * transport could not draw `AgentChatPopup` at all — a package it never asked\n * for decided whether its chat rendered. Loading it inside the factory keeps\n * the entry resolvable, and moves the failure to the one operation that\n * genuinely needs the peer, with a message that says what to install.\n *\n * What an extension must expose for this to work is in README.md, \"The agent\n * pop-up\": `POST /agui/run`, `/agui/abort`, `/agui/approval` and the two\n * control-plane reads, all proxied through the extension's own gateway with the\n * signed-in user's JWT — never a service token.\n *\n * Two things about the wire that are easy to get wrong:\n *\n * - The stream ends by **closing the connection** after `RUN_FINISHED` or\n * `RUN_ERROR`. There is no `data: [DONE]` sentinel; emitting one makes the\n * AG-UI client error the subject, because it `JSON.parse`s every `data:` line.\n * - Approvals go to their own endpoint. Sending a decision as `state` on a\n * second `/agui/run` opens a second concurrent stream, and two mappers then\n * write two sets of message ids into one bridge, which resets its text buffer\n * whenever the message id changes.\n */\n\nimport type { HttpAgent } from \"@ag-ui/client\";\nimport { AgentChatBridge } from \"./bridge\";\nimport type {\n AgentApprovalContext,\n AgentApprovalDecision,\n AgentApprovalEvent,\n AgentChatClient,\n AgentChatEvent,\n AgentChatSendInput,\n AgentToolEvent,\n} from \"./types\";\n\nexport interface CreateAguiAgentChatClientOptions {\n /**\n * Origin the AG-UI and control-plane paths hang off, no trailing slash.\n * For an extension this is its own gateway, which proxies to cortenacore.\n */\n baseUrl: string;\n /** The signed-in USER's JWT. Read on every request, never captured once. */\n getToken: () => string;\n /** The agent template slug — the extension id. Sessions are `agent:<id>:…`. */\n agentId: string;\n fetchImpl?: typeof fetch;\n /** Frontend tools advertised on every run; AG-UI requires a description. */\n tools?: Array<{ name: string; description: string; parameters?: unknown }>;\n /** Backoff before re-attaching a dropped stream. */\n reconnectDelays?: readonly number[];\n maxReconnectAttempts?: number;\n sleep?: (ms: number) => Promise<void>;\n now?: () => number;\n}\n\nconst DEFAULT_RECONNECT_DELAYS: readonly number[] = [500, 1_000, 2_000, 5_000, 10_000];\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 5;\n\nconst defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));\n\n/**\n * What a relative `baseUrl` resolves against.\n *\n * The browser's own origin, and a placeholder under Node — where this module\n * is exercised by tests with a `fetchImpl` stub and there is no origin to\n * speak of. The placeholder never reaches the network: the stub sees the\n * resolved string, and `/api/core/...` is what it asserts on.\n */\nfunction requestBase(): string {\n return typeof location === \"undefined\" ? \"http://cortena.invalid\" : location.origin;\n}\n\n/** What `@ag-ui/client` exports and this module constructs. */\ntype HttpAgentConstructor = typeof import(\"@ag-ui/client\").HttpAgent;\n\nexport const MISSING_AGUI_PEER =\n 'cortena-ui/agent-chat: the AG-UI transport needs the optional peer \"@ag-ui/client\". ' +\n \"Install it (pnpm add @ag-ui/client), or pass your own AgentChatClient to AgentChat.\";\n\n/**\n * `@ag-ui/client`, on demand.\n *\n * The specifier is a literal so a bundler still sees the dependency and puts\n * it in a chunk of its own — `test/bundle-probe.test.mjs` measures that the\n * agent-chat entry carries it. What changes is *when* it is evaluated, and\n * what happens when it is not there at all.\n */\nasync function loadHttpAgent(): Promise<HttpAgentConstructor> {\n try {\n return (await import(\"@ag-ui/client\")).HttpAgent;\n } catch (cause) {\n throw new Error(MISSING_AGUI_PEER, { cause });\n }\n}\n\ntype Sink<T> = (event: T) => void;\n\ninterface ActiveRun {\n runId: string;\n sessionKey: string;\n bridge: AgentChatBridge;\n agent: HttpAgent;\n aborted: boolean;\n}\n\nclass AguiAgentChatClient implements AgentChatClient {\n readonly agentId: string;\n\n private readonly opts: CreateAguiAgentChatClientOptions;\n private readonly delays: readonly number[];\n private readonly maxAttempts: number;\n private readonly sleep: (ms: number) => Promise<void>;\n private readonly chatSinks = new Set<Sink<AgentChatEvent>>();\n private readonly toolSinks = new Set<Sink<AgentToolEvent>>();\n private readonly approvalSinks = new Set<Sink<AgentApprovalEvent>>();\n private active: ActiveRun | null = null;\n /** The optional peer, requested when the client is created and awaited per run. */\n private readonly httpAgent: Promise<HttpAgentConstructor>;\n\n constructor(opts: CreateAguiAgentChatClientOptions) {\n this.opts = opts;\n this.agentId = opts.agentId;\n this.delays = opts.reconnectDelays ?? DEFAULT_RECONNECT_DELAYS;\n this.maxAttempts = opts.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;\n this.sleep = opts.sleep ?? defaultSleep;\n // Started here so a missing peer is discovered when the client is made\n // rather than mid-stream. The rejection is answered immediately: a client\n // created and never sent to must not raise an unhandled rejection, and\n // `startRun` awaits the same promise where the user can be told.\n this.httpAgent = loadHttpAgent();\n this.httpAgent.catch(() => {});\n }\n\n onChat(sink: Sink<AgentChatEvent>): () => void {\n this.chatSinks.add(sink);\n return () => this.chatSinks.delete(sink);\n }\n\n onTool(sink: Sink<AgentToolEvent>): () => void {\n this.toolSinks.add(sink);\n return () => this.toolSinks.delete(sink);\n }\n\n onApproval(sink: Sink<AgentApprovalEvent>): () => void {\n this.approvalSinks.add(sink);\n return () => this.approvalSinks.delete(sink);\n }\n\n get runUrl(): string {\n return `${this.opts.baseUrl}/agui/run`;\n }\n\n get abortUrl(): string {\n return `${this.opts.baseUrl}/agui/abort`;\n }\n\n get approvalUrl(): string {\n return `${this.opts.baseUrl}/agui/approval`;\n }\n\n private headers(): Record<string, string> {\n return {\n authorization: `Bearer ${this.opts.getToken()}`,\n \"content-type\": \"application/json\",\n };\n }\n\n private get fetch(): typeof fetch {\n return this.opts.fetchImpl ?? globalThis.fetch;\n }\n\n /** `sessions.list` and `chat.history` over the REST control plane. */\n async request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {\n const path = `${this.opts.baseUrl}/api/core/${method.split(\".\").join(\"/\")}`;\n const readOnly = method === \"sessions.list\" || method === \"chat.history\";\n // `new URL(path)` with no base throws on a relative `baseUrl`, and the one\n // in this module's own example is `\"/api/agent\"` — the shape every\n // extension uses, since the gateway that proxies these calls is the app's\n // own origin. `sessions.list` therefore threw before it was ever sent, and\n // the chat opened on \"The agent could not be reached\".\n const url = new URL(path, requestBase());\n if (readOnly) {\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== null) {\n url.searchParams.set(key, String(value));\n }\n }\n }\n const response = await this.fetch(url.toString(), {\n method: readOnly ? \"GET\" : \"POST\",\n headers: this.headers(),\n ...(readOnly ? {} : { body: JSON.stringify(params) }),\n });\n if (!response.ok) {\n throw new Error(await readError(response, method));\n }\n return (await response.json()) as T;\n }\n\n async send(input: AgentChatSendInput): Promise<void> {\n // Fire and forget: the caller only awaits the acceptance. Events follow on\n // the stream, exactly as they do when the server acknowledges a send.\n void this.startRun(input);\n }\n\n private async startRun(input: AgentChatSendInput): Promise<void> {\n const bridge = new AgentChatBridge({\n runId: input.runId,\n sessionKey: input.sessionKey,\n chat: (event) => this.emit(this.chatSinks, event),\n tool: (event) => this.emit(this.toolSinks, event),\n approval: (event) => this.emit(this.approvalSinks, event),\n ...(this.opts.now ? { now: this.opts.now } : {}),\n });\n\n const forwarded: Record<string, unknown> = {\n ...(input.thinking ? { thinking: input.thinking } : {}),\n ...(input.attachments?.length ? { attachments: input.attachments } : {}),\n ...(input.model ? { model: input.model } : {}),\n // A click inside a rendered A2UI surface or MCP App. Structured rather\n // than typed prose: cortenacore turns it into the documented envelope.\n ...(input.a2uiAction ? { a2uiAction: input.a2uiAction } : {}),\n ...(input.mcpAppAction ? { mcpAppAction: input.mcpAppAction } : {}),\n };\n\n let Agent: HttpAgentConstructor;\n try {\n Agent = await this.httpAgent;\n } catch (err) {\n // The one operation that needs the peer, and the only place it can be\n // reported: in the chat, as a failed run, rather than as a rejected\n // promise nobody is holding.\n bridge.fail(err instanceof Error ? err.message : MISSING_AGUI_PEER);\n return;\n }\n\n let attempt = 0;\n let resume = false;\n for (;;) {\n const runId = resume ? `${input.runId}:resume-${attempt}` : input.runId;\n const agent = new Agent({\n url: this.runUrl,\n headers: this.headers(),\n threadId: input.sessionKey,\n // A re-attach carries no new user message; the server re-streams the\n // run named by forwardedProps.resumeRunId instead of starting one.\n initialMessages: resume\n ? []\n : [{ id: input.runId, role: \"user\", content: input.message }],\n ...(this.opts.fetchImpl ? { fetch: this.opts.fetchImpl } : {}),\n });\n this.active = {\n runId: input.runId,\n sessionKey: input.sessionKey,\n bridge,\n agent,\n aborted: this.active?.aborted === true && this.active.runId === input.runId,\n };\n try {\n await agent.runAgent(\n {\n runId,\n ...(this.opts.tools?.length ? { tools: this.opts.tools } : {}),\n forwardedProps: {\n ...forwarded,\n ...(resume ? { resumeRunId: input.runId } : {}),\n },\n },\n { onEvent: ({ event }) => bridge.handleEvent(event as never) },\n );\n return;\n } catch (err) {\n if (bridge.finished || this.active?.aborted) {\n return;\n }\n attempt += 1;\n if (attempt > this.maxAttempts) {\n bridge.fail(err instanceof Error ? err.message : \"The agent stream failed\");\n return;\n }\n await this.sleep(this.delays[Math.min(attempt - 1, this.delays.length - 1)] ?? 1_000);\n // The backoff is up to ten seconds long, and everything can change in\n // it: the user presses Stop, or a late RUN_FINISHED lands on the\n // bridge. Checking only before the sleep re-attached to a run that had\n // already ended — a second stream, a second set of message ids into\n // one bridge, and an abort the user watched do nothing.\n if (bridge.finished || this.active?.aborted) {\n return;\n }\n resume = true;\n }\n }\n }\n\n async abort(params?: { runId?: string; sessionKey?: string }): Promise<void> {\n const active = this.active;\n if (active) {\n active.aborted = true;\n try {\n active.agent.abortRun();\n } catch {\n // Already finished; the POST below is still worth making.\n }\n }\n const runId = params?.runId ?? active?.runId;\n const threadId = params?.sessionKey ?? active?.sessionKey;\n if (!runId || !threadId) {\n return;\n }\n try {\n await this.fetch(this.abortUrl, {\n method: \"POST\",\n headers: this.headers(),\n body: JSON.stringify({ runId, threadId }),\n });\n } catch {\n // Best-effort: dropping the stream aborts the run server-side anyway.\n }\n }\n\n /**\n * Answer one approval, on the thread and run it arrived on.\n *\n * `this.active` is deliberately not consulted. An approval pauses a run, and\n * a user is free to open another session from the drawer or start another\n * turn while it waits — at which point `active.sessionKey` is a different\n * conversation and \"allow\" would be sent against it. The request carried its\n * own `{ runId, sessionKey }`; those are what go on the wire.\n */\n async resolveApproval(\n id: string,\n decision: AgentApprovalDecision,\n context: AgentApprovalContext,\n ): Promise<void> {\n const response = await this.fetch(this.approvalUrl, {\n method: \"POST\",\n headers: this.headers(),\n body: JSON.stringify({\n id,\n decision,\n threadId: context.sessionKey,\n runId: context.runId,\n }),\n });\n // A rejected decision has to reach the user. Swallowing it left the card\n // gone, the run paused server-side and nothing on screen saying so — the\n // user believes they answered and the agent believes nobody did.\n if (!response.ok) {\n throw new Error(await readError(response, \"approval\"));\n }\n }\n\n private emit<T>(sinks: Set<Sink<T>>, event: T): void {\n for (const sink of [...sinks]) {\n sink(event);\n }\n }\n}\n\n/**\n * An `AgentChatClient` that streams over AG-UI and reads sessions and history\n * over the REST control plane.\n *\n * ```tsx\n * const client = React.useMemo(\n * () => createAguiAgentChatClient({ baseUrl: \"/api/agent\", getToken: () => jwt, agentId: \"tasks\" }),\n * [jwt],\n * );\n * ```\n */\nexport function createAguiAgentChatClient(\n options: CreateAguiAgentChatClientOptions,\n): AgentChatClient {\n return new AguiAgentChatClient(options);\n}\n\nasync function readError(response: Response, method: string): Promise<string> {\n try {\n const body = (await response.json()) as { error?: { message?: string } };\n if (body?.error?.message) {\n return body.error.message;\n }\n } catch {\n // Not JSON. The status is all there is.\n }\n if (response.status === 404) {\n // The single most common misconfiguration: the control-plane endpoints are\n // off, so every path under /api/core answers 404 before authentication.\n return `${method} is unavailable (404). Check that the AG-UI and control-plane endpoints are enabled.`;\n }\n return `${method} failed (${response.status})`;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"agui-client.js","names":[],"sources":["../../src/agent-chat/agui-client.ts"],"sourcesContent":["/**\n * `AgentChatClient` over AG-UI.\n *\n * Ported from `cortena-shared/src/agui/client.ts` (Cortena monorepo,\n * DESIGN-37). `cortena-shared` is not published, so what this package needs is\n * copied rather than imported; keep the two in step.\n *\n * **This is the only module in cortena-ui that imports `@ag-ui/client`**, and\n * `cortena-ui/agent-chat` is the only entry that reaches it. `@ag-ui/client`\n * brings rxjs, zod 3, uuid and protobuf; `test/bundle-probe.test.mjs` asserts\n * none of it is reachable from `cortena-ui` or `cortena-ui/core`.\n *\n * It is an OPTIONAL peer, and the import is therefore **lazy**: a static\n * `import { HttpAgent }` made the whole `cortena-ui/agent-chat` entry fail to\n * load for a consumer that had not installed it, so an extension on another\n * transport could not draw `AgentChatPopup` at all — a package it never asked\n * for decided whether its chat rendered. Loading it inside the factory keeps\n * the entry resolvable, and moves the failure to the one operation that\n * genuinely needs the peer, with a message that says what to install.\n *\n * What an extension must expose for this to work is in README.md, \"The agent\n * pop-up\": `POST /agui/run`, `/agui/abort`, `/agui/approval` and the two\n * control-plane reads, all proxied through the extension's own gateway with the\n * signed-in user's JWT — never a service token.\n *\n * Two things about the wire that are easy to get wrong:\n *\n * - The stream ends by **closing the connection** after `RUN_FINISHED` or\n * `RUN_ERROR`. There is no `data: [DONE]` sentinel; emitting one makes the\n * AG-UI client error the subject, because it `JSON.parse`s every `data:` line.\n * - Approvals go to their own endpoint. Sending a decision as `state` on a\n * second `/agui/run` opens a second concurrent stream, and two mappers then\n * write two sets of message ids into one bridge, which resets its text buffer\n * whenever the message id changes.\n */\n\nimport type { HttpAgent } from \"@ag-ui/client\";\nimport { AgentChatBridge } from \"./bridge\";\nimport type {\n AgentApprovalContext,\n AgentApprovalDecision,\n AgentApprovalEvent,\n AgentChatClient,\n AgentChatEvent,\n AgentChatSendInput,\n AgentToolEvent,\n} from \"./types\";\n\nexport interface CreateAguiAgentChatClientOptions {\n /**\n * Origin the AG-UI and control-plane paths hang off, no trailing slash.\n * For an extension this is its own gateway, which proxies to cortenacore.\n */\n baseUrl: string;\n /** The signed-in USER's JWT. Read on every request, never captured once. */\n getToken: () => string;\n /** The agent template slug — the extension id. Sessions are `agent:<id>:…`. */\n agentId: string;\n fetchImpl?: typeof fetch;\n /** Frontend tools advertised on every run; AG-UI requires a description. */\n tools?: Array<{ name: string; description: string; parameters?: unknown }>;\n /** Backoff before re-attaching a dropped stream. */\n reconnectDelays?: readonly number[];\n maxReconnectAttempts?: number;\n sleep?: (ms: number) => Promise<void>;\n now?: () => number;\n}\n\nconst DEFAULT_RECONNECT_DELAYS: readonly number[] = [500, 1_000, 2_000, 5_000, 10_000];\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 5;\n\nconst defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));\n\n/**\n * What a relative `baseUrl` resolves against.\n *\n * The browser's own origin, and a placeholder under Node — where this module\n * is exercised by tests with a `fetchImpl` stub and there is no origin to\n * speak of. The placeholder never reaches the network: the stub sees the\n * resolved string, and `/api/core/...` is what it asserts on.\n */\nfunction requestBase(): string {\n return typeof location === \"undefined\" ? \"http://cortena.invalid\" : location.origin;\n}\n\n/** What `@ag-ui/client` exports and this module constructs. */\ntype HttpAgentConstructor = typeof import(\"@ag-ui/client\").HttpAgent;\n\nexport const MISSING_AGUI_PEER =\n 'cortena-ui/agent-chat: the AG-UI transport needs the optional peer \"@ag-ui/client\". ' +\n \"Install it (pnpm add @ag-ui/client), or pass your own AgentChatClient to AgentChat.\";\n\n/**\n * `@ag-ui/client`, on demand.\n *\n * The specifier is a literal so a bundler still sees the dependency and puts\n * it in a chunk of its own — `test/bundle-probe.test.mjs` measures that the\n * agent-chat entry carries it. What changes is *when* it is evaluated, and\n * what happens when it is not there at all.\n */\nasync function loadHttpAgent(): Promise<HttpAgentConstructor> {\n try {\n return (await import(\"@ag-ui/client\")).HttpAgent;\n } catch (cause) {\n throw new Error(MISSING_AGUI_PEER, { cause });\n }\n}\n\ntype Sink<T> = (event: T) => void;\n\ninterface ActiveRun {\n runId: string;\n sessionKey: string;\n bridge: AgentChatBridge;\n agent: HttpAgent;\n aborted: boolean;\n}\n\nclass AguiAgentChatClient implements AgentChatClient {\n readonly agentId: string;\n\n private readonly opts: CreateAguiAgentChatClientOptions;\n private readonly delays: readonly number[];\n private readonly maxAttempts: number;\n private readonly sleep: (ms: number) => Promise<void>;\n private readonly chatSinks = new Set<Sink<AgentChatEvent>>();\n private readonly toolSinks = new Set<Sink<AgentToolEvent>>();\n private readonly approvalSinks = new Set<Sink<AgentApprovalEvent>>();\n private active: ActiveRun | null = null;\n /** The optional peer, requested when the client is created and awaited per run. */\n private readonly httpAgent: Promise<HttpAgentConstructor>;\n\n constructor(opts: CreateAguiAgentChatClientOptions) {\n this.opts = opts;\n this.agentId = opts.agentId;\n this.delays = opts.reconnectDelays ?? DEFAULT_RECONNECT_DELAYS;\n this.maxAttempts = opts.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;\n this.sleep = opts.sleep ?? defaultSleep;\n // Started here so a missing peer is discovered when the client is made\n // rather than mid-stream. The rejection is answered immediately: a client\n // created and never sent to must not raise an unhandled rejection, and\n // `startRun` awaits the same promise where the user can be told.\n this.httpAgent = loadHttpAgent();\n this.httpAgent.catch(() => {});\n }\n\n onChat(sink: Sink<AgentChatEvent>): () => void {\n this.chatSinks.add(sink);\n return () => this.chatSinks.delete(sink);\n }\n\n onTool(sink: Sink<AgentToolEvent>): () => void {\n this.toolSinks.add(sink);\n return () => this.toolSinks.delete(sink);\n }\n\n onApproval(sink: Sink<AgentApprovalEvent>): () => void {\n this.approvalSinks.add(sink);\n return () => this.approvalSinks.delete(sink);\n }\n\n get runUrl(): string {\n return `${this.opts.baseUrl}/agui/run`;\n }\n\n get abortUrl(): string {\n return `${this.opts.baseUrl}/agui/abort`;\n }\n\n get approvalUrl(): string {\n return `${this.opts.baseUrl}/agui/approval`;\n }\n\n private headers(): Record<string, string> {\n return {\n authorization: `Bearer ${this.opts.getToken()}`,\n \"content-type\": \"application/json\",\n };\n }\n\n /**\n * The global `fetch` is BOUND, and that is the whole point of this getter.\n *\n * Every call site below is `this.fetch(...)`, which invokes whatever this\n * returns with `this` set to the client instance. A native `fetch` refuses\n * that: \"Failed to execute 'fetch' on 'Window': Illegal invocation\". So the\n * control plane — the session list, the history, abort and approval — threw\n * on its first call in every product build, and the pop-up rendered that\n * message where the conversation should be.\n *\n * It survived since 1.5.0 because the tests inject `fetchImpl`, and a plain\n * function stub does not care what `this` is. The stub is exactly what hid\n * the bug, so the test beside this change uses a `globalThis.fetch` that\n * rejects a foreign `this` the way the real one does.\n */\n private get fetch(): typeof fetch {\n return this.opts.fetchImpl ?? globalThis.fetch.bind(globalThis);\n }\n\n /** `sessions.list` and `chat.history` over the REST control plane. */\n async request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {\n const path = `${this.opts.baseUrl}/api/core/${method.split(\".\").join(\"/\")}`;\n const readOnly = method === \"sessions.list\" || method === \"chat.history\";\n // `new URL(path)` with no base throws on a relative `baseUrl`, and the one\n // in this module's own example is `\"/api/agent\"` — the shape every\n // extension uses, since the gateway that proxies these calls is the app's\n // own origin. `sessions.list` therefore threw before it was ever sent, and\n // the chat opened on \"The agent could not be reached\".\n const url = new URL(path, requestBase());\n if (readOnly) {\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== null) {\n url.searchParams.set(key, String(value));\n }\n }\n }\n const response = await this.fetch(url.toString(), {\n method: readOnly ? \"GET\" : \"POST\",\n headers: this.headers(),\n ...(readOnly ? {} : { body: JSON.stringify(params) }),\n });\n if (!response.ok) {\n throw new Error(await readError(response, method));\n }\n return (await response.json()) as T;\n }\n\n async send(input: AgentChatSendInput): Promise<void> {\n // Fire and forget: the caller only awaits the acceptance. Events follow on\n // the stream, exactly as they do when the server acknowledges a send.\n void this.startRun(input);\n }\n\n private async startRun(input: AgentChatSendInput): Promise<void> {\n const bridge = new AgentChatBridge({\n runId: input.runId,\n sessionKey: input.sessionKey,\n chat: (event) => this.emit(this.chatSinks, event),\n tool: (event) => this.emit(this.toolSinks, event),\n approval: (event) => this.emit(this.approvalSinks, event),\n ...(this.opts.now ? { now: this.opts.now } : {}),\n });\n\n const forwarded: Record<string, unknown> = {\n ...(input.thinking ? { thinking: input.thinking } : {}),\n ...(input.attachments?.length ? { attachments: input.attachments } : {}),\n ...(input.model ? { model: input.model } : {}),\n // A click inside a rendered A2UI surface or MCP App. Structured rather\n // than typed prose: cortenacore turns it into the documented envelope.\n ...(input.a2uiAction ? { a2uiAction: input.a2uiAction } : {}),\n ...(input.mcpAppAction ? { mcpAppAction: input.mcpAppAction } : {}),\n };\n\n let Agent: HttpAgentConstructor;\n try {\n Agent = await this.httpAgent;\n } catch (err) {\n // The one operation that needs the peer, and the only place it can be\n // reported: in the chat, as a failed run, rather than as a rejected\n // promise nobody is holding.\n bridge.fail(err instanceof Error ? err.message : MISSING_AGUI_PEER);\n return;\n }\n\n let attempt = 0;\n let resume = false;\n for (;;) {\n const runId = resume ? `${input.runId}:resume-${attempt}` : input.runId;\n const agent = new Agent({\n url: this.runUrl,\n headers: this.headers(),\n threadId: input.sessionKey,\n // A re-attach carries no new user message; the server re-streams the\n // run named by forwardedProps.resumeRunId instead of starting one.\n initialMessages: resume\n ? []\n : [{ id: input.runId, role: \"user\", content: input.message }],\n ...(this.opts.fetchImpl ? { fetch: this.opts.fetchImpl } : {}),\n });\n this.active = {\n runId: input.runId,\n sessionKey: input.sessionKey,\n bridge,\n agent,\n aborted: this.active?.aborted === true && this.active.runId === input.runId,\n };\n try {\n await agent.runAgent(\n {\n runId,\n ...(this.opts.tools?.length ? { tools: this.opts.tools } : {}),\n forwardedProps: {\n ...forwarded,\n ...(resume ? { resumeRunId: input.runId } : {}),\n },\n },\n { onEvent: ({ event }) => bridge.handleEvent(event as never) },\n );\n return;\n } catch (err) {\n if (bridge.finished || this.active?.aborted) {\n return;\n }\n attempt += 1;\n if (attempt > this.maxAttempts) {\n bridge.fail(err instanceof Error ? err.message : \"The agent stream failed\");\n return;\n }\n await this.sleep(this.delays[Math.min(attempt - 1, this.delays.length - 1)] ?? 1_000);\n // The backoff is up to ten seconds long, and everything can change in\n // it: the user presses Stop, or a late RUN_FINISHED lands on the\n // bridge. Checking only before the sleep re-attached to a run that had\n // already ended — a second stream, a second set of message ids into\n // one bridge, and an abort the user watched do nothing.\n if (bridge.finished || this.active?.aborted) {\n return;\n }\n resume = true;\n }\n }\n }\n\n async abort(params?: { runId?: string; sessionKey?: string }): Promise<void> {\n const active = this.active;\n if (active) {\n active.aborted = true;\n try {\n active.agent.abortRun();\n } catch {\n // Already finished; the POST below is still worth making.\n }\n }\n const runId = params?.runId ?? active?.runId;\n const threadId = params?.sessionKey ?? active?.sessionKey;\n if (!runId || !threadId) {\n return;\n }\n try {\n await this.fetch(this.abortUrl, {\n method: \"POST\",\n headers: this.headers(),\n body: JSON.stringify({ runId, threadId }),\n });\n } catch {\n // Best-effort: dropping the stream aborts the run server-side anyway.\n }\n }\n\n /**\n * Answer one approval, on the thread and run it arrived on.\n *\n * `this.active` is deliberately not consulted. An approval pauses a run, and\n * a user is free to open another session from the drawer or start another\n * turn while it waits — at which point `active.sessionKey` is a different\n * conversation and \"allow\" would be sent against it. The request carried its\n * own `{ runId, sessionKey }`; those are what go on the wire.\n */\n async resolveApproval(\n id: string,\n decision: AgentApprovalDecision,\n context: AgentApprovalContext,\n ): Promise<void> {\n const response = await this.fetch(this.approvalUrl, {\n method: \"POST\",\n headers: this.headers(),\n body: JSON.stringify({\n id,\n decision,\n threadId: context.sessionKey,\n runId: context.runId,\n }),\n });\n // A rejected decision has to reach the user. Swallowing it left the card\n // gone, the run paused server-side and nothing on screen saying so — the\n // user believes they answered and the agent believes nobody did.\n if (!response.ok) {\n throw new Error(await readError(response, \"approval\"));\n }\n }\n\n private emit<T>(sinks: Set<Sink<T>>, event: T): void {\n for (const sink of [...sinks]) {\n sink(event);\n }\n }\n}\n\n/**\n * An `AgentChatClient` that streams over AG-UI and reads sessions and history\n * over the REST control plane.\n *\n * ```tsx\n * const client = React.useMemo(\n * () => createAguiAgentChatClient({ baseUrl: \"/api/agent\", getToken: () => jwt, agentId: \"tasks\" }),\n * [jwt],\n * );\n * ```\n */\nexport function createAguiAgentChatClient(\n options: CreateAguiAgentChatClientOptions,\n): AgentChatClient {\n return new AguiAgentChatClient(options);\n}\n\nasync function readError(response: Response, method: string): Promise<string> {\n try {\n const body = (await response.json()) as { error?: { message?: string } };\n if (body?.error?.message) {\n return body.error.message;\n }\n } catch {\n // Not JSON. The status is all there is.\n }\n if (response.status === 404) {\n // The single most common misconfiguration: the control-plane endpoints are\n // off, so every path under /api/core answers 404 before authentication.\n return `${method} is unavailable (404). Check that the AG-UI and control-plane endpoints are enabled.`;\n }\n return `${method} failed (${response.status})`;\n}\n"],"mappings":"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"}
|
|
@@ -22,6 +22,16 @@ export interface CortenaAppAuth {
|
|
|
22
22
|
authServiceUrl?: string;
|
|
23
23
|
/** cortena-auth's public origin — the account link on the Profile page. */
|
|
24
24
|
publicAuthUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The organisation this launch is for — the `?org=` the host put on the URL,
|
|
27
|
+
* or the one this browser signed in to last.
|
|
28
|
+
*
|
|
29
|
+
* It reaches `LoginScreen` as the default for its organisation step, which is
|
|
30
|
+
* the whole point: `POST /api/auth/login` requires `orgId`, so without one the
|
|
31
|
+
* screen has to ask, and with one it must not ask again (DESIGN-126). It used
|
|
32
|
+
* to be declared here and read nowhere, which is how the fleet shipped a
|
|
33
|
+
* sign-in that 400s on a fresh browser.
|
|
34
|
+
*/
|
|
25
35
|
orgId?: string;
|
|
26
36
|
/**
|
|
27
37
|
* What the org advertises, from `GET /api/orgs/:orgId/auth-info`. The fetch
|
|
@@ -32,8 +42,18 @@ export interface CortenaAppAuth {
|
|
|
32
42
|
* with no way in.
|
|
33
43
|
*/
|
|
34
44
|
methods?: readonly LoginMethod[];
|
|
45
|
+
/**
|
|
46
|
+
* The user typed an organisation the app does not know yet. Fetch its
|
|
47
|
+
* `auth-info`, remember it, and update `orgId` and `methods`; reject to keep
|
|
48
|
+
* the user on the organisation step with your message.
|
|
49
|
+
*
|
|
50
|
+
* Omitted, a non-empty organisation is simply taken at its word and checked
|
|
51
|
+
* at sign-in.
|
|
52
|
+
*/
|
|
53
|
+
onOrg?: (orgId: string) => void | Promise<unknown>;
|
|
35
54
|
onPassword?: (credentials: PasswordCredentials) => void;
|
|
36
|
-
|
|
55
|
+
/** The organisation comes with the provider: cortena-auth requires it there too. */
|
|
56
|
+
onProvider?: (provider: Exclude<LoginMethod, "password">, orgId: string) => void;
|
|
37
57
|
/** A failed sign-in, in the user's words. */
|
|
38
58
|
error?: string;
|
|
39
59
|
loading?: boolean;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use client";
|
|
3
3
|
import { AppShell, documentTitle } from "../app-shell.js";
|
|
4
|
+
import { devWarn } from "../../lib/dev.js";
|
|
4
5
|
import { LoginScreen } from "../login-screen.js";
|
|
5
6
|
import { DEFAULT_SESSION_POLICY, SessionExpiryDialog, SessionGuard, decodeSessionClaims } from "../session-guard.js";
|
|
6
|
-
import { devWarn } from "../../lib/dev.js";
|
|
7
7
|
import { readCortenaSessionMessage } from "./launch.js";
|
|
8
8
|
import { CortenaProfilePage, CortenaSettingsPage } from "./settings.js";
|
|
9
9
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -41,7 +41,7 @@ import * as React from "react";
|
|
|
41
41
|
* <CortenaApp
|
|
42
42
|
* extension={{ id: "tasks", name: "Tasks" }}
|
|
43
43
|
* user={user}
|
|
44
|
-
* auth={{ authServiceUrl, publicAuthUrl, orgId, methods, onPassword, onProvider }}
|
|
44
|
+
* auth={{ authServiceUrl, publicAuthUrl, orgId, methods, onOrg, onPassword, onProvider }}
|
|
45
45
|
* session={{ launch, getToken, refresh, signOut, onTokenRenewed }}
|
|
46
46
|
* nav={{ groups, pathname }}
|
|
47
47
|
* agent={{ slug: "tasks", gatewayUrl: "/api/agent", createClient: createAguiAgentChatClient }}
|
|
@@ -58,7 +58,9 @@ import * as React from "react";
|
|
|
58
58
|
*
|
|
59
59
|
* 1. the launch, from `session.launch` — a value the app read synchronously
|
|
60
60
|
* in its own store initialiser, never a read this component performs;
|
|
61
|
-
* 2. no token → `LoginScreen` with the methods the org advertises
|
|
61
|
+
* 2. no token → `LoginScreen` with the methods the org advertises, and with
|
|
62
|
+
* `auth.orgId` as its organisation, so it asks for one only when nothing
|
|
63
|
+
* here knows it (DESIGN-126);
|
|
62
64
|
* 3. the session ended → `LoginScreen` with `signedOut` and a `returnTo`;
|
|
63
65
|
* 4. exactly one guard, at the top, above the shell and the pop-up alike;
|
|
64
66
|
* 5. `AppShell`, with Profile / Settings / Log out in the avatar menu;
|
|
@@ -335,6 +337,8 @@ function CortenaApp({ extension, user, auth, session, role = "extension", nav, a
|
|
|
335
337
|
name: extension.name
|
|
336
338
|
},
|
|
337
339
|
methods: auth?.methods,
|
|
340
|
+
orgId: auth?.orgId,
|
|
341
|
+
onOrg: auth?.onOrg,
|
|
338
342
|
onPassword: auth?.onPassword,
|
|
339
343
|
onProvider: auth?.onProvider,
|
|
340
344
|
error: auth?.error,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cortena-app.js","names":[],"sources":["../../../src/components/app/cortena-app.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport {\n AppShell,\n type AppShellHelp,\n type AppShellMenuItem,\n type AppShellNav,\n type HelpPanelSlotProps,\n documentTitle,\n} from \"@/components/app-shell\";\nimport {\n LoginScreen,\n type LoginMethod,\n type PasswordCredentials,\n} from \"@/components/login-screen\";\nimport {\n DEFAULT_SESSION_POLICY,\n SessionExpiryDialog,\n SessionGuard,\n type SessionPolicy,\n type SessionSignOutReason,\n type SessionTimers,\n decodeSessionClaims,\n} from \"@/components/session-guard\";\nimport type { DialogContentProps } from \"@/components/dialog\";\nimport type { AdminRoute } from \"@/components/admin-permissions/types\";\nimport { devWarn } from \"@/lib/dev\";\nimport { type CortenaLaunch, readCortenaSessionMessage } from \"./launch\";\nimport type { CortenaAppAgent } from \"./agent-mount\";\nimport {\n CortenaProfilePage,\n CortenaSettingsPage,\n type CortenaAppPermissions,\n type CortenaAppUser,\n type CortenaSettingsDeclaration,\n} from \"./settings\";\n\n/**\n * CortenaApp — everything around an extension's content, as one composition\n * (DESIGN-119).\n *\n * `AppShell` (DESIGN-88) owns only what renders after sign-in. Everything\n * around it — the sign-in screen, the signed-out state, the session guard, the\n * agent pop-up, the tab title and the Profile / Settings / Permissions pages —\n * was left to each extension to assemble, and the 2026-09-14 re-audit found\n * eight extensions with eight answers: seven local login pages, three of them\n * with dummy users; a guard mounted with a different subset of its behaviour\n * in each; a pop-up mounted in one extension without the user's token or the\n * agent slug; a Settings item greyed out in every shell. That is what this\n * replaces, in one mount.\n *\n * ## What it is, deliberately\n *\n * A plain React **client** component. It is router-agnostic (`pathname` in,\n * `onNavigate` out, real `href`s, exactly as `AppShell` already does), it\n * fetches nothing, it reads nothing from the browser during a first render,\n * and it does not assume it owns the document. Those are not stylistic: the\n * third consumer of this composition is CortenaWeb, in a `host` role, inside\n * its own Next.js root layout — and without every one of them the fleet keeps\n * two shells (DESIGN-119, decision note, constraint 2).\n *\n * ```tsx\n * // main.tsx — synchronously, before createRoot (§7.3)\n * const launch = readCortenaLaunch({ hostOrigins: [config.cortenaWebOrigin] });\n *\n * // App.tsx\n * <CortenaApp\n * extension={{ id: \"tasks\", name: \"Tasks\" }}\n * user={user}\n * auth={{ authServiceUrl, publicAuthUrl, orgId, methods, onPassword, onProvider }}\n * session={{ launch, getToken, refresh, signOut, onTokenRenewed }}\n * nav={{ groups, pathname }}\n * agent={{ slug: \"tasks\", gatewayUrl: \"/api/agent\", createClient: createAguiAgentChatClient }}\n * permissions={{ api: adminApi, canAdminister: isAdmin }}\n * settings={{ schema, load, save }}\n * pathname={pathname}\n * onNavigate={(href) => navigate(href)}\n * >\n * <Outlet />\n * </CortenaApp>\n * ```\n *\n * ## The order it does things in\n *\n * 1. the launch, from `session.launch` — a value the app read synchronously\n * in its own store initialiser, never a read this component performs;\n * 2. no token → `LoginScreen` with the methods the org advertises;\n * 3. the session ended → `LoginScreen` with `signedOut` and a `returnTo`;\n * 4. exactly one guard, at the top, above the shell and the pop-up alike;\n * 5. `AppShell`, with Profile / Settings / Log out in the avatar menu;\n * 6. the agent pop-up in `agentSlot`, in the `extension` role only;\n * 7. the standard pages when `pathname` names one, the app's `children`\n * otherwise.\n *\n * ## One guard, and only when there is one to run\n *\n * Decision (b) on DESIGN-119. A framed extension cannot refresh — CortenaWeb\n * holds the refresh token — so the rule is:\n *\n * the host declared it guards this session no guard here. Renewed tokens\n * arrive through\n * `session.onTokenRenewed`.\n * framed, and it did not a countdown from the token's\n * own `exp`, the standard\n * warning, and the signed-out\n * state with a `returnTo`.\n * not framed the full `SessionGuard`: idle\n * clock, proactive refresh,\n * cross-tab `BroadcastChannel`.\n *\n * Never two countdowns, and never the old behaviour of trusting a host that\n * never said anything. The declaration is read and origin-checked in\n * `readCortenaLaunch`; see `launch.ts`.\n */\n\nconst AgentMount = React.lazy(() => import(\"./agent-mount\"));\n\n/* ── the contract ────────────────────────────────────────────────────────── */\n\nexport interface CortenaAppExtension {\n /** Registered mark id, from `x-cortena.icon` in the OpenAPI document (§10.1). */\n id: string;\n /** Display name. The shell's title, the tab's title and the login heading. */\n name: string;\n /** Override the mark when it is not the extension id. Rarely needed. */\n icon?: string;\n}\n\nexport interface CortenaAppAuth {\n /** cortena-auth, in-cluster. From `config.json`; this component never calls it. */\n authServiceUrl?: string;\n /** cortena-auth's public origin — the account link on the Profile page. */\n publicAuthUrl?: string;\n orgId?: string;\n /**\n * What the org advertises, from `GET /api/orgs/:orgId/auth-info`. The fetch\n * stays in the app: cortena-ui cannot depend on firebase, and a component\n * that fetches cannot be rendered in the guide or in a test (§8.4).\n *\n * An empty or omitted list falls back to password rather than to a screen\n * with no way in.\n */\n methods?: readonly LoginMethod[];\n onPassword?: (credentials: PasswordCredentials) => void;\n onProvider?: (provider: Exclude<LoginMethod, \"password\">) => void;\n /** A failed sign-in, in the user's words. */\n error?: string;\n loading?: boolean;\n}\n\nexport interface CortenaSessionAdapter {\n /**\n * The launch, as the app read it synchronously before the first render —\n * `readCortenaLaunch()`. A value rather than a callback, so this component\n * touches no browser API while rendering (§7.3).\n */\n launch: CortenaLaunch;\n /** The current access token. Read on every use, never captured. */\n getToken: () => string;\n /**\n * When the access token expires, for a token whose `exp` cannot be read — an\n * opaque token, or one the decoder failed on. Epoch **milliseconds**; a value\n * below `1e11` is read as epoch seconds instead. Exactly `SessionToken`'s\n * own escape hatch, and the way to keep an unreadable credential guarded\n * rather than landing on the signed-out screen (see the note on the guard\n * below).\n */\n expiresAt?: number;\n /**\n * The origins a host declaration and a host-pushed token are accepted from —\n * CortenaWeb's, from this extension's own configuration, never from the\n * request.\n *\n * Given, the composition listens for the host's first bridge message and\n * takes the declaration from it; `event.origin` is the browser's, not the\n * sender's, and is the only part of a `postMessage` that cannot be forged\n * from inside the page that sent it, which makes this the safer of the two\n * declaration paths (see `launch.ts` on what `ancestorOrigins` cannot\n * promise). With none given nothing is ever trusted.\n */\n hostOrigins?: readonly string[];\n /** `POST /api/auth/refresh`. Only called when this app can refresh. */\n refresh?: () => Promise<{ accessToken: string }>;\n /**\n * `POST /api/auth/logout`, or the message that asks the host to sign out.\n *\n * Omit it in a framed launch where there is no host sign-out message to\n * send: the avatar menu's Log out is then disabled, which is what every\n * shell swap does today and is honest — a menu item that silently does\n * nothing is worse than one that is visibly unavailable.\n */\n signOut?: () => Promise<void>;\n /**\n * Renewed tokens, from wherever the app gets them — the bridge, its own\n * store, a rotation it ran itself. An alternative to `hostOrigins` for an\n * app that would rather own the `message` listener; both end up in the same\n * place, and a token identical to the one in force costs a comparison.\n */\n onTokenRenewed?: (listener: (accessToken: string) => void) => () => void;\n /** Org policy from `GET /api/orgs/:orgId/session-policy` (§10.4.2). */\n policy?: Partial<SessionPolicy>;\n /** The `BroadcastChannel` name. One per origin unless two apps must not share. */\n channelName?: string;\n /** The clock and the timers. Injected by tests and by the guide. */\n now?: () => number;\n timers?: SessionTimers;\n}\n\n/** Where the standard pages live. Overridable; the defaults are the fleet's. */\nexport interface CortenaAppRoutes {\n /** @default \"/me\" */\n profile?: string;\n /** @default \"/settings\" */\n settings?: string;\n /** @default \"/settings/permissions\" */\n permissions?: string;\n}\n\nexport const CORTENA_STANDARD_ROUTES: Required<CortenaAppRoutes> = /* @__PURE__ */ Object.freeze({\n profile: \"/me\",\n settings: \"/settings\",\n permissions: \"/settings/permissions\",\n});\n\nexport interface CortenaAppProps {\n extension: CortenaAppExtension;\n user: CortenaAppUser;\n auth?: CortenaAppAuth;\n session: CortenaSessionAdapter;\n /**\n * `extension` mounts the agent pop-up and is the default. `host` is\n * CortenaWeb: the pop-up slot stays empty because the host *is* the chat,\n * and its own guard is the one that renews and pushes tokens into frames.\n */\n role?: \"extension\" | \"host\";\n nav?: AppShellNav | null;\n agent?: CortenaAppAgent;\n settings?: CortenaSettingsDeclaration;\n permissions?: CortenaAppPermissions;\n help?: AppShellHelp;\n helpPanel?: React.ReactNode | ((props: HelpPanelSlotProps) => React.ReactNode);\n headerSlot?: React.ReactNode;\n /** Extra avatar-menu items, between Settings and Log out. */\n menuItems?: AppShellMenuItem[];\n bottomInset?: string | number;\n themeStorageKey?: string;\n /** The current path. The composition compares it to the standard routes. */\n pathname: string;\n /** Take over a navigation. The composition never touches `history`. */\n onNavigate: (href: string) => void;\n /**\n * The page half of the tab title for a route: `documentTitle(name, page)`\n * gives \"<page> · <name> · Cortena\" (§10.5, P-38). Set after mount, never\n * during a render.\n *\n * `false` leaves the title alone entirely, for a surface that is not the\n * whole tab — the guide, which mounts four of these on one page, and a host\n * that already sets its own. The composition does not assume it owns the\n * document; it assumes it usually does.\n */\n documentTitle?: ((pathname: string) => string | undefined) | false;\n routes?: CortenaAppRoutes;\n /**\n * Non-modal, and portalled somewhere other than the body. For the guide,\n * where three theme frames must not fight over focus. The product leaves\n * this alone, exactly as it leaves `SessionGuard`'s own pair alone.\n */\n sessionDialog?: { modal?: boolean; container?: DialogContentProps[\"container\"] };\n /** Told when the session ends, so the app can clear its own stores. */\n onSignedOut?: (reason: SessionSignOutReason) => void;\n /**\n * On the frame's own root — the shell, or the sign-in screen when there is\n * no session. `min-h-0 h-full` is how the guide boxes it.\n */\n className?: string;\n /** The routed content. Replaced by a standard page when `pathname` names one. */\n children?: React.ReactNode;\n}\n\n/* ── the standard routes ─────────────────────────────────────────────────── */\n\nexport type CortenaStandardRoute =\n | { kind: \"profile\" }\n | { kind: \"settings\"; adminRoute?: AdminRoute };\n\nconst ADMIN_ROUTE_NAMES = new Set<string>([\"members\", \"roles\", \"licence\", \"permissions\"]);\n\n/** `/settings` and `/settings/permissions/roles` both match; `/settingsX` does not. */\nfunction under(pathname: string, base: string): string | undefined {\n if (pathname === base) {\n return \"\";\n }\n return pathname.startsWith(`${base}/`) ? pathname.slice(base.length + 1) : undefined;\n}\n\n/**\n * Which standard page a path names, or `undefined` for the app's own content.\n *\n * Exported because an app that would rather place these pages itself — a Next\n * route segment, a react-router `<Route>` — needs the same answer this\n * composition uses, rather than a second copy of the rule.\n */\nexport function matchCortenaStandardRoute(\n pathname: string,\n routes?: CortenaAppRoutes,\n): CortenaStandardRoute | undefined {\n const resolved = { ...CORTENA_STANDARD_ROUTES, ...routes };\n if (under(pathname, resolved.profile) === \"\") {\n return { kind: \"profile\" };\n }\n const permissions = under(pathname, resolved.permissions);\n if (permissions !== undefined) {\n return {\n kind: \"settings\",\n adminRoute: ADMIN_ROUTE_NAMES.has(permissions) ? (permissions as AdminRoute) : \"members\",\n };\n }\n return under(pathname, resolved.settings) !== undefined ? { kind: \"settings\" } : undefined;\n}\n\n/* ── the unguarded frame's countdown ─────────────────────────────────────── */\n\n/** The countdown redraws once a second, which is the resolution it shows. */\nconst COUNTDOWN_TICK_MS = 1000;\n\ntype CountdownState = { state: \"active\" | \"warning\" | \"expired\"; remainingMs: number };\n\n/**\n * A countdown from the access token's own `exp`, for a framed launch the host\n * did not declare it guards (decision (b)).\n *\n * It is not `SessionGuard` and must not be: there is no idle clock to run\n * (the host's is the session's), nothing to refresh (the refresh token is the\n * host's), and no cross-tab election to hold (this frame is one surface of the\n * host's tab). What is left is the honest part — warn before the credential\n * dies, then hand the user back — and running the whole guard here would give\n * the user two countdowns for one session.\n */\nfunction useTokenCountdown(\n expiresAt: number | undefined,\n enabled: boolean,\n warnMs: number,\n now: () => number,\n timers: SessionTimers | undefined,\n): CountdownState {\n const [tick, setTick] = React.useState(0);\n const active = enabled && expiresAt !== undefined;\n\n const clock = React.useRef({ now, timers });\n clock.current = { now, timers };\n\n React.useEffect(() => {\n if (!active) {\n return;\n }\n let handle: unknown;\n let live = true;\n const set = clock.current.timers?.setTimeout ?? ((fn: () => void, ms: number) => globalThis.setTimeout(fn, ms));\n const clear = clock.current.timers?.clearTimeout ?? ((h: unknown) => globalThis.clearTimeout(h as number));\n const loop = () => {\n if (!live) {\n return;\n }\n setTick((n) => n + 1);\n handle = set(loop, COUNTDOWN_TICK_MS);\n };\n handle = set(loop, COUNTDOWN_TICK_MS);\n return () => {\n live = false;\n if (handle !== undefined) {\n clear(handle);\n }\n };\n // `tick` is deliberately not a dependency: the loop reschedules itself.\n }, [active, expiresAt]);\n\n if (!enabled) {\n return { state: \"active\", remainingMs: 0 };\n }\n if (expiresAt === undefined) {\n /**\n * No expiry, and no way to find one: this frame cannot guard the session.\n *\n * It is reported as already over rather than as \"fine\", and that is the\n * whole fix. A launch with no readable `exp` used to take this branch and\n * then count nothing — no warning, no expiry, no signed-out state — which\n * is precisely the silent unguarded frame decision (b) removed, arriving\n * through the back door.\n *\n * Over rather than warning, because a warning's entire content is a number\n * there is none of: \"Your session ends in 0:00\", permanently, is a lie,\n * and its only way out is Log out, which lands on this screen anyway. The\n * signed-out screen says the true thing, carries the `returnTo`, and\n * offers the better recovery — sign in again and come back to where you\n * were. An app whose token is legitimately opaque passes\n * `session.expiresAt` and is guarded normally.\n */\n return { state: \"expired\", remainingMs: 0 };\n }\n void tick;\n const remainingMs = Math.max(0, expiresAt - now());\n return {\n state: remainingMs <= 0 ? \"expired\" : remainingMs <= warnMs ? \"warning\" : \"active\",\n remainingMs,\n };\n}\n\n/** Below this an `expiresAt` is epoch seconds; `SessionToken` states the same rule. */\nconst SECONDS_CEILING = 1e11;\n\n/**\n * When the session ends: the token's own `exp`, or the expiry the app supplied\n * for a token whose claims cannot be read.\n */\nfunction sessionExpiry(accessToken: string, expiresAt: number | undefined): number | undefined {\n const claimed = decodeSessionClaims(accessToken).expiresAt;\n if (claimed !== undefined) {\n return claimed;\n }\n if (typeof expiresAt !== \"number\" || !Number.isFinite(expiresAt) || expiresAt <= 0) {\n return undefined;\n }\n return expiresAt < SECONDS_CEILING ? expiresAt * 1000 : expiresAt;\n}\n\n/* ── the composition ─────────────────────────────────────────────────────── */\n\nfunction CortenaApp({\n extension,\n user,\n auth,\n session,\n role = \"extension\",\n nav,\n agent,\n settings,\n permissions,\n help,\n helpPanel,\n headerSlot,\n menuItems,\n bottomInset,\n themeStorageKey,\n pathname,\n onNavigate,\n documentTitle: pageTitle,\n routes,\n sessionDialog,\n onSignedOut,\n className,\n children,\n}: CortenaAppProps) {\n const { launch } = session;\n const resolvedRoutes = { ...CORTENA_STANDARD_ROUTES, ...routes };\n\n /**\n * The token, seeded from the launch. The launch was read synchronously by\n * the app before the first render (§7.3), so this is a plain initial value\n * and not a read performed here.\n */\n const [token, setToken] = React.useState(() => launch.accessToken ?? \"\");\n const [signedOut, setSignedOut] = React.useState<SessionSignOutReason | undefined>(undefined);\n /** The host declared on the bridge rather than on the launch URL. */\n const [bridgeGuarded, setBridgeGuarded] = React.useState(false);\n\n /**\n * The token already in force. A ref rather than `token` itself, because\n * `adopt` has to tell \"the same token again\" from \"a new one\" and only the\n * second clears the signed-out state.\n */\n const adopted = React.useRef(launch.accessToken ?? \"\");\n\n /**\n * Take on a token from any source — the launch, the app's own store, a\n * rotation the app ran itself, or one the host pushed down the bridge — and\n * clear the signed-out state when it is genuinely a new one.\n *\n * Clearing it HERE, rather than only where a host pushes a token, is the\n * point. That was the only place it was cleared, and it exists only in a\n * host-guarded frame; on a standalone launch or an undeclared frame the\n * signed-out screen short-circuits the whole render, so a successful sign-in\n * set the token and the user stayed on \"You were signed out\" for ever, with\n * nothing on screen to explain it.\n */\n const adopt = React.useCallback((next: string | undefined) => {\n if (!next || next === adopted.current) {\n return;\n }\n adopted.current = next;\n setToken(next);\n setSignedOut(undefined);\n }, []);\n\n // The launch the app handed in.\n React.useEffect(() => {\n adopt(launch.accessToken);\n }, [adopt, launch.accessToken]);\n\n // And the app's own store, read after every render rather than off a\n // dependency: a sign-in happens inside the adapter, and a function prop's\n // identity says nothing about what it now returns. It is a ref comparison\n // per render — the same shape AppShell uses to notice a bottom bar that\n // arrives with the data.\n React.useEffect(() => {\n adopt(session.getToken());\n });\n\n /**\n * The host's declaration and its token renewals, on the bridge.\n *\n * Decision (b) allows the declaration to arrive as the first bridge message\n * rather than as a launch parameter, and this is the path to prefer:\n * `event.origin` is the browser's, not the sender's, and is the only part of\n * a `postMessage` that cannot be forged from inside the page that sent it.\n * `launch.ts` records what the launch-parameter path cannot promise on a\n * browser with no `ancestorOrigins`.\n *\n * Subscribed only where the app named the origins it will accept, and only\n * in a frame. With none named nothing is ever trusted — the same rule\n * `readCortenaLaunch` follows, and the same safe direction: the frame runs\n * its own countdown rather than trusting a parent it cannot name.\n */\n const hostOrigins = session.hostOrigins;\n React.useEffect(() => {\n if (!launch.embedded || !hostOrigins?.length || typeof window === \"undefined\") {\n return;\n }\n const onMessage = (event: MessageEvent) => {\n const message = readCortenaSessionMessage(event, hostOrigins);\n if (!message) {\n return;\n }\n if (message.guarded) {\n setBridgeGuarded(true);\n }\n adopt(message.accessToken);\n };\n window.addEventListener(\"message\", onMessage);\n return () => window.removeEventListener(\"message\", onMessage);\n }, [adopt, hostOrigins, launch.embedded]);\n\n // Whichever path said it. Going undeclared -> declared only ever takes a\n // countdown away, so a declaration that arrives a beat after the launch is\n // safe: there is still never more than one.\n const hostGuarded = Boolean(launch.embedded && (launch.hostGuarded || bridgeGuarded));\n\n // The adapter's own renewal channel, for an app that would rather own the\n // listener than hand over `hostOrigins`. Not gated on `hostGuarded`: a token\n // is a token, and an app that has a newer one should be able to say so.\n const subscribe = session.onTokenRenewed;\n React.useEffect(() => {\n if (!subscribe) {\n return;\n }\n return subscribe(adopt);\n }, [adopt, subscribe]);\n\n const end = React.useCallback(\n (reason: SessionSignOutReason) => {\n setSignedOut(reason);\n onSignedOut?.(reason);\n },\n [onSignedOut],\n );\n\n /**\n * Which of the three session behaviours this launch gets. One of them, ever.\n */\n const guard: \"none\" | \"countdown\" | \"full\" = hostGuarded\n ? \"none\"\n : launch.embedded && !launch.canRefresh\n ? \"countdown\"\n : \"full\";\n\n const warnMs = session.policy?.warnMs ?? DEFAULT_SESSION_POLICY.warnMs;\n const now = session.now ?? Date.now;\n const expiresAt = React.useMemo(\n () => sessionExpiry(token, session.expiresAt),\n [token, session.expiresAt],\n );\n const counting = guard === \"countdown\" && !signedOut;\n const countdown = useTokenCountdown(expiresAt, counting, warnMs, now, session.timers);\n\n // A frame this app cannot guard AND cannot read. Said out loud, because the\n // symptom on screen — a session that ends the moment it starts — names the\n // wrong cause.\n React.useEffect(() => {\n if (counting && expiresAt === undefined) {\n devWarn(\n \"cortena-app:unreadable-expiry\",\n \"cortena-ui/app: this launch is a frame the host has not declared it guards, and the \" +\n \"access token carries no readable `exp`. The composition has no clock to run, so it \" +\n \"renders the signed-out state rather than leaving the session unguarded and silent. \" +\n \"Pass `session.expiresAt` for an opaque token, or have the host declare the session \" +\n \"(cortenaGuard=host, or the first bridge message with `session.hostOrigins` set).\",\n );\n }\n }, [counting, expiresAt]);\n\n React.useEffect(() => {\n if (countdown.state === \"expired\") {\n // It cannot refresh, so it warns and hands the user back rather than\n // failing silently on the next request (decision (b)). `absolute` when\n // a real deadline was reached; `refresh_failed` when there was never a\n // deadline to reach, which is a different thing for an app that words\n // the two differently.\n end(expiresAt === undefined ? \"refresh_failed\" : \"absolute\");\n }\n }, [countdown.state, end, expiresAt]);\n\n // A launch this app owns needs a way to rotate its own credential. Without\n // one the guard reaches the proactive refresh at about 75% of the access\n // token's life, is rejected, and signs the user out — which is §10.4.1's\n // half-dead screen arriving from the inside, with nothing on screen to\n // explain it.\n React.useEffect(() => {\n if (guard === \"full\" && !session.refresh) {\n devWarn(\n \"cortena-app:missing-refresh\",\n \"cortena-ui/app: CortenaApp has no `session.refresh`, and this launch is not a frame the \" +\n \"host guards. The session guard will sign the user out at about 75% of the access \" +\n \"token's life. Pass `refresh` (POST /api/auth/refresh), or let the host declare that \" +\n \"it guards this session.\",\n );\n }\n }, [guard, session.refresh]);\n\n /* — the tab title (§10.5, P-38) — */\n const page = typeof pageTitle === \"function\" ? pageTitle(pathname) : undefined;\n const standard = matchCortenaStandardRoute(pathname, routes);\n const standardTitle =\n standard?.kind === \"profile\" ? \"Profile\" : standard?.kind === \"settings\" ? \"Settings\" : undefined;\n const silentTitle = pageTitle === false;\n React.useEffect(() => {\n // After mount, never during a render: the composition sits inside a host's\n // root layout and does not own the document.\n if (!silentTitle && typeof document !== \"undefined\") {\n document.title = documentTitle(extension.name, standardTitle ?? page);\n }\n }, [extension.name, page, silentTitle, standardTitle]);\n\n /* — signed out, or never signed in — */\n\n const loginScreen = (signedOutNow: boolean) => (\n <LoginScreen\n className={className}\n extension={{ id: extension.icon ?? extension.id, name: extension.name }}\n methods={auth?.methods}\n onPassword={auth?.onPassword}\n onProvider={auth?.onProvider}\n error={auth?.error}\n loading={auth?.loading}\n signedOut={signedOutNow}\n // Where the user was, so signing in again is a return rather than a\n // restart (§10.4). The launch's value wins: in a frame the path here is\n // the frame's, and the user's place is the host's.\n returnTo={signedOutNow ? (launch.returnTo ?? pathname) : undefined}\n />\n );\n\n if (signedOut) {\n return loginScreen(true);\n }\n if (!token) {\n return loginScreen(false);\n }\n\n /* — the frame — */\n\n const accountUrl = auth?.publicAuthUrl ? `${auth.publicAuthUrl.replace(/\\/$/, \"\")}/account` : undefined;\n\n const content =\n standard?.kind === \"profile\" ? (\n <CortenaProfilePage user={user} accountUrl={accountUrl} />\n ) : standard?.kind === \"settings\" ? (\n <CortenaSettingsPage\n extension={extension}\n user={user}\n accountUrl={accountUrl}\n permissions={permissions}\n settings={settings}\n adminRoute={standard.adminRoute}\n onAdminRoute={(next) => onNavigate(`${resolvedRoutes.permissions}/${next}`)}\n />\n ) : (\n children\n );\n\n const logout = session.signOut;\n\n return (\n <>\n {/*\n One guard, mounted at the top, outside the shell. The shell and the\n agent pop-up are both under it, so a session that ends warns once\n rather than once per surface (§10.4, P-37) — and the pop-up no longer\n mounts a second guard of its own.\n */}\n {guard === \"full\" ? (\n <SessionGuard\n token={{ accessToken: token }}\n policy={session.policy}\n channelName={session.channelName}\n now={session.now}\n timers={session.timers}\n refresh={\n session.refresh ??\n (() => Promise.reject(new Error(\"cortena-ui: CortenaApp has no session.refresh\")))\n }\n logout={logout ?? (() => Promise.resolve())}\n onSignedOut={end}\n modal={sessionDialog?.modal}\n container={sessionDialog?.container}\n />\n ) : null}\n {guard === \"countdown\" ? (\n <SessionExpiryDialog\n open={countdown.state === \"warning\"}\n remainingMs={countdown.remainingMs}\n // Nothing here can extend it: the refresh token is the host's. The\n // dialog drops Continue and offers the one answer that exists.\n canContinue={false}\n onLogout={() => end(\"user\")}\n modal={sessionDialog?.modal}\n container={sessionDialog?.container}\n />\n ) : null}\n\n <AppShell\n className={className}\n extension={{ id: extension.icon ?? extension.id, name: extension.name }}\n user={{ name: user.name, avatarUrl: user.avatarUrl }}\n nav={nav}\n headerSlot={headerSlot}\n menuItems={menuItems}\n bottomInset={bottomInset}\n themeStorageKey={themeStorageKey}\n help={help}\n helpPanel={helpPanel}\n onProfile={() => onNavigate(resolvedRoutes.profile)}\n // Never disabled: every extension has this page now, and an extension\n // that later gains settings declares a schema rather than building one\n // (decision (c)).\n onSettings={() => onNavigate(resolvedRoutes.settings)}\n // Disabled in a frame with no host sign-out message; see\n // `CortenaSessionAdapter.signOut`.\n onLogout={\n logout\n ? () => {\n // The user asked to leave. A sign-out the server refused must\n // not leave them on a shell whose session is over and must not\n // surface as an unhandled rejection: cortena-auth is the\n // authority for the credential, and the screen's only job now\n // is to stop pretending. `SessionGuard` makes the same call.\n void logout()\n .catch(() => {})\n .finally(() => end(\"user\"));\n }\n : undefined\n }\n agentSlot={\n role === \"extension\" && agent ? (\n // `null` while the chunk loads: the pill appearing a beat after\n // the shell is right, and a spinner in the corner of every screen\n // is not.\n <React.Suspense fallback={null}>\n <AgentMount\n extension={{ id: extension.id, name: extension.name }}\n agent={agent}\n getToken={session.getToken}\n />\n </React.Suspense>\n ) : undefined\n }\n >\n {content}\n </AppShell>\n </>\n );\n}\n\nexport { CortenaApp };\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cortena-app.js","names":[],"sources":["../../../src/components/app/cortena-app.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport {\n AppShell,\n type AppShellHelp,\n type AppShellMenuItem,\n type AppShellNav,\n type HelpPanelSlotProps,\n documentTitle,\n} from \"@/components/app-shell\";\nimport {\n LoginScreen,\n type LoginMethod,\n type PasswordCredentials,\n} from \"@/components/login-screen\";\nimport {\n DEFAULT_SESSION_POLICY,\n SessionExpiryDialog,\n SessionGuard,\n type SessionPolicy,\n type SessionSignOutReason,\n type SessionTimers,\n decodeSessionClaims,\n} from \"@/components/session-guard\";\nimport type { DialogContentProps } from \"@/components/dialog\";\nimport type { AdminRoute } from \"@/components/admin-permissions/types\";\nimport { devWarn } from \"@/lib/dev\";\nimport { type CortenaLaunch, readCortenaSessionMessage } from \"./launch\";\nimport type { CortenaAppAgent } from \"./agent-mount\";\nimport {\n CortenaProfilePage,\n CortenaSettingsPage,\n type CortenaAppPermissions,\n type CortenaAppUser,\n type CortenaSettingsDeclaration,\n} from \"./settings\";\n\n/**\n * CortenaApp — everything around an extension's content, as one composition\n * (DESIGN-119).\n *\n * `AppShell` (DESIGN-88) owns only what renders after sign-in. Everything\n * around it — the sign-in screen, the signed-out state, the session guard, the\n * agent pop-up, the tab title and the Profile / Settings / Permissions pages —\n * was left to each extension to assemble, and the 2026-09-14 re-audit found\n * eight extensions with eight answers: seven local login pages, three of them\n * with dummy users; a guard mounted with a different subset of its behaviour\n * in each; a pop-up mounted in one extension without the user's token or the\n * agent slug; a Settings item greyed out in every shell. That is what this\n * replaces, in one mount.\n *\n * ## What it is, deliberately\n *\n * A plain React **client** component. It is router-agnostic (`pathname` in,\n * `onNavigate` out, real `href`s, exactly as `AppShell` already does), it\n * fetches nothing, it reads nothing from the browser during a first render,\n * and it does not assume it owns the document. Those are not stylistic: the\n * third consumer of this composition is CortenaWeb, in a `host` role, inside\n * its own Next.js root layout — and without every one of them the fleet keeps\n * two shells (DESIGN-119, decision note, constraint 2).\n *\n * ```tsx\n * // main.tsx — synchronously, before createRoot (§7.3)\n * const launch = readCortenaLaunch({ hostOrigins: [config.cortenaWebOrigin] });\n *\n * // App.tsx\n * <CortenaApp\n * extension={{ id: \"tasks\", name: \"Tasks\" }}\n * user={user}\n * auth={{ authServiceUrl, publicAuthUrl, orgId, methods, onOrg, onPassword, onProvider }}\n * session={{ launch, getToken, refresh, signOut, onTokenRenewed }}\n * nav={{ groups, pathname }}\n * agent={{ slug: \"tasks\", gatewayUrl: \"/api/agent\", createClient: createAguiAgentChatClient }}\n * permissions={{ api: adminApi, canAdminister: isAdmin }}\n * settings={{ schema, load, save }}\n * pathname={pathname}\n * onNavigate={(href) => navigate(href)}\n * >\n * <Outlet />\n * </CortenaApp>\n * ```\n *\n * ## The order it does things in\n *\n * 1. the launch, from `session.launch` — a value the app read synchronously\n * in its own store initialiser, never a read this component performs;\n * 2. no token → `LoginScreen` with the methods the org advertises, and with\n * `auth.orgId` as its organisation, so it asks for one only when nothing\n * here knows it (DESIGN-126);\n * 3. the session ended → `LoginScreen` with `signedOut` and a `returnTo`;\n * 4. exactly one guard, at the top, above the shell and the pop-up alike;\n * 5. `AppShell`, with Profile / Settings / Log out in the avatar menu;\n * 6. the agent pop-up in `agentSlot`, in the `extension` role only;\n * 7. the standard pages when `pathname` names one, the app's `children`\n * otherwise.\n *\n * ## One guard, and only when there is one to run\n *\n * Decision (b) on DESIGN-119. A framed extension cannot refresh — CortenaWeb\n * holds the refresh token — so the rule is:\n *\n * the host declared it guards this session no guard here. Renewed tokens\n * arrive through\n * `session.onTokenRenewed`.\n * framed, and it did not a countdown from the token's\n * own `exp`, the standard\n * warning, and the signed-out\n * state with a `returnTo`.\n * not framed the full `SessionGuard`: idle\n * clock, proactive refresh,\n * cross-tab `BroadcastChannel`.\n *\n * Never two countdowns, and never the old behaviour of trusting a host that\n * never said anything. The declaration is read and origin-checked in\n * `readCortenaLaunch`; see `launch.ts`.\n */\n\nconst AgentMount = React.lazy(() => import(\"./agent-mount\"));\n\n/* ── the contract ────────────────────────────────────────────────────────── */\n\nexport interface CortenaAppExtension {\n /** Registered mark id, from `x-cortena.icon` in the OpenAPI document (§10.1). */\n id: string;\n /** Display name. The shell's title, the tab's title and the login heading. */\n name: string;\n /** Override the mark when it is not the extension id. Rarely needed. */\n icon?: string;\n}\n\nexport interface CortenaAppAuth {\n /** cortena-auth, in-cluster. From `config.json`; this component never calls it. */\n authServiceUrl?: string;\n /** cortena-auth's public origin — the account link on the Profile page. */\n publicAuthUrl?: string;\n /**\n * The organisation this launch is for — the `?org=` the host put on the URL,\n * or the one this browser signed in to last.\n *\n * It reaches `LoginScreen` as the default for its organisation step, which is\n * the whole point: `POST /api/auth/login` requires `orgId`, so without one the\n * screen has to ask, and with one it must not ask again (DESIGN-126). It used\n * to be declared here and read nowhere, which is how the fleet shipped a\n * sign-in that 400s on a fresh browser.\n */\n orgId?: string;\n /**\n * What the org advertises, from `GET /api/orgs/:orgId/auth-info`. The fetch\n * stays in the app: cortena-ui cannot depend on firebase, and a component\n * that fetches cannot be rendered in the guide or in a test (§8.4).\n *\n * An empty or omitted list falls back to password rather than to a screen\n * with no way in.\n */\n methods?: readonly LoginMethod[];\n /**\n * The user typed an organisation the app does not know yet. Fetch its\n * `auth-info`, remember it, and update `orgId` and `methods`; reject to keep\n * the user on the organisation step with your message.\n *\n * Omitted, a non-empty organisation is simply taken at its word and checked\n * at sign-in.\n */\n onOrg?: (orgId: string) => void | Promise<unknown>;\n onPassword?: (credentials: PasswordCredentials) => void;\n /** The organisation comes with the provider: cortena-auth requires it there too. */\n onProvider?: (provider: Exclude<LoginMethod, \"password\">, orgId: string) => void;\n /** A failed sign-in, in the user's words. */\n error?: string;\n loading?: boolean;\n}\n\nexport interface CortenaSessionAdapter {\n /**\n * The launch, as the app read it synchronously before the first render —\n * `readCortenaLaunch()`. A value rather than a callback, so this component\n * touches no browser API while rendering (§7.3).\n */\n launch: CortenaLaunch;\n /** The current access token. Read on every use, never captured. */\n getToken: () => string;\n /**\n * When the access token expires, for a token whose `exp` cannot be read — an\n * opaque token, or one the decoder failed on. Epoch **milliseconds**; a value\n * below `1e11` is read as epoch seconds instead. Exactly `SessionToken`'s\n * own escape hatch, and the way to keep an unreadable credential guarded\n * rather than landing on the signed-out screen (see the note on the guard\n * below).\n */\n expiresAt?: number;\n /**\n * The origins a host declaration and a host-pushed token are accepted from —\n * CortenaWeb's, from this extension's own configuration, never from the\n * request.\n *\n * Given, the composition listens for the host's first bridge message and\n * takes the declaration from it; `event.origin` is the browser's, not the\n * sender's, and is the only part of a `postMessage` that cannot be forged\n * from inside the page that sent it, which makes this the safer of the two\n * declaration paths (see `launch.ts` on what `ancestorOrigins` cannot\n * promise). With none given nothing is ever trusted.\n */\n hostOrigins?: readonly string[];\n /** `POST /api/auth/refresh`. Only called when this app can refresh. */\n refresh?: () => Promise<{ accessToken: string }>;\n /**\n * `POST /api/auth/logout`, or the message that asks the host to sign out.\n *\n * Omit it in a framed launch where there is no host sign-out message to\n * send: the avatar menu's Log out is then disabled, which is what every\n * shell swap does today and is honest — a menu item that silently does\n * nothing is worse than one that is visibly unavailable.\n */\n signOut?: () => Promise<void>;\n /**\n * Renewed tokens, from wherever the app gets them — the bridge, its own\n * store, a rotation it ran itself. An alternative to `hostOrigins` for an\n * app that would rather own the `message` listener; both end up in the same\n * place, and a token identical to the one in force costs a comparison.\n */\n onTokenRenewed?: (listener: (accessToken: string) => void) => () => void;\n /** Org policy from `GET /api/orgs/:orgId/session-policy` (§10.4.2). */\n policy?: Partial<SessionPolicy>;\n /** The `BroadcastChannel` name. One per origin unless two apps must not share. */\n channelName?: string;\n /** The clock and the timers. Injected by tests and by the guide. */\n now?: () => number;\n timers?: SessionTimers;\n}\n\n/** Where the standard pages live. Overridable; the defaults are the fleet's. */\nexport interface CortenaAppRoutes {\n /** @default \"/me\" */\n profile?: string;\n /** @default \"/settings\" */\n settings?: string;\n /** @default \"/settings/permissions\" */\n permissions?: string;\n}\n\nexport const CORTENA_STANDARD_ROUTES: Required<CortenaAppRoutes> = /* @__PURE__ */ Object.freeze({\n profile: \"/me\",\n settings: \"/settings\",\n permissions: \"/settings/permissions\",\n});\n\nexport interface CortenaAppProps {\n extension: CortenaAppExtension;\n user: CortenaAppUser;\n auth?: CortenaAppAuth;\n session: CortenaSessionAdapter;\n /**\n * `extension` mounts the agent pop-up and is the default. `host` is\n * CortenaWeb: the pop-up slot stays empty because the host *is* the chat,\n * and its own guard is the one that renews and pushes tokens into frames.\n */\n role?: \"extension\" | \"host\";\n nav?: AppShellNav | null;\n agent?: CortenaAppAgent;\n settings?: CortenaSettingsDeclaration;\n permissions?: CortenaAppPermissions;\n help?: AppShellHelp;\n helpPanel?: React.ReactNode | ((props: HelpPanelSlotProps) => React.ReactNode);\n headerSlot?: React.ReactNode;\n /** Extra avatar-menu items, between Settings and Log out. */\n menuItems?: AppShellMenuItem[];\n bottomInset?: string | number;\n themeStorageKey?: string;\n /** The current path. The composition compares it to the standard routes. */\n pathname: string;\n /** Take over a navigation. The composition never touches `history`. */\n onNavigate: (href: string) => void;\n /**\n * The page half of the tab title for a route: `documentTitle(name, page)`\n * gives \"<page> · <name> · Cortena\" (§10.5, P-38). Set after mount, never\n * during a render.\n *\n * `false` leaves the title alone entirely, for a surface that is not the\n * whole tab — the guide, which mounts four of these on one page, and a host\n * that already sets its own. The composition does not assume it owns the\n * document; it assumes it usually does.\n */\n documentTitle?: ((pathname: string) => string | undefined) | false;\n routes?: CortenaAppRoutes;\n /**\n * Non-modal, and portalled somewhere other than the body. For the guide,\n * where three theme frames must not fight over focus. The product leaves\n * this alone, exactly as it leaves `SessionGuard`'s own pair alone.\n */\n sessionDialog?: { modal?: boolean; container?: DialogContentProps[\"container\"] };\n /** Told when the session ends, so the app can clear its own stores. */\n onSignedOut?: (reason: SessionSignOutReason) => void;\n /**\n * On the frame's own root — the shell, or the sign-in screen when there is\n * no session. `min-h-0 h-full` is how the guide boxes it.\n */\n className?: string;\n /** The routed content. Replaced by a standard page when `pathname` names one. */\n children?: React.ReactNode;\n}\n\n/* ── the standard routes ─────────────────────────────────────────────────── */\n\nexport type CortenaStandardRoute =\n | { kind: \"profile\" }\n | { kind: \"settings\"; adminRoute?: AdminRoute };\n\nconst ADMIN_ROUTE_NAMES = new Set<string>([\"members\", \"roles\", \"licence\", \"permissions\"]);\n\n/** `/settings` and `/settings/permissions/roles` both match; `/settingsX` does not. */\nfunction under(pathname: string, base: string): string | undefined {\n if (pathname === base) {\n return \"\";\n }\n return pathname.startsWith(`${base}/`) ? pathname.slice(base.length + 1) : undefined;\n}\n\n/**\n * Which standard page a path names, or `undefined` for the app's own content.\n *\n * Exported because an app that would rather place these pages itself — a Next\n * route segment, a react-router `<Route>` — needs the same answer this\n * composition uses, rather than a second copy of the rule.\n */\nexport function matchCortenaStandardRoute(\n pathname: string,\n routes?: CortenaAppRoutes,\n): CortenaStandardRoute | undefined {\n const resolved = { ...CORTENA_STANDARD_ROUTES, ...routes };\n if (under(pathname, resolved.profile) === \"\") {\n return { kind: \"profile\" };\n }\n const permissions = under(pathname, resolved.permissions);\n if (permissions !== undefined) {\n return {\n kind: \"settings\",\n adminRoute: ADMIN_ROUTE_NAMES.has(permissions) ? (permissions as AdminRoute) : \"members\",\n };\n }\n return under(pathname, resolved.settings) !== undefined ? { kind: \"settings\" } : undefined;\n}\n\n/* ── the unguarded frame's countdown ─────────────────────────────────────── */\n\n/** The countdown redraws once a second, which is the resolution it shows. */\nconst COUNTDOWN_TICK_MS = 1000;\n\ntype CountdownState = { state: \"active\" | \"warning\" | \"expired\"; remainingMs: number };\n\n/**\n * A countdown from the access token's own `exp`, for a framed launch the host\n * did not declare it guards (decision (b)).\n *\n * It is not `SessionGuard` and must not be: there is no idle clock to run\n * (the host's is the session's), nothing to refresh (the refresh token is the\n * host's), and no cross-tab election to hold (this frame is one surface of the\n * host's tab). What is left is the honest part — warn before the credential\n * dies, then hand the user back — and running the whole guard here would give\n * the user two countdowns for one session.\n */\nfunction useTokenCountdown(\n expiresAt: number | undefined,\n enabled: boolean,\n warnMs: number,\n now: () => number,\n timers: SessionTimers | undefined,\n): CountdownState {\n const [tick, setTick] = React.useState(0);\n const active = enabled && expiresAt !== undefined;\n\n const clock = React.useRef({ now, timers });\n clock.current = { now, timers };\n\n React.useEffect(() => {\n if (!active) {\n return;\n }\n let handle: unknown;\n let live = true;\n const set = clock.current.timers?.setTimeout ?? ((fn: () => void, ms: number) => globalThis.setTimeout(fn, ms));\n const clear = clock.current.timers?.clearTimeout ?? ((h: unknown) => globalThis.clearTimeout(h as number));\n const loop = () => {\n if (!live) {\n return;\n }\n setTick((n) => n + 1);\n handle = set(loop, COUNTDOWN_TICK_MS);\n };\n handle = set(loop, COUNTDOWN_TICK_MS);\n return () => {\n live = false;\n if (handle !== undefined) {\n clear(handle);\n }\n };\n // `tick` is deliberately not a dependency: the loop reschedules itself.\n }, [active, expiresAt]);\n\n if (!enabled) {\n return { state: \"active\", remainingMs: 0 };\n }\n if (expiresAt === undefined) {\n /**\n * No expiry, and no way to find one: this frame cannot guard the session.\n *\n * It is reported as already over rather than as \"fine\", and that is the\n * whole fix. A launch with no readable `exp` used to take this branch and\n * then count nothing — no warning, no expiry, no signed-out state — which\n * is precisely the silent unguarded frame decision (b) removed, arriving\n * through the back door.\n *\n * Over rather than warning, because a warning's entire content is a number\n * there is none of: \"Your session ends in 0:00\", permanently, is a lie,\n * and its only way out is Log out, which lands on this screen anyway. The\n * signed-out screen says the true thing, carries the `returnTo`, and\n * offers the better recovery — sign in again and come back to where you\n * were. An app whose token is legitimately opaque passes\n * `session.expiresAt` and is guarded normally.\n */\n return { state: \"expired\", remainingMs: 0 };\n }\n void tick;\n const remainingMs = Math.max(0, expiresAt - now());\n return {\n state: remainingMs <= 0 ? \"expired\" : remainingMs <= warnMs ? \"warning\" : \"active\",\n remainingMs,\n };\n}\n\n/** Below this an `expiresAt` is epoch seconds; `SessionToken` states the same rule. */\nconst SECONDS_CEILING = 1e11;\n\n/**\n * When the session ends: the token's own `exp`, or the expiry the app supplied\n * for a token whose claims cannot be read.\n */\nfunction sessionExpiry(accessToken: string, expiresAt: number | undefined): number | undefined {\n const claimed = decodeSessionClaims(accessToken).expiresAt;\n if (claimed !== undefined) {\n return claimed;\n }\n if (typeof expiresAt !== \"number\" || !Number.isFinite(expiresAt) || expiresAt <= 0) {\n return undefined;\n }\n return expiresAt < SECONDS_CEILING ? expiresAt * 1000 : expiresAt;\n}\n\n/* ── the composition ─────────────────────────────────────────────────────── */\n\nfunction CortenaApp({\n extension,\n user,\n auth,\n session,\n role = \"extension\",\n nav,\n agent,\n settings,\n permissions,\n help,\n helpPanel,\n headerSlot,\n menuItems,\n bottomInset,\n themeStorageKey,\n pathname,\n onNavigate,\n documentTitle: pageTitle,\n routes,\n sessionDialog,\n onSignedOut,\n className,\n children,\n}: CortenaAppProps) {\n const { launch } = session;\n const resolvedRoutes = { ...CORTENA_STANDARD_ROUTES, ...routes };\n\n /**\n * The token, seeded from the launch. The launch was read synchronously by\n * the app before the first render (§7.3), so this is a plain initial value\n * and not a read performed here.\n */\n const [token, setToken] = React.useState(() => launch.accessToken ?? \"\");\n const [signedOut, setSignedOut] = React.useState<SessionSignOutReason | undefined>(undefined);\n /** The host declared on the bridge rather than on the launch URL. */\n const [bridgeGuarded, setBridgeGuarded] = React.useState(false);\n\n /**\n * The token already in force. A ref rather than `token` itself, because\n * `adopt` has to tell \"the same token again\" from \"a new one\" and only the\n * second clears the signed-out state.\n */\n const adopted = React.useRef(launch.accessToken ?? \"\");\n\n /**\n * Take on a token from any source — the launch, the app's own store, a\n * rotation the app ran itself, or one the host pushed down the bridge — and\n * clear the signed-out state when it is genuinely a new one.\n *\n * Clearing it HERE, rather than only where a host pushes a token, is the\n * point. That was the only place it was cleared, and it exists only in a\n * host-guarded frame; on a standalone launch or an undeclared frame the\n * signed-out screen short-circuits the whole render, so a successful sign-in\n * set the token and the user stayed on \"You were signed out\" for ever, with\n * nothing on screen to explain it.\n */\n const adopt = React.useCallback((next: string | undefined) => {\n if (!next || next === adopted.current) {\n return;\n }\n adopted.current = next;\n setToken(next);\n setSignedOut(undefined);\n }, []);\n\n // The launch the app handed in.\n React.useEffect(() => {\n adopt(launch.accessToken);\n }, [adopt, launch.accessToken]);\n\n // And the app's own store, read after every render rather than off a\n // dependency: a sign-in happens inside the adapter, and a function prop's\n // identity says nothing about what it now returns. It is a ref comparison\n // per render — the same shape AppShell uses to notice a bottom bar that\n // arrives with the data.\n React.useEffect(() => {\n adopt(session.getToken());\n });\n\n /**\n * The host's declaration and its token renewals, on the bridge.\n *\n * Decision (b) allows the declaration to arrive as the first bridge message\n * rather than as a launch parameter, and this is the path to prefer:\n * `event.origin` is the browser's, not the sender's, and is the only part of\n * a `postMessage` that cannot be forged from inside the page that sent it.\n * `launch.ts` records what the launch-parameter path cannot promise on a\n * browser with no `ancestorOrigins`.\n *\n * Subscribed only where the app named the origins it will accept, and only\n * in a frame. With none named nothing is ever trusted — the same rule\n * `readCortenaLaunch` follows, and the same safe direction: the frame runs\n * its own countdown rather than trusting a parent it cannot name.\n */\n const hostOrigins = session.hostOrigins;\n React.useEffect(() => {\n if (!launch.embedded || !hostOrigins?.length || typeof window === \"undefined\") {\n return;\n }\n const onMessage = (event: MessageEvent) => {\n const message = readCortenaSessionMessage(event, hostOrigins);\n if (!message) {\n return;\n }\n if (message.guarded) {\n setBridgeGuarded(true);\n }\n adopt(message.accessToken);\n };\n window.addEventListener(\"message\", onMessage);\n return () => window.removeEventListener(\"message\", onMessage);\n }, [adopt, hostOrigins, launch.embedded]);\n\n // Whichever path said it. Going undeclared -> declared only ever takes a\n // countdown away, so a declaration that arrives a beat after the launch is\n // safe: there is still never more than one.\n const hostGuarded = Boolean(launch.embedded && (launch.hostGuarded || bridgeGuarded));\n\n // The adapter's own renewal channel, for an app that would rather own the\n // listener than hand over `hostOrigins`. Not gated on `hostGuarded`: a token\n // is a token, and an app that has a newer one should be able to say so.\n const subscribe = session.onTokenRenewed;\n React.useEffect(() => {\n if (!subscribe) {\n return;\n }\n return subscribe(adopt);\n }, [adopt, subscribe]);\n\n const end = React.useCallback(\n (reason: SessionSignOutReason) => {\n setSignedOut(reason);\n onSignedOut?.(reason);\n },\n [onSignedOut],\n );\n\n /**\n * Which of the three session behaviours this launch gets. One of them, ever.\n */\n const guard: \"none\" | \"countdown\" | \"full\" = hostGuarded\n ? \"none\"\n : launch.embedded && !launch.canRefresh\n ? \"countdown\"\n : \"full\";\n\n const warnMs = session.policy?.warnMs ?? DEFAULT_SESSION_POLICY.warnMs;\n const now = session.now ?? Date.now;\n const expiresAt = React.useMemo(\n () => sessionExpiry(token, session.expiresAt),\n [token, session.expiresAt],\n );\n const counting = guard === \"countdown\" && !signedOut;\n const countdown = useTokenCountdown(expiresAt, counting, warnMs, now, session.timers);\n\n // A frame this app cannot guard AND cannot read. Said out loud, because the\n // symptom on screen — a session that ends the moment it starts — names the\n // wrong cause.\n React.useEffect(() => {\n if (counting && expiresAt === undefined) {\n devWarn(\n \"cortena-app:unreadable-expiry\",\n \"cortena-ui/app: this launch is a frame the host has not declared it guards, and the \" +\n \"access token carries no readable `exp`. The composition has no clock to run, so it \" +\n \"renders the signed-out state rather than leaving the session unguarded and silent. \" +\n \"Pass `session.expiresAt` for an opaque token, or have the host declare the session \" +\n \"(cortenaGuard=host, or the first bridge message with `session.hostOrigins` set).\",\n );\n }\n }, [counting, expiresAt]);\n\n React.useEffect(() => {\n if (countdown.state === \"expired\") {\n // It cannot refresh, so it warns and hands the user back rather than\n // failing silently on the next request (decision (b)). `absolute` when\n // a real deadline was reached; `refresh_failed` when there was never a\n // deadline to reach, which is a different thing for an app that words\n // the two differently.\n end(expiresAt === undefined ? \"refresh_failed\" : \"absolute\");\n }\n }, [countdown.state, end, expiresAt]);\n\n // A launch this app owns needs a way to rotate its own credential. Without\n // one the guard reaches the proactive refresh at about 75% of the access\n // token's life, is rejected, and signs the user out — which is §10.4.1's\n // half-dead screen arriving from the inside, with nothing on screen to\n // explain it.\n React.useEffect(() => {\n if (guard === \"full\" && !session.refresh) {\n devWarn(\n \"cortena-app:missing-refresh\",\n \"cortena-ui/app: CortenaApp has no `session.refresh`, and this launch is not a frame the \" +\n \"host guards. The session guard will sign the user out at about 75% of the access \" +\n \"token's life. Pass `refresh` (POST /api/auth/refresh), or let the host declare that \" +\n \"it guards this session.\",\n );\n }\n }, [guard, session.refresh]);\n\n /* — the tab title (§10.5, P-38) — */\n const page = typeof pageTitle === \"function\" ? pageTitle(pathname) : undefined;\n const standard = matchCortenaStandardRoute(pathname, routes);\n const standardTitle =\n standard?.kind === \"profile\" ? \"Profile\" : standard?.kind === \"settings\" ? \"Settings\" : undefined;\n const silentTitle = pageTitle === false;\n React.useEffect(() => {\n // After mount, never during a render: the composition sits inside a host's\n // root layout and does not own the document.\n if (!silentTitle && typeof document !== \"undefined\") {\n document.title = documentTitle(extension.name, standardTitle ?? page);\n }\n }, [extension.name, page, silentTitle, standardTitle]);\n\n /* — signed out, or never signed in — */\n\n const loginScreen = (signedOutNow: boolean) => (\n <LoginScreen\n className={className}\n extension={{ id: extension.icon ?? extension.id, name: extension.name }}\n methods={auth?.methods}\n // The default for the organisation step. With one the step is skipped\n // entirely, which is the returning browser and the `?token=` launch;\n // without one the screen asks, rather than posting a login cortena-auth\n // will reject for a missing `orgId` (DESIGN-126).\n orgId={auth?.orgId}\n onOrg={auth?.onOrg}\n onPassword={auth?.onPassword}\n onProvider={auth?.onProvider}\n error={auth?.error}\n loading={auth?.loading}\n signedOut={signedOutNow}\n // Where the user was, so signing in again is a return rather than a\n // restart (§10.4). The launch's value wins: in a frame the path here is\n // the frame's, and the user's place is the host's.\n returnTo={signedOutNow ? (launch.returnTo ?? pathname) : undefined}\n />\n );\n\n if (signedOut) {\n return loginScreen(true);\n }\n if (!token) {\n return loginScreen(false);\n }\n\n /* — the frame — */\n\n const accountUrl = auth?.publicAuthUrl ? `${auth.publicAuthUrl.replace(/\\/$/, \"\")}/account` : undefined;\n\n const content =\n standard?.kind === \"profile\" ? (\n <CortenaProfilePage user={user} accountUrl={accountUrl} />\n ) : standard?.kind === \"settings\" ? (\n <CortenaSettingsPage\n extension={extension}\n user={user}\n accountUrl={accountUrl}\n permissions={permissions}\n settings={settings}\n adminRoute={standard.adminRoute}\n onAdminRoute={(next) => onNavigate(`${resolvedRoutes.permissions}/${next}`)}\n />\n ) : (\n children\n );\n\n const logout = session.signOut;\n\n return (\n <>\n {/*\n One guard, mounted at the top, outside the shell. The shell and the\n agent pop-up are both under it, so a session that ends warns once\n rather than once per surface (§10.4, P-37) — and the pop-up no longer\n mounts a second guard of its own.\n */}\n {guard === \"full\" ? (\n <SessionGuard\n token={{ accessToken: token }}\n policy={session.policy}\n channelName={session.channelName}\n now={session.now}\n timers={session.timers}\n refresh={\n session.refresh ??\n (() => Promise.reject(new Error(\"cortena-ui: CortenaApp has no session.refresh\")))\n }\n logout={logout ?? (() => Promise.resolve())}\n onSignedOut={end}\n modal={sessionDialog?.modal}\n container={sessionDialog?.container}\n />\n ) : null}\n {guard === \"countdown\" ? (\n <SessionExpiryDialog\n open={countdown.state === \"warning\"}\n remainingMs={countdown.remainingMs}\n // Nothing here can extend it: the refresh token is the host's. The\n // dialog drops Continue and offers the one answer that exists.\n canContinue={false}\n onLogout={() => end(\"user\")}\n modal={sessionDialog?.modal}\n container={sessionDialog?.container}\n />\n ) : null}\n\n <AppShell\n className={className}\n extension={{ id: extension.icon ?? extension.id, name: extension.name }}\n user={{ name: user.name, avatarUrl: user.avatarUrl }}\n nav={nav}\n headerSlot={headerSlot}\n menuItems={menuItems}\n bottomInset={bottomInset}\n themeStorageKey={themeStorageKey}\n help={help}\n helpPanel={helpPanel}\n onProfile={() => onNavigate(resolvedRoutes.profile)}\n // Never disabled: every extension has this page now, and an extension\n // that later gains settings declares a schema rather than building one\n // (decision (c)).\n onSettings={() => onNavigate(resolvedRoutes.settings)}\n // Disabled in a frame with no host sign-out message; see\n // `CortenaSessionAdapter.signOut`.\n onLogout={\n logout\n ? () => {\n // The user asked to leave. A sign-out the server refused must\n // not leave them on a shell whose session is over and must not\n // surface as an unhandled rejection: cortena-auth is the\n // authority for the credential, and the screen's only job now\n // is to stop pretending. `SessionGuard` makes the same call.\n void logout()\n .catch(() => {})\n .finally(() => end(\"user\"));\n }\n : undefined\n }\n agentSlot={\n role === \"extension\" && agent ? (\n // `null` while the chunk loads: the pill appearing a beat after\n // the shell is right, and a spinner in the corner of every screen\n // is not.\n <React.Suspense fallback={null}>\n <AgentMount\n extension={{ id: extension.id, name: extension.name }}\n agent={agent}\n getToken={session.getToken}\n />\n </React.Suspense>\n ) : undefined\n }\n >\n {content}\n </AppShell>\n </>\n );\n}\n\nexport { CortenaApp };\n"],"mappings":"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"}
|
|
@@ -3,7 +3,7 @@ import { VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
//#region src/components/badge.d.ts
|
|
5
5
|
declare const badgeVariants: (props?: ({
|
|
6
|
-
variant?: "default" | "success" | "warn" | "destructive" | "danger" | "info" | "
|
|
6
|
+
variant?: "default" | "success" | "warn" | "destructive" | "danger" | "info" | "warning" | "neutral" | "secondary" | "outline" | null | undefined;
|
|
7
7
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
8
8
|
export interface BadgeProps extends React.ComponentProps<"span">, VariantProps<typeof badgeVariants> {}
|
|
9
9
|
declare function Badge({ className, variant, ...props }: BadgeProps): React.JSX.Element;
|
|
@@ -14,7 +14,7 @@ import { Button } from "@base-ui/react/button";
|
|
|
14
14
|
* rendered element, which is Base UI's equivalent.
|
|
15
15
|
*/
|
|
16
16
|
declare const buttonVariants: (props?: ({
|
|
17
|
-
variant?: "link" | "destructive" | "
|
|
17
|
+
variant?: "link" | "destructive" | "secondary" | "outline" | "primary" | "ghost" | "soft" | null | undefined;
|
|
18
18
|
size?: "sm" | "md" | "lg" | "icon" | "icon-sm" | null | undefined;
|
|
19
19
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
20
20
|
export interface ButtonProps extends React.ComponentProps<typeof Button>, VariantProps<typeof buttonVariants> {}
|
|
@@ -7,7 +7,7 @@ import * as React from "react";
|
|
|
7
7
|
* sentence case and the body font.
|
|
8
8
|
*/
|
|
9
9
|
declare const chipVariants: (props?: ({
|
|
10
|
-
variant?: "default" | "
|
|
10
|
+
variant?: "default" | "outline" | "accent" | null | undefined;
|
|
11
11
|
size?: "default" | "sm" | null | undefined;
|
|
12
12
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
13
13
|
export interface ChipProps extends React.ComponentProps<"span">, VariantProps<typeof chipVariants> {}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use client";
|
|
3
|
+
import { isDevBuild } from "../../lib/dev.js";
|
|
3
4
|
import { createColumnHelper as createColumnHelper$1, dataTableFeatures } from "./features.js";
|
|
4
5
|
import { expandColumn, selectColumn } from "./system-columns.js";
|
|
5
|
-
import { isDevBuild } from "../../lib/dev.js";
|
|
6
6
|
import { useServerSource } from "./use-server-source.js";
|
|
7
7
|
import * as React from "react";
|
|
8
8
|
import { useTable } from "@tanstack/react-table";
|