@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
@@ -10,6 +10,7 @@ export const EMAIL_HTML_TAGS = [
10
10
  "h1",
11
11
  "h2",
12
12
  "h3",
13
+ "hr",
13
14
  "li",
14
15
  "ol",
15
16
  "p",
@@ -33,6 +34,7 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
33
34
  h1: ["style"],
34
35
  h2: ["style"],
35
36
  h3: ["style"],
37
+ hr: ["style"],
36
38
  li: ["style"],
37
39
  ol: ["style"],
38
40
  p: ["style"],
@@ -1,5 +1,8 @@
1
1
  import { localeFallbackChain } from "./locale";
2
2
 
3
+ /** Maximum number of page-specific Help articles shared with the panel. */
4
+ export const HELP_VIEW_LIMIT = 3;
5
+
3
6
  export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
4
7
  export type HelpKind = (typeof HELP_KINDS)[number];
5
8
  /** The glossary's access levels; Help names only these, never app-specific permissions. */
@@ -17,6 +17,18 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
17
17
  }
18
18
  };
19
19
 
20
+ /** Default landing page when no valid home path is configured. */
21
+ export const DEFAULT_HOME_PATH = "/app/dashboard";
22
+
23
+ /** Resolve a local home path, falling back for root, auth, admin, or invalid targets. */
24
+ export const resolveHomePath = (value: unknown): string => {
25
+ const path = typeof value === "string" ? normalizeRedirectTo(value) : undefined;
26
+ if (!path || path === "/" || path === "/auth" || path.startsWith("/auth/") || path === "/admin" || path.startsWith("/admin/")) {
27
+ return DEFAULT_HOME_PATH;
28
+ }
29
+ return path;
30
+ };
31
+
20
32
  /** Extract a safe post-login redirect target from an incoming request URL. */
