@pithy-sh/core 0.10.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 (180) 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/audit/auditEvent.d.ts +5 -0
  17. package/dist/audit/auditEvent.d.ts.map +1 -1
  18. package/dist/audit/auditEvent.js +2 -0
  19. package/dist/capability/bindings.d.ts +47 -2
  20. package/dist/capability/bindings.d.ts.map +1 -1
  21. package/dist/capability/bindings.js +117 -7
  22. package/dist/capability/capability.d.ts +98 -28
  23. package/dist/capability/capability.d.ts.map +1 -1
  24. package/dist/capability/manifest.d.ts +2 -1
  25. package/dist/capability/manifest.d.ts.map +1 -1
  26. package/dist/capability/manifest.js +1 -1
  27. package/dist/capability/settings.d.ts +86 -2
  28. package/dist/capability/settings.d.ts.map +1 -1
  29. package/dist/capability/settings.js +80 -1
  30. package/dist/cli/commandCatalog.generated.d.ts +184 -22
  31. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  32. package/dist/cli/commandCatalog.generated.js +196 -22
  33. package/dist/cli/commandFacts.d.ts +1 -1
  34. package/dist/cli/commandFacts.d.ts.map +1 -1
  35. package/dist/cli/commandFacts.js +1 -0
  36. package/dist/controlPlane/capability.d.ts.map +1 -1
  37. package/dist/controlPlane/capability.js +12 -2
  38. package/dist/controlPlane/context.d.ts +5 -0
  39. package/dist/controlPlane/context.d.ts.map +1 -1
  40. package/dist/controlPlane/context.js +3 -1
  41. package/dist/controlPlane/data/connection.d.ts +47 -0
  42. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  43. package/dist/controlPlane/data/connection.js +55 -1
  44. package/dist/controlPlane/http/cors.d.ts +4 -4
  45. package/dist/controlPlane/http/cors.js +4 -4
  46. package/dist/controlPlane/http/guard.d.ts +11 -0
  47. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  48. package/dist/controlPlane/http/guard.js +17 -1
  49. package/dist/controlPlane/http/handlers.d.ts +60 -0
  50. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  51. package/dist/controlPlane/http/handlers.js +31 -2
  52. package/dist/controlPlane/http/readiness.d.ts +33 -0
  53. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  54. package/dist/controlPlane/http/readiness.js +39 -0
  55. package/dist/controlPlane/http/responses.d.ts +147 -0
  56. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  57. package/dist/controlPlane/http/responses.js +61 -4
  58. package/dist/controlPlane/http/routes.d.ts +1 -0
  59. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  60. package/dist/controlPlane/http/routes.js +17 -3
  61. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  62. package/dist/controlPlane/http/verify.js +2 -1
  63. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  64. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  65. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  66. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  67. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  68. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  69. package/dist/controlPlane/scope/scope.d.ts +13 -2
  70. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  71. package/dist/controlPlane/scope/scope.js +14 -3
  72. package/dist/createBackend.d.ts.map +1 -1
  73. package/dist/createBackend.js +7 -2
  74. package/dist/createEntrypoint.d.ts.map +1 -1
  75. package/dist/createEntrypoint.js +5 -3
  76. package/dist/entitlement/allowance.d.ts +25 -0
  77. package/dist/entitlement/allowance.d.ts.map +1 -0
  78. package/dist/entitlement/allowance.js +80 -0
  79. package/dist/entitlement/entitlement.d.ts +41 -0
  80. package/dist/entitlement/entitlement.d.ts.map +1 -1
  81. package/dist/entitlement/meter.d.ts +57 -0
  82. package/dist/entitlement/meter.d.ts.map +1 -0
  83. package/dist/entitlement/meter.js +13 -0
  84. package/dist/error/http.d.ts +144 -0
  85. package/dist/error/http.d.ts.map +1 -1
  86. package/dist/error/payload.d.ts +288 -0
  87. package/dist/error/payload.d.ts.map +1 -1
  88. package/dist/error/payload.js +100 -4
  89. package/dist/http/authContext.d.ts +7 -0
  90. package/dist/http/authContext.d.ts.map +1 -1
  91. package/dist/http/authContext.js +8 -1
  92. package/dist/http/scopeGates.d.ts +20 -0
  93. package/dist/http/scopeGates.d.ts.map +1 -0
  94. package/dist/http/scopeGates.js +36 -0
  95. package/dist/index.d.ts +1 -1
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +2 -2
  98. package/dist/logger/record.d.ts +84 -0
  99. package/dist/logger/record.d.ts.map +1 -1
  100. package/dist/migrations/capabilitySets.d.ts +23 -0
  101. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  102. package/dist/migrations/capabilitySets.js +74 -0
  103. package/dist/migrations/registry.d.ts +26 -0
  104. package/dist/migrations/registry.d.ts.map +1 -1
  105. package/dist/migrations/registry.js +38 -7
  106. package/dist/migrations/state.d.ts +60 -0
  107. package/dist/migrations/state.d.ts.map +1 -0
  108. package/dist/migrations/state.js +46 -0
  109. package/dist/naming/feature.d.ts +27 -5
  110. package/dist/naming/feature.d.ts.map +1 -1
  111. package/dist/naming/feature.js +36 -6
  112. package/dist/naming/limits.d.ts +47 -3
  113. package/dist/naming/limits.d.ts.map +1 -1
  114. package/dist/naming/limits.js +47 -3
  115. package/dist/naming/provisionScope.d.ts +6 -0
  116. package/dist/naming/provisionScope.d.ts.map +1 -1
  117. package/dist/naming/provisionScope.js +8 -3
  118. package/dist/naming/resourceNames.d.ts +21 -2
  119. package/dist/naming/resourceNames.d.ts.map +1 -1
  120. package/dist/naming/resourceNames.js +21 -3
  121. package/dist/seed/writeD1.d.ts +7 -1
  122. package/dist/seed/writeD1.d.ts.map +1 -1
  123. package/dist/seed/writeD1.js +9 -3
  124. package/dist/seed/writeKv.d.ts +2 -0
  125. package/dist/seed/writeKv.d.ts.map +1 -1
  126. package/dist/seed/writeKv.js +6 -2
  127. package/dist/version.generated.d.ts +1 -1
  128. package/dist/version.generated.js +1 -1
  129. package/dist/worker/serviceLoopback.d.ts +36 -0
  130. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  131. package/dist/worker/serviceLoopback.js +74 -0
  132. package/dist/workflow/faults.d.ts +12 -1
  133. package/dist/workflow/faults.d.ts.map +1 -1
  134. package/dist/workflow/faults.js +22 -15
  135. package/package.json +2 -2
  136. package/src/access/access.ts +298 -0
  137. package/src/access/acting.ts +59 -0
  138. package/src/access/policyCheck.ts +77 -0
  139. package/src/access/usage.ts +141 -0
  140. package/src/access/visibility.ts +109 -0
  141. package/src/audit/auditEvent.ts +4 -0
  142. package/src/capability/bindings.ts +159 -13
  143. package/src/capability/capability.ts +99 -29
  144. package/src/capability/settings.ts +146 -2
  145. package/src/cli/commandCatalog.generated.ts +212 -22
  146. package/src/cli/commandFacts.ts +1 -0
  147. package/src/controlPlane/capability.ts +25 -1
  148. package/src/controlPlane/context.ts +4 -0
  149. package/src/controlPlane/data/connection.ts +90 -0
  150. package/src/controlPlane/http/cors.ts +4 -4
  151. package/src/controlPlane/http/guard.ts +21 -0
  152. package/src/controlPlane/http/handlers.ts +41 -0
  153. package/src/controlPlane/http/readiness.ts +50 -0
  154. package/src/controlPlane/http/responses.ts +140 -2
  155. package/src/controlPlane/http/routes.ts +25 -1
  156. package/src/controlPlane/http/verify.ts +2 -0
  157. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  158. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  159. package/src/controlPlane/scope/scope.ts +14 -2
  160. package/src/createBackend.ts +24 -6
  161. package/src/createEntrypoint.ts +9 -3
  162. package/src/entitlement/allowance.ts +85 -0
  163. package/src/entitlement/entitlement.ts +38 -0
  164. package/src/entitlement/meter.ts +69 -0
  165. package/src/error/payload.ts +211 -4
  166. package/src/http/authContext.ts +13 -1
  167. package/src/http/scopeGates.ts +34 -0
  168. package/src/index.ts +1 -0
  169. package/src/migrations/capabilitySets.ts +87 -0
  170. package/src/migrations/registry.ts +46 -2
  171. package/src/migrations/state.ts +94 -0
  172. package/src/naming/feature.ts +46 -7
  173. package/src/naming/limits.ts +35 -3
  174. package/src/naming/provisionScope.ts +19 -1
  175. package/src/naming/resourceNames.ts +52 -5
  176. package/src/seed/writeD1.ts +13 -4
  177. package/src/seed/writeKv.ts +6 -2
  178. package/src/version.generated.ts +1 -1
  179. package/src/worker/serviceLoopback.ts +105 -0
  180. package/src/workflow/faults.ts +51 -24
