@apifuse/provider-sdk 2.2.0-beta.1 → 2.2.0-beta.11

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 (261) hide show
  1. package/AUTHORING.md +106 -0
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -1
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +14 -0
  9. package/bin/apifuse-pack-smoke.ts +22 -81
  10. package/bin/apifuse-pack-types.ts +266 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +53 -70
  13. package/bin/apifuse-submit-check.ts +510 -45
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth.d.ts +2 -2
  21. package/dist/auth.js +9 -18
  22. package/dist/ceremonies/index.d.ts +1 -1
  23. package/dist/ceremonies/index.js +8 -18
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/config/loader.d.ts +149 -8
  32. package/dist/config/loader.js +378 -89
  33. package/dist/contract-serialization.d.ts +2 -2
  34. package/dist/contract-serialization.js +3 -6
  35. package/dist/contract-types.d.ts +2 -2
  36. package/dist/contract.d.ts +3 -3
  37. package/dist/contract.js +4 -6
  38. package/dist/define.d.ts +8 -1
  39. package/dist/define.js +119 -122
  40. package/dist/dev.d.ts +1 -1
  41. package/dist/dev.js +1 -1
  42. package/dist/errors.d.ts +4 -1
  43. package/dist/errors.js +48 -0
  44. package/dist/i18n/catalog.d.ts +2 -2
  45. package/dist/i18n/catalog.js +4 -10
  46. package/dist/i18n/index.d.ts +2 -2
  47. package/dist/i18n/index.js +2 -2
  48. package/dist/i18n/keys.d.ts +2 -2
  49. package/dist/index.d.ts +44 -42
  50. package/dist/index.js +39 -37
  51. package/dist/lint.js +8 -15
  52. package/dist/provider.d.ts +11 -11
  53. package/dist/provider.js +9 -9
  54. package/dist/public-schema-field-lint.d.ts +1 -1
  55. package/dist/recipes/gov-api.js +1 -1
  56. package/dist/runtime/auth-flow.d.ts +1 -1
  57. package/dist/runtime/auth-flow.js +3 -3
  58. package/dist/runtime/browser.d.ts +1 -1
  59. package/dist/runtime/browser.js +15 -29
  60. package/dist/runtime/cache.d.ts +1 -1
  61. package/dist/runtime/cache.js +4 -8
  62. package/dist/runtime/choice.d.ts +1 -1
  63. package/dist/runtime/choice.js +31 -35
  64. package/dist/runtime/credential.d.ts +1 -1
  65. package/dist/runtime/credential.js +1 -1
  66. package/dist/runtime/env.d.ts +1 -1
  67. package/dist/runtime/executor.d.ts +1 -1
  68. package/dist/runtime/executor.js +15 -3
  69. package/dist/runtime/http.d.ts +2 -2
  70. package/dist/runtime/http.js +144 -348
  71. package/dist/runtime/insights.d.ts +1 -1
  72. package/dist/runtime/insights.js +6 -13
  73. package/dist/runtime/instrumentation.d.ts +2 -2
  74. package/dist/runtime/instrumentation.js +56 -19
  75. package/dist/runtime/keyring.js +1 -1
  76. package/dist/runtime/namespace.js +1 -1
  77. package/dist/runtime/otlp.d.ts +1 -1
  78. package/dist/runtime/perf.d.ts +1 -1
  79. package/dist/runtime/provider.d.ts +1 -1
  80. package/dist/runtime/provider.js +1 -2
  81. package/dist/runtime/proxy-errors.d.ts +1 -1
  82. package/dist/runtime/proxy-errors.js +9 -7
  83. package/dist/runtime/proxy-nodemaven.d.ts +35 -0
  84. package/dist/runtime/proxy-nodemaven.js +128 -0
  85. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  86. package/dist/runtime/proxy-retry-policy.js +326 -0
  87. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  88. package/dist/runtime/proxy-telemetry.js +55 -52
  89. package/dist/runtime/redis.d.ts +1 -1
  90. package/dist/runtime/redis.js +2 -4
  91. package/dist/runtime/request-options.d.ts +1 -1
  92. package/dist/runtime/secrets.d.ts +27 -0
  93. package/dist/runtime/secrets.js +51 -0
  94. package/dist/runtime/state.d.ts +2 -2
  95. package/dist/runtime/state.js +15 -4
  96. package/dist/runtime/stealth.d.ts +2 -2
  97. package/dist/runtime/stealth.js +108 -212
  98. package/dist/runtime/stt.d.ts +1 -1
  99. package/dist/runtime/stt.js +11 -15
  100. package/dist/runtime/trace.d.ts +2 -2
  101. package/dist/runtime/trace.js +2 -4
  102. package/dist/runtime/waterfall.d.ts +1 -1
  103. package/dist/schema.d.ts +1 -1
  104. package/dist/schema.js +7 -15
  105. package/dist/serve.d.ts +1 -1
  106. package/dist/serve.js +1 -1
  107. package/dist/server/index.d.ts +7 -7
  108. package/dist/server/index.js +6 -6
  109. package/dist/server/self-test-redaction.d.ts +1 -1
  110. package/dist/server/self-test-redaction.js +1 -1
  111. package/dist/server/self-test.d.ts +104 -3
  112. package/dist/server/self-test.js +673 -115
  113. package/dist/server/serve.d.ts +105 -4
  114. package/dist/server/serve.js +588 -80
  115. package/dist/server/types.d.ts +10 -9
  116. package/dist/server/types.js +3 -7
  117. package/dist/stateful/errors.d.ts +14 -0
  118. package/dist/stateful/errors.js +14 -0
  119. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  120. package/dist/stateful/http-provider-event-emitter.js +237 -0
  121. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  122. package/dist/stateful/http-session-owner-registry.js +210 -0
  123. package/dist/stateful/index.d.ts +18 -0
  124. package/dist/stateful/index.js +18 -0
  125. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  126. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  127. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  128. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  129. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  130. package/dist/stateful/provider-event-pipeline.js +1 -0
  131. package/dist/stateful/provider-events.d.ts +101 -0
  132. package/dist/stateful/provider-events.js +289 -0
  133. package/dist/stateful/session-key.d.ts +15 -0
  134. package/dist/stateful/session-key.js +86 -0
  135. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  136. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  137. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  138. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  139. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  140. package/dist/stateful/stateful-provider-adapter.js +287 -0
  141. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  142. package/dist/stateful/stateful-provider-observability.js +161 -0
  143. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  144. package/dist/stateful/stateful-provider-owner-forwarder.js +207 -0
  145. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  146. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  147. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  148. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  149. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  150. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  151. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  152. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  153. package/dist/stateful-signing.d.ts +18 -0
  154. package/dist/stateful-signing.js +27 -0
  155. package/dist/stealth/profiles.d.ts +1 -1
  156. package/dist/stealth/profiles.js +5 -14
  157. package/dist/stream.d.ts +1 -1
  158. package/dist/testing/index.d.ts +2 -2
  159. package/dist/testing/index.js +2 -2
  160. package/dist/testing/run.d.ts +1 -1
  161. package/dist/testing/run.js +7 -14
  162. package/dist/types.d.ts +87 -1
  163. package/dist/user-input.d.ts +30 -0
  164. package/dist/user-input.js +66 -0
  165. package/package.json +14 -5
  166. package/src/auth-turn/index.ts +1 -1
  167. package/src/auth.ts +28 -86
  168. package/src/ceremonies/index.ts +24 -78
  169. package/src/cli/commands.ts +10 -0
  170. package/src/cli/create.ts +42 -35
  171. package/src/cli/prompt-assets.ts +865 -0
  172. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  173. package/src/config/loader.ts +543 -208
  174. package/src/contract-serialization.ts +5 -11
  175. package/src/contract-types.ts +2 -2
  176. package/src/contract.ts +12 -28
  177. package/src/define.ts +247 -505
  178. package/src/dev.ts +4 -9
  179. package/src/errors.ts +58 -5
  180. package/src/i18n/catalog.ts +10 -32
  181. package/src/i18n/index.ts +2 -2
  182. package/src/i18n/keys.ts +5 -11
  183. package/src/index.ts +49 -42
  184. package/src/lint.ts +89 -159
  185. package/src/provider.ts +15 -11
  186. package/src/public-schema-field-lint.ts +7 -33
  187. package/src/recipes/gov-api.ts +2 -5
  188. package/src/runtime/auth-flow.ts +5 -7
  189. package/src/runtime/browser.ts +64 -187
  190. package/src/runtime/cache.ts +20 -67
  191. package/src/runtime/choice.ts +79 -132
  192. package/src/runtime/credential.ts +2 -2
  193. package/src/runtime/env.ts +1 -1
  194. package/src/runtime/executor.ts +23 -20
  195. package/src/runtime/http.ts +195 -559
  196. package/src/runtime/insights.ts +15 -53
  197. package/src/runtime/instrumentation.ts +78 -65
  198. package/src/runtime/keyring.ts +7 -19
  199. package/src/runtime/namespace.ts +2 -7
  200. package/src/runtime/otlp.ts +12 -23
  201. package/src/runtime/perf.ts +1 -1
  202. package/src/runtime/provider.ts +4 -9
  203. package/src/runtime/proxy-errors.ts +29 -42
  204. package/src/runtime/proxy-nodemaven.ts +178 -0
  205. package/src/runtime/proxy-retry-policy.ts +469 -0
  206. package/src/runtime/proxy-telemetry.ts +79 -77
  207. package/src/runtime/redis.ts +4 -12
  208. package/src/runtime/request-options.ts +4 -13
  209. package/src/runtime/secrets.ts +64 -0
  210. package/src/runtime/state.ts +41 -110
  211. package/src/runtime/stealth.ts +162 -377
  212. package/src/runtime/stt.ts +38 -94
  213. package/src/runtime/trace.ts +14 -44
  214. package/src/runtime/waterfall.ts +5 -18
  215. package/src/schema.ts +23 -84
  216. package/src/serve.ts +1 -1
  217. package/src/server/index.ts +24 -7
  218. package/src/server/self-test-redaction.ts +2 -2
  219. package/src/server/self-test.ts +857 -132
  220. package/src/server/serve.ts +859 -266
  221. package/src/server/types.ts +7 -19
  222. package/src/stateful/README.md +146 -0
  223. package/src/stateful/errors.ts +23 -0
  224. package/src/stateful/http-provider-event-emitter.ts +314 -0
  225. package/src/stateful/http-session-owner-registry.ts +306 -0
  226. package/src/stateful/index.ts +18 -0
  227. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  228. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  229. package/src/stateful/provider-event-pipeline.ts +61 -0
  230. package/src/stateful/provider-events.ts +462 -0
  231. package/src/stateful/session-key.ts +111 -0
  232. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  233. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  234. package/src/stateful/stateful-provider-adapter.ts +562 -0
  235. package/src/stateful/stateful-provider-observability.ts +261 -0
  236. package/src/stateful/stateful-provider-owner-forwarder.ts +279 -0
  237. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  238. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  239. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  240. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  241. package/src/stateful-signing.ts +46 -0
  242. package/src/stealth/profiles.ts +10 -26
  243. package/src/stream.ts +8 -19
  244. package/src/testing/index.ts +2 -2
  245. package/src/testing/run.ts +19 -67
  246. package/src/types.ts +88 -1
  247. package/src/user-input.ts +118 -0
  248. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  249. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  250. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  251. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  252. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  253. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  254. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  255. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  256. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  257. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  258. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  259. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  260. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  261. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -3,62 +3,72 @@ import { join } from "node:path";
