@pithy-sh/core 0.10.0 → 0.12.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 (270) 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/audit/recorder.d.ts +10 -2
  20. package/dist/audit/recorder.d.ts.map +1 -1
  21. package/dist/capability/bindings.d.ts +47 -2
  22. package/dist/capability/bindings.d.ts.map +1 -1
  23. package/dist/capability/bindings.js +117 -7
  24. package/dist/capability/capability.d.ts +110 -28
  25. package/dist/capability/capability.d.ts.map +1 -1
  26. package/dist/capability/capability.js +2 -0
  27. package/dist/capability/manifest.d.ts +2 -1
  28. package/dist/capability/manifest.d.ts.map +1 -1
  29. package/dist/capability/manifest.js +1 -1
  30. package/dist/capability/purge.d.ts +80 -0
  31. package/dist/capability/purge.d.ts.map +1 -0
  32. package/dist/capability/purge.js +76 -0
  33. package/dist/capability/settings.d.ts +86 -2
  34. package/dist/capability/settings.d.ts.map +1 -1
  35. package/dist/capability/settings.js +80 -1
  36. package/dist/cli/commandCatalog.generated.d.ts +238 -24
  37. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  38. package/dist/cli/commandCatalog.generated.js +251 -22
  39. package/dist/cli/commandFacts.d.ts +2 -1
  40. package/dist/cli/commandFacts.d.ts.map +1 -1
  41. package/dist/cli/commandFacts.js +2 -0
  42. package/dist/controlPlane/capability.d.ts.map +1 -1
  43. package/dist/controlPlane/capability.js +19 -2
  44. package/dist/controlPlane/context.d.ts +5 -0
  45. package/dist/controlPlane/context.d.ts.map +1 -1
  46. package/dist/controlPlane/context.js +3 -1
  47. package/dist/controlPlane/data/connection.d.ts +49 -12
  48. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  49. package/dist/controlPlane/data/connection.js +56 -2
  50. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  51. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  52. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  53. package/dist/controlPlane/error/errors.d.ts +4 -3
  54. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  55. package/dist/controlPlane/http/cors.d.ts +4 -4
  56. package/dist/controlPlane/http/cors.js +4 -4
  57. package/dist/controlPlane/http/guard.d.ts +11 -0
  58. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  59. package/dist/controlPlane/http/guard.js +17 -1
  60. package/dist/controlPlane/http/handlers.d.ts +139 -0
  61. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  62. package/dist/controlPlane/http/handlers.js +67 -4
  63. package/dist/controlPlane/http/readiness.d.ts +33 -0
  64. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  65. package/dist/controlPlane/http/readiness.js +39 -0
  66. package/dist/controlPlane/http/responses.d.ts +245 -0
  67. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  68. package/dist/controlPlane/http/responses.js +101 -4
  69. package/dist/controlPlane/http/routes.d.ts +2 -0
  70. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  71. package/dist/controlPlane/http/routes.js +31 -3
  72. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  73. package/dist/controlPlane/http/verify.js +2 -1
  74. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  75. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  76. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  79. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  80. package/dist/controlPlane/scope/scope.d.ts +23 -2
  81. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  82. package/dist/controlPlane/scope/scope.js +24 -3
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +9 -2
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/crypto/envelope.d.ts +86 -0
  88. package/dist/crypto/envelope.d.ts.map +1 -0
  89. package/dist/crypto/envelope.js +129 -0
  90. package/dist/crypto/fieldKeys.d.ts +41 -0
  91. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  92. package/dist/crypto/fieldKeys.js +56 -0
  93. package/dist/data/codecs.d.ts +23 -10
  94. package/dist/data/codecs.d.ts.map +1 -1
  95. package/dist/data/codecs.js +50 -36
  96. package/dist/entitlement/allowance.d.ts +25 -0
  97. package/dist/entitlement/allowance.d.ts.map +1 -0
  98. package/dist/entitlement/allowance.js +80 -0
  99. package/dist/entitlement/entitlement.d.ts +41 -0
  100. package/dist/entitlement/entitlement.d.ts.map +1 -1
  101. package/dist/entitlement/meter.d.ts +57 -0
  102. package/dist/entitlement/meter.d.ts.map +1 -0
  103. package/dist/entitlement/meter.js +13 -0
  104. package/dist/error/extend.d.ts +6 -5
  105. package/dist/error/extend.d.ts.map +1 -1
  106. package/dist/error/http.d.ts +168 -0
  107. package/dist/error/http.d.ts.map +1 -1
  108. package/dist/error/payload.d.ts +336 -0
  109. package/dist/error/payload.d.ts.map +1 -1
  110. package/dist/error/payload.js +116 -4
  111. package/dist/error/pithyError.d.ts +18 -7
  112. package/dist/error/pithyError.d.ts.map +1 -1
  113. package/dist/error/pithyError.js +6 -0
  114. package/dist/http/authContext.d.ts +7 -0
  115. package/dist/http/authContext.d.ts.map +1 -1
  116. package/dist/http/authContext.js +8 -1
  117. package/dist/http/scopeGates.d.ts +20 -0
  118. package/dist/http/scopeGates.d.ts.map +1 -0
  119. package/dist/http/scopeGates.js +36 -0
  120. package/dist/index.d.ts +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +2 -2
  123. package/dist/logger/logger.d.ts +12 -5
  124. package/dist/logger/logger.d.ts.map +1 -1
  125. package/dist/logger/record.d.ts +98 -0
  126. package/dist/logger/record.d.ts.map +1 -1
  127. package/dist/migrations/capabilitySets.d.ts +23 -0
  128. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  129. package/dist/migrations/capabilitySets.js +74 -0
  130. package/dist/migrations/registry.d.ts +26 -0
  131. package/dist/migrations/registry.d.ts.map +1 -1
  132. package/dist/migrations/registry.js +38 -7
  133. package/dist/migrations/state.d.ts +60 -0
  134. package/dist/migrations/state.d.ts.map +1 -0
  135. package/dist/migrations/state.js +46 -0
  136. package/dist/naming/feature.d.ts +27 -5
  137. package/dist/naming/feature.d.ts.map +1 -1
  138. package/dist/naming/feature.js +36 -6
  139. package/dist/naming/limits.d.ts +47 -3
  140. package/dist/naming/limits.d.ts.map +1 -1
  141. package/dist/naming/limits.js +47 -3
  142. package/dist/naming/provisionScope.d.ts +6 -0
  143. package/dist/naming/provisionScope.d.ts.map +1 -1
  144. package/dist/naming/provisionScope.js +8 -3
  145. package/dist/naming/resourceNames.d.ts +21 -2
  146. package/dist/naming/resourceNames.d.ts.map +1 -1
  147. package/dist/naming/resourceNames.js +21 -3
  148. package/dist/privacy/blindIndex.d.ts +27 -0
  149. package/dist/privacy/blindIndex.d.ts.map +1 -0
  150. package/dist/privacy/blindIndex.js +57 -0
  151. package/dist/privacy/classes.d.ts +142 -0
  152. package/dist/privacy/classes.d.ts.map +1 -0
  153. package/dist/privacy/classes.js +209 -0
  154. package/dist/privacy/classify.d.ts +336 -0
  155. package/dist/privacy/classify.d.ts.map +1 -0
  156. package/dist/privacy/classify.js +1024 -0
  157. package/dist/privacy/encrypted.d.ts +210 -0
  158. package/dist/privacy/encrypted.d.ts.map +1 -0
  159. package/dist/privacy/encrypted.js +350 -0
  160. package/dist/privacy/encryptedColumns.d.ts +64 -0
  161. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  162. package/dist/privacy/encryptedColumns.js +134 -0
  163. package/dist/privacy/fieldRotation.d.ts +34 -0
  164. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  165. package/dist/privacy/fieldRotation.js +186 -0
  166. package/dist/privacy/inventory.d.ts +12 -0
  167. package/dist/privacy/inventory.d.ts.map +1 -0
  168. package/dist/privacy/inventory.js +154 -0
  169. package/dist/privacy/posture.d.ts +114 -0
  170. package/dist/privacy/posture.d.ts.map +1 -0
  171. package/dist/privacy/posture.js +136 -0
  172. package/dist/privacy/redact.d.ts +7 -0
  173. package/dist/privacy/redact.d.ts.map +1 -0
  174. package/dist/privacy/redact.js +404 -0
  175. package/dist/privacy/report.d.ts +58 -0
  176. package/dist/privacy/report.d.ts.map +1 -0
  177. package/dist/privacy/report.js +15 -0
  178. package/dist/privacy/retirement.d.ts +68 -0
  179. package/dist/privacy/retirement.d.ts.map +1 -0
  180. package/dist/privacy/retirement.js +72 -0
  181. package/dist/privacy/sealed.d.ts +87 -0
  182. package/dist/privacy/sealed.d.ts.map +1 -0
  183. package/dist/privacy/sealed.js +221 -0
  184. package/dist/privacy/types.d.ts +559 -0
  185. package/dist/privacy/types.d.ts.map +1 -0
  186. package/dist/privacy/types.js +2 -0
  187. package/dist/seed/writeD1.d.ts +7 -1
  188. package/dist/seed/writeD1.d.ts.map +1 -1
  189. package/dist/seed/writeD1.js +9 -3
  190. package/dist/seed/writeKv.d.ts +2 -0
  191. package/dist/seed/writeKv.d.ts.map +1 -1
  192. package/dist/seed/writeKv.js +6 -2
  193. package/dist/version.generated.d.ts +1 -1
  194. package/dist/version.generated.js +1 -1
  195. package/dist/worker/serviceLoopback.d.ts +36 -0
  196. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  197. package/dist/worker/serviceLoopback.js +74 -0
  198. package/dist/workflow/faults.d.ts +12 -1
  199. package/dist/workflow/faults.d.ts.map +1 -1
  200. package/dist/workflow/faults.js +22 -15
  201. package/package.json +2 -2
  202. package/src/access/access.ts +298 -0
  203. package/src/access/acting.ts +59 -0
  204. package/src/access/policyCheck.ts +77 -0
  205. package/src/access/usage.ts +141 -0
  206. package/src/access/visibility.ts +109 -0
  207. package/src/audit/auditEvent.ts +4 -0
  208. package/src/audit/recorder.ts +10 -2
  209. package/src/capability/bindings.ts +159 -13
  210. package/src/capability/capability.ts +114 -29
  211. package/src/capability/purge.ts +124 -0
  212. package/src/capability/settings.ts +146 -2
  213. package/src/cli/commandCatalog.generated.ts +271 -22
  214. package/src/cli/commandFacts.ts +2 -0
  215. package/src/controlPlane/capability.ts +36 -1
  216. package/src/controlPlane/context.ts +4 -0
  217. package/src/controlPlane/data/connection.ts +90 -0
  218. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  219. package/src/controlPlane/error/errors.ts +4 -3
  220. package/src/controlPlane/http/cors.ts +4 -4
  221. package/src/controlPlane/http/guard.ts +21 -0
  222. package/src/controlPlane/http/handlers.ts +98 -4
  223. package/src/controlPlane/http/readiness.ts +50 -0
  224. package/src/controlPlane/http/responses.ts +240 -2
  225. package/src/controlPlane/http/routes.ts +55 -1
  226. package/src/controlPlane/http/verify.ts +2 -0
  227. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  228. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  229. package/src/controlPlane/scope/scope.ts +25 -2
  230. package/src/controlPlane/token/base64url.ts +1 -1
  231. package/src/createBackend.ts +28 -6
  232. package/src/createEntrypoint.ts +9 -3
  233. package/src/crypto/envelope.ts +196 -0
  234. package/src/crypto/fieldKeys.ts +76 -0
  235. package/src/data/codecs.ts +69 -37
  236. package/src/entitlement/allowance.ts +85 -0
  237. package/src/entitlement/entitlement.ts +38 -0
  238. package/src/entitlement/meter.ts +69 -0
  239. package/src/error/extend.ts +6 -5
  240. package/src/error/payload.ts +253 -4
  241. package/src/error/pithyError.ts +21 -7
  242. package/src/http/authContext.ts +13 -1
  243. package/src/http/scopeGates.ts +34 -0
  244. package/src/index.ts +2 -0
  245. package/src/logger/logger.ts +12 -5
  246. package/src/migrations/capabilitySets.ts +87 -0
  247. package/src/migrations/registry.ts +46 -2
  248. package/src/migrations/state.ts +94 -0
  249. package/src/naming/feature.ts +46 -7
  250. package/src/naming/limits.ts +35 -3
  251. package/src/naming/provisionScope.ts +19 -1
  252. package/src/naming/resourceNames.ts +52 -5
  253. package/src/privacy/blindIndex.ts +81 -0
  254. package/src/privacy/classes.ts +277 -0
  255. package/src/privacy/classify.ts +1447 -0
  256. package/src/privacy/encrypted.ts +513 -0
  257. package/src/privacy/encryptedColumns.ts +185 -0
  258. package/src/privacy/fieldRotation.ts +234 -0
  259. package/src/privacy/inventory.ts +147 -0
  260. package/src/privacy/posture.ts +205 -0
  261. package/src/privacy/redact.ts +423 -0
  262. package/src/privacy/report.ts +64 -0
  263. package/src/privacy/retirement.ts +102 -0
  264. package/src/privacy/sealed.ts +323 -0
  265. package/src/privacy/types.ts +804 -0
  266. package/src/seed/writeD1.ts +13 -4
  267. package/src/seed/writeKv.ts +6 -2
  268. package/src/version.generated.ts +1 -1
  269. package/src/worker/serviceLoopback.ts +105 -0
  270. package/src/workflow/faults.ts +51 -24
