@pithy-sh/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/asyncapi/document.d.ts +46 -0
  17. package/dist/asyncapi/document.d.ts.map +1 -0
  18. package/dist/asyncapi/document.js +115 -0
  19. package/dist/audit/auditEvent.d.ts +5 -0
  20. package/dist/audit/auditEvent.d.ts.map +1 -1
  21. package/dist/audit/auditEvent.js +2 -0
  22. package/dist/capability/bindings.d.ts +47 -2
  23. package/dist/capability/bindings.d.ts.map +1 -1
  24. package/dist/capability/bindings.js +117 -7
  25. package/dist/capability/capability.d.ts +105 -28
  26. package/dist/capability/capability.d.ts.map +1 -1
  27. package/dist/capability/composition.d.ts.map +1 -1
  28. package/dist/capability/composition.js +5 -1
  29. package/dist/capability/manifest.d.ts +2 -1
  30. package/dist/capability/manifest.d.ts.map +1 -1
  31. package/dist/capability/manifest.js +1 -1
  32. package/dist/capability/settings.d.ts +96 -4
  33. package/dist/capability/settings.d.ts.map +1 -1
  34. package/dist/capability/settings.js +85 -4
  35. package/dist/cli/command.d.ts +364 -0
  36. package/dist/cli/command.d.ts.map +1 -0
  37. package/dist/cli/command.js +418 -0
  38. package/dist/cli/commandCatalog.generated.d.ts +2437 -0
  39. package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
  40. package/dist/cli/commandCatalog.generated.js +2564 -0
  41. package/dist/cli/commandFacts.d.ts +164 -0
  42. package/dist/cli/commandFacts.d.ts.map +1 -0
  43. package/dist/cli/commandFacts.js +216 -0
  44. package/dist/controlPlane/capability.d.ts.map +1 -1
  45. package/dist/controlPlane/capability.js +12 -2
  46. package/dist/controlPlane/context.d.ts +5 -0
  47. package/dist/controlPlane/context.d.ts.map +1 -1
  48. package/dist/controlPlane/context.js +3 -1
  49. package/dist/controlPlane/data/connection.d.ts +47 -0
  50. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  51. package/dist/controlPlane/data/connection.js +55 -1
  52. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  53. package/dist/controlPlane/error/errors.js +4 -3
  54. package/dist/controlPlane/http/cors.d.ts +4 -4
  55. package/dist/controlPlane/http/cors.js +4 -4
  56. package/dist/controlPlane/http/guard.d.ts +11 -0
  57. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  58. package/dist/controlPlane/http/guard.js +28 -2
  59. package/dist/controlPlane/http/handlers.d.ts +60 -0
  60. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  61. package/dist/controlPlane/http/handlers.js +31 -2
  62. package/dist/controlPlane/http/readiness.d.ts +33 -0
  63. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  64. package/dist/controlPlane/http/readiness.js +39 -0
  65. package/dist/controlPlane/http/responses.d.ts +147 -0
  66. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  67. package/dist/controlPlane/http/responses.js +61 -4
  68. package/dist/controlPlane/http/routes.d.ts +2 -1
  69. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  70. package/dist/controlPlane/http/routes.js +50 -9
  71. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  72. package/dist/controlPlane/http/verify.js +2 -1
  73. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  74. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  75. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  76. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  79. package/dist/controlPlane/scope/scope.d.ts +13 -2
  80. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  81. package/dist/controlPlane/scope/scope.js +14 -3
  82. package/dist/createBackend.d.ts +7 -0
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +41 -6
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/entitlement/allowance.d.ts +25 -0
  88. package/dist/entitlement/allowance.d.ts.map +1 -0
  89. package/dist/entitlement/allowance.js +80 -0
  90. package/dist/entitlement/entitlement.d.ts +41 -0
  91. package/dist/entitlement/entitlement.d.ts.map +1 -1
  92. package/dist/entitlement/meter.d.ts +57 -0
  93. package/dist/entitlement/meter.d.ts.map +1 -0
  94. package/dist/entitlement/meter.js +13 -0
  95. package/dist/entitlement/require.js +1 -1
  96. package/dist/error/cause.d.ts +16 -116
  97. package/dist/error/cause.d.ts.map +1 -1
  98. package/dist/error/cause.js +19 -153
  99. package/dist/error/http.d.ts +184 -0
  100. package/dist/error/http.d.ts.map +1 -1
  101. package/dist/error/payload.d.ts +368 -0
  102. package/dist/error/payload.d.ts.map +1 -1
  103. package/dist/error/payload.js +126 -4
  104. package/dist/error/pithyError.d.ts +44 -0
  105. package/dist/error/pithyError.d.ts.map +1 -1
  106. package/dist/error/pithyError.js +56 -1
  107. package/dist/error/unresolved.d.ts +265 -0
  108. package/dist/error/unresolved.d.ts.map +1 -0
  109. package/dist/error/unresolved.js +599 -0
  110. package/dist/error/wire.d.ts +20 -0
  111. package/dist/error/wire.d.ts.map +1 -0
  112. package/dist/error/wire.js +19 -0
  113. package/dist/http/authContext.d.ts +7 -0
  114. package/dist/http/authContext.d.ts.map +1 -1
  115. package/dist/http/authContext.js +8 -1
  116. package/dist/http/oidcWebhook.d.ts +3 -3
  117. package/dist/http/oidcWebhook.js +3 -3
  118. package/dist/http/routeContract.js +1 -1
  119. package/dist/http/routeSpec.d.ts +66 -0
  120. package/dist/http/routeSpec.d.ts.map +1 -0
  121. package/dist/http/routeSpec.js +40 -0
  122. package/dist/http/sameOrigin.d.ts +1 -1
  123. package/dist/http/sameOrigin.js +1 -1
  124. package/dist/http/scopeGates.d.ts +20 -0
  125. package/dist/http/scopeGates.d.ts.map +1 -0
  126. package/dist/http/scopeGates.js +36 -0
  127. package/dist/http/signedWebhook.d.ts +3 -3
  128. package/dist/http/signedWebhook.js +3 -3
  129. package/dist/http/socket.d.ts +52 -0
  130. package/dist/http/socket.d.ts.map +1 -0
  131. package/dist/http/socket.js +2 -0
  132. package/dist/http/subprotocol.d.ts +29 -0
  133. package/dist/http/subprotocol.d.ts.map +1 -0
  134. package/dist/http/subprotocol.js +37 -0
  135. package/dist/http/validation.d.ts +40 -8
  136. package/dist/http/validation.d.ts.map +1 -1
  137. package/dist/http/validation.js +25 -1
  138. package/dist/index.d.ts +1 -1
  139. package/dist/index.d.ts.map +1 -1
  140. package/dist/index.js +4 -4
  141. package/dist/logger/record.d.ts +107 -0
  142. package/dist/logger/record.d.ts.map +1 -1
  143. package/dist/migrations/bookkeeping.d.ts +21 -0
  144. package/dist/migrations/bookkeeping.d.ts.map +1 -1
  145. package/dist/migrations/bookkeeping.js +32 -3
  146. package/dist/migrations/capabilitySets.d.ts +23 -0
  147. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  148. package/dist/migrations/capabilitySets.js +74 -0
  149. package/dist/migrations/groups.d.ts +140 -0
  150. package/dist/migrations/groups.d.ts.map +1 -0
  151. package/dist/migrations/groups.js +191 -0
  152. package/dist/migrations/migrateStep.d.ts +13 -0
  153. package/dist/migrations/migrateStep.d.ts.map +1 -0
  154. package/dist/migrations/migrateStep.js +20 -0
  155. package/dist/migrations/owner.d.ts +7 -0
  156. package/dist/migrations/owner.d.ts.map +1 -1
  157. package/dist/migrations/owner.js +11 -7
  158. package/dist/migrations/registry.d.ts +26 -0
  159. package/dist/migrations/registry.d.ts.map +1 -1
  160. package/dist/migrations/registry.js +38 -7
  161. package/dist/migrations/retained.js +2 -2
  162. package/dist/migrations/runner.d.ts +76 -5
  163. package/dist/migrations/runner.d.ts.map +1 -1
  164. package/dist/migrations/runner.js +130 -19
  165. package/dist/migrations/state.d.ts +60 -0
  166. package/dist/migrations/state.d.ts.map +1 -0
  167. package/dist/migrations/state.js +46 -0
  168. package/dist/naming/domains.d.ts +17 -0
  169. package/dist/naming/domains.d.ts.map +1 -1
  170. package/dist/naming/domains.js +62 -4
  171. package/dist/naming/environment.d.ts.map +1 -1
  172. package/dist/naming/environment.js +2 -1
  173. package/dist/naming/feature.d.ts +27 -5
  174. package/dist/naming/feature.d.ts.map +1 -1
  175. package/dist/naming/feature.js +36 -6
  176. package/dist/naming/limits.d.ts +47 -3
  177. package/dist/naming/limits.d.ts.map +1 -1
  178. package/dist/naming/limits.js +47 -3
  179. package/dist/naming/packageName.d.ts +17 -0
  180. package/dist/naming/packageName.d.ts.map +1 -0
  181. package/dist/naming/packageName.js +22 -0
  182. package/dist/naming/provisionScope.d.ts +6 -0
  183. package/dist/naming/provisionScope.d.ts.map +1 -1
  184. package/dist/naming/provisionScope.js +10 -4
  185. package/dist/naming/resourceNames.d.ts +21 -2
  186. package/dist/naming/resourceNames.d.ts.map +1 -1
  187. package/dist/naming/resourceNames.js +21 -3
  188. package/dist/openapi/document.d.ts +106 -0
  189. package/dist/openapi/document.d.ts.map +1 -0
  190. package/dist/openapi/document.js +314 -0
  191. package/dist/openapi/routeGroups.d.ts +8 -0
  192. package/dist/openapi/routeGroups.d.ts.map +1 -0
  193. package/dist/openapi/routeGroups.js +23 -0
  194. package/dist/seed/writeD1.d.ts +7 -1
  195. package/dist/seed/writeD1.d.ts.map +1 -1
  196. package/dist/seed/writeD1.js +9 -3
  197. package/dist/seed/writeKv.d.ts +2 -0
  198. package/dist/seed/writeKv.d.ts.map +1 -1
  199. package/dist/seed/writeKv.js +6 -2
  200. package/dist/text/comments.js +66 -4
  201. package/dist/text/literals.d.ts +43 -0
  202. package/dist/text/literals.d.ts.map +1 -0
  203. package/dist/text/literals.js +293 -0
  204. package/dist/version.generated.d.ts +1 -1
  205. package/dist/version.generated.d.ts.map +1 -1
  206. package/dist/version.generated.js +1 -1
  207. package/dist/worker/serviceLoopback.d.ts +36 -0
  208. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  209. package/dist/worker/serviceLoopback.js +74 -0
  210. package/dist/workflow/dispatchRoute.d.ts.map +1 -1
  211. package/dist/workflow/dispatchRoute.js +12 -6
  212. package/dist/workflow/faults.d.ts +12 -1
  213. package/dist/workflow/faults.d.ts.map +1 -1
  214. package/dist/workflow/faults.js +24 -18
  215. package/dist/workflow/hostEnv.d.ts +1 -0
  216. package/dist/workflow/hostEnv.d.ts.map +1 -1
  217. package/dist/workflow/hostEnv.js +4 -2
  218. package/dist/workflow/loopback.d.ts.map +1 -1
  219. package/dist/workflow/loopback.js +2 -1
  220. package/package.json +4 -4
  221. package/src/access/access.ts +298 -0
  222. package/src/access/acting.ts +59 -0
  223. package/src/access/policyCheck.ts +77 -0
  224. package/src/access/usage.ts +141 -0
  225. package/src/access/visibility.ts +109 -0
  226. package/src/asyncapi/document.ts +164 -0
  227. package/src/audit/auditEvent.ts +4 -0
  228. package/src/capability/bindings.ts +159 -13
  229. package/src/capability/capability.ts +106 -29
  230. package/src/capability/composition.ts +2 -1
  231. package/src/capability/settings.ts +165 -4
  232. package/src/cli/command.ts +865 -0
  233. package/src/cli/commandCatalog.generated.ts +2723 -0
  234. package/src/cli/commandFacts.ts +220 -0
  235. package/src/controlPlane/capability.ts +25 -1
  236. package/src/controlPlane/context.ts +4 -0
  237. package/src/controlPlane/data/connection.ts +90 -0
  238. package/src/controlPlane/error/errors.ts +10 -3
  239. package/src/controlPlane/http/cors.ts +4 -4
  240. package/src/controlPlane/http/guard.ts +35 -2
  241. package/src/controlPlane/http/handlers.ts +41 -0
  242. package/src/controlPlane/http/readiness.ts +50 -0
  243. package/src/controlPlane/http/responses.ts +140 -2
  244. package/src/controlPlane/http/routes.ts +82 -10
  245. package/src/controlPlane/http/verify.ts +2 -0
  246. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  247. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  248. package/src/controlPlane/scope/scope.ts +14 -2
  249. package/src/createBackend.ts +59 -8
  250. package/src/createEntrypoint.ts +9 -3
  251. package/src/entitlement/allowance.ts +85 -0
  252. package/src/entitlement/entitlement.ts +38 -0
  253. package/src/entitlement/meter.ts +69 -0
  254. package/src/entitlement/require.ts +1 -1
  255. package/src/error/cause.ts +18 -181
  256. package/src/error/payload.ts +270 -4
  257. package/src/error/pithyError.ts +76 -0
  258. package/src/error/unresolved.ts +853 -0
  259. package/src/error/wire.ts +40 -0
  260. package/src/http/authContext.ts +13 -1
  261. package/src/http/oidcWebhook.ts +3 -3
  262. package/src/http/routeContract.ts +1 -1
  263. package/src/http/routeSpec.ts +81 -0
  264. package/src/http/sameOrigin.ts +1 -1
  265. package/src/http/scopeGates.ts +34 -0
  266. package/src/http/signedWebhook.ts +3 -3
  267. package/src/http/socket.ts +55 -0
  268. package/src/http/subprotocol.ts +47 -0
  269. package/src/http/validation.ts +50 -8
  270. package/src/index.ts +1 -0
  271. package/src/migrations/bookkeeping.ts +39 -2
  272. package/src/migrations/capabilitySets.ts +87 -0
  273. package/src/migrations/groups.ts +295 -0
  274. package/src/migrations/migrateStep.ts +19 -0
  275. package/src/migrations/owner.ts +10 -6
  276. package/src/migrations/registry.ts +46 -2
  277. package/src/migrations/retained.ts +2 -2
  278. package/src/migrations/runner.ts +214 -22
  279. package/src/migrations/state.ts +94 -0
  280. package/src/naming/domains.ts +75 -16
  281. package/src/naming/environment.ts +2 -1
  282. package/src/naming/feature.ts +46 -7
  283. package/src/naming/limits.ts +35 -3
  284. package/src/naming/packageName.ts +20 -0
  285. package/src/naming/provisionScope.ts +21 -2
  286. package/src/naming/resourceNames.ts +52 -5
  287. package/src/openapi/document.ts +412 -0
  288. package/src/openapi/routeGroups.ts +25 -0
  289. package/src/seed/writeD1.ts +13 -4
  290. package/src/seed/writeKv.ts +6 -2
  291. package/src/text/comments.ts +56 -4
  292. package/src/text/literals.ts +294 -0
  293. package/src/version.generated.ts +1 -1
  294. package/src/worker/serviceLoopback.ts +105 -0
  295. package/src/workflow/dispatchRoute.ts +13 -6
  296. package/src/workflow/faults.ts +56 -27
  297. package/src/workflow/hostEnv.ts +9 -1
  298. package/src/workflow/loopback.ts +2 -1
