@pithy-sh/core 0.9.0 → 0.11.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 (298) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/asyncapi/document.d.ts +46 -0
  17. package/dist/asyncapi/document.d.ts.map +1 -0
  18. package/dist/asyncapi/document.js +115 -0
  19. package/dist/audit/auditEvent.d.ts +5 -0
  20. package/dist/audit/auditEvent.d.ts.map +1 -1
  21. package/dist/audit/auditEvent.js +2 -0
  22. package/dist/capability/bindings.d.ts +47 -2
  23. package/dist/capability/bindings.d.ts.map +1 -1
  24. package/dist/capability/bindings.js +117 -7
  25. package/dist/capability/capability.d.ts +105 -28
  26. package/dist/capability/capability.d.ts.map +1 -1
  27. package/dist/capability/composition.d.ts.map +1 -1
  28. package/dist/capability/composition.js +5 -1
  29. package/dist/capability/manifest.d.ts +2 -1
  30. package/dist/capability/manifest.d.ts.map +1 -1
  31. package/dist/capability/manifest.js +1 -1
  32. package/dist/capability/settings.d.ts +96 -4
  33. package/dist/capability/settings.d.ts.map +1 -1
  34. package/dist/capability/settings.js +85 -4
  35. package/dist/cli/command.d.ts +364 -0
  36. package/dist/cli/command.d.ts.map +1 -0
  37. package/dist/cli/command.js +418 -0
  38. package/dist/cli/commandCatalog.generated.d.ts +2437 -0
  39. package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
  40. package/dist/cli/commandCatalog.generated.js +2564 -0
  41. package/dist/cli/commandFacts.d.ts +164 -0
  42. package/dist/cli/commandFacts.d.ts.map +1 -0
  43. package/dist/cli/commandFacts.js +216 -0
  44. package/dist/controlPlane/capability.d.ts.map +1 -1
  45. package/dist/controlPlane/capability.js +12 -2
  46. package/dist/controlPlane/context.d.ts +5 -0
  47. package/dist/controlPlane/context.d.ts.map +1 -1
  48. package/dist/controlPlane/context.js +3 -1
  49. package/dist/controlPlane/data/connection.d.ts +47 -0
  50. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  51. package/dist/controlPlane/data/connection.js +55 -1
  52. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  53. package/dist/controlPlane/error/errors.js +4 -3
  54. package/dist/controlPlane/http/cors.d.ts +4 -4
  55. package/dist/controlPlane/http/cors.js +4 -4
  56. package/dist/controlPlane/http/guard.d.ts +11 -0
  57. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  58. package/dist/controlPlane/http/guard.js +28 -2
  59. package/dist/controlPlane/http/handlers.d.ts +60 -0
  60. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  61. package/dist/controlPlane/http/handlers.js +31 -2
  62. package/dist/controlPlane/http/readiness.d.ts +33 -0
  63. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  64. package/dist/controlPlane/http/readiness.js +39 -0
  65. package/dist/controlPlane/http/responses.d.ts +147 -0
  66. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  67. package/dist/controlPlane/http/responses.js +61 -4
  68. package/dist/controlPlane/http/routes.d.ts +2 -1
  69. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  70. package/dist/controlPlane/http/routes.js +50 -9
  71. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  72. package/dist/controlPlane/http/verify.js +2 -1
  73. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  74. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  75. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  76. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  79. package/dist/controlPlane/scope/scope.d.ts +13 -2
  80. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  81. package/dist/controlPlane/scope/scope.js +14 -3
  82. package/dist/createBackend.d.ts +7 -0
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +41 -6
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/entitlement/allowance.d.ts +25 -0
  88. package/dist/entitlement/allowance.d.ts.map +1 -0
  89. package/dist/entitlement/allowance.js +80 -0
  90. package/dist/entitlement/entitlement.d.ts +41 -0
  91. package/dist/entitlement/entitlement.d.ts.map +1 -1
  92. package/dist/entitlement/meter.d.ts +57 -0
  93. package/dist/entitlement/meter.d.ts.map +1 -0
  94. package/dist/entitlement/meter.js +13 -0
  95. package/dist/entitlement/require.js +1 -1
  96. package/dist/error/cause.d.ts +16 -116
  97. package/dist/error/cause.d.ts.map +1 -1
  98. package/dist/error/cause.js +19 -153
  99. package/dist/error/http.d.ts +184 -0
  100. package/dist/error/http.d.ts.map +1 -1
  101. package/dist/error/payload.d.ts +368 -0
  102. package/dist/error/payload.d.ts.map +1 -1
  103. package/dist/error/payload.js +126 -4
  104. package/dist/error/pithyError.d.ts +44 -0
  105. package/dist/error/pithyError.d.ts.map +1 -1
  106. package/dist/error/pithyError.js +56 -1
  107. package/dist/error/unresolved.d.ts +265 -0
  108. package/dist/error/unresolved.d.ts.map +1 -0
  109. package/dist/error/unresolved.js +599 -0
  110. package/dist/error/wire.d.ts +20 -0
  111. package/dist/error/wire.d.ts.map +1 -0
  112. package/dist/error/wire.js +19 -0
  113. package/dist/http/authContext.d.ts +7 -0
  114. package/dist/http/authContext.d.ts.map +1 -1
  115. package/dist/http/authContext.js +8 -1
  116. package/dist/http/oidcWebhook.d.ts +3 -3
  117. package/dist/http/oidcWebhook.js +3 -3
  118. package/dist/http/routeContract.js +1 -1
  119. package/dist/http/routeSpec.d.ts +66 -0
  120. package/dist/http/routeSpec.d.ts.map +1 -0
  121. package/dist/http/routeSpec.js +40 -0
  122. package/dist/http/sameOrigin.d.ts +1 -1
  123. package/dist/http/sameOrigin.js +1 -1
  124. package/dist/http/scopeGates.d.ts +20 -0
  125. package/dist/http/scopeGates.d.ts.map +1 -0
  126. package/dist/http/scopeGates.js +36 -0
  127. package/dist/http/signedWebhook.d.ts +3 -3
  128. package/dist/http/signedWebhook.js +3 -3
  129. package/dist/http/socket.d.ts +52 -0
  130. package/dist/http/socket.d.ts.map +1 -0
  131. package/dist/http/socket.js +2 -0
  132. package/dist/http/subprotocol.d.ts +29 -0
  133. package/dist/http/subprotocol.d.ts.map +1 -0
  134. package/dist/http/subprotocol.js +37 -0
  135. package/dist/http/validation.d.ts +40 -8
  136. package/dist/http/validation.d.ts.map +1 -1
  137. package/dist/http/validation.js +25 -1
  138. package/dist/index.d.ts +1 -1
  139. package/dist/index.d.ts.map +1 -1
  140. package/dist/index.js +4 -4
  141. package/dist/logger/record.d.ts +107 -0
  142. package/dist/logger/record.d.ts.map +1 -1
  143. package/dist/migrations/bookkeeping.d.ts +21 -0
  144. package/dist/migrations/bookkeeping.d.ts.map +1 -1
  145. package/dist/migrations/bookkeeping.js +32 -3
  146. package/dist/migrations/capabilitySets.d.ts +23 -0
  147. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  148. package/dist/migrations/capabilitySets.js +74 -0
  149. package/dist/migrations/groups.d.ts +140 -0
  150. package/dist/migrations/groups.d.ts.map +1 -0
  151. package/dist/migrations/groups.js +191 -0
  152. package/dist/migrations/migrateStep.d.ts +13 -0
  153. package/dist/migrations/migrateStep.d.ts.map +1 -0
  154. package/dist/migrations/migrateStep.js +20 -0
  155. package/dist/migrations/owner.d.ts +7 -0
  156. package/dist/migrations/owner.d.ts.map +1 -1
  157. package/dist/migrations/owner.js +11 -7
  158. package/dist/migrations/registry.d.ts +26 -0
  159. package/dist/migrations/registry.d.ts.map +1 -1
  160. package/dist/migrations/registry.js +38 -7
  161. package/dist/migrations/retained.js +2 -2
  162. package/dist/migrations/runner.d.ts +76 -5
  163. package/dist/migrations/runner.d.ts.map +1 -1
  164. package/dist/migrations/runner.js +130 -19
  165. package/dist/migrations/state.d.ts +60 -0
  166. package/dist/migrations/state.d.ts.map +1 -0
  167. package/dist/migrations/state.js +46 -0
  168. package/dist/naming/domains.d.ts +17 -0
  169. package/dist/naming/domains.d.ts.map +1 -1
  170. package/dist/naming/domains.js +62 -4
  171. package/dist/naming/environment.d.ts.map +1 -1
  172. package/dist/naming/environment.js +2 -1
  173. package/dist/naming/feature.d.ts +27 -5
  174. package/dist/naming/feature.d.ts.map +1 -1
  175. package/dist/naming/feature.js +36 -6
  176. package/dist/naming/limits.d.ts +47 -3
  177. package/dist/naming/limits.d.ts.map +1 -1
  178. package/dist/naming/limits.js +47 -3
  179. package/dist/naming/packageName.d.ts +17 -0
  180. package/dist/naming/packageName.d.ts.map +1 -0
  181. package/dist/naming/packageName.js +22 -0
  182. package/dist/naming/provisionScope.d.ts +6 -0
  183. package/dist/naming/provisionScope.d.ts.map +1 -1
  184. package/dist/naming/provisionScope.js +10 -4
  185. package/dist/naming/resourceNames.d.ts +21 -2
  186. package/dist/naming/resourceNames.d.ts.map +1 -1
  187. package/dist/naming/resourceNames.js +21 -3
  188. package/dist/openapi/document.d.ts +106 -0
  189. package/dist/openapi/document.d.ts.map +1 -0
  190. package/dist/openapi/document.js +314 -0
  191. package/dist/openapi/routeGroups.d.ts +8 -0
  192. package/dist/openapi/routeGroups.d.ts.map +1 -0
  193. package/dist/openapi/routeGroups.js +23 -0
  194. package/dist/seed/writeD1.d.ts +7 -1
  195. package/dist/seed/writeD1.d.ts.map +1 -1
  196. package/dist/seed/writeD1.js +9 -3
  197. package/dist/seed/writeKv.d.ts +2 -0
  198. package/dist/seed/writeKv.d.ts.map +1 -1
  199. package/dist/seed/writeKv.js +6 -2
  200. package/dist/text/comments.js +66 -4
  201. package/dist/text/literals.d.ts +43 -0
  202. package/dist/text/literals.d.ts.map +1 -0
  203. package/dist/text/literals.js +293 -0
  204. package/dist/version.generated.d.ts +1 -1
  205. package/dist/version.generated.d.ts.map +1 -1
  206. package/dist/version.generated.js +1 -1
  207. package/dist/worker/serviceLoopback.d.ts +36 -0
  208. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  209. package/dist/worker/serviceLoopback.js +74 -0
  210. package/dist/workflow/dispatchRoute.d.ts.map +1 -1
  211. package/dist/workflow/dispatchRoute.js +12 -6
  212. package/dist/workflow/faults.d.ts +12 -1
  213. package/dist/workflow/faults.d.ts.map +1 -1
  214. package/dist/workflow/faults.js +24 -18
  215. package/dist/workflow/hostEnv.d.ts +1 -0
  216. package/dist/workflow/hostEnv.d.ts.map +1 -1
  217. package/dist/workflow/hostEnv.js +4 -2
  218. package/dist/workflow/loopback.d.ts.map +1 -1
  219. package/dist/workflow/loopback.js +2 -1
  220. package/package.json +4 -4
  221. package/src/access/access.ts +298 -0
  222. package/src/access/acting.ts +59 -0
  223. package/src/access/policyCheck.ts +77 -0
  224. package/src/access/usage.ts +141 -0
  225. package/src/access/visibility.ts +109 -0
  226. package/src/asyncapi/document.ts +164 -0
  227. package/src/audit/auditEvent.ts +4 -0
  228. package/src/capability/bindings.ts +159 -13
  229. package/src/capability/capability.ts +106 -29
  230. package/src/capability/composition.ts +2 -1
  231. package/src/capability/settings.ts +165 -4
  232. package/src/cli/command.ts +865 -0
  233. package/src/cli/commandCatalog.generated.ts +2723 -0
  234. package/src/cli/commandFacts.ts +220 -0
  235. package/src/controlPlane/capability.ts +25 -1
  236. package/src/controlPlane/context.ts +4 -0
  237. package/src/controlPlane/data/connection.ts +90 -0
  238. package/src/controlPlane/error/errors.ts +10 -3
  239. package/src/controlPlane/http/cors.ts +4 -4
  240. package/src/controlPlane/http/guard.ts +35 -2
  241. package/src/controlPlane/http/handlers.ts +41 -0
  242. package/src/controlPlane/http/readiness.ts +50 -0
  243. package/src/controlPlane/http/responses.ts +140 -2
  244. package/src/controlPlane/http/routes.ts +82 -10
  245. package/src/controlPlane/http/verify.ts +2 -0
  246. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  247. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  248. package/src/controlPlane/scope/scope.ts +14 -2
  249. package/src/createBackend.ts +59 -8
  250. package/src/createEntrypoint.ts +9 -3
  251. package/src/entitlement/allowance.ts +85 -0
  252. package/src/entitlement/entitlement.ts +38 -0
  253. package/src/entitlement/meter.ts +69 -0
  254. package/src/entitlement/require.ts +1 -1
  255. package/src/error/cause.ts +18 -181
  256. package/src/error/payload.ts +270 -4
  257. package/src/error/pithyError.ts +76 -0
  258. package/src/error/unresolved.ts +853 -0
  259. package/src/error/wire.ts +40 -0
  260. package/src/http/authContext.ts +13 -1
  261. package/src/http/oidcWebhook.ts +3 -3
  262. package/src/http/routeContract.ts +1 -1
  263. package/src/http/routeSpec.ts +81 -0
  264. package/src/http/sameOrigin.ts +1 -1
  265. package/src/http/scopeGates.ts +34 -0
  266. package/src/http/signedWebhook.ts +3 -3
  267. package/src/http/socket.ts +55 -0
  268. package/src/http/subprotocol.ts +47 -0
  269. package/src/http/validation.ts +50 -8
  270. package/src/index.ts +1 -0
  271. package/src/migrations/bookkeeping.ts +39 -2
  272. package/src/migrations/capabilitySets.ts +87 -0
  273. package/src/migrations/groups.ts +295 -0
  274. package/src/migrations/migrateStep.ts +19 -0
  275. package/src/migrations/owner.ts +10 -6
  276. package/src/migrations/registry.ts +46 -2
  277. package/src/migrations/retained.ts +2 -2
  278. package/src/migrations/runner.ts +214 -22
  279. package/src/migrations/state.ts +94 -0
  280. package/src/naming/domains.ts +75 -16
  281. package/src/naming/environment.ts +2 -1
  282. package/src/naming/feature.ts +46 -7
  283. package/src/naming/limits.ts +35 -3
  284. package/src/naming/packageName.ts +20 -0
  285. package/src/naming/provisionScope.ts +21 -2
  286. package/src/naming/resourceNames.ts +52 -5
  287. package/src/openapi/document.ts +412 -0
  288. package/src/openapi/routeGroups.ts +25 -0
  289. package/src/seed/writeD1.ts +13 -4
  290. package/src/seed/writeKv.ts +6 -2
  291. package/src/text/comments.ts +56 -4
  292. package/src/text/literals.ts +294 -0
  293. package/src/version.generated.ts +1 -1
  294. package/src/worker/serviceLoopback.ts +105 -0
  295. package/src/workflow/dispatchRoute.ts +13 -6
  296. package/src/workflow/faults.ts +56 -27
  297. package/src/workflow/hostEnv.ts +9 -1
  298. package/src/workflow/loopback.ts +2 -1