@@ -0,0 +1,77 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import {
5
+ type CapabilitySettings,
6
+ SettingsAccountIncomplete,
7
+ type SettingsFinding,
8
+ type UnansweredEnvironment,
9
+ } from "../capability/settings";
10
+ import { outsidePolicy, type VisibilityPolicy } from "./visibility";
11
+
12
+ /**
13
+ * `pithy doctor`'s check for records a narrowed visibility policy no longer allows.
14
+ *
15
+ * **Narrowing `allowed` never rewrites a record.** Making a public file private breaks every link already
16
+ * published — and for media each one is a Cloudflare call before its bytes are signed — so the kit does
17
+ * not do it behind anyone's back. A write that would create a record outside the policy is refused; the
18
+ * records already outside it are named, here, per deployed environment, and moving them is the adopter's
19
+ * explicit act.
20
+ *
21
+ * The local tier says nothing: which records exist is a fact about a deployed database, which only the
22
+ * account tier can read. An unprovisioned environment, or an account that cannot be reached, is unchecked
23
+ * rather than clean — and only that environment is: the others are still asked, and what they hold is still
24
+ * named ({@link SettingsAccountIncomplete}).
25
+ */
26
+ export interface RecordsOutsidePolicyOptions {
27
+ /** The capability, for the finding's wording — `media`, `storage`. */
28
+ readonly capability: string;
29
+ /** What one record is called, for the finding's wording — `media record`, `file`. */
30
+ readonly noun: string;
31
+ /** The table the records live in, as named in SQL. */
32
+ readonly table: string;
33
+ /** The resolved policy this Worker composes. */
34
+ readonly policy: VisibilityPolicy;
35
+ /** How to move one record to an allowed level — the route or the call. */
36
+ readonly remedy: string;
37
+ }
38
+
39
+ /** The settings check: every record outside the policy, named, per deployed environment. */
40
+ export function recordsOutsidePolicy(options: RecordsOutsidePolicyOptions): CapabilitySettings {
41
+ const levels = outsidePolicy(options.policy);
42
+ return {
43
+ local: () => [],
44
+ async account(context) {
45
+ if (levels.length === 0) return [];
46
+ const findings: SettingsFinding[] = [];
47
+ const unanswered: UnansweredEnvironment[] = [];
48
+ for (const environment of context.environments) {
49
+ if (environment.name === "dev") continue;
50
+ let ids: readonly string[];
51
+ try {
52
+ ids = await context.account.appRows({
53
+ environment: environment.name,
54
+ workerDirectory: context.workerDirectory,
55
+ table: options.table,
56
+ column: "visibility",
57
+ values: levels,
58
+ });
59
+ } catch (cause) {
60
+ // Staging stood up before prod is the ordinary bring-up. Prod is unchecked; staging still answers.
61
+ unanswered.push({ environment: environment.name, cause });
62
+ continue;
63
+ }
64
+ if (ids.length === 0) continue;
65
+ const plural = ids.length === 1 ? options.noun : `${options.noun}s`;
66
+ findings.push({
67
+ setting: `${options.capability} visibility.allowed`,
68
+ environment: environment.name,
69
+ problem: `${ids.length} ${plural} ${ids.length === 1 ? "is" : "are"} ${levels.join(" or ")}, which visibility.allowed (${options.policy.allowed.join(", ")}) no longer allows: ${ids.join(", ")}.`,
70
+ action: `Nothing is rewritten for you. ${options.remedy}, or widen visibility.allowed in ${context.workerDirectory}'s pithy.config.ts.`,
71
+ });
72
+ }
73
+ if (unanswered.length > 0) throw new SettingsAccountIncomplete(findings, unanswered);
74
+ return findings;
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,141 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Kysely, type RawBuilder, sql } from "kysely";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * What an account is holding — records and bytes, per owner and per organization — for the control plane.
9
+ *
10
+ * ## A per-owner figure is always an organization's figure
11
+ *
12
+ * **A report run by an organization for user X filters by that organization's id**, because the
13
+ * organization must never learn that the same person holds an account in another one. The number alone
14
+ * leaks it: a per-user total summed across organizations discloses that the user exists elsewhere, with no
15
+ * name attached and no row shown — the figure simply does not reconcile with what the organization can
16
+ * see. So an unscoped per-owner read is not a convenience this offers. It is a read that does not exist:
17
+ * {@link UsageQuery} refuses `groupBy: "owner"` and an `ownerId` filter unless an organization is named,
18
+ * and every per-owner row is keyed by the organization it was counted in.
19
+ *
20
+ * Duplicate detection is the same disclosure in different clothes — *does anybody hold this file* against
21
+ * *how much does this person hold* — and both are closed by the same column. Neither consults an
22
+ * adopter's permission hook: a hook that widened a per-subject total would reopen exactly this.
23
+ *
24
+ * ## The organization is the column, not a join
25
+ *
26
+ * Counted by the `organizationId` written on each record, which is the fact at the time of the write. A
27
+ * join through current membership would re-attribute last quarter's bytes the moment somebody changed
28
+ * organization. Only stored records count — a pending upload holds nothing yet, and a failed one never
29
+ * will.
30
+ *
31
+ * ## Indexed
32
+ *
33
+ * The tables carry `(organizationId, createdAt)` and `(ownerId, createdAt)`, so an organization's slice
34
+ * over a window is one index range and a grouping by organization walks the index rather than sorting.
35
+ */
36
+
37
+ /** The sentinel that names records written with no organization in force — a query string cannot say null. */
38
+ export const NO_ORGANIZATION = "none";
39
+
40
+ const Id = z.string().min(1).max(128);
41
+
42
+ /** What a usage read asks. */
43
+ export const UsageQuery = z
44
+ .object({
45
+ groupBy: z
46
+ .enum(["owner", "organization"])
47
+ .describe(
48
+ "`organization` is one row per organization; `owner` is one row per owner within the organization named, never across organizations.",
49
+ ),
50
+ organizationId: Id.optional().describe(
51
+ "Restrict to one organization. Required with `groupBy=owner` and with `ownerId`, because a per-owner figure is only ever an organization's. `none` is the records written with no organization in force.",
52
+ ),
53
+ ownerId: Id.optional().describe("Restrict to one owner, within the organization named."),
54
+ since: z.iso.datetime().optional().describe("Count records created at or after this instant, ISO-8601."),
55
+ until: z.iso.datetime().optional().describe("Count records created before this instant, ISO-8601."),
56
+ limit: z.coerce.number().int().min(1).max(500).optional().describe("The most rows to return. 100 by default."),
57
+ })
58
+ .describe("A request for records and bytes held, grouped by owner or by organization.")
59
+ .check((ctx) => {
60
+ if (ctx.value.organizationId !== undefined) return;
61
+ if (ctx.value.groupBy !== "owner" && ctx.value.ownerId === undefined) return;
62
+ ctx.issues.push({
63
+ code: "custom",
64
+ input: ctx.value,
65
+ path: ["organizationId"],
66
+ message: `A per-owner read names its organization. Pass organizationId, or \`${NO_ORGANIZATION}\` for records written with none.`,
67
+ });
68
+ });
69
+ export type UsageQuery = z.output<typeof UsageQuery>;
70
+
71
+ /** One row of a usage read. */
72
+ export const UsageRow = z
73
+ .object({
74
+ organizationId: z
75
+ .string()
76
+ .nullable()
77
+ .describe("The organization these records were written under. Null for records written with none."),
78
+ ownerId: z
79
+ .string()
80
+ .nullable()
81
+ .optional()
82
+ .describe("The owner, on a per-owner read. Null for system-owned records. Absent on a per-organization read."),
83
+ records: z.number().int().nonnegative().describe("How many stored records."),
84
+ bytes: z.number().int().nonnegative().describe("Their total size in bytes. A record of unknown size counts zero."),
85
+ })
86
+ .describe("Records and bytes held by one organization, or by one owner within one organization.");
87
+ export type UsageRow = z.infer<typeof UsageRow>;
88
+
89
+ /** What a usage read answers. */
90
+ export const UsageResponse = z
91
+ .object({
92
+ rows: z.array(UsageRow).describe("Largest first. Capped at the requested limit."),
93
+ truncated: z.boolean().describe("Whether more rows matched than were returned."),
94
+ })
95
+ .describe("Records and bytes held, grouped as asked.");
96
+ export type UsageResponse = z.infer<typeof UsageResponse>;
97
+
98
+ /** The default page of a usage read. */
99
+ const DEFAULT_LIMIT = 100;
100
+
101
+ /** Rows as SQLite returns them, before the counts are made numbers. */
102
+ export interface RawUsageRow {
103
+ organizationId: string | null;
104
+ ownerId?: string | null;
105
+ records: number | string;
106
+ bytes: number | string | null;
107
+ }
108
+
109
+ /**
110
+ * The statement a usage read runs, over one record table — `pithyMediaAssets`, `pithyStorageObjects` —
111
+ * carrying `ownerId`, `organizationId`, `size`, `status` and `createdAt`. Exported so a test can ask SQLite
112
+ * how it plans the very statement {@link usageRollup} executes. Every value is a bound parameter.
113
+ */
114
+ export function usageStatement(table: string, query: UsageQuery): RawBuilder<RawUsageRow> {
115
+ const limit = query.limit ?? DEFAULT_LIMIT;
116
+ const organization = sql.ref("organizationId");
117
+ const owner = sql.ref("ownerId");
118
+ const filters = [sql`${sql.ref("status")} = 'stored'`];
119
+ if (query.organizationId === NO_ORGANIZATION) filters.push(sql`${organization} is null`);
120
+ else if (query.organizationId !== undefined) filters.push(sql`${organization} = ${query.organizationId}`);
121
+ if (query.ownerId !== undefined) filters.push(sql`${owner} = ${query.ownerId}`);
122
+ if (query.since !== undefined) filters.push(sql`${sql.ref("createdAt")} >= ${Date.parse(query.since)}`);
123
+ if (query.until !== undefined) filters.push(sql`${sql.ref("createdAt")} < ${Date.parse(query.until)}`);
124
+
125
+ const keys = query.groupBy === "owner" ? sql`${organization}, ${owner}` : sql`${organization}`;
126
+ return sql<RawUsageRow>`select ${keys}, count(*) as records, coalesce(sum(${sql.ref("size")}), 0) as bytes from ${sql.table(table)} where ${sql.join(filters, sql` and `)} group by ${keys} order by bytes desc limit ${limit + 1}`;
127
+ }
128
+
129
+ /** Read usage off one record table through any Kysely over D1 with the `CamelCasePlugin`. */
130
+ export async function usageRollup(db: Kysely<unknown>, table: string, query: UsageQuery): Promise<UsageResponse> {
131
+ const limit = query.limit ?? DEFAULT_LIMIT;
132
+ const result = await usageStatement(table, query).execute(db);
133
+ const byOwner = query.groupBy === "owner";
134
+ const rows = result.rows.slice(0, limit).map((row) => ({
135
+ organizationId: row.organizationId ?? null,
136
+ ...(byOwner ? { ownerId: row.ownerId ?? null } : {}),
137
+ records: Number(row.records),
138
+ bytes: Number(row.bytes ?? 0),
139
+ }));
140
+ return { rows, truncated: result.rows.length > limit };
141
+ }
@@ -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()
@@ -1,7 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import type { AuditEventInput } from "./auditEvent";
4
+ import type { Loggable } from "../privacy/types";
5
+ import type { AuditEventInput, AuditMetadata } from "./auditEvent";
5
6
 
6
7
  /**
7
8
  * The recorder seam. `emit()` on the request context (`c.var.emit`) takes one {@link AuditEventInput}
@@ -12,8 +13,15 @@ import type { AuditEventInput } from "./auditEvent";
12
13
  * `emit()` is **non-fatal by contract**: it never throws and never rejects, so an audited action is
13
14
  * never broken by an audit write. The real recorder swallows and logs its own failures; the no-op
14
15
  * simply resolves.
16
+ *
17
+ * **`metadata` refuses a value marked with a privacy class** (#132). The trail is the longest-lived internal
18
+ * surface there is — a row in the adopter's D1 with no retention window — so a `pii()`/`phi()`/`secret()`/
19
+ * `pci()` value in it, or inside an object in it, is a compile error, by the same `Loggable` rule the logger
20
+ * applies. `AuditMetadata` the schema stays permissive: a stored row still parses.
15
21
  */
16
- export type AuditEmit = (event: AuditEventInput) => Promise<void>;
22
+ export type AuditEmit = <M extends AuditMetadata = AuditMetadata>(
23
+ event: Omit<AuditEventInput, "metadata"> & { metadata?: (M & Loggable<M>) | null | undefined },
24
+ ) => Promise<void>;
17
25
 
18
26
  /**
19
27
  * The default recorder when no audit capability is composed: accept the event and drop it. Resolves
@@ -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