@k2b/cloud 0.28.0 → 0.29.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.
Files changed (112) hide show
  1. package/package.json +9 -6
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -8,6 +8,9 @@ import {
8
8
  MailAppAccessSchema,
9
9
  type MailBounceErrorCode,
10
10
  type MailErrorCode,
11
+ type MailLogAccess,
12
+ MailLogAccessSchema,
13
+ MailLogAppIdSchema,
11
14
  type MailProfile,
12
15
  type MailProfileInput,
13
16
  MailProfileInputSchema,
@@ -22,7 +25,7 @@ export class OutgoingMailError extends Error {
22
25
  constructor(
23
26
  public readonly code: MailErrorCode,
24
27
  message: string,
25
- public readonly status: 400 | 404 | 409 | 502 = 400,
28
+ public readonly status: 400 | 403 | 404 | 409 | 502 = 400,
26
29
  ) {
27
30
  super(message);
28
31
  }
@@ -75,11 +78,12 @@ const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
75
78
  await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
76
79
  return run(tx);
77
80
  });
78
- const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
81
+ const record = (db: SQL, context: MailAuditContext, action: string, target: string, metadata?: Record<string, unknown>) =>
79
82
  audit.record(
80
83
  {
81
84
  ...context,
82
85
  action: `outgoing_mail.${action}`,
86
+ ...(metadata ? { metadata } : {}),
83
87
  outcome: "allowed",
84
88
  target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
85
89
  },
@@ -241,7 +245,9 @@ const appsState = async (
241
245
  const registered = registeredApps ?? (await listApps());
242
246
  const { access, grants } = await accessRows(db);
243
247
  const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
244
- const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id)]);
248
+ const logGrants = await db<{ app_id: string; source_app_id: string }[]>`
249
+ SELECT app_id, source_app_id FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
250
+ const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id), ...logGrants.map((g) => g.app_id)]);
245
251
  const items = [...ids]
246
252
  .map((appId): AdminMailApp => {
247
253
  const app = registered.find((a) => a.id === appId);
@@ -251,6 +257,13 @@ const appsState = async (
251
257
  name: app?.name ?? appId,
252
258
  registered: !!app,
253
259
  declared: !!app?.platformPermissions?.includes("mail:send"),
260
+ readDeclared: appId !== "core" && !!app?.platformPermissions?.includes("mail:read"),
261
+ logApps:
262
+ appId === "core"
263
+ ? []
264
+ : logGrants
265
+ .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId)
266
+ .map((g) => g.source_app_id),
254
267
  mode,
255
268
  profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
256
269
  };
@@ -285,6 +298,41 @@ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAu
285
298
  name: appId,
286
299
  registered: false,
287
300
  declared: false,
301
+ readDeclared: false,
302
+ logApps: [],
303
+ mode: "default",
304
+ profiles: [],
305
+ }
306
+ );
307
+ });
308
+ };
309
+ const logAppsFor = async (appId: string): Promise<string[]> => {
310
+ if (appId === "core") return [];
311
+ const rows = await sql<{ source_app_id: string }[]>`SELECT source_app_id FROM outgoing_mail.app_log_access
312
+ WHERE app_id = ${appId} AND source_app_id <> 'core' AND source_app_id <> app_id ORDER BY source_app_id`;
313
+ return rows.map((row) => row.source_app_id);
314
+ };
315
+ const setAppLogAccess = async (appId: string, value: MailLogAccess, context: MailAuditContext): Promise<AdminMailApp> => {
316
+ const reader = MailLogAppIdSchema.safeParse(appId);
317
+ const parsed = MailLogAccessSchema.safeParse(value);
318
+ if (!reader.success || appId === "core" || !parsed.success || parsed.data.apps.includes(appId))
319
+ throw new OutgoingMailError("bad_input", "Choose a valid reader and distinct source apps other than Core or the reader itself.");
320
+ const apps = [...parsed.data.apps].sort();
321
+ const registered = await listApps();
322
+ return locked(async (tx) => {
323
+ await tx`DELETE FROM outgoing_mail.app_log_access WHERE app_id = ${appId}`;
324
+ await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by)
325
+ SELECT ${appId}, source_app_id, ${context.actor.userId ?? null}
326
+ FROM unnest(${toPgTextArray(apps)}::text[]) AS source_app_id`;
327
+ await record(tx, context, "app_log_access.update", appId, { apps });
328
+ return (
329
+ (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
330
+ appId,
331
+ name: appId,
332
+ registered: false,
333
+ declared: false,
334
+ readDeclared: false,
335
+ logApps: [],
288
336
  mode: "default",
289
337
  profiles: [],
290
338
  }
@@ -319,7 +367,18 @@ const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
319
367
  quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: row.used },