@@ -0,0 +1,109 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ValidationError } from "../error/pithyError";
6
+
7
+ /**
8
+ * Who may read a record, and learn that it exists — one convention, written once, for every capability
9
+ * that holds a record a person owns.
10
+ *
11
+ * **Three levels, and the kit owns them.** `private` is the owner, acting for the organization the record
12
+ * was written under; acting for any other organization, not even them. `organization` is any member of the
13
+ * record's organization, acting for it. `public` is anyone holding the id or the URL, signed in or not.
14
+ * Media and storage share the levels, the policy shape and the rules, so a record means the same thing in
15
+ * both and there is one place the meaning can be wrong.
16
+ *
17
+ * **The project decides which levels exist, and which one a record gets when it names none.** `allowed` is
18
+ * a set rather than a mode, because a set says everything a mode would and one thing it cannot:
19
+ * `["private", "organization"]` is a guarantee that nothing can be published, even by mistake.
20
+ */
21
+
22
+ /** Every level, weakest first. */
23
+ export const RECORD_VISIBILITIES = ["private", "organization", "public"] as const;
24
+
25
+ export const RecordVisibility = z
26
+ .enum(RECORD_VISIBILITIES)
27
+ .describe(
28
+ "Who may read a record and learn it exists: `private` is the owner acting for the record's organization, `organization` is any member acting for it, `public` is anyone holding the id or URL.",
29
+ );
30
+ export type RecordVisibility = z.infer<typeof RecordVisibility>;
31
+
32
+ /**
33
+ * The levels a project allows, and the one a record gets when a write names none.
34
+ *
35
+ * **`default` is `private` and must be in `allowed`**, and a policy that breaks either is refused at
36
+ * parse — which, for a capability, is at boot. **`allowed` defaults to all three**, so a capability that
37
+ * accepted `public` before this policy existed is not narrowed by it.
38
+ */
39
+ export const VisibilityPolicy = z
40
+ .object({
41
+ default: RecordVisibility.default("private").describe(
42
+ "The level a record gets when a write names none. Private, so a forgotten field can never publish a file. Must be one of `allowed`.",
43
+ ),
44
+ allowed: z
45
+ .array(RecordVisibility)
46
+ .min(1)
47
+ .default([...RECORD_VISIBILITIES])
48
+ .describe(
49
+ 'The levels a write may name. All three by default. `["private", "organization"]` means nothing can ever be public; a write naming a level outside it is refused, never coerced.',
50
+ ),
51
+ })
52
+ .describe("Which visibility levels this project allows, and the default for a write that names none.")
53
+ .check((ctx) => {
54
+ if (ctx.value.allowed.includes(ctx.value.default)) return;
55
+ ctx.issues.push({
56
+ code: "custom",
57
+ input: ctx.value,
58
+ path: ["default"],
59
+ message: `visibility.default is \`${ctx.value.default}\`, which visibility.allowed (${ctx.value.allowed.join(", ")}) does not include.`,
60
+ });
61
+ });
62
+ export type VisibilityPolicy = z.output<typeof VisibilityPolicy>;
63
+ export type VisibilityPolicyInput = z.input<typeof VisibilityPolicy>;
64
+
65
+ /**
66
+ * The level a write records: the one it named, or the policy's default.
67
+ *
68
+ * **Refused, never coerced.** A level outside `allowed` is a mistake somewhere — a client that did not
69
+ * know the policy, or a policy narrowed under a client — and quietly writing the default instead would
70
+ * hide it until somebody wondered why a published file was private, or worse. `organization` with no
71
+ * organization in force is refused for the same reason: a record no member can ever be acting for is not
72
+ * an organization record, it is a private one that says otherwise.
73
+ *
74
+ * Both refusals are the caller's to fix, so the remedy is in `message`; `action` names the config key for
75
+ * the operator who decides the policy.
76
+ */
77
+ export function resolveVisibility(
78
+ policy: VisibilityPolicy,
79
+ requested: RecordVisibility | undefined,
80
+ organizationId: string | null,
81
+ ): RecordVisibility {
82
+ const level = requested ?? policy.default;
83
+ if (!policy.allowed.includes(level)) {
84
+ throw new ValidationError({
85
+ message: `Visibility \`${level}\` is not allowed here. Allowed: ${policy.allowed.join(", ")}.`,
86
+ action: "Name one of visibility.allowed, or widen visibility.allowed in this Worker's pithy.config.ts.",
87
+ detail: `requested ${requested ?? "(default)"} resolved to ${level}, outside ${policy.allowed.join(", ")}`,
88
+ params: { visibility: level, allowed: policy.allowed.join(", ") },
89
+ });
90
+ }
91
+ if (level === "organization" && organizationId === null) {
92
+ throw new ValidationError({
93
+ message: "Visibility `organization` needs an organization in force. Choose one, or name another level.",
94
+ action: "Compose @pithy-sh/organization and choose an organization, or set a different visibility.default.",
95
+ detail: "organization visibility requested with no acting organization",
96
+ params: { visibility: level },
97
+ });
98
+ }
99
+ return level;
100
+ }
101
+
102
+ /**
103
+ * The levels a policy no longer allows — what `pithy doctor` searches records for after `allowed`
104
+ * narrows. Narrowing never rewrites a record: making a public record private breaks every link already
105
+ * published, so moving them is the adopter's explicit act, and this is how they find them.
106
+ */
107
+ export function outsidePolicy(policy: VisibilityPolicy): RecordVisibility[] {
108
+ return RECORD_VISIBILITIES.filter((level) => !policy.allowed.includes(level));
109
+ }
@@ -0,0 +1,164 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import type { Capability } from "../capability/capability";
6
+ import { ErrorBody } from "../error/wire";
7
+ import type { SocketChannel, SocketMessage } from "../http/socket";
8
+ import type { VerificationStrategy } from "../http/verification";
9
+ import {
10
+ type ContractInfo,
11
+ type JsonSchema,
12
+ jsonSchema,
13
+ openApiPath,
14
+ type UndescribedField,
15
+ undescribedFieldsIn,
16
+ } from "../openapi/document";
17
+
18
+ /**
19
+ * **The AsyncAPI contract: every socket a Worker serves, and every frame on it.**
20
+ *
21
+ * The OpenAPI document stops at the upgrade. This picks up after it, from the `sockets` each composed
22
+ * capability declares (`../http/socket.ts`): one channel per socket, one message per frame, and one operation
23
+ * per direction — `send` for what the Worker sends, `receive` for what it accepts, as AsyncAPI names them from
24
+ * the application's side. Every schema is the one the socket parses with, converted by `z.toJSONSchema` with
25
+ * its descriptions intact, so an engine with no generator still has typed messages to write against.
26
+ *
27
+ * AsyncAPI 3.0, whose schemas are JSON Schema — the dialect `z.toJSONSchema` writes.
28
+ */
29
+
30
+ /** The AsyncAPI version this module writes. */
31
+ export const ASYNCAPI_VERSION = "3.0.0";
32
+
33
+ /** One capability's sockets. Becomes the channels tagged with its name. */
34
+ export interface SocketGroup {
35
+ /** The capability's name. */
36
+ readonly tag: string;
37
+ /** The sockets it declared. */
38
+ readonly sockets: readonly SocketChannel[];
39
+ }
40
+
41
+ /** The sockets of every capability that declares one, grouped by capability, in composition order. */
42
+ export function socketGroupsOf(capabilities: readonly Capability[]): SocketGroup[] {
43
+ return capabilities
44
+ .filter((capability) => (capability.sockets ?? []).length > 0)
45
+ .map((capability) => ({ tag: capability.name, sockets: capability.sockets ?? [] }));
46
+ }
47
+
48
+ /** The subset of an AsyncAPI 3.0 document this module writes. */
49
+ export interface AsyncApiDocument {
50
+ asyncapi: string;
51
+ info: ContractInfo;
52
+ channels: Record<string, Record<string, unknown>>;
53
+ operations: Record<string, Record<string, unknown>>;
54
+ components: {
55
+ messages: Record<string, Record<string, unknown>>;
56
+ schemas: Record<string, JsonSchema>;
57
+ };
58
+ }
59
+
60
+ /** A frame's id in the document: its socket's name and its own, so two sockets' `error` frames stay apart. */
61
+ function messageId(socket: SocketChannel, message: SocketMessage, direction: "send" | "receive"): string {
62
+ return `${socket.name}.${direction}.${message.name}`;
63
+ }
64
+
65
+ /** One `components.messages` entry: a JSON frame by its schema, a text frame by its exact text. */
66
+ function messageOf(message: SocketMessage): Record<string, unknown> {
67
+ if (message.schema === undefined) {
68
+ return {
69
+ name: message.name,
70
+ summary: message.summary,
71
+ contentType: "text/plain",
72
+ payload: { type: "string", const: message.text ?? "", description: message.summary },
73
+ };
74
+ }
75
+ return {
76
+ name: message.name,
77
+ summary: message.summary,
78
+ contentType: "application/json",
79
+ payload: jsonSchema(message.schema, "input"),
80
+ };
81
+ }
82
+
83
+ /** The path parameters, from the same schema the upgrade route validates them with. */
84
+ function parametersOf(params: z.ZodType | undefined): Record<string, Record<string, unknown>> {
85
+ if (params === undefined) return {};
86
+ const properties = (jsonSchema(params, "input").properties ?? {}) as Record<string, JsonSchema>;
87
+ return Object.fromEntries(
88
+ Object.entries(properties).map(([name, property]) => [
89
+ name,
90
+ typeof property.description === "string" ? { description: property.description } : {},
91
+ ]),
92
+ );
93
+ }
94
+
95
+ function strategiesOf(socket: SocketChannel): readonly VerificationStrategy[] {
96
+ return typeof socket.verification === "string" ? [socket.verification] : socket.verification;
97
+ }
98
+
99
+ /** The AsyncAPI document for these capabilities' sockets. */
100
+ export function asyncApiDocument(info: ContractInfo, groups: readonly SocketGroup[]): AsyncApiDocument {
101
+ const document: AsyncApiDocument = {
102
+ asyncapi: ASYNCAPI_VERSION,
103
+ info,
104
+ channels: {},
105
+ operations: {},
106
+ components: { messages: {}, schemas: { Error: jsonSchema(ErrorBody, "input") } },
107
+ };
108
+
109
+ for (const group of groups) {
110
+ for (const socket of group.sockets) {
111
+ const channelMessages: Record<string, { $ref: string }> = {};
112
+ for (const direction of ["send", "receive"] as const) {
113
+ const refs: { $ref: string }[] = [];
114
+ for (const message of socket[direction]) {
115
+ const id = messageId(socket, message, direction);
116
+ document.components.messages[id] = messageOf(message);
117
+ channelMessages[id] = { $ref: `#/components/messages/${id}` };
118
+ refs.push({ $ref: `#/channels/${socket.name}/messages/${id}` });
119
+ }
120
+ if (refs.length > 0) {
121
+ document.operations[`${socket.name}.${direction}`] = {
122
+ action: direction,
123
+ channel: { $ref: `#/channels/${socket.name}` },
124
+ summary: direction === "send" ? "Frames the Worker sends." : "Frames the Worker accepts.",
125
+ messages: refs,
126
+ };
127
+ }
128
+ }
129
+ const parameters = parametersOf(socket.params);
130
+ document.channels[socket.name] = {
131
+ address: openApiPath(socket.path),
132
+ summary: socket.summary,
133
+ ...(socket.description === undefined ? {} : { description: socket.description }),
134
+ ...(Object.keys(parameters).length === 0 ? {} : { parameters }),
135
+ messages: channelMessages,
136
+ tags: [{ name: group.tag }],
137
+ bindings: { ws: { method: "GET" } },
138
+ "x-pithy-verification": strategiesOf(socket),
139
+ "x-pithy-subprotocols": socket.versions,
140
+ };
141
+ for (const [name, schema] of Object.entries(socket.schemas ?? {})) {
142
+ document.components.schemas[`${socket.name}.${name}`] = jsonSchema(schema, "input");
143
+ }
144
+ }
145
+ }
146
+ return document;
147
+ }
148
+
149
+ /**
150
+ * Every message property, schema property and channel parameter in the document without a description —
151
+ * the same rule as the OpenAPI document's, over the parts a client generates from.
152
+ */
153
+ export function undescribedAsyncFields(document: AsyncApiDocument): UndescribedField[] {
154
+ const parameters = Object.entries(document.channels).flatMap(([channel, item]) =>
155
+ Object.entries((item.parameters ?? {}) as Record<string, Record<string, unknown>>)
156
+ .filter(([, parameter]) => typeof parameter.description !== "string")
157
+ .map(([name]) => ({ pointer: `/channels/${channel}/parameters/${name}` })),
158
+ );
159
+ return [
160
+ ...parameters,
161
+ ...undescribedFieldsIn(document.components.messages, "/components/messages"),
162
+ ...undescribedFieldsIn(document.components.schemas, "/components/schemas"),
163
+ ];
164
+ }
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { ControlPlaneActorKind } from "../controlPlane/data/connection";
5
6
 
