@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -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
  };
@@ -0,0 +1,56 @@
1
+ import type { Server } from "bun";
2
+
3
+ /** Matches the existing runtime worker drain deadline. */
4
+ export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
5
+
6
+ /** Bun supplies the listening server to fetch, including for default-export entrypoints. */
7
+ export const createHttpDrain = (options: { timeoutMs?: number } = {}) => {
8
+ type HttpServer = Pick<Server<unknown>, "stop">;
9
+ const isHttpServer = (value: unknown): value is HttpServer =>
10
+ typeof value === "object" && value !== null && "stop" in value && typeof value.stop === "function";
11
+ const servers = new Set<HttpServer>();
12
+ let stopping = false;
13
+ let draining: Promise<void> | undefined;
14
+ return {
15
+ get stopping() {
16
+ return stopping;
17
+ },
18
+ fetch: async (request: Request, transport: unknown, handle: (request: Request) => Response | Promise<Response>): Promise<Response> => {
19
+ const server = isHttpServer(transport) ? transport : undefined;
20
+ if (server) servers.add(server);
21
+ if (stopping) {
22
+ // A listener that had not served a request before the signal was not
23
+ // captured yet. Reject that first request and close it as well.
24
+ void server?.stop(false);
25
+ return Response.json({ status: "stopping" }, { status: 503, headers: { "Retry-After": "3", Connection: "close" } });
26
+ }
27
+ // Preserve Bun request identity for websocket upgrades, requestIP and
28
+ // per-request timeouts. Forced stop aborts the native request signal.
29
+ return handle(request);
30
+ },
31
+ stop: (): Promise<void> => {
32
+ stopping = true;
33
+ draining ??= (async () => {
34
+ let timer: ReturnType<typeof setTimeout> | undefined;
35
+ let timedOut = false;
36
+ try {
37
+ await Promise.race([
38
+ Promise.all([...servers].map((server) => server.stop(false))),
39
+ new Promise<void>((resolve) => {
40
+ timer = setTimeout(() => {
41
+ // Cancel application work as well as the transport, before
42
+ // lifecycle hooks can tear down NATS or other dependencies.
43
+ timedOut = true;
44
+ resolve();
45
+ }, options.timeoutMs ?? HTTP_DRAIN_TIMEOUT_MS);
46
+ }),
47
+ ]);
48
+ if (timedOut) await Promise.all([...servers].map((server) => server.stop(true)));
49
+ } finally {
50
+ clearTimeout(timer);
51
+ }
52
+ })();
53
+ return draining;
54
+ },
55
+ };
56
+ };
@@ -1,4 +1,5 @@
1
1
  import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
2
+ import { AppAccessRuleSchema } from "../contracts/app-access";
2
3
  import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
3
4
  import type { AppRegistryEntry } from "../contracts/registry";
4
5
  import { isOwnAppPath, SAME_ORIGIN_PATH } from "./app-path";
@@ -28,6 +29,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
28
29
  if (!isStringArray(value.routes) || value.routes.some((route) => !route.startsWith("/"))) {
29
30
  return invalid("routes", "an array of absolute paths");
30
31
  }
32
+ if (value.access !== undefined && !AppAccessRuleSchema.safeParse(value.access).success)
33
+ return invalid("access", "a declarative app access rule");
31
34
  if (value.startedAt !== undefined && !(typeof value.startedAt === "number" && Number.isFinite(value.startedAt))) {
32
35
  return invalid("startedAt", "a finite number");
33
36
  }
@@ -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
 
@@ -15,6 +15,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
15
15
  const searchTags = searchQueries?.flatMap((query) => query.universalSearch!.tags.flatMap((tag) => [tag.tag, ...(tag.aliases ?? [])]));
16
16
  return {
17
17
  id: e.id,
18
+ access: e.access,
18
19
  platformPermissions: e.platformPermissions ? [...e.platformPermissions] : undefined,
19
20
  name: e.name,
20
21
  icon: e.icon,
@@ -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 });
@@ -7,6 +7,7 @@ import type { RailSnapshot } from "../contracts/rail-preferences";
7
7
  import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
8
8
  import type { User } from "../contracts/shared";
9
9
  import type { CORE_SETTINGS } from "../services/settings/core-settings";
10
+ import { preloadDisplayApps } from "../ssr/runtime";
10
11
  import { measureServerPhase } from "./server-timing";
11
12
 
