@k2b/cloud 0.39.0 → 0.41.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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -1,6 +1,8 @@
1
1
  /** Keep an app discoverable even when the ephemeral registry is recreated. */
2
2
 
3
- import { APP_REGISTRY_TTL_MS } from "./registry";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { hostname } from "node:os";
5
+ import { APP_REGISTRY_TTL_MS, type AppInstanceRecord, appInstancePrefix } from "./registry";
4
6
 
5
7
  const HEARTBEAT_INTERVAL_MS = 60_000;
6
8
  const HEARTBEAT_WRITE_TIMEOUT_MS = 10_000;
@@ -15,8 +17,171 @@ type HeartbeatRegistry<T> = {
15
17
  delete: (input: { key: string }) => Promise<unknown>;
16
18
  };
17
19
 
20
+ type SnapshotRegistry<T> = {
21
+ snapshot: (input: { prefix: string }) => Promise<{ entries: { key: string; value: T }[] }>;
22
+ };
23
+
24
+ type MembershipRegistry = HeartbeatRegistry<AppInstanceRecord> & SnapshotRegistry<AppInstanceRecord>;
25
+
26
+ type SharedEntry = {
27
+ read: () => Promise<{ value: unknown } | undefined>;
28
+ write: () => Promise<unknown>;
29
+ remove: () => Promise<unknown>;
30
+ prepare: (() => Promise<void>) | undefined;
31
+ };
32
+
33
+ const entryDigest = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("base64url");
34
+
35
+ type MembershipLock = {
36
+ withLock: <T>(
37
+ input: { resource: string; ttlMs?: number; signal?: AbortSignal },
38
+ fn: (lock: { expiresAt: Date }) => Promise<T>,
39
+ ) => Promise<T | null>;
40
+ };
41
+
42
+ /** One process membership shared by its app and capability heartbeats. Getters stay lazy until use. */
43
+ export const createRegistryMembership = (
44
+ appId: string,
45
+ options: {
46
+ registry: () => MembershipRegistry;
47
+ lock: () => MembershipLock;
48
+ instanceId?: string;
49
+ hostname?: string;
50
+ startedAt?: number;
51
+ writeTimeoutMs?: number;
52
+ },
53
+ ) => {
54
+ const instanceId = options.instanceId ?? randomBytes(12).toString("base64url");
55
+ const prefix = appInstancePrefix(appId);
56
+ const key = `${prefix}${instanceId}`;
57
+ const value: AppInstanceRecord = {
58
+ appId,
59
+ instanceId,
60
+ hostname: options.hostname ?? hostname(),
61
+ startedAt: options.startedAt ?? Date.now(),
62
+ entries: {},
63
+ };
64
+ const writeTimeoutMs = options.writeTimeoutMs ?? HEARTBEAT_WRITE_TIMEOUT_MS;
65
+ if (!Number.isFinite(writeTimeoutMs) || writeTimeoutMs <= 0) throw new RangeError("Membership write timeout must be positive and finite");
66
+ let left = false;
67
+ const sharedEntries = new Map<string, SharedEntry>();
68
+
69
+ // Prepare idempotent dependent data outside the lock to preserve its bounded lease.
70
+ const prepareShared = async (sharedKey: string): Promise<void> => {
71
+ for (const [key, { prepare }] of sharedEntries) {
72
+ if (key !== sharedKey) await prepare?.();
73
+ }
74
+ };
75
+
76
+ const locked = async (write: (lock: { expiresAt: Date }) => Promise<void>): Promise<void> => {
77
+ const result = await options
78
+ .lock()
79
+ .withLock({ resource: appId, ttlMs: writeTimeoutMs, signal: AbortSignal.timeout(writeTimeoutMs) }, async (lock) => {
80
+ await write(lock);
81
+ return true;
82
+ });
83
+ if (result === null) throw new Error(`Registry membership lock unavailable for app ${appId}`);
84
+ };
85
+ const upsert = () => options.registry().upsert({ key, value });
86
+ const publishShared = async (): Promise<void> => {
87
+ if (left) return;
88
+ await upsert();
89
+ // Never mix entries from different processes: the capability catalog rejects mismatched manifests.
90
+ for (const { write } of sharedEntries.values()) {
91
+ if (left) return;
92
+ await write();
93
+ }
94
+ };
95
+
96
+ return {
97
+ join: async (sharedKey: string, sharedValue: unknown, entry: SharedEntry): Promise<void> => {
98
+ left = false;
99
+ await prepareShared(sharedKey);
100
+ await locked(async () => {
101
+ value.entries[sharedKey] = entryDigest(sharedValue);
102
+ sharedEntries.set(sharedKey, entry);
103
+ await publishShared();
104
+ });
105
+ },
106
+ publish: async (sharedKey: string): Promise<void> => {
107
+ if (left) return;
108
+ await prepareShared(sharedKey);
109
+ await locked(async () => {
110
+ if (left) return;
111
+ await publishShared();
112
+ });
113
+ },
114
+ adopt: async (sharedKey: string): Promise<void> => {
115
+ if (left) return;
116
+ // A live process started after this one covers stored state when all read keys it declares match.
117
+ // Ignore keys it never registered, but heal split pairs when both keys are declared.
118
+ // Start order is total (instance id breaks ties), so two live replicas never take turns.
119
+ const startedLater = (other: AppInstanceRecord): boolean =>
120
+ other.startedAt > value.startedAt || (other.startedAt === value.startedAt && other.instanceId > value.instanceId);
121
+ const needsRepublish = async (): Promise<boolean> => {
122
+ const stored = await Promise.all(
123
+ [...sharedEntries].map(async ([key, { read }]) => {
124
+ const entry = await read();
125
+ return { key, digest: entry ? entryDigest(entry.value) : undefined };
126
+ }),
127
+ );
128
+ if (stored.some(({ digest }) => digest === undefined)) return false;
129
+ if (stored.every(({ key, digest }) => digest === value.entries[key])) return false;
130
+ const snapshot = await options.registry().snapshot({ prefix });
131
+ return !snapshot.entries.some(
132
+ ({ value }) =>
133
+ startedLater(value) &&
134
+ stored.some(({ key }) => key in value.entries) &&
135
+ stored.every(({ key, digest }) => !(key in value.entries) || value.entries[key] === digest),
136
+ );
137
+ };
138
+ if (!(await needsRepublish())) return;
139
+ await prepareShared(sharedKey);
140
+ await locked(async (lock) => {
141
+ if (left) return;
142
+ const stillNeedsRepublish = await needsRepublish();
143
+ if (left || !stillNeedsRepublish) return;
144
+ if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
145
+ throw new Error("membership lease expired during adoption; shared entry left to its TTL");
146
+ }
147
+ await publishShared();
148
+ });
149
+ },
150
+ renew: async (): Promise<void> => {
151
+ if (left) return;
152
+ if (!(await options.registry().touch({ key }))) {
153
+ await locked(async () => {
154
+ if (!left) await upsert();
155
+ });
156
+ }
157
+ },
158
+ leave: async (): Promise<void> => {
159
+ left = true;
160
+ // A failed delete must never lead to shared removal: leave routing to its TTL.
161
+ // Repeat the delete even after leaving, to remove a late registration write.
162
+ await options.registry().delete({ key });
163
+ await locked(async (lock) => {
164
+ const snapshot = await options.registry().snapshot({ prefix });
165
+ // Ephemeral has no conditional delete, so the remaining lease bounds the removal window.
166
+ // A delete that NATS applies only after the lease expired can still remove a newer
167
+ // replica's entry; that replica restores it on its next refresh (at most 60 s).
168
+ if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
169
+ throw new Error("membership lease expired during stop; shared entry left to its TTL");
170
+ }
171
+ if (!snapshot.entries.some((entry) => entry.key !== key)) {
172
+ for (const { remove } of sharedEntries.values()) {
173
+ if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
174
+ throw new Error("membership lease expired during stop; shared entry left to its TTL");
175
+ }
176
+ await remove();
177
+ }
178
+ }
179
+ });
180
+ },
181
+ };
182
+ };
183
+
18
184
  type HeartbeatOptions<T> = {
19
- registry: HeartbeatRegistry<T>;
20
185
  key?: string;
21
186
  /** Publish dependent data before advertising or renewing the app. */
22
187
  beforeWrite?: () => Promise<void>;
@@ -26,7 +191,10 @@ type HeartbeatOptions<T> = {
26
191
  writeTimeoutMs?: number;
27
192
  onError?: (error: unknown) => void;
28
193
  onStale?: (error: unknown) => void;
29
- };
194
+ } & (
195
+ | { registry: HeartbeatRegistry<T>; membership?: undefined }
196
+ | { registry: HeartbeatRegistry<T> & SnapshotRegistry<T>; membership: ReturnType<typeof createRegistryMembership> }
197
+ );
30
198
 