320
368
  }));
321
369
  };
322
- export const outgoingMailStore = { list, get, put, setDefault, delete: remove, apps: appsState, setAppAccess, profilesForApp };
370
+ export const outgoingMailStore = {
371
+ list,
372
+ get,
373
+ put,
374
+ setDefault,
375
+ delete: remove,
376
+ apps: appsState,
377
+ setAppAccess,
378
+ setAppLogAccess,
379
+ logAppsFor,
380
+ profilesForApp,
381
+ };
323
382
 
324
383
  /** Platform email send path only. Never use this helper to shape API or CLI responses. */
325
384
  export const resolveMailCredentials = async (key?: string | { id: string }) => {
@@ -1,4 +1,4 @@
1
- import { type LiquidTemplateOptions, renderLiquidTemplate } from "../../shared/template-rendering";
1
+ import { LiquidTemplateError, type LiquidTemplateOptions, renderLiquidTemplate } from "../../shared/template-rendering";
2
2
  import {
3
3
  type GotenbergConfig,
4
4
  GotenbergRenderError,
@@ -84,6 +84,7 @@ export const renderTemplatePdfPreview = async (
84
84
  ok: false,
85
85
  error: {
86
86
  phase: "template",
87
+ ...(error instanceof LiquidTemplateError ? { code: error.reason } : {}),
87
88
  message: error instanceof Error ? error.message : "Template rendering failed.",
88
89
  status: 400,
89
90
  },
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { err, fail, ok, type PageParams, type Paginated } from "@k2b/stdlib";
11
11
  import { paginateItems } from "../../server/services";
12
+ import { ImageMetadataError } from "../image-metadata";
12
13
  import type { SettingEntry } from ".";
13
14
  import * as settingsPrimitives from ".";
14
15
  import { SETTINGS_MAP, validateSettingValue } from "./defaults";
@@ -66,6 +67,8 @@ export const settingsService = {
66
67
  await settingsPrimitives.set(config.key, validated.value);
67
68
  return ok(undefined);
68
69
  } catch (error) {
70
+ // A Result cannot carry 422; the typed HTTPException keeps its status through Hono.
71
+ if (error instanceof ImageMetadataError) throw error;
69
72
  const message = error instanceof Error ? error.message : String(error);
70
73
  if (message.startsWith("Unknown setting:")) {
71
74
  return fail(err.badInput(message));
@@ -17,6 +17,7 @@ import { sql } from "bun";
17
17
  import { HTTPException } from "hono/http-exception";
18
18
  import { hasRole, type User } from "../../contracts/shared";
19
19
  import { claimCacheFill, completeCacheFill, MISSING_SETTING } from "../cache-fill";
20
+ import { stripImageDataUrlMetadata } from "../image-metadata";
20
21
  import { toPgTextArray } from "../postgres";
21
22
  import { requestCacheRedis } from "../request-cache-redis";
22
23
  import { decryptValue, encryptValue } from "./crypto";
@@ -193,7 +194,9 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient): Pro
193
194
  const validated = validateSettingValue(def, value);
194
195
  if (!validated.ok) throw new Error(validated.error);
195
196
 
196
- const encrypted = await encryptValue(validated.value);
197
+ const storedValue =
198
+ def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
199
+ const encrypted = await encryptValue(storedValue);
197
200
  await (db ?? sql)`
198
201
  INSERT INTO settings.entries (key, value, updated_at)
199
202
  VALUES (${key}, ${encrypted}, now())
@@ -36,7 +36,11 @@ const mapPlace = (place: GeoPlace): WeatherCity => ({
36
36
  const getGeoBaseUrl = async (): Promise<Result<string>> => {
37
37
  const geoUrl = (await settings.get<string>("weather.geo_url")).trim();
38
38
  if (!geoUrl) {
39
- return fail(err.internal("Geo API URL is not configured. Set weather.geo_url."));
39
+ return fail({
40
+ code: "WEATHER_CITY_SEARCH_NOT_CONFIGURED",
41
+ message: "Geo API URL is not configured. Set weather.geo_url.",
42
+ status: 500,
43
+ });
40
44
  }
41
45
  return ok(geoUrl.replace(/\/$/, ""));
42
46
  };
@@ -69,7 +73,7 @@ const list = async (config: {
69
73
  }
70
74
 
71
75
  const baseUrlResult = await getGeoBaseUrl();
72
- if (!baseUrlResult.ok) return unavailable();
76
+ if (!baseUrlResult.ok) return baseUrlResult;
73
77
  const geoUrl = baseUrlResult.data;
74
78
  const result = await geoService.place.list({
75
79
  baseUrl: geoUrl,
@@ -57,7 +57,7 @@ Most replies need no offer. Make at most one offer, in the last sentence of the
57
57
  - The user corrected a result that their own Skill shaped: offer to add the correction to that Skill.
58
58
  {%- endif %}
59
59
  {%- if memoryToolEnabled %}
60
- - The user corrected the tone of a result that a built-in or shared Skill shaped, such as a mail that is too formal: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
60
+ - The user corrected the tone of a result, such as a mail that is too formal, without stating a lasting rule: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
61
61
  {%- endif %}
62
62
  - The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}scheduled task.
63
63
  - A concrete next step clearly saves the user real work they may not know you can do: offer it, such as a manual follow-up the user mentioned, drafting the reply, or turning findings into Space tasks.
@@ -66,7 +66,7 @@ Offer only what the Skills, tool hints, and apps in this prompt support. Do not
66
66
  Offer a new Skill only when no listed Skill{% if skillSearch %} or search_skills result{% endif %} already covers the approach; a single preference belongs in memory, not a Skill. Built-in and shared Skills change for everyone, so change one only when the user asks for that. After a yes to a Skill offer, load skill-creator and draft from this conversation; its create or update review is the confirmation.
67
67
  {%- endif %}
68
68
  Make no offer for a simple fact or small talk, while you ask a question or wait for approval, after a failure or blocker, when your previous reply already ended with an offer, when the user declined it in this chat, or when the user asked for no suggestions or only a short answer. A request to shorten a draft or text is a correction, not a request for no offers. Organization, Project, and user instructions about suggestions take precedence.
69
- When the user asks what you can do, first take one quick look, where your tools allow it, at what they already work with, such as their Spaces, recent chats, or files, and lead with that. Give three to five concrete examples grounded in that work and in the Skills, tools, and apps available to you; leave out apps where they have no data, such as mail without a mailbox.
69
+ When the user asks what you can do, lead with what they already work with: use the Recent work section when it is present; otherwise first take one quick look, where your tools allow it, at their Spaces, recent chats, or files. Give three to five concrete examples grounded in that work and in the Skills, tools, and apps available to you; leave out apps where they have no data, such as mail without a mailbox.
70
70
  {%- endif %}
71
71
  {%- if tools.size > 0 %}
72
72
 
@@ -65,6 +65,7 @@ const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
65
65
  [ipv6ToBigInt("2001:2::")!, 48],
66
66
  [ipv6ToBigInt("2001:db8::")!, 32],
67
67
  [ipv6ToBigInt("2002::")!, 16],
68
+ [ipv6ToBigInt("3fff::")!, 20],
68
69
  [ipv6ToBigInt("fc00::")!, 7],
69
70
  [ipv6ToBigInt("fe80::")!, 10],
70
71
  [ipv6ToBigInt("fec0::")!, 10],
@@ -9,7 +9,8 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
9
9
 
10
10
  try {
11
11
  const url = new URL(trimmed, REDIRECT_BASE);
12
- if (url.origin !== REDIRECT_BASE) return undefined;
12
+ // Dot segments can resolve to `//host`, which a browser reads as another host.
13
+ if (url.origin !== REDIRECT_BASE || url.pathname.startsWith("//")) return undefined;
13
14
  return `${url.pathname}${url.search}${url.hash}`;
14
15
  } catch {
15
16
  return undefined;
@@ -1,10 +1,24 @@
1
- import { Liquid } from "liquidjs";
1
+ import {
2
+ AssertionError,
3
+ Context,
4
+ defaultOperators,
5
+ type Emitter,
6
+ type FilterImplOptions,
7
+ Liquid,
8
+ LiquidError,
9
+ type Operators,
10
+ toValue,
11
+ toValueSync,
12
+ } from "liquidjs";
2
13
 
3
14
  const TEMPLATE_MAX_BYTES = 200_000;
4
15
  const RENDER_MAX_BYTES = 300_000;
16
+ // Synchronous rendering blocks every request in the app process. Legitimate templates
17
+ // take milliseconds; keep this well below normal request latency budgets.
18
+ const RENDER_TIMEOUT_MS = 1_000;
5
19
 
6
20
  type LiquidEngine = Liquid;
7
- export type LiquidTemplateErrorReason = "render_too_large";
21
+ export type LiquidTemplateErrorReason = "render_too_large" | "render_timeout" | "render_memory_limit";
8
22
 
9
23
  export class LiquidTemplateError extends Error {
10
24
  constructor(
@@ -22,6 +36,7 @@ export type LiquidTemplateOptions = {
22
36
  escapeOutput?: boolean | ((value: unknown) => string);
23
37
  templateMaxBytes?: number;
24
38
  renderMaxBytes?: number;
39
+ renderTimeoutMs?: number;
25
40
  memoryLimit?: number;
26
41
  };
27
42
 
@@ -52,6 +67,35 @@ const TEMPLATE_TAG_RE = /{%-?\s*([A-Za-z_][A-Za-z0-9_]*)\b/g;
52
67
 
53
68
  const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
54
69
 
70
+ class BoundedEmitter implements Emitter {
71
+ buffer = "";
72
+ private bytes = 0;
73
+ private lastCodeUnit = 0;
74
+
75
+ constructor(private readonly maxBytes: number) {}
76
+
77
+ write(input: unknown): void {
78
+ const value: unknown = toValue(input);
79
+ // Match LiquidJS's emitter: nil is empty, arrays concatenate their elements.
80
+ if (value === null || value === undefined) return;
81
+ if (Array.isArray(value)) {
82
+ for (const item of value) this.write(item);
83
+ return;
84
+ }
85
+ const chunk = String(value);
86
+ if (!chunk) return;
87
+ const last = this.lastCodeUnit;
88
+ const first = chunk.charCodeAt(0);
89
+ // A surrogate pair split across writes encodes as four bytes, rather than six.
90
+ const paired = last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff;
91
+ const bytes = this.bytes + byteLength(chunk) - (paired ? 2 : 0);
92
+ if (bytes > this.maxBytes) throw new LiquidTemplateError("render_too_large", "Rendered template is too large");
93
+ this.bytes = bytes;
94
+ this.lastCodeUnit = chunk.charCodeAt(chunk.length - 1);
95
+ this.buffer += chunk;
96
+ }
97
+ }
98
+
55
99
  export const escapeTemplateOutput = (value: unknown): string =>
56
100
  String(value).replace(/[&<>"'`=/]/g, (char) => {
57
101
  switch (char) {
@@ -76,16 +120,34 @@ export const escapeTemplateOutput = (value: unknown): string =>
76
120
  }
77
121
  });
78
122
 
123
+ const expandedTextLength = (input: unknown): number => {
124
+ const value: unknown = toValue(input);
125
+ if (typeof value === "string") return value.length;
126
+ if (Array.isArray(value)) return value.reduce((length: number, item: unknown) => length + expandedTextLength(item), 0);
127
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).length;
128
+ return 0;
129
+ };
130
+
79
131
  const createEngine = (options: LiquidTemplateOptions = {}) => {
80
132
  const outputEscape =
81
133
  typeof options.escapeOutput === "function" ? options.escapeOutput : options.escapeOutput === false ? undefined : escapeTemplateOutput;
134
+ const operators: Operators = {};
135
+ for (const [name, handler] of Object.entries(defaultOperators)) {
136
+ operators[name] = (...args: [unknown, Context] | [unknown, unknown, Context]): boolean => {
137
+ const result: boolean = Reflect.apply(handler, undefined, args);
138
+ const context = args[args.length - 1];
139
+ if (context instanceof Context) context.renderLimit.check(performance.now());
140
+ return result;
141
+ };
142
+ }
82
143
  const engine = new Liquid({
144
+ operators,
83
145
  strictVariables: true,
84
146
  strictFilters: true,
85
147
  ownPropertyOnly: true,
86
148
  ...(outputEscape ? { outputEscape } : {}),
87
149
  parseLimit: options.templateMaxBytes ?? TEMPLATE_MAX_BYTES,
88
- renderLimit: options.renderMaxBytes ?? RENDER_MAX_BYTES,
150
+ renderLimit: options.renderTimeoutMs ?? RENDER_TIMEOUT_MS,
89
151
  memoryLimit: options.memoryLimit ?? 2_000_000,
90
152
  cache: false,
91
153
  dynamicPartials: false,
@@ -94,7 +156,42 @@ const createEngine = (options: LiquidTemplateOptions = {}) => {
94
156
  partials: [],
95
157
  });
96
158
 
159
+ const renderTemplates = engine.renderer.renderTemplates.bind(engine.renderer);
160
+ engine.renderer.renderTemplates = function* (templates, context, emitter) {
161
+ // Capture renders without an emitter; bound its intermediate buffer as well.
162
+ const result = yield* renderTemplates(
163
+ templates,
164
+ context,
165
+ emitter ?? new BoundedEmitter(Math.max(options.renderMaxBytes ?? RENDER_MAX_BYTES, RENDER_MAX_BYTES)),
166
+ );
167
+ // LiquidJS checks before templates and every loop body (even an empty one).
168
+ // Check after them too, so a slow final filter cannot return over budget.
169
+ context.renderLimit.check(performance.now());
170
+ return result;
171
+ };
172
+
173
+ for (const name of ["push", "concat", "unshift", "map"]) {
174
+ const filter = engine.filters[name];
175
+ if (!filter) continue;
176
+ const handler = typeof filter === "function" ? filter : filter.handler;
177
+ const bounded: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
178
+ const result: unknown = yield handler.apply(this, args);
179
+ // Charge added text only; charging the left array again would compound.
180
+ this.context.memoryLimit.use(expandedTextLength(name === "map" ? result : args[1]));
181
+ return result;
182
+ };
183
+ engine.registerFilter(name, typeof filter === "function" ? bounded : { ...filter, handler: bounded });
184
+ }
97
185
  for (const [name, filter] of Object.entries(options.filters ?? {})) engine.registerFilter(name, filter);
186
+ for (const [name, filter] of Object.entries(engine.filters)) {
187
+ const handler = typeof filter === "function" ? filter : filter.handler;
188
+ const timed: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
189
+ const result: unknown = yield handler.apply(this, args);
190
+ this.context.renderLimit.check(performance.now());
191
+ return result;
192
+ };
193
+ engine.registerFilter(name, typeof filter === "function" ? timed : { ...filter, handler: timed });
194
+ }
98
195
  return engine;
99
196
  };
100
197
 
@@ -126,11 +223,27 @@ export const validateLiquidTemplate = (
126
223
  export const renderLiquidTemplate = (template: string, data: Record<string, unknown>, options: LiquidTemplateOptions = {}): string => {
127
224
  const valid = validateLiquidTemplate(template, options);
128
225
  if (!valid.ok) throw new Error(valid.error);
129
- const rendered = engineFor(options).parseAndRenderSync(template, data);
130
- if (byteLength(rendered) > (options.renderMaxBytes ?? RENDER_MAX_BYTES)) {
131
- throw new LiquidTemplateError("render_too_large", "Rendered template is too large");
226
+ const engine = engineFor(options);
227
+ const context = new Context(data, engine.options, { sync: true }, { liquid: engine });
228
+ const emitter = new BoundedEmitter(options.renderMaxBytes ?? RENDER_MAX_BYTES);
229
+ try {
230
+ toValueSync(engine.renderer.renderTemplates(engine.parse(template), context, emitter));
231
+ return emitter.buffer;
232
+ } catch (error) {
233
+ // LiquidJS wraps emitter and limiter failures in RenderError.originalError.
234
+ let original = error;
235
+ while (original instanceof LiquidError && original.originalError) original = original.originalError;
236
+ if (original instanceof LiquidTemplateError) throw original;
237
+ if (original instanceof AssertionError) {
238
+ if (original.message === "template render limit exceeded") {
239
+ throw new LiquidTemplateError("render_timeout", "Template rendering exceeded its time budget");
240
+ }
241
+ if (original.message === "memory alloc limit exceeded") {
242
+ throw new LiquidTemplateError("render_memory_limit", "Template rendering exceeded its memory limit");
243
+ }
244
+ }
245
+ throw error;
132
246
  }
133
- return rendered;
134
247
  };
135
248
 
136
249
  export const liquidTemplateVariables = (template: string, options: LiquidTemplateOptions = {}): string[] => {
@@ -0,0 +1,30 @@
1
+ import { useLocale } from "@k2b/ui";
2
+ import { Show } from "solid-js";
3
+ import { appBadgeCount } from "./app-badges";
4
+ import { railMessages } from "./rail-messages";
5
+
6
+ /** What a screen reader hears after the app name; empty without a count. Says no more than the "99+" shown. */
7
+ export const useAppBadgeDescription = (endpoint: () => string | undefined) => {
8
+ const locale = useLocale();
9
+ return () => {
10
+ const count = appBadgeCount(endpoint());
11
+ const t = railMessages.resolve([locale()]).t;
12
+ return count > 99 ? t.badgeMany : count > 0 ? t.badge({ count }) : "";
13
+ };
14
+ };
15
+
16
+ /**
17
+ * Count on the corner of an app icon. It is positioned over the icon and never
18
+ * takes layout space, so appearing, growing to "99+", or clearing moves
19
+ * nothing. Decorative: the link carries the description.
20
+ */
21
+ export function AppBadge(props: { endpoint?: string }) {
22
+ const count = () => appBadgeCount(props.endpoint);
23
+ return (
24
+ <Show when={count() > 0}>
25
+ <span class="cloud-app-badge" aria-hidden="true">
26
+ {count() > 99 ? "99+" : count()}
27
+ </span>
28
+ </Show>
29
+ );
30
+ }
@@ -3,6 +3,7 @@ import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
3
3
  import { createSignal, For, type JSX, Show } from "solid-js";
4
4
  import { Dynamic } from "solid-js/web";
5
5
  import type { CloudTheme } from "../shared/theme";
6
+ import { AppBadge, useAppBadgeDescription } from "./AppBadge";
6
7
  import { MobileProfileActions } from "./MobileProfileActions";
7
8
  import { platformMessages } from "./platform-messages";
8
9
  import { openRailEditor } from "./RailEditor";
@@ -17,6 +18,8 @@ export type AppLaunchpadApp = {
17
18
  href: string;
18
19
  description?: string;
19
20
  accent?: string;
21
+ /** The app's `nav.badge` endpoint. */
22
+ badge?: string;
20
23
  };
21
24
 
22
25
  export type AppLaunchpadLegalLink = {
@@ -119,39 +122,57 @@ export const AppLaunchpadPanel = (
119
122
  <section class="mb-5" aria-label={t().shortcuts}>
120
123
  <div class="flex flex-wrap justify-center gap-3">
121
124
  <For each={shortcuts()}>
122
- {(shortcut) => (
123
- <a
124
- onClick={follow}
125
- href={shortcut.href}
126
- class="group flex w-16 min-w-0 flex-col items-center gap-1 rounded-lg p-1 text-center focus-ui"
127
- >
128
- <span class="grid h-9 w-9 place-items-center rounded-xl bg-[var(--ui-hover)] text-lg transition-colors group-hover:bg-[var(--ui-active)]">
129
- <i class={shortcut.iconClass} aria-hidden="true" />
130
- </span>
131
- <span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">{shortcut.label}</span>
132
- </a>
133
- )}
125
+ {(shortcut) => {
126
+ const badge = useAppBadgeDescription(() => shortcut.badge);
127
+ return (
128
+ <a
129
+ onClick={follow}
130
+ href={shortcut.href}
131
+ class="group flex w-16 min-w-0 flex-col items-center gap-1 rounded-lg p-1 text-center focus-ui"
132
+ >
133
+ <span class="relative grid h-9 w-9 place-items-center rounded-xl bg-[var(--ui-hover)] text-lg transition-colors group-hover:bg-[var(--ui-active)]">
134
+ <i class={shortcut.iconClass} aria-hidden="true" />
135
+ <AppBadge endpoint={shortcut.badge} />
136
+ </span>
137
+ <span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">
138
+ {shortcut.label}
139
+ <Show when={badge()}>
140
+ <span class="sr-only">, {badge()}</span>
141
+ </Show>
142
+ </span>
143
+ </a>
144
+ );
145
+ }}
134
146
  </For>
135
147
  </div>
136
148
  </section>
137
149
  </Show>
138
150
  <div class="launchpad-apps-grid flex flex-wrap justify-center gap-x-4 gap-y-4 sm:gap-x-7 sm:gap-y-6">
139
151
  <For each={apps()}>
140
- {(app) => (
141
- <a
142
- onClick={follow}
143
- href={app.href}
144
- class="launchpad-app group flex w-[4.75rem] min-w-0 flex-col items-center gap-1.5 rounded-2xl p-1 text-center focus-ui sm:w-[6.25rem] sm:gap-2 sm:p-2"
145
- >
146
- <span
147
- class="app-icon grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
148
- style={appIconStyle(app)}
152
+ {(app) => {
153
+ const badge = useAppBadgeDescription(() => app.badge);
154
+ return (
155
+ <a
156
+ onClick={follow}
157
+ href={app.href}
158
+ class="launchpad-app group flex w-[4.75rem] min-w-0 flex-col items-center gap-1.5 rounded-2xl p-1 text-center focus-ui sm:w-[6.25rem] sm:gap-2 sm:p-2"
149
159
  >
150
- <i class={app.iconClass} />
151
- </span>
152
- <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">{app.label}</span>
153
- </a>
154
- )}
160
+ <span
161
+ class="app-icon relative grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
162
+ style={appIconStyle(app)}
163
+ >
164
+ <i class={app.iconClass} />
165
+ <AppBadge endpoint={app.badge} />
166
+ </span>
167
+ <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">
168
+ {app.label}
169
+ <Show when={badge()}>
170
+ <span class="sr-only">, {badge()}</span>
171
+ </Show>
172
+ </span>
173
+ </a>
174
+ );
175
+ }}
155
176
  </For>
156
177
  </div>
157
178
  <Show when={!apps().length && !shortcuts().length}>
@@ -0,0 +1,33 @@
1
+ import type { IslandErrorProps } from "@k2b/ssr";
2
+ import { i18n } from "@k2b/stdlib";
3
+ import { Button, Placeholder, useLocale } from "@k2b/ui";
4
+
5
+ const messages = i18n.define({
6
+ baseLocale: "en",
7
+ messages: {
8
+ en: { title: "This section could not be displayed.", retry: "Try again" },
9
+ de: { title: "Dieser Bereich konnte nicht angezeigt werden.", retry: "Erneut versuchen" },
10
+ },
11
+ });
12
+
13
+ /**
14
+ * Takes the place of an island or client component whose rendering failed.
15
+ * `defineApp` hands it to @k2b/ssr as `errorFallback`, so every island of every
16
+ * app gets it; the locale comes from `<html lang>`. "Try again" remounts the
17
+ * island with its original props.
18
+ */
19
+ export default function IslandError(props: IslandErrorProps) {
20
+ const locale = useLocale();
21
+ const t = () => messages.resolve([locale()]).t;
22
+ return (
23
+ <Placeholder
24
+ state="error"
25
+ title={t().title}
26
+ action={
27
+ <Button variant="secondary" onClick={() => props.reset()}>
28
+ {t().retry}
29
+ </Button>
30
+ }
31
+ />
32
+ );
33
+ }
@@ -61,6 +61,7 @@ function buildNavLinks(
61
61
  match: resolveNavMatch(app) ?? app.nav.href.split("?")[0] ?? app.nav.href,
62
62
  description: app.description,
63
63
  accent: app.appearance?.accent,
64
+ badge: app.nav.badge,
64
65
  } satisfies LayoutAppLink,
65
66
  }));
66
67
  const primary = links.filter((entry) => entry.section === "primary").map((entry) => entry.link);
@@ -75,6 +76,7 @@ function buildNavLinks(
75
76
  match: "/admin",
76
77
  description: t.platformAdministration,
77
78
  accent: undefined,
79
+ badge: undefined,
78
80
  });
79
81
  }
80
82
  return { primary, more };
@@ -166,6 +168,7 @@ export default function Layout(props: LayoutProps) {
166
168
  href: app.href,
167
169
  description: app.description,
168
170
  accent: app.accent,
171
+ badge: app.badge,
169
172
  }));
170
173
  const searchLinks = navigationSearchItems(
171
174
  runtime.apps,
@@ -95,11 +95,13 @@ const registerTopic = (topic: HelpTopic) => {
95
95
  /** Register an app's server-prepared Markdown manifest with the shared help UI. */
96
96
  export function LayoutHelpDocuments(props: LayoutHelpDocumentsProps) {
97
97
  onMount(() => {
98
- window.__cloudLayoutHelpPageBase = props.pageBase;
98
+ // Cleanup runs while this unmounts, when a parent `<Show>` may already have dropped the props' source.
99
+ const pageBase = props.pageBase;
100
+ window.__cloudLayoutHelpPageBase = pageBase;
99
101
  const disposers = props.documents.map((document) => registerTopic({ ...document, kind: "document" }));
100
102
  onCleanup(() => {
101
103
  disposers.forEach((dispose) => dispose());
102
- if (window.__cloudLayoutHelpPageBase === props.pageBase) delete window.__cloudLayoutHelpPageBase;
104
+ if (window.__cloudLayoutHelpPageBase === pageBase) delete window.__cloudLayoutHelpPageBase;
103
105
  });
104
106
  });
105
107
  return null;