3
3
 
4
4
  import { Hono } from "hono";
5
5
  import { z } from "zod";
6
- import { AuthAbortError, createAuthFlowHelpers } from "../auth";
6
+ import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
7
7
  import {
8
8
  AuthError,
9
+ isProviderError,
10
+ isSessionExpiredError,
11
+ isTransportError,
9
12
  ProviderError,
10
- SessionExpiredError,
11
- TransportError,
12
- } from "../errors";
13
+ } from "../errors.js";
13
14
  import {
14
15
  loadProviderLocaleCatalogs,
15
16
  localizeAuthTurn,
16
17
  type ProviderLocaleCatalogMap,
17
- } from "../i18n/catalog";
18
- import type { ProviderLocale } from "../i18n/keys";
18
+ } from "../i18n/catalog.js";
19
+ import type { ProviderLocale } from "../i18n/keys.js";
19
20
  import {
20
21
  categoryForStatus,
21
22
  isRetryableCategory,
22
23
  PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
23
24
  type ProviderErrorCategory,
24
- } from "../observability";
25
- import { createScratchpad } from "../runtime/auth-flow";
26
- import { createBrowserClient } from "../runtime/browser";
27
- import { createProviderCache } from "../runtime/cache";
25
+ } from "../observability.js";
26
+ import { createScratchpad } from "../runtime/auth-flow.js";
27
+ import { createBrowserClient } from "../runtime/browser.js";
28
+ import { createProviderCache } from "../runtime/cache.js";
28
29
  import {
29
30
  createProviderChoiceContext,
30
31
  PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
31
- } from "../runtime/choice";
32
- import { createCredentialContext } from "../runtime/credential";
33
- import { createEnvContext } from "../runtime/env";
34
- import { executeOperation } from "../runtime/executor";
35
- import { createHttpClient } from "../runtime/http";
36
- import { wrapWithInstrumentation } from "../runtime/instrumentation";
37
- import { getProviderBaseUrl } from "../runtime/provider";
32
+ } from "../runtime/choice.js";
33
+ import { createCredentialContext } from "../runtime/credential.js";
34
+ import { createEnvContext } from "../runtime/env.js";
35
+ import { executeOperation } from "../runtime/executor.js";
36
+ import { createHttpClient } from "../runtime/http.js";
37
+ import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
38
+ import { getProviderBaseUrl } from "../runtime/provider.js";
38
39
  import {
39
40
  PROXY_AUTH_IP_DENIED_CODE,
40
41
  PROXY_EDGE_AUTH_REJECTED_CODE,
41
42
  PROXY_POOL_EXHAUSTED_CODE,
42
- } from "../runtime/proxy-errors";
43
+ } from "../runtime/proxy-errors.js";
44
+ import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector } from "../runtime/proxy-telemetry.js";
43
45
  import {
44
- PROVIDER_TELEMETRY_HEADER,
45
- ProxyTelemetryCollector,
46
- } from "../runtime/proxy-telemetry";
46
+ assertRequiredSecretsPresent,
47
+ listMissingRequiredSecrets,
48
+ MISSING_SECRET_CODE,
49
+ } from "../runtime/secrets.js";
47
50
  import {
48
51
  createProviderRuntimeStateFromEnv,
49
52
  createUnsupportedProviderRuntimeState,
50
- } from "../runtime/state";
51
- import { createStealthClient } from "../runtime/stealth";
52
- import { createSttClientFromEnv } from "../runtime/stt";
53
- import { createTraceContext } from "../runtime/trace";
54
- import { parseSchema } from "../schema";
55
- import { getStealthProfile } from "../stealth/profiles";
53
+ } from "../runtime/state.js";
54
+ import { createStealthClient } from "../runtime/stealth.js";
55
+ import { createSttClientFromEnv } from "../runtime/stt.js";
56
+ import { createTraceContext } from "../runtime/trace.js";
57
+ import { parseSchema } from "../schema.js";
58
+ import {
59
+ STATEFUL_NONCE_HEADER as STATEFUL_FORWARDING_NONCE_HEADER,
60
+ STATEFUL_SIGNATURE_HEADER as STATEFUL_FORWARDING_SIGNATURE_HEADER,
61
+ STATEFUL_TIMESTAMP_HEADER as STATEFUL_FORWARDING_TIMESTAMP_HEADER,
62
+ verifyStatefulRequestSignature,
63
+ } from "../stateful-signing.js";
64
+ import { StatefulRoutingDeadlineError } from "../stateful/stateful-provider-session-routing.js";
65
+ import { getStealthProfile } from "../stealth/profiles.js";
56
66
  import {
57
67
  APIFUSE_STREAM_DONE_EVENT,
58
68
  APIFUSE_STREAM_ERROR_EVENT,
59
69
  encodeSseEvent,
60
70
  error as streamError,
61
- } from "../stream";
71
+ } from "../stream.js";
62
72
  import type {
63
73
  AuthContext,
64
74
  AuthTurn,
@@ -75,29 +85,98 @@ import type {
75
85
  ProviderStreamEvent,
76
86
  StealthClient,
77
87
  SttContext,
78
- } from "../types";
88
+ } from "../types.js";
79
89
  import {
80
90
  createSelfTestApp,
91
+ createSelfTestAuthFlowInvoke,
81
92
  createSelfTestInvoke,
82
93
  resolveSelfTestPort,
83
- } from "./self-test";
84
- import { resolveSelfTestMasterSecrets } from "./self-test-token";
94
+ } from "./self-test.js";
95
+ import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
85
96
  import {
86
97
  type AuthFlowRequest,
87
98
  AuthFlowRequestSchema,
88
99
  type AuthFlowResponse,
89
100
  type AuthFlowSuccessResponse,
101
+ OperationConnectionSchema,
90
102
  type OperationErrorResponse,
91
103
  type OperationRequest,
92
104
  OperationRequestSchema,
93
105
  type OperationResponse,
94
106
  type OperationSuccessResponse,
95
- } from "./types";
107
+ } from "./types.js";
96
108
 
97
109
  const DEFAULT_HOST = "0.0.0.0";
98
110
  const DEFAULT_PORT = 3000;
99
111
  const AUTH_FLOW_LOCALES = ["en", "ko", "ja"] as const;
100
112
  const retryResponseMeta = new WeakMap<ProviderContext, HttpRetrySummary>();
113
+ const STATEFUL_INTERNAL_OPERATIONS_ROUTE = "/__apifuse/stateful/operations";
114
+ const STATEFUL_FORWARDING_SOURCE_POD_HEADER = "x-apifuse-stateful-source-pod";
115
+ const DEFAULT_STATEFUL_FORWARDING_MAX_SKEW_MS = 5 * 60_000;
116
+ const DEFAULT_STATEFUL_FORWARDING_REPLAY_CACHE_MAX_ENTRIES = 10_000;
117
+ const STATEFUL_FORWARDING_REPLAY_BUCKET_MS = 10_000;
118
+ const STATEFUL_FORWARDING_REPLAY_RETRY_AFTER_SECONDS = Math.ceil(
119
+ STATEFUL_FORWARDING_REPLAY_BUCKET_MS / 1_000,
120
+ );
121
+
122
+ export const ProviderServerStatefulForwardEnvelopeSchema = z
123
+ .object({
124
+ requestId: z.string().min(1),
125
+ providerId: z.string().min(1),
126
+ operationId: z.string().min(1),
127
+ sessionKey: z.string().min(1),
128
+ connectionId: z.string().min(1),
129
+ serviceAccountId: z.string().min(1),
130
+ ownerPodId: z.string().min(1),
131
+ generation: z.number().int().positive(),
132
+ sourcePodId: z.string().min(1),
133
+ forwardedAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
134
+ deadlineAt: z
135
+ .string()
136
+ .refine((value) => Number.isFinite(Date.parse(value)))
137
+ .optional(),
138
+ idempotencyKey: z.string().min(1).optional(),
139
+ operationRequest: OperationRequestSchema.extend({
140
+ connection: OperationConnectionSchema.strict().optional(),
141
+ }).strict(),
142
+ })
143
+ .strict();
144
+
145
+ export type ProviderServerStatefulForwardEnvelope = Readonly<
146
+ z.infer<typeof ProviderServerStatefulForwardEnvelopeSchema>
147
+ >;
148
+
149
+ export type ProviderServerStatefulOwnerFence = Readonly<
150
+ Pick<
151
+ ProviderServerStatefulForwardEnvelope,
152
+ | "providerId"
153
+ | "sessionKey"
154
+ | "ownerPodId"
155
+ | "generation"
156
+ | "sourcePodId"
157
+ | "forwardedAt"
158
+ | "requestId"
159
+ | "idempotencyKey"
160
+ >
161
+ >;
162
+
163
+ export type ProviderServerStatefulOwnerFenceValidator = (
164
+ fence: ProviderServerStatefulOwnerFence,
165
+ signal: AbortSignal,
166
+ ) => boolean | Promise<boolean>;
167
+
168
+ export type ProviderServerOperationExecutorInput = {
169
+ readonly provider: ProviderDefinition;
170
+ readonly operationId: string;
171
+ readonly ctx: ProviderContext;
172
+ readonly request: OperationRequest & { readonly deadlineAt?: string };
173
+ readonly signal?: AbortSignal;
174
+ readonly internalStatefulForward?: ProviderServerStatefulForwardEnvelope;
175
+ };
176
+
177
+ export type ProviderServerOperationExecutor = (
178
+ input: ProviderServerOperationExecutorInput,
179
+ ) => Promise<unknown>;
101
180
 
