@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
@@ -2,6 +2,8 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import { noActingOrganization } from "./access/acting";
5
7
  import { noopEmit } from "./audit/recorder";
6
8
  import { BindingSpec } from "./capability/bindings";
7
9
  import type {
@@ -14,17 +16,22 @@ import type {
14
16
  } from "./capability/capability";
15
17
  import { recordComposition } from "./capability/composition";
16
18
  import { validateBindings } from "./capability/validateBindings";
19
+ import { pithyCommandOf } from "./cli/command";
17
20
  import { isControlPlaneCapability } from "./controlPlane/capability";
18
21
  import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors";
22
+ import { registerReadinessGuards } from "./controlPlane/http/readiness";
19
23
  import { buildDbRegistry, composeDatabases, type DbRegistry } from "./data/databases";
20
24
  import { noEntitlementProvider } from "./entitlement/entitlement";
21
25
  import { ValidationError } from "./error/pithyError";
26
+ import { describeRoute } from "./http/routeSpec";
22
27
  import { catalogFor, composeMessages } from "./i18n/registry";
23
28
  import { bakedTranslator, DEFAULT_LOCALE } from "./i18n/translator";
24
29
  import { buildKvRegistry, composeKv, type KvRegistry } from "./kv/namespaces";
25
30
  import { loggingErrorHandler } from "./logger/errorHandler";
26
31
  import type { Logger } from "./logger/logger";
27
32
  import { bindRequestContext, createWorkerLogger } from "./logger/worker";
33
+ import type { RouteGroup } from "./openapi/document";
34
+ import { recordRouteGroups } from "./openapi/routeGroups";
28
35
  import { HEALTH_PATH } from "./worker/health";
29
36
  import { workerVersion } from "./worker/identity";
30
37
  import { registeredWorkflowBinding } from "./workflow/bindings";
@@ -103,6 +110,18 @@ function envNameOf(env: Record<string, unknown>, fallback: string | undefined):
103
110
  return typeof env.ENVIRONMENT === "string" && env.ENVIRONMENT.length > 0 ? env.ENVIRONMENT : (fallback ?? "unknown");
104
111
  }
105
112
 
113
+ /** What `GET /health` answers. */
114
+ export const HealthBody = z
115
+ .object({
116
+ status: z.literal("ok").describe("Always `ok`: an answer at all is the liveness signal."),
117
+ version: z
118
+ .string()
119
+ .nullable()
120
+ .describe("The Cloudflare version id of the build answering, or null where the Worker cannot say."),
121
+ })
122
+ .describe("Liveness, and which build is answering.");
123
+ export type HealthBody = z.infer<typeof HealthBody>;
124
+
106
125
  /**
107
126
  * Assemble capabilities into a deployable Hono app — a valid Worker `fetch` handler.
108
127
  *
@@ -135,7 +154,7 @@ export function createBackend<
135
154
  if (!present.has(dep)) {
136
155
  throw new ValidationError({
137
156
  message: `Capability "${cap.name}" requires the "${dep}" capability, which is not composed.`,
138
- action: `Add ${dep}() to createBackend's capabilities (run \`pithy add ${dep}\`).`,
157
+ action: `Add ${dep}() to createBackend's capabilities (run \`${pithyCommandOf("add", { args: [dep], flags: { "--with-prerequisites": true } }, { runtime: true })}\`).`,
139
158
  });
140
159
  }
141
160
  }
@@ -188,15 +207,19 @@ export function createBackend<
188
207
  // not here). Defaults to the CF-native Mode 2 logger — structured records to Workers Logs, zero-config.
189
208
  const baseLogger = options.logger ?? createWorkerLogger();
190
209
 
210
+ // Every composed capability's `adminRoutes`, folded per path: the surface CORS answers for, and the
211
+ // surface no readiness guard may refuse. One computation, so the two cannot disagree.
212
+ const adminSurface = corsSurface(all);
213
+
191
214
  // **CORS first — before this Worker's own base middleware and before every capability's.**
192
215
  //
193
216
  // Hono runs middleware in registration order, so whatever registers first is the only thing that can
194
217
  // put a header on a response somebody else short-circuits. Capabilities short-circuit for real
195
- // reasons: `@pithy-sh/vector`'s `provisionGuard` is an `app.use("*")` that throws on every request
196
- // when the provision record disagrees with config. Register CORS after that and a misprovisioned
197
- // Worker answers a management call with no `Access-Control-Allow-Origin`, so the dashboard sees the
198
- // opaque `fetch` TypeError this exists to remove — on precisely the misconfiguration where the
199
- // operator most needs to read the real message. Ahead of `validateBindings` for the same reason.
218
+ // reasons: a rate limit, or a readiness guard like `@pithy-sh/vector`'s `provisionGuard`, which
219
+ // throws on every request off the admin surface when the provision record disagrees with config.
220
+ // Register CORS after either and the refusal reaches a browser with no `Access-Control-Allow-Origin`,
221
+ // so the dashboard sees the opaque `fetch` TypeError this exists to remove instead of the reason.
222
+ // Ahead of `validateBindings` for the same reason.
200
223
  //
201
224
  // It lives here rather than in the capability's `middleware` hook because that hook runs inside the
202
225
  // loop below, in the adopter's composition order, and this must not depend on where they listed
@@ -205,7 +228,7 @@ export function createBackend<
205
228
  const controlPlane = all.find(isControlPlaneCapability);
206
229
  if (controlPlane) {
207
230
  const cors = controlPlane.controlPlaneConfig;
208
- registerControlPlaneCors(app, corsSurface(all), allowedOriginSet(cors), cors.corsMaxAgeSeconds);
231
+ registerControlPlaneCors(app, adminSurface, allowedOriginSet(cors), cors.corsMaxAgeSeconds);
209
232
  }
210
233
 
211
234
  let validated = false;
@@ -216,6 +239,9 @@ export function createBackend<
216
239
  validated = true;
217
240
  }
218
241
  if (c.get("auth") === undefined) c.set("auth", null);
242
+ // Nothing in force until `@pithy-sh/organization`'s middleware publishes a resolver. Media and storage
243
+ // read it to scope every record, so a Worker with no tenancy answers null rather than throwing.
244
+ if (c.get("actingOrganization") === undefined) c.set("actingOrganization", noActingOrganization);
219
245
  // Null means nothing negotiated, which is not the same fact as "the default was chosen" — only
220
246
  // `@pithy-sh/i18n` ever sets it, and `c.var.t` works either way.
221
247
  if (c.get("locale") === undefined) c.set("locale", null);
@@ -283,14 +309,39 @@ export function createBackend<
283
309
  * before `version_metadata` was declared reports "I cannot tell you", and `deploy` reports the check
284
310
  * as inconclusive instead of failing it.
285
311
  */