@@ -0,0 +1,109 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ValidationError } from "../error/pithyError";
6
+
7
+ /**
8
+ * Who may read a record, and learn that it exists — one convention, written once, for every capability
9
+ * that holds a record a person owns.
10
+ *
11
+ * **Three levels, and the kit owns them.** `private` is the owner, acting for the organization the record
12
+ * was written under; acting for any other organization, not even them. `organization` is any member of the
13
+ * record's organization, acting for it. `public` is anyone holding the id or the URL, signed in or not.
14
+ * Media and storage share the levels, the policy shape and the rules, so a record means the same thing in
15
+ * both and there is one place the meaning can be wrong.
16
+ *
17
+ * **The project decides which levels exist, and which one a record gets when it names none.** `allowed` is
18
+ * a set rather than a mode, because a set says everything a mode would and one thing it cannot:
19
+ * `["private", "organization"]` is a guarantee that nothing can be published, even by mistake.
20
+ */
21
+
22
+ /** Every level, weakest first. */
23
+ export const RECORD_VISIBILITIES = ["private", "organization", "public"] as const;
24
+
25
+ export const RecordVisibility = z
26
+ .enum(RECORD_VISIBILITIES)
27
+ .describe(
28
+ "Who may read a record and learn it exists: `private` is the owner acting for the record's organization, `organization` is any member acting for it, `public` is anyone holding the id or URL.",
29
+ );
30
+ export type RecordVisibility = z.infer<typeof RecordVisibility>;
31
+
32
+ /**
33
+ * The levels a project allows, and the one a record gets when a write names none.
34
+ *
35
+ * **`default` is `private` and must be in `allowed`**, and a policy that breaks either is refused at
36
+ * parse — which, for a capability, is at boot. **`allowed` defaults to all three**, so a capability that
37
+ * accepted `public` before this policy existed is not narrowed by it.
38
+ */
39
+ export const VisibilityPolicy = z
40
+ .object({
41
+ default: RecordVisibility.default("private").describe(
42
+ "The level a record gets when a write names none. Private, so a forgotten field can never publish a file. Must be one of `allowed`.",
43
+ ),
44
+ allowed: z
45
+ .array(RecordVisibility)
46
+ .min(1)
47
+ .default([...RECORD_VISIBILITIES])
48
+ .describe(
49
+ 'The levels a write may name. All three by default. `["private", "organization"]` means nothing can ever be public; a write naming a level outside it is refused, never coerced.',
50
+ ),
51
+ })
52
+ .describe("Which visibility levels this project allows, and the default for a write that names none.")
53
+ .check((ctx) => {
54
+ if (ctx.value.allowed.includes(ctx.value.default)) return;
55
+ ctx.issues.push({
56
+ code: "custom",
57
+ input: ctx.value,
58
+ path: ["default"],
59
+ message: `visibility.default is \`${ctx.value.default}\`, which visibility.allowed (${ctx.value.allowed.join(", ")}) does not include.`,
60
+ });
61
+ });
62
+ export type VisibilityPolicy = z.output<typeof VisibilityPolicy>;
63
+ export type VisibilityPolicyInput = z.input<typeof VisibilityPolicy>;
64
+
65
+ /**
66
+ * The level a write records: the one it named, or the policy's default.
67
+ *
68
+ * **Refused, never coerced.** A level outside `allowed` is a mistake somewhere — a client that did not
69
+ * know the policy, or a policy narrowed under a client — and quietly writing the default instead would
70
+ * hide it until somebody wondered why a published file was private, or worse. `organization` with no
71
+ * organization in force is refused for the same reason: a record no member can ever be acting for is not
72
+ * an organization record, it is a private one that says otherwise.
73
+ *
74
+ * Both refusals are the caller's to fix, so the remedy is in `message`; `action` names the config key for
75
+ * the operator who decides the policy.
76
+ */
77
+ export function resolveVisibility(
78
+ policy: VisibilityPolicy,
79
+ requested: RecordVisibility | undefined,
80
+ organizationId: string | null,
81
+ ): RecordVisibility {
82
+ const level = requested ?? policy.default;
83
+ if (!policy.allowed.includes(level)) {
84
+ throw new ValidationError({
85
+ message: `Visibility \`${level}\` is not allowed here. Allowed: ${policy.allowed.join(", ")}.`,
86
+ action: "Name one of visibility.allowed, or widen visibility.allowed in this Worker's pithy.config.ts.",
87
+ detail: `requested ${requested ?? "(default)"} resolved to ${level}, outside ${policy.allowed.join(", ")}`,
88
+ params: { visibility: level, allowed: policy.allowed.join(", ") },
89
+ });
90
+ }
91
+ if (level === "organization" && organizationId === null) {
92
+ throw new ValidationError({
93
+ message: "Visibility `organization` needs an organization in force. Choose one, or name another level.",
94
+ action: "Compose @pithy-sh/organization and choose an organization, or set a different visibility.default.",
95
+ detail: "organization visibility requested with no acting organization",
96
+ params: { visibility: level },
97
+ });
98
+ }
99
+ return level;
100
+ }
101
+
102
+ /**
103
+ * The levels a policy no longer allows — what `pithy doctor` searches records for after `allowed`
104
+ * narrows. Narrowing never rewrites a record: making a public record private breaks every link already
105
+ * published, so moving them is the adopter's explicit act, and this is how they find them.
106
+ */
107
+ export function outsidePolicy(policy: VisibilityPolicy): RecordVisibility[] {
108
+ return RECORD_VISIBILITIES.filter((level) => !policy.allowed.includes(level));
109
+ }
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { ControlPlaneActorKind } from "../controlPlane/data/connection";
5
6
 