21
33
  export const redirectPathFromRequestUrl = (requestUrl: string): string => {
22
34
  const url = new URL(requestUrl);
@@ -0,0 +1,22 @@
1
+ const STREAM_CURSOR_REGEX = /^s6t\.[A-Za-z0-9_-]+\.\d+$/;
2
+
3
+ export const parseStreamCursor = (cursor: string | null | undefined): { resource: string; seq: number } | null => {
4
+ if (!cursor || !STREAM_CURSOR_REGEX.test(cursor)) return null;
5
+ const [, resource, sequence] = cursor.split(".");
6
+ const seq = Number(sequence);
7
+ if (!resource || !Number.isSafeInteger(seq) || seq < 0) return null;
8
+ return { resource, seq };
9
+ };
10
+
11
+ export const compareStreamCursor = (left: string, right: string): number => {
12
+ const l = parseStreamCursor(left);
13
+ const r = parseStreamCursor(right);
14
+ if (!l || !r || l.resource !== r.resource) throw new Error(`Invalid stream cursor comparison: "${left}" vs "${right}"`);
15
+ return l.seq - r.seq;
16
+ };
17
+
18
+ export const maxStreamCursor = (a: string | null, b: string | null): string | null => {
19
+ if (!a) return b;
20
+ if (!b) return a;
21
+ return compareStreamCursor(a, b) >= 0 ? a : b;
22
+ };
@@ -0,0 +1,142 @@
1
+ import type { Topic } from "@k2b/sync";
2
+ import * as Y from "yjs";
3
+ import { compareStreamCursor } from "./cursor";
4
+ import { SHARED_TEXT_NAME, sharedTextNodeId, toBase64 } from "./events";
5
+ import { minimalTextDiff } from "./text-diff";
6
+ import type {
7
+ SharedTextActor,
8
+ SharedTextContributor,
9
+ SharedTextEditResult,
10
+ SharedTextSnapshot,
11
+ SharedTextStore,
12
+ SharedTextStoredState,
13
+ SharedTextSyncEvent,
14
+ } from "./types";
15
+
16
+ type DocumentLog = {
17
+ topic(): Pick<Topic<SharedTextSyncEvent>, "latestCursor" | "publish">;
18
+ replay(config: {
19
+ docId: string;
20
+ after: string | null;
21
+ until: string;
22
+ doc: Y.Doc;
23
+ signal?: AbortSignal;
24
+ }): Promise<SharedTextContributor[]>;
25
+ };
26
+
27
+ /**
28
+ * Fallback text becomes the same Yjs items in every copy: an update published
29
+ * against one copy then applies to the next one, which a random client ID
30
+ * would leave pending and silently drop from the saved state.
31
+ */
32
+ const FALLBACK_CLIENT_ID = 0;
33
+
34
+ const createDoc = (stored: SharedTextStoredState): Y.Doc => {
35
+ const doc = new Y.Doc({ gc: true });
36
+ try {
37
+ if (stored.state) Y.applyUpdate(doc, stored.state, "snapshot");
38
+ else if (stored.text) {
39
+ const clientID = doc.clientID;
40
+ doc.clientID = FALLBACK_CLIENT_ID;
41
+ doc.getText(SHARED_TEXT_NAME).insert(0, stored.text);
42
+ doc.clientID = clientID;
43
+ }
44
+ return doc;
45
+ } catch (error) {
46
+ doc.destroy();
47
+ throw error;
48
+ }
49
+ };
50
+
51
+ export const bindDocuments = <Saved, Context>({ topic, replay }: DocumentLog, store: SharedTextStore<Saved, Context>) => {
52
+ return {
53
+ async read(docId: string, options: { signal?: AbortSignal } = {}): Promise<SharedTextSnapshot | null> {
54
+ const stored = await store.load(docId, options);
55
+ if (!stored) return null;
56
+ const latest = await topic().latestCursor({ tenantId: docId });
57
+ if (!latest || (stored.cursor && compareStreamCursor(latest, stored.cursor) <= 0))
58
+ return { state: stored.state, text: stored.text, cursor: stored.cursor };
59
+ const doc = createDoc(stored);
60
+ try {
61
+ await replay({ docId, after: stored.cursor, until: latest, doc, signal: options.signal });
62
+ return { state: Y.encodeStateAsUpdate(doc), text: doc.getText(SHARED_TEXT_NAME).toString(), cursor: latest };
63
+ } finally {
64
+ doc.destroy();
65
+ }
66
+ },
67
+ async edit(
68
+ docId: string,
69
+ options: { actor: SharedTextActor | null; context?: Context; signal?: AbortSignal },
70
+ update: (current: { text: string; contributors: SharedTextContributor[]; state(): Uint8Array }) => string | Promise<string>,
71
+ ): Promise<SharedTextEditResult<Saved>> {
72
+ const requestedAt = Date.now();
73
+ const stored = await store.load(docId, { signal: options.signal });
74
+ if (!stored) return { status: "notFound" };
75
+ const handle = topic();
76
+ const target = await handle.latestCursor({ tenantId: docId });
77
+ const doc = createDoc(stored);
78
+ try {
79
+ let contributors: SharedTextContributor[] = [];
80
+ try {
81
+ if (target) contributors = await replay({ docId, after: stored.cursor, until: target, doc, signal: options.signal });
82
+ } catch (error) {
83
+ // A cancelled request is not a sync failure: it throws like every other abort before publication.
84
+ options.signal?.throwIfAborted();
85
+ return { status: "syncFailed", error };
86
+ }
87
+ options.signal?.throwIfAborted();
88
+ const ytext = doc.getText(SHARED_TEXT_NAME);
89
+ const text = ytext.toString();
90
+ const next = await update({ text, contributors, state: () => Y.encodeStateAsUpdate(doc) });
91
+ if (next === text) return { status: "unchanged", text };
92
+ // A request cancelled while the callback ran must not publish a write.
93
+ options.signal?.throwIfAborted();
94
+ const diff = minimalTextDiff(text, next);
95
+ const beforeEditState = Y.encodeStateVector(doc);
96
+ doc.transact(() => {
97
+ if (diff.deleteCount > 0) ytext.delete(diff.index, diff.deleteCount);
98
+ if (diff.insert.length > 0) ytext.insert(diff.index, diff.insert);
99
+ }, "cli-edit");
100
+ const actor = options.actor;
101
+ const published = await handle.publish({
102
+ tenantId: docId,
103
+ data: {
104
+ kind: "sync",
105
+ payload: toBase64(Y.encodeStateAsUpdate(doc, beforeEditState)),
106
+ originNodeId: sharedTextNodeId,
107
+ originPeerId: null,
108
+ ...(actor ? { actor } : {}),
109
+ },
110
+ });
111
+ let persistedDoc: Y.Doc | undefined;
112
+ try {
113
+ persistedDoc = createDoc(stored);
114
+ await replay({ docId, after: stored.cursor, until: published.cursor, doc: persistedDoc, signal: options.signal });
115
+ const persistedText = persistedDoc.getText(SHARED_TEXT_NAME).toString();
116
+ const credited = new Map(contributors.map((contributor) => [`${contributor.kind}:${contributor.id}`, contributor]));
117
+ if (actor) credited.set(`${actor.kind}:${actor.id}`, { ...actor, lastContributedAt: new Date(requestedAt) });
118
+ const saved = await store.save(
119
+ docId,
120
+ {
121
+ state: Y.encodeStateAsUpdate(persistedDoc),
122
+ text: persistedText,
123
+ cursor: published.cursor,
124
+ restoreRevision: stored.restoreRevision,
125
+ requestedAt,
126
+ actor,
127
+ contributors: [...credited.values()],
128
+ },
129
+ options.context,
130
+ );
131
+ return { status: "edited", text: persistedText, cursor: published.cursor, saved };
132
+ } catch (error) {
133
+ return { status: "persistFailed", cursor: published.cursor, error };
134
+ } finally {
135
+ persistedDoc?.destroy();
136
+ }
137
+ } finally {
138
+ doc.destroy();
139
+ }
140
+ },
141
+ };
142
+ };
@@ -0,0 +1,43 @@
1
+ import { fromBase64Strict } from "@k2b/stdlib";
2
+ import * as Y from "yjs";
3
+ import type { SharedTextEvent } from "./types";
4
+
5
+ export const SHARED_TEXT_NAME = "codemirror";
6
+ export const sharedTextNodeId = crypto.randomUUID();
7
+ export const toBase64 = (data: Uint8Array): string => Buffer.from(data).toString("base64");
8
+ export const fromBase64 = fromBase64Strict;
9
+
10
+ /** A retained event cannot be decoded as a Yjs update: retrying never helps, no replay can pass it. */
11
+ export class MalformedSharedTextEventError extends Error {
12
+ constructor(
13
+ readonly docId: string,
14
+ readonly cursor: string,
15
+ cause: unknown,
16
+ ) {
17
+ super(`Malformed sync event for document ${docId} at cursor ${cursor}: ${cause instanceof Error ? cause.message : String(cause)}`);
18
+ this.name = "MalformedSharedTextEventError";
19
+ }
20
+ }
21
+
22
+ /** Ingress check: a publish is accepted only when it is base64 of a decodable Yjs update. */
23
+ export const isValidSharedTextUpdate = (payload: string): boolean => {
24
+ try {
25
+ Y.decodeUpdate(fromBase64(payload));
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ };
31
+
32
+ export const applySharedTextEvent = (doc: Y.Doc, event: { cursor: string; data: SharedTextEvent }, docId: string): void => {
33
+ if (event.data.kind !== "sync") return;
34
+ try {
35
+ const update = fromBase64(event.data.payload);
36
+ // Decode completely before mutating the document: malformed bytes must not
37
+ // partially integrate state before recovery skips the invalid event.
38
+ Y.decodeUpdate(update);
39
+ Y.applyUpdate(doc, update, `replay:${event.cursor}`);
40
+ } catch (error) {
41
+ throw new MalformedSharedTextEventError(docId, event.cursor, error);
42
+ }
43
+ };
@@ -0,0 +1,12 @@
1
+ export { compareStreamCursor, maxStreamCursor, parseStreamCursor } from "./cursor";
2
+ export {
3
+ applySharedTextEvent,
4
+ fromBase64,
5
+ isValidSharedTextUpdate,
6
+ MalformedSharedTextEventError,
7
+ SHARED_TEXT_NAME,
8
+ sharedTextNodeId,
9
+ toBase64,
10
+ } from "./events";
11
+ export { sharedText } from "./log";
12
+ export * from "./types";
@@ -0,0 +1,48 @@
1
+ import type { TopicConfig } from "@k2b/sync";
2
+ import type * as Y from "yjs";
3
+ import { getProcessSync } from "../_internal/process-sync";
4
+ import { compareStreamCursor } from "./cursor";
5
+ import { bindDocuments } from "./documents";
6
+ import { applySharedTextEvent } from "./events";
7
+ import type { SharedTextContributor, SharedTextStore, SharedTextSyncEvent } from "./types";
8
+
9
+ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "deadLetterRetention" | "maxPayloadBytes">>) => {
10
+ const topic = () => getProcessSync().topic<SharedTextSyncEvent>(topicConfig);
11
+ const replay = async (config: {
12
+ docId: string;
13
+ after: string | null;
14
+ until: string;
15
+ doc: Y.Doc;
16
+ signal?: AbortSignal;
17
+ }): Promise<SharedTextContributor[]> => {
18
+ const contributors = new Map<string, SharedTextContributor>();
19
+ const handle = topic();
20
+ const after = config.after ?? handle.cursorAt(0);
21
+ if (compareStreamCursor(after, config.until) >= 0) return [];
22
+ let reachedTarget = false;
23
+ for await (const event of handle.replay({ tenantId: config.docId, after, until: config.until, signal: config.signal })) {
24
+ applySharedTextEvent(config.doc, event, config.docId);
25
+ if (event.data.actor)
26
+ contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
27
+ ...event.data.actor,
28
+ lastContributedAt: event.publishedAt,
29
+ });
30
+ reachedTarget = event.cursor === config.until;
31
+ }
32
+ if (!reachedTarget) throw new Error(`Target cursor "${config.until}" was not reached during replay`);
33
+ return [...contributors.values()];
34
+ };
35
+
36
+ return {
37
+ id: topicConfig.id,
38
+ topicConfig,
39
+ topic,
40
+ replay,
41
+ withStore<Saved, Context>(store: SharedTextStore<Saved, Context>) {
42
+ return bindDocuments({ topic, replay }, store);
43
+ },
44
+ };
45
+ };
46
+
47
+ /** Server-only document logs. Applications authorize callers before read/edit. */
48
+ export const sharedText = { define };
@@ -0,0 +1,23 @@
1
+ const splitsSurrogatePair = (text: string, index: number): boolean => {
2
+ const before = text.charCodeAt(index - 1);
3
+ const after = text.charCodeAt(index);
4
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
5
+ };
6
+
7
+ /** One replacement retaining the shared prefix and suffix at Unicode boundaries. */
8
+ export const minimalTextDiff = (before: string, after: string): { index: number; deleteCount: number; insert: string } => {
9
+ let index = 0;
10
+ while (index < before.length && index < after.length && before[index] === after[index]) index++;
11
+ if (splitsSurrogatePair(before, index) || splitsSurrogatePair(after, index)) index--;
12
+
13
+ let suffix = 0;
14
+ while (
15
+ suffix < before.length - index &&
16
+ suffix < after.length - index &&
17
+ before[before.length - suffix - 1] === after[after.length - suffix - 1]
18
+ )
19
+ suffix++;
20
+ if (splitsSurrogatePair(before, before.length - suffix) || splitsSurrogatePair(after, after.length - suffix)) suffix--;
21
+
22
+ return { index, deleteCount: before.length - index - suffix, insert: after.slice(index, after.length - suffix) };
23
+ };
@@ -0,0 +1,41 @@
1
+ export type SharedTextActor = { kind: "user" | "service_account"; id: string };
2
+
3
+ /** Actor is optional so events retained from older deployments remain readable. */
4
+ export type SharedTextEvent = {
5
+ kind: "sync" | "awareness";
6
+ payload: string;
7
+ originNodeId: string;
8
+ originPeerId: string | null;
9
+ actor?: SharedTextActor;
10
+ };
11
+ export type SharedTextSyncEvent = SharedTextEvent & { kind: "sync" };
12
+ export type SharedTextContributor = SharedTextActor & { lastContributedAt: Date };
13
+
14
+ /** The store anchors legacy cursors to this log before returning a snapshot. */
15
+ export type SharedTextStoredState = {
16
+ state: Uint8Array | null;
17
+ cursor: string | null;
18
+ restoreRevision: string;
19
+ text: string | null;
20
+ };
21
+ export type SharedTextSaveInput = {
22
+ state: Uint8Array;
23
+ text: string;
24
+ cursor: string;
25
+ restoreRevision: string;
26
+ requestedAt: number;
27
+ actor: SharedTextActor | null;
28
+ contributors: SharedTextContributor[];
29
+ };
30
+ export type SharedTextSnapshot = { state: Uint8Array | null; text: string | null; cursor: string | null };
31
+ export type SharedTextEditResult<Saved> =
32
+ | { status: "notFound" }
33
+ | { status: "unchanged"; text: string }
34
+ | { status: "syncFailed"; error: unknown }
35
+ | { status: "persistFailed"; cursor: string; error: unknown }
36
+ | { status: "edited"; text: string; cursor: string; saved: Saved };
37
+
38
+ export type SharedTextStore<Saved, Context> = {
39
+ load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
40
+ save(docId: string, input: SharedTextSaveInput, context: Context | undefined): Promise<Saved>;
41
+ };
@@ -1,5 +1,6 @@
1
1
  import { AppWorkspace } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js/jsx-runtime";
