@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
@@ -17,8 +17,14 @@ export interface SeedD1Options {
17
17
  export interface SeedD1Result {
18
18
  /** The table seeded. */
19
19
  table: string;
20
- /** The number of rows validated (and, unless `dryRun`, written). */
20
+ /** The number of rows the fixture holds, every one validated. */
21
21
  rows: number;
22
+ /**
23
+ * The number of rows this call inserted. Under `INSERT OR IGNORE` a row whose key is already there is
24
+ * not, so a re-run writes none, and says so here rather than repeating `rows` (#764). Always `0` under
25
+ * `dryRun`.
26
+ */
27
+ written: number;
22
28
  }
23
29
 
24
30
  /**
@@ -89,15 +95,18 @@ export async function seedD1Group(
89
95
  });
90
96
 
91
97
  if (options.dryRun || encoded.length === 0) {
92
- return { table: group.table, rows: encoded.length };
98
+ return { table: group.table, rows: encoded.length, written: 0 };
93
99
  }
94
100
 
101
+ let written = 0n;
95
102
  for (const chunk of chunkRowsByBoundParameters(encoded, columnsPerRow(encoded))) {
96
- await db
103
+ const results = await db
97
104
  .insertInto(group.table)
98
105
  .orIgnore()
99
106
  .values(chunk as never)
100
107
  .execute();
108
+ // D1's `meta.changes`: the rows the statement inserted, which an ignored conflict is not.
109
+ for (const result of results) written += result.numInsertedOrUpdatedRows ?? 0n;
101
110
  }
102
- return { table: group.table, rows: encoded.length };
111
+ return { table: group.table, rows: encoded.length, written: Number(written) };
103
112
  }
@@ -19,6 +19,8 @@ export interface SeedKvResult {
19
19
  store: string;
20
20
  /** The number of entries validated (and, unless `dryRun`, considered for writing). */
21
21
  entries: number;
22
+ /** The number of entries this call put. An existing key is left alone and not counted (#764). Always `0` under `dryRun`. */
23
+ written: number;
22
24
  }
23
25
 