6
7
  /**
7
8
  * The audit-event seam. `AuditEvent` is the shape any capability hands to `emit()` (the request
@@ -95,6 +96,9 @@ export const AuditEvent = z
95
96
  .string()
96
97
  .nullish()
97
98
  .describe("The acting principal's stable id (user id, service/token name); null for `system`/`anonymous`."),
99
+ actorKind: ControlPlaneActorKind.nullish().describe(
100
+ "For a `control-plane` actor, what kind of management client acted — `agent`, `dashboard` or `script` — from the connection it called through (#721). `actorType` names all three alike; this is what tells them apart. Stamped by `requireControlPlane`, so an emit site does not set it; null for every other actor type, and for a connection registered before the kind was recorded.",
101
+ ),
98
102
  sessionId: z
99
103
  .string()
100
104
  .nullish()
@@ -23,10 +23,87 @@ export const BindingType = z
23
23
  .describe(
24
24
  "Durable Object — a single-threaded, stateful actor with its own storage. Backed by an exported DO class; the CLI wires the namespace binding and the class migration tag.",
25
25
  ),
26
+ z
27
+ .literal("hyperdrive")
28
+ .describe("Hyperdrive — a pooled, cached connection to an existing database. The adopter creates the config."),
29
+ z.literal("browser").describe("Browser Rendering — a headless browser the Worker drives."),
30
+ z.literal("images").describe("Cloudflare Images — transform and optimize images from the Worker."),
31
+ z
32
+ .literal("analytics_engine_datasets")
33
+ .describe("Workers Analytics Engine — write data points to a dataset, query them with SQL."),
34
+ z.literal("pipelines").describe("Pipelines — send records into a stream that lands in R2. The adopter creates it."),
35
+ z
36
+ .literal("mtls_certificates")
37
+ .describe("mTLS certificate — present a client certificate on outbound requests. The adopter uploads it."),
38
+ z
39
+ .literal("dispatch_namespaces")
40
+ .describe("Workers for Platforms dispatch namespace — call Workers uploaded into it. The adopter creates it."),
41
+ z
42
+ .literal("artifacts")
43
+ .describe(
44
+ "Artifacts — versioned storage that speaks Git, one namespace of repositories per environment. Cloudflare creates the namespace on its first repo.",
45
+ ),
26
46
  ])
27
47
  .describe("Kind of Cloudflare resource a binding refers to.");
28
48
  export type BindingType = z.infer<typeof BindingType>;
29
49
 
50
+ /**
51
+ * **How a stanza the kit creates gets this kind's entries** (#662).
52
+ *
53
+ * Every `env.<name>` stanza Pithy creates starts from the top level, because wrangler inherits none of the keys a
54
+ * binding lives in. What it starts with depends on whose resource the entry names, and that is a fact about the
55
+ * kind, not about the JSON shape wrangler happens to give it:
56
+ *
57
+ * - **`carry`** — account-level and identical everywhere. A dataset, a certificate, a rate limiter's
58
+ * `namespace_id`: the top level's entries come down whole into every stanza, because nothing in them is an
59
+ * environment's own.
60
+ * - **`provision`** — the environment owns the resource. Seeded empty, refilled by the provisioner, because
61
+ * dev's database id in staging's stanza is staging writing to dev's database.
62
+ * - **`branch`** — an account-level resource and a per-environment *decision*. Carried into a feature, which is a
63
+ * branch of `dev`; left empty in a declared environment, which is not. A `send_email` entry names an address
64
+ * verified once at the account, and where this environment's mail goes is a choice production does not inherit
65
+ * from a laptop. A Hyperdrive config is one origin database's connection, and a pipeline stream feeds one sink:
66
+ * the account holds both, but the top level's are dev's, and a declared environment seeded with them would read
67
+ * and write dev's Postgres and dev's sink with nothing failing.
68
+ *
69
+ * Three values, not a boolean. `send_email` was carried everywhere for a day and wrote a developer's address into
70
+ * production's first stanza (#650 review); emptied everywhere, it left a feature with no `NOTIFY`.
71
+ */
72
+ export type SeedPolicy = "carry" | "provision" | "branch";
73
+
74
+ /**
75
+ * The policy per kind, as a total record — so a kind added to {@link BindingType} without one is a type error
76
+ * here, and `bindings.test.ts` fails on a member whose answer it does not state.
77
+ */
78
+ const SEED_POLICY: Record<BindingType, SeedPolicy> = {
79
+ ai: "carry",
80
+ ratelimit: "carry",
81
+ browser: "carry",
82
+ images: "carry",
83
+ hyperdrive: "branch",
84
+ analytics_engine_datasets: "carry",
85
+ mtls_certificates: "carry",
86
+ dispatch_namespaces: "carry",
87
+ pipelines: "branch",
88
+ d1: "provision",
89
+ kv: "provision",
90
+ r2: "provision",
91
+ vectorize: "provision",
92
+ queue: "provision",
93
+ durable_object: "provision",
94
+ workflow: "provision",
95
+ service: "provision",
96
+ secret: "provision",
97
+ email: "branch",
98
+ // The namespace is environment-derived, so a stanza starts empty and its own name is written in (#673).
99
+ artifacts: "provision",
100
+ };
101
+
102
+ /** How a newly created stanza gets this kind's entries. See {@link SeedPolicy}. */
103
+ export function seedPolicy(type: BindingType): SeedPolicy {
104
+ return SEED_POLICY[type];
105
+ }
106
+
30
107
  /**
31
108
  * Whether a provision command creates the **resource** behind this kind of binding, rather than the
32
109
  * adopter standing it up themselves.
@@ -69,19 +146,54 @@ export function isProvisionedBinding(type: BindingType): boolean {
69
146
  *
70
147
  * `service` is in neither, deliberately: `pithy feature` writes it out of the target Worker's env-scoped
71
148
  * script name, and no shipped capability declares one.
149
+ *
150
+ * **The pure-wrangler-entry kinds are written and never provisioned (#662).** `hyperdrive`, `browser`, `images`,
151
+ * `analytics_engine_datasets`, `pipelines`, `mtls_certificates` and `dispatch_namespaces` each take a binding name
152
+ * and, where wrangler requires one, the account-level resource the spec states as `target`. Pithy writes the
153
+ * entry; the adopter stands the resource up. `queue` writes its `queues.producers` entry under the name
154
+ * `pithy provision` creates the queue with, and `email` writes `send_email` with nothing but its name.
155
+ *
156
+ * **`artifacts` is written and never provisioned (#673).** Its entry is `{ binding, namespace }`, and the namespace
157
+ * is `<project>-<env>`, derivable offline. There is no create call to make: Cloudflare creates a namespace the first
158
+ * time a repo is created in it. There is a teardown call, because repos cost storage, and `pithy feature destroy`
159
+ * makes it.
72
160
  */