12
13
  type PageEnv<T extends object> = {
@@ -45,6 +46,7 @@ export const createStatusPreservingSsrHandler = <T extends object>(
45
46
  const handler = args[args.length - 1] as SsrHandler<E, T>;
46
47
 
47
48
  return createHandler<E>(...middlewares, async (context) => {
49
+ await preloadDisplayApps(context);
48
50
  const result = await measureServerPhase(context, "ssr_data", () => handler(context as Context<E & PageEnv<T>>));
49
51
  if (result instanceof Response) return result;
50
52
  await measureServerPhase(context, "ssr_finalize", () => finalizePage?.(context as unknown as Context<PageEnv<T>>));
@@ -4,11 +4,11 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "so
4
4
  import { createStore, reconcile } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
7
- import { groupDisplayName } from "../shared/account-display";
8
7
  import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
9
8
  import { isManagerEntry } from "./managers";
10
9
  import { accessMessages } from "./messages";
11
10
  import PrincipalPicker, { principalKey } from "./PrincipalPicker";
11
+ import { principalLabel } from "./principal-label";
12
12
  import { serviceAccountKindDisplay } from "./service-account-kind";
13
13
 
14
14
  // ─────────────────────────────────────────────────────────────────────────
@@ -43,7 +43,7 @@ type PermissionEditorProps = {
43
43
  grantAccess: (
44
44
  principal: Principal,
45
45
  permission: GrantableLevel,
46
- display?: { displayName: string; serviceAccountKind?: ServiceAccountKind },
46
+ display?: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
47
47
  scope?: string,
48
48
  ) => Promise<AccessEntry>;
49
49
 
@@ -145,16 +145,6 @@ const resolveEntryDisplay = (
145
145
  return defaults[permission] ?? defaults.none;
146
146
  };
147
147
 
148
- const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
149
- // Audiences always use the localized label; a server-supplied name is not translated.
150
- if (entry.principal.type === "authenticated") return t.allUsers;
151
- if (entry.principal.type === "public") return t.public;
152
- if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
153
- if (entry.principal.type === "user") return entry.principal.userId;
154
- if (entry.principal.type === "service_account") return entry.principal.serviceAccountId;
155
- return entry.principal.groupId;
156
- };
157
-
158
148
  const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
159
149
  switch (entry.principal.type) {
160
150
  case "user":
@@ -204,7 +194,10 @@ export default function PermissionEditor(props: PermissionEditorProps) {
204
194
  }
205
195
  }
206
196
 
207
- const grant = async (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => {
197
+ const grant = async (
198
+ principal: Principal,
199
+ display: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
200
+ ) => {
208
201
  if (disposed || grantPending() || updateMut.loading() || revokeMut.loading()) return;
209
202
  const key = principalKey(principal);
210
203
  if (entries.some((entry) => principalKey(entry.principal) === key)) return;
@@ -224,8 +217,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
224
217
  const entry = await props.grantAccess(principal, first.level, display, first.scope);
225
218
  if (disposed) return;
226
219
  const index = entries.findIndex((entry) => entry.id === id);
227
- if (index !== -1) setEntries(index, reconcile({ ...entry }));
228
- announce(t().accessGranted({ name: display.displayName }));
220
+ const saved = {
221
+ ...entry,
222
+ displayName: entry.displayName ?? display.displayName,
223
+ avatarHash: entry.avatarHash !== undefined ? entry.avatarHash : display.avatarHash,
224
+ serviceAccountKind: entry.serviceAccountKind ?? display.serviceAccountKind,
225
+ };
226
+ if (index !== -1) setEntries(index, reconcile(saved));
227
+ announce(t().accessGranted({ name: principalLabel(saved, locale()) }));
229
228
  } catch (err) {
230
229
  if (!disposed) setEntries((current) => current.filter((entry) => entry.id !== id));
231
230
  // Prompts are global, so a grant that fails after its dialog closed still says so.
@@ -264,7 +263,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
264
263
 
265
264
  const revokeMut = mutation.create<string | null, AccessEntry>({
266
265
  mutation: async (entry) => {
267
- const displayName = getEntryDisplayName(entry, t(), locale());
266
+ const displayName = principalLabel(entry, locale());
268
267
  const confirmed = await prompts.confirm(t().removeAccessConfirm({ name: displayName }), {
269
268
  title: t().removeAccess,
270
269
  variant: "danger",
@@ -395,7 +394,7 @@ function AccessEntryRow(props: {
395
394
  }) {
396
395
  const locale = useLocale();
397
396
  const t = () => accessMessages.resolve([locale()]).t;
398
- const displayName = () => getEntryDisplayName(props.entry, t(), locale());
397
+ const displayName = () => principalLabel(props.entry, locale());
399
398
  const entryKey = () => levelKey(props.entry.permission, props.entry.scope);
400
399
  const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
401
400
  const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;