@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
@@ -0,0 +1,128 @@
1
+ import type { Topic, TopicConfig } from "@k2b/sync";
2
+ import { type SQL, sql } from "bun";
3
+ import type { z } from "zod";
4
+ import { lazySync } from "../_internal/process-sync";
5
+ import { logger } from "../services/logging";
6
+ import { createPgOutbox } from "../services/outbox";
7
+ import type { LiveEnvelope } from "./live-engine";
8
+
9
+ const RECONCILE_INTERVAL_MS = 1_000;
10
+ const STALE_CHECK_INTERVAL_MS = 60_000;
11
+
12
+ /**
13
+ * The live topic of one application. Frozen: Sync rejects a declaration that
14
+ * differs from the existing stream, so two releases with different values
15
+ * would break each other during a rollout. A change ships under a new id.
16
+ */
17
+ export const liveTopicConfig = (appId: string) =>
18
+ ({
19
+ id: `cloud:live:${appId}`,
20
+ owner: "cloud",
21
+ retention: { maxAgeMs: 24 * 3_600_000, maxBytes: 64 * 1024 ** 2 },
22
+ maxPayloadBytes: 40 * 1024,
23
+ deadLetterRetention: { maxBytes: 1024 ** 2 },
24
+ }) satisfies TopicConfig;
25
+
26
+ type LiveOutboxRow = { id: string; attempts: number; ordering_key: string; payload: LiveEnvelope };
27
+
28
+ const log = logger("events:live");
29
+
30
+ const liveTopics = lazySync((sync) => {
31
+ const topics = new Map<string, Topic<LiveEnvelope>>();
32
+ return (appId: string): Topic<LiveEnvelope> => {
33
+ const existing = topics.get(appId);
34
+ if (existing) return existing;
35
+ const topic = sync.topic<LiveEnvelope>(liveTopicConfig(appId));
36
+ topics.set(appId, topic);
37
+ return topic;
38
+ };
39
+ });
40
+
41
+ /** Dispatcher of one application's live rows: ordered per key, deleted once published. */
42
+ export const liveOutbox = (appId: string, publish: (row: LiveOutboxRow) => Promise<unknown>) =>
43
+ createPgOutbox<LiveOutboxRow>({
44
+ table: "events.outbox",
45
+ name: `events:live:${appId}`,
46
+ where: { kind: "live", app_id: appId },
47
+ orderBy: "ordering_key",
48
+ sequence: "seq",
49
+ reconcileIntervalMs: RECONCILE_INTERVAL_MS,
50
+ publish,
51
+ });
52
+
53
+ /** Applications whose live updates this process defines, with the wake of their running dispatcher. */
54
+ const dispatchers = new Map<string, (() => void) | null>();
55
+
56
+ export const liveTopic = (appId: string): Topic<LiveEnvelope> => liveTopics()(appId);
57
+
58
+ const logStaleRows = async (appId: string): Promise<void> => {
59
+ const [row] = await sql<{ count: number; oldest_seconds: number | null }[]>`
60
+ SELECT COUNT(*)::int AS count, EXTRACT(EPOCH FROM now() - MIN(created_at))::int AS oldest_seconds
61
+ FROM events.outbox
62
+ WHERE kind = 'live' AND app_id = ${appId} AND created_at < now() - interval '60 seconds'
63
+ `;
64
+ if (row && row.count > 0) log.warn("Live updates wait to be published", { appId, count: row.count, oldestSeconds: row.oldest_seconds });
65
+ };
66
+
67
+ /** Starts delivery for an application with registered publishers; shared with live sockets and access invalidations. */
68
+ export const startLiveDelivery = async (appId: string): Promise<(() => Promise<void>) | null> => {
69
+ if (!dispatchers.has(appId)) return null;
70
+ const [installed] = await sql<{ ready: boolean }[]>`
71
+ SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
72
+ `;
73
+ if (!installed?.ready) {
74
+ throw new Error(
75
+ `"${appId}" writes live updates to events.outbox, which does not exist. Update Cloud Core first: its migration creates the outbox.`,
76
+ );
77
+ }
78
+ const topic = liveTopics()(appId);
79
+ const outbox = liveOutbox(appId, (row) => topic.publish({ data: row.payload, orderingKey: row.ordering_key, idempotencyKey: row.id }));
80
+ outbox.start();
81
+ dispatchers.set(appId, () => void outbox.notify());
82
+ const staleCheck = setInterval(() => {
83
+ logStaleRows(appId).catch((error) =>
84
+ log.warn("Live outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
85
+ );
86
+ }, STALE_CHECK_INTERVAL_MS);
87
+ staleCheck.unref();
88
+ return async () => {
89
+ clearInterval(staleCheck);
90
+ dispatchers.set(appId, null);
91
+ await outbox.stop();
92
+ };
93
+ };
94
+
95
+ /** Transactional publisher using the application's existing live topic and outbox. */
96
+ export const defineLivePublisher = <const Event extends z.ZodType>(definition: { appId: string; event: Event }) => {
97
+ const { appId, event } = definition;
98
+ if (!dispatchers.has(appId)) dispatchers.set(appId, null);
99
+ return {
100
+ /**
101
+ * Writes one update in `tx`, the transaction that makes the change: a
102
+ * rollback writes nothing, a commit publishes it at least once. Data above
103
+ * 32 KiB becomes a reload hint for `key`; `data` must not hold anything a
104
+ * reader of `key` may not see. `access: true` announces that who may read
105
+ * `key` changed, in a row of its own before the data: every replica checks
106
+ * its subscribers of `key` again before it delivers the data, and its
107
+ * collections right after.
108
+ */
109
+ publish: async (
110
+ tx: SQL,
111
+ input: { key: string; data: z.input<Event>; access?: true } | { key: string; data?: undefined; access: true },
112
+ ): Promise<void> => {
113
+ // Store the JSON form of the input, and validate exactly that form here:
114
+ // the browser parses it, and data that does not survive JSON fails this write.
115
+ const data: unknown = input.data === undefined ? undefined : JSON.parse(JSON.stringify(input.data));
116
+ if (data !== undefined) event.parse(data);
117
+ const enqueue = (envelope: LiveEnvelope) =>
118
+ tx`SELECT events.enqueue(${crypto.randomUUID()}::uuid, ${appId}, 'live', ${input.key}, ${JSON.stringify(envelope)}::text::jsonb)`;
119
+ // The access change is a row of its own, so data too large for the outbox cannot drop it.
120
+ if (input.access) await enqueue({ v: 1, k: input.key, a: true });
121
+ if (data !== undefined) await enqueue({ v: 1, k: input.key, d: data });
122
+ },
123
+ /** Publishes committed updates now instead of within the next second. Call it after the commit. */
124
+ wake: (): void => dispatchers.get(appId)?.(),
125
+ /** The topic head. Read it before loading the snapshot it belongs to. */
126
+ cursor: (): Promise<string> => liveTopics()(appId).head(),
127
+ };
128
+ };
@@ -1,72 +1,18 @@
1
- import type { Topic, TopicConfig } from "@k2b/sync";
2
- import { type ServerWebSocket, type SQL, sql } from "bun";
1
+ import type { ServerWebSocket } from "bun";
3
2
  import { type Context, Hono } from "hono";
4
3
  import { upgradeWebSocket } from "hono/bun";
5
4
  import type { z } from "zod";
6
- import { lazySync } from "../_internal/process-sync";
5
+ import { guardAppAccessSocket } from "../server/middleware/app-access";
7
6
  import { type AuthContext, auth } from "../server/middleware/auth";
8
7
  import { rateLimit } from "../server/middleware/rate-limit";
9
- import { logger } from "../services/logging";
10
- import { createPgOutbox } from "../services/outbox";
11
8
  import * as settings from "../services/settings";
12
9
  import { publicCloudOrigin } from "../shared/app-url";
13
- import {
14
- createLiveEngine,
15
- type LiveChannel,
16
- type LiveConnectionHandle,
17
- type LiveEngine,
18
- type LiveEnvelope,
19
- type LiveViewer,
20
- } from "./live-engine";
10
+ import { defineLivePublisher, liveTopic, startLiveDelivery } from "./live-delivery";
11
+ import { createLiveEngine, type LiveChannel, type LiveConnectionHandle, type LiveEngine, type LiveViewer } from "./live-engine";
21
12
 
13
+ export { liveOutbox, liveTopicConfig } from "./live-delivery";
22
14
  export type { LiveChannel, LiveViewer } from "./live-engine";
23
15
 
24
- const RECONCILE_INTERVAL_MS = 1_000;
25
- const STALE_CHECK_INTERVAL_MS = 60_000;
26
-
27
- /**
28
- * The live topic of one application. Frozen: Sync rejects a declaration that
29
- * differs from the existing stream, so two releases with different values
30
- * would break each other during a rollout. A change ships under a new id.
31
- */
32
- export const liveTopicConfig = (appId: string) =>
33
- ({
34
- id: `cloud:live:${appId}`,
35
- owner: "cloud",
36
- retention: { maxAgeMs: 24 * 3_600_000, maxBytes: 64 * 1024 ** 2 },
37
- maxPayloadBytes: 40 * 1024,
38
- deadLetterRetention: { maxBytes: 1024 ** 2 },
39
- }) satisfies TopicConfig;
40
-
41
- type LiveOutboxRow = { id: string; attempts: number; ordering_key: string; payload: LiveEnvelope };
42
-
43
- const log = logger("events:live");
44
-
45
- const liveTopics = lazySync((sync) => {
46
- const topics = new Map<string, Topic<LiveEnvelope>>();
47
- return (appId: string): Topic<LiveEnvelope> => {
48
- const existing = topics.get(appId);
49
- if (existing) return existing;
50
- const topic = sync.topic<LiveEnvelope>(liveTopicConfig(appId));
51
- topics.set(appId, topic);
52
- return topic;
53
- };
54
- });
55
-
56
- /** Dispatcher of one application's live rows: ordered per key, deleted once published. */
57
- export const liveOutbox = (appId: string, publish: (row: LiveOutboxRow) => Promise<unknown>) =>
58
- createPgOutbox<LiveOutboxRow>({
59
- table: "events.outbox",
60
- name: `events:live:${appId}`,
61
- where: { kind: "live", app_id: appId },
62
- orderBy: "ordering_key",
63
- sequence: "seq",
64
- reconcileIntervalMs: RECONCILE_INTERVAL_MS,
65
- publish,
66
- });
67
-
68
- /** Applications whose live updates this process defines, with the wake of their running dispatcher. */
69
- const dispatchers = new Map<string, (() => void) | null>();
70
16
  /** Applications whose live socket this process mounts; only the started application may be among them. */
71
17
  const served = new Set<string>();
72
18
  /** Live sockets served by this process; they close when the application stops. */
@@ -80,23 +26,7 @@ export const stopLiveEngines = () => {
80
26
  for (const engine of engines) engine.stop();
81
27
  };
82
28
 
83
- const logStaleRows = async (appId: string): Promise<void> => {
84
- const [row] = await sql<{ count: number; oldest_seconds: number | null }[]>`
85
- SELECT COUNT(*)::int AS count, EXTRACT(EPOCH FROM now() - MIN(created_at))::int AS oldest_seconds
86
- FROM events.outbox
87
- WHERE kind = 'live' AND app_id = ${appId} AND created_at < now() - interval '60 seconds'
88
- `;
89
- if (row && row.count > 0) log.warn("Live updates wait to be published", { appId, count: row.count, oldestSeconds: row.oldest_seconds });
90
- };
91
-
92
- /**
93
- * Publishes the rows of the started application's live definitions. `app.start()`
94
- * calls it with the started application's ID. A definition of another
95
- * application only reads that application's cursor, and its rows are published
96
- * by that application. It does nothing without a definition of the started
97
- * application, and fails when this process mounts the socket of another
98
- * application or Core has not created the outbox yet.
99
- */
29
+ /** Starts delivery only for the application whose live sockets this process serves. */
100
30
  export const startLiveOutbox = async (appId: string): Promise<(() => Promise<void>) | null> => {
101
31
  const foreign = [...served].filter((other) => other !== appId);
102
32
  if (foreign.length > 0) {
@@ -104,35 +34,16 @@ export const startLiveOutbox = async (appId: string): Promise<(() => Promise<voi
104
34
  `This process mounts the live socket of "${foreign.join('", "')}", but starts "${appId}". Use the ID from the application's declaration.`,
105
35
  );
106
36
  }
107
- if (!dispatchers.has(appId)) return null;
108
- const [installed] = await sql<{ ready: boolean }[]>`
109
- SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
110
- `;
111
- if (!installed?.ready) {
112
- throw new Error(
113
- `"${appId}" writes live updates to events.outbox, which does not exist. Update Cloud Core first: its migration creates the outbox.`,
114
- );
115
- }
37
+ const stopDelivery = await startLiveDelivery(appId);
38
+ if (!stopDelivery) return null;
116
39
  // Delivery starts again: the engines stopped before give way to new ones.
117
40
  if (stopped) {
118
41
  engines.clear();
119
42
  stopped = false;
120
43
  }
121
- const topic = liveTopics()(appId);
122
- const outbox = liveOutbox(appId, (row) => topic.publish({ data: row.payload, orderingKey: row.ordering_key, idempotencyKey: row.id }));
123
- outbox.start();
124
- dispatchers.set(appId, () => void outbox.notify());
125
- const staleCheck = setInterval(() => {
126
- logStaleRows(appId).catch((error) =>
127
- log.warn("Live outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
128
- );
129
- }, STALE_CHECK_INTERVAL_MS);
130
- staleCheck.unref();
131
44
  return async () => {
132
45
  stopLiveEngines();
133
- clearInterval(staleCheck);
134
- dispatchers.set(appId, null);
135
- await outbox.stop();
46
+ await stopDelivery();
136
47
  };
137
48
  };
138
49
 
@@ -217,36 +128,9 @@ const refusalOf = async (c: Context<AuthContext>): Promise<Refusal | null> => {
217
128
  * write updates; only the application itself publishes and serves them.
218
129
  */
219
130
  export const defineLive = <const Event extends z.ZodType>(definition: { appId: string; event: Event }) => {
220
- const { appId, event } = definition;
221
- if (!dispatchers.has(appId)) dispatchers.set(appId, null);
131
+ const { appId } = definition;
222
132
  return {
223
- /**
224
- * Writes one update in `tx`, the transaction that makes the change: a
225
- * rollback writes nothing, a commit publishes it at least once. Data above
226
- * 32 KiB becomes a reload hint for `key`; `data` must not hold anything a
227
- * reader of `key` may not see. `access: true` announces that who may read
228
- * `key` changed, in a row of its own before the data: every replica checks
229
- * its subscribers of `key` again before it delivers the data, and its
230
- * collections right after.
231
- */
232
- publish: async (
233
- tx: SQL,
234
- input: { key: string; data: z.input<Event>; access?: true } | { key: string; data?: undefined; access: true },
235
- ): Promise<void> => {
236
- // Store the JSON form of the input, and validate exactly that form here:
237
- // the browser parses it, and data that does not survive JSON fails this write.
238
- const data: unknown = input.data === undefined ? undefined : JSON.parse(JSON.stringify(input.data));
239
- if (data !== undefined) event.parse(data);
240
- const enqueue = (envelope: LiveEnvelope) =>
241
- tx`SELECT events.enqueue(${crypto.randomUUID()}::uuid, ${appId}, 'live', ${input.key}, ${JSON.stringify(envelope)}::text::jsonb)`;
242
- // The access change is a row of its own, so data too large for the outbox cannot drop it.
243
- if (input.access) await enqueue({ v: 1, k: input.key, a: true });
244
- if (data !== undefined) await enqueue({ v: 1, k: input.key, d: data });
245
- },
246
- /** Publishes committed updates now instead of within the next second. Call it after the commit. */
247
- wake: (): void => dispatchers.get(appId)?.(),
248
- /** The topic head. Read it before loading the snapshot it belongs to. */
249
- cursor: (): Promise<string> => liveTopics()(appId).head(),
133
+ ...defineLivePublisher(definition),
250
134
  /**
251
135
  * The live socket with these channels. Mount it once, at `/api/<app>/live`,
252
136
  * in the application's own process: `app.start()` of another application fails.
@@ -261,7 +145,7 @@ export const defineLive = <const Event extends z.ZodType>(definition: { appId: s
261
145
  let engine: LiveEngine | null = null;
262
146
  const serve = (): LiveEngine => {
263
147
  if (engine && engines.has(engine)) return engine;
264
- engine = createLiveEngine({ appId, topic: () => liveTopics()(appId), channels });
148
+ engine = createLiveEngine({ appId, topic: () => liveTopic(appId), channels });
265
149
  engines.add(engine);
266
150
  // A socket that opens while the application stops closes with 1012 and starts nothing.
267
151
  if (stopped) engine.stop();
@@ -284,13 +168,13 @@ export const defineLive = <const Event extends z.ZodType>(definition: { appId: s
284
168
  const viewer = viewerOf(c);
285
169
  const revalidate = revalidator(c);
286
170
  let connection: LiveConnectionHandle | null = null;
287
- return {
171
+ return guardAppAccessSocket(c, appId, {
288
172
  onOpen: (_event, ws) => {
289
173
  connection = serve().open(ws.raw as ServerWebSocket<unknown>, viewer, revalidate);
290
174
  },
291
175
  onMessage: (message) => connection?.message(message.data),
292
176
  onClose: () => connection?.closed(),
293
- };
177
+ });
294
178
  }),
295
179
  );
296
180
  },
@@ -0,0 +1,95 @@
1
+ import { MapView, MapViewFrame, type MapViewProps, mapAttribution, useLocale } from "@k2b/ui";
2
+ import { createSignal, For, onCleanup, Show, splitProps } from "solid-js";
3
+ import type { z } from "zod";
4
+ import { invokeCapabilityWithDataSchema } from "../capabilities/client";
5
+ import { type GeoAvailabilityDataSchema, geoContract } from "../contracts/geo";
6
+ import { mapMessages } from "./map-messages";
7
+
8
+ export type CloudMapProps = Omit<MapViewProps, "style" | "attribution"> & {
9
+ /** The authorized SSR snapshot keeps the first browser render identical. */
10
+ availability?: z.infer<typeof GeoAvailabilityDataSchema>;
11
+ };
12
+
13
+ /** Cloud's same-origin, budgeted map. Domain lists and recovery actions belong to the host. */
14
+ export function CloudMap(props: CloudMapProps) {
15
+ const [local, map] = splitProps(props, ["availability", "onStateChange", "notice"]);
16
+ const locale = useLocale();
17
+ const [recovery, setRecovery] = createSignal("");
18
+ const controller = new AbortController();
19
+ onCleanup(() => controller.abort());
20
+ let generation = 0;
21
+ const onStateChange: NonNullable<MapViewProps["onStateChange"]> = (state) => {
22
+ local.onStateChange?.(state);
23
+ const current = ++generation;
24
+ if (state !== "error") {
25
+ setRecovery("");
26
+ return;
27
+ }
28
+ void invokeCapabilityWithDataSchema(
29
+ { kind: "query", appId: "core", capabilityId: geoContract.availability.id, input: {}, signal: controller.signal },
30
+ geoContract.availability.data,
31
+ )
32
+ .then((result) => {
33
+ if (!result.ok || controller.signal.aborted || current !== generation) return;
34
+ const availability = result.data.data;
35
+ const t = mapMessages.resolve([locale()]).t;
36
+ if (!availability.configured) setRecovery(t.notConfigured);
37
+ else if (availability.cloud.exhausted || availability.person.exhausted) {
38
+ const exhausted = availability.cloud.exhausted ? availability.cloud : availability.person;
39
+ const reset = new Intl.DateTimeFormat(locale(), { dateStyle: "short", timeStyle: "short" }).format(new Date(exhausted.resetsAt));
40
+ setRecovery(availability.cloud.exhausted ? t.cloudBudget({ reset }) : t.personBudget({ reset }));
41
+ }
42
+ })
43
+ .catch(() => {
44
+ /* MapView already displays its retry state. */
45
+ });
46
+ };
47
+ const language = () => encodeURIComponent(new Intl.Locale(locale()).language);
48
+ const enabled = () =>
49
+ !local.availability || (local.availability.configured && !local.availability.cloud.exhausted && !local.availability.person.exhausted);
50
+ return (
51
+ <Show
52
+ when={enabled()}
53
+ fallback={
54
+ <div class="relative" style={{ height: `${map.height}px` }}>
55
+ <MapViewFrame {...map} attribution={<CloudMapAttribution />} />
56
+ <Show when={local.notice}>
57
+ <div class="k2b-map-view__messages">
58
+ <div class="k2b-map-view__notice">{local.notice}</div>
59
+ </div>
60
+ </Show>
61
+ </div>
62
+ }
63
+ >
64
+ <MapView
65
+ {...map}
66
+ onStateChange={onStateChange}
67
+ notice={
68
+ <>
69
+ {local.notice}
70
+ <Show when={recovery()}>
71
+ <span role="status">{recovery()}</span>
72
+ </Show>
73
+ </>
74
+ }
75
+ style={{ light: `/api/geo/style?theme=light&locale=${language()}`, dark: `/api/geo/style?theme=dark&locale=${language()}` }}
76
+ attribution={<CloudMapAttribution />}
77
+ />
78
+ </Show>
79
+ );
80
+ }
81
+
82
+ export function CloudMapAttribution() {
83
+ return (
84
+ <For each={mapAttribution}>
85
+ {(item, index) => (
86
+ <>
87
+ {index() > 0 && " · "}
88
+ <a href={item.href} target="_blank" rel="noopener noreferrer">
89
+ {item.label}
90
+ </a>
91
+ </>
92
+ )}
93
+ </For>
94
+ );
95
+ }
@@ -0,0 +1,17 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const mapMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ notConfigured: "Maps are not set up. The saved places remain available.",
8
+ cloudBudget: ({ reset }: { reset: string }) => `The Cloud map budget is used up until ${reset}.`,
9
+ personBudget: ({ reset }: { reset: string }) => `Your map budget is used up until ${reset}.`,
10
+ },
11
+ de: {
12
+ notConfigured: "Karten sind nicht eingerichtet. Die gespeicherten Orte bleiben verfügbar.",
13
+ cloudBudget: ({ reset }) => `Das Kartenbudget der Cloud ist bis ${reset} aufgebraucht.`,
14
+ personBudget: ({ reset }) => `Dein Kartenbudget ist bis ${reset} aufgebraucht.`,
15
+ },
16
+ },
17
+ });
package/src/geo/ui.ts ADDED
@@ -0,0 +1 @@
1
+ export { CloudMap, CloudMapAttribution, type CloudMapProps } from "./CloudMap";
package/src/index.ts CHANGED
@@ -36,6 +36,8 @@ export type { RuntimeCompatibilityIssue } from "./_internal/runtime-compatibilit
36
36
  export { assessRuntimeCompatibility } from "./_internal/runtime-compatibility";