73
161
  export function isWrittenBinding(type: BindingType): boolean {
74
- return (
75
- type === "d1" ||
76
- type === "kv" ||
77
- type === "r2" ||
78
- type === "ai" ||
79
- type === "durable_object" ||
80
- type === "ratelimit" ||
81
- type === "workflow"
82
- );
162
+ return WRITTEN.has(type);
83
163
  }
84
164
 
165
+ const WRITTEN = new Set<BindingType>([
166
+ "d1",
167
+ "kv",
168
+ "r2",
169
+ "ai",
170
+ "durable_object",
171
+ "ratelimit",
172
+ "workflow",
173
+ "queue",
174
+ "email",
175
+ "hyperdrive",
176
+ "browser",
177
+ "images",
178
+ "analytics_engine_datasets",
179
+ "pipelines",
180
+ "mtls_certificates",
181
+ "dispatch_namespaces",
182
+ "artifacts",
183
+ ]);
184
+
185
+ /**
186
+ * The kinds whose wrangler entry names an account-level resource the adopter creates, and what wrangler calls
187
+ * that field — what `BindingSpec.target` is written as. `true` where wrangler refuses the entry without it.
188
+ */
189
+ export const TARGET_KINDS: Readonly<Partial<Record<BindingType, { field: string; required: boolean }>>> = {
190
+ hyperdrive: { field: "id", required: true },
191
+ mtls_certificates: { field: "certificate_id", required: true },
192
+ pipelines: { field: "stream", required: true },
193
+ dispatch_namespaces: { field: "namespace", required: true },
194
+ analytics_engine_datasets: { field: "dataset", required: false },
195
+ };
196
+
85
197
  /**
86
198
  * A JavaScript class name — what `className` may be.
87
199
  *
@@ -101,13 +213,16 @@ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
101
213
  */
