@k2b/cloud 0.22.0 → 0.24.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -1,6 +1,7 @@
1
1
  import type { HtmlFn } from "@k2b/ssr";
2
2
  import type { Context } from "hono";
3
3
  import type { ClientErrorStatusCode, ServerErrorStatusCode } from "hono/utils/http-status";
4
+ import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa";
4
5
  import { getLocale } from "../server/locale";
5
6
  import { preloadLayoutAnnouncements } from "../server/middleware/settings";
6
7
  import { createLoginRedirectUrl } from "../shared/redirect";
@@ -10,8 +11,8 @@ export type PageErrorOptions = {
10
11
  title?: string;
11
12
  description?: string;
12
13
  action?: { label: string; href: string; icon?: string };
13
- /** Keep standalone/public pages outside the Cloud navigation shell. */
14
- layout?: "cloud" | "minimal";
14
+ /** Keep standalone/public pages outside the Cloud navigation shell; `pwa` renders a page of the mobile app. */
15
+ layout?: "cloud" | "minimal" | "pwa";
15
16
  };
16
17
 
17
18
  export type PageErrorStatus = ClientErrorStatusCode | ServerErrorStatusCode;
@@ -22,7 +23,7 @@ export const createPageResponses = (html: HtmlFn<PageOptions>) => {
22
23
  // Auth middleware may reject before ssr() initializes page metadata.
23
24
  c.set("page", { lang: getLocale(c) });
24
25
  c.header("Cache-Control", "private, no-store");
25
- await preloadLayoutAnnouncements(c);
26
+ if (options.layout !== "pwa") await preloadLayoutAnnouncements(c);
26
27
  // Load JSX only after the application's SSR plugin has been installed.
27
28
  const { renderPageError } = await import("../ssr/PageError");
28
29
  const response = await html(renderPageError(c, status, options), c.get("page"));
@@ -38,5 +39,20 @@ export const createPageResponses = (html: HtmlFn<PageOptions>) => {
38
39
  },
39
40
  };
40
41
 
41
- return { access, error };
42
+ /**
43
+ * Route policy for pages of the mobile app (preview), below `/pwa/`. Only an app session reaches them. Without
44
+ * one, Core renews the app session with the phone's device key and returns to the same page; `pwa_launch` marks
45
+ * that return, so a second miss stops at the shell's "unavailable" state instead of bouncing again.
46
+ */
47
+ const pwaAccess = {
48
+ onReject: (c: Context, reason: "unauthenticated" | "forbidden") => {
49
+ c.header("Cache-Control", "private, no-store");
50
+ if (reason === "forbidden" || c.get("actor")) return error(c, 403, { layout: "pwa" });
51
+ const url = new URL(c.req.url);
52
+ if (url.searchParams.has("pwa_launch")) return `${PWA_SCOPE}?pwa=unavailable`;
53
+ return `${PWA_AUTH_PATH}/session/launch?to=${encodeURIComponent(url.pathname + url.search)}`;
54
+ },
55
+ };
56
+
57
+ return { access, pwaAccess, error };
42
58
  };
@@ -104,6 +104,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
104
104
  )
105
105
  return invalid("searchLinks", "an array of same-origin navigation links");
106
106
  }
