@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,400 @@
1
+ import type { AvatarConfig, PersonAvatar, RobotAvatar } from "../contracts/avatar";
2
+
3
+ /**
4
+ * Draws built avatars as SVG: flat shapes on a 96 × 96 canvas with their own background, so they read the same on light
5
+ * and dark surfaces. The server serves this markup under the account's avatar URL and the browser previews the same
6
+ * markup while someone builds, so both always agree. The output contains only shapes drawn from fixed ids: no text,
7
+ * no links, no scripts and no input from the config other than ids.
8
+ *
9
+ * Raise `AVATAR_RENDERER_REVISION` whenever a drawing changes, so stored avatars get a new hash and caches refetch.
10
+ */
11
+ export const AVATAR_RENDERER_REVISION = 1;
12
+
13
+ type Swatch = { id: string; color: string };
14
+
15
+ /** Skin tones from light to very dark. */
16
+ export const SKIN_TONE_COLORS: Record<PersonAvatar["skin"], string> = {
17
+ s1: "#f6dcc9",
18
+ s2: "#eec19d",
19
+ s3: "#d79f70",
20
+ s4: "#b67a4c",
21
+ s5: "#8b5a37",
22
+ s6: "#5c3a24",
23
+ };
24
+
25
+ export const HAIR_COLORS: Record<PersonAvatar["hairColor"], string> = {
26
+ black: "#26201d",
27
+ dark: "#4b3024",
28
+ brown: "#83522f",
29
+ blond: "#dcaa52",
30
+ red: "#b9532d",
31
+ grey: "#9c9a98",
32
+ white: "#e9e6e0",
33
+ blue: "#3f6fd6",
34
+ pink: "#df749e",
35
+ };
36
+
37
+ /** One choice sets the background and the matching clothing and headwear colour. */
38
+ export const BACKGROUND_COLORS: Record<PersonAvatar["bg"], { background: string; accent: string }> = {
39
+ sky: { background: "#d7e8fb", accent: "#2f6fd0" },
40
+ mint: { background: "#d3efe1", accent: "#1d9268" },
41
+ sand: { background: "#f5e6c6", accent: "#c0802a" },
42
+ rose: { background: "#f7dbe1", accent: "#cc506c" },
43
+ lilac: { background: "#e5defa", accent: "#7354d4" },
44
+ stone: { background: "#e6e4e1", accent: "#57534e" },
45
+ peach: { background: "#fbe0cf", accent: "#d9632f" },
46
+ teal: { background: "#cdeded", accent: "#15837f" },
47
+ };
48
+
49
+ export const ROBOT_BODY_COLORS: Record<RobotAvatar["color"], string> = {
50
+ steel: "#9aa5b1",
51
+ white: "#e4e7eb",
52
+ blue: "#5b8def",
53
+ teal: "#2fb5a8",
54
+ coral: "#f07a5f",
55
+ yellow: "#f2c03a",
56
+ purple: "#9b7be8",
57
+ graphite: "#4b5563",
58
+ };
59
+
60
+ /** Swatches for pickers, in shape-set order. */
61
+ export const avatarSwatches = {
62
+ skin: Object.entries(SKIN_TONE_COLORS).map(([id, color]) => ({ id, color })) as Swatch[],
63
+ hairColor: Object.entries(HAIR_COLORS).map(([id, color]) => ({ id, color })) as Swatch[],
64
+ bg: Object.entries(BACKGROUND_COLORS).map(([id, colors]) => ({ id, color: colors.background })) as Swatch[],
65
+ robotColor: Object.entries(ROBOT_BODY_COLORS).map(([id, color]) => ({ id, color })) as Swatch[],
66
+ };
67
+
68
+ /** Darkens (`amount` towards black) or lightens (`towardsWhite`) a hex colour. */
69
+ export const mixColor = (hex: string, amount: number, towardsWhite = false): string => {
70
+ const value = Number.parseInt(hex.slice(1), 16);
71
+ const target = towardsWhite ? 255 : 0;
72
+ const channel = (shift: number) => {
73
+ const current = (value >> shift) & 255;
74
+ return Math.round(current + (target - current) * amount);
75
+ };
76
+ return `#${((1 << 24) | (channel(16) << 16) | (channel(8) << 8) | channel(0)).toString(16).slice(1)}`;
77
+ };
78
+
79
+ /** WCAG relative luminance of a hex colour. */
80
+ export const relativeLuminance = (hex: string): number => {
81
+ const value = Number.parseInt(hex.slice(1), 16);
82
+ const linear = (channel: number) => {
83
+ const srgb = channel / 255;
84
+ return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
85
+ };
86
+ return 0.2126 * linear((value >> 16) & 255) + 0.7152 * linear((value >> 8) & 255) + 0.0722 * linear(value & 255);
87
+ };
88
+
89
+ export const contrastRatio = (a: string, b: string): number => {
90
+ const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x) as [number, number];
91
+ return (light + 0.05) / (dark + 0.05);
92
+ };
93
+
94
+ /** Alpha-blends `top` over `bottom`; used where a face mark is drawn half transparent. */
95
+ export const blendColor = (top: string, bottom: string, alpha: number): string => {
96
+ const t = Number.parseInt(top.slice(1), 16);
97
+ const b = Number.parseInt(bottom.slice(1), 16);
98
+ const channel = (shift: number) => Math.round(((t >> shift) & 255) * alpha + ((b >> shift) & 255) * (1 - alpha));
99
+ return `#${((1 << 24) | (channel(16) << 16) | (channel(8) << 8) | channel(0)).toString(16).slice(1)}`;
100
+ };
101
+
102
+ const INK = "#1a1311";
103
+ const LIP = "#d99a8c";
104
+ const SCLERA_ALPHA = 0.8;
105
+
106
+ /**
107
+ * The colours of the face marks on one skin tone. Lines use the dark ink where it reaches 3:1 against the skin and a
108
+ * warm light tone where it does not, so eyes and mouth stay readable on every skin tone.
109
+ */
110
+ export const personFaceColors = (skin: PersonAvatar["skin"]) => {
111
+ const skinColor = SKIN_TONE_COLORS[skin];
112
+ const line = contrastRatio(INK, skinColor) >= 3 ? INK : LIP;
113
+ return { skin: skinColor, ink: INK, line, sclera: blendColor("#ffffff", skinColor, SCLERA_ALPHA) };
114
+ };
115
+
116
+ /** The horizontal span of each mouth; the drawing uses these numbers, and the moustache is wider than every one. */
117
+ export const PERSON_MOUTH_SPANS: Record<PersonAvatar["mouth"], [number, number]> = {
118
+ smile: [41, 55],
119
+ grin: [40, 56],
120
+ neutral: [42, 54],
121
+ oh: [45, 51],
122
+ smirk: [42, 55],
123
+ };
124
+ const MOUSTACHE_LEFT = 36.5;
125
+ const MOUSTACHE_RIGHT = 59.5;
126
+ /** A curved moustache wider than the mouth; never a narrow block under the nose. */
127
+ export const MOUSTACHE_PATH = `M${MOUSTACHE_LEFT} 59.5C${MOUSTACHE_LEFT + 3.5} 54.5 45 54.8 48 57C51 54.8 ${MOUSTACHE_RIGHT - 3.5} 54.5 ${MOUSTACHE_RIGHT} 59.5C${MOUSTACHE_RIGHT - 4.5} 60.6 51.5 59.6 48 58.8C44.5 59.6 ${MOUSTACHE_LEFT + 4.5} 60.6 ${MOUSTACHE_LEFT} 59.5Z`;
128
+
129
+ /** Front hair shapes. Every one is symmetric or soft; a hard side parting is not part of the shape set. */
130
+ const HAIR_FRONT: Partial<Record<PersonAvatar["hair"], string>> = {
131
+ short: "M25 44C23 25 34 17 48 17S73 25 71 44C67 33 59 28 48 28S29 33 25 44Z",
132
+ buzz: "M26.5 38C27.5 24 36 19.5 48 19.5S68.5 24 69.5 38C65 30 57 27 48 27S31 30 26.5 38Z",
133
+ long: "M25 44C24 26 34 17 48 17S71 24 71.5 42C63 35 55 27 48 24C42 30 32 36 25 44Z",
134
+ bob: "M25 40C24 22 36 16 48 16S72 22 71 40C64 36 56 34 48 34S32 36 25 40Z",
135
+ bun: "M25 44C23 25 34 17 48 17S73 25 71 44C67 33 59 28 48 28S29 33 25 44Z",
136
+ locs: "M25 42C24 24 35 17 48 17S72 24 71 42C66 33 58 29 48 29S30 33 25 42Z",
137
+ afro: "M25 42C24 24 35 17 48 17S72 24 71 42C66 33 58 29 48 29S30 33 25 42Z",
138
+ };
139
+ const CURLS: [number, number][] = [
140
+ [27.7, 35.1],
141
+ [31.9, 27.8],
142
+ [39.1, 22.8],
143
+ [48, 21],
144
+ [56.9, 22.8],
145
+ [64.1, 27.8],
146
+ [68.3, 35.1],
147
+ ];
148
+
149
+ const hasMoustache = (config: PersonAvatar) => config.beard === "moustache" || config.beard === "goatee" || config.beard === "full";
150
+
151
+ /** The mouth, drawn from its span in `PERSON_MOUTH_SPANS`. */
152
+ export const personMouthMarkup = (config: PersonAvatar): string => {
153
+ const { ink, line } = personFaceColors(config.skin);
154
+ const [left, right] = PERSON_MOUTH_SPANS[config.mouth];
155
+ const y = hasMoustache(config) ? 1.5 : 0;
156
+ const stroke = (d: string) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="2.4" stroke-linecap="round"/>`;
157
+ if (config.mouth === "smile") return stroke(`M${left} ${60 + y}Q48 ${66 + y} ${right} ${60 + y}`);
158
+ if (config.mouth === "grin")
159
+ return `<path d="M${left} ${59 + y}H${right}Q${right - 1} ${68 + y} 48 ${68 + y}T${left} ${59 + y}Z" fill="${ink}"/><path d="M${left + 2} ${59.4 + y}H${right - 2}V${61.2 + y}Q48 ${62.2 + y} ${left + 2} ${61.2 + y}Z" fill="#ffffff"/>`;
160
+ if (config.mouth === "neutral") return stroke(`M${left} ${62 + y}H${right}`);
161
+ if (config.mouth === "oh")
162
+ return `<ellipse cx="48" cy="${62 + y}" rx="${(right - left) / 2}" ry="3.6" fill="${ink}" stroke="${line}" stroke-width="1.2"/>`;
163
+ return stroke(`M${left} ${62 + y}Q50 ${64 + y} ${right} ${59 + y}`);
164
+ };
165
+
166
+ /** Both eyes; open eyes sit on a light sclera, closed eyes are lines in the contrasting line colour. */
167
+ export const personEyesMarkup = (config: PersonAvatar): string => {
168
+ const { ink, line, sclera } = personFaceColors(config.skin);
169
+ const scleraMark = (x: number) => `<ellipse cx="${x}" cy="47" rx="3.7" ry="4.3" fill="${sclera}"/>`;
170
+ const oval = (x: number) =>
171
+ `${scleraMark(x)}<ellipse cx="${x}" cy="47" rx="2.5" ry="3.4" fill="${ink}"/><circle cx="${x + 0.9}" cy="45.8" r=".9" fill="#ffffff"/>`;
172
+ const lid = (d: string) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="2.3" stroke-linecap="round"/>`;
173
+ const happy = (x: number) => lid(`M${x - 4} 48.5Q${x} 44 ${x + 4} 48.5`);
174
+ const eye = (x: number) => {
175
+ if (config.eyes === "dots") return `${scleraMark(x)}<circle cx="${x}" cy="47" r="2.9" fill="${ink}"/>`;
176
+ if (config.eyes === "ovals") return oval(x);
177
+ if (config.eyes === "happy") return happy(x);
178
+ return lid(`M${x - 4} 46.5Q${x} 50.5 ${x + 4} 46.5`);
179
+ };
180
+ return config.eyes === "wink" ? oval(39) + happy(57) : eye(39) + eye(57);
181
+ };
182
+
183
+ /** Glasses. Sunglasses cover the eyes, so on dark skin their lenses get an outline in the contrasting line colour. */
184
+ export const personGlassesMarkup = (config: PersonAvatar): string => {
185
+ const { ink, line } = personFaceColors(config.skin);
186
+ const frame = `fill="none" stroke="${ink}" stroke-width="2"`;
187
+ if (config.glasses === "round")
188
+ return `<circle cx="39" cy="47" r="6.4" ${frame}/><circle cx="57" cy="47" r="6.4" ${frame}/><path d="M45.4 46.5Q48 45 50.6 46.5M32.6 46L27 45M63.4 46L69 45" ${frame}/>`;
189
+ if (config.glasses === "square")
190
+ return `<rect x="31.5" y="41.8" width="15" height="10.8" rx="3" ${frame}/><rect x="49.5" y="41.8" width="15" height="10.8" rx="3" ${frame}/><path d="M46.5 46H49.5M31.5 45.5L27 45M64.5 45.5L69 45" ${frame}/>`;
191
+ if (config.glasses === "half")
192
+ return `<path d="M31.5 43.5Q39 40.5 46.5 43.5M49.5 43.5Q57 40.5 64.5 43.5" fill="none" stroke="${ink}" stroke-width="3"/><path d="M31.5 44Q32 53 39 53T46.5 44M49.5 44Q50 53 57 53T64.5 44M46.5 44.5H49.5" fill="none" stroke="${ink}" stroke-width="1.1" opacity=".7"/>`;
193
+ if (config.glasses === "sun") {
194
+ const outline = line === ink ? "" : ` stroke="${line}" stroke-width="1.4"`;
195
+ return `<path d="M31 43H47V47Q47 54 39 54T31 47ZM49 43H65V47Q65 54 57 54T49 47Z" fill="${ink}"${outline}/><path d="M47 44.5H49M31 44L27 44.5M65 44L69 44.5" ${frame}/>`;
196
+ }
197
+ return "";
198
+ };
199
+
200
+ /** Fixed crops: the whole avatar, or the head for option thumbnails in a picker. */
201
+ export const AVATAR_CROPS = { full: "0 0 96 96", personThumbnail: "12 2 72 72", robotThumbnail: "8 0 80 80" } as const;
202
+ export type AvatarCrop = "full" | "thumbnail";
203
+
204
+ const svg = (parts: string[], viewBox: string) =>
205
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="96" height="96">${parts.join("")}</svg>`;
206
+
207
+ export function renderPersonAvatar(config: PersonAvatar, crop: AvatarCrop = "full"): string {
208
+ const { skin: skinColor } = personFaceColors(config.skin);
209
+ const skinShade = mixColor(skinColor, 0.13);
210
+ const hair = HAIR_COLORS[config.hairColor];
211
+ const hairBack = mixColor(hair, 0.12);
212
+ const { background, accent } = BACKGROUND_COLORS[config.bg];
213
+ const accentShade = mixColor(accent, 0.18);
214
+ const hidesHair = config.head === "hijab" || config.head === "turban";
215
+ const hidesFrontHair = hidesHair || config.head === "beanie";
216
+ const p: string[] = [`<rect width="96" height="96" fill="${background}"/>`];
217
+
218
+ if (!hidesHair) {
219
+ if (config.hair === "afro") p.push(`<circle cx="48" cy="37" r="31" fill="${hairBack}"/>`);
220
+ if (config.hair === "long") p.push(`<path d="M22 46C20 22 33 13 48 13S76 22 74 46L77 86C64 92 32 92 19 86Z" fill="${hairBack}"/>`);
221
+ if (config.hair === "bob")
222
+ p.push(`<path d="M21 46C19 22 33 13 48 13S77 22 75 46L76 68C70 72 65 71 63 66H33C31 71 26 72 20 68Z" fill="${hairBack}"/>`);
223
+ if (config.hair === "locs") {
224
+ for (let x = 20; x <= 72; x += 6.5)
225
+ p.push(`<rect x="${x}" y="26" width="6" height="${x < 30 || x > 64 ? 58 : 44}" rx="3" fill="${hairBack}"/>`);
226
+ }
227
+ }
228
+
229
+ p.push(`<path d="M12 98C14 83 28 76 48 76S82 83 84 98Z" fill="${accent}"/>`);
230
+ if (config.head === "hijab") {
231
+ p.push(`<path d="M17 96C12 64 17 15 48 13S84 64 79 96Z" fill="${accent}"/>`);
232
+ } else {
233
+ p.push(`<rect x="41" y="60" width="14" height="19" rx="4" fill="${skinShade}"/><path d="M40 76Q48 84 56 76" fill="${skinShade}"/>`);
234
+ }
235
+
236
+ const earX = config.face === "oval" ? 27 : config.face === "square" ? 26 : 25.5;
237
+ if (config.head !== "hijab") {
238
+ p.push(`<circle cx="${earX}" cy="48" r="5.2" fill="${skinColor}"/><circle cx="${96 - earX}" cy="48" r="5.2" fill="${skinColor}"/>`);
239
+ }
240
+ if (config.face === "square") p.push(`<rect x="26" y="21" width="44" height="50" rx="15" fill="${skinColor}"/>`);
241
+ else if (config.face === "oval") p.push(`<ellipse cx="48" cy="46" rx="21" ry="27" fill="${skinColor}"/>`);
242
+ else p.push(`<ellipse cx="48" cy="46" rx="22.5" ry="25" fill="${skinColor}"/>`);
243
+
244
+ if (config.extra === "blush")
245
+ p.push(`<circle cx="34" cy="56" r="4.5" fill="#f07a7a" opacity=".32"/><circle cx="62" cy="56" r="4.5" fill="#f07a7a" opacity=".32"/>`);
246
+ if (config.extra === "freckles") {
247
+ const freckle = mixColor(skinColor, 0.32);
248
+ for (const [x, y] of [
249
+ [33, 53],
250
+ [36, 55.5],
251
+ [31, 56],
252
+ [63, 53],
253
+ [60, 55.5],
254
+ [65, 56],
255
+ ])
256
+ p.push(`<circle cx="${x}" cy="${y}" r="1" fill="${freckle}"/>`);
257
+ }
258
+ if (config.extra === "mole") p.push(`<circle cx="60" cy="61" r="1.2" fill="${mixColor(skinColor, 0.45)}"/>`);
259
+ p.push(`<ellipse cx="48" cy="53.5" rx="2.6" ry="2" fill="${skinShade}"/>`);
260
+
261
+ if (config.beard === "stubble")
262
+ p.push(`<path d="M27 52C28 68 38 74 48 74S68 68 69 52C64 62 58 66 48 66S32 62 27 52Z" fill="${hair}" opacity=".3"/>`);
263
+ if (config.beard === "full")
264
+ p.push(
265
+ `<path d="M25 47C25 68 36 78 48 78S71 68 71 47C68 56 64 60 60 58S52 56 48 56S40 56 36 58S28 56 25 47Z" fill="${hair}"/><ellipse cx="48" cy="61" rx="6.5" ry="3.4" fill="${skinColor}"/>`,
266
+ );
267
+ if (config.beard === "goatee") p.push(`<path d="M42.5 64C43.5 71 52.5 71 53.5 64Q48 66.5 42.5 64Z" fill="${hair}"/>`);
268
+ if (hasMoustache(config)) p.push(`<path d="${MOUSTACHE_PATH}" fill="${hair}"/>`);
269
+
270
+ p.push(personMouthMarkup(config), personEyesMarkup(config));
271
+
272
+ const browColor = mixColor(config.hairColor === "white" ? "#a8a49e" : hair, 0.25);
273
+ const brows: Record<Exclude<PersonAvatar["brows"], "none">, [string, string, number]> = {
274
+ straight: ["M35 39.5H43", "M53 39.5H61", 2.4],
275
+ arched: ["M35 40.5Q39 36.5 43 39.5", "M53 39.5Q57 36.5 61 40.5", 2.4],
276
+ thick: ["M34.5 40.5L43.5 39", "M52.5 39L61.5 40.5", 3.8],
277
+ raised: ["M35 37.5Q39 34 43 37", "M53 39.5H61", 2.4],
278
+ };
279
+ if (config.brows !== "none") {
280
+ const [left, right, width] = brows[config.brows];
281
+ p.push(`<path d="${left}${right}" fill="none" stroke="${browColor}" stroke-width="${width}" stroke-linecap="round"/>`);
282
+ }
283
+
284
+ if (!hidesFrontHair) {
285
+ if (config.hair === "bun") p.push(`<circle cx="48" cy="14" r="8.5" fill="${hair}"/>`);
286
+ if (config.hair === "curly") {
287
+ p.push(`<path d="M25 44C23 25 34 18 48 18S73 25 71 44C67 34 59 30 48 30S29 34 25 44Z" fill="${hair}"/>`);
288
+ for (const [x, cy] of CURLS) p.push(`<circle cx="${x}" cy="${cy}" r="7.4" fill="${hair}"/>`);
289
+ } else {
290
+ const front = HAIR_FRONT[config.hair];
291
+ if (front) p.push(`<path d="${front}" fill="${hair}"${config.hair === "buzz" ? ' opacity=".9"' : ""}/>`);
292
+ }
293
+ }
294
+
295
+ if (config.head === "beanie")
296
+ p.push(
297
+ `<path d="M23 42C22 18 35 11 48 11S74 18 73 42Z" fill="${accent}"/><rect x="21" y="34" width="54" height="10" rx="5" fill="${accentShade}"/><circle cx="48" cy="9" r="4.5" fill="${accentShade}"/>`,
298
+ );
299
+ if (config.head === "cap")
300
+ p.push(
301
+ `<path d="M24 38C24 20 35 13 48 13S72 20 72 38Z" fill="${accent}"/><path d="M24 37C38 32.5 62 33 81 39.5C73 44 57 41.5 47 40.5S29 40 24 37Z" fill="${accentShade}"/>`,
302
+ );
303
+ if (config.head === "hijab")
304
+ p.push(`<path d="M24 47C21 24 34 15 48 15S75 24 72 47C69 33 60 27 48 27S27 33 24 47Z" fill="${accentShade}"/>`);
305
+ if (config.head === "turban")
306
+ p.push(
307
+ `<path d="M22 42C18 18 34 9 48 10S78 18 74 42C68 33 58 29 48 33C38 29 28 33 22 42Z" fill="${accent}"/><path d="M29 23Q48 35 67 20" fill="none" stroke="${accentShade}" stroke-width="2.6" stroke-linecap="round"/>`,
308
+ );
309
+ if (config.head === "kippa") p.push(`<path d="M38 22.5Q48 13 58 22.5Q48 20 38 22.5Z" fill="${accent}"/>`);
310
+ if (config.head === "phones")
311
+ p.push(
312
+ `<path d="M24 45C21 14 75 14 72 45" fill="none" stroke="#3a3330" stroke-width="3.4"/><rect x="18.5" y="40" width="9" height="15" rx="4.5" fill="#3a3330"/><rect x="68.5" y="40" width="9" height="15" rx="4.5" fill="#3a3330"/>`,
313
+ );
314
+
315
+ p.push(personGlassesMarkup(config));
316
+ if (config.extra === "earrings" && config.head !== "hijab")
317
+ p.push(`<circle cx="${earX}" cy="55" r="2" fill="#e2b13c"/><circle cx="${96 - earX}" cy="55" r="2" fill="#e2b13c"/>`);
318
+
319
+ return svg(p, crop === "full" ? AVATAR_CROPS.full : AVATAR_CROPS.personThumbnail);
320
+ }
321
+
322
+ const SCREEN = "#1f2430";
323
+ const LIGHT = "#fbbf24";
324
+ const GLOW = "#7dd3fc";
325
+
326
+ /** Lines on a robot: light on dark bodies, dark on light ones. */
327
+ export const robotLineColor = (color: RobotAvatar["color"]): string =>
328
+ relativeLuminance(ROBOT_BODY_COLORS[color]) < 0.2 ? "#f8fafc" : SCREEN;
329
+
330
+ export function renderRobotAvatar(config: RobotAvatar, crop: AvatarCrop = "full"): string {
331
+ const body = ROBOT_BODY_COLORS[config.color];
332
+ const shade = mixColor(body, 0.2);
333
+ const line = robotLineColor(config.color);
334
+ const heads: Record<RobotAvatar["head"], { top: number; shape: string }> = {
335
+ square: { top: 22, shape: `<rect x="22" y="22" width="52" height="44" rx="12" fill="${body}"/>` },
336
+ round: { top: 19, shape: `<circle cx="48" cy="44" r="25" fill="${body}"/>` },
337
+ capsule: { top: 26, shape: `<rect x="20" y="26" width="56" height="38" rx="19" fill="${body}"/>` },
338
+ tv: {
339
+ top: 20,
340
+ shape: `<rect x="24" y="20" width="48" height="46" rx="7" fill="${body}"/><rect x="28" y="24" width="40" height="38" rx="5" fill="${mixColor(body, 0.08, true)}"/>`,
341
+ },
342
+ };
343
+ const { top, shape } = heads[config.head];
344
+ const p: string[] = [`<rect width="96" height="96" fill="${BACKGROUND_COLORS[config.bg].background}"/>`];
345
+
346
+ if (config.antenna === "ball")
347
+ p.push(
348
+ `<path d="M48 ${top + 2}V${top - 8}" stroke="${shade}" stroke-width="3" stroke-linecap="round"/><circle cx="48" cy="${top - 11}" r="4.2" fill="${LIGHT}"/>`,
349
+ );
350
+ if (config.antenna === "double")
351
+ p.push(
352
+ `<path d="M40 ${top + 3}L34 ${top - 7}M56 ${top + 3}L62 ${top - 7}" stroke="${shade}" stroke-width="3" stroke-linecap="round"/><circle cx="33" cy="${top - 9}" r="3.2" fill="${LIGHT}"/><circle cx="63" cy="${top - 9}" r="3.2" fill="${LIGHT}"/>`,
353
+ );
354
+ if (config.antenna === "dish")
355
+ p.push(
356
+ `<path d="M48 ${top + 2}V${top - 5}" stroke="${shade}" stroke-width="3"/><path d="M38 ${top - 5}Q48 ${top - 17} 58 ${top - 5}Z" fill="${shade}"/>`,
357
+ );
358
+
359
+ p.push(
360
+ `<rect x="42" y="60" width="12" height="16" rx="3" fill="${shade}"/><rect x="16" y="74" width="64" height="34" rx="14" fill="${body}"/>`,
361
+ );
362
+ if (config.body === "panel") p.push(`<rect x="34" y="81" width="28" height="13" rx="4" fill="${shade}"/>`);
363
+ if (config.body === "light") p.push(`<circle cx="48" cy="87" r="5" fill="${LIGHT}"/>`);
364
+ if (config.body === "buttons") for (const x of [40, 48, 56]) p.push(`<circle cx="${x}" cy="86" r="2.6" fill="${shade}"/>`);
365
+
366
+ const earX = config.head === "round" ? 19 : config.head === "capsule" ? 14 : 16;
367
+ p.push(
368
+ `<rect x="${earX}" y="37" width="8" height="14" rx="3" fill="${shade}"/><rect x="${88 - earX}" y="37" width="8" height="14" rx="3" fill="${shade}"/>`,
369
+ );
370
+ p.push(shape);
371
+
372
+ const roundEye = (x: number) => `<circle cx="${x}" cy="42" r="6.5" fill="#ffffff"/><circle cx="${x}" cy="42" r="2.9" fill="${SCREEN}"/>`;
373
+ if (config.eyes === "round") p.push(roundEye(38) + roundEye(58));
374
+ if (config.eyes === "visor")
375
+ p.push(
376
+ `<rect x="28" y="36" width="40" height="13" rx="6.5" fill="${SCREEN}"/><rect x="33" y="40.8" width="30" height="3.6" rx="1.8" fill="${GLOW}"/>`,
377
+ );
378
+ if (config.eyes === "pixel")
379
+ p.push(
380
+ `<rect x="29" y="33" width="38" height="19" rx="6" fill="${SCREEN}"/><rect x="35.5" y="39" width="5.5" height="6.5" rx="1" fill="${GLOW}"/><rect x="55" y="39" width="5.5" height="6.5" rx="1" fill="${GLOW}"/>`,
381
+ );
382
+ if (config.eyes === "cyclops") p.push(`<circle cx="48" cy="42" r="9" fill="#ffffff"/><circle cx="48" cy="42" r="4" fill="${SCREEN}"/>`);
383
+ if (config.eyes === "happy")
384
+ p.push(`<path d="M33 44Q38 38 43 44M53 44Q58 38 63 44" fill="none" stroke="${line}" stroke-width="2.8" stroke-linecap="round"/>`);
385
+
386
+ if (config.mouth === "grill")
387
+ p.push(
388
+ `<rect x="38" y="54" width="20" height="7" rx="2.5" fill="${SCREEN}"${line === SCREEN ? "" : ` stroke="${line}" stroke-width="1.2"`}/><path d="M43 55.5V59.5M48 55.5V59.5M53 55.5V59.5" stroke="${body}" stroke-width="1.6" stroke-linecap="round"/>`,
389
+ );
390
+ if (config.mouth === "smile")
391
+ p.push(`<path d="M40 54.5Q48 61.5 56 54.5" fill="none" stroke="${line}" stroke-width="2.8" stroke-linecap="round"/>`);
392
+ if (config.mouth === "led") for (const x of [40.5, 45.5, 50.5, 55.5]) p.push(`<circle cx="${x}" cy="57" r="2" fill="${LIGHT}"/>`);
393
+ if (config.mouth === "line") p.push(`<path d="M41 57H55" stroke="${line}" stroke-width="2.8" stroke-linecap="round"/>`);
394
+
395
+ return svg(p, crop === "full" ? AVATAR_CROPS.full : AVATAR_CROPS.robotThumbnail);
396
+ }
397
+
398
+ /** Renders any stored built avatar, whole or as the head crop a picker uses for option thumbnails. */
399
+ export const renderAvatarSvg = (config: AvatarConfig, crop: AvatarCrop = "full"): string =>
400
+ config.kind === "person" ? renderPersonAvatar(config, crop) : renderRobotAvatar(config, crop);
@@ -3,6 +3,7 @@
3
3
  // Re-export from stdlib for backward compatibility
4
4
  // Prefer importing directly from @k2b/stdlib
5
5
  export { dates, dates as calendar, encoding, fileIcons, gradients } from "@k2b/stdlib";
6
+ export { principalLabel } from "../access/principal-label";
6
7
  export {
7
8
  ACCOUNT_ACTION_NOTICE_SAMPLE,
8
9
  type AccountActionNoticeInput,
@@ -34,6 +35,7 @@ export {
34
35
  } from "./ai-usage";
35
36
  export * from "./app-presentation";
36
37
  export * from "./app-url";
38
+ export * from "./avatar-art";
37
39
  export * from "./branding";
38
40
  export * from "./email-html";
39
41
  export * from "./format";
@@ -111,16 +111,34 @@ export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
111
111
  export type NetworkAllowlist = {
112
112
  readonly hosts: ReadonlySet<string>;
113
113
  readonly networks: ReadonlyArray<Readonly<{ address: string; family: 4 | 6; bits: number }>>;
114
+ /** Exact hosts/IPs restricted to a port. Optional for existing callers constructing lists. */
115
+ readonly hostPorts?: ReadonlyMap<string, ReadonlySet<number>>;
114
116
  };
115
117
 
116
118
  export const EMPTY_NETWORK_ALLOWLIST: NetworkAllowlist = { hosts: new Set(), networks: [] };
117
119
 
118
120
  export const parseNetworkAllowlist = (entries: readonly string[]): NetworkAllowlist => {
119
121
  const hosts = new Set<string>();
122
+ const hostPorts = new Map<string, Set<number>>();
120
123
  const networks: Array<{ address: string; family: 4 | 6; bits: number }> = [];
121
124
  for (const entry of entries) {
122
125
  const invalid = (reason = "expected an exact host name, IP address or CIDR") =>
123
126
  new Error(`Invalid network allowlist entry "${entry}": ${reason}`);
127
+ const portMatch = entry.match(/^(?:\[([^\]]+)\]|([^:/\[\]]+)):([^:]*)$/);
128
+ if (portMatch) {
129
+ const rawHost = portMatch[1] ?? portMatch[2]!;
130
+ const rawPort = portMatch[3]!;
131
+ if (!/^[1-9]\d{0,4}$/.test(rawPort) || Number(rawPort) > 65535)
132
+ throw invalid("expected a port from 1 to 65535 without leading zeros");
133
+ if (portMatch[1] && parseNetworkAddress(rawHost)?.family !== 6) throw invalid();
134
+ const parsed = parseNetworkAllowlist([rawHost]);
135
+ const host = [...parsed.hosts][0] ?? parsed.networks[0]?.address;
136
+ if (!host) throw invalid();
137
+ const ports = hostPorts.get(host) ?? new Set<number>();
138
+ ports.add(Number(rawPort));
139
+ hostPorts.set(host, ports);
140
+ continue;
141
+ }
124
142
  const parts = entry.split("/");
125
143
  const raw = parts[0] ?? "";
126
144
  const literal = parseNetworkAddress(raw);
@@ -160,7 +178,7 @@ export const parseNetworkAllowlist = (entries: readonly string[]): NetworkAllowl
160
178
  throw invalid();
161
179
  hosts.add(hostname);
162
180
  }
163
- return { hosts, networks };
181
+ return hostPorts.size ? { hosts, networks, hostPorts } : { hosts, networks };
164
182
  };
165
183
 
166
184
  /** Parse operator configuration with the variable name in startup errors. */
@@ -172,15 +190,16 @@ export const parseNetworkAllowlistEnv = (variable: string, entries: readonly str
172
190
  }
173
191
  };
174
192
 
175
- export const isAllowlistedNetworkAddress = (allowlist: NetworkAllowlist, rawAddress: string): boolean => {
193
+ export const isAllowlistedNetworkAddress = (allowlist: NetworkAllowlist, rawAddress: string, port?: number): boolean => {
176
194
  const literal = parseNetworkAddress(rawAddress);
177
195
  if (!literal) return false;
196
+ if (port !== undefined && allowlist.hostPorts?.get(literal.address)?.has(port)) return true;
178
197
  if (literal.family === 6) {
179
198
  const value = ipv6ToBigInt(literal.address);
180
199
  // Only IPv4-mapped IPv6 reaches the embedded IPv4 host directly.
181
200
  if (value >> 32n === 0xffffn) {
182
201
  const ipv4 = [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 255n)).join(".");
183
- if (isAllowlistedNetworkAddress(allowlist, ipv4)) return true;
202
+ if (isAllowlistedNetworkAddress(allowlist, ipv4, port)) return true;
184
203
  }
185
204
  return allowlist.networks.some((network) => network.family === 6 && ipv6InRange(value, ipv6ToBigInt(network.address), network.bits));
186
205
  }
@@ -198,10 +198,42 @@ const createEngine = (options: LiquidTemplateOptions = {}) => {
198
198
  const defaultEngine = createEngine();
199
199
  const engineFor = (options: LiquidTemplateOptions = {}) => (Object.keys(options).length > 0 ? createEngine(options) : defaultEngine);
200
200
 
201
- export const migrateLegacyMustacheTemplate = (template: string): string =>
202
- template
203
- .replace(/{{#\s*([A-Za-z_][A-Za-z0-9_]*)\s*}}([\s\S]*?){{\/\s*\1\s*}}/g, "{% if $1 != blank %}$2{% endif %}")
204
- .replace(/{{\^\s*([A-Za-z_][A-Za-z0-9_]*)\s*}}([\s\S]*?){{\/\s*\1\s*}}/g, "{% if $1 == blank %}$2{% endif %}");
201
+ // Two linear passes preserve the legacy positive-then-inverted replacement order.
202
+ // Index closing tags once instead of rescanning the suffix for each unmatched opener.
203
+ const migrateLegacySections = (template: string, kind: "#" | "^"): string => {
204
+ const tags = [...template.matchAll(/{{([#^/])\s*([A-Za-z_][A-Za-z0-9_]*)\s*}}/g)];
205
+ const nextClose = new Map<string, number>();
206
+ const pairs = new Map<number, number>();
207
+ for (let i = tags.length - 1; i >= 0; i--) {
208
+ const tag = tags[i]!;
209
+ const name = tag[2]!;
210
+ if (tag[1] === "/") nextClose.set(name, i);
211
+ else if (tag[1] === kind) {
212
+ const close = nextClose.get(name);
213
+ if (close !== undefined) pairs.set(i, close);
214
+ }
215
+ }
216
+ const chunks: string[] = [];
217
+ let cursor = 0;
218
+ for (let i = 0; i < tags.length; i++) {
219
+ const close = pairs.get(i);
220
+ if (close === undefined) continue;
221
+ const openTag = tags[i]!;
222
+ const closeTag = tags[close]!;
223
+ chunks.push(
224
+ template.slice(cursor, openTag.index),
225
+ `{% if ${openTag[2]} ${kind === "#" ? "!=" : "=="} blank %}`,
226
+ template.slice(openTag.index + openTag[0].length, closeTag.index),
227
+ "{% endif %}",
228
+ );
229
+ cursor = closeTag.index + closeTag[0].length;
230
+ i = close;
231
+ }
232
+ chunks.push(template.slice(cursor));
233
+ return chunks.join("");
234
+ };
235
+
236
+ export const migrateLegacyMustacheTemplate = (template: string): string => migrateLegacySections(migrateLegacySections(template, "#"), "^");
205
237
 
206
238
  export const validateLiquidTemplate = (
207
239
  template: string,
@@ -31,7 +31,7 @@ type DocumentLog = {
31
31
  */
32
32
  const FALLBACK_CLIENT_ID = 0;
33
33
 
34
- const createDoc = (stored: SharedTextStoredState): Y.Doc => {
34
+ export const createDoc = (stored: SharedTextStoredState): Y.Doc => {
35
35
  const doc = new Y.Doc({ gc: true });
36
36
  try {
37
37
  if (stored.state) Y.applyUpdate(doc, stored.state, "snapshot");
@@ -9,4 +9,6 @@ export {
9
9
  toBase64,
10
10
  } from "./events";
11
11
  export { sharedText } from "./log";
12
+ export type { SharedTextSnapshotBacklog, SharedTextSnapshotRequest, SharedTextSnapshotStore } from "./snapshots";
13
+ export { sharedTextSnapshotJobConfig } from "./snapshots";
12
14
  export * from "./types";
@@ -1,9 +1,10 @@
1
- import type { TopicConfig } from "@k2b/sync";
1
+ import { RetentionGapError, type TopicConfig } from "@k2b/sync";
2
2
  import type * as Y from "yjs";
3
3
  import { getProcessSync } from "../_internal/process-sync";
4
4
  import { compareStreamCursor } from "./cursor";
5
5
  import { bindDocuments } from "./documents";
6
- import { applySharedTextEvent } from "./events";
6
+ import { applySharedTextEvent, MalformedSharedTextEventError } from "./events";
7
+ import { snapshots } from "./snapshots";
7
8
  import type { SharedTextContributor, SharedTextStore, SharedTextSyncEvent } from "./types";
8
9
 
9
10
  const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "deadLetterRetention" | "maxPayloadBytes">>) => {
@@ -33,11 +34,58 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
33
34
  return [...contributors.values()];
34
35
  };
35
36
 
37
+ const recover = async (config: {
38
+ docId: string;
39
+ after: string | null;
40
+ until: string;
41
+ doc: Y.Doc;
42
+ signal?: AbortSignal;
43
+ }): Promise<{ recoveredUpdates: number; malformedUpdates: number; historyGaps: number }> => {
44
+ const handle = topic();
45
+ let recoveredUpdates = 0;
46
+ let malformedUpdates = 0;
47
+ let historyGaps = 0;
48
+ let after = config.after ?? handle.cursorAt(0);
49
+ while (compareStreamCursor(after, config.until) < 0) {
50
+ config.signal?.throwIfAborted();
51
+ try {
52
+ for await (const event of handle.replay({ tenantId: config.docId, after, until: config.until, signal: config.signal })) {
53
+ try {
54
+ applySharedTextEvent(config.doc, event, config.docId);
55
+ recoveredUpdates++;
56
+ } catch (error) {
57
+ if (!(error instanceof MalformedSharedTextEventError)) throw error;
58
+ malformedUpdates++;
59
+ }
60
+ after = event.cursor;
61
+ }
62
+ // The replay covered every document event up to the captured head.
63
+ after = config.until;
64
+ } catch (error) {
65
+ if (!(error instanceof RetentionGapError) || !error.resumeAfter || compareStreamCursor(error.resumeAfter, after) <= 0) throw error;
66
+ historyGaps++;
67
+ // Skip only unavailable sequences; include the first retained event.
68
+ // Strict forward progress and a fixed head bound this loop even if
69
+ // retention advances concurrently. Never advance past the captured head.
70
+ after = compareStreamCursor(error.resumeAfter, config.until) > 0 ? config.until : error.resumeAfter;
71
+ }
72
+ }
73
+ config.signal?.throwIfAborted();
74
+ return { recoveredUpdates, malformedUpdates, historyGaps };
75
+ };
76
+
36
77
  return {
37
78
  id: topicConfig.id,
38
79
  topicConfig,
39
80
  topic,
40
81
  replay,
82
+ recover,
83
+ async firstSequence(): Promise<number> {
84
+ for await (const entry of getProcessSync().listTopics({ idPrefix: topicConfig.id })) {
85
+ if (entry.id === topicConfig.id) return entry.firstSequence;
86
+ }
87
+ return 0;
88
+ },
41
89
  withStore<Saved, Context>(store: SharedTextStore<Saved, Context>) {
42
90
  return bindDocuments({ topic, replay }, store);
43
91
  },
@@ -45,4 +93,4 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
45
93
  };
46
94
 
47
95
  /** Server-only document logs. Applications authorize callers before read/edit. */
48
- export const sharedText = { define };
96
+ export const sharedText = { define, snapshots };