3
+ import type { PanelConsentSnapshot } from "../contracts/panel-context";
3
4
  import type { RailSnapshot } from "../contracts/rail-preferences";
4
5
  import { getLocale } from "../server/locale";
5
6
  import type { LayoutAnnouncementsState } from "../server/middleware/settings";
@@ -10,6 +11,7 @@ import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
10
11
 
11
12
  type Breadcrumb = { title: string; href?: string };
12
13
  type AdminLayoutContext = {
14
+ get(key: "panelConsent"): PanelConsentSnapshot | undefined;
13
15
  get(key: "railPreferences"): RailSnapshot | undefined;
14
16
  get(key: "user"): any;
15
17
  get(key: "page"): any;
@@ -143,7 +143,7 @@ export const AppLaunchpadPanel = (
143
143
  <i class={shortcut.iconClass} aria-hidden="true" />
144
144
  <AppBadge endpoint={shortcut.badge} appId={shortcut.appId} />
145
145
  </span>
146
- <span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">
146
+ <span class="max-w-full k2b-truncate-fade text-[11px] font-medium text-primary dark:text-white">
147
147
  {shortcut.label}
148
148
  <Show when={badge()}>
149
149
  <span class="sr-only">, {badge()}</span>
@@ -173,7 +173,7 @@ export const AppLaunchpadPanel = (
173
173
  <i class={app.iconClass} />
174
174
  <AppBadge endpoint={app.badge} appId={app.id} />
175
175
  </span>
176
- <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">
176
+ <span class="max-w-full k2b-truncate-fade text-[11px] font-medium text-primary sm:text-xs dark:text-white">
177
177
  {app.label}
178
178
  <Show when={badge()}>
179
179
  <span class="sr-only">, {badge()}</span>
@@ -1,30 +1,85 @@
1
- import { useLocale } from "@k2b/ui";
2
- import { createEffect } from "solid-js";
1
+ import { TabBar, useLocale } from "@k2b/ui";
2
+ import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import { isServer, Portal } from "solid-js/web";
3
4
  import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
4
5
  import GlobalSearchTrigger from "./GlobalSearchTrigger";
5
6
  import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
6
7
  import LayoutHelpTrigger from "./LayoutHelpTrigger";
7
8
  import LayoutPreferences from "./LayoutPreferences";
8
9
  import type { LayoutBreadcrumb } from "./layout-runtime";
10
+ import PanelTrigger from "./PanelTrigger";
9
11
  import ProfilePreferences from "./ProfilePreferences";
10
12
  import { platformMessages } from "./platform-messages";
11
13
  import RailApps from "./RailApps";
14
+ import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
15
+ import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
12
16
  import ShellFocusToggle from "./ShellFocusToggle";
13
17
  import { useShellData } from "./shell-data";
14
18
 
15
19
  type CloudShellProps =
16
- | { slot: "rail"; currentPath: string }
17
- | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] };
20
+ | { slot: "rail"; currentPath: string; panelOpen?: boolean }
21
+ | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[]; panelOpen?: boolean };
18
22
 
