@k2b/cloud 0.33.0 → 0.35.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 (68) 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 +130 -16
  8. package/src/ai/firecrawl-tools.ts +3 -30
  9. package/src/ai/resource-refs.ts +35 -3
  10. package/src/ai/system-prompt.ts +3 -2
  11. package/src/api/index.ts +2 -0
  12. package/src/api/mcp.ts +48 -17
  13. package/src/api/pwa.ts +7 -2
  14. package/src/api/resource-links.ts +43 -0
  15. package/src/api/widgets.ts +163 -82
  16. package/src/browser/CloudResourceSearch.tsx +6 -6
  17. package/src/browser/command-shortcuts.ts +2 -11
  18. package/src/browser/resource-picker.tsx +1 -0
  19. package/src/browser/resource-search.ts +7 -2
  20. package/src/browser/widgets.ts +23 -5
  21. package/src/capabilities/testing.ts +13 -1
  22. package/src/cli/capabilities.ts +61 -17
  23. package/src/cli/index.ts +2 -0
  24. package/src/cli/resource-links.ts +13 -0
  25. package/src/contracts/app.ts +7 -1
  26. package/src/contracts/capabilities.ts +25 -5
  27. package/src/contracts/registry.ts +3 -1
  28. package/src/contracts/resource-href.ts +7 -0
  29. package/src/contracts/widgets.ts +80 -6
  30. package/src/desktop/index.ts +3 -2
  31. package/src/desktop/solid.tsx +7 -5
  32. package/src/server/widget-request.ts +34 -5
  33. package/src/services/app-approval.ts +4 -2
  34. package/src/services/approvals/index.ts +40 -11
  35. package/src/services/approvals/list.ts +1 -0
  36. package/src/services/approvals/migrate.ts +10 -0
  37. package/src/services/network-security.ts +33 -9
  38. package/src/services/pdf/markdown.ts +11 -7
  39. package/src/services/pwa-devices.ts +68 -15
  40. package/src/shared/app-presentation.ts +49 -1
  41. package/src/shared/index.ts +1 -0
  42. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  43. package/src/shared/markdown/index.ts +8 -2
  44. package/src/shared/network-address.ts +46 -45
  45. package/src/ssr/Layout.tsx +3 -15
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  47. package/src/ssr/LayoutHelp.tsx +320 -183
  48. package/src/ssr/LayoutHelpTrigger.tsx +1 -3
  49. package/src/ssr/LayoutPreferences.tsx +15 -5
  50. package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
  51. package/src/ssr/MobileProfileActions.tsx +3 -8
  52. package/src/ssr/ProfilePreferences.tsx +8 -10
  53. package/src/ssr/ShellRuntime.island.tsx +1 -5
  54. package/src/ssr/ThemeCycleButton.tsx +46 -0
  55. package/src/ssr/app-appearance.ts +6 -1
  56. package/src/ssr/layout-help-url.ts +39 -0
  57. package/src/ssr/platform-messages.ts +0 -8
  58. package/src/ssr/preference-controller.ts +53 -19
  59. package/src/ssr/profile-actions.ts +12 -16
  60. package/src/ssr/profile-preferences-messages.ts +4 -2
  61. package/src/ssr/rail-navigation.ts +3 -0
  62. package/src/ssr/shell-data.tsx +2 -2
  63. package/src/styles/effects.css +13 -3
  64. package/src/styles/global.css +47 -12
  65. package/src/styles/input.css +0 -8
  66. package/src/styles/tokens.css +0 -2
  67. package/src/styles/utilities-navigation.css +10 -13
  68. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
@@ -1,17 +1,29 @@
1
- import type { DashboardWidgetSize, WidgetStreamLine } from "../contracts/widgets";
1
+ import type { DashboardWidgetSize, WidgetSettings, WidgetStreamLine } from "../contracts/widgets";
2
2
  import { LOCALE_HEADER } from "../shared/locale";
3
3
  import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
4
4
 