107
+ if (value.pwa !== undefined) {
108
+ if (!isRecord(value.pwa) || value.pwa.href !== `/pwa/${value.id as string}`) return invalid("pwa", "a mobile app part at /pwa/<id>");
109
+ if (value.pwa.requiresRoles !== undefined && !isStringArray(value.pwa.requiresRoles)) {
110
+ return invalid("pwa.requiresRoles", "an array of strings");
111
+ }
112
+ }
107
113
  if (value.widgets !== undefined) {
108
114
  if (
109
115
  !Array.isArray(value.widgets) ||
@@ -1,6 +1,7 @@
1
1
  import type { EphemeralConfig } from "@k2b/sync";
2
2
  import type { AppAppearanceColor } from "../contracts/app";
3
3
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
4
+ import { fileProviderIssues } from "../contracts/file-provider";
4
5
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
5
6
  import type { DashboardWidgetPresentation } from "../contracts/widgets";
6
7
  import { resolveAppPresentations } from "../shared/app-presentation";
@@ -174,24 +175,98 @@ const capabilityEndpoint = (baseUrl: string): string | null => {
174
175
  const appAccent = (value: string | undefined): AppAppearanceColor | undefined =>
175
176
  /^#[0-9a-f]{6}$/i.test(value ?? "") ? (value as AppAppearanceColor) : undefined;
176
177
 
178
+ /** Last manifest per app whose left-out entries were checked, so each registered manifest logs once. */
179
+ const checkedLeftOutEntries = new Map<string, string>();
180
+
181
+ /**
182
+ * Logs the entries of a registered manifest that this release left out, typically because the app runs a
183
+ * newer Cloud release. Core answers those operations as not found, so the operator needs their names.
184
+ * A left-out file provider is named too, because the app then quietly stops offering files. `manifest` is
185
+ * the manifest as read, before the file-provider contract check, which logs its own rejections.
186
+ */
187
+ const reportLeftOutEntries = (sent: unknown, manifest: CapabilityManifest): void => {
188
+ if (checkedLeftOutEntries.get(manifest.appId) === manifest.manifestHash) return;
189
+ checkedLeftOutEntries.set(manifest.appId, manifest.manifestHash);
190
+ const kept = new Set([...manifest.types, ...manifest.queries, ...manifest.actions, ...manifest.commands].map((entry) => entry.localId));
191
+ const groups = sent as Record<string, unknown>;
192
+ const leftOut = (["types", "queries", "actions", "commands"] as const).flatMap((group) => {
193
+ const entries = groups[group];
194
+ if (!Array.isArray(entries)) return [];
195
+ return entries.flatMap((entry: unknown) => {
196
+ const localId = entry && typeof entry === "object" ? (entry as { localId?: unknown }).localId : undefined;
197
+ return typeof localId === "string" && !kept.has(localId) ? [`${group}/${localId}`] : [];
198
+ });
199
+ });
200
+ if (groups.fileProvider !== undefined && !manifest.fileProvider) leftOut.push("fileProvider");
201
+ if (leftOut.length === 0) return;
202
+ console.warn(
203
+ JSON.stringify({
204
+ level: "warn",
205
+ source: "capability-registry",
206
+ message: "Left out capability entries this Cloud release cannot read",
207
+ appId: manifest.appId,
208
+ manifestHash: manifest.manifestHash,
209
+ leftOutCount: leftOut.length,
210
+ leftOut: leftOut.slice(0, 20),
211
+ }),
212
+ );
213
+ };
214
+
215
+ /** Last checked manifest per app: its hash and whether its file provider passed the contract check. */
216
+ const checkedFileProviders = new Map<string, { manifestHash: string; valid: boolean }>();
217
+
218
+ /**
219
+ * Ignores a file provider that does not match the contract, logging once per manifest; the app's other
220
+ * capabilities stay available. The delivered `manifestHash` stays, because it identifies what the app registered.
221
+ */
222
+ const withValidFileProvider = (manifest: CapabilityManifest): CapabilityManifest => {
223
+ if (!manifest.fileProvider) return manifest;
224
+ const checked = checkedFileProviders.get(manifest.appId);
225
+ let valid = checked?.manifestHash === manifest.manifestHash ? checked.valid : undefined;
226
+ if (valid === undefined) {
227
+ const issues = fileProviderIssues(manifest);
228
+ valid = issues.length === 0;
229
+ checkedFileProviders.set(manifest.appId, { manifestHash: manifest.manifestHash, valid });
230
+ if (!valid) {
231
+ console.error(
232
+ JSON.stringify({
233
+ level: "error",
234
+ source: "capability-registry",
235
+ message: "Ignored a file provider that does not match the file-provider contract",
236
+ appId: manifest.appId,
237
+ manifestHash: manifest.manifestHash,
238
+ issues: issues
239
+ .slice(0, 10)
240
+ .map(({ function: name, localId, code, path, message }) => ({ function: name, localId, code, path, message })),
241
+ }),
242
+ );
243
+ }
244
+ }
245
+ if (valid) return manifest;
246
+ const { fileProvider: _ignored, ...rest } = manifest;
247
+ return rest;
248
+ };
249
+
250
+ /** Ignores record fields from a newer release; the endpoint always comes from the live app registry. */
177
251
  export const resolveLiveCapabilityRegistryEntry = (
178
252
  key: string,
179
253
  value: unknown,
180
254
  app: AppRegistryEntry | undefined,
181
255
  ): CapabilityRegistryEntry | null => {
182
256
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
183
- if (Object.keys(value).some((field) => field !== "appId" && field !== "manifest" && field !== "presentation")) return null;
184
257
  const record = value as Partial<CapabilityRegistryRecord>;
185
258
  if (!app || typeof record.appId !== "string" || key !== `capabilities/${record.appId}` || record.appId !== app.id) return null;
186
259
  if (!app.capabilities) return null;
187
260
  const endpoint = capabilityEndpoint(app.baseUrl);
188
261
  if (!endpoint) return null;
189
262
  try {
190
- const manifest = parseCapabilityManifest(record.manifest, app.id);
191
- const presentation = compileCapabilityPresentation(manifest, record.presentation);
263
+ const read = parseCapabilityManifest(record.manifest, app.id);
264
+ const manifest = withValidFileProvider(read);
265
+ const presentation = compileCapabilityPresentation(manifest, record.presentation, "reader");
192
266
  if (app.capabilities.protocolVersion !== manifest.protocolVersion || app.capabilities.manifestHash !== manifest.manifestHash) {
193
267
  return null;
194
268
  }
269
+ reportLeftOutEntries(record.manifest, read);
195
270
  return {
196
271
  appId: app.id,
197
272
  appName: app.name,
@@ -67,6 +67,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
67
67
  ),
68
68
  legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
69
69
  searchLinks: e.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
70
+ pwa: e.pwa ? { href: e.pwa.href, requiresRoles: e.pwa.requiresRoles ? ([...e.pwa.requiresRoles] as Role[]) : undefined } : undefined,
70
71
  openapi: e.openapi,
71
72
  };
72
73
  }),
@@ -33,6 +33,15 @@ function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolea
33
33
  });
