@k2b/cloud 0.33.0 → 0.34.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 (65) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +1 -0
  4. package/src/_internal/registry.ts +10 -1
  5. package/src/_internal/widget-declarations.ts +16 -3
  6. package/src/_internal/widget-settings.ts +93 -0
  7. package/src/ai/capabilities.ts +8 -5
  8. package/src/ai/resource-refs.ts +35 -3
  9. package/src/ai/system-prompt.ts +3 -2
  10. package/src/api/index.ts +2 -0
  11. package/src/api/mcp.ts +48 -17
  12. package/src/api/pwa.ts +7 -2
  13. package/src/api/resource-links.ts +43 -0
  14. package/src/api/widgets.ts +163 -82
  15. package/src/browser/CloudResourceSearch.tsx +6 -6
  16. package/src/browser/command-shortcuts.ts +2 -11
  17. package/src/browser/resource-picker.tsx +1 -0
  18. package/src/browser/resource-search.ts +7 -2
  19. package/src/browser/widgets.ts +23 -5
  20. package/src/capabilities/testing.ts +13 -1
  21. package/src/cli/capabilities.ts +61 -17
  22. package/src/cli/index.ts +2 -0
  23. package/src/cli/resource-links.ts +13 -0
  24. package/src/contracts/app.ts +7 -1
  25. package/src/contracts/capabilities.ts +25 -5
  26. package/src/contracts/registry.ts +3 -1
  27. package/src/contracts/resource-href.ts +7 -0
  28. package/src/contracts/widgets.ts +80 -6
  29. package/src/desktop/index.ts +3 -2
  30. package/src/desktop/solid.tsx +7 -5
  31. package/src/server/widget-request.ts +34 -5
  32. package/src/services/app-approval.ts +4 -2
  33. package/src/services/approvals/index.ts +40 -11
  34. package/src/services/approvals/list.ts +1 -0
  35. package/src/services/approvals/migrate.ts +10 -0
  36. package/src/services/pdf/markdown.ts +11 -7
  37. package/src/services/pwa-devices.ts +68 -15
  38. package/src/shared/app-presentation.ts +49 -1
  39. package/src/shared/index.ts +1 -0
  40. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  41. package/src/shared/markdown/index.ts +8 -2
  42. package/src/ssr/Layout.tsx +3 -15
  43. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  44. package/src/ssr/LayoutHelp.tsx +319 -183
  45. package/src/ssr/LayoutHelpTrigger.tsx +1 -3
  46. package/src/ssr/LayoutPreferences.tsx +15 -5
  47. package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
  48. package/src/ssr/MobileProfileActions.tsx +3 -8
  49. package/src/ssr/ProfilePreferences.tsx +8 -10
  50. package/src/ssr/ShellRuntime.island.tsx +1 -5
  51. package/src/ssr/ThemeCycleButton.tsx +46 -0
  52. package/src/ssr/app-appearance.ts +6 -1
  53. package/src/ssr/layout-help-url.ts +39 -0
  54. package/src/ssr/platform-messages.ts +0 -8
  55. package/src/ssr/preference-controller.ts +53 -19
  56. package/src/ssr/profile-actions.ts +12 -16
  57. package/src/ssr/profile-preferences-messages.ts +4 -2
  58. package/src/ssr/rail-navigation.ts +3 -0
  59. package/src/ssr/shell-data.tsx +2 -2
  60. package/src/styles/effects.css +13 -3
  61. package/src/styles/global.css +27 -1
  62. package/src/styles/input.css +0 -8
  63. package/src/styles/tokens.css +0 -2
  64. package/src/styles/utilities-navigation.css +10 -13
  65. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
@@ -1,6 +1,7 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { type SQL, sql } from "bun";
3
3
  import type { Context } from "hono";
4
+ import { lazySync } from "../_internal/process-sync";
4
5
  import { PWA_LIMITS as limits, type PwaDeviceView, type PwaErrorCode, type PwaPairingState, type PwaPlatform } from "../contracts/pwa";
5
6
  import { isAccountCategoryAllowed } from "./account-category-policy";
6
7
  import { audit } from "./audit";