102
214
  const CLASS_MODULE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$/;
103
215
 
216
+ /** What a `target` may be: a Cloudflare id or resource name, starting alphanumeric. */
217
+ const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
218
+
104
219
  /**
105
220
  * The kinds whose resource Pithy names and creates — the only kinds `scope` and `resource` mean anything
106
221
  * for.
107
222
  *
108
223
  * {@link FEATURE_RESOURCE_KINDS} rather than a fresh literal, because it is already the repo's answer to
109
224
  * "which bindings does a provisioning run compose a `<project>-<scope>-<thing>` name for": it is the
110
- * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same three. Every
225
+ * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same four. Every
111
226
  * other kind's name is composed by a different rule with no `<thing>` slot to fill — a Workflow's from
112
227
  * the capability and the job, a Worker script's by wrangler — and a `secret` already states its scope in
113
228
  * `defineSecretRegistry`, where the value it names lives.
@@ -159,6 +274,16 @@ export const BindingSpec = z
159
274
  .describe(
160
275
  'The Worker this binding calls, named as it appears in `apps/<name>/` (e.g. "api"). Meaningful only for a `service` binding — the CLI resolves it to that Worker\'s environment-scoped script name when writing `services` into wrangler.jsonc, so worker-to-worker RPC targets the right deployment per environment. Ignored for every other kind.',
161
276
  ),
277
+ target: z
278
+ .string()
279
+ .min(1)
280
+ // An id or a resource name, never prose: a manifest is third-party data out of `node_modules`, and this
281
+ // string lands in the adopter's `wrangler.jsonc` and in every stanza the kit carries it into.
282
+ .regex(TARGET, "A target is a resource id or name: letters, digits, `.`, `_` and `-`.")
283
+ .optional()
284
+ .describe(
285
+ "The account-level resource this binding addresses, which the adopter creates themselves: a Hyperdrive config id, an mTLS certificate id, a Pipelines stream, a dispatch namespace, or an Analytics Engine dataset. Written into the field wrangler names for it (`id`, `certificate_id`, `stream`, `namespace`, `dataset`), and carried whole into every environment, because the resource is the account's. Required for `hyperdrive`, `mtls_certificates`, `pipelines` and `dispatch_namespaces`; optional for `analytics_engine_datasets`. Refused on every other kind.",
286
+ ),
162
287
  remote: z
163
288
  .boolean()
164
289
  .optional()
@@ -169,7 +294,7 @@ export const BindingSpec = z
169
294
  .enum(["environment", "global"])
170
295
  .optional()
171
296
  .describe(
172
- "Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv` or `r2` binding.",
297
+ "Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding.",
173
298
  ),
174
299
  resource: z
175
300
  .string()
@@ -181,7 +306,7 @@ export const BindingSpec = z
181
306
  .regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.")
182
307
  .optional()
183
308
  .describe(
184
- "The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv` or `r2` binding.",
309
+ "The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding.",
185
310
  ),
186
311
  })
187
312
  .describe("Declares a Cloudflare binding a capability requires in the Worker env.")
@@ -234,10 +359,31 @@ export const BindingSpec = z
234
359
  code: "custom",
235
360
  input: ctx.value,
236
361
  path: [field],
237
- message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv or r2 binding has — this one is a ${ctx.value.type}.`,
362
+ message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv, r2 or queue binding has — this one is a ${ctx.value.type}.`,
238
363
  });
239
364
  }
240
365
  }
366
+ // `target` is the resource a carried kind addresses, written into the one field wrangler names for it. A kind
367
+ // that requires it is refused without it — wrangler refuses the entry, and a writer emitting one would stop
368
+ // `wrangler dev` and `wrangler deploy` both. A kind with no such field is refused with it, for the reason
369
+ // `scope` is: a declaration nothing writes reads, in the spec, as one that was honored.
370
+ const target = TARGET_KINDS[ctx.value.type];
371
+ if (target?.required && ctx.value.target === undefined) {
372
+ ctx.issues.push({
373
+ code: "custom",
374
+ input: ctx.value,
375
+ path: ["target"],
376
+ message: `Binding "${ctx.value.name}" needs a target — the ${ctx.value.type} resource it addresses, written as \`${target.field}\`.`,
377
+ });
378
+ }
379
+ if (target === undefined && ctx.value.target !== undefined) {
380
+ ctx.issues.push({
381
+ code: "custom",
382
+ input: ctx.value,
383
+ path: ["target"],
384
+ message: `Binding "${ctx.value.name}" declares a target, which a ${ctx.value.type} binding does not take.`,
385
+ });
386
+ }
241
387
  });
242
388
  export type BindingSpec = z.infer<typeof BindingSpec>;
243
389
 
@@ -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";
@@ -31,6 +32,19 @@ import type { CapabilitySettings } from "./settings";
31
32
  export interface PithyVars {
32
33
  /** The authenticated identity, populated by `@pithy-sh/auth`; `null` until a strategy sets it. */