31
199
  export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOptions<T>) => {
32
200
  const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
@@ -44,6 +212,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
44
212
  let timer: Timer | null = null;
45
213
  let inFlight: Promise<void> | null = null;
46
214
  let running = false;
215
+ let needsMembershipCleanup = false;
47
216
  let lastSuccessAt = 0;
48
217
  let staleReported = false;
49
218
  const key = options.key ?? `apps/${appId}`;
@@ -54,31 +223,49 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
54
223
  staleReported = false;
55
224
  };
56
225
 
226
+ const remove = () => (options.membership ? options.membership.leave() : registry.delete({ key }));
227
+ const upsert = () => registry.upsert({ key, value: entry });
228
+
57
229
  const register = async (): Promise<void> => {
58
230
  await options.beforeWrite?.();
59
- await registry.upsert({ key, value: entry });
231
+ if (options.membership) {
232
+ const registry = options.registry;
233
+ await options.membership.join(key, entry, {
234
+ read: async () => (await registry.snapshot({ prefix: key })).entries.find((candidate) => candidate.key === key),
235
+ write: upsert,
236
+ remove: () => registry.delete({ key }),
237
+ prepare: options.beforeWrite,
238
+ });
239
+ } else await upsert();
60
240
  noteSuccess();
61
241
  };