@@ -30,6 +31,13 @@ const fail = (code: PwaErrorCode, status: PwaError["status"], attemptsLeft?: num
30
31
 
31
32
  /** The initiating web session of a pairing. */
32
33
  export type PwaWebActor = { userId: string; sid: string };
34
+ /** Best-effort wake-ups for browsers waiting on a pairing claim. Postgres stays authoritative. */
35
+ export type PwaPairingClaimHints = {
36
+ /** Call after the claim commits. Must not throw or delay the caller. */
37
+ publish(pairingId: string): void;
38
+ /** Settles on the first hint for this pairing, or when `signal` aborts. */
39
+ wait(pairingId: string, signal: AbortSignal): Promise<void>;
40
+ };
33
41
  /** Administration of another account's phones needs current admin authority. */
34
42
  export type PwaDeviceAdministrator = { userId: string; admin: boolean };
35
43
  export type PwaRevocationReason = "user" | "unpaired" | "admin" | "replaced" | "account_expired";
@@ -87,6 +95,25 @@ type UserRow = {
87
95
  };
88
96
 
89
97
  const log = logger("pwa-devices");
98
+ const claimTopic = lazySync((sync) =>
99
+ sync.topic<null>({
100
+ id: "cloud:pwa-pairing-claims",
101
+ owner: "cloud",
102
+ retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
103
+ maxPayloadBytes: 2000,
104
+ }),
105
+ );
106
+ /** Core NATS broadcast reaches the waiting browser on any replica. */
107
+ export const syncPairingClaimHints: PwaPairingClaimHints = {
108
+ publish: (pairingId) => {
109
+ void Promise.resolve()
110
+ .then(() => claimTopic().publish({ tenantId: pairingId, data: null }))
111
+ .catch((error) => log.warn("App pairing claim hint failed", { error: error instanceof Error ? error.name : "UnknownError" }));
112
+ },
113
+ wait: async (pairingId, signal) => {
114
+ for await (const _event of claimTopic().live({ tenantId: pairingId, signal })) return;
115
+ },
116
+ };
90
117
  const iso = (value: Date | string) => new Date(value).toISOString();
91
118
  const passed = (value: Date | string) => new Date(value).getTime() <= Date.now();
92
119
  const DEVICE_KEY = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43})$/;
@@ -243,7 +270,7 @@ export type PwaCompletion =
243
270
  | { state: "paired"; credentials: PwaCredentials | null; platform: PwaPlatform }
244
271
  | { state: "waiting"; code: string; account: { name: string }; expiresAt: string };
245
272
 