34
34
  }
35
35
 
36
+ /** `If-None-Match` names the current file: a list of tags, possibly weak, or `*`. */
37
+ function matchesEtag(header: string | null, etag: string): boolean {
38
+ if (!header) return false;
39
+ return header.split(",").some((part) => {
40
+ const tag = part.trim();
41
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
42
+ });
43
+ }
44
+
36
45
  async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
37
46
  const suffix = encoding === "br" ? ".br" : ".gz";
38
47
  const file = Bun.file(`${path}${suffix}`);
@@ -72,13 +81,19 @@ export function servePublicAsset(isDevelopment: boolean) {
72
81
  }
73
82
  }
74
83
 
84
+ // Development rebuilds files in place under the same address, so the browser asks again on every use; an
85
+ // unchanged file then costs one round trip instead of its whole size on every page.
86
+ const etag = `"${selectedEncoding ?? "identity"}-${selected.size.toString(36)}-${selected.lastModified.toString(36)}"`;
75
87
  const headers = new Headers({
76
- "Cache-Control": isDevelopment ? "no-store" : "public, max-age=31536000, immutable",
77
- "Content-Length": String(selected.size),
78
- "Content-Type": sourceFile.type || "application/octet-stream",
88
+ "Cache-Control": isDevelopment ? "no-cache" : "public, max-age=31536000, immutable",
89
+ ETag: etag,
79
90
  Vary: "Accept-Encoding",
80
91
  });
81
92
 
93
+ if (matchesEtag(c.req.header("If-None-Match") ?? null, etag)) return new Response(null, { status: 304, headers });
94
+
95
+ headers.set("Content-Length", String(selected.size));
96
+ headers.set("Content-Type", sourceFile.type || "application/octet-stream");
82
97
  if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
83
98
 
84
99
  return new Response(c.req.method === "HEAD" ? null : selected, { headers });
@@ -1,6 +1,18 @@
1
1
  import { dates } from "@k2b/stdlib";
2
2
  import { mutation as mutations } from "@k2b/stdlib/solid";
3
- import { Button, CopyButton, DateTimePicker, NoticeCard, Placeholder, prompts, Select, Tag, TextInput, useLocale } from "@k2b/ui";
3
+ import {
4
+ Button,
5
+ CopyButton,
6
+ DateTimePicker,
7
+ DetailPanel,
8
+ NoticeCard,
9
+ Placeholder,
10
+ prompts,
11
+ Select,
12
+ Tag,
13
+ TextInput,
14
+ useLocale,
15
+ } from "@k2b/ui";
4
16
  import { createEffect, createSignal, For, Show } from "solid-js";