6
7
  /**
7
8
  * The audit-event seam. `AuditEvent` is the shape any capability hands to `emit()` (the request
@@ -95,6 +96,9 @@ export const AuditEvent = z
95
96
  .string()
96
97
  .nullish()
97
98
  .describe("The acting principal's stable id (user id, service/token name); null for `system`/`anonymous`."),
99
+ actorKind: ControlPlaneActorKind.nullish().describe(
100
+ "For a `control-plane` actor, what kind of management client acted — `agent`, `dashboard` or `script` — from the connection it called through (#721). `actorType` names all three alike; this is what tells them apart. Stamped by `requireControlPlane`, so an emit site does not set it; null for every other actor type, and for a connection registered before the kind was recorded.",
101
+ ),
98
102
  sessionId: z
99
103
  .string()
100
104
  .nullish()
@@ -23,10 +23,87 @@ export const BindingType = z
23
23
  .describe(
24
24
  "Durable Object — a single-threaded, stateful actor with its own storage. Backed by an exported DO class; the CLI wires the namespace binding and the class migration tag.",
25
25
  ),
26
+ z
27
+ .literal("hyperdrive")
28
+ .describe("Hyperdrive — a pooled, cached connection to an existing database. The adopter creates the config."),
29
+ z.literal("browser").describe("Browser Rendering — a headless browser the Worker drives."),
30
+ z.literal("images").describe("Cloudflare Images — transform and optimize images from the Worker."),
31
+ z
32
+ .literal("analytics_engine_datasets")
33
+ .describe("Workers Analytics Engine — write data points to a dataset, query them with SQL."),
34
+ z.literal("pipelines").describe("Pipelines — send records into a stream that lands in R2. The adopter creates it."),
35
+ z
36
+ .literal("mtls_certificates")
37
+ .describe("mTLS certificate — present a client certificate on outbound requests. The adopter uploads it."),
38
+ z
39
+ .literal("dispatch_namespaces")
40
+ .describe("Workers for Platforms dispatch namespace — call Workers uploaded into it. The adopter creates it."),
41
+ z
42
+ .literal("artifacts")
43
+ .describe(
44
+ "Artifacts — versioned storage that speaks Git, one namespace of repositories per environment. Cloudflare creates the namespace on its first repo.",
45
+ ),
26
46
  ])
27
47
  .describe("Kind of Cloudflare resource a binding refers to.");
28
48
  export type BindingType = z.infer<typeof BindingType>;
29
49
 
50
+ /**
51
+ * **How a stanza the kit creates gets this kind's entries** (#662).
52
+ *
53
+ * Every `env.<name>` stanza Pithy creates starts from the top level, because wrangler inherits none of the keys a
54
+ * binding lives in. What it starts with depends on whose resource the entry names, and that is a fact about the
55
+ * kind, not about the JSON shape wrangler happens to give it:
56
+ *
57
+ * - **`carry`** — account-level and identical everywhere. A dataset, a certificate, a rate limiter's
58
+ * `namespace_id`: the top level's entries come down whole into every stanza, because nothing in them is an
59
+ * environment's own.
60
+ * - **`provision`** — the environment owns the resource. Seeded empty, refilled by the provisioner, because
61
+ * dev's database id in staging's stanza is staging writing to dev's database.
62
+ * - **`branch`** — an account-level resource and a per-environment *decision*. Carried into a feature, which is a
63
+ * branch of `dev`; left empty in a declared environment, which is not. A `send_email` entry names an address
64
+ * verified once at the account, and where this environment's mail goes is a choice production does not inherit
65
+ * from a laptop. A Hyperdrive config is one origin database's connection, and a pipeline stream feeds one sink:
66
+ * the account holds both, but the top level's are dev's, and a declared environment seeded with them would read
67
+ * and write dev's Postgres and dev's sink with nothing failing.
68
+ *
69
+ * Three values, not a boolean. `send_email` was carried everywhere for a day and wrote a developer's address into
70
+ * production's first stanza (#650 review); emptied everywhere, it left a feature with no `NOTIFY`.
71
+ */
72
+ export type SeedPolicy = "carry" | "provision" | "branch";
73
+
74
+ /**
75
+ * The policy per kind, as a total record — so a kind added to {@link BindingType} without one is a type error
76
+ * here, and `bindings.test.ts` fails on a member whose answer it does not state.
77
+ */
78
+ const SEED_POLICY: Record<BindingType, SeedPolicy> = {
79
+ ai: "carry",
80
+ ratelimit: "carry",
81
+ browser: "carry",
82
+ images: "carry",
83
+ hyperdrive: "branch",
84
+ analytics_engine_datasets: "carry",
85
+ mtls_certificates: "carry",
86
+ dispatch_namespaces: "carry",
87
+ pipelines: "branch",
88
+ d1: "provision",
89
+ kv: "provision",
90
+ r2: "provision",
91
+ vectorize: "provision",
92
+ queue: "provision",
93
+ durable_object: "provision",
94
+ workflow: "provision",
95
+ service: "provision",
96
+ secret: "provision",
97
+ email: "branch",
98
+ // The namespace is environment-derived, so a stanza starts empty and its own name is written in (#673).
99
+ artifacts: "provision",
100
+ };
101
+
102
+ /** How a newly created stanza gets this kind's entries. See {@link SeedPolicy}. */
103
+ export function seedPolicy(type: BindingType): SeedPolicy {
104
+ return SEED_POLICY[type];
105
+ }
106
+
30
107
  /**
31
108
  * Whether a provision command creates the **resource** behind this kind of binding, rather than the
32
109
  * adopter standing it up themselves.
@@ -69,19 +146,54 @@ export function isProvisionedBinding(type: BindingType): boolean {
69
146
  *
70
147
  * `service` is in neither, deliberately: `pithy feature` writes it out of the target Worker's env-scoped
71
148
  * script name, and no shipped capability declares one.
149
+ *
150
+ * **The pure-wrangler-entry kinds are written and never provisioned (#662).** `hyperdrive`, `browser`, `images`,
151
+ * `analytics_engine_datasets`, `pipelines`, `mtls_certificates` and `dispatch_namespaces` each take a binding name
152
+ * and, where wrangler requires one, the account-level resource the spec states as `target`. Pithy writes the
153
+ * entry; the adopter stands the resource up. `queue` writes its `queues.producers` entry under the name
154
+ * `pithy provision` creates the queue with, and `email` writes `send_email` with nothing but its name.
155
+ *
156
+ * **`artifacts` is written and never provisioned (#673).** Its entry is `{ binding, namespace }`, and the namespace
157
+ * is `<project>-<env>`, derivable offline. There is no create call to make: Cloudflare creates a namespace the first
158
+ * time a repo is created in it. There is a teardown call, because repos cost storage, and `pithy feature destroy`
159
+ * makes it.
72
160
  */