24
26
  /**
@@ -87,13 +89,15 @@ export async function seedKvGroup<V extends z.ZodType, K extends z.ZodObject, M
87
89
  });
88
90
 
89
91
  if (options.dryRun) {
90
- return { store: group.store, entries: group.entries.length };
92
+ return { store: group.store, entries: group.entries.length, written: 0 };
91
93
  }
92
94
 
95
+ let written = 0;
93
96
  for (const entry of group.entries) {
94
97
  // Never overwrite an existing key — the KV counterpart of D1's INSERT OR IGNORE.
95
98
  if (await keyExists(store, entry.key)) continue;
96
99
  await store.put(entry.key, entry.value, entry.metadata !== undefined ? { metadata: entry.metadata } : {});
100
+ written += 1;
97
101
  }
98
- return { store: group.store, entries: group.entries.length };
102
+ return { store: group.store, entries: group.entries.length, written };
99
103
  }
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/core";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.10.0";
16
+ export const PACKAGE_VERSION = "0.12.0";
@@ -0,0 +1,105 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { commandName } from "../cli/command";
6
+ import { UpstreamError } from "../error/pithyError";
7
+ import { ENVIRONMENT_VAR } from "./identity";
8
+
9
+ /**
10
+ * **A service binding under `pithy dev`: the call reaches the local target on its pinned port (#617).**
11
+ *
12
+ * Deployed, `env.APP_B.fetch(request)` is a service binding: Cloudflare runs the call inside the runtime, into
13
+ * the script the binding names. Locally there is no such script. `wrangler dev` would look for one in its own
14
+ * cross-process registry — the one `pithy dev` never relies on — and a Worker started on its own with
15
+ * `pithy dev --app app-a` would find nothing there at all. What does exist is the target itself, on the port its
16
+ * feature pinned for it, and `pithy dev` knows that address before anything starts.
17
+ *
18
+ * So `pithy dev` publishes one var, {@link SERVICE_ORIGINS_VAR}: a JSON map from each service binding this
19
+ * Worker declares to its target's local origin. Under `dev`, and only there, each of those bindings is replaced
20
+ * with a stand-in whose `fetch` sends the same request to that origin. The call site is byte-identical either
21
+ * way. A target that is not running refuses the connection, so the call fails at once as
22
+ * `core/upstream_failed`, naming the binding and the address, rather than hanging on a registry lookup.
23
+ *
24
+ * **Only `dev`, and only what was published.** The var is written by `pithy dev` and by nothing else, and the
25
+ * gate reads `ENVIRONMENT` off the request env as the Workflow loopback does (`workflow/dispatch.ts`): a
26
+ * deployment that lost its `ENVIRONMENT` var never opens it.
27
+ */
28
+
29
+ /** The var `pithy dev` publishes: each service binding's local target, as JSON. */
30
+ export const SERVICE_ORIGINS_VAR = "PITHY_SERVICE_ORIGINS";
31
+
32
+ /** The one environment the stand-in is made in. */
33
+ const LOCAL = "dev";
34
+
35
+ /** What the var carries: binding name to an `http(s)` origin. */
36
+ const ServiceOrigins = z
37
+ .record(
38
+ z.string().describe("A service binding's name in the Worker env."),
39
+ z.url({ protocol: /^https?$/ }).describe("The local origin of the Worker it calls, as `pithy dev` pinned it."),
40
+ )
41
+ .describe("Each service binding's local target, published by `pithy dev`.");
42
+
43
+ /** The transport. The runtime's `fetch`, or a test's. */
44
+ export type ServiceLoopbackFetch = (request: Request) => Promise<Response>;
45
+
46
+ /** A Fetcher's one method that a plain `fetch` target uses. */
47
+ interface LocalService {
48
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
49
+ }
50
+
51
+ /** A stand-in for one service binding: every request sent to `origin`, its path, method, headers and body kept. */
52
+ function localService(binding: string, origin: string, send: ServiceLoopbackFetch): LocalService {
53
+ const target = new URL(origin);
54
+ return {
55
+ async fetch(input, init) {
56
+ const request = new Request(input, init);
57
+ const url = new URL(request.url);
58
+ url.protocol = target.protocol;
59
+ url.host = target.host;
60
+ try {
61
+ return await send(new Request(url, request));
62
+ } catch (cause) {
63
+ throw new UpstreamError(
64
+ {
65
+ message: `The Worker ${binding} calls is not answering.`,
66
+ action: `Check that it is running under ${commandName("dev")} at ${target.origin}.`,
67
+ detail: `${request.method} ${url.href} failed: ${String(cause)}`,
68
+ },
69
+ { cause },
70
+ );
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Options for {@link withLocalServices}. */
77
+ export interface LocalServicesOptions {
78
+ /** The transport. Defaults to the runtime's `fetch`. */
79
+ fetch?: ServiceLoopbackFetch;
80
+ }
81
+
82
+ /**
83
+ * The env a request runs with: under `pithy dev`, each published service binding replaced with its local
84
+ * stand-in; anywhere else, the env unchanged — the same object, so nothing outside `dev` pays for this.
85
+ */
86
+ export function withLocalServices(
87
+ env: Record<string, unknown>,
88
+ options: LocalServicesOptions = {},
89
+ ): Record<string, unknown> {
90
+ if (env[ENVIRONMENT_VAR] !== LOCAL) return env;
91
+ const published = env[SERVICE_ORIGINS_VAR];
92
+ if (typeof published !== "string") return env;
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(published);
96
+ } catch {
97
+ return env;
98
+ }
99
+ const origins = ServiceOrigins.safeParse(parsed);
100
+ if (!origins.success || Object.keys(origins.data).length === 0) return env;
101
+ const send = options.fetch ?? ((request: Request) => fetch(request));
102
+ const local: Record<string, unknown> = { ...env };
103
+ for (const [binding, origin] of Object.entries(origins.data)) local[binding] = localService(binding, origin, send);
104
+ return local;
105
+ }
@@ -192,6 +192,27 @@ export interface DurableStepLike extends WorkflowStepLike {
192
192
  sleep(name: string, durationMs: number): Promise<void>;
193
193
  }
194
194
 
195
+ /**
196
+ * The platform step's configured `do`: a retry policy ahead of the body. Every `WorkflowStep` has it; a
197
+ * test double that only takes two arguments does not, which is why {@link ClassifiedSteps.once} is the one
198
+ * place it is reached for.
199
+ */
200
+ interface ConfiguredStepLike {
201
+ do<T>(name: string, config: { retries: { limit: number; delay: number } }, fn: () => Promise<T>): Promise<T>;
202
+ }
203
+
204
+ /** What {@link classifiedSteps} hands back: a pausing runner, and a step that runs exactly once. */
205
+ export interface ClassifiedSteps extends DurableStepLike {
206
+ /**
207
+ * Run a named step with **no retries**, still classified (pithy-sh/pithy#602).
208
+ *
209
+ * For a body that mutates somebody else's system — a provider roll — where a platform re-running the
210
+ * step after an eviction would do it twice. The classification still decides what the instance's
211
+ * failure says; it can no longer decide on a retry, because there is none to give.
212
+ */
213
+ once<T>(name: string, fn: () => Promise<T>): Promise<T>;
214
+ }
215
+
195
216
  /**
196
217
  * The platform's terminal-error class — `NonRetryableError` from `cloudflare:workflows`, taken as a
197
218
  * parameter.
@@ -222,31 +243,37 @@ export function classifiedSteps(
222
243
  step: WorkflowStepLike,
223
244
  policy: WorkflowRetryPolicy,
224
245
  terminalError: TerminalErrorConstructor,
225
- ): DurableStepLike {
226
- const runner: DurableStepLike = {
246
+ ): ClassifiedSteps {
247
+ const classify =
248
+ <T>(fn: () => Promise<T>) =>
249
+ async (): Promise<T> => {
250
+ try {
251
+ return await fn();
252
+ } catch (error) {
253
+ const fault = classifyWorkflowFault(error, policy);
254
+ if (fault.disposition === "retry") throw error;
255
+ // The one sanctioned non-`PithyError` throw in the kit: a platform contract, at the durable
256
+ // boundary, carrying the `PithyError` it replaced as its `cause` so nothing is lost in this
257
+ // process. Across the boundary the text is the whole channel — the engine records it and
258
+ // discards the throw — so what an operator is owed is encoded into it, at one stated site,
259
+ // and read back at one (./stepMessage). The `cause` does not travel; `detail` never did.
260
+ const terminal = new terminalError(
261
+ encodeWorkflowStepMessage({
262
+ code: fault.code,
263
+ message: messageOf(error),
264
+ action: pithyActionOf(error),
265
+ }),
266
+ );
267
+ terminal.cause = error;
268
+ throw terminal;
269
+ }
270
+ };
271
+ const runner: ClassifiedSteps = {
227
272
  do<T>(name: string, fn: () => Promise<T>): Promise<T> {
228
- return step.do(name, async () => {
229
- try {
230
- return await fn();
231
- } catch (error) {
232
- const fault = classifyWorkflowFault(error, policy);
233
- if (fault.disposition === "retry") throw error;
234
- // The one sanctioned non-`PithyError` throw in the kit: a platform contract, at the durable
235
- // boundary, carrying the `PithyError` it replaced as its `cause` so nothing is lost in this
236
- // process. Across the boundary the text is the whole channel — the engine records it and
237
- // discards the throw — so what an operator is owed is encoded into it, at one stated site,
238
- // and read back at one (./stepMessage). The `cause` does not travel; `detail` never did.
239
- const terminal = new terminalError(
240
- encodeWorkflowStepMessage({
241
- code: fault.code,
242
- message: messageOf(error),
243
- action: pithyActionOf(error),
244
- }),
245
- );
246
- terminal.cause = error;
247
- throw terminal;
248
- }
249
- });
273
+ return step.do(name, classify(fn));
274
+ },
275
+ once<T>(name: string, fn: () => Promise<T>): Promise<T> {
276
+ return (step as unknown as ConfiguredStepLike).do(name, { retries: { limit: 0, delay: 0 } }, classify(fn));
250
277
  },
251
278
  /**
252
279
  * Forwarded, and deliberately unclassified. A pause raises no fault of the capability's — the