@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,104 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ import { sql } from "kysely";
5
+ //#region src/access/usage.ts
6
+ /**
7
+ * What an account is holding — records and bytes, per owner and per organization — for the control plane.
8
+ *
9
+ * ## A per-owner figure is always an organization's figure
10
+ *
11
+ * **A report run by an organization for user X filters by that organization's id**, because the
12
+ * organization must never learn that the same person holds an account in another one. The number alone
13
+ * leaks it: a per-user total summed across organizations discloses that the user exists elsewhere, with no
14
+ * name attached and no row shown — the figure simply does not reconcile with what the organization can
15
+ * see. So an unscoped per-owner read is not a convenience this offers. It is a read that does not exist:
16
+ * {@link UsageQuery} refuses `groupBy: "owner"` and an `ownerId` filter unless an organization is named,
17
+ * and every per-owner row is keyed by the organization it was counted in.
18
+ *
19
+ * Duplicate detection is the same disclosure in different clothes — *does anybody hold this file* against
20
+ * *how much does this person hold* — and both are closed by the same column. Neither consults an
21
+ * adopter's permission hook: a hook that widened a per-subject total would reopen exactly this.
22
+ *
23
+ * ## The organization is the column, not a join
24
+ *
25
+ * Counted by the `organizationId` written on each record, which is the fact at the time of the write. A
26
+ * join through current membership would re-attribute last quarter's bytes the moment somebody changed
27
+ * organization. Only stored records count — a pending upload holds nothing yet, and a failed one never
28
+ * will.
29
+ *
30
+ * ## Indexed
31
+ *
32
+ * The tables carry `(organizationId, createdAt)` and `(ownerId, createdAt)`, so an organization's slice
33
+ * over a window is one index range and a grouping by organization walks the index rather than sorting.
34
+ */
35
+ /** The sentinel that names records written with no organization in force — a query string cannot say null. */
36
+ const NO_ORGANIZATION = "none";
37
+ const Id = z.string().min(1).max(128);
38
+ /** What a usage read asks. */
39
+ const UsageQuery = z.object({
40
+ groupBy: z.enum(["owner", "organization"]).describe("`organization` is one row per organization; `owner` is one row per owner within the organization named, never across organizations."),
41
+ organizationId: Id.optional().describe("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."),
42
+ ownerId: Id.optional().describe("Restrict to one owner, within the organization named."),
43
+ since: z.iso.datetime().optional().describe("Count records created at or after this instant, ISO-8601."),
44
+ until: z.iso.datetime().optional().describe("Count records created before this instant, ISO-8601."),
45
+ limit: z.coerce.number().int().min(1).max(500).optional().describe("The most rows to return. 100 by default.")
46
+ }).describe("A request for records and bytes held, grouped by owner or by organization.").check((ctx) => {
47
+ if (ctx.value.organizationId !== void 0) return;
48
+ if (ctx.value.groupBy !== "owner" && ctx.value.ownerId === void 0) return;
49
+ ctx.issues.push({
50
+ code: "custom",
51
+ input: ctx.value,
52
+ path: ["organizationId"],
53
+ message: `A per-owner read names its organization. Pass organizationId, or \`${NO_ORGANIZATION}\` for records written with none.`
54
+ });
55
+ });
56
+ /** One row of a usage read. */
57
+ const UsageRow = z.object({
58
+ organizationId: z.string().nullable().describe("The organization these records were written under. Null for records written with none."),
59
+ ownerId: z.string().nullable().optional().describe("The owner, on a per-owner read. Null for system-owned records. Absent on a per-organization read."),
60
+ records: z.number().int().nonnegative().describe("How many stored records."),
61
+ bytes: z.number().int().nonnegative().describe("Their total size in bytes. A record of unknown size counts zero.")
62
+ }).describe("Records and bytes held by one organization, or by one owner within one organization.");
63
+ /** What a usage read answers. */
64
+ const UsageResponse = z.object({
65
+ rows: z.array(UsageRow).describe("Largest first. Capped at the requested limit."),
66
+ truncated: z.boolean().describe("Whether more rows matched than were returned.")
67
+ }).describe("Records and bytes held, grouped as asked.");
68
+ /** The default page of a usage read. */
69
+ const DEFAULT_LIMIT = 100;
70
+ /**
71
+ * The statement a usage read runs, over one record table — `pithyMediaAssets`, `pithyStorageObjects` —
72
+ * carrying `ownerId`, `organizationId`, `size`, `status` and `createdAt`. Exported so a test can ask SQLite
73
+ * how it plans the very statement {@link usageRollup} executes. Every value is a bound parameter.
74
+ */
75
+ function usageStatement(table, query) {
76
+ const limit = query.limit ?? DEFAULT_LIMIT;
77
+ const organization = sql.ref("organizationId");
78
+ const owner = sql.ref("ownerId");
79
+ const filters = [sql`${sql.ref("status")} = 'stored'`];
80
+ if (query.organizationId === "none") filters.push(sql`${organization} is null`);
81
+ else if (query.organizationId !== void 0) filters.push(sql`${organization} = ${query.organizationId}`);
82
+ if (query.ownerId !== void 0) filters.push(sql`${owner} = ${query.ownerId}`);
83
+ if (query.since !== void 0) filters.push(sql`${sql.ref("createdAt")} >= ${Date.parse(query.since)}`);
84
+ if (query.until !== void 0) filters.push(sql`${sql.ref("createdAt")} < ${Date.parse(query.until)}`);
85
+ const keys = query.groupBy === "owner" ? sql`${organization}, ${owner}` : sql`${organization}`;
86
+ return sql`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}`;
87
+ }
88
+ /** Read usage off one record table through any Kysely over D1 with the `CamelCasePlugin`. */
89
+ async function usageRollup(db, table, query) {
90
+ const limit = query.limit ?? DEFAULT_LIMIT;
91
+ const result = await usageStatement(table, query).execute(db);
92
+ const byOwner = query.groupBy === "owner";
93
+ return {
94
+ rows: result.rows.slice(0, limit).map((row) => ({
95
+ organizationId: row.organizationId ?? null,
96
+ ...byOwner ? { ownerId: row.ownerId ?? null } : {},
97
+ records: Number(row.records),
98
+ bytes: Number(row.bytes ?? 0)
99
+ })),
100
+ truncated: result.rows.length > limit
101
+ };
102
+ }
103
+ //#endregion
104
+ export { NO_ORGANIZATION, UsageQuery, UsageResponse, UsageRow, usageRollup, usageStatement };
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * Who may read a record, and learn that it exists — one convention, written once, for every capability
6
+ * that holds a record a person owns.
7
+ *
8
+ * **Three levels, and the kit owns them.** `private` is the owner, acting for the organization the record
9
+ * was written under; acting for any other organization, not even them. `organization` is any member of the
10
+ * record's organization, acting for it. `public` is anyone holding the id or the URL, signed in or not.
11
+ * Media and storage share the levels, the policy shape and the rules, so a record means the same thing in
12
+ * both and there is one place the meaning can be wrong.
13
+ *
14
+ * **The project decides which levels exist, and which one a record gets when it names none.** `allowed` is
15
+ * a set rather than a mode, because a set says everything a mode would and one thing it cannot:
16
+ * `["private", "organization"]` is a guarantee that nothing can be published, even by mistake.
17
+ */
18
+ /** Every level, weakest first. */
19
+ export declare const RECORD_VISIBILITIES: readonly ["private", "organization", "public"];
20
+ export declare const RecordVisibility: z.ZodEnum<{
21
+ organization: "organization";
22
+ private: "private";
23
+ public: "public";
24
+ }>;
25
+ export type RecordVisibility = z.infer<typeof RecordVisibility>;
26
+ /**
27
+ * The levels a project allows, and the one a record gets when a write names none.
28
+ *
29
+ * **`default` is `private` and must be in `allowed`**, and a policy that breaks either is refused at
30
+ * parse — which, for a capability, is at boot. **`allowed` defaults to all three**, so a capability that
31
+ * accepted `public` before this policy existed is not narrowed by it.
32
+ */
33
+ export declare const VisibilityPolicy: z.ZodObject<{
34
+ default: z.ZodDefault<z.ZodEnum<{
35
+ organization: "organization";
36
+ private: "private";
37
+ public: "public";
38
+ }>>;
39
+ allowed: z.ZodDefault<z.ZodArray<z.ZodEnum<{
40
+ organization: "organization";
41
+ private: "private";
42
+ public: "public";
43
+ }>>>;
44
+ }, z.core.$strip>;
45
+ export type VisibilityPolicy = z.output<typeof VisibilityPolicy>;
46
+ export type VisibilityPolicyInput = z.input<typeof VisibilityPolicy>;
47
+ /**
48
+ * The level a write records: the one it named, or the policy's default.
49
+ *
50
+ * **Refused, never coerced.** A level outside `allowed` is a mistake somewhere — a client that did not
51
+ * know the policy, or a policy narrowed under a client — and quietly writing the default instead would
52
+ * hide it until somebody wondered why a published file was private, or worse. `organization` with no
53
+ * organization in force is refused for the same reason: a record no member can ever be acting for is not
54
+ * an organization record, it is a private one that says otherwise.
55
+ *
56
+ * Both refusals are the caller's to fix, so the remedy is in `message`; `action` names the config key for
57
+ * the operator who decides the policy.
58
+ */
59
+ export declare function resolveVisibility(policy: VisibilityPolicy, requested: RecordVisibility | undefined, organizationId: string | null): RecordVisibility;
60
+ /**
61
+ * The levels a policy no longer allows — what `pithy doctor` searches records for after `allowed`
62
+ * narrows. Narrowing never rewrites a record: making a public record private breaks every link already
63
+ * published, so moving them is the adopter's explicit act, and this is how they find them.
64
+ */
65
+ export declare function outsidePolicy(policy: VisibilityPolicy): RecordVisibility[];
66
+ //# sourceMappingURL=visibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visibility.d.ts","sourceRoot":"","sources":["../../src/access/visibility.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;GAaG;AAEH,kCAAkC;AAClC,eAAO,MAAM,mBAAmB,YAAI,SAAS,EAAE,cAAc,EAAE,QAAQ,CAAU,CAAC;AAElF,eAAO,MAAM,gBAAgB;;;;EAI1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;iBAsBzB,CAAC;AACL,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AACjE,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,gBAAgB,GAAG,SAAS,EACvC,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,gBAAgB,CAmBlB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,gBAAgB,GAAG,gBAAgB,EAAE,CAE1E"}
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { ValidationError } from "../error/pithyError.js";
4
+ import { z } from "zod";
5
+ //#region src/access/visibility.ts
6
+ /**
7
+ * Who may read a record, and learn that it exists — one convention, written once, for every capability
8
+ * that holds a record a person owns.
9
+ *
10
+ * **Three levels, and the kit owns them.** `private` is the owner, acting for the organization the record
11
+ * was written under; acting for any other organization, not even them. `organization` is any member of the
12
+ * record's organization, acting for it. `public` is anyone holding the id or the URL, signed in or not.
13
+ * Media and storage share the levels, the policy shape and the rules, so a record means the same thing in
14
+ * both and there is one place the meaning can be wrong.
15
+ *
16
+ * **The project decides which levels exist, and which one a record gets when it names none.** `allowed` is
17
+ * a set rather than a mode, because a set says everything a mode would and one thing it cannot:
18
+ * `["private", "organization"]` is a guarantee that nothing can be published, even by mistake.
19
+ */
20
+ /** Every level, weakest first. */
21
+ const RECORD_VISIBILITIES = [
22
+ "private",
23
+ "organization",
24
+ "public"
25
+ ];
26
+ const RecordVisibility = z.enum(RECORD_VISIBILITIES).describe("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.");
27
+ /**
28
+ * The levels a project allows, and the one a record gets when a write names none.
29
+ *
30
+ * **`default` is `private` and must be in `allowed`**, and a policy that breaks either is refused at
31
+ * parse — which, for a capability, is at boot. **`allowed` defaults to all three**, so a capability that
32
+ * accepted `public` before this policy existed is not narrowed by it.
33
+ */
34
+ const VisibilityPolicy = z.object({
35
+ default: RecordVisibility.default("private").describe("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`."),
36
+ allowed: z.array(RecordVisibility).min(1).default([...RECORD_VISIBILITIES]).describe("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.")
37
+ }).describe("Which visibility levels this project allows, and the default for a write that names none.").check((ctx) => {
38
+ if (ctx.value.allowed.includes(ctx.value.default)) return;
39
+ ctx.issues.push({
40
+ code: "custom",
41
+ input: ctx.value,
42
+ path: ["default"],
43
+ message: `visibility.default is \`${ctx.value.default}\`, which visibility.allowed (${ctx.value.allowed.join(", ")}) does not include.`
44
+ });
45
+ });
46
+ /**
47
+ * The level a write records: the one it named, or the policy's default.
48
+ *
49
+ * **Refused, never coerced.** A level outside `allowed` is a mistake somewhere — a client that did not
50
+ * know the policy, or a policy narrowed under a client — and quietly writing the default instead would
51
+ * hide it until somebody wondered why a published file was private, or worse. `organization` with no
52
+ * organization in force is refused for the same reason: a record no member can ever be acting for is not
53
+ * an organization record, it is a private one that says otherwise.
54
+ *
55
+ * Both refusals are the caller's to fix, so the remedy is in `message`; `action` names the config key for
56
+ * the operator who decides the policy.
57
+ */
58
+ function resolveVisibility(policy, requested, organizationId) {
59
+ const level = requested ?? policy.default;
60
+ if (!policy.allowed.includes(level)) throw new ValidationError({
61
+ message: `Visibility \`${level}\` is not allowed here. Allowed: ${policy.allowed.join(", ")}.`,
62
+ action: "Name one of visibility.allowed, or widen visibility.allowed in this Worker's pithy.config.ts.",
63
+ detail: `requested ${requested ?? "(default)"} resolved to ${level}, outside ${policy.allowed.join(", ")}`,
64
+ params: {
65
+ visibility: level,
66
+ allowed: policy.allowed.join(", ")
67
+ }
68
+ });
69
+ if (level === "organization" && organizationId === null) throw new ValidationError({
70
+ message: "Visibility `organization` needs an organization in force. Choose one, or name another level.",
71
+ action: "Compose @pithy-sh/organization and choose an organization, or set a different visibility.default.",
72
+ detail: "organization visibility requested with no acting organization",
73
+ params: { visibility: level }
74
+ });
75
+ return level;
76
+ }
77
+ /**
78
+ * The levels a policy no longer allows — what `pithy doctor` searches records for after `allowed`
79
+ * narrows. Narrowing never rewrites a record: making a public record private breaks every link already
80
+ * published, so moving them is the adopter's explicit act, and this is how they find them.
81
+ */
82
+ function outsidePolicy(policy) {
83
+ return RECORD_VISIBILITIES.filter((level) => !policy.allowed.includes(level));
84
+ }
85
+ //#endregion
86
+ export { RECORD_VISIBILITIES, RecordVisibility, VisibilityPolicy, outsidePolicy, resolveVisibility };
@@ -76,6 +76,11 @@ export declare const AuditEvent: z.ZodObject<{
76
76
  user: "user";
77
77
  }>;