62
242
 
63
243
  const refresh = async (): Promise<void> => {
64
244
  // Touch renews the existing entry (and emits a registry invalidation).
65
245
  // Avoid rebuilding the manifest here. A missing entry is repaired
66
- // immediately, preserving restart-free registry recovery.
246
+ // immediately, preserving restart-free registry recovery. The newest live
247
+ // publisher wins; an entry whose publisher left is replaced by this one.
67
248
  await options.beforeWrite?.();
249
+ await options.membership?.renew();
68
250
  const touched = await registry.touch({ key });
69
- if (!touched) await registry.upsert({ key, value: entry });
251
+ if (!touched) {
252
+ if (options.membership) await options.membership.publish(key);
253
+ else await upsert();
254
+ } else if (options.membership) {
255
+ await options.membership.adopt(key);
256
+ }
70
257
  noteSuccess();
71
258
  };
72
259
 
73
260
  const writeWithTimeout = async (write: Promise<void>): Promise<void> => {
74
261
  let timer: Timer | null = null;
75
262
  let timedOut = false;
76
- void write
77
- .then(async () => {
78
- // A write that completed after its timeout must not resurrect a stopped app.
79
- if (timedOut && !running) await registry.delete({ key }).catch(() => false);
80
- })
81
- .catch(() => undefined);
263
+ // A write that settled after its timeout must not resurrect a stopped app. A failed
264
+ // operation may still have landed earlier steps, such as the liveness key.
265
+ const removeLateWrite = async () => {
266
+ if (timedOut && !running) await remove().catch(() => false);
267
+ };
268
+ void write.then(removeLateWrite, removeLateWrite).catch(() => undefined);
82
269
  try {
83
270
  await Promise.race([
84
271
  write,
@@ -140,6 +327,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
140
327
  return;
141
328
  }
142
329
  running = true;
330
+ needsMembershipCleanup = options.membership !== undefined;
143
331
  const operation = writeWithTimeout(register());
144
332
  inFlight = operation;
145
333
  try {
@@ -153,7 +341,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
153
341
  schedule(intervalMs);
154
342
  },
155
343
  stop: async () => {
156
- if (!running) return;
344
+ if (!running && !needsMembershipCleanup) return;
157
345
  running = false;
158
346
  if (timer) clearTimeout(timer);
159
347
  timer = null;
@@ -165,7 +353,8 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
165
353
  // still required so a stopped app cannot remain discoverable.
166
354
  }
167
355
  inFlight = null;
168
- await registry.delete({ key });
356
+ await remove();
357
+ needsMembershipCleanup = false;
169
358
  },
170
359
  };
171
360
  };