33
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;
34
48
  /**
35
49
  * The resolved request locale, populated by `@pithy-sh/i18n`'s middleware; `null` in a Worker that
36
50
  * does not compose it — which is what "nothing was negotiated" looks like, as distinct from "the
@@ -132,6 +146,12 @@ export type PithyHonoEnv = { Bindings: Record<string, unknown>; Variables: Pithy
132
146
 
133
147
  export type PithyMiddleware = (app: Hono<PithyHonoEnv>) => void;
134
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
+
135
155
  /**
136
156
  * The structural seam for one capability's secret-registry slice — secret name → its declaration.
137
157
  * Core carries it on the {@link Capability} contract so a capability can declare which secrets it
@@ -230,29 +250,18 @@ export type SecretBranchSeam = Record<string, readonly string[]>;
230
250
  export type InapplicableSecretSeam = Record<string, string>;
231
251
 
232
252
  /**
233
- * The structural seam for one capability's token-profile slice — profile name → the scoped CF API
234
- * token that capability's code needs. Declared next to the code that uses it (alongside
235
- * {@link Capability.secretRegistry}), so a capability owns its token's least-privilege scope and where
236
- * the minted value lands. Core carries only the structural axes (never interpreted here); the concrete
237
- * `TokenProfile`, the permission catalog, `defineTokenProfile`, and the mint engine live in
238
- * `@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.
239
257
  */