73
161
  export function isWrittenBinding(type: BindingType): boolean {
74
- return (
75
- type === "d1" ||
76
- type === "kv" ||
77
- type === "r2" ||
78
- type === "ai" ||
79
- type === "durable_object" ||
80
- type === "ratelimit" ||
81
- type === "workflow"
82
- );
162
+ return WRITTEN.has(type);
83
163
  }
84
164
 
165
+ const WRITTEN = new Set<BindingType>([
166
+ "d1",
167
+ "kv",
168
+ "r2",
169
+ "ai",
170
+ "durable_object",
171
+ "ratelimit",
172
+ "workflow",
173
+ "queue",
174
+ "email",
175
+ "hyperdrive",
176
+ "browser",
177
+ "images",
178
+ "analytics_engine_datasets",
179
+ "pipelines",
180
+ "mtls_certificates",
181
+ "dispatch_namespaces",
182
+ "artifacts",
183
+ ]);
184
+
185
+ /**
186
+ * The kinds whose wrangler entry names an account-level resource the adopter creates, and what wrangler calls
187
+ * that field — what `BindingSpec.target` is written as. `true` where wrangler refuses the entry without it.
188
+ */
189
+ export const TARGET_KINDS: Readonly<Partial<Record<BindingType, { field: string; required: boolean }>>> = {
190
+ hyperdrive: { field: "id", required: true },
191
+ mtls_certificates: { field: "certificate_id", required: true },
192
+ pipelines: { field: "stream", required: true },
193
+ dispatch_namespaces: { field: "namespace", required: true },
194
+ analytics_engine_datasets: { field: "dataset", required: false },
195
+ };
196
+
85
197
  /**
86
198
  * A JavaScript class name — what `className` may be.
87
199
  *
@@ -101,13 +213,16 @@ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
101
213
  */