@@ -1,4 +1,5 @@
1
- import type { EphemeralConfig } from "@k2b/sync";
1
+ import { createHash } from "node:crypto";
2
+ import type { EphemeralConfig, MutexConfig } from "@k2b/sync";
2
3
  import type { AppAppearanceColor } from "../contracts/app";
3
4
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
4
5
  import { fileProviderIssues } from "../contracts/file-provider";
@@ -45,6 +46,32 @@ export const CAPABILITY_REGISTRY_CONFIG = {
45
46
  } as const satisfies EphemeralConfig;
46
47
 
47
48
  export const APP_REGISTRY_PREFIX = "apps/";
49
+ export const APP_INSTANCE_PREFIX = "instances/";
50
+
51
+ // Fit Sync's 96-byte name limit for every app id that fits apps/<id>; appId stays in the record value.
52
+ export const appInstancePrefix = (appId: string): string => {
53
+ const segment = createHash("sha256").update(appId).digest("base64url").slice(0, 16);
54
+ return `${APP_INSTANCE_PREFIX}${segment}/`;
55
+ };
56
+
57
+ export type AppInstanceRecord = {
58
+ appId: string;
59
+ instanceId: string;
60
+ hostname: string;
61
+ startedAt: number;
62
+ entries: Record<string, string>;
63
+ };
64
+
65
+ // The id and owner are a cross-version contract: all replicas must use the same lock.
66
+ // TTL and retry are local budgets; Sync's drift identity is [id, owner, replicas].
67
+ export const APP_MEMBERSHIP_LOCK_CONFIG = {
68
+ id: "cloud-app-membership",
69
+ owner: REGISTRY_OWNER,
70
+ retry: { delayMs: 200, maxAttempts: 50 },
71
+ } as const satisfies MutexConfig;
72
+
73
+ export const appInstanceRegistry = lazySync((sync) => sync.ephemeral<AppInstanceRecord>(APP_REGISTRY_CONFIG));
74
+ export const appMembershipLock = lazySync((sync) => sync.mutex(APP_MEMBERSHIP_LOCK_CONFIG));
48
75
 
49
76
  export type CapabilityRegistryRecord = { appId: string; manifest: CapabilityManifest; presentation?: CapabilityPresentationCatalog };
50
77
 
@@ -19,6 +19,7 @@ import { buildRuntimeFromRegistry } from "./runtime-context";
19
19
  const log = logger("runtime-watcher");
20
20
 
21
21
  let current: CloudRuntime | undefined;
22
+ let coreRelease: string | undefined;
22
23
  let initPromise: Promise<void> | undefined;
23
24
  let watcherTask: Promise<void> | undefined;
24
25
  let abort: AbortController | undefined;