240
- export interface TokenProfileSeam {
241
- /** The CF permissions the token grants, as short catalog keys (`d1:read`, `secrets:write`). */
242
- readonly permissions: readonly string[];
243
- /** The resource scope: `"account"` (the whole account, the default) or an explicit CF resource map. */
244
- readonly resources?: "account" | Record<string, string>;
245
- /** The secret-registry name the minted value is stored under — its backend decides the destination. */
246
- readonly secret?: string;
247
- /** A built-in destination override (`dev-vars` | `ephemeral` | `secrets-store`) for a tooling profile. */
248
- readonly defaultStore?: string;
249
- /** Why the profile exists / what consumes it — surfaced in `pithy token` help and docs. */
250
- 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;
251
263
  }
252
264
 
253
- /** A capability's token-profile slice: profile name → {@link TokenProfileSeam}. */
254
- export type TokenProfileSeamMap = Record<string, TokenProfileSeam>;
255
-
256
265
  /**
257
266
  * One thing an **adopter** plugged into a capability — the capability's own name for something the kit
258
267
  * did not ship and cannot enumerate. `@pithy-sh/auth` declares one per additional Better Auth plugin
@@ -379,13 +388,6 @@ export interface Capability<
379
388
  * whenever it is composed declares nothing, which is the ordinary case.
380
389
  */