246
- export const createPwaDeviceService = () => {
273
+ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairingClaimHints) => {
247
274
  const appSession = (deviceId: string) => ({ ttlSeconds: limits.sessionSeconds, pwaDeviceId: deviceId, recordLogin: false });
248
275
 
249
276
  /**
@@ -334,21 +361,46 @@ export const createPwaDeviceService = () => {
334
361
  },
335
362
 
336
363
  /** Never returns the code: the person types what the phone shows. */
337
- inspectPairing: async (actor: PwaWebActor, id: string) => {
338
- const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
339
- SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
340
- WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
341
- `;
342
- if (!p) return fail("NOT_FOUND", 404);
343
- if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
344
- return fail("EXPIRED", 410);
345
- return {
346
- state: p.state,
347
- claimUntil: iso(p.claim_until),
348
- expiresAt: iso(p.expires_at),
349
- device: p.state === "pending" || !p.device_name || !p.platform ? null : { name: p.device_name, platform: p.platform },
350
- attemptsLeft: Math.max(0, limits.confirmAttempts - p.failed_attempts),
364
+ inspectPairing: async (actor: PwaWebActor, id: string, hold?: { ms: number; signal?: AbortSignal }) => {
365
+ const read = async (validateInitiator = false) => {
366
+ const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
367
+ SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
368
+ WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
369
+ `;
370
+ if (!p) return fail("NOT_FOUND", 404);
371
+ if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
372
+ return fail("EXPIRED", 410);
373
+ if (validateInitiator && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
374
+ return {
375
+ state: p.state,
376
+ claimUntil: iso(p.claim_until),
377
+ expiresAt: iso(p.expires_at),
378
+ device: p.state === "pending" || !p.device_name || !p.platform ? null : { name: p.device_name, platform: p.platform },
379
+ attemptsLeft: Math.max(0, limits.confirmAttempts - p.failed_attempts),
380
+ };
351
381
  };
382
+ if (!hold) return read();
383
+ // Listen before the first read so a claim committed in between usually wakes this request.
384
+ // live() has no subscription-ready barrier, so the hint is best effort; the deadline re-read is the guarantee.
385
+ // The read after the hold also checks the initiating web session, which may have ended meanwhile.
386
+ const waiting = new AbortController();
387
+ const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
388
+ const stop = () => waiting.abort();
389
+ hold.signal?.addEventListener("abort", stop, { once: true });
390
+ if (hold.signal?.aborted) stop();
391
+ const hint = hints.wait(id, waiting.signal).catch(() => {});
392
+ let timer: ReturnType<typeof setTimeout> | undefined;
393
+ try {
394
+ const first = await read();
395
+ if (first.state !== "pending") return first;
396
+ timer = setTimeout(stop, Math.min(hold.ms, new Date(first.claimUntil).getTime() - Date.now()));
397
+ await Promise.race([hint, ended]);
398
+ return await read(true);
399
+ } finally {
400
+ clearTimeout(timer);
401
+ hold.signal?.removeEventListener("abort", stop);
402
+ stop();
403
+ }
352
404
  },
353
405
 
354
406
  confirmPairing: async (actor: PwaWebActor, id: string, code: string) => {
@@ -438,6 +490,7 @@ export const createPwaDeviceService = () => {
438
490
  WHERE id = ${p.id}::uuid`;
439
491
  return { account: { name: accountName(user) }, expiresAt: p.expires_at };
440
492
  });
493
+ hints.publish(snapshot.id);
441
494
  return { code, account: result.account, expiresAt: iso(result.expiresAt), completionSecret };
442
495
  },
443
496
 
@@ -26,7 +26,22 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
26
26
  widgets: Object.fromEntries(
27
27
  [...new Set([...Object.keys(current.widgets ?? {}), ...Object.keys(next.widgets ?? {})])].map((id) => [
28
28
  id,
29
- { ...current.widgets?.[id], ...next.widgets?.[id] },
29
+ {
30
+ ...current.widgets?.[id],
31
+ ...next.widgets?.[id],
32
+ settings: Object.fromEntries(
33
+ [...new Set([...Object.keys(current.widgets?.[id]?.settings ?? {}), ...Object.keys(next.widgets?.[id]?.settings ?? {})])].map(
34
+ (name) => [
35
+ name,
36
+ {
37
+ ...current.widgets?.[id]?.settings?.[name],
38
+ ...next.widgets?.[id]?.settings?.[name],
39
+ options: { ...current.widgets?.[id]?.settings?.[name]?.options, ...next.widgets?.[id]?.settings?.[name]?.options },
40
+ },
41
+ ],
42
+ ),
43
+ ),
44
+ },
30
45
  ]),
31
46
  ),
32
47
  };
@@ -65,6 +80,39 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
65
80
  ...widget,
66
81
  title: translation.widgets?.[widget.id]?.title ?? widget.title,
67
82
  description: translation.widgets?.[widget.id]?.description ?? widget.description,
83
+ ...(widget.settings && typeof widget.settings === "object" && !Array.isArray(widget.settings)
84
+ ? {
85
+ settings: Object.fromEntries(
86
+ Object.entries(widget.settings).map(([name, field]) => {
87
+ if (!field || typeof field !== "object") return [name, field];
88
+ const translated = translation.widgets?.[widget.id]?.settings?.[name];
89
+ return [
90
+ name,
91
+ {
92
+ ...field,
93
+ label: translated?.label ?? field.label,
94
+ ...(field.kind === "text" ? { placeholder: translated?.placeholder ?? field.placeholder } : {}),
95
+ ...(field.kind === "choice" && Array.isArray(field.options)
96
+ ? {
97
+ options: field.options.map((option) =>
98
+ option && typeof option === "object"
99
+ ? {
100
+ ...option,
101
+ label:
102
+ translated?.options && Object.hasOwn(translated.options, option.value)
103
+ ? (translated.options[option.value] ?? option.label)
104
+ : option.label,
105
+ }
106
+ : option,
107
+ ),
108
+ }
109
+ : {}),
110
+ },
111
+ ];
112
+ }),
113
+ ),
114
+ }
115
+ : {}),
68
116
  })),
69
117
  };
70
118
  };
@@ -44,6 +44,7 @@ export * from "./locale";
44
44
  export * from "./locale-preference";
45
45
  export * from "./login-method";
46
46
  export { markdown } from "./markdown";
47
+ export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
47
48
  export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
48
49
  export { createProgressValue, evaluateFormula, formatValue, isFormula, isTotalRow, parseProgressValue } from "./markdown/formula";
49
50
  export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
@@ -0,0 +1,37 @@
1
+ import { MARKDOWN_CALLOUT_ICON_ATTRIBUTES } from "@k2b/ui";
2
+ import type sanitizeHtml from "sanitize-html";
3
+
4
+ /** Fresh sanitize-html options for one document: SVG is limited to fixed callout icons. */
5
+ export const markdownCalloutSanitizeOptions = () => {
6
+ const elements: { inIcon: boolean; discard: boolean }[] = [];
7
+ let closed: { inIcon: boolean; discard: boolean } | undefined;
8
+ return {
9
+ allowedTags: ["svg", "path"],
10
+ allowedAttributes: { svg: Object.keys(MARKDOWN_CALLOUT_ICON_ATTRIBUTES), path: ["d"] },
11
+ // onOpenTag sees raw attributes before transforms; discarded SVG must keep ordinary HTML and text.
12
+ onOpenTag: (tag, attrs) => {
13
+ const parent = elements.at(-1);
14
+ elements.push({
15
+ inIcon: !!parent?.inIcon || (tag === "svg" && attrs.class === MARKDOWN_CALLOUT_ICON_ATTRIBUTES.class),
16
+ discard: !!parent?.discard || (!!parent?.inIcon && tag !== "path"),
17
+ });
18
+ },
19
+ // sanitize-html closes a tag before calling exclusiveFilter for that element.
20
+ onCloseTag: () => {
21
+ closed = elements.pop();
22
+ },
23
+ // excludeTag preserves ordinary content, including after an unclosed author SVG.
24
+ exclusiveFilter: ({ tag }) => closed?.discard || ((tag === "svg" || tag === "path") && !closed?.inIcon ? "excludeTag" : false),
25
+ textFilter: (text) => (elements.at(-1)?.inIcon ? "" : text),
26
+ transformTags: {
27
+ svg: (tagName) => ({
28
+ tagName,
29
+ attribs: { ...MARKDOWN_CALLOUT_ICON_ATTRIBUTES },
30
+ }),
31
+ path: (tagName, attrs) => ({
32
+ tagName,
33
+ attribs: Object.fromEntries(Object.entries(attrs).filter(([name]) => name === "d")),
34
+ }),
35
+ },
36
+ } satisfies sanitizeHtml.IOptions;
37
+ };
@@ -8,6 +8,7 @@
8
8
  import { markdownInfoBlocks } from "@k2b/ui";
9
9
  import { Marked, type MarkedExtension } from "marked";
10
10
  import sanitizeHtml from "sanitize-html";
11
+ import { markdownCalloutSanitizeOptions } from "./callout-sanitizer";
11
12
  import { markdownClient } from "./client";
12
13
  import { codeExtension, plainCodeExtension } from "./extensions/code";
13
14
  import { guidedHelpExtension } from "./extensions/guided-help";
@@ -73,8 +74,10 @@ export type MarkdownRenderOptions = {
73
74
  locale?: string;
74
75
  };
75
76
 
76
- const sanitizeRenderedHtml = (html: string): string =>
77
- sanitizeHtml(html, {
77
+ const sanitizeRenderedHtml = (html: string): string => {
78
+ const callouts = markdownCalloutSanitizeOptions();
79
+ return sanitizeHtml(html, {
80
+ ...callouts,
78
81
  allowedTags: [
79
82
  ...sanitizeHtml.defaults.allowedTags,
80
83
  "annotation",
@@ -101,6 +104,7 @@ const sanitizeRenderedHtml = (html: string): string =>
101
104
  "mtext",
102
105
  "semantics",
103
106
  "span",
107
+ ...callouts.allowedTags,
104
108
  "sub",
105
109
  "sup",
106
110
  "table",
@@ -129,6 +133,7 @@ const sanitizeRenderedHtml = (html: string): string =>
129
133
  input: ["checked", "class", "disabled", "type"],
130
134
  math: ["xmlns"],
131
135
  pre: ["class"],
136
+ ...callouts.allowedAttributes,
132
137
  span: ["aria-hidden", "class", "style", "title"],
133
138
  },
134
139
  allowedSchemes: ["http", "https", "mailto", "tel", "note", "attach"],
@@ -150,6 +155,7 @@ const sanitizeRenderedHtml = (html: string): string =>
150
155
  },
151
156
  },
152
157
  });
158
+ };
153
159
 
154
160
  /**
155
161
  * Render markdown to HTML for server-side display.
@@ -11,9 +11,8 @@ import { getDateConfig } from "../server/time";
11
11
  import { dates } from "../shared";
12
12
  import { readThemePreferenceFromCookieHeader } from "../shared/theme";
13
13
  import AnnouncementBanners from "./AnnouncementBanners";
14
- import { appAppearanceStyle } from "./app-appearance";
14
+ import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
15
15
  import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
16
- import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
17
16
  import { getLayoutHelp } from "./help";
18
17
  import LayoutFooter from "./LayoutFooter";
19
18
  import LayoutHeader from "./LayoutHeader";
@@ -151,7 +150,7 @@ export default function Layout(props: LayoutProps) {
151
150
  const user = c.get("user");
152
151
  const pathname = new URL(c.req.raw.url).pathname;
153
152
  const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
154
- const currentApp = currentRoute?.app;
153
+ const currentApp = resolveCurrentApp(runtime.apps, pathname);
155
154
  const registeredHelp = getLayoutHelp(c);
156
155
  const runtimeHelp = registeredHelp ? { documents: registeredHelp.documents, pageBase: registeredHelp.pageBase } : undefined;
157
156
  const workspaceLayout = resolveAppWorkspaceLayoutForSidebar(
@@ -164,17 +163,6 @@ export default function Layout(props: LayoutProps) {
164
163
  const searchLinks = Object.fromEntries(
165
164
  runtime.apps.flatMap((app) => (visibleAppIds.has(app.id) && app.searchLinks?.length ? [[app.id, app.searchLinks] as const] : [])),
166
165
  );
167
- const searchHelpApps: GlobalSearchHelpApp[] = runtime.apps
168
- .filter((app) => (app.searchTags?.length ?? 0) > 0)
169
- .map((app) => ({
170
- appId: app.id,
171
- appName: app.name,
172
- appIcon: app.icon,
173
- help: app.searchHelp,
174
- tags: [...new Set((app.searchTags ?? []).map((tag) => tag.toLowerCase()))],
175
- tagHelp: [...(app.searchTagHelp ?? [])],
176
- }))
177
- .sort((a, b) => a.appName.localeCompare(b.appName));
178
166
  const settings = c.get("settings");
179
167
  const announcements = c.get("announcements");
180
168
  const appName = settings?.app?.name || "Cloud";
@@ -223,7 +211,7 @@ export default function Layout(props: LayoutProps) {
223
211
  theme,
224
212
  accent: currentApp?.appearance?.accent,
225
213
  searchLinks,
226
- searchHelpApps,
214
+ appId: currentApp?.id,
227
215
  };
228
216
  if (focusMode) {
229
217
  return (
@@ -0,0 +1,43 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import type { HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
4
+ import { LayoutHelpPage } from "./LayoutHelp";
5
+
6
+ const documents: HelpDocumentManifest[] = Array.from({ length: 8 }, (_, index) => ({
7
+ id: `topic-${index}`,
8
+ title: `Topic ${index}`,
9
+ description: "Help browser fixture",
10
+ order: index,
11
+ url: `/api/help/topic-${index}`,
12
+ searchUrl: "/api/help/search",
13
+ }));
14
+ const html = Array.from(
15
+ { length: 8 },
16
+ (_, index) =>
17
+ `<h2 id="section-${index}" class="help-section-title" data-help-icon="ti ti-point">Section ${index}</h2>${Array.from({ length: 18 }, () => "<p>Read this guidance to finish the task and understand how the app works.</p>").join("")}`,
18
+ ).join("");
19
+ window.fetch = Object.assign(
20
+ async (input: URL | RequestInfo) => {
21
+ await new Promise((resolve) => setTimeout(resolve, 80));
22
+ const path = String(input);
23
+ if (path.includes("/search")) return Response.json({ locale: "en", ids: ["topic-0", "topic-1"] });
24
+ const id = path.split("/").at(-1) ?? "topic-0";
25
+ const payload: HelpDocumentPayload = { id, locale: "en", title: id, markdown: "Help", html };
26
+ return Response.json(payload);
27
+ },
28
+ { preconnect: () => {} },
29
+ );
30
+ const main = document.querySelector<HTMLElement>(".layout-content-main");
31
+ if (!main) throw new Error("Missing Layout main");
32
+ const embedded = new URL(location.href).searchParams.has("embedded");
33
+ const host = document.createElement("div");
34
+ if (embedded) host.style.cssText = "width:360px;height:700px";
35
+ main.append(host);
36
+ render(
37
+ () => (
38
+ <LocaleProvider locale="en">
39
+ <LayoutHelpPage documents={documents} embedded={embedded} pageBase="/app/spaces/help" />
40
+ </LocaleProvider>
41
+ ),
42
+ host,
43
+ );