286
- app.get(HEALTH_PATH, (c) => c.json({ status: "ok", version: workerVersion(c.env) }));
312
+ const groups: RouteGroup[] = [];
313
+ const healthStart = app.routes.length;
314
+ app.get(
315
+ HEALTH_PATH,
316
+ describeRoute({
317
+ summary: "Report liveness, and which build is answering.",
318
+ verification: "public",
319
+ responses: { 200: HealthBody },
320
+ }),
321
+ (c) => c.json({ status: "ok", version: workerVersion(c.env) } satisfies HealthBody),
322
+ );
323
+ groups.push({ tag: "core", routes: app.routes.slice(healthStart) });
287
324
 
288
325
  for (const cap of all) {
289
326
  for (const middleware of cap.middleware ?? []) middleware(app);
290
327
  }
328
+ // Readiness guards after every capability's middleware, so the verifier, `emit` and every rate limit
329
+ // are in place, and before any capability's routes, so a terminal handler cannot outrun them. Each is
330
+ // skipped on the admin surface: the control plane answers whatever else is wrong with the Worker,
331
+ // because it is how a management client finds out what that is (#763).
332
+ registerReadinessGuards(
333
+ app,
334
+ adminSurface,
335
+ all.flatMap((cap) => cap.readiness ?? []),
336
+ );
337
+ // Each hook's slice of the route table is noted as it mounts, so the OpenAPI contract can tag every
338
+ // operation with the capability that owns it (`openapi/routeGroups.ts`).
291
339
  for (const cap of all) {
340
+ const start = app.routes.length;
292
341
  cap.routes?.(app);
342
+ groups.push({ tag: cap.name, routes: app.routes.slice(start) });
293
343
  }
344
+ recordRouteGroups(app, groups);
294
345
 
295
346
  return app as unknown as Hono<BackendEnv<readonly [...Caps, App]>>;
296
347
  }
@@ -4,6 +4,7 @@
4
4
  import type { ExecutionContext, ForwardableEmailMessage } from "@cloudflare/workers-types";
5
5
  import type { Capability, CapabilityEmailHandler } from "./capability/capability";
6
6
  import { type CreateBackendOptions, createBackend } from "./createBackend";
7
+ import { withLocalServices } from "./worker/serviceLoopback";
7
8
  import { triggerWorkflow } from "./workflow/dispatch";
8
9
  import { composeWorkflows, scheduledWorkflows } from "./workflow/register";
9
10
 
@@ -88,7 +89,11 @@ export function createEntrypoint<
88
89
  const registry = composeWorkflows(all);
89
90
  const scheduled = scheduledWorkflows(registry);
90
91
 
91
- const entrypoint: PithyEntrypoint = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
92
+ // Under `pithy dev`, a service binding's local stand-in goes into the env before anything reads it — the
93
+ // binding check included (#617). Anywhere else this hands back the env it was given.
94
+ const entrypoint: PithyEntrypoint = {
95
+ fetch: (request, env, ctx) => app.fetch(request, withLocalServices(env), ctx),
96
+ };
92
97
 