381
390
  inapplicableSecrets?: InapplicableSecretSeam;
382
- /**
383
- * The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
384
- * {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
385
- * — the value of a minted token lands in the secret the profile names. `pithy token` aggregates every
386
- * capability's slice with the built-in tooling profiles into one registry.
387
- */
388
- tokenProfiles?: TokenProfileSeamMap;
389
391
  /**
390
392
  * Extra CF permissions this capability needs the **CI system token** to hold, as short catalog keys
391
393
  * (e.g. `email:routing` for email provisioning, `kv:write` for a module that seeds KV in CI). They
@@ -393,6 +395,20 @@ export interface Capability<
393
395
  * extends what CI can do without the adopter hand-editing token scopes. Additive and optional.
394
396
  */
395
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[];
396
412
  /**
397
413
  * The admin routes this capability contributes behind the `control-plane` strategy, described well
398
414
  * enough for a management client to call them — full mounted path, required scope, and a one-line
@@ -539,8 +555,30 @@ export interface Capability<
539
555
  * parsed with. A capability with no socket leaves it unset.
540
556
  */
541
557
  sockets?: readonly SocketChannel[];
542
- /** Composable middleware (e.g. turnstile(), requireAuth()). */
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
+ */
543
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[];
544
582
  /**
545
583
  * Whether this capability fills the entitlement seam — replaces `c.var.entitlements` with a real
546
584
  * resolver. `@pithy-sh/payments` declares it; nothing else does today.
@@ -552,6 +590,27 @@ export interface Capability<
552
590
  * flag and say so, which turns a fleet of production 403s into one line at startup.
553
591
  */
554
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[];
555
614
  /**
556
615
  * This capability's client-safe projection — the **only** values of its config that may reach a
557
616
  * browser bundle. Resolved at build time against a {@link ClientProjectionContext} (the environment),
@@ -599,6 +658,17 @@ export interface Capability<
599
658
  * time, and read by `pithy doctor`.
600
659
  */
601
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[];
602
672
  /** Bindings this capability needs in the env (normalized — `optional` is always set). */
603
673
  requiredBindings: BindingSpec[];
604
674
  }