19
23
  /**
20
24
  * The interactive part of the shell chrome. `LayoutRail` and `LayoutHeader`
21
25
  * own the frame and the plain links around it as server markup.
22
26
  */
23
27
  export default function CloudShell(props: CloudShellProps) {
24
- return props.slot === "rail" ? <RailNavigation currentPath={props.currentPath} /> : <HeaderControls {...props} />;
28
+ return props.slot === "rail" ? (
29
+ <RailNavigation currentPath={props.currentPath} panelOpen={props.panelOpen} />
30
+ ) : (
31
+ <HeaderControls {...props} />
32
+ );
25
33
  }
26
34
 
27
- function RailNavigation(props: { currentPath: string }) {
35
+ /**
36
+ * The phone bar's live copy, mounted with the rail's island so the shell adds no island instance. It renders nothing
37
+ * while the server's bar is current, so mounting moves and refocuses nothing. When the saved rail preferences or the
38
+ * URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, it renders the new bar into the
39
+ * bar's row, and CSS hides the server's.
40
+ */
41
+ function BarNavigation(props: { currentPath: string }) {
42
+ if (isServer) return null;
43
+ const data = useShellData();
44
+ const locale = useLocale();
45
+ const t = () => platformMessages.resolve([locale()]).t;
46
+ const initial = data?.rail;
47
+ if (!data || !initial) return null;
48
+ const [settings, setSettings] = createSignal(initial);
49
+ const [currentPath, setCurrentPath] = createSignal(props.currentPath);
50
+ const itemsFor = (rail: typeof initial, path: string) =>
51
+ phoneBarItems(projectRailNavigation(data.apps, rail, locale()), path, { label: t().home, path: data.home ?? "/" });
52
+ const served = JSON.stringify(itemsFor(initial, props.currentPath));
53
+ const items = createMemo(() => itemsFor(settings(), currentPath()));
54
+ onMount(() => {
55
+ const sync = () => setSettings(readRailContext()?.settings ?? initial);
56
+ const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
57
+ syncLocation();
58
+ window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
59
+ window.addEventListener("popstate", syncLocation);
60
+ window.addEventListener("hashchange", syncLocation);
61
+ // In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
62
+ // reports; where a browser lacks it, the bar follows at the next page load.
63
+ window.navigation?.addEventListener("currententrychange", syncLocation);
64
+ onCleanup(() => {
65
+ window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
66
+ window.removeEventListener("popstate", syncLocation);
67
+ window.removeEventListener("hashchange", syncLocation);
68
+ window.navigation?.removeEventListener("currententrychange", syncLocation);
69
+ });
70
+ });
71
+ const row = document.querySelector<HTMLElement>(".cloud-tab-bar__row");
72
+ if (!row) return null;
73
+ return (
74
+ <Show when={JSON.stringify(items()) !== served}>
75
+ <Portal mount={row}>
76
+ <TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={items()} />
77
+ </Portal>
78
+ </Show>
79
+ );
80
+ }
81
+
82
+ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
28
83
  const data = useShellData();
29
84
  const locale = useLocale();
30
85
  const t = () => platformMessages.resolve([locale()]).t;
@@ -36,9 +91,15 @@ function RailNavigation(props: { currentPath: string }) {
36
91
  <ShellFocusToggle variant="rail" />
37
92
  <RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
38
93
  <AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
94
+ <BarNavigation currentPath={props.currentPath} />
39
95
  <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
96
+ {(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="rail" open={props.panelOpen} />}
40
97
  <GlobalSearchTrigger variant="rail" />
41
- <LayoutHelpTrigger variant="rail" />
98
+ <LayoutHelpTrigger
99
+ variant="rail"
100
+ panel={data.panel?.providers.some((provider) => provider.id === "core")}
101
+ helpOpen={!!props.panelOpen && data.panel?.provider === "core"}
102
+ />
42
103
  <ProfilePreferences
43
104
  appHref={data.profile.appHref}
44
105
  avatarSrc={data.profile.avatarSrc}
@@ -51,7 +112,13 @@ function RailNavigation(props: { currentPath: string }) {
51
112
  }
52
113
 
53
114
  /** The header's title and controls, between the server-rendered Home and Sign in. */
54
- function HeaderControls(props: { authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] }) {
115
+ function HeaderControls(props: {
116
+ authenticated: boolean;
117
+ title?: string;
118
+ launchpad: boolean;
119
+ breadcrumbs?: LayoutBreadcrumb[];
120
+ panelOpen?: boolean;
121
+ }) {
55
122
  const data = useShellData();
56
123
  const locale = useLocale();
57
124
  const t = () => platformMessages.resolve([locale()]).t;
@@ -65,13 +132,31 @@ function HeaderControls(props: { authenticated: boolean; title?: string; launchp
65
132
  </div>
66
133
  )}
67
134
  <div class="flex min-w-0 items-center lg:hidden">
68
- <span class="truncate font-semibold">{props.title}</span>
135
+ <span class="layout-header-title truncate font-semibold">{props.title}</span>
136
+ {props.launchpad && (
137
+ <AppLaunchpadButton
138
+ profile={
139
+ data.profile
140
+ ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
141
+ : undefined
142
+ }
143
+ apps={data.apps}
144
+ legalLinks={data.legalLinks}
145
+ variant="title"
146
+ label={props.title}
147
+ />
148
+ )}
69
149
  </div>
70
150
  </div>
71
151
  <div class="flex shrink-0 items-center gap-1">
72
152
  <div class="flex items-center gap-1 lg:hidden">
73
- <LayoutHelpTrigger variant="header" />
153
+ {(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="header" open={props.panelOpen} />}
74
154
  {props.authenticated && <GlobalSearchTrigger variant="header" />}
155
+ <LayoutHelpTrigger
156
+ variant="header"
157
+ panel={data.panel?.providers.some((provider) => provider.id === "core")}
158
+ helpOpen={!!props.panelOpen && data.panel?.provider === "core"}
159
+ />
75
160
  {props.launchpad && (
76
161
  <AppLaunchpadButton
77
162
  profile={