5
- export type { DashboardWidgetSize, WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
5
+ export type {
6
+ DashboardWidgetSize,
7
+ WidgetResponse,
8
+ WidgetSettingField,
9
+ WidgetSettings,
10
+ WidgetSettingsDeclaration,
11
+ WidgetSettingValue,
12
+ WidgetStreamLine,
13
+ WidgetStreamStatus,
14
+ } from "../contracts/widgets";
6
15
 
7
16
  /**
8
17
  * Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
9
18
  * `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
10
19
  * request, for example to retry one widget; without it Core asks every declared widget. `sizes` names the size to ask
11
20
  * a widget for; a widget without one, or with a size it does not offer, answers in its default size. Aborting `signal`
12
- * stops every widget Core still waits for. Rejects when the request fails or the stream breaks off.
21
+ * stops every widget Core still waits for. When `requests` is given, POST sends one invocation per entry with its
22
+ * settings, ignoring `keys` and `sizes`; start and widget lines identify entries by id. Rejects when the request fails
23
+ * or the stream breaks off.
13
24
  */
14
25
  export const streamWidgets = async (options: {
26
+ requests?: readonly { id: string; key: string; size?: DashboardWidgetSize; settings?: WidgetSettings }[];
15
27
  keys?: readonly string[];
16
28
  sizes?: Readonly<Record<string, DashboardWidgetSize>>;
17
29
  signal: AbortSignal;
@@ -19,11 +31,17 @@ export const streamWidgets = async (options: {
19
31
  onLine: (line: WidgetStreamLine) => void;
20
32
  }): Promise<void> => {
21
33
  const query = new URLSearchParams();
22
- for (const key of options.keys ?? []) query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
34
+ for (const key of options.requests ? [] : (options.keys ?? []))
35
+ query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
23
36
  const search = query.toString();
24
37
  const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
38
+ ...(options.requests ? { method: "POST", body: JSON.stringify({ widgets: options.requests }) } : {}),
25
39
  signal: options.signal,
26
- headers: { accept: NDJSON_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
40
+ headers: {
41
+ accept: NDJSON_CONTENT_TYPE,
42
+ [LOCALE_HEADER]: options.locale,
43
+ ...(options.requests ? { "content-type": "application/json" } : {}),
44
+ },
27
45
  });
28
46
  if (!response.ok) throw new Error(`Widgets failed with ${response.status}`);
29
47
  await readNdjsonLines(response, options.onLine, "Widgets");
@@ -1,5 +1,10 @@
1
1
  import { capabilityManifestEvolutionIssues, compileCapabilities } from "../_internal/capabilities";
2
- import type { CapabilityDefinitions, CapabilityManifest } from "../contracts/capabilities";
2
+ import {
3
+ type CapabilityDefinitions,
4
+ type CapabilityManifest,
5
+ type CapabilityResult,
6
+ CapabilitySemanticLinkSchema,
7
+ } from "../contracts/capabilities";
3
8
 
4
9
  /** Compile and validate the public manifest an app would register at startup. */
5
10
  export const compileCapabilityManifest = (appId: string, definitions: CapabilityDefinitions): CapabilityManifest =>
@@ -10,3 +15,10 @@ export const assertCapabilityManifestEvolution = (previous: CapabilityManifest,
10
15
  const issues = capabilityManifestEvolutionIssues(previous, next);
11
16
  if (issues.length > 0) throw new Error(`Breaking Capability manifest evolution:\n- ${issues.join("\n- ")}`);
12
17
  };
18
+
19
+ /** Assert that a reader result exposes a valid destination for its resource permalink. */
20
+ export const assertResourceOpenLink = (result: Pick<CapabilityResult<unknown>, "links">) => {
21
+ const link = result.links?.find((link) => link.rel === "open");
22
+ if (!link) throw new Error("Resource reader must return a top-level open link");
23
+ return CapabilitySemanticLinkSchema.parse(link);
24
+ };
@@ -6,6 +6,8 @@ import {
6
6
  CAPABILITY_MAX_RESULT_BYTES,
7
7
  CapabilityCatalogSchema,
8
8
  CapabilityErrorSchema,
9
+ type CapabilityManifest,
10
+ CapabilitySemanticLinkSchema,
9
11
  CapabilityStreamSchema,
10
12
  CloudResourceRefSchema,
11
13
  capabilityResultSchema,
@@ -14,6 +16,7 @@ import {
14
16
  } from "../contracts/capabilities";
15
17
  import { saveCapabilityStream } from "./capability-streams";
16
18
  import { arg, type CliInputFlagValue, type CloudCliContext, command, defineCliCommands, flag, readCliInput } from "./index";
19
+ import { cloudResourceUrl } from "./resource-links";
17
20
 
18
21
  const parseInput = async (input: CliInputFlagValue): Promise<unknown> => {
19
22
  const raw = await readCliInput(input, {
@@ -65,6 +68,26 @@ const readCapabilityJson = async (response: Response, maxBytes: number): Promise
65
68
  return parsed.data;
66
69
  };
67
70
 
71
+ const findCatalogApps = async (ctx: CloudCliContext, appIds: ReadonlySet<string>) => {
72
+ const wantedApps = new Set(appIds);
73
+ const apps: z.infer<typeof CapabilityCatalogSchema>["apps"] = [];
74
+ let cursor: string | undefined;
75
+ const seenCursors = new Set<string>();
76
+ while (wantedApps.size) {
77
+ const query = new URLSearchParams({ limit: "25" });
78
+ if (cursor) query.set("cursor", cursor);
79
+ const page = CapabilityCatalogSchema.parse(
80
+ await readCapabilityJson(await ctx.fetch(`/api/capabilities/v1/catalog?${query}`), CAPABILITY_MAX_CATALOG_BYTES),
81
+ );
82
+ for (const app of page.apps) if (wantedApps.delete(app.appId)) apps.push(app);
83
+ if (!wantedApps.size || !page.page.hasMore) break;
84
+ cursor = page.page.nextCursor;
85
+ if (seenCursors.has(cursor)) throw new Error("Capability catalog returned a repeated cursor.");
86
+ seenCursors.add(cursor);
87
+ }
88
+ return apps;
89
+ };
90
+
68
91
  const invoke = async (config: {
69
92
  ctx: CloudCliContext;
70
93
  kind: "queries" | "actions";
@@ -72,6 +95,7 @@ const invoke = async (config: {
72
95
  capabilityId: string;
73
96
  input?: CliInputFlagValue;
74
97
  inputValue?: unknown;
98
+ manifest?: CapabilityManifest;
75
99
  idempotencyKey?: string;
76
100
  }) => {
77
101
  const headers = new Headers({ "content-type": "application/json" });
@@ -113,7 +137,40 @@ const invoke = async (config: {
113
137
  }
114
138
  throw error;
115
139
  }
116
- printGenericResult(config.ctx, capabilityResultSchema(z.unknown()).parse(body));
140
+ const result = capabilityResultSchema(z.unknown()).parse(body);
141
+ const manifests = config.manifest ? [config.manifest] : [];
142
+ const wantedApps = new Set((result.refs ?? []).map(cloudResourceRefAppId));
143
+ for (const manifest of manifests) wantedApps.delete(manifest.appId);
144
+ if (wantedApps.size) {
145
+ try {
146
+ const apps = await findCatalogApps(config.ctx, wantedApps);
147
+ manifests.push(...apps.map((app) => app.manifest));
148
+ } catch {
149
+ config.ctx.error("The capability catalog is unavailable; some resource permalinks could not be added.");
150
+ }
151
+ }
152
+ const absoluteLinks = (value: unknown): unknown => {
153
+ if (Array.isArray(value)) return value.map(absoluteLinks);
154
+ if (!value || typeof value !== "object") return value;
155
+ const link = CapabilitySemanticLinkSchema.safeParse(value);
156
+ if (link.success) return { ...link.data, href: new URL(link.data.href, config.ctx.options.server).href };
157
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, absoluteLinks(nested)]));
158
+ };
159
+ printGenericResult(
160
+ config.ctx,
161
+ absoluteLinks({
162
+ ...result,
163
+ ...(result.refs
164
+ ? {
165
+ refs: result.refs.map((ref) =>
166
+ ref.id.isWellFormed() && manifests.some((manifest) => resolveCapabilityResourceReader(manifest, ref))
167
+ ? { ...ref, permalink: cloudResourceUrl(config.ctx, ref) }
168
+ : ref,
169
+ ),
170
+ }
171
+ : {}),
172
+ }),
173
+ );
117
174
  };
118
175
 
119
176
  export default defineCliCommands({
@@ -230,25 +287,12 @@ export default defineCliCommands({
230
287
  run: async ({ ctx, args }) => {
231
288
  const ref = CloudResourceRefSchema.parse({ type: args.type, id: args.id });
232
289
  const appId = cloudResourceRefAppId(ref);
233
- let cursor: string | undefined;
234
- const seenCursors = new Set<string>();
235
- let app: z.infer<typeof CapabilityCatalogSchema>["apps"][number] | undefined;
236
- do {
237
- const query = new URLSearchParams({ limit: "25" });
238
- if (cursor) query.set("cursor", cursor);
239
- const page = CapabilityCatalogSchema.parse(
240
- await readCapabilityJson(await ctx.fetch(`/api/capabilities/v1/catalog?${query}`), CAPABILITY_MAX_CATALOG_BYTES),
241
- );
242
- app = page.apps.find((candidate) => candidate.appId === appId);
243
- const nextCursor = page.page.hasMore ? page.page.nextCursor : undefined;
244
- if (nextCursor && seenCursors.has(nextCursor)) throw new Error("Capability catalog returned a repeated cursor.");
245
- if (nextCursor) seenCursors.add(nextCursor);
246
- cursor = nextCursor;
247
- } while (!app && cursor);
290
+ const apps = await findCatalogApps(ctx, new Set([appId]));
291
+ const app = apps.find((candidate) => candidate.appId === appId);
248
292
  if (!app) throw new Error(`Capability app ${appId} is unavailable.`);
249
293
  const reader = resolveCapabilityResourceReader(app.manifest, ref);
250
294
  if (!reader) throw new Error(`Cloud resource type ${ref.type} is unknown or has no reader.`);
251
- await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id } });
295
+ await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id }, manifest: app.manifest });
252
296
  },
253
297
  }),
254
298
  command("action", {
package/src/cli/index.ts CHANGED
@@ -100,3 +100,5 @@ export * from "./commands";
100
100
  export * from "./locale";
101
101
  export * from "./output";
102
102
  export * from "./plugin";
103
+
104
+ export * from "./resource-links";
@@ -0,0 +1,13 @@
1
+ import type { CloudResourceRef } from "../contracts/capabilities";
2
+ import { cloudResourceHref } from "../contracts/resource-href";
3
+ import type { CloudCliContext } from "./index";
4
+
5
+ export const cloudResourceUrl = (ctx: Pick<CloudCliContext, "options">, ref: CloudResourceRef): string =>
6
+ new URL(cloudResourceHref(ref), ctx.options.server).href;
7
+
8
+ /** The resource id must be the id its Type's reader accepts. */
9
+ export const withCloudResourcePermalink = <T extends { id: string }>(
10
+ ctx: Pick<CloudCliContext, "options">,
11
+ type: CloudResourceRef["type"],
12
+ resource: T,
13
+ ) => ({ ...resource, permalink: cloudResourceUrl(ctx, { type, id: resource.id }) });
@@ -2,7 +2,7 @@ import type { Sync } from "@k2b/sync";
2
2
  import type { PlatformPermission } from "./outgoing-mail";
3
3
  import type { AppRegistryHelpSummary } from "./registry";
4
4
  import type { Role } from "./shared";
5
- import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
5
+ import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
6
6
 
7
7
  /**
8
8
  * One link entry contributed by an app to the global legal/info footer
@@ -73,9 +73,12 @@ export type AppPresentationTranslation = {
73
73
  widgets?: Readonly<Record<string, AppWidgetTranslation>>;
74
74
  };
75
75
 
76
+ /** Translated widget presentation, keyed by stable widget, setting, and option identifiers. */
76
77
  export type AppWidgetTranslation = {
77
78
  title?: string;
78
79
  description?: string;
80
+ /** Setting presentation keyed by setting name, with option labels keyed by option value. */
81
+ settings?: Readonly<Record<string, { label?: string; placeholder?: string; options?: Readonly<Record<string, string>> }>>;
79
82
  };
80
83
 
81
84
  export type AppPresentationCatalog = {
@@ -163,6 +166,7 @@ export type AppMeta = {
163
166
  */
164
167
  export type AppNavBadge = { count: number };
165
168
 
169
+ /** A widget endpoint and its board presentation; the handler owns resource authorization. */
166
170
  export type WidgetEndpoint = {
167
171
  /** Unique-within-the-app id, e.g. "open-requests". */
168
172
  id: string;
@@ -179,6 +183,8 @@ export type WidgetEndpoint = {
179
183
  sizes?: readonly DashboardWidgetSize[];
180
184
  /** Size the widget starts in, one of `sizes`. Defaults to the largest offered size. */
181
185
  defaultSize?: DashboardWidgetSize;
186
+ /** Optional per-entry settings; handlers must work without them and authorize selected resources. */
187
+ settings?: WidgetSettingsDeclaration;
182
188
  /**
183
189
  * Put the widget on the default board of everyone who may see it and list it under "Suggested for you" in the
184
190
  * gallery. Suggest only widgets that are useful without any setup.
@@ -15,6 +15,8 @@ export const CAPABILITY_PROTOCOL_VERSION = 2 as const;
15
15
  export const CAPABILITY_MAX_REQUEST_BYTES = 256 * 1024;
16
16
  export const CAPABILITY_MAX_RESULT_BYTES = 256 * 1024;
17
17
  export const CAPABILITY_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
18
+ export const CAPABILITY_QUALIFIED_ID_MAX_LENGTH = 180;
19
+ export const CLOUD_RESOURCE_ID_MAX_LENGTH = 512;
18
20
 
19
21
  export const CAPABILITY_FRAMEWORK_ERROR_CODES = {
20
22
  appUnavailable: "APP_UNAVAILABLE",
@@ -75,18 +77,20 @@ export const CapabilityLocalIdSchema = z
75
77
  export const CapabilityQualifiedIdSchema = z
76
78
  .string()
77
79
  .min(3)
78
- .max(180)
80
+ .max(CAPABILITY_QUALIFIED_ID_MAX_LENGTH)
79
81
  .regex(CAPABILITY_QUALIFIED_ID_PATTERN, "Expected a namespaced capability id such as contacts.contact");
80
82
 
81
83
  export const CloudResourceRefSchema = z
82
84
  .object({
83
85
  type: CapabilityQualifiedIdSchema.describe("Namespaced resource type declared by the owning app."),
84
- id: z.string().min(1).max(512).describe("Stable app-owned resource identifier."),
86
+ id: z.string().min(1).max(CLOUD_RESOURCE_ID_MAX_LENGTH).describe("Stable app-owned resource identifier."),
85
87
  })
86
88
  .strict();
87
89
 
88
90
  export type CloudResourceRef = z.infer<typeof CloudResourceRefSchema>;
89
91
 
92
+ export { cloudResourceHref } from "./resource-href";
93
+
90
94
  export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
91
95
  title: z.string().min(1).max(500).optional().describe("Optional current user-facing resource label."),
92
96
  preview: z.string().max(2000).optional().describe("Optional short plain-text context for the resource."),
@@ -95,6 +99,13 @@ export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
95
99
 
96
100
  export type CloudResourceReference = z.infer<typeof CloudResourceReferenceSchema>;
97
101
 
102
+ // Consumer permalinks may encode all 512 UTF-16 units of an id (up to 4,608 characters).
103
+ export const CloudResourceHrefSchema = z.string().min(1).max(8192).regex(CLOUD_PATH_PATTERN);
104
+
105
+ export const LinkedCloudResourceReferenceSchema = CloudResourceReferenceSchema.extend({
106
+ href: z.union([CloudResourceHrefSchema, z.url({ protocol: /^https?$/ }).max(8192)]).optional(),
107
+ }).strict();
108
+
98
109
  export const CapabilitySemanticLinkSchema = z
99
110
  .object({
100
111
  rel: z.enum(["open", "edit", "status", "preview", "download"]).describe("Semantic relationship; clients decide how to present it."),
@@ -219,7 +230,10 @@ export const capabilityResultSchema = <T extends z.ZodType>(
219
230
  data,
220
231
  stream: CapabilityStreamSchema.optional(),
221
232
  summary: z.string().trim().min(1).max(500).optional(),
222
- refs: z.array(CloudResourceReferenceSchema).max(100).optional(),
233
+ refs: z
234
+ .array(options?.consumer ? LinkedCloudResourceReferenceSchema : CloudResourceReferenceSchema)
235
+ .max(100)
236
+ .optional(),
223
237
  page: CapabilityPageSchema.optional(),
224
238
  links: z.array(CapabilitySemanticLinkSchema).max(20).optional(),
225
239
  presentation: options?.consumer
@@ -241,8 +255,14 @@ export const capabilityResultSchema = <T extends z.ZodType>(
241
255
  }
242
256
  }) as unknown as z.ZodType<CapabilityResult<z.output<T>>>;
243
257
 
244
- export const capabilityResultJsonSchema = (dataSchema: Record<string, unknown>): Record<string, unknown> =>
245
- z.toJSONSchema(capabilityResultSchema(z.fromJSONSchema(structuredClone(dataSchema))), { io: "output" }) as Record<string, unknown>;
258
+ export const capabilityResultJsonSchema = (
259
+ dataSchema: Record<string, unknown>,
260
+ options?: { consumer?: boolean },
261
+ ): Record<string, unknown> =>
262
+ z.toJSONSchema(capabilityResultSchema(z.fromJSONSchema(structuredClone(dataSchema)), options), { io: "output" }) as Record<
263
+ string,
264
+ unknown
265
+ >;
246
266
 
247
267
  export const CapabilityErrorSchema = z
248
268
  .object({
@@ -2,7 +2,7 @@ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPre
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
3
  import type { PlatformPermission } from "./outgoing-mail";
4
4
  import type { Role } from "./shared";
5
- import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
5
+ import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
6
6
 
7
7
  /**
8
8
  * App-registry entry type. Populated internally by `defineApp()` + the
@@ -54,6 +54,8 @@ export type AppRegistryLegalLink = {
54
54
 
55
55
  /** A widget declaration as the registry carries it; see `WidgetEndpoint`. Older apps send only `id` and `path`. */
56
56
  export type AppRegistryWidget = {
57
+ /** Per-entry setting declaration, validated again when listed. */
58
+ settings?: WidgetSettingsDeclaration;
57
59
  id: string;
58
60
  /** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
59
61
  path: string;
@@ -0,0 +1,7 @@
1
+ import type { CloudResourceRef } from "./capabilities";
2
+
3
+ /**
4
+ * The permanent Cloud path of a resource. The fixed `~` keeps the ids `.` and `..` from being read as dot segments.
5
+ * This module stays free of runtime imports: the `cld` host loads it at startup.
6
+ */
7
+ export const cloudResourceHref = (ref: CloudResourceRef): string => `/open/${ref.type}/~${encodeURIComponent(ref.id)}`;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { CAPABILITY_QUALIFIED_ID_MAX_LENGTH, CLOUD_RESOURCE_ID_MAX_LENGTH, type CloudResourceRef } from "./capabilities";
2
3
 
3
4
  /**
4
5
  * Widget JSON contract — what an app's widget endpoint must return when the
@@ -64,6 +65,59 @@ export type DashboardWidgetSize = (typeof DASHBOARD_WIDGET_SIZES)[number];
64
65
  export const WIDGET_TITLE_MAX_LENGTH = 80;
65
66
  /** Longest declared widget description, in characters: one sentence in the widget gallery. */
66
67
  export const WIDGET_DESCRIPTION_MAX_LENGTH = 200;
68
+ /**
69
+ * Core signs `widget.read:<widgetId>`; the invocation token's OperationSchema limits operations to 200 characters
70
+ * matching /^[a-z][a-z0-9:._-]*$/ (services/identity/invocation-token.ts). Longer IDs or other characters cannot be invoked.
71
+ */
72
+ export const WIDGET_ID_MAX_LENGTH = 188;
73
+ /** `<appId>/<widgetId>`; the invocation token's audience rule limits app IDs to 80 characters. */
74
+ export const WIDGET_KEY_MAX_LENGTH = 80 + 1 + WIDGET_ID_MAX_LENGTH;
75
+
76
+ /** Maximum settings a widget can declare. */
77
+ export const WIDGET_SETTINGS_MAX_FIELDS = 4;
78
+ /** Maximum setting name length, matching /^[a-z][a-zA-Z0-9]{0,31}$/. */
79
+ export const WIDGET_SETTING_NAME_MAX_LENGTH = 32;
80
+ /** Maximum options in a choice setting; at least one is required. */
81
+ export const WIDGET_SETTING_MAX_OPTIONS = 6;
82
+ /** Maximum text setting length, also used when no maxLength is declared. */
83
+ export const WIDGET_SETTING_TEXT_MAX_LENGTH = 200;
84
+ /** Maximum board entries in one widget stream request. */
85
+ export const WIDGET_REQUEST_MAX_ENTRIES = 100;
86
+ /** Maximum board entry identifier length, in characters. */
87
+ export const WIDGET_REQUEST_MAX_ID_LENGTH = 120;
88
+ const jsonCodeUnitMaxBytes = "\\u001f".length;
89
+ const resourceRefMaxBytes =
90
+ '{"type":"","id":""}'.length + CAPABILITY_QUALIFIED_ID_MAX_LENGTH + CLOUD_RESOURCE_ID_MAX_LENGTH * jsonCodeUnitMaxBytes;
91
+ const settingMemberMaxBytes = '"":'.length + WIDGET_SETTING_NAME_MAX_LENGTH + resourceRefMaxBytes;
92
+ const settingsMaxBytes = "{}".length + WIDGET_SETTINGS_MAX_FIELDS * settingMemberMaxBytes + (WIDGET_SETTINGS_MAX_FIELDS - 1) * ",".length;
93
+ const sizeNameMaxBytes = Math.max(...DASHBOARD_WIDGET_SIZES.map((size) => size.length));
94
+ const requestEntryMaxBytes =
95
+ '{"id":"","key":"","size":"","settings":}'.length +
96
+ WIDGET_REQUEST_MAX_ID_LENGTH * jsonCodeUnitMaxBytes +
97
+ WIDGET_KEY_MAX_LENGTH +
98
+ sizeNameMaxBytes +
99
+ settingsMaxBytes;
100
+ /**
101
+ * Largest compact JSON POST body in UTF-8: 100 entries with maximum IDs, keys, size names and four settings.
102
+ * Free-form entry and resource IDs need up to six bytes per UTF-16 code unit (e.g. `\u001f`); keys, size names,
103
+ * setting names and resource types are ASCII. A resource reference is larger than 200 text characters at six bytes
104
+ * each. Include every object's punctuation and the commas between entries and settings.
105
+ */
106
+ export const WIDGET_REQUEST_MAX_BYTES =
107
+ '{"widgets":['.length + WIDGET_REQUEST_MAX_ENTRIES * requestEntryMaxBytes + (WIDGET_REQUEST_MAX_ENTRIES - 1) * ",".length + "]}".length;
108
+
109
+ /** A widget's editable setting; resource values carry identity, never authorization. */
110
+ export type WidgetSettingField =
111
+ | { kind: "choice"; label: string; options: readonly { label: string; value: string }[]; default?: string }
112
+ | { kind: "resource"; label: string; type: string }
113
+ | { kind: "toggle"; label: string; default?: boolean }
114
+ | { kind: "text"; label: string; placeholder?: string; maxLength?: number };
115
+ /** Settings keyed by short camelCase names, shared by discovery and request validation. */
116
+ export type WidgetSettingsDeclaration = Readonly<Record<string, WidgetSettingField>>;
117
+ /** A validated setting value; a handler must still authorize every resource reference. */
118
+ export type WidgetSettingValue = string | boolean | CloudResourceRef;
119
+ /** Validated widget settings; handlers must also work when values are missing. */
120
+ export type WidgetSettings = Readonly<Record<string, WidgetSettingValue>>;
67
121
 
68
122
  export const isDashboardWidgetSize = (value: unknown): value is DashboardWidgetSize =>
69
123
  DASHBOARD_WIDGET_SIZES.some((size) => size === value);
@@ -89,10 +143,15 @@ export const fitWidgetSize = (
89
143
  widget: { sizes: readonly DashboardWidgetSize[]; defaultSize: DashboardWidgetSize },
90
144
  ): DashboardWidgetSize => (isDashboardWidgetSize(requested) && widget.sizes.includes(requested) ? requested : widget.defaultSize);
91
145
 
92
- /** What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. */
146
+ /**
147
+ * What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. Settings are validated against the
148
+ * declaration; handlers must still authorize every chosen resource and work without settings.
149
+ */
93
150
  export type WidgetRequest = {
94
151
  /** The size the widget is shown in, always one the widget declares. */
95
152
  size: DashboardWidgetSize;
153
+ /** Declared, validated values with choice and toggle defaults filled. */
154
+ settings: WidgetSettings;
96
155
  };
97
156
 
98
157
  export const WidgetAccentSchema = z
@@ -237,12 +296,27 @@ export const widgetKey = (appId: string, widgetId: string): string => `${appId}/
237
296
  export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
238
297
 
239
298
  /**
240
- * One line of `GET /api/widgets/v1` (`application/x-ndjson`): `start` names the widgets Core asks, in registry
241
- * order; one `widget` line follows per widget in the order they finish; `done` closes the stream. Every widget named
242
- * in `start` gets exactly one line.
299
+ * One line of `GET` or `POST /api/widgets/v1` (`application/x-ndjson`). GET names widget keys in registry order; POST
300
+ * names entry ids in request order. One `widget` line follows per entry in the order they finish; `done` closes the
301
+ * stream. Every name in `start` gets exactly one line. Only POST widget lines include a board entry id.
243
302
  */
244
303
  export type WidgetStreamLine =
245
304
  | { type: "start"; widgets: string[] }
246
- | { type: "widget"; key: string; status: "ok"; widget: WidgetResponse; ms: number }
247
- | { type: "widget"; key: string; status: Exclude<WidgetStreamStatus, "ok">; ms: number }
305
+ | {
306
+ type: "widget";
307
+ /** Board entry id, set only for POST entries. */
308
+ id?: string;
309
+ key: string;
310
+ status: "ok";
311
+ widget: WidgetResponse;
312
+ ms: number;
313
+ }
314
+ | {
315
+ type: "widget";
316
+ /** Board entry id, set only for POST entries. */
317
+ id?: string;
318
+ key: string;
319
+ status: Exclude<WidgetStreamStatus, "ok">;
320
+ ms: number;
321
+ }
248
322
  | { type: "done"; status: "complete" | "partial" };
@@ -304,8 +304,9 @@ let sqlInstance: DesktopSql | null = null;
304
304
 
305
305
  const getSql = (): DesktopSql => {
306
306
  if (sqlInstance) return sqlInstance;
307
- const req = runtimeRequire();
308
- if (!req || typeof window !== "undefined") throw new Error("desktop.sql is only available in the Bun desktop process.");
307
+ // Checked before `runtimeRequire`: its `Function` probe would report a `script-src` violation under a CSP without 'unsafe-eval'.
308
+ const req = typeof window === "undefined" ? runtimeRequire() : null;
309
+ if (!req) throw new Error("desktop.sql is only available in the Bun desktop process.");
309
310
  const requireFn = req as (id: string) => unknown;
310
311
 
311
312
  const { Database } = requireFn("bun:sqlite") as { Database: new (path: string) => any };
@@ -1,5 +1,5 @@
1
1
  import { suppressTextSelection } from "@k2b/ui";
2
- import { deserialize as deserializeProps, serialize as serializeProps } from "seroval";
2
+ import { fromJSON, toJSON } from "seroval";
3
3
  import type { Component, JSX, ParentProps } from "solid-js";
4
4
  import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
5
5
  import { Dynamic, Portal } from "solid-js/web";
@@ -48,7 +48,7 @@ export const defineDesktopWindows = <Definitions extends Record<string, DesktopW
48
48
  ({
49
49
  kind: desktopWindowDescriptorKind,
50
50
  name,
51
- props: serializeProps(props ?? {}),
51
+ props: JSON.stringify(toJSON(props ?? {})),
52
52
  }) satisfies DesktopWindowDescriptor as unknown as JSX.Element;
53
53
  }
54
54
 
@@ -69,12 +69,14 @@ export function DesktopWindowHost<Definitions extends Record<string, DesktopWind
69
69
  const descriptor = createMemo(() => nativeDescriptor());
70
70
  const component = createMemo(() => {
71
71
  const current = descriptor();
72
- return current ? props.windows.__desktopWindowDefinitions[current.name] : null;
72
+ if (!current) return null;
73
+ // The name comes from the URL: only a registered window resolves, never an inherited member such as `constructor`.
74
+ const definitions = props.windows.__desktopWindowDefinitions;
75
+ return Object.hasOwn(definitions, current.name) ? definitions[current.name] : null;
73
76
  });
74
77
  const windowProps = createMemo(() => {
75
78
  const current = descriptor();
76
- if (!current) return {};
77
- return deserializeProps<Record<string, unknown>>(current.props);
79
+ return current && component() ? fromJSON<Record<string, unknown>>(JSON.parse(current.props)) : {};
78
80
  });
79
81
 
80
82
  return createMemo(() => {
@@ -1,8 +1,24 @@
1
1
  import type { Context } from "hono";
2
- import { type DashboardWidgetSize, fitWidgetSize, resolveWidgetSizes, type WidgetRequest } from "../contracts/widgets";
2
+ import { parseWidgetSettings } from "../_internal/widget-settings";
3
+ import {
4
+ type DashboardWidgetSize,
5
+ fitWidgetSize,
6
+ resolveWidgetSizes,
7
+ type WidgetRequest,
8
+ type WidgetSettingsDeclaration,
9
+ } from "../contracts/widgets";
3
10
 
4
11
  /** The query parameter Core uses to ask a widget handler for one size. */
5
12
  export const WIDGET_SIZE_PARAM = "size";
13
+ /** The query parameter Core uses to send one entry's validated settings. */
14
+ export const WIDGET_SETTINGS_PARAM = "settings";
15
+ /**
16
+ * Bun's default maximum request head is 16 KiB (a 17,000-character query was verified to return 431). The invocation
17
+ * JWT uses up to 4 KiB (IDENTITY_MAX_COMPACT_TOKEN_BYTES), and the path and other forwarded headers use about 1–2 KiB,
18
+ * leaving headroom for 8 KiB of URL-encoded settings. Core measures that encoding and never forwards more. A decoded
19
+ * value is never longer than its encoding, so the app never drops settings Core sends; longer input counts as missing.
20
+ */
21
+ export const WIDGET_SETTINGS_PARAM_MAX_LENGTH = 8 * 1024;
6
22
 
7
23
  const requests = new WeakMap<Request, WidgetRequest>();
8
24
 
@@ -12,9 +28,21 @@ const requests = new WeakMap<Request, WidgetRequest>();
12
28
  */
13
29
  export const setWidgetRequest = (
14
30
  c: Context,
15
- declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize },
31
+ declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize; settings?: WidgetSettingsDeclaration },
16
32
  ): WidgetRequest => {
17
- const request = { size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)) };
33
+ const param = c.req.query(WIDGET_SETTINGS_PARAM);
34
+ let raw: unknown;
35
+ if (param !== undefined && param.length <= WIDGET_SETTINGS_PARAM_MAX_LENGTH) {
36
+ try {
37
+ raw = JSON.parse(param);
38
+ } catch {
39
+ /* Malformed settings are treated as missing values. */
40
+ }
41
+ }
42
+ const request = {
43
+ size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)),
44
+ settings: parseWidgetSettings(declaration.settings, raw),
45
+ };
18
46
  requests.set(c.req.raw, request);
19
47
  return request;
20
48
  };
@@ -22,6 +50,7 @@ export const setWidgetRequest = (
22
50
  /**
23
51
  * What the dashboard asks this widget handler for. `size` is always one of the sizes the widget declares; return the
24
52
  * content that fits it, for example only the one number in `small`. Outside a dashboard invocation, such as a request
25
- * to the handler's own public route, the request is `large`, the height every widget had before sizes existed.
53
+ * to the handler's own public route, the request is `{ size: "large", settings: {} }`. Settings are validated against
54
+ * the declaration. The handler must still authorize every chosen resource on every call and work without settings.
26
55
  */
27
- export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large" };
56
+ export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large", settings: {} };
@@ -296,7 +296,8 @@ export const createAppApprovalService = (
296
296
  UPDATE auth.app_devices d SET action_wake_at=now(), action_wake_ref=${approvalId}::uuid
297
297
  WHERE d.id IN (
298
298
  SELECT device.id FROM auth.app_devices device JOIN approvals.requests r ON r.approver_user_id=device.user_id
299
- WHERE r.id=${approvalId}::uuid AND r.status='pending' AND r.expires_at > now()
299
+ JOIN auth.users u ON u.id=r.approver_user_id
300
+ WHERE r.id=${approvalId}::uuid AND r.status='pending' AND r.expires_at > now() AND r.approver_auth_epoch=u.auth_epoch
300
301
  AND EXISTS (SELECT 1 FROM ai.pending_actions p JOIN ai.turns t ON t.id=p.turn_id
301
302
  WHERE p.approval_id=r.id AND p.status='pending' AND t.run_config->>'signedInSession' IS NULL)
302
303
  AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
@@ -661,7 +662,8 @@ export const createAppApprovalService = (
661
662
  };
662
663
  const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
663
664
  if (!hold) return result((await read()).state);
664
- // Listen before the first read so a decision committed in between still wakes this request.
665
+ // Listen before the first read so a decision committed in between usually wakes this request.
666
+ // live() has no subscription-ready barrier, so the hint is best effort; the deadline re-read is the guarantee.
665
667
  const waiting = new AbortController();
666
668
  const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
667
669
  const stop = () => waiting.abort();