cortena-ui 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. package/src/index.ts +6 -0
@@ -0,0 +1,632 @@
1
+ "use client";
2
+ "use client";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Button } from "./button.js";
5
+ import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog.js";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import * as React from "react";
8
+ //#region src/components/session-guard.tsx
9
+ /** The org policy's defaults, used when neither `policy` nor a claim says otherwise. */
10
+ const DEFAULT_SESSION_POLICY = /* @__PURE__ */ Object.freeze({
11
+ idleMs: 288e5,
12
+ absoluteMs: 432e5,
13
+ warnMs: 12e4
14
+ });
15
+ const DEFAULT_TIMERS = /* @__PURE__ */ Object.freeze({
16
+ setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms),
17
+ clearTimeout: (handle) => {
18
+ globalThis.clearTimeout(handle);
19
+ }
20
+ });
21
+ /** The default `BroadcastChannel` name. One channel per origin is the point. */
22
+ const SESSION_CHANNEL_NAME = "cortena-session";
23
+ /** Epoch milliseconds, whichever unit the claim was written in. */
24
+ const SECONDS_CEILING = 1e11;
25
+ function epochMs(value) {
26
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return;
27
+ return value < SECONDS_CEILING ? value * 1e3 : value;
28
+ }
29
+ /**
30
+ * The four session claims out of a JWT payload, **decoded and not verified**.
31
+ *
32
+ * Returns `{}` for anything that is not a readable JWT — an opaque token, a
33
+ * truncated one, a payload that is not JSON. It never throws: a token the guard
34
+ * cannot read falls back to the policy defaults, because a parse error must not
35
+ * be what ends a user's session.
36
+ */
37
+ function decodeSessionClaims(accessToken) {
38
+ const payload = readJwtPayload(accessToken);
39
+ if (!payload) return {};
40
+ return {
41
+ issuedAt: epochMs(payload.iat),
42
+ expiresAt: epochMs(payload.exp),
43
+ idleExpiresAt: epochMs(payload.idle_exp),
44
+ sessionExpiresAt: epochMs(payload.session_exp)
45
+ };
46
+ }
47
+ function readJwtPayload(accessToken) {
48
+ if (typeof accessToken !== "string") return;
49
+ const segment = accessToken.split(".")[1];
50
+ if (!segment) return;
51
+ try {
52
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
53
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
54
+ const binary = globalThis.atob(padded);
55
+ const json = decodeURIComponent(Array.from(binary, (char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
56
+ const parsed = JSON.parse(json);
57
+ return parsed && typeof parsed === "object" ? parsed : void 0;
58
+ } catch {
59
+ return;
60
+ }
61
+ }
62
+ /** Refresh at three quarters of the access token's life, never on a 401. */
63
+ const REFRESH_AT_FRACTION = .75;
64
+ /** At most one activity broadcast per this many ms; the local clock is exact. */
65
+ const ACTIVITY_BROADCAST_MS = 5e3;
66
+ /** The countdown redraws once a second, which is the resolution it shows. */
67
+ const COUNTDOWN_TICK_MS = 1e3;
68
+ /**
69
+ * At most one idle-clock reset per second from the pointer.
70
+ *
71
+ * `pointermove` fires on every frame a mouse is in motion — hundreds a second
72
+ * on a 120 Hz display — and each one re-entered the whole tick, recomputed the
73
+ * deadlines and tore down and rebuilt the timer. The idle clock has a
74
+ * resolution of hours; a second is finer than it can express.
75
+ */
76
+ const ACTIVITY_THROTTLE_MS = 1e3;
77
+ /**
78
+ * The largest delay `setTimeout` can express. Anything above it wraps to a
79
+ * 32-bit signed integer and fires IMMEDIATELY, which turns a long deadline
80
+ * into a busy loop rather than a long wait.
81
+ */
82
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
83
+ /**
84
+ * The longest idle window a claim may ask for. Beyond this the claim is not a
85
+ * policy, it is a mistake — seconds parsed as milliseconds, a fixture with a
86
+ * year 3000 expiry, a clock that has jumped — and honouring it means a session
87
+ * that never idles out at all. Absent is safer than absurd: the default
88
+ * applies, and cortena-auth remains the authority either way.
89
+ */
90
+ const MAX_PLAUSIBLE_IDLE_MS = 2592e6;
91
+ /** Whether `idle_exp` describes a window this guard will act on. */
92
+ function plausibleIdleMs(ms) {
93
+ return ms !== void 0 && ms > 0 && ms <= MAX_PLAUSIBLE_IDLE_MS ? ms : void 0;
94
+ }
95
+ /** The Web Lock the proactive refresh is taken under. One rotation per origin. */
96
+ const REFRESH_LOCK_NAME = "cortena-session-refresh";
97
+ /**
98
+ * How long a broadcast claim holds, for a browser with no `navigator.locks`.
99
+ * Comfortably longer than a refresh round-trip and far shorter than the gap
100
+ * between two proactive refreshes.
101
+ */
102
+ const REFRESH_CLAIM_MS = 3e4;
103
+ /**
104
+ * How long the broadcast election runs before the winner rotates.
105
+ *
106
+ * One tick is all it takes: `BroadcastChannel` delivers within the same task
107
+ * queue turn, so every tab that reached 75% of the same token's life at the
108
+ * same instant has posted its ballot by the time this elapses. Long enough to
109
+ * hear them, short enough that nobody notices the token rotated 50ms late.
110
+ */
111
+ const REFRESH_ELECTION_MS = 50;
112
+ /**
113
+ * How long a tab that lost the election waits for the winner's `refreshed`.
114
+ *
115
+ * A leader can vanish between claiming and rotating — the user closed the tab,
116
+ * the renderer was killed — and every other tab would then sit waiting for a
117
+ * message that is never coming, until its token expired. This is the takeover:
118
+ * comfortably longer than a refresh round-trip, far shorter than the life of
119
+ * the credential it protects.
120
+ */
121
+ const REFRESH_TAKEOVER_MS = 1e4;
122
+ /**
123
+ * A ballot for one election. `Math.random` on purpose: this breaks a tie
124
+ * between tabs of one user in one browser, and nothing is defended by its
125
+ * unpredictability. Fixed width so the comparison is over the whole ballot.
126
+ */
127
+ function refreshNonce() {
128
+ return Math.random().toString(36).slice(2, 12).padEnd(10, "0");
129
+ }
130
+ function useSessionGuard(options) {
131
+ const { token, policy, channelName = SESSION_CHANNEL_NAME } = options;
132
+ const warnMs = policy?.warnMs ?? DEFAULT_SESSION_POLICY.warnMs;
133
+ const policyIdleMs = policy?.idleMs;
134
+ const policyAbsoluteMs = policy?.absoluteMs;
135
+ const latest = React.useRef(options);
136
+ latest.current = options;
137
+ const now = React.useCallback(() => (latest.current.now ?? Date.now)(), []);
138
+ const timers = React.useCallback(() => latest.current.timers ?? DEFAULT_TIMERS, []);
139
+ const [state, setState] = React.useState("active");
140
+ const [remainingMs, setRemainingMs] = React.useState(0);
141
+ const [reason, setReason] = React.useState(void 0);
142
+ const engineRef = React.useRef(null);
143
+ if (engineRef.current === null) {
144
+ const startedAt = now();
145
+ const claims = decodeSessionClaims(token.accessToken);
146
+ const claimedIdleMs = claims.idleExpiresAt !== void 0 && claims.issuedAt !== void 0 ? plausibleIdleMs(claims.idleExpiresAt - claims.issuedAt) : void 0;
147
+ const idleMs = policyIdleMs ?? claimedIdleMs ?? DEFAULT_SESSION_POLICY.idleMs;
148
+ const sessionStart = claims.issuedAt ?? startedAt;
149
+ engineRef.current = {
150
+ idleMs,
151
+ sessionStart,
152
+ lastActivity: policyIdleMs === void 0 && claimedIdleMs !== void 0 && claims.idleExpiresAt !== void 0 ? claims.idleExpiresAt - idleMs : startedAt,
153
+ absoluteDeadline: policyAbsoluteMs !== void 0 ? sessionStart + policyAbsoluteMs : claims.sessionExpiresAt ?? sessionStart + DEFAULT_SESSION_POLICY.absoluteMs,
154
+ refreshing: false,
155
+ ended: false,
156
+ timer: void 0,
157
+ lastBroadcast: 0,
158
+ lastPointer: 0,
159
+ lastRefreshClaim: 0
160
+ };
161
+ }
162
+ const engine = engineRef.current;
163
+ const tickRef = React.useRef(() => {});
164
+ const clearTimer = React.useCallback(() => {
165
+ if (engine.timer !== void 0) {
166
+ timers().clearTimeout(engine.timer);
167
+ engine.timer = void 0;
168
+ }
169
+ }, [engine, timers]);
170
+ const schedule = React.useCallback((ms) => {
171
+ clearTimer();
172
+ engine.timer = timers().setTimeout(() => {
173
+ engine.timer = void 0;
174
+ tickRef.current();
175
+ }, Math.min(Math.max(0, ms), MAX_TIMEOUT_MS));
176
+ }, [
177
+ clearTimer,
178
+ engine,
179
+ timers
180
+ ]);
181
+ const post = React.useCallback((message) => {
182
+ try {
183
+ engine.channel?.postMessage(message);
184
+ } catch {}
185
+ }, [engine]);
186
+ const end = React.useCallback((why, opts) => {
187
+ if (engine.ended) return;
188
+ engine.ended = true;
189
+ clearTimer();
190
+ setState("signedOut");
191
+ setReason(why);
192
+ setRemainingMs(0);
193
+ if (opts?.broadcast !== false) post({
194
+ type: "signed-out",
195
+ reason: why
196
+ });
197
+ const done = () => latest.current.onSignedOut(why);
198
+ if (opts?.logout === false || why === "refresh_failed") {
199
+ done();
200
+ return;
201
+ }
202
+ Promise.resolve().then(() => latest.current.logout()).catch(() => {}).finally(done);
203
+ }, [
204
+ clearTimer,
205
+ engine,
206
+ post
207
+ ]);
208
+ const adoptToken = React.useCallback((accessToken, expiresAtProp, at) => {
209
+ engine.seenToken = accessToken;
210
+ const claims = decodeSessionClaims(accessToken);
211
+ const expiresAt = claims.expiresAt ?? epochMs(expiresAtProp);
212
+ const issuedAt = claims.issuedAt ?? at;
213
+ engine.expiresAt = expiresAt;
214
+ engine.refreshAt = expiresAt !== void 0 && expiresAt > issuedAt ? issuedAt + (expiresAt - issuedAt) * REFRESH_AT_FRACTION : void 0;
215
+ if (policyAbsoluteMs === void 0 && claims.sessionExpiresAt !== void 0) engine.absoluteDeadline = claims.sessionExpiresAt;
216
+ }, [engine, policyAbsoluteMs]);
217
+ const doRefresh = React.useCallback(() => {
218
+ if (engine.refreshing || engine.ended) return Promise.resolve();
219
+ engine.refreshing = true;
220
+ engine.lastRefreshClaim = now();
221
+ return Promise.resolve().then(() => latest.current.refresh()).then((next) => {
222
+ engine.refreshing = false;
223
+ if (engine.ended) return;
224
+ adoptToken(next.accessToken, void 0, now());
225
+ post({
226
+ type: "refreshed",
227
+ ...engine.expiresAt === void 0 ? {} : { expiresAt: engine.expiresAt }
228
+ });
229
+ tickRef.current();
230
+ }).catch(() => {
231
+ engine.refreshing = false;
232
+ end("refresh_failed");
233
+ });
234
+ }, [
235
+ adoptToken,
236
+ end,
237
+ engine,
238
+ now,
239
+ post
240
+ ]);
241
+ /** Abandon any election in flight, and its timer. */
242
+ const clearElection = React.useCallback(() => {
243
+ if (engine.election?.timer !== void 0) timers().clearTimeout(engine.election.timer);
244
+ engine.election = void 0;
245
+ }, [engine, timers]);
246
+ /**
247
+ * The ballots are in.
248
+ *
249
+ * The highest one rotates. Everybody else waits for its `refreshed` — and
250
+ * takes the rotation over if the winner never sends one, because a leader
251
+ * that is closed between claiming and refreshing would otherwise leave every
252
+ * remaining tab waiting for a message nobody is going to send.
253
+ */
254
+ const settleElection = React.useCallback(() => {
255
+ const election = engine.election;
256
+ if (!election || engine.ended) return;
257
+ if (election.best === election.nonce) {
258
+ engine.election = void 0;
259
+ if (engine.refreshing) return;
260
+ if (engine.refreshAt !== void 0 && now() < engine.refreshAt) return;
261
+ doRefresh();
262
+ return;
263
+ }
264
+ election.timer = timers().setTimeout(() => {
265
+ engine.election = void 0;
266
+ if (engine.ended || engine.refreshing) return;
267
+ if (engine.refreshAt !== void 0 && now() < engine.refreshAt) return;
268
+ doRefresh();
269
+ }, REFRESH_TAKEOVER_MS);
270
+ }, [
271
+ doRefresh,
272
+ engine,
273
+ now,
274
+ timers
275
+ ]);
276
+ /**
277
+ * The proactive refresh, once per origin rather than once per tab.
278
+ *
279
+ * Every open tab reaches 75% of the same token's life at the same instant,
280
+ * so without a claim a user with four tabs fires four rotations at once.
281
+ * The refresh token rotates on use, so three of them present a token that
282
+ * has just been superseded — and a failed refresh is a signed-out state, by
283
+ * design. Four tabs, one of them signed in.
284
+ *
285
+ * `navigator.locks` is the right instrument and is available everywhere
286
+ * Cortena runs. `ifAvailable` means a tab that does not get the lock does
287
+ * NOT queue behind it: the rotation is already happening, and a second one
288
+ * afterwards is exactly what this prevents.
289
+ *
290
+ * Where locks are absent, an election over the channel the guard already
291
+ * owns. Claiming and refreshing in the same tick was no coordination at all:
292
+ * every tab reaches the deadline on the same clock, so every tab posted its
293
+ * claim and rotated before any of them could hear another's — the exact
294
+ * simultaneous rotation the lock exists to prevent, in the browsers with no
295
+ * lock to take. So a tab posts a ballot, waits one tick to hear the others,
296
+ * and only the highest ballot rotates. The losers wait for its `refreshed`,
297
+ * and take over if it never comes.
298
+ */
299
+ const refreshOnce = React.useCallback(() => {
300
+ if (engine.refreshing || engine.ended) return;
301
+ const locks = globalThis.navigator?.locks;
302
+ if (locks && typeof locks.request === "function") {
303
+ locks.request(REFRESH_LOCK_NAME, { ifAvailable: true }, async (lock) => {
304
+ if (!lock) return;
305
+ if (engine.ended || engine.refreshing) return;
306
+ if (engine.refreshAt !== void 0 && now() < engine.refreshAt) return;
307
+ await doRefresh();
308
+ }).catch(() => {
309
+ doRefresh();
310
+ });
311
+ return;
312
+ }
313
+ const at = now();
314
+ if (engine.election || at - engine.lastRefreshClaim < REFRESH_CLAIM_MS) return;
315
+ engine.lastRefreshClaim = at;
316
+ const nonce = refreshNonce();
317
+ engine.election = {
318
+ nonce,
319
+ best: nonce
320
+ };
321
+ post({
322
+ type: "refresh-claim",
323
+ at,
324
+ nonce
325
+ });
326
+ engine.election.timer = timers().setTimeout(settleElection, REFRESH_ELECTION_MS);
327
+ }, [
328
+ doRefresh,
329
+ engine,
330
+ now,
331
+ post,
332
+ settleElection,
333
+ timers
334
+ ]);
335
+ tickRef.current = React.useCallback(() => {
336
+ if (engine.ended) return;
337
+ const at = now();
338
+ const idleDeadline = engine.lastActivity + engine.idleMs;
339
+ const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
340
+ if (at >= deadline) {
341
+ end(engine.absoluteDeadline <= idleDeadline ? "absolute" : "idle");
342
+ return;
343
+ }
344
+ const warnAt = deadline - warnMs;
345
+ if (at >= warnAt) {
346
+ setState("warning");
347
+ setRemainingMs(deadline - at);
348
+ schedule(Math.min(COUNTDOWN_TICK_MS, deadline - at));
349
+ return;
350
+ }
351
+ setState("active");
352
+ setRemainingMs(deadline - at);
353
+ const { refreshAt } = engine;
354
+ if (refreshAt !== void 0 && at >= refreshAt && !engine.refreshing) refreshOnce();
355
+ const wakeAt = refreshAt !== void 0 && refreshAt > at && !engine.refreshing ? Math.min(warnAt, refreshAt) : warnAt;
356
+ schedule(wakeAt - at);
357
+ }, [
358
+ end,
359
+ engine,
360
+ now,
361
+ refreshOnce,
362
+ schedule,
363
+ warnMs
364
+ ]);
365
+ const stateRef = React.useRef(state);
366
+ stateRef.current = state;
367
+ const recordActivity = React.useCallback((at, opts) => {
368
+ if (engine.ended) return;
369
+ if (!opts?.force && stateRef.current !== "active") return;
370
+ if (at > engine.lastActivity) engine.lastActivity = at;
371
+ if (opts?.broadcast !== false && at - engine.lastBroadcast >= ACTIVITY_BROADCAST_MS) {
372
+ engine.lastBroadcast = at;
373
+ post({
374
+ type: "activity",
375
+ at
376
+ });
377
+ }
378
+ tickRef.current();
379
+ }, [engine, post]);
380
+ const continueSession = React.useCallback(() => {
381
+ if (engine.ended) return;
382
+ const at = now();
383
+ engine.lastBroadcast = at;
384
+ post({
385
+ type: "continue",
386
+ at
387
+ });
388
+ recordActivity(at, {
389
+ force: true,
390
+ broadcast: false
391
+ });
392
+ doRefresh();
393
+ }, [
394
+ doRefresh,
395
+ engine,
396
+ now,
397
+ post,
398
+ recordActivity
399
+ ]);
400
+ const signOut = React.useCallback((why = "user") => {
401
+ end(why);
402
+ }, [end]);
403
+ React.useEffect(() => {
404
+ let channel;
405
+ try {
406
+ channel = typeof globalThis.BroadcastChannel === "function" ? new globalThis.BroadcastChannel(channelName) : void 0;
407
+ } catch {
408
+ channel = void 0;
409
+ }
410
+ engine.channel = channel;
411
+ if (channel) channel.onmessage = (event) => {
412
+ const message = event.data;
413
+ if (!message || typeof message !== "object") return;
414
+ if (message.type === "activity") recordActivity(message.at, { broadcast: false });
415
+ else if (message.type === "continue") recordActivity(message.at, {
416
+ force: true,
417
+ broadcast: false
418
+ });
419
+ else if (message.type === "refreshed") {
420
+ engine.lastRefreshClaim = now();
421
+ clearElection();
422
+ if (message.expiresAt !== void 0) {
423
+ const at = now();
424
+ engine.refreshAt = at + (message.expiresAt - at) * REFRESH_AT_FRACTION;
425
+ }
426
+ tickRef.current();
427
+ } else if (message.type === "refresh-claim") {
428
+ engine.lastRefreshClaim = Math.max(engine.lastRefreshClaim, message.at);
429
+ const ballot = message.nonce ?? "";
430
+ if (engine.election && ballot > engine.election.best) engine.election.best = ballot;
431
+ } else if (message.type === "signed-out") end(message.reason, {
432
+ logout: false,
433
+ broadcast: false
434
+ });
435
+ };
436
+ const onPointer = () => {
437
+ const at = now();
438
+ if (at - engine.lastPointer < ACTIVITY_THROTTLE_MS) return;
439
+ engine.lastPointer = at;
440
+ recordActivity(at);
441
+ };
442
+ const onKey = () => recordActivity(now());
443
+ /**
444
+ * The tab came back. **Judge the clock before crediting the hand.**
445
+ *
446
+ * A laptop lid closed at 5pm and opened at 9am delivers exactly one
447
+ * `visibilitychange`, and the timer that should have signed the user out
448
+ * at 1am never ran — a background tab's timers are throttled, and a
449
+ * suspended machine's do not run at all. Crediting the wake as activity
450
+ * first reset `lastActivity` to now and bought a fully idle session a
451
+ * fresh eight hours, every morning, for ever. So the deadline that
452
+ * elapsed while nobody was looking is evaluated against the OLD
453
+ * `lastActivity`, and only a session that survives it is credited.
454
+ */
455
+ const onWake = () => {
456
+ if (engine.ended) return;
457
+ const at = now();
458
+ const idleDeadline = engine.lastActivity + engine.idleMs;
459
+ if (at >= Math.min(idleDeadline, engine.absoluteDeadline)) {
460
+ end(engine.absoluteDeadline <= idleDeadline ? "absolute" : "idle");
461
+ return;
462
+ }
463
+ recordActivity(at);
464
+ };
465
+ const onVisibility = () => {
466
+ if (typeof document !== "undefined" && document.visibilityState === "visible") onWake();
467
+ };
468
+ window.addEventListener("pointerdown", onPointer, { passive: true });
469
+ window.addEventListener("pointermove", onPointer, { passive: true });
470
+ window.addEventListener("keydown", onKey, { passive: true });
471
+ window.addEventListener("focus", onWake);
472
+ document.addEventListener("visibilitychange", onVisibility);
473
+ tickRef.current();
474
+ return () => {
475
+ window.removeEventListener("pointerdown", onPointer);
476
+ window.removeEventListener("pointermove", onPointer);
477
+ window.removeEventListener("keydown", onKey);
478
+ window.removeEventListener("focus", onWake);
479
+ document.removeEventListener("visibilitychange", onVisibility);
480
+ if (channel) {
481
+ channel.onmessage = null;
482
+ channel.close();
483
+ }
484
+ engine.channel = void 0;
485
+ clearElection();
486
+ clearTimer();
487
+ };
488
+ }, [
489
+ channelName,
490
+ clearElection,
491
+ clearTimer,
492
+ end,
493
+ engine,
494
+ now,
495
+ recordActivity
496
+ ]);
497
+ React.useEffect(() => {
498
+ if (engine.seenToken === token.accessToken) return;
499
+ adoptToken(token.accessToken, token.expiresAt, now());
500
+ tickRef.current();
501
+ }, [
502
+ adoptToken,
503
+ engine,
504
+ now,
505
+ token.accessToken,
506
+ token.expiresAt
507
+ ]);
508
+ const recordActivityNow = React.useCallback(() => recordActivity(now()), [now, recordActivity]);
509
+ const idleDeadline = engine.lastActivity + engine.idleMs;
510
+ const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
511
+ return {
512
+ state,
513
+ remainingMs: state === "signedOut" ? 0 : remainingMs,
514
+ canContinue: idleDeadline < engine.absoluteDeadline,
515
+ reason,
516
+ idleDeadline,
517
+ absoluteDeadline: engine.absoluteDeadline,
518
+ deadline,
519
+ continueSession,
520
+ signOut,
521
+ recordActivity: recordActivityNow
522
+ };
523
+ }
524
+ /** `m:ss`, rounded up, so the countdown reaches 0:00 exactly as it ends. */
525
+ function formatCountdown(ms) {
526
+ const seconds = Math.max(0, Math.ceil(ms / 1e3));
527
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
528
+ }
529
+ /**
530
+ * The countdown. `role="alertdialog"` rather than `dialog`, because it is not a
531
+ * surface the user opened — it interrupts them to ask a question with a
532
+ * deadline, and assistive technology announces it as such.
533
+ *
534
+ * It is deliberately undismissable: no Escape, no outside press, no close X.
535
+ * Both ways out are decisions — Continue or Log out — and a modal you can wave
536
+ * away without answering is one that ends a session by accident. Focus opens on
537
+ * Continue, which is the answer nearly every user wants.
538
+ */
539
+ function SessionExpiryDialog({ open, remainingMs, canContinue = true, onContinue, onLogout, modal = true, container }) {
540
+ const continueRef = React.useRef(null);
541
+ return /* @__PURE__ */ jsx(Dialog, {
542
+ open,
543
+ modal,
544
+ disablePointerDismissal: true,
545
+ onOpenChange: () => {},
546
+ children: /* @__PURE__ */ jsxs(DialogContent, {
547
+ role: "alertdialog",
548
+ "data-slot": "session-expiry-dialog",
549
+ container,
550
+ initialFocus: canContinue ? continueRef : void 0,
551
+ className: "max-w-[420px]",
552
+ children: [
553
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsxs("div", {
554
+ className: "flex items-center gap-3",
555
+ children: [/* @__PURE__ */ jsx("span", {
556
+ "data-slot": "session-expiry-glyph",
557
+ className: cn("flex size-9 shrink-0 items-center justify-center", "rounded-[var(--ds-radius-full)] bg-[var(--ds-warning-soft)]", "text-[color:var(--ds-warning)]"),
558
+ children: /* @__PURE__ */ jsx(CountdownGlyph, {})
559
+ }), /* @__PURE__ */ jsxs(DialogTitle, {
560
+ "data-slot": "session-expiry-title",
561
+ children: [
562
+ "Your session ends in",
563
+ " ",
564
+ /* @__PURE__ */ jsx("span", {
565
+ "data-slot": "session-expiry-countdown",
566
+ className: "font-mono tabular-nums",
567
+ children: formatCountdown(remainingMs)
568
+ })
569
+ ]
570
+ })]
571
+ }) }),
572
+ /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx(DialogDescription, {
573
+ "data-slot": "session-expiry-message",
574
+ children: canContinue ? "You have been inactive for a while. Continue to stay signed in — anything you have not saved is kept." : "This session has reached its maximum length and cannot be extended. Save anything you need before it ends."
575
+ }) }),
576
+ /* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
577
+ variant: "secondary",
578
+ onClick: onLogout,
579
+ "data-slot": "session-expiry-logout",
580
+ children: "Log out"
581
+ }), canContinue ? /* @__PURE__ */ jsx(Button, {
582
+ ref: continueRef,
583
+ onClick: onContinue,
584
+ "data-slot": "session-expiry-continue",
585
+ children: "Continue"
586
+ }) : null] })
587
+ ]
588
+ })
589
+ });
590
+ }
591
+ /** A clock face. Inline rather than a lucide import: one icon, one shape. */
592
+ function CountdownGlyph() {
593
+ return /* @__PURE__ */ jsxs("svg", {
594
+ viewBox: "0 0 24 24",
595
+ fill: "none",
596
+ stroke: "currentColor",
597
+ strokeWidth: "2",
598
+ className: "size-5",
599
+ "aria-hidden": true,
600
+ focusable: "false",
601
+ children: [/* @__PURE__ */ jsx("circle", {
602
+ cx: "12",
603
+ cy: "12",
604
+ r: "9"
605
+ }), /* @__PURE__ */ jsx("path", {
606
+ d: "M12 7v5l3 2",
607
+ strokeLinecap: "round",
608
+ strokeLinejoin: "round"
609
+ })]
610
+ });
611
+ }
612
+ /**
613
+ * Mount one per surface: the shell mounts it, and so does the agent pop-up
614
+ * (§17). A user whose extension warns them while the chat pop-up beside it does
615
+ * not has learned nothing about how long a Cortena session lasts.
616
+ */
617
+ function SessionGuard({ modal, container, ...options }) {
618
+ const session = useSessionGuard(options);
619
+ return /* @__PURE__ */ jsx(SessionExpiryDialog, {
620
+ open: session.state === "warning",
621
+ remainingMs: session.remainingMs,
622
+ canContinue: session.canContinue,
623
+ onContinue: session.continueSession,
624
+ onLogout: () => session.signOut("user"),
625
+ modal,
626
+ container
627
+ });
628
+ }
629
+ //#endregion
630
+ export { DEFAULT_SESSION_POLICY, REFRESH_LOCK_NAME, SESSION_CHANNEL_NAME, SessionExpiryDialog, SessionGuard, decodeSessionClaims, formatCountdown, useSessionGuard };
631
+
632
+ //# sourceMappingURL=session-guard.js.map