@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -0,0 +1,99 @@
1
+ import {
2
+ HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH,
3
+ HELP_SHORTCUT_ID_MAX_LENGTH,
4
+ HELP_SHORTCUT_KEYS_MAX_LENGTH,
5
+ HELP_SHORTCUT_TITLE_MAX_LENGTH,
6
+ HELP_START_SHORTCUT_LIMIT,
7
+ type HelpShortcut,
8
+ } from "../ssr/layout-help-start";
9
+ import type { PanelContextMessage, PanelPageContext } from "./panel-context";
10
+
11
+ /**
12
+ * The zod-free half of the panel context contract, for code on every page: islands, the provider runtime and the
13
+ * slot markup must not pull the schema into their first load (shell and Mail first-load guards). The zod schema in
14
+ * ./panel-context stays the contract; a parity test keeps the check below at least as strict.
15
+ */
16
+ export const PANEL_CONTEXT_PARAM = "cloud_panel_context";
17
+
18
+ /** Core is the platform's own surface. Every other provider asks once per app. */
19
+ export const panelProviderNeedsConsent = (appId: string): boolean => appId !== "core";
20
+
21
+ /**
22
+ * Only application-owned same-origin paths. The context must already be valid: Layout and the slot controller build
23
+ * it from validated parts. Null also removes a caller-supplied reserved parameter.
24
+ */
25
+ export function withPanelContext(href: string, context: PanelPageContext | null): string {
26
+ // The parsed result decides, not the raw text: a query may hold a raw backslash (browsers keep it), while
27
+ // `/\\host` or `/\t/host` resolve to another origin and `/.//host` normalises to a protocol-relative path.
28
+ const base = "https://panel.invalid";
29
+ const url = href.startsWith("/") ? new URL(href, base) : null;
30
+ if (!url || url.origin !== base || url.pathname.startsWith("//")) throw new Error("Expected a same-origin panel path");
31
+ url.searchParams.delete(PANEL_CONTEXT_PARAM);
32
+ if (context) url.searchParams.set(PANEL_CONTEXT_PARAM, JSON.stringify(context));
33
+ return `${url.pathname}${url.search}${url.hash}`;
34
+ }
35
+
36
+ const HELP_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
37
+ const APP_ID = /^[a-z][a-z0-9-]*$/;
38
+ const QUALIFIED_ID = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
39
+ // RFC 9562 versions 1 to 8 with the RFC variant; the shell always uses crypto.randomUUID().
40
+ const PAGE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
41
+ // Exact keys, including symbol and non-enumerable ones, like the other panel messages.
42
+ const exactKeys = (value: unknown, required: string[], optional: string[] = []): value is Record<string, unknown> => {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
44
+ const keys = Reflect.ownKeys(value);
45
+ return (
46
+ required.every((key) => Object.hasOwn(value, key)) &&
47
+ keys.every((key) => typeof key === "string" && (required.includes(key) || optional.includes(key)))
48
+ );
49
+ };
50
+ const text = (value: unknown, min: number, max: number, pattern?: RegExp): value is string =>
51
+ typeof value === "string" && value.length >= min && value.length <= max && (!pattern || pattern.test(value));
52
+
53
+ export function isPanelPageContext(value: unknown): value is PanelPageContext {
54
+ if (!exactKeys(value, ["v", "seq", "pageId", "app"], ["scope", "help"])) return false;
55
+ const { v, seq, pageId, app, scope, help } = value;
56
+ return (
57
+ v === 1 &&
58
+ typeof seq === "number" &&
59
+ Number.isSafeInteger(seq) &&
60
+ seq >= 0 &&
61
+ text(pageId, 36, 36, PAGE_ID) &&
62
+ text(app, 1, 80, APP_ID) &&
63
+ (scope === undefined || (exactKeys(scope, ["type", "id"]) && text(scope.type, 3, 180, QUALIFIED_ID) && text(scope.id, 1, 512))) &&
64
+ (help === undefined ||
65
+ // Index by index: `every` skips the holes of a sparse array, which the schema rejects.
66
+ (Array.isArray(help) &&
67
+ help.length >= 1 &&
68
+ help.length <= 3 &&
69
+ Array.from({ length: help.length }, (_, index) => index).every((index) => index in help && text(help[index], 0, 180, HELP_ID))))
70
+ );
71
+ }
72
+
73
+ export function isPanelShortcut(value: unknown): value is HelpShortcut {
74
+ return (
75
+ exactKeys(value, ["id", "title", "shortcut"], ["description", "scope"]) &&
76
+ text(value.id, 1, HELP_SHORTCUT_ID_MAX_LENGTH) &&
77
+ text(value.title, 1, HELP_SHORTCUT_TITLE_MAX_LENGTH) &&
78
+ (value.description === undefined || text(value.description, 0, HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH)) &&
79
+ text(value.shortcut, 1, HELP_SHORTCUT_KEYS_MAX_LENGTH) &&
80
+ (value.scope === undefined || value.scope === "selection" || value.scope === "page")
81
+ );
82
+ }
83
+
84
+ export function isPanelContextMessage(value: unknown): value is PanelContextMessage {
85
+ if (!exactKeys(value, ["type", "v", "context"], ["shortcuts"])) return false;
86
+ const { shortcuts } = value;
87
+ return (
88
+ value.type === "cloud.panel.context" &&
89
+ value.v === 1 &&
90
+ (value.context === null || isPanelPageContext(value.context)) &&
91
+ (shortcuts === undefined ||
92
+ (value.context !== null &&
93
+ Array.isArray(shortcuts) &&
94
+ shortcuts.length <= HELP_START_SHORTCUT_LIMIT &&
95
+ Array.from({ length: shortcuts.length }, (_, index) => index).every(
96
+ (index) => index in shortcuts && isPanelShortcut(shortcuts[index]),
97
+ )))
98
+ );
99
+ }
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+
3
+ export {
4
+ isPanelContextMessage,
5
+ isPanelPageContext,
6
+ PANEL_CONTEXT_PARAM,
7
+ panelProviderNeedsConsent,
8
+ withPanelContext,
9
+ } from "./panel-context-plain";
10
+
11
+ import { HELP_VIEW_LIMIT } from "../shared/help";
12
+ import {
13
+ HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH,
14
+ HELP_SHORTCUT_ID_MAX_LENGTH,
15
+ HELP_SHORTCUT_KEYS_MAX_LENGTH,
16
+ HELP_SHORTCUT_TITLE_MAX_LENGTH,
17
+ HELP_START_SHORTCUT_LIMIT,
18
+ } from "../ssr/layout-help-start";
19
+ import {
20
+ CAPABILITY_APP_ID_MAX_LENGTH,
21
+ CAPABILITY_QUALIFIED_ID_MAX_LENGTH,
22
+ CapabilityAppIdSchema,
23
+ CLOUD_RESOURCE_ID_MAX_LENGTH,
24
+ CloudResourceRefSchema,
25
+ } from "./capabilities";
26
+
27
+ /** Local Help document ids, as accepted by Layout.help, bounded for URL transport. */
28
+ export const PANEL_HELP_ID_MAX_LENGTH = 180;
29
+ export const PanelPageContextSchema = z
30
+ .object({
31
+ v: z.literal(1),
32
+ seq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
33
+ pageId: z.uuid(),
34
+ app: CapabilityAppIdSchema,
35
+ scope: CloudResourceRefSchema.optional(),
36
+ help: z
37
+ .array(
38
+ z
39
+ .string()
40
+ .max(PANEL_HELP_ID_MAX_LENGTH)
41
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
42
+ )
43
+ .min(1)
44
+ .max(HELP_VIEW_LIMIT)
45
+ .optional(),
46
+ })
47
+ .strict();
48
+ /** v1 carries only the place and static Help ids. Later fields are additive; never infer authority from a ref. */
49
+ export type PanelPageContext = z.infer<typeof PanelPageContextSchema>;
50
+ export type PanelConsentSnapshot = { provider: string; allowed: boolean | null; revision: number };
51
+
52
+ // 128 chars cover JSON keys/punctuation, version and a 16-digit safe seq; add UUID (36), app (80),
53
+ // ref type (180), ref id (512), and three Help ids (180 each + quotes/comma).
54
+ // Only ref ids allow JSON escapes: each UTF-16 unit can expand to six characters (\uXXXX).
55
+ export const PANEL_CONTEXT_PARAM_MAX_LENGTH =
56
+ 128 +
57
+ 36 +
58
+ CAPABILITY_APP_ID_MAX_LENGTH +
59
+ CAPABILITY_QUALIFIED_ID_MAX_LENGTH +
60
+ 6 * CLOUD_RESOURCE_ID_MAX_LENGTH +
61
+ HELP_VIEW_LIMIT * (PANEL_HELP_ID_MAX_LENGTH + 3);
62
+
63
+ export const encodePanelContextParam = (context: PanelPageContext): string => JSON.stringify(PanelPageContextSchema.parse(context));
64
+
65
+ export function parsePanelContextParam(value: string | null | undefined): PanelPageContext | null {
66
+ if (!value || value.length > PANEL_CONTEXT_PARAM_MAX_LENGTH) return null;
67
+ try {
68
+ const parsed = PanelPageContextSchema.safeParse(JSON.parse(value));
69
+ return parsed.success ? parsed.data : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ const PanelShortcutSchema = z
76
+ .object({
77
+ id: z.string().min(1).max(HELP_SHORTCUT_ID_MAX_LENGTH),
78
+ title: z.string().min(1).max(HELP_SHORTCUT_TITLE_MAX_LENGTH),
79
+ description: z.string().max(HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH).optional(),
80
+ shortcut: z.string().min(1).max(HELP_SHORTCUT_KEYS_MAX_LENGTH),
81
+ scope: z.enum(["selection", "page"]).optional(),
82
+ })
83
+ .strict();
84
+ export const PanelContextMessageSchema = z
85
+ .object({
86
+ type: z.literal("cloud.panel.context"),
87
+ v: z.literal(1),
88
+ context: PanelPageContextSchema.nullable(),
89
+ shortcuts: z.array(PanelShortcutSchema).max(HELP_START_SHORTCUT_LIMIT).optional(),
90
+ })
91
+ .strict()
92
+ .refine((message) => message.context !== null || message.shortcuts === undefined);
93
+ export type PanelContextMessage = z.infer<typeof PanelContextMessageSchema>;
@@ -1,4 +1,4 @@
1
- import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog, AppSearchLink } from "./app";
1
+ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPanel, AppPresentationCatalog, AppSearchLink } from "./app";
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
3
  import type { PlatformPermission } from "./outgoing-mail";