78
78
  actorId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
79
+ actorKind: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
80
+ agent: "agent";
81
+ dashboard: "dashboard";
82
+ script: "script";
83
+ }>>>;
79
84
  sessionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
80
85
  resourceType: z.ZodOptional<z.ZodNullable<z.ZodString>>;
81
86
  resourceId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1 +1 @@
1
- {"version":3,"file":"auditEvent.d.ts","sourceRoot":"","sources":["../../src/audit/auditEvent.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAgBxB;;;;;GAKG;AACH,eAAO,MAAM,WAAW,aAM+F,CAAC;AACxH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC;AAEvD,gGAAgG;AAChG,eAAO,MAAM,YAAY;;;;EAItB,CAAC;AACJ,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAC;AAEzD,iGAAiG;AACjG,eAAO,MAAM,aAAa;;;;EAIvB,CAAC;AACJ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;;;;EAIxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D,mGAAmG;AACnG,eAAO,MAAM,aAAa,wCAIvB,CAAC;AACJ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCiF,CAAC;AACzG,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AACrD,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC"}
1
+ {"version":3,"file":"auditEvent.d.ts","sourceRoot":"","sources":["../../src/audit/auditEvent.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAiBxB;;;;;GAKG;AACH,eAAO,MAAM,WAAW,aAM+F,CAAC;AACxH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC;AAEvD,gGAAgG;AAChG,eAAO,MAAM,YAAY;;;;EAItB,CAAC;AACJ,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAC;AAEzD,iGAAiG;AACjG,eAAO,MAAM,aAAa;;;;EAIvB,CAAC;AACJ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;;;;EAIxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D,mGAAmG;AACnG,eAAO,MAAM,aAAa,wCAIvB,CAAC;AACJ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2CiF,CAAC;AACzG,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AACrD,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC"}
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import { ControlPlaneActorKind } from "../controlPlane/data/connection.js";
3
4
  import { z } from "zod";
4
5
  /**
5
6
  * The action taxonomy is **federated, not centralized**: `action` is an open, namespace-validated
@@ -57,6 +58,7 @@ const AuditEvent = z.object({
57
58
  severity: AuditSeverity.default("info").describe("How serious the event is; defaults to `info`."),
58
59
  actorType: AuditActorType.describe("The kind of principal that acted."),
59
60
  actorId: z.string().nullish().describe("The acting principal's stable id (user id, service/token name); null for `system`/`anonymous`."),
61
+ actorKind: ControlPlaneActorKind.nullish().describe("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."),
60
62
  sessionId: z.string().nullish().describe("The session this action belongs to, tying a chain of actions together; null when none."),
61
63
  resourceType: z.string().nullish().describe("The type of resource the action targeted (e.g. `user`, `secret`); null when not resource-scoped."),
62
64
  resourceId: z.string().nullish().describe("The id of the resource the action targeted; null when not resource-scoped."),
@@ -1,8 +1,33 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { z } from "zod";
4
- export declare const BindingType: z.ZodUnion<readonly [z.ZodLiteral<"d1">, z.ZodLiteral<"kv">, z.ZodLiteral<"r2">, z.ZodLiteral<"ai">, z.ZodLiteral<"vectorize">, z.ZodLiteral<"queue">, z.ZodLiteral<"ratelimit">, z.ZodLiteral<"email">, z.ZodLiteral<"secret">, z.ZodLiteral<"workflow">, z.ZodLiteral<"service">, z.ZodLiteral<"durable_object">]>;
4
+ export declare const BindingType: z.ZodUnion<readonly [z.ZodLiteral<"d1">, z.ZodLiteral<"kv">, z.ZodLiteral<"r2">, z.ZodLiteral<"ai">, z.ZodLiteral<"vectorize">, z.ZodLiteral<"queue">, z.ZodLiteral<"ratelimit">, z.ZodLiteral<"email">, z.ZodLiteral<"secret">, z.ZodLiteral<"workflow">, z.ZodLiteral<"service">, z.ZodLiteral<"durable_object">, z.ZodLiteral<"hyperdrive">, z.ZodLiteral<"browser">, z.ZodLiteral<"images">, z.ZodLiteral<"analytics_engine_datasets">, z.ZodLiteral<"pipelines">, z.ZodLiteral<"mtls_certificates">, z.ZodLiteral<"dispatch_namespaces">, z.ZodLiteral<"artifacts">]>;
5
5
  export type BindingType = z.infer<typeof BindingType>;
6
+ /**
7
+ * **How a stanza the kit creates gets this kind's entries** (#662).
8
+ *
9
+ * Every `env.<name>` stanza Pithy creates starts from the top level, because wrangler inherits none of the keys a
10
+ * binding lives in. What it starts with depends on whose resource the entry names, and that is a fact about the
11
+ * kind, not about the JSON shape wrangler happens to give it:
12
+ *
13
+ * - **`carry`** — account-level and identical everywhere. A dataset, a certificate, a rate limiter's
14
+ * `namespace_id`: the top level's entries come down whole into every stanza, because nothing in them is an
15
+ * environment's own.
16
+ * - **`provision`** — the environment owns the resource. Seeded empty, refilled by the provisioner, because
17
+ * dev's database id in staging's stanza is staging writing to dev's database.
18
+ * - **`branch`** — an account-level resource and a per-environment *decision*. Carried into a feature, which is a
19
+ * branch of `dev`; left empty in a declared environment, which is not. A `send_email` entry names an address
20
+ * verified once at the account, and where this environment's mail goes is a choice production does not inherit
21
+ * from a laptop. A Hyperdrive config is one origin database's connection, and a pipeline stream feeds one sink:
22
+ * the account holds both, but the top level's are dev's, and a declared environment seeded with them would read
23
+ * and write dev's Postgres and dev's sink with nothing failing.
24
+ *
25
+ * Three values, not a boolean. `send_email` was carried everywhere for a day and wrote a developer's address into
26
+ * production's first stanza (#650 review); emptied everywhere, it left a feature with no `NOTIFY`.
27
+ */
28
+ export type SeedPolicy = "carry" | "provision" | "branch";
29
+ /** How a newly created stanza gets this kind's entries. See {@link SeedPolicy}. */
30
+ export declare function seedPolicy(type: BindingType): SeedPolicy;
6
31
  /**
7
32
  * Whether a provision command creates the **resource** behind this kind of binding, rather than the
8
33
  * adopter standing it up themselves.
@@ -42,20 +67,40 @@ export declare function isProvisionedBinding(type: BindingType): boolean;
42
67
  *
43
68
  * `service` is in neither, deliberately: `pithy feature` writes it out of the target Worker's env-scoped
44
69
  * script name, and no shipped capability declares one.
70
+ *
71
+ * **The pure-wrangler-entry kinds are written and never provisioned (#662).** `hyperdrive`, `browser`, `images`,
72
+ * `analytics_engine_datasets`, `pipelines`, `mtls_certificates` and `dispatch_namespaces` each take a binding name
73
+ * and, where wrangler requires one, the account-level resource the spec states as `target`. Pithy writes the
74
+ * entry; the adopter stands the resource up. `queue` writes its `queues.producers` entry under the name
75
+ * `pithy provision` creates the queue with, and `email` writes `send_email` with nothing but its name.
76
+ *
77
+ * **`artifacts` is written and never provisioned (#673).** Its entry is `{ binding, namespace }`, and the namespace
78
+ * is `<project>-<env>`, derivable offline. There is no create call to make: Cloudflare creates a namespace the first
79
+ * time a repo is created in it. There is a teardown call, because repos cost storage, and `pithy feature destroy`
80
+ * makes it.
45
81
  */
46
82
  export declare function isWrittenBinding(type: BindingType): boolean;
83
+ /**
84
+ * The kinds whose wrangler entry names an account-level resource the adopter creates, and what wrangler calls
85
+ * that field — what `BindingSpec.target` is written as. `true` where wrangler refuses the entry without it.
86
+ */
87
+ export declare const TARGET_KINDS: Readonly<Partial<Record<BindingType, {
88
+ field: string;
89
+ required: boolean;
90
+ }>>>;
47
91
  /**
48
92
  * Declares a Cloudflare binding a capability requires in the Worker env. The authoring
49
93
  * shape lets `optional` be omitted (defaults false); `createBackend` normalizes via parse.
50
94
  */
51
95
  export declare const BindingSpec: z.ZodObject<{
52
- type: z.ZodUnion<readonly [z.ZodLiteral<"d1">, z.ZodLiteral<"kv">, z.ZodLiteral<"r2">, z.ZodLiteral<"ai">, z.ZodLiteral<"vectorize">, z.ZodLiteral<"queue">, z.ZodLiteral<"ratelimit">, z.ZodLiteral<"email">, z.ZodLiteral<"secret">, z.ZodLiteral<"workflow">, z.ZodLiteral<"service">, z.ZodLiteral<"durable_object">]>;
96
+ type: z.ZodUnion<readonly [z.ZodLiteral<"d1">, z.ZodLiteral<"kv">, z.ZodLiteral<"r2">, z.ZodLiteral<"ai">, z.ZodLiteral<"vectorize">, z.ZodLiteral<"queue">, z.ZodLiteral<"ratelimit">, z.ZodLiteral<"email">, z.ZodLiteral<"secret">, z.ZodLiteral<"workflow">, z.ZodLiteral<"service">, z.ZodLiteral<"durable_object">, z.ZodLiteral<"hyperdrive">, z.ZodLiteral<"browser">, z.ZodLiteral<"images">, z.ZodLiteral<"analytics_engine_datasets">, z.ZodLiteral<"pipelines">, z.ZodLiteral<"mtls_certificates">, z.ZodLiteral<"dispatch_namespaces">, z.ZodLiteral<"artifacts">]>;
53
97
  name: z.ZodString;
54
98
  optional: z.ZodDefault<z.ZodBoolean>;
55
99
  className: z.ZodOptional<z.ZodString>;
56
100
  classModule: z.ZodOptional<z.ZodString>;
57
101
  job: z.ZodOptional<z.ZodString>;
58
102
  service: z.ZodOptional<z.ZodString>;
103
+ target: z.ZodOptional<z.ZodString>;
59
104
  remote: z.ZodOptional<z.ZodBoolean>;
60
105
  scope: z.ZodOptional<z.ZodEnum<{
61
106
  environment: "environment";
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../../src/capability/bindings.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,WAAW,sTAmBuC,CAAC;AAChE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAU3D;AAkCD;;;GAGG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;iBAwHpB,CAAC;AACL,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC"}
1
+ {"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../../src/capability/bindings.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,WAAW,4iBAuCuC,CAAC;AAChE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,QAAQ,CAAC;AA8B1D,mFAAmF;AACnF,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,CAExD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE3D;AAsBD;;;GAGG;AACH,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,CAMrG,CAAC;AAqCF;;;GAGG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;iBAuJpB,CAAC;AACL,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC"}
@@ -16,9 +16,47 @@ const BindingType = z.union([
16
16
  z.literal("secret").describe("Secret from the Secrets Store, encrypted at rest."),
17
17
  z.literal("workflow").describe("Cloudflare Workflow — durable, multi-step execution."),
18
18
  z.literal("service").describe("Service binding — direct RPC to another Worker."),
19
- z.literal("durable_object").describe("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.")
19
+ z.literal("durable_object").describe("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."),
20
+ z.literal("hyperdrive").describe("Hyperdrive — a pooled, cached connection to an existing database. The adopter creates the config."),
21
+ z.literal("browser").describe("Browser Rendering — a headless browser the Worker drives."),
22
+ z.literal("images").describe("Cloudflare Images — transform and optimize images from the Worker."),
23
+ z.literal("analytics_engine_datasets").describe("Workers Analytics Engine — write data points to a dataset, query them with SQL."),
24
+ z.literal("pipelines").describe("Pipelines — send records into a stream that lands in R2. The adopter creates it."),
25
+ z.literal("mtls_certificates").describe("mTLS certificate — present a client certificate on outbound requests. The adopter uploads it."),
26
+ z.literal("dispatch_namespaces").describe("Workers for Platforms dispatch namespace — call Workers uploaded into it. The adopter creates it."),
27
+ z.literal("artifacts").describe("Artifacts — versioned storage that speaks Git, one namespace of repositories per environment. Cloudflare creates the namespace on its first repo.")
20
28
  ]).describe("Kind of Cloudflare resource a binding refers to.");
21
29
  /**
30
+ * The policy per kind, as a total record — so a kind added to {@link BindingType} without one is a type error
31
+ * here, and `bindings.test.ts` fails on a member whose answer it does not state.
32
+ */
33
+ const SEED_POLICY = {
34
+ ai: "carry",
35
+ ratelimit: "carry",
36
+ browser: "carry",
37
+ images: "carry",
38
+ hyperdrive: "branch",
39
+ analytics_engine_datasets: "carry",
40
+ mtls_certificates: "carry",
41
+ dispatch_namespaces: "carry",
42
+ pipelines: "branch",
43
+ d1: "provision",
44
+ kv: "provision",
45
+ r2: "provision",
46
+ vectorize: "provision",
47
+ queue: "provision",
48
+ durable_object: "provision",
49
+ workflow: "provision",
50
+ service: "provision",
51
+ secret: "provision",
52
+ email: "branch",
53
+ artifacts: "provision"
54
+ };
55
+ /** How a newly created stanza gets this kind's entries. See {@link SeedPolicy}. */
56
+ function seedPolicy(type) {
57
+ return SEED_POLICY[type];
58
+ }
59
+ /**
22
60
  * Whether a provision command creates the **resource** behind this kind of binding, rather than the
23
61
  * adopter standing it up themselves.
24
62
  *
@@ -59,10 +97,66 @@ function isProvisionedBinding(type) {
59
97
  *
60
98
  * `service` is in neither, deliberately: `pithy feature` writes it out of the target Worker's env-scoped
61
99
  * script name, and no shipped capability declares one.
100
+ *
101
+ * **The pure-wrangler-entry kinds are written and never provisioned (#662).** `hyperdrive`, `browser`, `images`,
102
+ * `analytics_engine_datasets`, `pipelines`, `mtls_certificates` and `dispatch_namespaces` each take a binding name
103
+ * and, where wrangler requires one, the account-level resource the spec states as `target`. Pithy writes the
104
+ * entry; the adopter stands the resource up. `queue` writes its `queues.producers` entry under the name
105
+ * `pithy provision` creates the queue with, and `email` writes `send_email` with nothing but its name.
106
+ *
107
+ * **`artifacts` is written and never provisioned (#673).** Its entry is `{ binding, namespace }`, and the namespace
108
+ * is `<project>-<env>`, derivable offline. There is no create call to make: Cloudflare creates a namespace the first
109
+ * time a repo is created in it. There is a teardown call, because repos cost storage, and `pithy feature destroy`
110
+ * makes it.
62
111
  */
63
112
  function isWrittenBinding(type) {
64
- return type === "d1" || type === "kv" || type === "r2" || type === "ai" || type === "durable_object" || type === "ratelimit" || type === "workflow";
113
+ return WRITTEN.has(type);
65
114
  }
115
+ const WRITTEN = /* @__PURE__ */ new Set([
116
+ "d1",
117
+ "kv",
118
+ "r2",
119
+ "ai",
120
+ "durable_object",
121
+ "ratelimit",
122
+ "workflow",
123
+ "queue",
124
+ "email",
125
+ "hyperdrive",
126
+ "browser",
127
+ "images",
128
+ "analytics_engine_datasets",
129
+ "pipelines",
130
+ "mtls_certificates",
131
+ "dispatch_namespaces",
132
+ "artifacts"
133
+ ]);
134
+ /**
135
+ * The kinds whose wrangler entry names an account-level resource the adopter creates, and what wrangler calls
136
+ * that field — what `BindingSpec.target` is written as. `true` where wrangler refuses the entry without it.
137
+ */
138
+ const TARGET_KINDS = {
139
+ hyperdrive: {
140
+ field: "id",
141
+ required: true
142
+ },
143
+ mtls_certificates: {
144
+ field: "certificate_id",
145
+ required: true
146
+ },
147
+ pipelines: {
148
+ field: "stream",
149
+ required: true
150
+ },
151
+ dispatch_namespaces: {
152
+ field: "namespace",
153
+ required: true
154
+ },
155
+ analytics_engine_datasets: {
156
+ field: "dataset",
157
+ required: false
158
+ }
159
+ };
66
160
  /**
67
161
  * A JavaScript class name — what `className` may be.
68
162
  *
@@ -80,13 +174,15 @@ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
80
174
  * landed on, which is `configImports.isInside`'s argument in a regex.
81
175
  */
82
176
  const CLASS_MODULE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$/;
177
+ /** What a `target` may be: a Cloudflare id or resource name, starting alphanumeric. */
178
+ const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
83
179
  /**
84
180
  * The kinds whose resource Pithy names and creates — the only kinds `scope` and `resource` mean anything
85
181
  * for.
86
182
  *
87
183
  * {@link FEATURE_RESOURCE_KINDS} rather than a fresh literal, because it is already the repo's answer to
88
184
  * "which bindings does a provisioning run compose a `<project>-<scope>-<thing>` name for": it is the
89
- * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same three. Every
185
+ * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same four. Every
90
186
  * other kind's name is composed by a different rule with no `<thing>` slot to fill — a Workflow's from
91
187
  * the capability and the job, a Worker script's by wrangler — and a `secret` already states its scope in
92
188
  * `defineSecretRegistry`, where the value it names lives.
@@ -104,9 +200,10 @@ const BindingSpec = z.object({
104
200
  classModule: z.string().min(1).regex(CLASS_MODULE, "A classModule is a bare module specifier: a package, optionally with a path into it.").optional().describe("The module the class is exported from (e.g. \"@pithy-sh/multiplayer/src/session/durableObject\") — what the CLI writes the Worker entry's `export { <className> } from \"<classModule>\";` against. Required for a `durable_object` binding, because wrangler resolves `class_name` against the entry `main` names and refuses the deploy when nothing there exports it. Its own module, never the package entry point, which `pithy.config.ts` loads in Node. Ignored for every other kind."),
105
201
  job: z.string().min(1).regex(NAME_SEGMENT, "A job is one name segment: lowercase letters, digits, and single hyphens.").optional().describe("The job within the capability this Workflow runs (e.g. \"send\") — the `<job>` segment of the deployed `<project>-<env>-<capability>-<job>` name, and the second half of the `<capability>/<job>` dispatch key. Meaningful only for a `workflow` binding, where it is what lets `pithy add` name the Workflow offline instead of leaving the binding unwritten. Ignored for every other kind."),
106
202
  service: z.string().min(1).optional().describe("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."),
203
+ target: z.string().min(1).regex(TARGET, "A target is a resource id or name: letters, digits, `.`, `_` and `-`.").optional().describe("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."),
107
204
  remote: z.boolean().optional().describe("Reach the real Cloudflare resource during local development instead of a local emulation. Set it for a binding that has none — Vectorize and Workers AI both lack local simulation — so `wrangler dev` and any Workflow host, which always runs locally, still work. Left unset rather than defaulting to false, so a spec that does not care emits no flag at all. Ignored in a deployed Worker."),
108
- scope: z.enum(["environment", "global"]).optional().describe("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."),
109
- resource: z.string().min(1).regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.").optional().describe("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.")
205
+ scope: z.enum(["environment", "global"]).optional().describe("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."),
206
+ resource: z.string().min(1).regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.").optional().describe("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.")
110
207
  }).describe("Declares a Cloudflare binding a capability requires in the Worker env.").check((ctx) => {
111
208
  if (ctx.value.type === "durable_object" && ctx.value.className === void 0) ctx.issues.push({
112
209
  code: "custom",
@@ -132,9 +229,22 @@ const BindingSpec = z.object({
132
229
  code: "custom",
133
230
  input: ctx.value,
134
231
  path: [field],
135
- message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv or r2 binding has — this one is a ${ctx.value.type}.`
232
+ 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}.`
136
233
  });
137
234
  }
235
+ const target = TARGET_KINDS[ctx.value.type];
236
+ if (target?.required && ctx.value.target === void 0) ctx.issues.push({
237
+ code: "custom",
238
+ input: ctx.value,
239
+ path: ["target"],
240
+ message: `Binding "${ctx.value.name}" needs a target — the ${ctx.value.type} resource it addresses, written as \`${target.field}\`.`
241
+ });
242
+ if (target === void 0 && ctx.value.target !== void 0) ctx.issues.push({
243
+ code: "custom",
244
+ input: ctx.value,
245
+ path: ["target"],
246
+ message: `Binding "${ctx.value.name}" declares a target, which a ${ctx.value.type} binding does not take.`
247
+ });
138
248
  });
139
249
  //#endregion
140
- export { BindingSpec, BindingType, isProvisionedBinding, isWrittenBinding };
250
+ export { BindingSpec, BindingType, TARGET_KINDS, isProvisionedBinding, isWrittenBinding, seedPolicy };