5
17
  import type { PermissionLevel, ServiceAccountCredential } from "../contracts/shared";
6
18
  import { accessMessages } from "./messages";
@@ -56,9 +68,7 @@ function TokenDialog(props: { token: string }) {
56
68
  const t = () => accessMessages.resolve([locale()]).t;
57
69
  return (
58
70
  <div class="flex flex-col gap-4">
59
- <NoticeCard tone="warning" icon={false}>
60
- {t().copyOnce}
61
- </NoticeCard>
71
+ <NoticeCard tone="warning">{t().copyOnce}</NoticeCard>
62
72
  <div class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-900">
63
73
  <code class="block break-all font-mono text-xs text-primary">{props.token}</code>
64
74
  </div>
@@ -203,39 +213,29 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
203
213
  if (confirmed) await revokeMutation.mutate({ id: key.id, name: key.name });
204
214
  };
205
215
 
216
+ // The heading is a detail panel section heading: in a detail panel the keys line up with the sections around them,
217
+ // and below a settings group title they read as one level down.
206
218
  return (
207
- <section class="flex flex-col gap-4">
208
- <div class="flex items-start justify-between gap-3">
209
- <div>
210
- <h3 class="flex items-center gap-1.5 text-sm font-semibold text-primary">
211
- <i class="ti ti-key text-sm" />
212
- {props.title ?? t().apiKeys}
213
- </h3>
214
- <p class="mt-1 text-xs text-dimmed">{props.description ?? t().apiKeysDescription}</p>
215
- </div>
216
- <Button type="button" variant="secondary" size="sm" class="shrink-0" onClick={openCreate} disabled={createMutation.loading()}>
217
- <i class="ti ti-plus" />
219
+ <DetailPanel.Section
220
+ title={props.title ?? t().apiKeys}
221
+ icon="ti ti-key"
222
+ description={props.description ?? t().apiKeysDescription}
223
+ actions={
224
+ <Button type="button" variant="secondary" size="sm" onClick={openCreate} disabled={createMutation.loading()}>
225
+ <i class="ti ti-plus" aria-hidden="true" />
218
226
  {t().add}
219
227
  </Button>
220
- </div>
221
-
222
- <Show
223
- when={keys().length > 0}
224
- fallback={
225
- <Placeholder
226
- icon="ti ti-key"
227
- class="rounded-lg border border-dashed border-zinc-200 dark:border-zinc-800"
228
- description={t().noApiKeys}
229
- />
230
- }
231
- >
228
+ }
229
+ >
230
+ <Show when={keys().length > 0} fallback={<Placeholder icon="ti ti-key" align="left" description={t().noApiKeys} />}>
232
231
  <div class="flex flex-col gap-2">
233
232
  <For each={keys()}>
234
233
  {(key) => (
235
234
  <div class="group/api-key flex items-center gap-3 rounded-lg bg-zinc-50/70 p-3 dark:bg-zinc-900/35">
236
235
  <div class="min-w-0 flex-1">
237
- <div class="flex min-w-0 items-center gap-2">
238
- <span class="truncate text-sm font-medium text-primary">{key.name}</span>
236
+ {/* The name and its tags wrap instead of squeezing the name to a few letters in a narrow section. */}
237
+ <div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
238
+ <span class="max-w-full truncate text-sm font-medium text-primary">{key.name}</span>
239
239
  <Tag color={key.permission === "none" ? "#dc2626" : "#2563eb"}>
240
240
  {permissionLabel(key.permission, options(), t().noAccess)}
241
241
  </Tag>
@@ -266,6 +266,6 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
266
266
  </For>
267
267
  </div>
268
268
  </Show>
269
- </section>
269
+ </DetailPanel.Section>
270
270
  );
271
271
  }
@@ -8,6 +8,7 @@ import type { AccessSubject } from "../server/services/access";
8
8
  import { isAccountCategoryAllowed } from "../services/account-category-policy";
9
9
  import { isAccountExpired } from "../services/account-model";
10
10
  import { accounts } from "../services/accounts";
11
+ import { withoutAdminRole } from "../services/session/user";
11
12
  import { LOCALE_HEADER } from "../shared/locale";
12
13
  import type { AiCapabilityCatalogEntry } from "./capabilities";
13
14
  import { aiToolAudit } from "./tool-audit";
@@ -30,6 +31,10 @@ export const resolveAiCapabilityActor = async (input: {
30
31
  const user = await (input.getUser ?? accounts.users.get)({ id: conversation.createdByUserId });
31
32
  if (!user || isAccountExpired(user.accountExpires)) throw new Error("Cloud capability actor is no longer active.");
32
33
  if (!(await isAccountCategoryAllowed(user))) throw new Error("Cloud capability actor category is disabled.");
34
+ // A turn started in the mobile app keeps its limits: no administrator role, and invocations say so.
35
+ if (input.persistedActor.sessionKind === "app") {
36
+ return { actor: { kind: "user", user: withoutAdminRole(user), sessionKind: "app" }, accessSubject: { type: "user", userId: user.id } };
37
+ }
33
38
  return { actor: { kind: "user", user }, accessSubject: { type: "user", userId: user.id } };
34
39
  };
35
40
 
@@ -705,11 +705,12 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
705
705
 
706
706
  /** Render one unified turn block. Shared by persisted assistant groups and the live turn. */
707
707
  export function AiTurnBlockView(props: { block: AiTurnBlock; turnId: string; streaming?: boolean; active?: boolean }) {
708
+ const locale = useLocale();
708
709
  // The id/kind remain stable while the immutable block value changes during a turn.
709
710
  return (
710
711
  <Switch>
711
712
  <Match when={props.block.kind === "text" && props.block}>
712
- {(block) => <AssistantMarkdownBlock html={markdown.renderSync(block().text)} />}
713
+ {(block) => <AssistantMarkdownBlock html={markdown.renderSync(block().text, { locale: locale() })} />}
713
714
  </Match>
714
715
  <Match when={props.block.kind === "thinking" && props.block}>
715
716
  {(block) => <ThinkingBlockView text={block().text} streaming={props.streaming} />}
@@ -22,6 +22,9 @@ const messages = i18n.define({
22
22
  thinking: "Thinking",
23
23
  showReasoning: "Show reasoning",
24
24
  byteRange: ({ start, end }: { start: string; end: string }) => `Bytes ${start}–${end}`,
25
+ streamLoginRequired: "Your session has ended. Sign in again to continue this chat.",
26
+ streamAccessDenied: "You no longer have access to this chat.",
27
+ streamNotFound: "This chat is no longer available.",
25
28
  },
26
29
  de: {
27
30
  backgroundRun: "Hintergrundlauf",
@@ -43,6 +46,9 @@ const messages = i18n.define({
43
46
  thinking: "Denkt nach",
44
47
  showReasoning: "Denkprozess anzeigen",
45
48
  byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
49
+ streamLoginRequired: "Deine Sitzung ist abgelaufen. Melde dich erneut an, um diesen Chat fortzusetzen.",
50
+ streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
51
+ streamNotFound: "Dieser Chat ist nicht mehr verfügbar.",
46
52
  },
47
53
  },
48
54
  });
@@ -1,6 +1,7 @@
1
1
  import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
2
2
  import { createStore, reconcile } from "solid-js/store";
3
3
  import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
+ import { aiChatMessages } from "../chat/messages";
4
5
  import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
5
6
  import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
6
7
  import type {
@@ -24,7 +25,14 @@ import {
24
25
  reduceProjection,
25
26
  visibleMessages,
26
27
  } from "./projection";
27
- import { type AiConversationStreamTransport, type AiStreamHandle, aiSseConversationStreamTransport } from "./transport";
28
+ import {
29
+ type AiConversationStreamTransport,
30
+ AiStreamError,
31
+ type AiStreamErrorCode,
32
+ type AiStreamHandle,
33
+ aiSseConversationStreamTransport,
34
+ terminalAiStreamErrorCode,
35
+ } from "./transport";
28
36
 
29
37
  type ComposerDraftInput = {
30
38
  draftContent?: AiDraftContentPart[];
@@ -108,7 +116,7 @@ export type CreateAiChatControllerOptions = {
108
116
  frontendTools?: Record<string, AiFrontendToolHandler>;
109
117
  /** Explicitly advertise only client tools with a connected handler. */
110
118
  clientToolIds?: AiClientToolId[];
111
- /** Overrides the default SSE transport, for example with a shared WebSocket channel. */
119
+ /** Overrides the default SSE transport of the conversation stream. */
112
120
  streamTransport?: AiConversationStreamTransport;
113
121
  };
114
122
 
@@ -142,6 +150,21 @@ const completeFrontendToolBlock = (blocks: AiTurnBlock[], callId: string, result
142
150
  const isActiveConversationLoading = (activeConversationId: string | null, loadingConversationId: string | null): boolean =>
143
151
  activeConversationId !== null && loadingConversationId === activeConversationId;
144
152
 
153
+ /**
154
+ * Why the stream ended, in the request's locale: the server renders it into
155
+ * `<html lang>`, which is also what `useLocale()` of `@k2b/ui` resolves in an
156
+ * island. The controller stays headless and does not load the component library.
157
+ */
158
+ const streamErrorText = (code: AiStreamErrorCode): string => {
159
+ const t = aiChatMessages(typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
160
+ if (code === "login_required") return t.streamLoginRequired;
161
+ return code === "access_denied" ? t.streamAccessDenied : t.streamNotFound;
162
+ };
163
+
164
+ /** A chat that cannot continue says why in the page's language; any other error shows its own message. */
165
+ const errorText = (error: unknown, fallback: string): string =>
166
+ error instanceof AiStreamError ? streamErrorText(error.code) : error instanceof Error ? error.message : fallback;
167
+
145
168
  export const createAiChatController = (options: CreateAiChatControllerOptions) => {
146
169
  const [activeConversationId, setActiveConversationIdSignal] = createSignal<string | null>(options.initialConversationId ?? null);
147
170
  const [globalError, setGlobalError] = createSignal<string | null>(options.initialError ?? null);
@@ -178,6 +201,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
178
201
  const abortRequests = new Map<string, Promise<boolean>>();
179
202
  let stream: AiStreamHandle | null = null;
180
203
  let streamSession: AiStreamSession | null = null;
204
+ /** The chat error left by a stream that ended for good, until a stream opens again. */
205
+ let streamError: string | null = null;
181
206
  let streamGeneration = 0;
182
207
  let conversationOpenGeneration = 0;
183
208
  let historyTargetSeq: number | null = null;
@@ -217,9 +242,12 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
217
242
  if (activeConversationId()) setGlobalError(message);
218
243
  else setGlobalError(message);
219
244
  };
245
+ /** Starts the person's next action in the active chat clean, including a stream that ended with an error. */
220
246
  const clearErrors = () => {
221
247
  setGlobalError(null);
222
248
  setRunError(null);
249
+ const conversationId = activeConversationId();
250
+ if (conversationId) openStream(conversationId);
223
251
  };
224
252
  const error = () => globalError() ?? runError();
225
253
 
@@ -314,17 +342,29 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
314
342
  conversationId,
315
343
  url: url(`/conversations/${conversationId}/stream`),
316
344
  onStatus: (status) => {
317
- if (isCurrentStreamSession(streamSession, session)) setStreamStatus(status);
345
+ if (!isCurrentStreamSession(streamSession, session)) return;
346
+ setStreamStatus(status);
347
+ if (status !== "open" || streamError === null) return;
348
+ if (globalError() === streamError) setGlobalError(null);
349
+ streamError = null;
318
350
  },
319
351
  onEvent: (event) => applyEvent(session, event),
352
+ // The transport has stopped. Drop its handle so the next visit, refresh,
353
+ // or action in this chat can subscribe again.
320
354
  onError: (error) => {
321
355
  if (!isCurrentStreamSession(streamSession, session)) return;
322
- setStreamStatus("idle");
323
- setConversationError(conversationId, error.message);
356
+ endStream(conversationId, error);
324
357
  },
325
358
  });
326
359
  };
327
360
 
361
+ /** Stops the chat's stream for good and shows why. */
362
+ const endStream = (conversationId: string, error: Error) => {
363
+ closeStream();
364
+ streamError = errorText(error, error.message);
365
+ setConversationError(conversationId, streamError);
366
+ };
367
+
328
368
  // ---- frontend tools ---------------------------------------------------
329
369
 
330
370
  const runFrontendTools = () => {
@@ -381,13 +421,20 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
381
421
  } catch {}
382
422
  };
383
423
 
424
+ /** Loads a chat. A status after which no retry can load it throws an `AiStreamError`, like the stream's. */
425
+ const fetchDetail = async (conversationId: string, fallback: string): Promise<AiConversationDetail> => {
426
+ const response = await fetch(url(`/conversations/${conversationId}`));
427
+ if (response.ok) return (await response.json()) as AiConversationDetail;
428
+ const message = await readError(response, fallback);
429
+ const ended = terminalAiStreamErrorCode(response.status);
430
+ throw ended ? new AiStreamError(ended, message) : new Error(message);
431
+ };
432
+
384
433
  const loadDetail = async (conversationId: string, shouldReportError: () => boolean): Promise<AiConversationDetail | null> => {
385
434
  try {
386
- return await request<AiConversationDetail>(`/conversations/${conversationId}`, { method: "GET" }, "Failed to open conversation");
435
+ return await fetchDetail(conversationId, "Failed to open conversation");
387
436
  } catch (loadError) {
388
- if (shouldReportError()) {
389
- setConversationError(conversationId, loadError instanceof Error ? loadError.message : "Failed to open conversation");
390
- }
437
+ if (shouldReportError()) setConversationError(conversationId, errorText(loadError, "Failed to open conversation"));
391
438
  return null;
392
439
  }
393
440
  };
@@ -422,6 +469,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
422
469
 
423
470
  const openConversation = async (conversationId: string) => {
424
471
  if (activeConversationId() === conversationId && state.conversation?.id === conversationId) {
472
+ openStream(conversationId);
425
473
  void markConversationViewed(conversationId);
426
474
  return "current" as const;
427
475
  }
@@ -461,22 +509,31 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
461
509
  };
462
510
 
463
511
  let conversationRefreshGeneration = 0;
464
- const refreshActiveConversation = async (): Promise<void> => {
512
+ /**
513
+ * Loads the active chat again. Resolves `false` when it no longer exists or
514
+ * is no longer readable: the chat then ends like its stream, with the reason
515
+ * as its error, because no retry can load it. A response that a newer
516
+ * refresh or a newer opening of a chat overtook changes nothing.
517
+ */
518
+ const refreshActiveConversation = async (): Promise<boolean> => {
465
519
  const conversationId = activeConversationId();
466
- if (!conversationId) return;
520
+ if (!conversationId) return true;
467
521
  const refreshGeneration = ++conversationRefreshGeneration;
468
522
  const openGeneration = conversationOpenGeneration;
469
- const detail = await request<AiConversationDetail>(
470
- `/conversations/${conversationId}`,
471
- { method: "GET" },
472
- "Failed to refresh conversation",
473
- );
474
- if (
523
+ const overtaken = () =>
475
524
  !isActiveConversation(conversationId) ||
476
525
  refreshGeneration !== conversationRefreshGeneration ||
477
- openGeneration !== conversationOpenGeneration
478
- )
479
- return;
526
+ openGeneration !== conversationOpenGeneration;
527
+ let detail: AiConversationDetail;
528
+ try {
529
+ detail = await fetchDetail(conversationId, "Failed to refresh conversation");
530
+ } catch (refreshError) {
531
+ if (!(refreshError instanceof AiStreamError)) throw refreshError;
532
+ if (overtaken()) return true;
533
+ endStream(conversationId, refreshError);
534
+ return false;
535
+ }
536
+ if (overtaken()) return true;
480
537
  const currentDraft = state.conversation?.draft;
481
538
  if (currentDraft && currentDraft.revision > detail.conversation.draft.revision) detail.conversation.draft = currentDraft;
482
539
  const windowOldest = detail.messages[0]?.seq;
@@ -485,6 +542,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
485
542
  if (preservedOlder.length === 0) setHasMore(conversationId, detail.hasMoreMessages ?? false);
486
543
  if (detail.timeline) setTimeline(conversationId, detail.timeline);
487
544
  setRunError(conversationRunError(detail.conversation));
545
+ openStream(conversationId);
546
+ return true;
488
547
  };
489
548
 
490
549
  const requestMessagesPage = async (conversationId: string, before: number, limit: number): Promise<AiMessagesPage> => {
@@ -573,8 +632,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
573
632
  if (conversationId) void openConversation(conversationId);
574
633
  else {
575
634
  conversationOpenGeneration += 1;
576
- clearErrors();
577
635
  setActiveConversationIdSignal(null);
636
+ clearErrors();
578
637
  historyTargetSeq = null;
579
638
  setLoadingConversationId(null);
580
639
  closeStream();
@@ -13,6 +13,38 @@ export type AiConversationStreamTransport = {
13
13
  }) => AiStreamHandle;
14
14
  };
15
15
 
16
+ /** Why a conversation stream ended for good. A client shows its own text for the code. */
17
+ export type AiStreamErrorCode = "login_required" | "access_denied" | "not_found";
18
+
19
+ /** A stream that will not recover by reconnecting; `message` comes from the server when it sent one. */
20
+ export class AiStreamError extends Error {
21
+ readonly code: AiStreamErrorCode;
22
+
23
+ constructor(code: AiStreamErrorCode, message: string) {
24
+ super(message);
25
+ this.name = "AiStreamError";
26
+ this.code = code;
27
+ }
28
+ }
29
+
30
+ const TERMINAL_STATUS_CODES: Readonly<Record<number, AiStreamErrorCode>> = {
31
+ 401: "login_required",
32
+ 403: "access_denied",
33
+ 404: "not_found",
34
+ };
35
+
36
+ /** The code of an HTTP status after which a conversation cannot continue, by retrying or reconnecting. */
37
+ export const terminalAiStreamErrorCode = (status: number): AiStreamErrorCode | undefined => TERMINAL_STATUS_CODES[status];
38
+
39
+ const terminalStreamError = async (response: Response, code: AiStreamErrorCode): Promise<AiStreamError> => {
40
+ const body: unknown = await response.json().catch(() => null);
41
+ const message =
42
+ body && typeof body === "object" && "message" in body && typeof body.message === "string" && body.message
43
+ ? body.message
44
+ : `AI stream failed: ${response.status}`;
45
+ return new AiStreamError(code, message);
46
+ };
47
+
16
48
  const RECONNECT_BASE_MS = 500;
17
49
  const RECONNECT_MAX_MS = 5_000;
18
50
  const CONNECT_TIMEOUT_MS = 10_000;
@@ -53,12 +85,15 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
53
85
  /**
54
86
  * Subscribe to a conversation's SSE stream with automatic reconnect. Each
55
87
  * (re)connect starts with a fresh `state` event, so the projection self-heals on
56
- * every reconnect without cursor bookkeeping.
88
+ * every reconnect without cursor bookkeeping. A 401, 403, or 404 cannot heal
89
+ * that way: the subscription stops and reports an `AiStreamError` once through
90
+ * `onError`, its only signal that the stream ended. Status stays where it was.
57
91
  */
58
92
  export const subscribeAiStream = (input: {
59
93
  url: string;
60
94
  onEvent: (event: AiStreamEvent) => void;
61
95
  onStatus?: (status: AiStreamConnectionStatus) => void;
96
+ onError?: (error: AiStreamError) => void;
62
97
  fetch?: AiStreamFetch;
63
98
  }): AiStreamHandle => {
64
99
  const fetchStream: AiStreamFetch = input.fetch ?? fetch;
@@ -80,8 +115,17 @@ export const subscribeAiStream = (input: {
80
115
  try {
81
116
  input.onStatus?.(reconnectDelay === RECONNECT_BASE_MS ? "connecting" : "reconnecting");
82
117
  const response = await fetchStream(input.url, { signal: attempt.signal, headers: { Accept: "text/event-stream" } });
83
- clearConnectTimer();
84
118
  if (stopped) return;
119
+ const terminalCode = terminalAiStreamErrorCode(response.status);
120
+ if (terminalCode) {
121
+ // The connect timeout still bounds reading the error body.
122
+ const error = await terminalStreamError(response, terminalCode);
123
+ if (stopped) return;
124
+ stopped = true;
125
+ input.onError?.(error);
126
+ return;
127
+ }
128
+ clearConnectTimer();
85
129
  if (!response.ok || !response.body) throw new Error(`AI stream failed: ${response.status}`);
86
130
  input.onStatus?.("open");
87
131
  reconnectDelay = RECONNECT_BASE_MS;
@@ -113,5 +157,5 @@ export const subscribeAiStream = (input: {
113
157
  };
114
158
 
115
159
  export const aiSseConversationStreamTransport: AiConversationStreamTransport = {
116
- subscribe: ({ url, onEvent, onStatus }) => subscribeAiStream({ url, onEvent, onStatus }),
160
+ subscribe: ({ url, onEvent, onStatus, onError }) => subscribeAiStream({ url, onEvent, onStatus, onError }),
117
161
  };