37
37
  export { buildRuntimeFromRegistry } from "./_internal/runtime-context";
38
38
  export { type AppSkillDefinition, skill } from "./ai/app-skills";
39
+ export type { AppAccessDeclaration, AppAccessRule, FixedAppAccessRule } from "./contracts/app-access";
40
+ export { appAccess } from "./contracts/app-access";
39
41
  export { defineCapabilities } from "./contracts/capabilities";
40
42
  export type {
41
43
  AnyBoundNotificationDefinition,
@@ -1,5 +1,41 @@
1
+ import { isIP } from "node:net";
1
2
  import type { Context } from "hono";
2
3
 
3
4
  // The gateway (packages/gateway/src/client-address.ts) resolves this address using GATEWAY_TRUSTED_PROXIES.
4
5
  export const clientIp = (c: Context): string =>
5
6
  c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
7
+
8
+ /** Stable network key for IP budgets: IPv4 addresses and IPv6 /64 networks. */
9
+ export const clientIpBucket = (ip: string): string => {
10
+ if (isIP(ip) !== 6) return `ip:${ip}`;
11
+
12
+ let address = ip.split("%", 1)[0]!;
13
+ if (address.includes(".")) {
14
+ const tail = address.lastIndexOf(":") + 1;
15
+ const value = address
16
+ .slice(tail)
17
+ .split(".")
18
+ .reduce((value, octet) => value * 256 + Number(octet), 0);
19
+ address = `${address.slice(0, tail)}${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
20
+ }
21
+ const [head = "", tail = ""] = address.split("::");
22
+ const left = head ? head.split(":") : [];
23
+ const right = tail ? tail.split(":") : [];
24
+ const groups = (address.includes("::") ? [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right] : left).map(
25
+ (group) => Number.parseInt(group, 16),
26
+ );
27
+ const ipv4 = (high: number, low: number) => `ip:${[high >> 8, high & 255, low >> 8, low & 255].join(".")}`;
28
+ const [g0, g1, g2, g3, g4, g5, g6 = 0, g7 = 0] = groups;
29
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) return ipv4(g6, g7);
30
+ // NAT64 (64:ff9b::/96) and Teredo (2001::/32, inverted) carry the client's IPv4 address in the low 32 bits;
31
+ // their /64 names a translator or relay that unrelated clients share.
32
+ if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return ipv4(g6, g7);
33
+ if (g0 === 0x2001 && g1 === 0) return ipv4(g6 ^ 0xffff, g7 ^ 0xffff);
34
+ // 6to4 (2002::/16) gives one IPv4 holder a whole /48; key it by that IPv4 address instead of 65,536 /64 budgets.
35
+ if (g0 === 0x2002) return ipv4(g1 ?? 0, g2 ?? 0);
36
+ // One subscriber usually has at least a /64; rotating host addresses must not reset the budget.
37
+ return `ip6:${groups
38
+ .slice(0, 4)
39
+ .map((group) => group.toString(16))
40
+ .join(":")}::/64`;
41
+ };
@@ -1,5 +1,6 @@
1
1
  export { buildMetadata } from "../_internal/build-metadata";
2
2
  export { loadPendingApprovals } from "../api/approvals-loader";
3
+ export { geoClient } from "../capabilities/geo-client";
3
4
  export { expectUserBackedActor, getUserBackedActor, isAppSessionActor, userFromActor } from "./actor";
4
5
  export type { ApiErrorBody, ApiErrorResponse, ApiErrorStatus } from "./api";
5
6
  export { api, respond, respondMessage } from "./api";
@@ -7,6 +8,7 @@ export type { CreateApiClientConfig } from "./api-client";
7
8
  export { api as apiClient } from "./api-client";
8
9
  export { appAssetPath } from "./app-assets";
9
10
  export type { AppContext } from "./app-context";
11
+ export { clientIp, clientIpBucket } from "./client-ip";
10
12
  export { defineHelp, defineHelpCollection, type HelpCollection, type HelpDefinition, type HelpDefinitionDocument } from "./help";
11
13
  export { DEFAULT_LOCALE, getLocale, LOCALE_COOKIE, LOCALE_HEADER, locale, preferredLocale, resolveLocale } from "./locale";
12
14
  export type {
@@ -39,6 +41,7 @@ export {
39
41
  v,
40
42
  validator,
41
43
  } from "./middleware";
44
+ export { guardAppAccessSocket } from "./middleware/app-access";
42
45
  export { RateLimitError, type RateLimiter, type RateLimiterConfig, type RateLimitResult, ratelimit } from "./ratelimit";
43
46
  export { REQUEST_IDLE_TIMEOUT_MAX_SECONDS, REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "./request-timeout";
44
47
  export type {
@@ -49,8 +52,6 @@ export type {
49
52
  AccessUser,
50
53
  AccessUserSource,
51
54
  EffectiveGroup,
52
- GeoPlace,
53
- GeoService,
54
55
  PageParams,
55
56
  Paginated,
56
57
  PermissionLevel,
@@ -72,8 +73,6 @@ export {
72
73
  fail,
73
74
  freeipa,
74
75
  generatePassword,
75
- geo,
76
- geoService,
77
76
  getAccess,
78
77
  getEffectiveGroupIds,
79
78
  getEffectiveGroups,