4
4
  import type { Role } from "./shared";
@@ -103,6 +103,8 @@ export type AppRegistryEntry = {
103
103
  searchLinks?: readonly AppSearchLink[];
104
104
  /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
105
105
  pwa?: AppRegistryPwaPart;
106
+ /** Optional panel declaration; older readers ignore it. */
107
+ panel?: AppPanel;
106
108
  widgets?: AppRegistryWidget[];
107
109
  /** Setting keys declared by this app. Used by admin tooling to avoid treating live app-owned settings as legacy. */
108
110
  settingKeys?: readonly string[];
@@ -0,0 +1,35 @@
1
+ export const SEEN_HINTS_COOKIE = "cloud_seen_hints";
2
+ export const SEEN_HINTS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
3
+ export const MAX_SEEN_HINTS = 64;
4
+
5
+ const COOKIE_ATTRIBUTES = `; Path=/; Max-Age=${SEEN_HINTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
6
+ // RFC 6265's 4096-byte budget includes name, value and attributes. Reserve Secure on HTTP too.
7
+ const MAX_COOKIE_VALUE_LENGTH = 4096 - `${SEEN_HINTS_COOKIE}=${COOKIE_ATTRIBUTES}; Secure`.length;
8
+
9
+ export const isHintId = (id: unknown): id is string => typeof id === "string" && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(id);
10
+
11
+ /** Keep the most recent occurrence, with the newest hints last. */
12
+ const normalize = (ids: readonly string[]): string[] => {
13
+ const recent = [...new Set(ids.filter(isHintId).toReversed())].reverse().slice(-MAX_SEEN_HINTS);
14
+ while (recent.join("~").length > MAX_COOKIE_VALUE_LENGTH) recent.shift();
15
+ return recent;
16
+ };
17
+
18
+ export const parseSeenHintsCookieHeader = (header: string | null | undefined): string[] => {
19
+ const value = header
20
+ ?.split(";")
21
+ .map((part) => part.trim())
22
+ .find((part) => part.startsWith(`${SEEN_HINTS_COOKIE}=`))
23
+ ?.slice(SEEN_HINTS_COOKIE.length + 1);
24
+ return normalize(value?.split("~") ?? []);
25
+ };
26
+
27
+ export const serializeSeenHints = (ids: readonly string[]): string => normalize(ids).join("~");
28
+
29
+ export const mergeSeenHints = (current: readonly string[], id: string): string[] => {
30
+ if (!isHintId(id)) throw new Error(`Invalid hint id: ${id}`);
31
+ return normalize([...current, id]);
32
+ };
33
+
34
+ export const seenHintsSetCookie = (ids: readonly string[], secure: boolean): string =>
35
+ `${SEEN_HINTS_COOKIE}=${serializeSeenHints(ids)}${COOKIE_ATTRIBUTES}${secure ? "; Secure" : ""}`;
@@ -401,6 +401,8 @@ export type SettingEntry = {
401
401
  valueSource: SettingValueSource;
402
402
  resetValueSource: Exclude<SettingValueSource, "custom">;
403
403
  isCustom: boolean;
404
+ /** A stored row exists but this application cannot decrypt or validate it; `value` is the fallback in use. */
405
+ unreadable: boolean;
404
406
  templateVars?: string[];
405
407
  options?: SettingOption[];
406
408
  min?: number;
@@ -0,0 +1,5 @@
1
+ import type { Context } from "hono";
2
+
3
+ // The gateway (packages/gateway/src/client-address.ts) resolves this address using GATEWAY_TRUSTED_PROXIES.
4
+ export const clientIp = (c: Context): string =>
5
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
@@ -0,0 +1,38 @@
1
+ import { keyboardPlatform } from "@k2b/ui";
2
+ import { helpAgentMarkdown } from "../_internal/help-catalog";
3
+ import type { HelpArticle } from "../services/help/types";
4
+ import { type HelpDocumentPayload, parseHelpReference } from "../shared/help";
5
+ import { renderHelpMarkdown } from "../shared/markdown";
6
+
7
+ /** The same authorized article presentation for the API and the server's first frame. */
8
+ export const buildHelpDocumentPayload = (
9
+ document: HelpArticle,
10
+ userAgent: string,
11
+ renderMarkdown: typeof renderHelpMarkdown = renderHelpMarkdown,
12
+ ): HelpDocumentPayload => {
13
+ const links = (reference: { appId?: string; documentId: string }) =>
14
+ document.links[`${reference.appId ?? document.appId}/${reference.documentId}`];
15
+ const resolve = (reference: string) => {
16
+ const ref = parseHelpReference(reference);
17
+ return ref ? links(ref) : undefined;
18
+ };
19
+ const payload: HelpDocumentPayload = {
20
+ locale: document.locale,
21
+ id: document.documentId,
22
+ title: document.title,
23
+ markdown: helpAgentMarkdown(document),
24
+ kind: document.kind,
25
+ needs: document.needs,
26
+ next: document.next ? resolve(document.next) : undefined,
27
+ related: document.related.flatMap((reference) => {
28
+ const target = resolve(reference);
29
+ return target ? [target] : [];
30
+ }),
31
+ html: renderMarkdown(document.markdown, {
32
+ locale: document.locale,
33
+ platform: keyboardPlatform(userAgent),
34
+ links,
35
+ }),
36
+ };
37
+ return payload;
38
+ };
@@ -411,7 +411,16 @@ const getAuthority = (c: Context<AuthContext>): RequestAuthority => {
411
411
  * True when the request acts through the mobile app's session, directly or through an
412
412
  * invocation made for one. Such requests cannot create authority that outlives the phone.
413
413
  */
414
- const isAppSession = (c: Context<AuthContext>): boolean => isAppSessionActor(c.get("actor"));
414
+ const isAppSession = <E extends AuthContext>(c: Context<E>): boolean => isAppSessionActor(c.get("actor"));
415
+
416
+ /** Returns only the browser session identity authenticated for this request. */
417
+ const browserSignIn = async <E extends AuthContext>(c: Context<E>): Promise<{ userId: string; sid: string } | null> => {
418
+ if (c.get("credentialKind") !== "session" || c.get("actor")?.kind !== "user" || isAppSession(c)) return null;
419
+ const token = c.get("sessionToken");
420
+ if (!token) return null;
421
+ const authenticated = await session.authenticateRequest(c, token);
422
+ return authenticated?.data.kind === "web" ? { userId: authenticated.data.userId, sid: authenticated.data.sid } : null;
423
+ };
415
424
 
416
425
  /** The standard answer for actions the mobile app may not take. */
417
426
  export const APP_SESSION_FORBIDDEN = { code: "FORBIDDEN", message: "Use Cloud on the web for this." } as const;
@@ -460,6 +469,7 @@ export const auth = {
460
469
  requireRole,
461
470
  requireUser,
462
471
  isAppSession,
472
+ browserSignIn,
463
473
  rejectAppSession,
464
474
  requireOAuthScope,
465
475
  getAuthority,
@@ -23,6 +23,7 @@
23
23
  * orthogonal to the request lifecycle.
24
24
  */
25
25
  import { describeRoute } from "hono-openapi";
26
+ import { noTransform } from "./no-transform";
26
27
  import { rateLimit } from "./rate-limit";
27
28
  import { requestLogger } from "./request-logger";
28
29
  import { routeTemplate } from "./route-template";
@@ -31,6 +32,14 @@ import { settings } from "./settings";
31
32
  import { validator } from "./validator";
32
33
 
33
34
  export const middleware = {
35
+ /**
36
+ * Use when another party can repeatedly write text beside unreadable text,
37
+ * personal submissions or secrets worth guessing, including live refetches.
38
+ * Metadata alone and containers every writer can read stay compressed.
39
+ * Mount `.use("*", middleware.noTransform())` first on the app/route-group
40
+ * router, or use it as per-route middleware. Sets Cache-Control: no-transform.
41
+ */
42
+ noTransform,
34
43
  /**
35
44
  * Live cluster registry on `c.get("runtime")`. Required for Layout/Sidebar.
36
45
  * Also reports the matched route template to gateway telemetry — you do not
@@ -0,0 +1,19 @@
1
+ import { createMiddleware } from "hono/factory";
2
+
3
+ export const noTransform = () =>
4
+ createMiddleware(async (c, next) => {
5
+ await next();
6
+ if (c.res.status === 101) return;
7
+ const value = c.res.headers.get("Cache-Control");
8
+ if (
9
+ (value?.match(/(?:[^,"]|"[^"]*")+/g) ?? []).some((directive) => directive.split("=", 1)[0]?.trim().toLowerCase() === "no-transform")
10
+ )
11
+ return;
12
+ const merged = value ? `${value}, no-transform` : "no-transform";
13
+ try {
14
+ c.res.headers.set("Cache-Control", merged);
15
+ } catch {
16
+ c.res = new Response(c.res.body, c.res);
17
+ c.res.headers.set("Cache-Control", merged);
18
+ }
19
+ });
@@ -2,6 +2,7 @@ import type { Context, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
3
  import type { MessageResponse } from "../../contracts/shared";
4
4
  import * as settings from "../../services/settings";
5
+ import { clientIp } from "../client-ip";
5
6
  import { ratelimit } from "../ratelimit";
6
7
  import { type AuthContext, auth } from "./auth";
7
8
 
@@ -29,9 +30,6 @@ const DEFAULT_WINDOW_SECS = 1;
29
30
  const LIMITER_PREFIX = "cloud:rate-limit";
30
31
  const limiterCache = new Map<string, ReturnType<typeof ratelimit>>();
31
32
 
32
- const getClientIp = (c: Context): string =>
33
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
34
-
35
33
  const pathMatches = (requestPath: string, matcher: string | RegExp): boolean => {
36
34
  if (matcher instanceof RegExp) return matcher.test(requestPath);
37
35
  return requestPath === matcher || requestPath.startsWith(`${matcher}/`);
@@ -83,13 +81,13 @@ const getLimiter = (limitPerSecond: number, windowSecs: number) => {
83
81
  };
84
82
 
85
83
  const resolveIdentifier = async (c: Context<AuthContext>, keyBy: RateLimitConfig["keyBy"]): Promise<string> => {
86
- if (keyBy === "ip") return `ip:${getClientIp(c)}`;
84
+ if (keyBy === "ip") return `ip:${clientIp(c)}`;
87
85
 
88
86
  const token = auth.session.getToken(c);
89
- if (!token) return `ip:${getClientIp(c)}`;
87
+ if (!token) return `ip:${clientIp(c)}`;
90
88
 
91
89
  const userId = await auth.session.getRequestSubject(c, token);
92
- if (!userId) return `ip:${getClientIp(c)}`;
90
+ if (!userId) return `ip:${clientIp(c)}`;
93
91
 
94
92
  return `user:${userId}`;
95
93
  };
@@ -0,0 +1,45 @@
1
+ import { isUnsafeNetworkAddress, parseNetworkAddress } from "../../shared/network-address";
2
+
3
+ const ADDRESS_BUDGET = 64;
4
+
5
+ const ipv6Groups = (address: string): number[] => {
6
+ const [left = "", right = ""] = address.split("::");
7
+ const leading = left ? left.split(":") : [];
8
+ const trailing = right ? right.split(":") : [];
9
+ return [...leading, ...Array<string>(8 - leading.length - trailing.length).fill("0"), ...trailing].map((group) =>
10
+ Number.parseInt(group, 16),
11
+ );
12
+ };
13
+
14
+ export const publicClientAddress = (raw: string | null | undefined): string | null => {
15
+ try {
16
+ const input = raw?.trim();
17
+ if (!input || input.length > ADDRESS_BUDGET) return null;
18
+ if (input.includes("[") || input.includes("]")) return null;
19
+ const parsed = parseNetworkAddress(input);
20
+ if (!parsed || isUnsafeNetworkAddress(parsed.address)) return null;
21
+ if (parsed.family === 6) {
22
+ const groups = ipv6Groups(parsed.address);
23
+ if (
24
+ [0, 0, 0, 0, 0, 0xffff].every((group, index) => groups[index] === group) ||
25
+ [0x64, 0xff9b, 0, 0, 0, 0].every((group, index) => groups[index] === group)
26
+ ) {
27
+ const high = groups[6]!;
28
+ const low = groups[7]!;
29
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
30
+ }
31
+ }
32
+ return parsed.address;
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
37
+
38
+ export const addressPrefix = (address: string): string | null => {
39
+ const canonical = publicClientAddress(address);
40
+ if (!canonical) return null;
41
+ if (!canonical.includes(":")) return canonical;
42
+ const groups = ipv6Groups(canonical).map((group) => group.toString(16));
43
+ const prefix = parseNetworkAddress(`${groups.slice(0, 4).join(":")}:0:0:0:0`);
44
+ return prefix ? `${prefix.address}/64` : null;
45
+ };