@@ -0,0 +1,294 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **Every string a source file writes, as it reads at run time near enough to scan (#711)** — each quoted or
6
+ * template literal's text, quotes off, escapes resolved to the character they stand for, and every `${…}` blanked
7
+ * to `<x>`.
8
+ *
9
+ * Gates that read what the code *says* — `packages/cli/src/ci/runnableCommands.test.ts` reads every `pithy …` a
10
+ * line names — used one regular expression for "a string or template literal", and it has a measured false
11
+ * reading: a template whose interpolation holds a template of its own, `` `${path}${detail ? ` — ${detail}` :
12
+ * ""}` ``, closed at the inner backtick, and everything after it was read as a literal that opened there. The
13
+ * scan then reported text the file does not contain, and missed the text it does. So this walks the source and
14
+ * keeps a stack: a `${` opens an expression, whose own literals are read as literals in their turn, and its
15
+ * matching `}` resumes the template it interrupted.
16
+ *
17
+ * Comments must be blanked first (`./comments`), as every caller here already does. A regex literal is stepped
18
+ * over by the same rule `./comments` uses — a `/` where a value may begin opens one — so a quote inside a pattern
19
+ * does not open a string.
20
+ */
21
+ export function literalTexts(source: string, options: LiteralOptions = {}): string[] {
22
+ return literalSpans(source, options).map((span) => span.text);
23
+ }
24
+
25
+ /** How a source is read. */
26
+ export interface LiteralOptions {
27
+ /**
28
+ * The source is a `.tsx` file: an element's text and its attribute strings are read as literals too, and a
29
+ * `<` where a value may begin opens an element rather than comparing. A JSX text run is read with its
30
+ * whitespace collapsed, as React renders it; a `{…}` child is `<x>`.
31
+ */
32
+ readonly jsx?: boolean;
33
+ }
34
+
35
+ /** One literal: its text as {@link literalTexts} reads it, and the source range it spans, delimiters included. */
36
+ export interface LiteralSpan {
37
+ readonly text: string;
38
+ readonly start: number;
39
+ readonly end: number;
40
+ /** A template's interpolations, each the range of the code between `${` and `}`. Absent for any other literal. */
41
+ readonly holes?: readonly (readonly [number, number])[];
42
+ }
43
+
44
+ /**
45
+ * {@link literalTexts}, with where each one sits — so a gate can read what is around a literal: a `+` beside it, or
46
+ * the array it is an element of being joined.
47
+ */
48
+ export function literalSpans(source: string, options: LiteralOptions = {}): LiteralSpan[] {
49
+ const found: LiteralSpan[] = [];
50
+ walk(source, 0, found, false, options.jsx === true);
51
+ return found;
52
+ }
53
+
54
+ /** After one of these, a `/` opens a regex literal; after anything else it divides. */
55
+ const VALUE_POSITION = new Set([
56
+ "",
57
+ "(",
58
+ ",",
59
+ "=",
60
+ ":",
61
+ "[",
62
+ "!",
63
+ "&",
64
+ "|",
65
+ "?",
66
+ "{",
67
+ "}",
68
+ ";",
69
+ "+",
70
+ "-",
71
+ "*",
72
+ "%",
73
+ "^",
74
+ "~",
75
+ "<",
76
+ ">",
77
+ ]);
78
+
79
+ /**
80
+ * Walk code from `start`, pushing every literal it meets. Inside an interpolation (`nested`), an unmatched `}`
81
+ * closes it: the walk returns the index just past that brace.
82
+ */
83
+ function walk(source: string, start: number, found: LiteralSpan[], nested: boolean, jsx: boolean): number {
84
+ let depth = 0;
85
+ let previous = "";
86
+ let index = start;
87
+ while (index < source.length) {
88
+ const char = source[index] as string;
89
+ if (char === '"' || char === "'") {
90
+ index = quoted(source, index, char, found);
91
+ previous = char;
92
+ continue;
93
+ }
94
+ if (char === "`") {
95
+ index = template(source, index, found, jsx);
96
+ previous = char;
97
+ continue;
98
+ }
99
+ if (jsx && char === "<" && opensElement(source, index, previous)) {
100
+ // An element is read whole, or — when it does not parse as one, as a generic arrow `<T,>` does not — the
101
+ // `<` is code after all, and what it read is put back.
102
+ const mark = found.length;
103
+ const end = element(source, index, found);
104
+ if (end !== null) {
105
+ index = end;
106
+ previous = ")";
107
+ continue;
108
+ }
109
+ found.length = mark;
110
+ }
111
+ if (char === "/" && VALUE_POSITION.has(previous)) {
112
+ index = endOfRegex(source, index);
113
+ previous = "/";
114
+ continue;
115
+ }
116
+ if (char === "{") depth += 1;
117
+ if (char === "}") {
118
+ if (depth === 0 && nested) return index + 1;
119
+ depth -= 1;
120
+ }
121
+ if (!/\s/.test(char)) previous = char;
122
+ index += 1;
123
+ }
124
+ return index;
125
+ }
126
+
127
+ /** One quoted string: its text pushed, the index past its closing quote returned. A newline ends it unclosed. */
128
+ function quoted(source: string, start: number, quote: string, found: LiteralSpan[]): number {
129
+ let text = "";
130
+ for (let index = start + 1; index < source.length; index += 1) {
131
+ const char = source[index] as string;
132
+ if (char === "\\") {
133
+ text += source[index + 1] ?? "";
134
+ index += 1;
135
+ continue;
136
+ }
137
+ if (char === quote || char === "\n") {
138
+ const end = char === quote ? index + 1 : index;
139
+ found.push({ text, start, end });
140
+ return end;
141
+ }
142
+ text += char;
143
+ }
144
+ found.push({ text, start, end: source.length });
145
+ return source.length;
146
+ }
147
+
148
+ /**
149
+ * One template: its text pushed with each `${…}` as `<x>`, and every literal inside an interpolation pushed in
150
+ * its own right. Returns the index past the closing backtick.
151
+ */
152
+ function template(source: string, start: number, found: LiteralSpan[], jsx: boolean): number {
153
+ let text = "";
154
+ const holes: [number, number][] = [];
155
+ const slot = found.length;
156
+ found.push({ text: "", start, end: start });
157
+ const close = (end: number): LiteralSpan => ({ text, start, end, ...(holes.length > 0 ? { holes } : {}) });
158
+ let index = start + 1;
159
+ while (index < source.length) {
160
+ const char = source[index] as string;
161
+ if (char === "\\") {
162
+ text += source[index + 1] ?? "";
163
+ index += 2;
164
+ continue;
165
+ }
166
+ if (char === "`") {
167
+ found[slot] = close(index + 1);
168
+ return index + 1;
169
+ }
170
+ if (char === "$" && source[index + 1] === "{") {
171
+ text += "<x>";
172
+ const from = index + 2;
173
+ index = walk(source, from, found, true, jsx);
174
+ // `walk` returns past the `}` that closed it; an unclosed one ran to the end.
175
+ holes.push([from, source[index - 1] === "}" ? index - 1 : index]);
176
+ continue;
177
+ }
178
+ text += char;
179
+ index += 1;
180
+ }
181
+ found[slot] = close(source.length);
182
+ return source.length;
183
+ }
184
+
185
+ /** After one of these words, as after a {@link VALUE_POSITION} character, a value begins. */
186
+ const VALUE_WORDS = new Set(["return", "yield", "await", "default", "case", "else", "do"]);
187
+
188
+ /**
189
+ * Whether the `<` at `index` opens a JSX element: where a value may begin — after an operator, a bracket, or a
190
+ * word such as `return` — and followed by a tag name or, for a fragment, `>`. After an identifier or a closing
191
+ * bracket it compares, or opens a type's arguments.
192
+ */
193
+ function opensElement(source: string, index: number, previous: string): boolean {
194
+ if (!/[A-Za-z_$>]/.test(source[index + 1] ?? "")) return false;
195
+ if (VALUE_POSITION.has(previous)) return true;
196
+ const before = /([A-Za-z_$][\w$]*)\s*$/.exec(source.slice(Math.max(0, index - 32), index));
197
+ return before !== null && VALUE_WORDS.has(before[1] as string);
198
+ }
199
+
200
+ /** A JSX text run as React renders it: whitespace collapsed, and nothing pushed for a run that is only space. */
201
+ function pushText(text: string, start: number, end: number, found: LiteralSpan[]): void {
202
+ const collapsed = text.replace(/\s+/g, " ").trim();
203
+ if (collapsed.length > 0) found.push({ text: collapsed, start, end });
204
+ }
205
+
206
+ /**
207
+ * One JSX element from its `<`: each attribute string and each text run pushed, every `{…}` walked as code. Returns
208
+ * the index past the element, or `null` when what follows the `<` is not an element — a generic arrow `<T,>(…)`,
209
+ * or a `<` that never closes — for the caller to read as code.
210
+ */
211
+ function element(source: string, start: number, found: LiteralSpan[]): number | null {
212
+ let index = start + 1;
213
+ const name = /^[A-Za-z_$][\w$.:-]*/.exec(source.slice(index))?.[0] ?? "";
214
+ index += name.length;
215
+ // The attributes, up to `>` or `/>`.
216
+ for (;;) {
217
+ while (/\s/.test(source[index] ?? "")) index += 1;
218
+ const char = source[index];
219
+ if (char === undefined) return null;
220
+ if (char === "/" && source[index + 1] === ">") return index + 2;
221
+ if (char === ">") {
222
+ index += 1;
223
+ break;
224
+ }
225
+ if (char === "{") {
226
+ index = walk(source, index + 1, found, true, true);
227
+ continue;
228
+ }
229
+ const attribute = /^[A-Za-z_$][\w$:-]*/.exec(source.slice(index))?.[0];
230
+ if (attribute === undefined || name === "") return null;
231
+ index += attribute.length;
232
+ while (/\s/.test(source[index] ?? "")) index += 1;
233
+ if (source[index] !== "=") continue;
234
+ index += 1;
235
+ while (/\s/.test(source[index] ?? "")) index += 1;
236
+ const value = source[index];
237
+ if (value === "{") {
238
+ index = walk(source, index + 1, found, true, true);
239
+ continue;
240
+ }
241
+ if (value !== '"' && value !== "'") return null;
242
+ // A JSX attribute string has no escapes, and may run across lines.
243
+ const close = source.indexOf(value, index + 1);
244
+ if (close === -1) return null;
245
+ found.push({ text: source.slice(index + 1, close), start: index, end: close + 1 });
246
+ index = close + 1;
247
+ }
248
+ // The children, up to the closing tag.
249
+ let text = "";
250
+ let from = index;
251
+ while (index < source.length) {
252
+ const char = source[index] as string;
253
+ if (char === "{") {
254
+ text += "<x>";
255
+ index = walk(source, index + 1, found, true, true);
256
+ continue;
257
+ }
258
+ if (char === "<") {
259
+ const closing = /^<\s*\//.exec(source.slice(index));
260
+ if (closing !== null) {
261
+ pushText(text, from, index, found);
262
+ const end = source.indexOf(">", index);
263
+ return end === -1 ? null : end + 1;
264
+ }
265
+ pushText(text, from, index, found);
266
+ const end = element(source, index, found);
267
+ if (end === null) return null;
268
+ index = end;
269
+ text = "";
270
+ from = index;
271
+ continue;
272
+ }
273
+ text += char;
274
+ index += 1;
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /** Where a regex literal ends. A `/` inside a character class does not close it; a newline does. */
280
+ function endOfRegex(source: string, start: number): number {
281
+ let inClass = false;
282
+ for (let index = start + 1; index < source.length; index += 1) {
283
+ const char = source[index];
284
+ if (char === "\\") {
285
+ index += 1;
286
+ continue;
287
+ }
288
+ if (char === "\n") return index;
289
+ if (char === "[") inClass = true;
290
+ else if (char === "]") inClass = false;
291
+ else if (char === "/" && !inClass) return index + 1;
292
+ }
293
+ return source.length;
294
+ }
@@ -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.9.0";
16
+ export const PACKAGE_VERSION = "0.11.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
+ }
@@ -1,19 +1,20 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { zValidator } from "@hono/zod-validator";
5
4
  import type { Context, Hono, Next } from "hono";
6
5
  import type { PithyHonoEnv } from "../capability/capability";
6
+ import { commandName } from "../cli/command";
7
7
  import { type AmbientEnv, ambientEnv, compositionEnvironment } from "../env/ambient";
8
8
  import { ForbiddenError, PithyError } from "../error/pithyError";
9
- import { validationHook } from "../http/validation";
9
+ import { describeRoute } from "../http/routeSpec";
10
+ import { validate } from "../http/validation";
10
11
  import type { VerificationStrategy } from "../http/verification";
11
12
  import { workflowBindingFor } from "./dispatch";
12
13
  import {
13
14
  WORKFLOW_DISPATCH_ROUTE,
14
15
  WorkflowDispatchParams,
15
16
  WorkflowDispatchRequest,
16
- type WorkflowDispatchResponse,
17
+ WorkflowDispatchResponse,
17
18
  } from "./schemas";
18
19
  import type { RegisteredWorkflow, WorkflowRegistry } from "./spec";
19
20
 
@@ -104,7 +105,7 @@ function specForBinding(registry: WorkflowRegistry, binding: string): Registered
104
105
  function refuseOutsideDev(capability: string, environment: string | undefined): ForbiddenError {
105
106
  return new ForbiddenError({
106
107
  message: `Workflow dispatch over HTTP is served only in the ${DISPATCH_ENVIRONMENT} environment.`,
107
- action: `Dispatch through the cross-script ${capability} Workflow binding, or run this host under pithy dev.`,
108
+ action: `Dispatch through the cross-script ${capability} Workflow binding, or run this host under ${commandName("dev")}.`,
108
109
  detail: `The composition's ENVIRONMENT is ${environment ?? "unstamped"}.`,
109
110
  });
110
111
  }
@@ -129,9 +130,15 @@ export function registerWorkflowDispatchRoute(app: Hono<PithyHonoEnv>, options:
129
130
 
130
131
  app.post(
131
132
  WORKFLOW_DISPATCH_ROUTE,
133
+ describeRoute({
134
+ summary: "Start a Workflow instance on this host, in dev only.",
135
+ description: "Refused outside a `dev` composition. Deployed Workers dispatch through a Workflow binding instead.",
136
+ verification: WORKFLOW_DISPATCH_VERIFICATION,
137
+ responses: { 202: WorkflowDispatchResponse },
138
+ }),
132
139
  guard,
133
- zValidator("param", WorkflowDispatchParams, validationHook),
134
- zValidator("json", WorkflowDispatchRequest, validationHook),
140
+ validate("param", WorkflowDispatchParams),
141
+ validate("json", WorkflowDispatchRequest),
135
142
  async (c) => {
136
143
  const { binding } = c.req.valid("param");
137
144
  const { id, params } = c.req.valid("json");
@@ -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
@@ -259,8 +286,10 @@ export function classifiedSteps(
259
286
  * answer that cannot change.
260
287
  */
261
288
  sleep(name: string, durationMs: number): Promise<void> {
262
- const pause = step.sleep;
263
- if (pause === undefined) {
289
+ // Read to test for presence, then called off `step` itself. On Cloudflare the step is an RPC
290
+ // stub, where a property read is another stub: `.call` on it is a remote call to a method named
291
+ // `call`, which the receiver does not implement.
292
+ if (step.sleep === undefined) {
264
293
  return runner.do<void>(`${name}/cannot-pause`, async () => {
265
294
  throw new InternalError({
266
295
  message: "This Workflow step runner cannot pause.",
@@ -269,7 +298,7 @@ export function classifiedSteps(
269
298
  });
270
299
  });
271
300
  }
272
- return pause.call(step, name, durationMs);
301
+ return step.sleep(name, durationMs);
273
302
  },
274
303
  };
275
304
  return runner;
@@ -59,6 +59,13 @@ export const HostEnvProvider = z
59
59
  .min(1)
60
60
  .optional()
61
61
  .describe("The `pithy` command that fills it, where one does. Rendered as the report's action line."),
62
+ decision: z
63
+ .string()
64
+ .min(1)
65
+ .optional()
66
+ .describe(
67
+ "What fills it when only a person may run the command that does — one that touches production — said as their decision. Rendered in place of `command`.",
68
+ ),
62
69
  })
63
70
  .describe("What fills one field of a host worker's env, so a failure names the thing an operator changes.");
64
71
  export type HostEnvProvider = z.infer<typeof HostEnvProvider>;
@@ -170,7 +177,8 @@ export function hostEnvProviderSentence(provider: HostEnvProvider): string {
170
177
  : provider.kind === "secret"
171
178
  ? `Secret ${provider.name}, read through @pithy-sh/secrets.`
172
179
  : `Config key ${provider.name}.`;
173
- return provider.command ? `${where} Run ${provider.command}.` : where;
180
+ if (provider.command) return `${where} Run ${provider.command}.`;
181
+ return provider.decision ? `${where} ${provider.decision}` : where;
174
182
  }
175
183
 
176
184
  /**
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { commandName } from "../cli/command";
4
5
  import { UpstreamError, UpstreamTimeoutError } from "../error/pithyError";
5
6
  import { type WorkflowDispatchRequest, workflowDispatchPath } from "./schemas";
6
7
  import type { WorkflowBinding } from "./spec";
@@ -84,7 +85,7 @@ export function loopbackWorkflowBinding(options: LoopbackWorkflowOptions): Workf
84
85
  const owner = options.capability ?? options.binding;
85
86
  const send = options.fetch ?? ((target: string, init: RequestInit) => fetch(target, init));
86
87
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
87
- const running = `Check that the ${owner} host is running under pithy dev at ${origin}.`;
88
+ const running = `Check that the ${owner} host is running under ${commandName("dev")} at ${origin}.`;
88
89
 
89
90
  return {
90
91
  async create(instance?: { id?: string; params?: unknown }): Promise<unknown> {