102
181
  type RequestCleanup = () => void | Promise<void>;
103
182
 
@@ -156,9 +235,7 @@ function createStealthStub(): StealthClient {
156
235
  };
157
236
  }
158
237
 
159
- function getProviderStealthBaseUrl(
160
- provider: ProviderDefinition,
161
- ): string | undefined {
238
+ function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined {
162
239
  const baseUrl = getProviderBaseUrl(provider);
163
240
  if (baseUrl) {
164
241
  return baseUrl;
@@ -168,15 +245,10 @@ function getProviderStealthBaseUrl(
168
245
  }
169
246
 
170
247
  function getProviderStealthProfile(provider: ProviderDefinition) {
171
- return provider.stealth?.profile
172
- ? getStealthProfile(provider.stealth.profile)
173
- : undefined;
248
+ return provider.stealth?.profile ? getStealthProfile(provider.stealth.profile) : undefined;
174
249
  }
175
250
 
176
- function isProductionProviderBrowserMode(
177
- provider: ProviderDefinition,
178
- env = process.env,
179
- ): boolean {
251
+ function isProductionProviderBrowserMode(provider: ProviderDefinition, env = process.env): boolean {
180
252
  if (provider.runtime !== "browser") {
181
253
  return false;
182
254
  }
@@ -185,9 +257,7 @@ function isProductionProviderBrowserMode(
185
257
  return true;
186
258
  }
187
259
 
188
- return (
189
- env.NODE_ENV === "production" && env.APIFUSE__PROVIDER__ID === provider.id
190
- );
260
+ return env.NODE_ENV === "production" && env.APIFUSE__PROVIDER__ID === provider.id;
191
261
  }
192
262
 
193
263
  export function resolveProviderProxyAffinityKey(
@@ -195,18 +265,19 @@ export function resolveProviderProxyAffinityKey(
195
265
  request: OperationRequest,
196
266
  operationId: string,
197
267
  ): string {
198
- const connectionKey =
199
- request.connection?.id ?? request.connection?.externalRef;
268
+ const connectionKey = resolveOperationConnectionId(request) ?? request.connection?.externalRef;
200
269
  const affinity =
201
- typeof provider.proxy === "object"
202
- ? provider.proxy.session?.affinity
203
- : undefined;
270
+ typeof provider.proxy === "object" ? provider.proxy.session?.affinity : undefined;
204
271
  if (affinity === "operation") {
205
272
  return `${provider.id}/${operationId}`;
206
273
  }
207
274
  return connectionKey ?? provider.id;
208
275
  }
209
276
 
277
+ function resolveOperationConnectionId(request: OperationRequest): string | undefined {
278
+ return request.connection?.id ?? request.connectionId;
279
+ }
280
+
210
281
  function createProviderContext(
211
282
  provider: ProviderDefinition,
212
283
  request: OperationRequest,
@@ -220,11 +291,7 @@ function createProviderContext(
220
291
  const stealthProfile = getProviderStealthProfile(provider);
221
292
  const proxyClientOptions = {
222
293
  upstream: { proxy: provider.proxy },
223
- affinityKey: resolveProviderProxyAffinityKey(
224
- provider,
225
- request,
226
- operationId,
227
- ),
294
+ affinityKey: resolveProviderProxyAffinityKey(provider, request, operationId),
228
295
  telemetry: proxyTelemetry,
229
296
  };
230
297
  let wrappedContext: ProviderContext | undefined;
@@ -245,7 +312,7 @@ function createProviderContext(
245
312
  values: request.connection?.secrets,
246
313
  });
247
314
  const requestContext = {
248
- connectionId: request.connection?.id,
315
+ connectionId: resolveOperationConnectionId(request),
249
316
  headers: request.headers ?? {},
250
317
  };
251
318
  const context = wrapWithInstrumentation({
@@ -263,11 +330,7 @@ function createProviderContext(
263
330
  state,
264
331
  stealth: stealthBaseUrl
265
332
  ? stealthProfile
266
- ? createStealthClient(
267
- stealthBaseUrl,
268
- stealthProfile.name,
269
- stealthClientOptions,
270
- )
333
+ ? createStealthClient(stealthBaseUrl, stealthProfile.name, stealthClientOptions)
271
334
  : createStealthClient(stealthBaseUrl, stealthClientOptions)
272
335
  : createStealthStub(),
273
336
  browser:
@@ -380,11 +443,7 @@ function createAuthFlowContext(
380
443
  http: createHttpClient(baseUrl, proxyClientOptions),
381
444
  stealth: stealthBaseUrl
382
445
  ? stealthProfile
383
- ? createStealthClient(
384
- stealthBaseUrl,
385
- stealthProfile.name,
386
- stealthClientOptions,
387
- )
446
+ ? createStealthClient(stealthBaseUrl, stealthProfile.name, stealthClientOptions)
388
447
  : createStealthClient(stealthBaseUrl, stealthClientOptions)
389
448
  : createStealthStub(),
390
449
  env: createEnvContext(provider.secrets?.map((secret) => secret.name)),
@@ -429,6 +488,12 @@ export type ProviderServerLogEvent =
429
488
  retryable?: boolean;
430
489
  issues?: Array<{ path: string; code: string; message: string }>;
431
490
  })
491
+ | {
492
+ level: "warn";
493
+ event: "provider_secrets_missing";
494
+ providerId: string;
495
+ missingSecrets: string[];
496
+ }
432
497
  | {
433
498
  level: "warn";
434
499
  event: "provider_cleanup_failed";
@@ -439,18 +504,59 @@ export type ProviderServerLogEvent =
439
504
  resource: "browser" | "stealth";
440
505
  errorClass: string;
441
506
  message: string;
507
+ }
508
+ | {
509
+ level: "error";
510
+ event: "provider_shutdown_hook_failed";
511
+ providerId: string;
512
+ hookIndex: number;
513
+ errorClass: string;
514
+ message: string;
442
515
  };
443
516
 
444
517
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
445
518
 
446
519
  export type ProviderServerOptions = {
447
520
  logger?: ProviderServerLogger;
521
+ /** Optional provider-specific operation executor. Stateful providers use this to preserve provider-local runtime semantics. */
522
+ operationExecutor?: ProviderServerOperationExecutor;
523
+ /** Optional signed internal executor for stateful owner forwarding. */
524
+ internalOperationExecutor?: ProviderServerOperationExecutor;
525
+ statefulForwarding?: {
526
+ readonly secret: string;
527
+ readonly maxSkewMs?: number;
528
+ readonly replayCacheMaxEntries?: number;
529
+ /** Required fail-closed check against the SDK/runtime owner registry. */
530
+ readonly validateOwnerFence: ProviderServerStatefulOwnerFenceValidator;
531
+ };
448
532
  /** Optional STT override for tests or custom hosts; local/prod normally resolves from env. */
449
533
  stt?: SttContext;
450
534
  /** Optional runtime state override for tests or custom hosts. Production resolves Redis from env and fails closed when unavailable. */
451
535
  state?: ProviderRuntimeState;
452
536
  /** Allow process-local runtime state only for local development and tests. */
453
537
  allowMemoryStateFallback?: boolean;
538
+ /**
539
+ * Graceful process shutdown. Hooks run in declaration order after listeners stop accepting work.
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * await serve(provider, {
544
+ * shutdown: {
545
+ * hooks: [
546
+ * async () => { await emitter.flush(); },
547
+ * async () => { await sessionManager.closeAll("server-shutdown"); },
548
+ * async () => { await lease.release(); },
549
+ * async () => { await router.close(); },
550
+ * ],
551
+ * },
552
+ * });
553
+ * ```
554
+ */
555
+ shutdown?: {
556
+ readonly hooks?: Array<() => Promise<void>>;
557
+ readonly signals?: boolean | NodeJS.Signals[];
558
+ readonly timeoutMs?: number;
559
+ };
454
560
  };
455
561
 
456
562
  const defaultProviderServerLogger: ProviderServerLogger = (event) => {
@@ -497,11 +603,19 @@ function zodDetails(error: z.ZodError): Array<{
497
603
  }));
498
604
  }
499
605
 
500
- function toErrorResponse(
501
- error: unknown,
502
- requestId?: string,
503
- ): OperationErrorResponse {
504
- if (error instanceof ProviderError) {
606
+ function toErrorResponse(error: unknown, requestId?: string): OperationErrorResponse {
607
+ if (error instanceof StatefulRoutingDeadlineError) {
608
+ return {
609
+ error: {
610
+ code: "STATEFUL_FORWARDING_DEADLINE_EXPIRED",
611
+ message: "Stateful forwarding deadline expired.",
612
+ ...(requestId ? { requestId } : {}),
613
+ details: { retryable: false },
614
+ },
615
+ };
616
+ }
617
+
618
+ if (isProviderError(error)) {
505
619
  const details = publicProviderErrorDetails(error);
506
620
  return {
507
621
  error: {
@@ -525,11 +639,22 @@ function toErrorResponse(
525
639
  };
526
640
  }
527
641
 
642
+ // A masked internal error MUST NOT be advertised as retryable: without an
643
+ // explicit retryable:false the hub (bori provider-backed engine) defaults 5xx
644
+ // to retryable:true, which turns a deterministic pre-upstream crash into an
645
+ // infinite START->CONTINUE->restart loop (2026-07-22 catchtable reserve RCA).
646
+ // We still refuse to leak message/stack — only the error class name (or the
647
+ // primitive type for non-Error throwables) is surfaced for ops triage.
528
648
  return {
529
649
  error: {
530
650
  code: "internal_error",
531
651
  message: "Internal error",
532
652
  ...(requestId ? { requestId } : {}),
653
+ details: {
654
+ retryable: false,
655
+ category: "internal_error",
656
+ errorClass: error instanceof Error ? error.name : typeof error,
657
+ },
533
658
  },
534
659
  };
535
660
  }
@@ -557,7 +682,12 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
557
682
  return value !== null && typeof value === "object" && !Array.isArray(value);
558
683
  }
559
684
 
560
- function providerObservabilityDetails(error: ProviderError):
685
+ // Accepts `unknown` so the branded guards narrow cleanly from the top: the
686
+ // subtype error classes are structurally compatible with ProviderError, so
687
+ // narrowing from a ProviderError-typed value would collapse the negative branch
688
+ // to `never`. Narrowing from unknown avoids that while still recognizing errors
689
+ // from a duplicate SDK module instance.
690
+ function providerObservabilityDetails(error: unknown):
561
691
  | {
562
692
  category: ProviderErrorCategory;
563
693
  taxonomyVersion: string;
@@ -570,14 +700,26 @@ function providerObservabilityDetails(error: ProviderError):
570
700
  // operation (see design.md §4.3 D3). Without this branch the auth error would
571
701
  // serialize as a bare 401 with no retryable/category, losing the refresh
572
702
  // signal for exactly the retryOnAuthRefresh operations it is meant to enable.
573
- if (error instanceof SessionExpiredError) {
703
+ if (isSessionExpiredError(error)) {
574
704
  return {
575
705
  category: error.options?.category ?? "credential_expired",
576
706
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
577
707
  retryable: error.options?.retryable ?? false,
578
708
  };
579
709
  }
580
- if (!(error instanceof TransportError)) {
710
+ // Missing-secret errors carry the canonical credential_unavailable category
711
+ // so Gateway/observability can attribute the failure to provisioning, not
712
+ // the upstream. Matched by code (not constructor) so both the SDK-owned
713
+ // runtime gate and any not-yet-migrated provider-thrown MISSING_SECRET
714
+ // serialize identically, including across duplicate SDK module instances.
715
+ if (isProviderError(error) && error.code === MISSING_SECRET_CODE) {
716
+ return {
717
+ category: error.options?.category ?? "credential_unavailable",
718
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
719
+ retryable: error.options?.retryable ?? false,
720
+ };
721
+ }
722
+ if (!isTransportError(error)) {
581
723
  return undefined;
582
724
  }
583
725
  const isProxyPoolCode =
@@ -610,7 +752,7 @@ function providerObservabilityDetails(error: ProviderError):
610
752
  }
611
753
 
612
754
  function publicProviderErrorMessage(error: ProviderError): string {
613
- if (error instanceof TransportError) {
755
+ if (isTransportError(error)) {
614
756
  if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
615
757
  return error.message;
616
758
  }
@@ -633,22 +775,27 @@ function publicProviderErrorMessage(error: ProviderError): string {
633
775
  return error.message;
634
776
  }
635
777
 
636
- function toStatusCode(
637
- error: unknown,
638
- ): 400 | 401 | 404 | 429 | 500 | 502 | 503 | 504 {
778
+ function toStatusCode(error: unknown): 400 | 401 | 404 | 429 | 500 | 502 | 503 | 504 {
639
779
  if (error instanceof z.ZodError) {
640
780
  return 400;
641
781
  }
782
+ if (error instanceof StatefulRoutingDeadlineError) {
783
+ return 504;
784
+ }
642
785
 
643
- if (error instanceof TransportError) {
786
+ if (isTransportError(error)) {
644
787
  return error.code === "transport_timeout" ? 504 : 502;
645
788
  }
646
789
 
647
- if (error instanceof ProviderError) {
790
+ if (isProviderError(error)) {
648
791
  switch (error.code) {
649
792
  case "AUTH_REQUIRED":
650
793
  case "reauth_required":
651
794
  return 401;
795
+ // Unprovisioned declared secret: a deployment/config defect, never an
796
+ // upstream failure — explicit 400 (was only reached via fallthrough).
797
+ case MISSING_SECRET_CODE:
798
+ return 400;
652
799
  case "NOT_FOUND":
653
800
  case "not_found":
654
801
  case "NO_DATA":
@@ -662,6 +809,7 @@ function toStatusCode(
662
809
  return 502;
663
810
  case "STT_UNAVAILABLE":
664
811
  case "UNSUPPORTED_STT_BACKEND":
812
+ case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
665
813
  return 503;
666
814
  }
667
815
 
@@ -690,20 +838,17 @@ function logProviderError(
690
838
  status: number,
691
839
  cost: ProviderRequestCost,
692
840
  ): void {
693
- const code =
694
- error instanceof ProviderError
695
- ? (error.code ?? "provider_error")
696
- : error instanceof z.ZodError
697
- ? "invalid_request"
841
+ const code = isProviderError(error)
842
+ ? (error.code ?? "provider_error")
843
+ : error instanceof z.ZodError
844
+ ? "invalid_request"
845
+ : error instanceof StatefulRoutingDeadlineError
846
+ ? "STATEFUL_FORWARDING_DEADLINE_EXPIRED"
698
847
  : "internal_error";
699
848
  const errorClass = error instanceof Error ? error.name : typeof error;
700
849
  const message = error instanceof Error ? error.message : String(error);
701
- const details =
702
- error instanceof ProviderError
703
- ? providerObservabilityDetails(error)
704
- : undefined;
705
- const emit =
706
- typeof logger === "function" ? logger : defaultProviderServerLogger;
850
+ const details = isProviderError(error) ? providerObservabilityDetails(error) : undefined;
851
+ const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
707
852
  emit({
708
853
  level: status >= 500 ? "error" : "warn",
709
854
  event: "provider_request_failed",
@@ -716,7 +861,7 @@ function logProviderError(
716
861
  code,
717
862
  errorClass,
718
863
  message,
719
- ...(error instanceof TransportError && error.upstreamStatus
864
+ ...(isTransportError(error) && error.upstreamStatus
720
865
  ? { upstreamStatus: error.upstreamStatus }
721
866
  : {}),
722
867
  ...(details
@@ -738,8 +883,7 @@ function logProviderCleanupError(
738
883
  resource: "browser" | "stealth",
739
884
  error: unknown,
740
885
  ): void {
741
- const emit =
742
- typeof logger === "function" ? logger : defaultProviderServerLogger;
886
+ const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
743
887
  const errorClass = error instanceof Error ? error.name : typeof error;
744
888
  const message = error instanceof Error ? error.message : String(error);
745
889
  emit({
@@ -764,8 +908,7 @@ function logProviderSuccess(
764
908
  status: number,
765
909
  cost: ProviderRequestCost,
766
910
  ): void {
767
- const emit =
768
- typeof logger === "function" ? logger : defaultProviderServerLogger;
911
+ const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
769
912
  emit({
770
913
  level: "info",
771
914
  event: "provider_request_completed",
@@ -811,18 +954,13 @@ function toJsonSuccessResponse(
811
954
  };
812
955
  }
813
956
 
814
- function isAsyncIterable<T = unknown>(
815
- value: unknown,
816
- ): value is AsyncIterable<T> {
957
+ function isAsyncIterable<T = unknown>(value: unknown): value is AsyncIterable<T> {
817
958
  if (!value || typeof value !== "object") return false;
818
959
  const iterator = Reflect.get(value, Symbol.asyncIterator);
819
960
  return typeof iterator === "function";
820
961
  }
821
962
 
822
- function responseWithCleanup(
823
- response: Response,
824
- cleanup: RequestCleanup,
825
- ): Response {
963
+ function responseWithCleanup(response: Response, cleanup: RequestCleanup): Response {
826
964
  if (!response.body) {
827
965
  void cleanup();
828
966
  return response;
@@ -871,10 +1009,7 @@ async function validateSseEvent(
871
1009
  const transport = getSseTransport(operation);
872
1010
  const schema = transport?.events?.[event.event];
873
1011
  if (!schema) {
874
- if (
875
- event.event === APIFUSE_STREAM_ERROR_EVENT ||
876
- event.event === APIFUSE_STREAM_DONE_EVENT
877
- ) {
1012
+ if (event.event === APIFUSE_STREAM_ERROR_EVENT || event.event === APIFUSE_STREAM_DONE_EVENT) {
878
1013
  return event;
879
1014
  }
880
1015
  throw new ProviderError(
@@ -887,11 +1022,7 @@ async function validateSseEvent(
887
1022
  },
888
1023
  );
889
1024
  }
890
- const data = await parseSchema(
891
- schema,
892
- event.data,
893
- `transport.events.${event.event}`,
894
- );
1025
+ const data = await parseSchema(schema, event.data, `transport.events.${event.event}`);
895
1026
  return { ...event, data };
896
1027
  }
897
1028
 
@@ -911,8 +1042,7 @@ function assertStreamPayloadWithinLimit(
911
1042
  throw new ProviderError(
912
1043
  `Stream ${kind} exceeded declared byte limit (${actualBytes} > ${maxBytes}).`,
913
1044
  {
914
- code:
915
- kind === "event" ? "STREAM_EVENT_TOO_LARGE" : "STREAM_CHUNK_TOO_LARGE",
1045
+ code: kind === "event" ? "STREAM_EVENT_TOO_LARGE" : "STREAM_CHUNK_TOO_LARGE",
916
1046
  retryable: false,
917
1047
  category: "input_validation",
918
1048
  fix:
@@ -957,15 +1087,10 @@ function toSseResponse(
957
1087
  const validated = await validateSseEvent(operation, next.value);
958
1088
  const encodedEvent = encodeSseEvent(validated);
959
1089
  const encodedBytes = encoder.encode(encodedEvent);
960
- assertStreamPayloadWithinLimit(
961
- encodedBytes.byteLength,
962
- transport?.maxEventBytes,
963
- "event",
964
- );
1090
+ assertStreamPayloadWithinLimit(encodedBytes.byteLength, transport?.maxEventBytes, "event");
965
1091
  controller.enqueue(encodedBytes);
966
1092
  } catch (error) {
967
- const message =
968
- error instanceof Error ? error.message : "Stream failed";
1093
+ const message = error instanceof Error ? error.message : "Stream failed";
969
1094
  controller.enqueue(
970
1095
  encoder.encode(
971
1096
  encodeSseEvent(
@@ -1012,11 +1137,7 @@ function enforceStreamChunkLimit(
1012
1137
  return;
1013
1138
  }
1014
1139
  if (value) {
1015
- assertStreamPayloadWithinLimit(
1016
- byteLength(value),
1017
- maxChunkBytes,
1018
- "chunk",
1019
- );
1140
+ assertStreamPayloadWithinLimit(byteLength(value), maxChunkBytes, "chunk");
1020
1141
  controller.enqueue(value);
1021
1142
  }
1022
1143
  } catch (error) {
@@ -1036,10 +1157,7 @@ function toStreamingResponse(
1036
1157
  requestId?: string,
1037
1158
  ): Response {
1038
1159
  const transport = operation.transport?.kind ?? "json";
1039
- if (
1040
- transport === "sse" &&
1041
- (result instanceof Response || result instanceof ReadableStream)
1042
- ) {
1160
+ if (transport === "sse" && (result instanceof Response || result instanceof ReadableStream)) {
1043
1161
  void cleanup();
1044
1162
  throw new ProviderError(
1045
1163
  "SSE operations must return an AsyncIterable of typed stream.event(...) values.",
@@ -1053,20 +1171,13 @@ function toStreamingResponse(
1053
1171
  }
1054
1172
  if (result instanceof Response) {
1055
1173
  const httpTransport = getHttpStreamTransport(operation);
1056
- if (
1057
- httpTransport &&
1058
- result.body &&
1059
- httpTransport?.maxChunkBytes !== undefined
1060
- ) {
1174
+ if (httpTransport && result.body && httpTransport?.maxChunkBytes !== undefined) {
1061
1175
  return responseWithCleanup(
1062
- new Response(
1063
- enforceStreamChunkLimit(result.body, httpTransport.maxChunkBytes),
1064
- {
1065
- headers: result.headers,
1066
- status: result.status,
1067
- statusText: result.statusText,
1068
- },
1069
- ),
1176
+ new Response(enforceStreamChunkLimit(result.body, httpTransport.maxChunkBytes), {
1177
+ headers: result.headers,
1178
+ status: result.status,
1179
+ statusText: result.statusText,
1180
+ }),
1070
1181
  cleanup,
1071
1182
  );
1072
1183
  }
@@ -1086,8 +1197,7 @@ function toStreamingResponse(
1086
1197
  : {
1087
1198
  "Content-Type":
1088
1199
  operation.transport?.kind === "http-stream"
1089
- ? (operation.transport.contentType ??
1090
- "application/octet-stream")
1200
+ ? (operation.transport.contentType ?? "application/octet-stream")
1091
1201
  : "application/octet-stream",
1092
1202
  },
1093
1203
  }),
@@ -1107,18 +1217,14 @@ function toStreamingResponse(
1107
1217
  );
1108
1218
  }
1109
1219
 
1110
- function getSseTransport(
1111
- operation: OperationDefinition,
1112
- ): OperationSseTransport | undefined {
1220
+ function getSseTransport(operation: OperationDefinition): OperationSseTransport | undefined {
1113
1221
  return operation.transport?.kind === "sse" ? operation.transport : undefined;
1114
1222
  }
1115
1223
 
1116
1224
  function getHttpStreamTransport(
1117
1225
  operation: OperationDefinition,
1118
1226
  ): OperationHttpStreamTransport | undefined {
1119
- return operation.transport?.kind === "http-stream"
1120
- ? operation.transport
1121
- : undefined;
1227
+ return operation.transport?.kind === "http-stream" ? operation.transport : undefined;
1122
1228
  }
1123
1229
 
1124
1230
  function toAuthFlowResponse(
@@ -1139,9 +1245,7 @@ function toAuthFlowResponse(
1139
1245
  };
1140
1246
  }
1141
1247
 
1142
- function authFlowLocaleFromHeaders(
1143
- headers?: Record<string, string>,
1144
- ): ProviderLocale {
1248
+ function authFlowLocaleFromHeaders(headers?: Record<string, string>): ProviderLocale {
1145
1249
  const header = Object.entries(headers ?? {}).find(
1146
1250
  ([key]) => key.toLowerCase() === "accept-language",
1147
1251
  )?.[1];
@@ -1159,9 +1263,7 @@ function isAuthFlowLocale(value: string | undefined): value is ProviderLocale {
1159
1263
  }
1160
1264
 
1161
1265
  function isAuthTurn(value: unknown): value is AuthTurn {
1162
- return (
1163
- !!value && typeof value === "object" && "kind" in value && "turnId" in value
1164
- );
1266
+ return !!value && typeof value === "object" && "kind" in value && "turnId" in value;
1165
1267
  }
1166
1268
 
1167
1269
  function loadAuthFlowLocaleCatalogs(
@@ -1198,10 +1300,7 @@ function materializeAuthFlowTurn(
1198
1300
  });
1199
1301
  }
1200
1302
 
1201
- function withAuthRequestHeaders(
1202
- request: AuthFlowRequest,
1203
- headers: Headers,
1204
- ): AuthFlowRequest {
1303
+ function withAuthRequestHeaders(request: AuthFlowRequest, headers: Headers): AuthFlowRequest {
1205
1304
  return {
1206
1305
  ...request,
1207
1306
  headers: {
@@ -1219,17 +1318,9 @@ async function handleOperation(
1219
1318
  state: ProviderRuntimeState = createUnsupportedProviderRuntimeState(),
1220
1319
  proxyTelemetry?: ProxyTelemetryCollector,
1221
1320
  ): Promise<Response | OperationResponse> {
1222
- const ctx = createProviderContext(
1223
- provider,
1224
- request,
1225
- operationId,
1226
- options,
1227
- state,
1228
- proxyTelemetry,
1229
- );
1321
+ const ctx = createProviderContext(provider, request, operationId, options, state, proxyTelemetry);
1230
1322
  const operation = provider.operations[operationId];
1231
- const streaming =
1232
- operation?.transport?.kind && operation.transport.kind !== "json";
1323
+ const streaming = operation?.transport?.kind && operation.transport.kind !== "json";
1233
1324
  let cleanupCalled = false;
1234
1325
  const cleanup = async () => {
1235
1326
  if (cleanupCalled) return;
@@ -1260,12 +1351,14 @@ async function handleOperation(
1260
1351
  }
1261
1352
  };
1262
1353
  try {
1263
- const result = await executeOperation(
1264
- provider,
1265
- operationId,
1266
- ctx,
1267
- request.input,
1268
- );
1354
+ const result = options.operationExecutor
1355
+ ? await options.operationExecutor({
1356
+ provider,
1357
+ operationId,
1358
+ ctx,
1359
+ request,
1360
+ })
1361
+ : await executeOperation(provider, operationId, ctx, request.input);
1269
1362
  if (streaming && operation) {
1270
1363
  return toStreamingResponse(operation, result, cleanup, request.requestId);
1271
1364
  }
@@ -1309,13 +1402,16 @@ async function handleAuthFlow(
1309
1402
  });
1310
1403
  }
1311
1404
 
1312
- const { context, getPatch } = createAuthFlowContext(
1313
- provider,
1314
- request,
1315
- options,
1316
- signal,
1317
- );
1405
+ // Same SDK-owned gate as executeOperation: OAuth/credentials ceremonies
1406
+ // depend on declared secrets (client ids/secrets), so fail structured before
1407
+ // any flow code runs instead of at whatever point the ceremony first reads
1408
+ // the env. `abort` stays exempt: a user must always be able to cancel a
1409
+ // stranded flow even when provisioning is broken.
1410
+ const { context, getPatch } = createAuthFlowContext(provider, request, options, signal);
1318
1411
  try {
1412
+ if (route !== "abort") {
1413
+ assertRequiredSecretsPresent(provider, context.env);
1414
+ }
1319
1415
  const result =
1320
1416
  route === "start"
1321
1417
  ? await flow.start(context)
@@ -1357,12 +1453,142 @@ async function handleAuthFlow(
1357
1453
  }
1358
1454
  }
1359
1455
 
1456
+ class StatefulForwardingReplayCache {
1457
+ readonly #nonces = new Map<string, number>();
1458
+ readonly #expiryBuckets = new Map<number, Set<string>>();
1459
+ #nextExpiryBucket?: number;
1460
+ #latestExpiryBucket?: number;
1461
+
1462
+ constructor(private readonly maxEntries: number) {}
1463
+
1464
+ claim(nonce: string, expiresAtMs: number, nowMs: number): "accepted" | "replayed" | "full" {
1465
+ this.dropExpiredBuckets(nowMs);
1466
+ if (this.#nonces.has(nonce)) return "replayed";
1467
+ if (this.#nonces.size >= this.maxEntries) return "full";
1468
+ const expiryBucket =
1469
+ Math.ceil(expiresAtMs / STATEFUL_FORWARDING_REPLAY_BUCKET_MS) *
1470
+ STATEFUL_FORWARDING_REPLAY_BUCKET_MS;
1471
+ this.#nonces.set(nonce, expiryBucket);
1472
+ const bucket = this.#expiryBuckets.get(expiryBucket) ?? new Set<string>();
1473
+ bucket.add(nonce);
1474
+ this.#expiryBuckets.set(expiryBucket, bucket);
1475
+ this.#nextExpiryBucket = Math.min(this.#nextExpiryBucket ?? expiryBucket, expiryBucket);
1476
+ this.#latestExpiryBucket = Math.max(this.#latestExpiryBucket ?? expiryBucket, expiryBucket);
1477
+ return "accepted";
1478
+ }
1479
+
1480
+ private dropExpiredBuckets(nowMs: number): void {
1481
+ if (this.#nextExpiryBucket === undefined || this.#latestExpiryBucket === undefined) return;
1482
+ if (nowMs >= this.#latestExpiryBucket) {
1483
+ this.#nonces.clear();
1484
+ this.#expiryBuckets.clear();
1485
+ this.#nextExpiryBucket = undefined;
1486
+ this.#latestExpiryBucket = undefined;
1487
+ return;
1488
+ }
1489
+ while (this.#nextExpiryBucket <= nowMs) {
1490
+ const bucket = this.#expiryBuckets.get(this.#nextExpiryBucket);
1491
+ if (bucket) {
1492
+ for (const cachedNonce of bucket) this.#nonces.delete(cachedNonce);
1493
+ this.#expiryBuckets.delete(this.#nextExpiryBucket);
1494
+ }
1495
+ this.#nextExpiryBucket += STATEFUL_FORWARDING_REPLAY_BUCKET_MS;
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ function verifyStatefulForwardingRequest(input: {
1501
+ readonly options: ProviderServerOptions;
1502
+ readonly rawBody: string;
1503
+ readonly headers: Headers;
1504
+ readonly method: string;
1505
+ readonly path: string;
1506
+ readonly replayCache: StatefulForwardingReplayCache;
1507
+ }): void {
1508
+ const config = input.options.statefulForwarding;
1509
+ if (!config?.secret) {
1510
+ throw new ProviderError("Stateful forwarding is not configured.", {
1511
+ code: "STATEFUL_FORWARDING_NOT_CONFIGURED",
1512
+ });
1513
+ }
1514
+ const timestamp = input.headers.get(STATEFUL_FORWARDING_TIMESTAMP_HEADER) ?? "";
1515
+ const signature = input.headers.get(STATEFUL_FORWARDING_SIGNATURE_HEADER) ?? "";
1516
+ const nonce = input.headers.get(STATEFUL_FORWARDING_NONCE_HEADER) ?? "";
1517
+ if (!timestamp || !signature || !nonce) {
1518
+ throw new ProviderError("Stateful forwarding signature headers are missing.", {
1519
+ code: "STATEFUL_FORWARDING_SIGNATURE_MISSING",
1520
+ });
1521
+ }
1522
+ if (nonce.length > 256) {
1523
+ throw new ProviderError("Stateful forwarding nonce is invalid.", {
1524
+ code: "STATEFUL_FORWARDING_NONCE_INVALID",
1525
+ });
1526
+ }
1527
+ const timestampMs = Date.parse(timestamp);
1528
+ const maxSkewMs = config.maxSkewMs ?? DEFAULT_STATEFUL_FORWARDING_MAX_SKEW_MS;
1529
+ if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > maxSkewMs) {
1530
+ throw new ProviderError(
1531
+ "Stateful forwarding signature timestamp is outside the allowed skew.",
1532
+ { code: "STATEFUL_FORWARDING_TIMESTAMP_INVALID" },
1533
+ );
1534
+ }
1535
+ if (
1536
+ !verifyStatefulRequestSignature({
1537
+ secret: config.secret,
1538
+ timestamp,
1539
+ rawBody: input.rawBody,
1540
+ method: input.method,
1541
+ path: input.path,
1542
+ nonce,
1543
+ signature,
1544
+ })
1545
+ ) {
1546
+ throw new ProviderError("Stateful forwarding signature is invalid.", {
1547
+ code: "STATEFUL_FORWARDING_SIGNATURE_INVALID",
1548
+ });
1549
+ }
1550
+ const replayResult = input.replayCache.claim(nonce, timestampMs + maxSkewMs, Date.now());
1551
+ if (replayResult === "replayed") {
1552
+ throw new ProviderError("Stateful forwarding nonce has already been used.", {
1553
+ code: "STATEFUL_FORWARDING_REPLAY_DETECTED",
1554
+ });
1555
+ }
1556
+ if (replayResult === "full") {
1557
+ throw new ProviderError("Stateful forwarding replay cache is at capacity.", {
1558
+ code: "STATEFUL_FORWARDING_REPLAY_CACHE_FULL",
1559
+ });
1560
+ }
1561
+ }
1562
+
1563
+ function operationRequestFromForwardingEnvelope(
1564
+ envelope: ProviderServerStatefulForwardEnvelope,
1565
+ ): OperationRequest & { readonly deadlineAt?: string } {
1566
+ return {
1567
+ ...envelope.operationRequest,
1568
+ ...(envelope.deadlineAt !== undefined ? { deadlineAt: envelope.deadlineAt } : {}),
1569
+ };
1570
+ }
1571
+
1572
+ function parseStatefulForwardingEnvelope(rawBody: unknown): ProviderServerStatefulForwardEnvelope {
1573
+ const parsed = ProviderServerStatefulForwardEnvelopeSchema.safeParse(rawBody);
1574
+ if (parsed.success) return parsed.data;
1575
+ throw new ProviderError("Stateful forwarding envelope is invalid.", {
1576
+ code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
1577
+ details: zodDetails(parsed.error),
1578
+ });
1579
+ }
1580
+
1360
1581
  export function createServerApp(
1361
1582
  provider: ProviderDefinition,
1362
1583
  options: ProviderServerOptions = {},
1363
1584
  ): Hono {
1585
+ validateStatefulServerConfig(options);
1364
1586
  const app = new Hono();
1365
1587
  const logger = options.logger ?? defaultProviderServerLogger;
1588
+ const statefulForwardingReplayCache = new StatefulForwardingReplayCache(
1589
+ options.statefulForwarding?.replayCacheMaxEntries ??
1590
+ DEFAULT_STATEFUL_FORWARDING_REPLAY_CACHE_MAX_ENTRIES,
1591
+ );
1366
1592
  const state =
1367
1593
  options.state ??
1368
1594
  createProviderRuntimeStateFromEnv({
@@ -1370,6 +1596,24 @@ export function createServerApp(
1370
1596
  allowMemoryFallback: options.allowMemoryStateFallback === true,
1371
1597
  });
1372
1598
 
1599
+ // Boot-time visibility for unprovisioned declared secrets: emit a structured
1600
+ // warn so deploy tooling/alerting sees the gap the moment the pod boots,
1601
+ // instead of discovering it request-by-request. Deliberately log-only — a
1602
+ // boot crash would trade a structured MISSING_SECRET signal for
1603
+ // CrashLoopBackOff. Requests still fail closed via the executeOperation gate.
1604
+ const missingSecretsAtBoot = listMissingRequiredSecrets(
1605
+ provider,
1606
+ createEnvContext(provider.secrets?.map((secret) => secret.name)),
1607
+ );
1608
+ if (missingSecretsAtBoot.length > 0) {
1609
+ logger({
1610
+ level: "warn",
1611
+ event: "provider_secrets_missing",
1612
+ providerId: provider.id,
1613
+ missingSecrets: missingSecretsAtBoot,
1614
+ });
1615
+ }
1616
+
1373
1617
  app.notFound((c) =>
1374
1618
  c.json(
1375
1619
  {
@@ -1390,6 +1634,162 @@ export function createServerApp(
1390
1634
  }),
1391
1635
  );
1392
1636
 
1637
+ app.post(STATEFUL_INTERNAL_OPERATIONS_ROUTE, async (c) => {
1638
+ let rawBodyText = "";
1639
+ let rawBody: unknown;
1640
+ const operation = "stateful-internal";
1641
+ const requestCost = startRequestCost();
1642
+ try {
1643
+ if (!options.internalOperationExecutor) {
1644
+ throw new ProviderError("Stateful internal operation executor is not configured.", {
1645
+ code: "STATEFUL_INTERNAL_EXECUTOR_NOT_CONFIGURED",
1646
+ });
1647
+ }
1648
+ rawBodyText = await c.req.raw.clone().text();
1649
+ verifyStatefulForwardingRequest({
1650
+ options,
1651
+ rawBody: rawBodyText,
1652
+ headers: c.req.raw.headers,
1653
+ method: c.req.raw.method,
1654
+ path: STATEFUL_INTERNAL_OPERATIONS_ROUTE,
1655
+ replayCache: statefulForwardingReplayCache,
1656
+ });
1657
+ try {
1658
+ rawBody = JSON.parse(rawBodyText);
1659
+ } catch {
1660
+ throw new ProviderError("Stateful forwarding envelope is not valid JSON.", {
1661
+ code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
1662
+ });
1663
+ }
1664
+ const envelope = parseStatefulForwardingEnvelope(rawBody);
1665
+ if (envelope.providerId !== provider.id) {
1666
+ throw new ProviderError(
1667
+ "Stateful forwarding envelope providerId does not match the served provider.",
1668
+ { code: "STATEFUL_FORWARDING_PROVIDER_MISMATCH" },
1669
+ );
1670
+ }
1671
+ if (envelope.requestId !== envelope.operationRequest.requestId) {
1672
+ throw new ProviderError("Stateful forwarding requestId values do not match.", {
1673
+ code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
1674
+ });
1675
+ }
1676
+ if (
1677
+ envelope.sourcePodId !==
1678
+ (c.req.raw.headers.get(STATEFUL_FORWARDING_SOURCE_POD_HEADER) ?? "")
1679
+ ) {
1680
+ throw new ProviderError("Stateful forwarding source pod does not match its header.", {
1681
+ code: "STATEFUL_FORWARDING_SOURCE_POD_MISMATCH",
1682
+ });
1683
+ }
1684
+ if (
1685
+ envelope.forwardedAt !== (c.req.raw.headers.get(STATEFUL_FORWARDING_TIMESTAMP_HEADER) ?? "")
1686
+ ) {
1687
+ throw new ProviderError(
1688
+ "Stateful forwarding forwardedAt does not match its signature timestamp.",
1689
+ { code: "STATEFUL_FORWARDING_ENVELOPE_INVALID" },
1690
+ );
1691
+ }
1692
+ const deadlineAtMs = envelope.deadlineAt ? Date.parse(envelope.deadlineAt) : undefined;
1693
+ if (deadlineAtMs !== undefined && deadlineAtMs <= Date.now()) {
1694
+ throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt as string);
1695
+ }
1696
+ const remainingDeadlineMs =
1697
+ deadlineAtMs === undefined ? undefined : deadlineAtMs - Date.now();
1698
+ const deadlineSignal =
1699
+ remainingDeadlineMs === undefined ? undefined : AbortSignal.timeout(remainingDeadlineMs);
1700
+ const signal = deadlineSignal
1701
+ ? AbortSignal.any([c.req.raw.signal, deadlineSignal])
1702
+ : c.req.raw.signal;
1703
+ const ownerFenceValidation = Promise.resolve(
1704
+ options.statefulForwarding?.validateOwnerFence(
1705
+ {
1706
+ providerId: envelope.providerId,
1707
+ sessionKey: envelope.sessionKey,
1708
+ ownerPodId: envelope.ownerPodId,
1709
+ generation: envelope.generation,
1710
+ sourcePodId: envelope.sourcePodId,
1711
+ forwardedAt: envelope.forwardedAt,
1712
+ requestId: envelope.requestId,
1713
+ ...(envelope.idempotencyKey ? { idempotencyKey: envelope.idempotencyKey } : {}),
1714
+ },
1715
+ signal,
1716
+ ),
1717
+ );
1718
+ let ownerFenceValid: boolean | undefined;
1719
+ try {
1720
+ ownerFenceValid = deadlineSignal
1721
+ ? await Promise.race([
1722
+ ownerFenceValidation,
1723
+ new Promise<never>((_resolve, reject) => {
1724
+ deadlineSignal.addEventListener(
1725
+ "abort",
1726
+ () =>
1727
+ reject(
1728
+ new StatefulRoutingDeadlineError(
1729
+ envelope.requestId,
1730
+ envelope.deadlineAt as string,
1731
+ ),
1732
+ ),
1733
+ { once: true },
1734
+ );
1735
+ }),
1736
+ ])
1737
+ : await ownerFenceValidation;
1738
+ } catch (error) {
1739
+ if (deadlineSignal?.aborted) {
1740
+ throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt as string);
1741
+ }
1742
+ throw error;
1743
+ }
1744
+ if (ownerFenceValid !== true) {
1745
+ throw new ProviderError("Stateful forwarding owner fence is no longer current.", {
1746
+ code: "STATEFUL_FORWARDING_OWNER_FENCE_INVALID",
1747
+ });
1748
+ }
1749
+ const request = operationRequestFromForwardingEnvelope(envelope);
1750
+ const operationId = envelope.operationId;
1751
+ const ctx = createProviderContext(provider, request, operationId, options, state);
1752
+ if (deadlineAtMs !== undefined && deadlineAtMs <= Date.now()) {
1753
+ throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt as string);
1754
+ }
1755
+ const output = await options.internalOperationExecutor({
1756
+ provider,
1757
+ operationId,
1758
+ ctx,
1759
+ request,
1760
+ internalStatefulForward: envelope,
1761
+ signal,
1762
+ });
1763
+ logProviderSuccess(
1764
+ logger,
1765
+ provider,
1766
+ "operation",
1767
+ operationId || operation,
1768
+ request.requestId,
1769
+ 200,
1770
+ finishRequestCost(requestCost),
1771
+ );
1772
+ return c.json({ data: output });
1773
+ } catch (error) {
1774
+ const status = toStatusCode(error);
1775
+ if (isProviderError(error) && error.code === "STATEFUL_FORWARDING_REPLAY_CACHE_FULL") {
1776
+ c.header("Retry-After", String(STATEFUL_FORWARDING_REPLAY_RETRY_AFTER_SECONDS));
1777
+ }
1778
+ const requestId = extractRequestId(rawBody);
1779
+ logProviderError(
1780
+ logger,
1781
+ provider,
1782
+ "operation",
1783
+ operation,
1784
+ requestId,
1785
+ error,
1786
+ status,
1787
+ finishRequestCost(requestCost),
1788
+ );
1789
+ return c.json(toErrorResponse(error, requestId), status);
1790
+ }
1791
+ });
1792
+
1393
1793
  app.post("/v1/:operation", async (c) => {
1394
1794
  let rawBody: unknown;
1395
1795
  const operation = c.req.param("operation");
@@ -1462,17 +1862,8 @@ export function createServerApp(
1462
1862
  .clone()
1463
1863
  .json()
1464
1864
  .catch(() => undefined);
1465
- const body = withAuthRequestHeaders(
1466
- AuthFlowRequestSchema.parse(rawBody),
1467
- c.req.raw.headers,
1468
- );
1469
- const response = await handleAuthFlow(
1470
- provider,
1471
- body,
1472
- "start",
1473
- options,
1474
- c.req.raw.signal,
1475
- );
1865
+ const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1866
+ const response = await handleAuthFlow(provider, body, "start", options, c.req.raw.signal);
1476
1867
  logProviderSuccess(
1477
1868
  logger,
1478
1869
  provider,
@@ -1508,17 +1899,8 @@ export function createServerApp(
1508
1899
  .clone()
1509
1900
  .json()
1510
1901
  .catch(() => undefined);
1511
- const body = withAuthRequestHeaders(
1512
- AuthFlowRequestSchema.parse(rawBody),
1513
- c.req.raw.headers,
1514
- );
1515
- const response = await handleAuthFlow(
1516
- provider,
1517
- body,
1518
- "continue",
1519
- options,
1520
- c.req.raw.signal,
1521
- );
1902
+ const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1903
+ const response = await handleAuthFlow(provider, body, "continue", options, c.req.raw.signal);
1522
1904
  logProviderSuccess(
1523
1905
  logger,
1524
1906
  provider,
@@ -1554,17 +1936,8 @@ export function createServerApp(
1554
1936
  .clone()
1555
1937
  .json()
1556
1938
  .catch(() => undefined);
1557
- const body = withAuthRequestHeaders(
1558
- AuthFlowRequestSchema.parse(rawBody),
1559
- c.req.raw.headers,
1560
- );
1561
- const response = await handleAuthFlow(
1562
- provider,
1563
- body,
1564
- "poll",
1565
- options,
1566
- c.req.raw.signal,
1567
- );
1939
+ const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1940
+ const response = await handleAuthFlow(provider, body, "poll", options, c.req.raw.signal);
1568
1941
  logProviderSuccess(
1569
1942
  logger,
1570
1943
  provider,
@@ -1600,17 +1973,8 @@ export function createServerApp(
1600
1973
  .clone()
1601
1974
  .json()
1602
1975
  .catch(() => undefined);
1603
- const body = withAuthRequestHeaders(
1604
- AuthFlowRequestSchema.parse(rawBody),
1605
- c.req.raw.headers,
1606
- );
1607
- const response = await handleAuthFlow(
1608
- provider,
1609
- body,
1610
- "refresh",
1611
- options,
1612
- c.req.raw.signal,
1613
- );
1976
+ const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1977
+ const response = await handleAuthFlow(provider, body, "refresh", options, c.req.raw.signal);
1614
1978
  logProviderSuccess(
1615
1979
  logger,
1616
1980
  provider,
@@ -1646,17 +2010,8 @@ export function createServerApp(
1646
2010
  .clone()
1647
2011
  .json()
1648
2012
  .catch(() => undefined);
1649
- const body = withAuthRequestHeaders(
1650
- AuthFlowRequestSchema.parse(rawBody),
1651
- c.req.raw.headers,
1652
- );
1653
- const response = await handleAuthFlow(
1654
- provider,
1655
- body,
1656
- "abort",
1657
- options,
1658
- c.req.raw.signal,
1659
- );
2013
+ const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
2014
+ const response = await handleAuthFlow(provider, body, "abort", options, c.req.raw.signal);
1660
2015
  logProviderSuccess(
1661
2016
  logger,
1662
2017
  provider,
@@ -1687,12 +2042,54 @@ export function createServerApp(
1687
2042
  return app;
1688
2043
  }
1689
2044
 
2045
+ function validateStatefulServerConfig(options: ProviderServerOptions): void {
2046
+ if (options.statefulForwarding && !options.internalOperationExecutor) {
2047
+ throw new Error(
2048
+ "Invalid provider server configuration: statefulForwarding requires internalOperationExecutor; missing option internalOperationExecutor.",
2049
+ );
2050
+ }
2051
+ if (options.internalOperationExecutor && !options.statefulForwarding?.secret) {
2052
+ throw new Error(
2053
+ "Invalid provider server configuration: internalOperationExecutor requires statefulForwarding.secret; missing option statefulForwarding.secret.",
2054
+ );
2055
+ }
2056
+ if (
2057
+ options.statefulForwarding &&
2058
+ typeof options.statefulForwarding.validateOwnerFence !== "function"
2059
+ ) {
2060
+ throw new Error(
2061
+ "Invalid provider server configuration: statefulForwarding requires validateOwnerFence.",
2062
+ );
2063
+ }
2064
+ if (
2065
+ options.statefulForwarding?.maxSkewMs !== undefined &&
2066
+ (!Number.isFinite(options.statefulForwarding.maxSkewMs) ||
2067
+ options.statefulForwarding.maxSkewMs <= 0)
2068
+ ) {
2069
+ throw new Error("Invalid provider server configuration: maxSkewMs must be positive.");
2070
+ }
2071
+ if (
2072
+ options.statefulForwarding?.replayCacheMaxEntries !== undefined &&
2073
+ (!Number.isInteger(options.statefulForwarding.replayCacheMaxEntries) ||
2074
+ options.statefulForwarding.replayCacheMaxEntries <= 0)
2075
+ ) {
2076
+ throw new Error(
2077
+ "Invalid provider server configuration: replayCacheMaxEntries must be a positive integer.",
2078
+ );
2079
+ }
2080
+ }
2081
+
1690
2082
  type BunServeRuntime = {
1691
2083
  serve: (options: {
1692
2084
  port: number;
1693
2085
  hostname: string;
1694
2086
  fetch: (request: Request) => Response | Promise<Response>;
1695
- }) => unknown;
2087
+ }) => BunServerHandle;
2088
+ };
2089
+
2090
+ type BunServerHandle = {
2091
+ readonly port: number;
2092
+ stop(closeActiveConnections?: boolean): Promise<void>;
1696
2093
  };
1697
2094
 
1698
2095
  function getBunServeRuntime(): BunServeRuntime | undefined {
@@ -1708,11 +2105,20 @@ function getBunServeRuntime(): BunServeRuntime | undefined {
1708
2105
 
1709
2106
  return {
1710
2107
  serve(options) {
1711
- return serve(options);
2108
+ return serve(options) as BunServerHandle;
1712
2109
  },
1713
2110
  };
1714
2111
  }
1715
2112
 
2113
+ export type ProviderServerCloseOptions = {
2114
+ readonly timeoutMs?: number;
2115
+ };
2116
+
2117
+ export type ProviderServerHandle = {
2118
+ readonly port: number;
2119
+ close(options?: ProviderServerCloseOptions): Promise<void>;
2120
+ };
2121
+
1716
2122
  export interface ServeOptions extends ProviderServerOptions {
1717
2123
  host?: string;
1718
2124
  port?: number;
@@ -1724,46 +2130,233 @@ export interface ServeOptions extends ProviderServerOptions {
1724
2130
  selfTestPort?: number;
1725
2131
  }
1726
2132
 
2133
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
2134
+ const DEFAULT_SHUTDOWN_SIGNALS: NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
2135
+
2136
+ type SignalServerRegistration = {
2137
+ readonly signals: ReadonlySet<NodeJS.Signals>;
2138
+ readonly close: () => Promise<void>;
2139
+ };
2140
+
2141
+ type ProcessSignalCoordinator = {
2142
+ readonly registrations: Set<SignalServerRegistration>;
2143
+ readonly listener: () => void;
2144
+ handling: boolean;
2145
+ };
2146
+
2147
+ const processSignalCoordinators = new Map<NodeJS.Signals, ProcessSignalCoordinator>();
2148
+
1727
2149
  export async function serve(
1728
2150
  provider: ProviderDefinition,
1729
2151
  options: ServeOptions = {},
1730
- ): Promise<void> {
2152
+ ): Promise<ProviderServerHandle> {
1731
2153
  const bunRuntime = getBunServeRuntime();
1732
2154
 
1733
2155
  if (bunRuntime === undefined) {
1734
- throw new ProviderError(
1735
- "Bun runtime is required to start the provider server",
1736
- {
1737
- code: "RUNTIME_UNSUPPORTED",
1738
- },
1739
- );
2156
+ throw new ProviderError("Bun runtime is required to start the provider server", {
2157
+ code: "RUNTIME_UNSUPPORTED",
2158
+ });
1740
2159
  }
2160
+ const logger = options.logger ?? defaultProviderServerLogger;
2161
+ const configuredTimeoutMs = shutdownTimeout(
2162
+ options.shutdown?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
2163
+ );
2164
+ const configuredSignals = resolveShutdownSignals(options.shutdown?.signals ?? true);
1741
2165
 
1742
2166
  const app = createServerApp(provider, {
1743
2167
  logger: options.logger,
1744
2168
  stt: options.stt,
2169
+ state: options.state,
2170
+ allowMemoryStateFallback: options.allowMemoryStateFallback,
2171
+ operationExecutor: options.operationExecutor,
2172
+ internalOperationExecutor: options.internalOperationExecutor,
2173
+ statefulForwarding: options.statefulForwarding,
2174
+ });
2175
+
2176
+ const servers: BunServerHandle[] = [];
2177
+ try {
2178
+ servers.push(
2179
+ bunRuntime.serve({
2180
+ port: options.port ?? DEFAULT_PORT,
2181
+ hostname: options.host ?? DEFAULT_HOST,
2182
+ fetch: app.fetch,
2183
+ }),
2184
+ );
2185
+
2186
+ // Internal self-test listener (health dependency inversion): a SEPARATE
2187
+ // socket the tenant-facing gateway never dials. Off by default — it only
2188
+ // starts when the shared self-test master secret env is present.
2189
+ const selfTestSecrets = resolveSelfTestMasterSecrets();
2190
+ if (selfTestSecrets) {
2191
+ const selfTestApp = createSelfTestApp(provider, {
2192
+ secrets: selfTestSecrets,
2193
+ invoke: createSelfTestInvoke(app),
2194
+ authFlow: createSelfTestAuthFlowInvoke(app),
2195
+ });
2196
+ servers.push(
2197
+ bunRuntime.serve({
2198
+ port: options.selfTestPort ?? resolveSelfTestPort(),
2199
+ hostname: options.host ?? DEFAULT_HOST,
2200
+ fetch: selfTestApp.fetch,
2201
+ }),
2202
+ );
2203
+ }
2204
+ } catch (error) {
2205
+ await Promise.allSettled(servers.map((startedServer) => startedServer.stop(true)));
2206
+ throw error;
2207
+ }
2208
+
2209
+ const server = servers[0];
2210
+ if (!server) throw new Error("Provider server failed to create its primary listener.");
2211
+ let closePromise: Promise<void> | undefined;
2212
+ let unregisterSignals = () => {};
2213
+
2214
+ const close = (closeOptions: ProviderServerCloseOptions = {}): Promise<void> => {
2215
+ if (closePromise) return closePromise;
2216
+ const timeoutMs = shutdownTimeout(closeOptions.timeoutMs ?? configuredTimeoutMs);
2217
+ closePromise = closeProviderServers({
2218
+ servers,
2219
+ hooks: options.shutdown?.hooks ?? [],
2220
+ timeoutMs,
2221
+ logger,
2222
+ providerId: provider.id,
2223
+ }).finally(() => unregisterSignals());
2224
+ return closePromise;
2225
+ };
2226
+
2227
+ try {
2228
+ unregisterSignals = registerForProcessSignals(configuredSignals, () =>
2229
+ close({ timeoutMs: configuredTimeoutMs }),
2230
+ );
2231
+ } catch (error) {
2232
+ unregisterSignals();
2233
+ await Promise.allSettled(servers.map((startedServer) => startedServer.stop(true)));
2234
+ throw error;
2235
+ }
2236
+
2237
+ return { port: server.port, close };
2238
+ }
2239
+
2240
+ function registerForProcessSignals(
2241
+ signals: NodeJS.Signals[],
2242
+ close: () => Promise<void>,
2243
+ ): () => void {
2244
+ if (signals.length === 0) return () => {};
2245
+ const registration: SignalServerRegistration = {
2246
+ signals: new Set(signals),
2247
+ close,
2248
+ };
2249
+ let registered = true;
2250
+ const unregister = () => {
2251
+ if (!registered) return;
2252
+ registered = false;
2253
+ for (const signal of registration.signals) {
2254
+ const coordinator = processSignalCoordinators.get(signal);
2255
+ if (!coordinator) continue;
2256
+ coordinator.registrations.delete(registration);
2257
+ if (coordinator.registrations.size === 0 && !coordinator.handling) {
2258
+ process.removeListener(signal, coordinator.listener);
2259
+ processSignalCoordinators.delete(signal);
2260
+ }
2261
+ }
2262
+ };
2263
+ try {
2264
+ for (const signal of signals) {
2265
+ let coordinator = processSignalCoordinators.get(signal);
2266
+ if (!coordinator) {
2267
+ const created: ProcessSignalCoordinator = {
2268
+ registrations: new Set(),
2269
+ handling: false,
2270
+ listener: () => handleCoordinatedSignal(signal, created),
2271
+ };
2272
+ coordinator = created;
2273
+ processSignalCoordinators.set(signal, coordinator);
2274
+ process.on(signal, coordinator.listener);
2275
+ }
2276
+ coordinator.registrations.add(registration);
2277
+ }
2278
+ } catch (error) {
2279
+ unregister();
2280
+ throw error;
2281
+ }
2282
+ return unregister;
2283
+ }
2284
+
2285
+ function handleCoordinatedSignal(
2286
+ signal: NodeJS.Signals,
2287
+ coordinator: ProcessSignalCoordinator,
2288
+ ): void {
2289
+ if (coordinator.handling) return;
2290
+ coordinator.handling = true;
2291
+ const registrations = [...coordinator.registrations];
2292
+ void Promise.allSettled(registrations.map((registration) => registration.close())).finally(() => {
2293
+ if (processSignalCoordinators.get(signal) === coordinator) {
2294
+ process.removeListener(signal, coordinator.listener);
2295
+ processSignalCoordinators.delete(signal);
2296
+ }
2297
+ try {
2298
+ process.kill(process.pid, signal);
2299
+ } catch {
2300
+ process.exitCode = 1;
2301
+ }
1745
2302
  });
2303
+ }
2304
+
2305
+ async function closeProviderServers(input: {
2306
+ readonly servers: BunServerHandle[];
2307
+ readonly hooks: Array<() => Promise<void>>;
2308
+ readonly timeoutMs: number;
2309
+ readonly logger: ProviderServerLogger;
2310
+ readonly providerId: string;
2311
+ }): Promise<void> {
2312
+ const deadline = Date.now() + input.timeoutMs;
2313
+ const gracefulStops = input.servers.map((server) => server.stop(false));
2314
+ for (const gracefulStop of gracefulStops) gracefulStop.catch(() => undefined);
2315
+ for (const [hookIndex, hook] of input.hooks.entries()) {
2316
+ try {
2317
+ await withinShutdownBudget(Promise.resolve().then(hook), deadline);
2318
+ } catch (error) {
2319
+ try {
2320
+ input.logger({
2321
+ level: "error",
2322
+ event: "provider_shutdown_hook_failed",
2323
+ providerId: input.providerId,
2324
+ hookIndex,
2325
+ errorClass: error instanceof Error ? error.name : "UnknownError",
2326
+ message: error instanceof Error ? error.message : "Shutdown hook failed.",
2327
+ });
2328
+ } catch {}
2329
+ }
2330
+ }
2331
+ const forcedStops = input.servers.map((server) => server.stop(true));
2332
+ await withinShutdownBudget(
2333
+ Promise.allSettled([...gracefulStops, ...forcedStops]).then(() => undefined),
2334
+ deadline,
2335
+ ).catch(() => undefined);
2336
+ }
1746
2337
 
1747
- bunRuntime.serve({
1748
- port: options.port ?? DEFAULT_PORT,
1749
- hostname: options.host ?? DEFAULT_HOST,
1750
- fetch: app.fetch,
2338
+ async function withinShutdownBudget<T>(promise: Promise<T>, deadline: number): Promise<T> {
2339
+ promise.catch(() => undefined);
2340
+ const remainingMs = Math.max(0, deadline - Date.now());
2341
+ let timer: ReturnType<typeof setTimeout> | undefined;
2342
+ const timeout = new Promise<never>((_resolve, reject) => {
2343
+ timer = setTimeout(() => reject(new Error("Provider server shutdown timed out.")), remainingMs);
1751
2344
  });
2345
+ try {
2346
+ return await Promise.race([promise, timeout]);
2347
+ } finally {
2348
+ if (timer) clearTimeout(timer);
2349
+ }
2350
+ }
1752
2351
 
1753
- // Internal self-test listener (health dependency inversion): a SEPARATE
1754
- // socket the tenant-facing gateway never dials. Off by default — it only
1755
- // starts when the shared self-test master secret env is present.
1756
- const selfTestSecrets = resolveSelfTestMasterSecrets();
1757
- if (selfTestSecrets) {
1758
- const selfTestApp = createSelfTestApp(provider, {
1759
- secrets: selfTestSecrets,
1760
- invoke: createSelfTestInvoke(app),
1761
- });
1762
- bunRuntime.serve({
1763
- port: options.selfTestPort ?? resolveSelfTestPort(),
1764
- hostname: options.host ?? DEFAULT_HOST,
1765
- fetch: selfTestApp.fetch,
1766
- });
2352
+ function resolveShutdownSignals(signals: boolean | NodeJS.Signals[]): NodeJS.Signals[] {
2353
+ if (signals === false) return [];
2354
+ return [...new Set(signals === true ? DEFAULT_SHUTDOWN_SIGNALS : signals)];
2355
+ }
2356
+
2357
+ function shutdownTimeout(value: number): number {
2358
+ if (!Number.isFinite(value) || value < 0) {
2359
+ throw new Error("Provider server shutdown timeoutMs must be a non-negative finite number.");
1767
2360
  }
1768
- await Promise.resolve();
2361
+ return value;
1769
2362
  }