@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
@@ -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();
@@ -72,6 +72,8 @@ export type ApprovalRequest = {
72
72
  reviewDigest: string;
73
73
  status: ApprovalStatus;
74
74
  decidedVia: ApprovalDecisionChannel | null;
75
+ decidedDeviceId: string | null;
76
+ approverAuthEpoch: string | null;
75
77
  decidedAt: Date | null;
76
78
  consumedAt: Date | null;
77
79
  createdAt: Date;
@@ -83,11 +85,23 @@ const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "appr
83
85
  requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
84
86
  requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
85
87
  assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
88
+ decided_device_id AS "decidedDeviceId", approver_auth_epoch::text AS "approverAuthEpoch",
86
89
  decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
87
90
  created_at AS "createdAt", expires_at AS "expiresAt"`;
88
91
 
89
92
  const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
90
93
 
94
+ const lockDecider = async (tx: SQL, userId: string, deviceId: string | null) => {
95
+ // User before device before request: sign-out everywhere updates the user first, then devices.
96
+ const [user] = await tx<{ epoch: string }[]>`SELECT auth_epoch::text AS epoch FROM auth.users WHERE id=${userId}::uuid FOR SHARE`;
97
+ const [device] =
98
+ deviceId === null
99
+ ? []
100
+ : await tx`SELECT 1 FROM auth.app_devices
101
+ WHERE id=${deviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL FOR SHARE`;
102
+ return { epoch: user?.epoch ?? null, deviceActive: !!device };
103
+ };
104
+
91
105
  export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
92
106
  /**
93
107
  * Records the question once per requester reference. Asking again, for example after a worker restart, returns the
@@ -103,10 +117,10 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
103
117
  return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
104
118
  const [row] = await tx<{ id: string }[]>`
105
119
  INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
106
- requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
120
+ requester_title, subject_id, approver_user_id, approver_auth_epoch, assurance, sentence, details, args_digest, review_digest, expires_at)
107
121
  VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
108
122
  ${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
109
- ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
123
+ (SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
110
124
  ${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
111
125
  ON CONFLICT (requester_kind, requester_ref) DO NOTHING
112
126
  RETURNING id
@@ -115,6 +129,7 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
115
129
  const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
116
130
  WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
117
131
  AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
132
+ AND approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid)
118
133
  AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
119
134
  return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
120
135
  });
@@ -137,12 +152,9 @@ export const decide = async (
137
152
  db?: SQL,
138
153
  ): Promise<ApprovalDecisionResult> => {
139
154
  const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
140
- // Lock the device before the request so revocation and the decision serialize.
141
- if (actor.deviceId !== undefined) {
142
- const [device] = await tx`SELECT 1 FROM auth.app_devices
143
- WHERE id=${actor.deviceId}::uuid AND user_id=${actor.userId}::uuid AND revoked_at IS NULL FOR SHARE`;
144
- if (!device) return { ok: false, code: "NOT_FOUND" };
145
- }
155
+ // Lock the approver and device before the request so revocation and decisions serialize.
156
+ const decider = await lockDecider(tx, actor.userId, actor.deviceId ?? null);
157
+ if (actor.deviceId !== undefined && !decider.deviceActive) return { ok: false, code: "NOT_FOUND" };
146
158
  const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
147
159
  WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
148
160
  if (!row) return { ok: false, code: "NOT_FOUND" };
@@ -151,8 +163,9 @@ export const decide = async (
151
163
  const [won] = rejectOnly
152
164
  ? []
153
165
  : await tx<ApprovalRequest[]>`
154
- UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
166
+ UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_device_id=${actor.deviceId ?? null}::uuid, decided_at=now()
155
167
  WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
168
+ AND approver_auth_epoch=${decider.epoch}::bigint
156
169
  RETURNING ${columns()}
157
170
  `;
158
171
  if (won) {
@@ -178,7 +191,8 @@ export const decide = async (
178
191
  }
179
192
  if (row.status === "pending") {
180
193
  const [expired] = await tx<{ id: string }[]>`UPDATE approvals.requests SET status='expired'
181
- WHERE id=${id}::uuid AND status='pending' AND expires_at <= now() RETURNING id`;
194
+ WHERE id=${id}::uuid AND status='pending'
195
+ AND (expires_at <= now() OR approver_auth_epoch IS DISTINCT FROM ${decider.epoch}::bigint) RETURNING id`;
182
196
  return { ok: false, code: expired || !rejectOnly ? "EXPIRED" : "REJECT_ONLY" };
183
197
  }
184
198
  if (
@@ -194,7 +208,7 @@ export const decide = async (
194
208
 
195
209
  export type ApprovalConsumeResult =
196
210
  | { ok: true }
197
- | { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
211
+ | { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" | "REVOKED" };
198
212
  export const consume = async (
199
213
  id: string,
200
214
  input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
@@ -203,11 +217,26 @@ export const consume = async (
203
217
  const argsDigest = approvalArgsDigest(input.input),
204
218
  reviewDigest = approvalReviewDigest(input.review);
205
219
  return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
220
+ const [initial] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
221
+ WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}`;
222
+ if (!initial) return { ok: false, code: "NOT_FOUND" };
223
+ if (initial.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
224
+ if (initial.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
225
+ const decider = await lockDecider(tx, initial.approverUserId, initial.decidedDeviceId);
206
226
  const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
207
227
  WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
208
228
  if (!row) return { ok: false, code: "NOT_FOUND" };
209
229
  if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
210
230
  if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
231
+ if (
232
+ (row.decidedVia === "cloud_login" && row.decidedDeviceId === null) ||
233
+ (row.decidedDeviceId !== null && !decider.deviceActive) ||
234
+ row.approverAuthEpoch === null ||
235
+ row.approverAuthEpoch !== decider.epoch
236
+ ) {
237
+ await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
238
+ return { ok: false, code: "REVOKED" };
239
+ }
211
240
  if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
212
241
  await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
213
242
  return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
@@ -33,6 +33,7 @@ export type PendingApprovalRow = {
33
33
  // Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
34
34
  const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
35
35
  WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
36
+ AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
36
37
  AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
37
38
  AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
38
39
  const matches = (appId?: string, link?: string) => sql`
@@ -38,4 +38,14 @@ export const migrateApprovals = async (): Promise<void> => {
38
38
  END $$`.simple();
39
39
  await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
40
40
  await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
41
+ await sql`ALTER TABLE approvals.requests ADD COLUMN IF NOT EXISTS decided_device_id UUID`.simple();
42
+ // Backfill pending rows once at column creation; approved rows and older-release rows keep NULL and count as ended.
43
+ await sql`DO $$ BEGIN
44
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
45
+ WHERE table_schema='approvals' AND table_name='requests' AND column_name='approver_auth_epoch') THEN
46
+ ALTER TABLE approvals.requests ADD COLUMN approver_auth_epoch BIGINT;
47
+ UPDATE approvals.requests r SET approver_auth_epoch=u.auth_epoch FROM auth.users u
48
+ WHERE u.id=r.approver_user_id AND r.status='pending';
49
+ END IF;
50
+ END $$`.simple();
41
51
  };
@@ -127,19 +127,23 @@ hr { border: 0; border-top: 1px solid #d1d5db; margin: .9em 0; }
127
127
  };
128
128
 
129
129
  /**
130
- * Markdown info blocks print as the calm NoticeCard: a light tint per tone, no
131
- * border, and the preset's own text colour, so they stay readable on paper.
130
+ * Markdown callouts print with a light tint and no border. The inline icon
131
+ * and title use print-safe tone ink; the body keeps the preset's text colour.
132
132
  * The tints are a step stronger than on screen because paper and printers wash
133
133
  * them out. Every preset and application document shares them.
134
134
  */
135
135
  const INFO_BLOCK_PRINT_CSS = `
136
136
  .k2b-sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
137
137
  .k2b-notice-card { margin: 0 0 1em; padding: .85em 1.05em; border-radius: .8em; background: var(--notice-tint); break-inside: avoid; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
138
- .k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; }
139
- .k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; }
140
- .k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; }
141
- .k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; }
142
- .k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; }
138
+ .k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; --notice-ink: #4b5563; }
139
+ .k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; --notice-ink: #1d4ed8; }
140
+ .k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; --notice-ink: #15803d; }
141
+ .k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; --notice-ink: #92400e; }
142
+ .k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; --notice-ink: #b91c1c; }
143
+ .k2b-callout { display: grid; grid-template-columns: 1.25em minmax(0, 1fr); column-gap: .7em; }
144
+ .k2b-callout > * { grid-column: 2; }
145
+ .k2b-callout > .k2b-callout__icon { grid-column: 1; grid-row: 1 / span 2; width: 1.25em; height: 1lh; color: var(--notice-ink); }
146
+ .k2b-callout > .k2b-notice-card__title { color: var(--notice-ink); }
143
147
  .k2b-notice-card__title { margin: 0 0 .25em; font-weight: 600; }
144
148
  .k2b-notice-card__body > :first-child { margin-top: 0; }
145
149
  .k2b-notice-card__body > :last-child { margin-bottom: 0; }