@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,8 +2,9 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import type { ExecutionContext, ForwardableEmailMessage } from "@cloudflare/workers-types";
5
- import type { Hono } from "hono";
5
+ import type { Hono, MiddlewareHandler } from "hono";
6
6
  import type { z } from "zod";
7
+ import type { ActingOrganizationResolver } from "../access/acting";
7
8
  import type { AuditEmit } from "../audit/recorder";
8
9
  import type { ControlPlaneContext } from "../controlPlane/context";
9
10
  import type { AdminRoute } from "../controlPlane/discovery/adminRoute";
@@ -14,6 +15,7 @@ import type { DatabaseSpecMap } from "../data/databases";
14
15
  import type { EntitlementResolver } from "../entitlement/entitlement";
15
16
  import type { AuthContext } from "../http/authContext";
16
17
  import type { SameOriginGate } from "../http/sameOrigin";
18
+ import type { SocketChannel } from "../http/socket";
17
19
  import type { LocaleCatalogs } from "../i18n/catalog";
18
20
  import type { LocaleContext } from "../i18n/locale";
19
21
  import type { Translator } from "../i18n/translator";
@@ -30,6 +32,19 @@ import type { CapabilitySettings } from "./settings";
30
32
  export interface PithyVars {
31
33
  /** The authenticated identity, populated by `@pithy-sh/auth`; `null` until a strategy sets it. */
32
34
  auth: AuthContext | null;
35
+ /**
36
+ * Which organization this request acts for, and what the caller's role there allows — a resolver,
37
+ * populated by `@pithy-sh/organization`'s middleware. With no organization capability composed it is
38
+ * {@link import("../access/acting").noActingOrganization}, which answers `null`: nothing is in force,
39
+ * every record a request writes carries a null organization, and every rule that admits by organization
40
+ * admits nobody.
41
+ *
42
+ * **A seam beside {@link auth}, never a field on it.** `auth` says who is signed in; this says what they
43
+ * may do *here*, and a capability that gates on it — media, storage — must not import the capability
44
+ * that computes it. Lazy, because resolving it is a D1 read most requests never need; read it with
45
+ * `await c.var.actingOrganization()`, or through `accessCaller` in `access/access.ts`.
46
+ */
47
+ actingOrganization: ActingOrganizationResolver;
33
48
  /**
34
49
  * The resolved request locale, populated by `@pithy-sh/i18n`'s middleware; `null` in a Worker that
35
50
  * does not compose it — which is what "nothing was negotiated" looks like, as distinct from "the
@@ -131,6 +146,12 @@ export type PithyHonoEnv = { Bindings: Record<string, unknown>; Variables: Pithy
131
146
 
132
147
  export type PithyMiddleware = (app: Hono<PithyHonoEnv>) => void;
133
148
 
149
+ /**
150
+ * One readiness guard: a handler that refuses because **this Worker** is in a bad state. See
151
+ * {@link Capability.readiness} for where it runs and where it does not.
152
+ */
153
+ export type PithyReadinessGuard = MiddlewareHandler<PithyHonoEnv>;
154
+
134
155
  /**
135
156
  * The structural seam for one capability's secret-registry slice — secret name → its declaration.
136
157
  * Core carries it on the {@link Capability} contract so a capability can declare which secrets it
@@ -229,29 +250,18 @@ export type SecretBranchSeam = Record<string, readonly string[]>;
229
250
  export type InapplicableSecretSeam = Record<string, string>;
230
251
 
231
252
  /**
232
- * The structural seam for one capability's token-profile slice — profile name → the scoped CF API
233
- * token that capability's code needs. Declared next to the code that uses it (alongside
234
- * {@link Capability.secretRegistry}), so a capability owns its token's least-privilege scope and where
235
- * the minted value lands. Core carries only the structural axes (never interpreted here); the concrete
236
- * `TokenProfile`, the permission catalog, `defineTokenProfile`, and the mint engine live in
237
- * `@pithy-sh/cloudflare`/the CLI. `pithy token` aggregates every capability's slice into one registry.
253
+ * One route a capability gates on an `AuthContext` scope — see {@link Capability.scopeGates}.
254
+ *
255
+ * Descriptive, never behavioral: the gate itself is the capability's own middleware, and this is what a
256
+ * diagnostic reads to name it. `route` is the method and the mounted path, as the route table writes them.
238
257
  */
239
- export interface TokenProfileSeam {
240
- /** The CF permissions the token grants, as short catalog keys (`d1:read`, `secrets:write`). */
241
- readonly permissions: readonly string[];
242
- /** The resource scope: `"account"` (the whole account, the default) or an explicit CF resource map. */
243
- readonly resources?: "account" | Record<string, string>;
244
- /** The secret-registry name the minted value is stored under — its backend decides the destination. */
245
- readonly secret?: string;
246
- /** A built-in destination override (`dev-vars` | `ephemeral` | `secrets-store`) for a tooling profile. */
247
- readonly defaultStore?: string;
248
- /** Why the profile exists / what consumes it — surfaced in `pithy token` help and docs. */
249
- readonly description?: string;
258
+ export interface ScopeGate {
259
+ /** The scope the gate requires, as the resolved config names it — `leaderboard:submit`. */
260
+ readonly scope: string;
261
+ /** The method and mounted path it guards, `POST /leaderboard/:board`. */
262
+ readonly route: string;
250
263
  }
251
264
 
252
- /** A capability's token-profile slice: profile name → {@link TokenProfileSeam}. */
253
- export type TokenProfileSeamMap = Record<string, TokenProfileSeam>;
254
-
255
265
  /**
256
266
  * One thing an **adopter** plugged into a capability — the capability's own name for something the kit
257
267
  * did not ship and cannot enumerate. `@pithy-sh/auth` declares one per additional Better Auth plugin
@@ -378,13 +388,6 @@ export interface Capability<
378
388
  * whenever it is composed declares nothing, which is the ordinary case.
379
389
  */
380
390
  inapplicableSecrets?: InapplicableSecretSeam;
381
- /**
382
- * The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
383
- * {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
384
- * — the value of a minted token lands in the secret the profile names. `pithy token` aggregates every
385
- * capability's slice with the built-in tooling profiles into one registry.
386
- */
387
- tokenProfiles?: TokenProfileSeamMap;
388
391
  /**
389
392
  * Extra CF permissions this capability needs the **CI system token** to hold, as short catalog keys
390
393
  * (e.g. `email:routing` for email provisioning, `kv:write` for a module that seeds KV in CI). They
@@ -392,6 +395,20 @@ export interface Capability<
392
395
  * extends what CI can do without the adopter hand-editing token scopes. Additive and optional.
393
396
  */
394
397
  ciPermissions?: readonly string[];
398
+ /**
399
+ * The CF permissions this capability's **deployed Workers** call Cloudflare with, as short catalog keys
400
+ * (`secrets:write`, `images:write`, `analytics:read`). The runtime counterpart of {@link ciPermissions}.
401
+ *
402
+ * **A deployed application holds one Cloudflare API token, and this is the only way into it (#728).**
403
+ * Every composed capability's keys union into the project-level `runtime` profile, minted per
404
+ * environment into the Secrets Store and read by every Worker of that environment through one binding,
405
+ * the way every capability's `ciPermissions` union into the one `ci-system` token. A capability cannot
406
+ * declare a token of its own. A project whose composition declares none mints no runtime token.
407
+ *
408
+ * Build it from the resolved config, as `secretRegistry` is built: a capability configured to call
409
+ * nothing on Cloudflare declares nothing, and adds nothing to the token. Additive and optional.
410
+ */
411
+ runtimePermissions?: readonly string[];
395
412
  /**
396
413
  * The admin routes this capability contributes behind the `control-plane` strategy, described well
397
414
  * enough for a management client to call them — full mounted path, required scope, and a one-line
@@ -532,8 +549,36 @@ export interface Capability<
532
549
  boot?: (context: CapabilityComposeContext) => void;
533
550
  /** Mounts a Hono sub-router. */
534
551
  routes?: (app: Hono<PithyHonoEnv>) => void;
535
- /** Composable middleware (e.g. turnstile(), requireAuth()). */
552
+ /**
553
+ * The WebSockets this capability serves, described for the AsyncAPI contract (`../http/socket.ts`). Built
554
+ * from the resolved config, like `adminRoutes`: the paths it mounted, and the schemas its frames are
555
+ * parsed with. A capability with no socket leaves it unset.
556
+ */
557
+ sockets?: readonly SocketChannel[];
558
+ /**
559
+ * Composable middleware (e.g. turnstile(), requireAuth()). Runs on every request, the control-plane
560
+ * surface included. A refusal that protects a route — a rate limit, authentication, a CSRF check —
561
+ * belongs here. A refusal because the Worker itself is broken belongs in {@link Capability.readiness}.
562
+ */
536
563
  middleware?: PithyMiddleware[];
564
+ /**
565
+ * Guards that refuse because **this Worker is misconfigured or unprovisioned** — `@pithy-sh/vector`'s
566
+ * `provisionGuard`, when the deployed index disagrees with config. Not for protecting a route: a rate
567
+ * limit, authentication and a CSRF check refuse a *caller*, and they belong in
568
+ * {@link Capability.middleware}.
569
+ *
570
+ * `createBackend` mounts every readiness guard after every capability's `middleware` and before any
571
+ * capability's routes, so the verifier, `emit` and every rate limit are already in place. It skips
572
+ * each guard on the admin surface — every composed capability's `adminRoutes`, the seam's own
573
+ * included, the set CORS is derived from. The control plane is the diagnostic surface, so it cannot
574
+ * sit behind the faults it diagnoses. Every other request meets the guard, `GET /health` aside, which
575
+ * is registered ahead of all of it.
576
+ *
577
+ * A handler rather than a registrar like `middleware`, because core decides where it mounts. A guard
578
+ * that wanted its own path would be deciding the exemption by path shape, which is the thing this
579
+ * exists to take away from it.
580
+ */
581
+ readiness?: PithyReadinessGuard[];
537
582
  /**
538
583
  * Whether this capability fills the entitlement seam — replaces `c.var.entitlements` with a real
539
584
  * resolver. `@pithy-sh/payments` declares it; nothing else does today.
@@ -545,6 +590,27 @@ export interface Capability<
545
590
  * flag and say so, which turns a fleet of production 403s into one line at startup.
546
591
  */
547
592
  providesEntitlements?: boolean;
593
+ /**
594
+ * Whether this capability fills `AuthContext.scopes` — whether anything will ever put a string in the
595
+ * array the scope gates read. `@pithy-sh/auth` declares it when an adopter supplies `resolveScopes`, and
596
+ * not otherwise (#560).
597
+ *
598
+ * The same reason {@link providesEntitlements} exists, one seam over. Every scope gate fails closed, so a
599
+ * Worker composing `leaderboard` with no resolver is not broken: it refuses every caller on the gated
600
+ * routes, forever, and the runtime cannot tell that from a caller who simply is not trusted. `pithy doctor`
601
+ * compares this flag against {@link scopeGates} and says so.
602
+ */
603
+ providesScopes?: boolean;
604
+ /**
605
+ * The routes this capability gates on an `AuthContext` scope, built from the resolved config — a gate the
606
+ * config has switched off is not declared. Omit it when nothing is gated, which is the ordinary case.
607
+ *
608
+ * **Declared so the CLI can name what will deny.** With no capability composed that
609
+ * {@link providesScopes}, every route listed here answers 403 to every caller, and `pithy doctor` names
610
+ * each one. Each capability's route-contract test holds this list to the `scopes` its `describeRoute`s
611
+ * carry, so the declaration cannot drift from the routes.
612
+ */
613
+ scopeGates?: readonly ScopeGate[];
548
614
  /**
549
615
  * This capability's client-safe projection — the **only** values of its config that may reach a
550
616
  * browser bundle. Resolved at build time against a {@link ClientProjectionContext} (the environment),
@@ -592,6 +658,17 @@ export interface Capability<
592
658
  * time, and read by `pithy doctor`.
593
659
  */
594
660
  extensions?: readonly CapabilityExtension[];
661
+ /**
662
+ * The role powers this capability's own routes gate on, through the acting-organization seam —
663
+ * `media:manage`, `storage:manage`. Omit it when a capability asks no power, which is the normal case.
664
+ *
665
+ * **Declared so the catalog requirement can be conditional.** `@pithy-sh/organization` refuses a role
666
+ * catalog in which no role holds a power a composed capability gates on — a route nobody could ever use
667
+ * — and a project that composes no media must not be refused for not placing `media:manage`. The
668
+ * organization capability reads this list at compose, across every composed capability, rather than
669
+ * importing any of them.
670
+ */
671
+ powers?: readonly string[];
595
672
  /** Bindings this capability needs in the env (normalized — `optional` is always set). */
596
673
  requiredBindings: BindingSpec[];
597
674
  }
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { pithyCommandOf } from "../cli/command";
4
5
  import { InternalError } from "../error/pithyError";
5
6
  import type { Capability } from "./capability";
6
7
 
@@ -90,7 +91,7 @@ export function composedCapability<T extends Capability>(name: string, is: (capa
90
91
  // missing-peer check names it: a capability name is a published part of this framework, and a
91
92
  // wiring fault that will not say which wire is a fault nobody can act on.
92
93
  message: `The "${name}" capability is not composed, so this job could not run.`,
93
- action: `Add ${name}(...) to createBackend's capabilities (run \`pithy add ${name}\`).`,
94
+ action: `Add ${name}(...) to createBackend's capabilities (run \`${pithyCommandOf("add", { args: [name], flags: { "--with-prerequisites": true } }, { runtime: true })}\`).`,
94
95
  detail: `no composed capability named "${name}" carrying its seams was found.`,
95
96
  });
96
97
  }
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { InternalError } from "../error/pithyError";
5
6
  import type { HostEnvReport } from "../workflow/hostEnv";