102
214
  const CLASS_MODULE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$/;
103
215
 
216
+ /** What a `target` may be: a Cloudflare id or resource name, starting alphanumeric. */
217
+ const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
218
+
104
219
  /**
105
220
  * The kinds whose resource Pithy names and creates — the only kinds `scope` and `resource` mean anything
106
221
  * for.
107
222
  *
108
223
  * {@link FEATURE_RESOURCE_KINDS} rather than a fresh literal, because it is already the repo's answer to
109
224
  * "which bindings does a provisioning run compose a `<project>-<scope>-<thing>` name for": it is the
110
- * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same three. Every
225
+ * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same four. Every
111
226
  * other kind's name is composed by a different rule with no `<thing>` slot to fill — a Workflow's from
112
227
  * the capability and the job, a Worker script's by wrangler — and a `secret` already states its scope in
113
228
  * `defineSecretRegistry`, where the value it names lives.
@@ -159,6 +274,16 @@ export const BindingSpec = z
159
274
  .describe(
160
275
  'The Worker this binding calls, named as it appears in `apps/<name>/` (e.g. "api"). Meaningful only for a `service` binding — the CLI resolves it to that Worker\'s environment-scoped script name when writing `services` into wrangler.jsonc, so worker-to-worker RPC targets the right deployment per environment. Ignored for every other kind.',
161
276
  ),
277
+ target: z
278
+ .string()
279
+ .min(1)
280
+ // An id or a resource name, never prose: a manifest is third-party data out of `node_modules`, and this
281
+ // string lands in the adopter's `wrangler.jsonc` and in every stanza the kit carries it into.
282
+ .regex(TARGET, "A target is a resource id or name: letters, digits, `.`, `_` and `-`.")
283
+ .optional()
284
+ .describe(
285
+ "The account-level resource this binding addresses, which the adopter creates themselves: a Hyperdrive config id, an mTLS certificate id, a Pipelines stream, a dispatch namespace, or an Analytics Engine dataset. Written into the field wrangler names for it (`id`, `certificate_id`, `stream`, `namespace`, `dataset`), and carried whole into every environment, because the resource is the account's. Required for `hyperdrive`, `mtls_certificates`, `pipelines` and `dispatch_namespaces`; optional for `analytics_engine_datasets`. Refused on every other kind.",
286
+ ),
162
287
  remote: z
163
288
  .boolean()
164
289
  .optional()
@@ -169,7 +294,7 @@ export const BindingSpec = z
169
294
  .enum(["environment", "global"])
170
295
  .optional()
171
296
  .describe(
172
- "Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv` or `r2` binding.",
297
+ "Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding.",
173
298
  ),
174
299
  resource: z
175
300
  .string()
@@ -181,7 +306,7 @@ export const BindingSpec = z
181
306
  .regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.")
182
307
  .optional()
183
308
  .describe(
184
- "The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv` or `r2` binding.",
309
+ "The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding.",
185
310
  ),
186
311
  })
187
312
  .describe("Declares a Cloudflare binding a capability requires in the Worker env.")
@@ -234,10 +359,31 @@ export const BindingSpec = z
234
359
  code: "custom",
235
360
  input: ctx.value,
236
361
  path: [field],
237
- message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv or r2 binding has — this one is a ${ctx.value.type}.`,
362
+ message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv, r2 or queue binding has — this one is a ${ctx.value.type}.`,
238
363
  });
239
364
  }
240
365
  }
366
+ // `target` is the resource a carried kind addresses, written into the one field wrangler names for it. A kind
367
+ // that requires it is refused without it — wrangler refuses the entry, and a writer emitting one would stop
368
+ // `wrangler dev` and `wrangler deploy` both. A kind with no such field is refused with it, for the reason
369
+ // `scope` is: a declaration nothing writes reads, in the spec, as one that was honored.
370
+ const target = TARGET_KINDS[ctx.value.type];
371
+ if (target?.required && ctx.value.target === undefined) {
372
+ ctx.issues.push({
373
+ code: "custom",
374
+ input: ctx.value,
375
+ path: ["target"],
376
+ message: `Binding "${ctx.value.name}" needs a target — the ${ctx.value.type} resource it addresses, written as \`${target.field}\`.`,
377
+ });
378
+ }
379
+ if (target === undefined && ctx.value.target !== undefined) {
380
+ ctx.issues.push({
381
+ code: "custom",
382
+ input: ctx.value,
383
+ path: ["target"],
384
+ message: `Binding "${ctx.value.name}" declares a target, which a ${ctx.value.type} binding does not take.`,
385
+ });
386
+ }
241
387
  });
242
388
  export type BindingSpec = z.infer<typeof BindingSpec>;
243
389