@@ -27,8 +28,12 @@ const refresh = async () => {
27
28
  const apps = await listApps();
28
29
  const capabilities = await listCapabilities(apps);
29
30
  current = buildRuntimeFromRegistry(apps, capabilities);
31
+ coreRelease = apps.find((app) => app.id === "core")?.runtime?.release;
30
32
  };
31
33
 
34
+ /** Core's release from the live registry, absent before initialization and after shutdown. */
35
+ export const registeredCoreRelease = (): string | undefined => coreRelease;
36
+
32
37
  export const ensureRuntimeWatcher = (): Promise<void> => {
33
38
  // Concurrent callers (start() + first request) wait on the same init —
34
39
  // returning before `current` is populated would race getCurrentRuntime().
@@ -68,6 +73,7 @@ export const stopRuntimeWatcher = async (): Promise<void> => {
68
73
  watcherTask = undefined;
69
74
  initPromise = undefined;
70
75
  current = undefined;
76
+ coreRelease = undefined;
71
77
  };
72
78
 
73
79
  export const getCurrentRuntime = (): CloudRuntime => {
@@ -0,0 +1,103 @@
1
+ import type { Mutex } from "@k2b/sync";
2
+ import { expBackoff } from "@k2b/sync/retry";
3
+ import { logger } from "../services/logging";
4
+ import { lazySync, waitForNats } from "./process-sync";
5
+
6
+ const SETUP_LEASE_MS = 60_000;
7
+ const setupMutex = lazySync((sync) => sync.mutex({ id: "app-setup", owner: "cloud", ttlMs: SETUP_LEASE_MS, retry: { maxAttempts: 1 } }));
8
+
9
+ const scheduleRenewal = (renew: () => Promise<void> | undefined, intervalMs: number): (() => void) => {
10
+ const timer = setInterval(() => void renew(), intervalMs);
11
+ timer.unref();
12
+ return () => clearInterval(timer);
13
+ };
14
+
15
+ /** Internal seam for testing lease timing without NATS or wall-clock waits. */
16
+ export const runExclusive = async ({
17
+ mutex,
18
+ resource,
19
+ run,
20
+ leaseMs = SETUP_LEASE_MS,
21
+ sleep = Bun.sleep,
22
+ now = Date.now,
23
+ schedule = scheduleRenewal,
24
+ log = logger("setup"),
25
+ }: {
26
+ mutex: Pick<Mutex, "ready" | "acquire" | "extend" | "release">;
27
+ resource: string;
28
+ run: () => Promise<void>;
29
+ leaseMs?: number;
30
+ sleep?: (ms: number) => Promise<unknown>;
31
+ now?: () => number;
32
+ schedule?: typeof scheduleRenewal;
33
+ log?: Pick<ReturnType<typeof logger>, "info" | "warn" | "error">;
34
+ }): Promise<void> => {
35
+ const startedAt = now();
36
+ const nats = { application: resource };
37
+ await waitForNats(() => mutex.ready(), nats);
38
+ let lastWaitLog = Number.NEGATIVE_INFINITY;
39
+ let attempt = 1;
40
+ let lock = await waitForNats(() => mutex.acquire({ resource }), nats);
41
+ while (!lock) {
42
+ if (now() - lastWaitLog >= 30_000) {
43
+ lastWaitLog = now();
44
+ log.info("waiting for setup on another replica", { appId: resource, waitedMs: now() - startedAt });
45
+ }
46
+ await sleep(Math.min(2_000, expBackoff(attempt++, { baseMs: 250, maxMs: 2_000, jitter: 0.2 })));
47
+ lock = await waitForNats(() => mutex.acquire({ resource }), nats);
48
+ }
49
+
50
+ const lease = lock;
51
+ let lost = false;
52
+ let running = true;
53
+ let confirmedExpiresAt = lease.expiresAt.getTime();
54
+ let renewing: Promise<void> | undefined;
55
+ const checkExpiry = () => {
56
+ if (running && now() >= confirmedExpiresAt) lost = true;
57
+ };
58
+ const cancelRenewal = schedule(() => {
59
+ // Check even when an earlier extend is still in flight or the event loop was delayed.
60
+ checkExpiry();
61
+ if (renewing || lost) return;
62
+ renewing = (async () => {
63
+ try {
64
+ const extended = await mutex.extend(lease);
65
+ // extend mutates the handle; check the previous confirmed expiry first.
66
+ checkExpiry();
67
+ if (extended) confirmedExpiresAt = lease.expiresAt.getTime();
68
+ else lost = true;
69
+ } catch (error) {
70
+ log.warn("setup lease renewal failed", { appId: resource, error });
71
+ checkExpiry();
72
+ }
73
+ })().finally(() => {
74
+ renewing = undefined;
75
+ });
76
+ return renewing;
77
+ }, leaseMs / 3);
78
+
79
+ try {
80
+ try {
81
+ await run();
82
+ } finally {
83
+ checkExpiry();
84
+ running = false;
85
+ cancelRenewal();
86
+ // Do not race a release against an unfinished renewal.
87
+ await renewing;
88
+ if (lost) {
89
+ log.error("setup lease lost; another replica may have run setup concurrently; restarting so setup reruns", { appId: resource });
90
+ }
91
+ }
92
+ if (lost) throw new Error(`Setup lease lost for application "${resource}"`);
93
+ } finally {
94
+ try {
95
+ if (!(await mutex.release(lease))) log.warn("setup lease release failed; it will expire", { appId: resource });
96
+ } catch (error) {
97
+ log.warn("setup lease release failed; it will expire", { appId: resource, error });
98
+ }
99
+ }
100
+ };
101
+
102
+ export const runSetupExclusively = (appId: string, setup: () => Promise<void>): Promise<void> =>
103
+ runExclusive({ mutex: setupMutex(), resource: appId, run: setup });
@@ -1,7 +1,43 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
1
3
  import { resolve, sep } from "node:path";
2
4
  import type { Context } from "hono";
3
5
 
4
6
  const publicRoot = resolve(process.cwd(), "public");
7
+ const versions = new Map<string, { size: number; mtimeMs: number; version: string }>();
8
+
9
+ /**
10
+ * Version of Core's shared stylesheets: the release, which names one build. `local` and `development` are the
11
+ * defaults of unnamed builds and name many, so those sheets get no version and are revalidated instead.
12
+ */
13
+ export function sharedStylesheetVersion(release: string | undefined): string | undefined {
14
+ return !release || release === "local" || release === "development" ? undefined : release;
15
+ }
16
+
17
+ /**
18
+ * Content version of a file below `public/`: hashed on first use and re-hashed only when the file's size or
19
+ * modification time changes; a missing or unreadable file has no version.
20
+ */
21
+ export function publicFileVersion(pathname: string): string | undefined {
22
+ const path = resolvePublicAsset(pathname);
23
+ if (!path) return undefined;
24
+ try {
25
+ const stat = statSync(path, { throwIfNoEntry: false });
26
+ if (!stat?.isFile()) return undefined;
27
+ const cached = versions.get(path);
28
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.version;
29
+ const version = createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
30
+ versions.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, version });
31
+ return version;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** Append a URL-encoded version only when the asset has one. */
38
+ export function versionedPublicHref(pathname: string, version: string | undefined): string {
39
+ return version === undefined ? pathname : `${pathname}?v=${encodeURIComponent(version)}`;
40
+ }
5
41
 
6
42
  function decodePathname(pathname: string): string | null {
7
43
  try {
@@ -48,14 +84,15 @@ async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.B
48
84
  return (await file.exists()) ? file : null;
49
85
  }
50
86
 
51
- export function servePublicAsset(isDevelopment: boolean) {
87
+ export function servePublicAsset(isDevelopment: boolean, sharedVersion?: string) {
52
88
  return async (c: Context) => {
53
89
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
54
90
  c.header("Allow", "GET, HEAD");
55
91
  return c.text("Method Not Allowed", 405);
56
92
  }
57
93
 
58
- const path = resolvePublicAsset(new URL(c.req.url).pathname);
94
+ const url = new URL(c.req.url);
95
+ const path = resolvePublicAsset(url.pathname);
59
96
  if (!path) return c.notFound();
60
97
 
61
98
  const sourceFile = Bun.file(path);
@@ -84,8 +121,20 @@ export function servePublicAsset(isDevelopment: boolean) {
84
121
  // Development rebuilds files in place under the same address, so the browser asks again on every use; an
85
122
  // unchanged file then costs one round trip instead of its whole size on every page.
86
123
  const etag = `"${selectedEncoding ?? "identity"}-${selected.size.toString(36)}-${selected.lastModified.toString(36)}"`;
124
+ // The page head links these files with the version this process names for them. Any other version may name
125
+ // other content (a rolling deploy, an app started before a Core redeploy), so it is revalidated, never kept.
126
+ let cacheControl = isDevelopment ? "no-cache" : "public, max-age=31536000, immutable";
127
+ if (!isDevelopment) {
128
+ const pathname = decodePathname(url.pathname)!;
129
+ const shared = /^\/public\/(fonts|tabler-icons|global)\.css$/.test(pathname);
130
+ const own = /^\/public\/[^/]+\/(app\.css|favicon\.svg)$/.test(pathname);
131
+ if (shared || own) {
132
+ const expected = shared ? sharedVersion : publicFileVersion(url.pathname);
133
+ if (expected === undefined || url.searchParams.get("v") !== expected) cacheControl = "no-cache";
134
+ }
135
+ }
87
136
  const headers = new Headers({
88
- "Cache-Control": isDevelopment ? "no-cache" : "public, max-age=31536000, immutable",
137
+ "Cache-Control": cacheControl,
89
138
  ETag: etag,
90
139
  Vary: "Accept-Encoding",
91
140
  });
@@ -2,6 +2,7 @@ import type { HtmlFn, RenderFn } from "@k2b/ssr";
2
2
  import { createSSRHandler } from "@k2b/ssr/hono";
3
3
  import type { Context, Env, MiddlewareHandler, TypedResponse } from "hono";
4
4
  import type { StatusCode } from "hono/utils/http-status";
5
+ import type { PanelConsentSnapshot } from "../contracts/panel-context";
5
6
  import type { RailSnapshot } from "../contracts/rail-preferences";
6
7
  import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
7
8
  import type { User } from "../contracts/shared";
@@ -13,6 +14,7 @@ type PageEnv<T extends object> = {
13
14
  page: Partial<T>;
14
15
  user?: User;
15
16
  railPreferences?: RailSnapshot;
17
+ panelConsent?: PanelConsentSnapshot;
16
18
  settings?: Unflatten<SettingsFlat<typeof CORE_SETTINGS>>;
17
19
  };
18
20
  };
@@ -425,7 +425,7 @@ function AccessEntryRow(props: {
425
425
  <Show
426
426
  when={props.entry.principal.type === "user"}
427
427
  fallback={
428
- <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
428
+ <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-secondary dark:bg-zinc-700">
429
429
  <i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
430
430
  </div>
431
431
  }
@@ -509,7 +509,7 @@ function AccessEntryRow(props: {
509
509
  aria-label={t().remove({ name: displayName() })}
510
510
  // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
511
511
  aria-description={props.lastManager ? removeLabel() : undefined}
512
- class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
512
+ class="focus-ui flex h-7 w-7 items-center justify-center rounded text-dimmed transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
513
513
  >
514
514
  <i class="ti ti-x text-sm" />
515
515
  </IconButton>
@@ -350,7 +350,7 @@ const ResultRow = (props: {
350
350
  <Show
351
351
  when={props.avatar}
352
352
  fallback={
353
- <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
353
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-secondary dark:bg-zinc-700">
354
354
  <i class={`ti ${props.icon} text-sm`} />
355
355
  </div>
356
356
  }