93
98
  // Cron-carrying jobs get the Worker's one `scheduled` entry. Each is started independently so one
94
99
  // job whose binding is missing cannot stop the rest of the schedule from running; the failure is
@@ -102,7 +107,7 @@ export function createEntrypoint<
102
107
  // can never receive on a schedule therefore fails loudly here, which is the author's bug and
103
108
  // is exactly what should surface.
104
109
  try {
105
- await triggerWorkflow(env, registry, entry.key, {});
110
+ await triggerWorkflow(withLocalServices(env), registry, entry.key, {});
106
111
  } catch (error) {
107
112
  failures.push(error);
108
113
  }
@@ -114,7 +119,8 @@ export function createEntrypoint<
114
119
  // Only expose an `email` handler when something actually handles inbound mail — an inert one would
115
120
  // silently drop any message Email Routing delivered to this Worker.
116
121
  if (handlers.length > 0) {
117
- entrypoint.email = async (message, env, ctx) => {
122
+ entrypoint.email = async (message, given, ctx) => {
123
+ const env = withLocalServices(given);
118
124
  if (handlers.length === 1) {
119
125
  await handlers[0]?.(message, env, ctx);
120
126
  return;
@@ -0,0 +1,85 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Context } from "hono";
5
+ import type { PithyHonoEnv } from "../capability/capability";
6
+ import { InternalError, messageOf } from "../error/pithyError";
7
+ import type { MeterLimit } from "./entitlement";
8
+
9
+ /**
10
+ * The allowance readers — how much the current caller may have, and whether a switch is on.
11
+ *
12
+ * const seats = await quota(c, "seats"); // number, or null for unlimited
13
+ * if (seats !== null && (await countMembers(orgId)) >= seats) throw new SeatLimitReached(…);
14
+ * if (await feature(c, "auditExport")) { … } // boolean, never null
15
+ *
16
+ * **Neither takes an entitlement key, and that is the point.** A route does not know which tier the
17
+ * caller's holder is on, and during an upgrade overlap it holds two. The provider resolves across every
18
+ * key the holder has and answers the higher, so an adopter compares numbers and never ranks tiers.
19
+ *
20
+ * **Read, never enforced.** A quota denies nothing on its own and a feature set to `false` denies
21
+ * nothing either — `requireEntitlement` still gates on the key alone. What a number means for a request
22
+ * is the adopter's code to decide, because only the adopter can count what it owns.
23
+ *
24
+ * **They live in core for the reason `requireEntitlement` does.** A reader that arrived with
25
+ * `@pithy-sh/payments` would be absent when payments is, and a route reading an allowance must then fail
26
+ * rather than read something. So with no provider composed — or one that declares no allowances — both
27
+ * throw `core/internal` naming the gap. Never `0`, which is a paywall shut for a reason nobody can see;
28
+ * never unlimited, which is a paywall that does not exist.
29
+ */
30
+
31
+ /** The refusal when nothing on the request can answer an allowance. The adopter's wiring, so `core/internal`. */
32
+ function noProvider(c: Context<PithyHonoEnv>, kind: "quota" | "feature", name: string): InternalError {
33
+ const provider = c.var.entitlements?.provider ?? null;
34
+ return new InternalError({
35
+ message: `No ${kind} "${name}" can be resolved here.`,
36
+ action: "Compose a payments capability that declares its entitlements in this Worker's pithy.config.ts.",
37
+ detail:
38
+ provider === null
39
+ ? `no entitlement provider is composed, so ${kind}("${name}") has nothing to read.`
40
+ : `${provider} resolves entitlements and declares no allowances, so ${kind}("${name}") has nothing to read.`,
41
+ });
42
+ }
43
+
44
+ /** How many of `name` the caller's holder may have: a whole number, or `null` for unlimited. */
45
+ export async function quota(c: Context<PithyHonoEnv>, name: string): Promise<number | null> {
46
+ const resolver = c.var.entitlements;
47
+ if (!resolver?.quota) throw noProvider(c, "quota", name);
48
+ return resolver.quota(name);
49
+ }
50
+
51
+ /** Whether `name` is on for the caller's holder. Always a boolean. */
52
+ export async function feature(c: Context<PithyHonoEnv>, name: string): Promise<boolean> {
53
+ const resolver = c.var.entitlements;
54
+ if (!resolver?.feature) throw noProvider(c, "feature", name);
55
+ return resolver.feature(name);
56
+ }
57
+
58
+ /**
59
+ * The ceiling a capability enforces on a meter it counts itself, for the current caller.
60
+ *
61
+ * **No provider, or one that binds nothing to this meter, is `{ enforced: false }`** — and the capability
62
+ * keeps its own configured ceiling. Enforcement is opt-in per meter because a cap that arrives by surprise
63
+ * breaks an adopter who was already over it.
64
+ *
65
+ * **A provider that throws refuses, and logs.** Storage already says which way round to err: over-counting
66
+ * briefly and reclaiming is the right way round, and under-counting has no recovery. Treating a failed read
67
+ * as unenforced would hand a customer an unbounded allowance for as long as the read kept failing. The cost
68
+ * is real and is stated rather than discovered: our bug becomes a refused write for a paying customer.
69
+ * Enforcement being opt-in is what bounds it — an adopter who would rather over-serve leaves the meter
70
+ * advisory.
71
+ */
72
+ export async function meterLimit(c: Context<PithyHonoEnv>, meter: string): Promise<MeterLimit> {
73
+ const resolver = c.var.entitlements;
74
+ if (!resolver?.meterLimit) return { enforced: false };
75
+ try {
76
+ return await resolver.meterLimit(meter);
77
+ } catch (error) {
78
+ c.var.log?.error("meter limit resolution failed", { provider: resolver.provider, meter, cause: messageOf(error) });
79
+ throw new InternalError({
80
+ message: "Usage could not be checked, so nothing was written.",
81
+ action: "Read the preceding log line for why the allowance read failed.",
82
+ detail: `${resolver.provider} threw resolving the ${meter} meter's ceiling; refusing rather than serving unbounded.`,
83
+ });
84
+ }
85
+ }
@@ -132,8 +132,46 @@ export interface EntitlementResolver {
132
132
  * See {@link EntitlementHolder} for why this is a label and a tenant rather than the holder itself.
133
133
  */
134
134
  holder?(): Promise<EntitlementHolder | undefined>;
135
+ /**
136
+ * How many of `name` the current caller's holder may have — a whole number, or `null` for unlimited.
137
+ * Optional: a provider with no allowances omits it, and {@link import("./allowance").quota} refuses
138
+ * rather than inventing a number. A provider throws for a name it does not declare, never answers 0.
139
+ */
140
+ quota?(name: string): Promise<number | null>;
141
+ /** Whether `name` is on for the current caller's holder. Never null. Optional, as {@link quota} is. */
142
+ feature?(name: string): Promise<boolean>;
143
+ /**
144
+ * The ceiling a capability that counts its own rows enforces for `meter` — storage's bytes, media's
145
+ * items — or `{ enforced: false }` where the provider binds no ceiling to it. Optional, and absent is
146
+ * `{ enforced: false }`: a capability composed with no provider keeps its own configured behavior.
147
+ */
148
+ meterLimit?(meter: string): Promise<MeterLimit>;
135
149
  }
136
150
 
151
+ /**
152
+ * What a provider says about one meter for the current caller: whether it binds a ceiling at all, and if
153
+ * it does, what the ceiling is and whose rows it covers. `limit: null` is unlimited — an enforced meter on
154
+ * a subject whose plan or override says so.
155
+ *
156
+ * Two states rather than a nullable number, because "this meter is advisory here" and "this subject is
157
+ * unlimited" are different facts and a capability must fall back to its own configured ceiling on the
158
+ * first and never on the second.
159
+ *
160
+ * **An enforced ceiling says which rows it is the whole of**, because a ceiling counted against the wrong
161
+ * rows is a different allowance. Under organization billing the plan's 10 GB is the company's: counted
162
+ * against one member's files it becomes 10 GB per member, and a paid quota is bypassed by an invitation.
163
+ * So `tenant` is the organization the ceiling was resolved for, exactly as {@link EntitlementHolder.tenant}
164
+ * is, and a capability sums every row it holds for that tenant, whoever wrote it. `null` means the holder
165
+ * is not a tenant: the ceiling is the caller's own, and the caller's rows are what count.
166
+ *
167
+ * It is a counting key, never a gate. A capability that would write a row for a different tenant than the
168
+ * one the ceiling covers refuses the write — that row would sit outside the sum, and under-counting has no
169
+ * recovery.
170
+ */
171
+ export type MeterLimit =
172
+ | { readonly enforced: false }
173
+ | { readonly enforced: true; readonly limit: number | null; readonly tenant: string | null };
174
+
137
175
  /**
138
176
  * How a resolver's holder appears in a denial's `detail` and on the denial's audit row — and **nothing a
139
177
  * gate may compare**.
@@ -0,0 +1,69 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "../capability/capability";
5
+ import type { Logger } from "../logger/logger";
6
+
7
+ /**
8
+ * The usage-meter seam — how a capability that writes the rows a meter counts asks whether one more is
9
+ * allowed, when there is no request to ask through.
10
+ *
11
+ * `@pithy-sh/email` enqueues from a route, from a Workflow step and from a cron, and only the first has a
12
+ * `c.var.entitlements`. So the meter it charges is found the way the ledger is found — among the composed
13
+ * capabilities, by a structural predicate — and it is handed the holder explicitly. Nothing here imports the
14
+ * provider: an email capability composed with no payments finds no meter and enqueues as it always did.
15
+ *
16
+ * **A meter refuses; it never bills.** `charge` either records the use or throws the provider's own refusal
17
+ * (`payments/quota_exceeded`, 402). What it counted is never reported to a store — the kit does not sell
18
+ * overage — so this seam has no way to read a count back out.
19
+ */
20
+
21
+ /** Who a charge is against: the holder pair, exactly as the provider keys it. */
22
+ export interface MeterSubject {
23
+ /** The kind of holder — `user` or `organization` under `@pithy-sh/payments`. */
24
+ readonly subjectType: string;
25
+ /** The holder's id. */
26
+ readonly subjectId: string;
27
+ }
28
+
29
+ /** One charge against one meter. */
30
+ export interface MeterCharge {
31
+ /** Who uses it. */
32
+ readonly subject: MeterSubject;
33
+ /** Which meter — `emails`. */
34
+ readonly meter: string;
35
+ /** How many. A whole number from 1. */
36
+ readonly amount: number;
37
+ /** The clock. Defaults to now. */
38
+ readonly now?: Date;
39
+ /** Where a refused or failed charge is logged. Optional; the provider logs through its own when absent. */
40
+ readonly log?: Logger;
41
+ }
42
+
43
+ /** The meter a provider exposes. */
44
+ export interface UsageMeter {
45
+ /** The capability answering — `payments`. */
46
+ readonly provider: string;
47
+ /**
48
+ * Record `amount` of `meter` against `subject`, or refuse. An advisory meter records nothing and refuses
49
+ * nothing. **A charge that cannot be checked refuses**, because under-counting has no recovery.
50
+ */
51
+ charge(env: Record<string, unknown>, charge: MeterCharge): Promise<void>;
52
+ }
53
+
54
+ /** A composed capability that provides a meter. */
55
+ export interface UsageMeterProvider extends Capability {
56
+ /** The meter. */
57
+ readonly usageMeter: UsageMeter;
58
+ }
59
+
60
+ /** Whether a composed capability provides a usage meter. Structural, so no provider package is imported. */
61
+ export function isUsageMeterProvider(capability: Capability): capability is UsageMeterProvider {
62
+ const meter = (capability as { usageMeter?: { charge?: unknown } }).usageMeter;
63
+ return typeof meter?.charge === "function";
64
+ }
65
+
66
+ /** The composed usage meter, or undefined when nothing composed provides one. */
67
+ export function composedUsageMeter(capabilities: readonly Capability[]): UsageMeter | undefined {
68
+ return capabilities.find(isUsageMeterProvider)?.usageMeter;
69
+ }
@@ -16,7 +16,7 @@ import {
16
16
  * `verification`-strategy sibling every paid route carries: it says what the caller must *hold*, the
17
17
  * way `requireAuth()` says who the caller must *be*, and it belongs on the route line beside it.
18
18
  *
19
- * app.get("/reports", requireAuth(), requireEntitlement("pro"), zValidator(…), handler)
19
+ * app.get("/reports", requireAuth(), requireEntitlement("pro"), validate(…), handler)
20
20
  *
21
21
  * It lives in `@pithy-sh/core` rather than in `@pithy-sh/payments` for the same reason `requireAuth()`
22
22
  * is copied into each capability instead of imported from `@pithy-sh/auth`: a gate that arrives with a
@@ -11,8 +11,12 @@
11
11
  * This module holds only what does not — the facts about the shapes a runtime hands over, and the filter
12
12
  * that decides whether one of its strings is safe to put in front of an adopter.
13
13
  *
14
+ * The one exception is an import that would not resolve, and it has its own module: `unresolved.ts`
15
+ * decides the kind and the remedy for all three surfaces, because three surfaces deciding it kept
16
+ * disagreeing (#709).
17
+ *
14
18
  * **The filter is here for the same reason the facts are, and the argument took two issues to finish.**
15
- * #223 moved `rootCause`, `prop` and `isBuildFailureWrapper` in and deliberately left `safeReason` beside
19
+ * #223 moved `rootCause` and `prop` in and deliberately left `safeReason` beside
16
20
  * the three refusals, on the reading that what a surface may say is that surface's business. That is true
17
21
  * of the sentences and false of the filter: whether a string carries a path, a stack frame or half a
18
22
  * parser's ANSI box is a property of the string, and the answer cannot differ between the CLI, a
@@ -26,11 +30,12 @@
26
30
  /**
27
31
  * Read a property off an unknown throwable without widening anything to `any`.
28
32
  *
29
- * Deliberately duck-typed. Bun's `ResolveMessage` and `BuildMessage` — the two shapes the CLI's `bin` and
30
- * the vite plugin actually catch, since both run on Bun — are their own classes and are **not**
31
- * `instanceof Error`. An `instanceof` gate here passes a whole suite under vitest, which runs on Node,
32
- * and silently drops the parser's own sentence on the runtime that ships. That is #207, and it is why
33
- * every reader of a cause in this kit goes through this function.
33
+ * Deliberately duck-typed. A cause is `unknown`, and an `instanceof Error` gate is a claim about a
34
+ * runtime's classes that the runtime can break. Bun did: on 1.3 its `ResolveMessage` and `BuildMessage`
35
+ * were **not** `instanceof Error`, so a gate passed a whole suite under vitest, which runs on Node, and
36
+ * silently dropped the parser's own sentence on the runtime that ships. That is #207. Bun 1.4 made both
37
+ * real `Error`s (measured on 1.4.2), and nothing here had to change, which is the point: every reader of
38
+ * a cause in this kit goes through this function and reads fields, never a class.
34
39
  *
35
40
  * Non-enumerable properties are read too — `AggregateError.errors` is one, and {@link rootCause}
36
41
  * depends on it.
@@ -49,7 +54,7 @@ export function prop(cause: unknown, key: string): unknown {
49
54
  * and only by running on Bun with a failure of the *realistic* shape.
50
55
  *
51
56
  * **And the realistic shape is the wrapped one.** A stray brace cascades — one missing `}` in a
52
- * `pithy.config.ts` produced four diagnostics on Bun 1.3.14 — so a syntax error arrives wrapped far more
57
+ * `pithy.config.ts` produces four diagnostics on Bun 1.4.2 — so a syntax error arrives wrapped far more
53
58
  * often than bare. Classified as-is, the wrapper matches nothing: not a resolution failure, not a parse
54
59
  * error, just something with a message. The refusal then says *the config threw while loading, run the
55
60
  * file directly* while holding the line and column in its hand. #207 fixed the bare case and left this
@@ -73,33 +78,6 @@ export function rootCause(cause: unknown): unknown {
73
78
  return current;
74
79
  }
75
80
 
76
- /** Bun's wrapper announces itself: `4 errors building "<absolute path>"`, and nothing else says that. */
77
- const BUILD_FAILURE = /^\d+ errors? building "/;
78
-
79
- /**
80
- * The wrapper **after the diagnostics are gone** — a build that failed, and that is all that is left.
81
- *
82
- * {@link rootCause} handles the first `import()`. It cannot handle the second: Bun caches a failed module
83
- * and re-throws an `AggregateError` with **no `errors` at all**, just the count and the path. There is
84
- * nothing to unwrap, so a classifier that only unwraps reads a syntax error as "it threw" from the second
85
- * caller onward — which is what `pithy doctor` shows an adopter, because resolving the project's account
86
- * loads the root config before the report does (#206, #223).
87
- *
88
- * Nothing else Bun throws degrades this way: a `ResolveMessage`, a bare `BuildMessage` and a module's own
89
- * `Error` are byte-identical on every import. Only the wrapper forgets.
90
- *
91
- * So the wrapper is recognized by its own message. **What it proves is narrow and worth stating:** the
92
- * module reached the builder and the builder produced diagnostics. It did not fail to resolve — Bun
93
- * throws the first `ResolveMessage` bare, wrapping nothing, even for a file with several bad imports —
94
- * and it did not run, so it cannot have thrown. It failed to parse or to build. A caller may say that
95
- * much and no more: the message itself carries an absolute path and a count, neither of which is the
96
- * adopter's business, so it is a *predicate* here rather than a string anyone is tempted to print.
97
- */
98
- export function isBuildFailureWrapper(cause: unknown): boolean {
99
- const message = prop(cause, "message");
100
- return typeof message === "string" && BUILD_FAILURE.test(message);
101
- }
102
-
103
81
  /** Escape sequences a runtime colors its diagnostics with. They are formatting, and they never travel. */
104
82
  // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping the control characters is the point.
105
83
  const ANSI = /\u001b\[[0-9;]*m/g;
@@ -139,19 +117,9 @@ export function causeMessage(cause: unknown): string | undefined {
139
117
  * developer one frame of digging, and a mistake puts a path, a source line or a stack frame in a field
140
118
  * that is rendered.
141
119
  *
142
- * **Provenance first, then content.** Bun's build-failure wrapper is refused outright, before a single
143
- * content test runs. The content tests exist for a diagnostic that *might* be safe; the wrapper never is,
144
- * because its whole message is a count nobody asked for and a path that must not travel. Leaving it to
145
- * the content tests is what leaked: `2 errors building "app/config:12:5.ts"` has no leading slash, sailed
146
- * through the absolute-path check, and produced *The config does not parse: 2 errors building
147
- * "app/config:12:5.ts". Line 12, column 5.* — a path the adopter never wrote, and a position fabricated
148
- * out of a file name (#223). That suppression was written three times because the filter was. It is
149
- * written once here, and {@link failurePosition} refuses the same shape for the same reason.
150
- *
151
120
  * A trailing period comes off: every caller puts this inside a sentence of its own.
152
121
  */
153
122
  export function safeReason(cause: unknown): string | undefined {
154
- if (isBuildFailureWrapper(cause)) return undefined;
155
123
  const text = (causeMessage(cause) ?? "").trim();
156
124
  if (text.length === 0 || text.length > REASON_LIMIT) return undefined;
157
125
  if (text.includes("\n")) return undefined;
@@ -176,13 +144,14 @@ export interface FailurePosition {
176
144
  * path they are appended to. Whether a surface prints them is that surface's policy: two of the three
177
145
  * classifiers do, and the capability loader deliberately does not.
178
146
  *
179
- * Refuses Bun's build-failure wrapper on provenance, exactly as {@link safeReason} does. That message
180
- * holds a file name and a count and nothing about where anything failed, so a `:12:5` found in it is a
181
- * coincidence of the path's own characters — and a fabricated position reads as authoritative.
147
+ * `isOwn`, when given, is asked about the file a structured position names, and a position in a file it
148
+ * disowns is no position at all: Bun's line and column in a file the config imports, quoted as the config's,
149
+ * send the adopter to a place in their config that is fine (#709).
182
150
  */
183
- export function failurePosition(cause: unknown): FailurePosition | undefined {
184
- if (isBuildFailureWrapper(cause)) return undefined;
151
+ export function failurePosition(cause: unknown, isOwn?: (file: string) => boolean): FailurePosition | undefined {
185
152
  const position = prop(cause, "position");
153
+ const file = prop(position, "file");
154
+ if (isOwn !== undefined && typeof file === "string" && !isOwn(file)) return undefined;
186
155
  const line = prop(position, "line");
187
156
  const column = prop(position, "column");
188
157
  if (typeof line === "number" && line > 0 && typeof column === "number") return { line, column };
@@ -190,135 +159,3 @@ export function failurePosition(cause: unknown): FailurePosition | undefined {
190
159
  if (!match?.[1] || !match[2]) return undefined;
191
160
  return { line: Number(match[1]), column: Number(match[2]) };
192
161
  }
193
-
194
- /** What kind of thing failed to resolve. Three causes that wore one sentence until #489. */
195
- export type UnresolvedKind =
196
- /** A bare specifier for a package that is not installed. Installing dependencies is the remedy. */
197
- | "package"
198
- /** An installed package asked for a subpath it does not provide. Installing again changes nothing. */
199
- | "package-subpath"
200
- /** A relative import of a file in the project's own tree. Installing again changes nothing. */
201
- | "local-file";
202
-
203
- /** Whether a path sits inside an installed package. */
204
- function insideNodeModules(path: string): boolean {
205
- return path.split(/[\\/]/).includes("node_modules");
206
- }
207
-
208
- /**
209
- * Which of the three unresolved-import causes this was, or `undefined` when it cannot be told.
210
- *
211
- * **The discriminator was already in the message and was being thrown away.** `unresolvedSpecifier`
212
- * matches `Cannot find (?:package|module)` — and `package` versus `module` is exactly the fact that
213
- * separates a missing dependency from the other two. It sat in a non-capturing group, so every cause
214
- * arrived wearing one sentence, and for two of the three that sentence advised an install which is
215
- * *guaranteed* to change nothing. That is #489's class exactly: guidance a caller cannot follow, worst
216
- * for the caller it is written for, which has no human to shrug and try something else.
217
- *
218
- * Measured across Node 24.13.0 and Bun 1.3.14, because the two runtimes hand back different halves and
219
- * each hands back exactly one usable one:
220
- *
221
- * | cause | Node says | Bun's `specifier` |
222
- * |---|---|---|
223
- * | package absent | `Cannot find package 'pkg'`, no `url` | `pkg` |
224
- * | subpath missing | `Cannot find module '/abs/node_modules/pkg/src/nope.js'`, `url` under node_modules | `pkg/src/nope` |
225
- * | adopter's file | `Cannot find module '/abs/apps/board/src/x'`, `url` outside node_modules | `./src/x` |
226
- *
227
- * Bun gives the **specifier as written**, so a leading `.` settles it. Node gives the **already-resolved
228
- * path**, where being under `node_modules` settles it. Neither read works on both runtimes; the pair
229
- * does, and it is the same predicate — *is this inside node_modules* — applied to whichever fact the
230
- * runtime supplied.
231
- */
232
- export function unresolvedKind(cause: unknown): UnresolvedKind | undefined {
233
- if (/Cannot find package /.test(causeMessage(cause) ?? "")) return "package";
234
-
235
- const specifier = unresolvedSpecifier(cause);
236
- if (specifier === undefined) return undefined;
237
-
238
- // Bun's field is the specifier as written: relative means the adopter's own file.
239
- if (/^(?:\.|\/|[A-Za-z]:[\\/])/.test(specifier)) {
240
- return insideNodeModules(specifier) ? "package-subpath" : "local-file";
241
- }
242
-
243
- // Node states an already-resolved absolute path; a bare specifier reaching here is a package subpath.
244
- const url = prop(cause, "url");
245
- if (typeof url === "string" && url.length > 0) {
246
- return insideNodeModules(url) ? "package-subpath" : "local-file";
247
- }
248
- return "package-subpath";
249
- }
250
-
251
- /**
252
- * The specifier that did not resolve.
253
- *
254
- * A recorded fact about two runtimes: Bun's `ResolveMessage` carries it as a **field**, and Node states it
255
- * in prose. The field is preferred wherever there is one, because the prose around it names the referrer's
256
- * absolute path — the specifier is the adopter's own import, the referrer is our frame.
257
- *
258
- * Whether a surface names it, and what it advises when it does, is that surface's policy and stays there.
259
- */
260
- export function unresolvedSpecifier(cause: unknown): string | undefined {
261
- const field = prop(cause, "specifier");
262
- if (typeof field === "string" && field.length > 0) return field;
263
- const match = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(causeMessage(cause) ?? "");
264
- return match?.[1];
265
- }
266
-
267
- /** A package name as npm allows one: an optional `@scope/`, and never a store's dot-directory. */
268
- const PACKAGE_NAME = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
269
-
270
- /**
271
- * The file the failing import was written in, as the runtime named it. **Module-private on purpose**: it
272
- * is an absolute path, and {@link importingPackage} exists to make sure only its package half leaves here.
273
- */
274
- function importerPath(cause: unknown): string | undefined {
275
- const field = prop(cause, "referrer");
276
- if (typeof field === "string" && field.length > 0) return field;
277
- const message = causeMessage(cause) ?? "";
278
- // Node ends its sentence with the path, so it is taken to the end of the line — a directory may
279
- // contain a space. Bun quotes it when it states one in prose, and always carries the field as well.
280
- return (/ imported from (.+)$/.exec(message) ?? /\bfrom ['"]([^'"]+)['"]/.exec(message))?.[1];
281
- }
282
-
283
- /**
284
- * The package whose own source contained the import that did not resolve — **never a path**.
285
- *
286
- * {@link unresolvedSpecifier} answers *what* did not resolve. This answers *where the import lives*, and
287
- * the two are different facts whenever the failing import is not the adopter's own. #480 is that case
288
- * verbatim: `pithy add email` on a fresh project refused with
289
- *
290
- * > Nothing resolves "@pithy-sh/core/src/capability/capability".
291
- *
292
- * while `@pithy-sh/core` resolved from the Worker at every step of the run, before and after. The
293
- * specifier was honest — the runtime really could not resolve it — but the import belonged to
294
- * `@pithy-sh/email`, which had just been installed and was the package genuinely not linked. So the
295
- * refusal named the one dependency that was definitely fine, and an adopter following it ran `bun
296
- * install` against a tree that already had core in it. The importer is the half that would have pointed
297
- * at `email`, and both runtimes hand it over: **Bun's `ResolveMessage` carries `referrer` as a field**
298
- * (measured on 1.3.14) and Node states it in prose as `imported from <path>`.
299
- *
300
- * **A package name, or nothing.** The referrer is an absolute path — our frame, and the reason
301
- * {@link unresolvedSpecifier} drops the prose around the specifier — so what travels is the one part of
302
- * it that is not a path: the package directory it sits under. A referrer outside `node_modules` is the
303
- * adopter's own file, and there is nothing to say about it that the refusal does not already say by
304
- * naming the config, so it answers `undefined` rather than a path. The path itself is never returned
305
- * from this module, which is what makes leaking it impossible rather than merely discouraged.
306
- *
307
- * The **last** `node_modules` in the path, because every store layout nests: pnpm's
308
- * `node_modules/.pnpm/<id>/node_modules/<pkg>` and Bun's isolated linker's
309
- * `node_modules/.bun/<pkg>@<version>/node_modules/<pkg>` (both measured) put the real owner last, and so
310
- * does an ordinary nested install. The name is then checked against what npm allows, so a store's own
311
- * bookkeeping directory (`.bun`, `.pnpm`, `hono@4.6.3`) can only ever answer nothing.
312
- *
313
- * Whether a surface names it, and what it advises when it does, is that surface's policy and stays there.
314
- */
315
- export function importingPackage(cause: unknown): string | undefined {
316
- const referrer = importerPath(cause);
317
- if (referrer === undefined) return undefined;
318
- const segments = referrer.split(/[\\/]/);
319
- const at = segments.lastIndexOf("node_modules");
320
- const head = at === -1 ? undefined : segments[at + 1];
321
- if (head === undefined) return undefined;
322
- const name = head.startsWith("@") ? `${head}/${segments[at + 2] ?? ""}` : head;
323
- return PACKAGE_NAME.test(name) ? name : undefined;
324
- }