6
7
  import { hostEnvProviderSentence } from "../workflow/hostEnv";
7
8
 
@@ -69,6 +70,13 @@ export const SettingsFinding = z
69
70
  .describe(
70
71
  "What resolves it: the `pithy` command, the `pithy.config.ts` key, or the one-time account action. Never optional — a finding nobody can act on is a complaint.",
71
72
  ),
73
+ fix: z
74
+ .string()
75
+ .min(1)
76
+ .optional()
77
+ .describe(
78
+ "The `pithy` command that resolves it, whole and runnable as written, when one does. Absent when the remedy is an edit or an account action, and from a capability older than the field — `pithy doctor --json` reports either as `fix: null`.",
79
+ ),
72
80
  })
73
81
  .describe("One capability setting that does not work, with the action that resolves it.");
74
82
  export type SettingsFinding = z.infer<typeof SettingsFinding>;
@@ -90,8 +98,15 @@ export interface SettingsEnvironment {
90
98
  export interface SettingsCheckContext {
91
99
  /** The root config's `name` — the leading segment of every resource this project provisions. */
92
100
  project: string;
93
- /** The Worker whose `pithy.config.ts` composed this capability. */
101
+ /** The Worker whose `pithy.config.ts` composed this capability, by its `wrangler.jsonc` name. */
94
102
  worker: string;
103
+ /**
104
+ * That Worker's directory under `apps/` — the value `--worker` takes, and the one a refusal lists (#711).
105
+ *
106
+ * Not `worker` under another name. A scaffold deploys `apps/api` as `acme-api`, so a command a finding names
107
+ * for one Worker is written with this.
108
+ */
109
+ workerDirectory: string;
95
110
  /** Every environment the project declares, in declaration order. */
96
111
  environments: readonly SettingsEnvironment[];
97
112
  }
@@ -101,8 +116,8 @@ export interface SettingsCheckContext {
101
116
  *
102
117
  * Closed for the reason `CapabilityHealth` is: handing a capability a raw Cloudflare client would make
103
118
  * every capability a place a network call can be invented, and would put `@pithy-sh/cloudflare` in the
104
- * dependency list of packages that need none of it. Three questions cover what the first checks ask; a
105
- * fourth lands here, once, when a capability actually needs it.
119
+ * dependency list of packages that need none of it. A question lands here once a capability actually needs
120
+ * it, and not before: six cover what the checks ask.
106
121
  *
107
122
  * Every method may throw. A throw is the account failing to answer, and the runner reports that
108
123
  * capability's account tier as unchecked rather than as a pass.
@@ -127,6 +142,47 @@ export interface SettingsAccountReader {
127
142
  * created it with, so the check looks where the value went.
128
143
  */
129
144
  storeEntry(name: string): Promise<boolean>;
145
+ /**
146
+ * The ids of the rows in one of the kit's own tables whose column holds one of `values`, in the app
147
+ * database a Worker binds as `DB` in one deployed environment (#727).
148
+ *
149
+ * Asked by media and storage after a project narrows `visibility.allowed`: narrowing never rewrites a
150
+ * record, so the records left outside the policy have to be *named*, and only the deployed database
151
+ * holds them. Closed in its own way — the table must be a `pithy_` one and the column an identifier,
152
+ * both refused otherwise, and the values are bound, never interpolated — so it reads a fact about a
153
+ * capability's own rows and cannot become a query language.
154
+ */
155
+ appRows(request: AppRowsRequest): Promise<readonly string[]>;
156
+ /**
157
+ * Whether that environment's **D1 vault** holds a `json` secret that still carries a top-level `field` —
158
+ * asked of its manager Worker, which opens the value and answers a bit, never the value (#728). `false`
159
+ * for a secret that is not stored, since it carries nothing.
160
+ *
161
+ * Asked by media of `media-storage-credentials`, which held a supplied Cloudflare token until the runtime
162
+ * token replaced it: the upgrade is finished only once the bundle has been rewritten without it.
163
+ */
164
+ vaultSecretField(request: { name: string; environment: string; field: string }): Promise<boolean>;
165
+ /**
166
+ * Whether the account holds a Cloudflare API token of exactly this name (#728). The name is the token's,
167
+ * composed with the namer that minted it — never a pattern, so a check names one credential it can see.
168
+ *
169
+ * Asked by secrets of the manager token the runtime token superseded, which nothing revokes for you.
170
+ */
171
+ accountToken(name: string): Promise<boolean>;
172
+ }
173
+
174
+ /** What {@link SettingsAccountReader.appRows} is asked. */
175
+ export interface AppRowsRequest {
176
+ /** The deployed environment whose app database is read. `dev` is refused: it has no deployed database. */
177
+ readonly environment: string;
178
+ /** The Worker's `apps/` directory — whose `wrangler.jsonc` names the database for that environment. */
179
+ readonly workerDirectory: string;
180
+ /** The kit table, as it is named in SQL — `pithy_media_assets`. Must carry the `pithy_` prefix. */
181
+ readonly table: string;
182
+ /** The column compared, as it is named in SQL — `visibility`. */
183
+ readonly column: string;
184
+ /** The values that make a row worth naming. */
185
+ readonly values: readonly string[];
130
186
  }
131
187
 
132
188
  /** What the account tier is told: everything the local tier gets, plus the account it may ask. */
@@ -135,6 +191,56 @@ export interface SettingsAccountContext extends SettingsCheckContext {
135
191
  account: SettingsAccountReader;
136
192
  }
137
193
 
194
+ /** One environment an account check could not answer for, and why. */
195
+ export interface UnansweredEnvironment {
196
+ /** The environment id. */
197
+ readonly environment: string;
198
+ /** What the account, or the project, said instead of an answer. */
199
+ readonly cause: unknown;
200
+ }
201
+
202
+ /**
203
+ * An account check that answered for some environments and not for others.
204
+ *
205
+ * A check that asks per environment must not let one unanswerable environment cost the others their
206
+ * findings: staging stood up before prod is the ordinary bring-up, and a check that threw on prod's missing
207
+ * database would throw away what it had already found in staging. So it collects what it can, and throws
208
+ * this at the end carrying those findings. The runner reports them as findings and the tier as unchecked,
209
+ * both — a fault and an unanswered question are two facts, and neither hides the other.
210
+ */
211
+ export class SettingsAccountIncomplete extends InternalError {
212
+ /** What the environments that did answer were found to have wrong. */
213
+ readonly findings: readonly SettingsFinding[];
214
+ /** The environments that did not answer, in the order they were asked. */
215
+ readonly unanswered: readonly string[];
216
+
217
+ constructor(findings: readonly SettingsFinding[], unanswered: readonly UnansweredEnvironment[]) {
218
+ const reasons = unanswered.map(({ environment, cause }) => `${environment}: ${describeCause(cause)}`).join("; ");
219
+ super(
220
+ {
221
+ message: `The account check could not answer for ${unanswered.map(({ environment }) => environment).join(", ")}.`,
222
+ detail: reasons,
223
+ },
224
+ { cause: unanswered[0]?.cause },
225
+ );
226
+ this.findings = findings;
227
+ this.unanswered = unanswered.map(({ environment }) => environment);
228
+ }
229
+ }
230
+
231
+ /** A cause, as one line. */
232
+ function describeCause(cause: unknown): string {
233
+ return cause instanceof Error ? cause.message : String(cause);
234
+ }
235
+
236
+ /**
237
+ * The findings an account check reached before it threw — none for an ordinary throw, and what it had
238
+ * collected for a {@link SettingsAccountIncomplete}.
239
+ */
240
+ export function settingsFindingsBefore(error: unknown): readonly SettingsFinding[] {
241
+ return error instanceof SettingsAccountIncomplete ? error.findings : [];
242
+ }
243
+
138
244
  /**
139
245
  * A capability's settings check, as it hangs off the capability.
140
246
  *
@@ -149,6 +255,58 @@ export interface CapabilitySettings {
149
255
  account?(context: SettingsAccountContext): Promise<SettingsFinding[]>;
150
256
  }
151
257
 
258
+ /**
259
+ * **Two or more checks on one capability, as the one `settings` it declares (#728).** Media names records a
260
+ * narrowed visibility no longer admits and a bundle still holding a superseded token: two questions, one
261
+ * capability, one seam.
262
+ *
263
+ * Each tier runs every check's, in declaration order. A check that could not answer for some environments
264
+ * costs those environments and nothing else: the others' findings are kept, and the run ends in one
265
+ * {@link SettingsAccountIncomplete} carrying every finding and every unanswered environment. A check that
266
+ * throws outright is unanswered for every environment the context declares.
267
+ */
268
+ export function allSettings(...checks: readonly CapabilitySettings[]): CapabilitySettings {
269
+ const accounts = checks.flatMap((check) => (check.account ? [check.account.bind(check)] : []));
270
+ return {
271
+ async local(context) {
272
+ const findings: SettingsFinding[] = [];
273
+ for (const check of checks) findings.push(...(await check.local(context)));
274
+ return findings;
275
+ },
276
+ ...(accounts.length === 0
277
+ ? {}
278
+ : {
279
+ async account(context) {
280
+ const findings: SettingsFinding[] = [];
281
+ const unanswered: UnansweredEnvironment[] = [];
282
+ for (const account of accounts) {
283
+ try {
284
+ findings.push(...(await account(context)));
285
+ } catch (cause) {
286
+ findings.push(...settingsFindingsBefore(cause));
287
+ if (cause instanceof SettingsAccountIncomplete) {
288
+ for (const environment of cause.unanswered) {
289
+ if (!unanswered.some((entry) => entry.environment === environment)) {
290
+ unanswered.push({ environment, cause: cause.cause ?? cause });
291
+ }
292
+ }
293
+ continue;
294
+ }
295
+ // With no environment to name, there is nothing the throw could be reported against.
296
+ if (context.environments.length === 0) throw cause;
297
+ for (const { name } of context.environments) {
298
+ if (!unanswered.some((entry) => entry.environment === name))
299
+ unanswered.push({ environment: name, cause });
300
+ }
301
+ }
302
+ }
303
+ if (unanswered.length > 0) throw new SettingsAccountIncomplete(findings, unanswered);
304
+ return findings;
305
+ },
306
+ }),
307
+ };
308
+ }
309
+
152
310
  /**
153
311
  * Turn a host env's own parse report into findings — the bridge that makes "one schema, two readers" real.
154
312
  *
@@ -156,7 +314,7 @@ export interface CapabilitySettings {
156
314
  * import("../workflow/hostEnv").checkHostEnv} against its own declaration, and hands the report here. The
157
315
  * problem line is Zod's own message and the action line is {@link hostEnvProviderSentence} — the same
158
316
  * words the host writes into its log before it refuses to start. Two readers of one declaration, and no
159
- * second wording to drift.
317
+ * second wording to drift. A provider that names a command carries it as `fix` too.
160
318
  */
161
319
  export function hostEnvFindings(report: HostEnvReport<unknown>, environment: string | null): SettingsFinding[] {
162
320
  return report.problems.map((problem) => ({
@@ -164,5 +322,8 @@ export function hostEnvFindings(report: HostEnvReport<unknown>, environment: str
164
322
  environment,
165
323
  problem: problem.reason,
166
324
  action: hostEnvProviderSentence(problem.provider),
325
+ // The provider's own command, where it names one, as the finding's `fix` (#697): the same words the action
326
+ // line runs, without the sentence around them.
327
+ ...(problem.provider.command === undefined ? {} : { fix: problem.provider.command }),
167
328
  }));
168
329
  }