@authhero/cloudflare-adapter 2.37.5 → 2.38.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.
- package/dist/cloudflare-adapter.cjs +23 -23
- package/dist/cloudflare-adapter.d.ts +14 -2
- package/dist/cloudflare-adapter.mjs +373 -566
- package/dist/sync-defaults-errors-BlLZh6Gt.mjs +250 -0
- package/dist/sync-defaults-errors-Htp1NWTq.js +1 -0
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/dist/types/wfp-provisioner/index.d.ts +3 -0
- package/dist/types/wfp-provisioner/provisioner-steps.d.ts +54 -0
- package/dist/types/wfp-provisioner/provisioner.d.ts +4 -0
- package/dist/types/wfp-provisioner/sync-defaults-errors.d.ts +11 -0
- package/dist/types/wfp-provisioner/tenant-hook.d.ts +10 -2
- package/dist/types/workflows/enqueue-hook.d.ts +49 -0
- package/dist/types/workflows/entrypoint.example.d.ts +118 -0
- package/dist/types/workflows/executor.d.ts +17 -0
- package/dist/types/workflows/index.d.ts +20 -0
- package/dist/types/workflows/provision-operation.d.ts +56 -0
- package/dist/types/workflows/reconcile.d.ts +45 -0
- package/dist/types/workflows/types.d.ts +49 -0
- package/dist/types/workflows/verify.d.ts +35 -0
- package/dist/workflows.cjs +1 -0
- package/dist/workflows.d.ts +392 -0
- package/dist/workflows.mjs +279 -0
- package/package.json +10 -5
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { TenantsDataAdapter, TenantOperation } from '@authhero/adapter-interfaces';
|
|
2
|
+
import { TenantOperationStores, StepConfig, StepRunner, TenantOperationExecutor } from '@authhero/multi-tenancy';
|
|
3
|
+
|
|
4
|
+
interface CfApiClientOptions {
|
|
5
|
+
accountId: string;
|
|
6
|
+
apiToken: string;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
interface D1Database {
|
|
12
|
+
uuid: string;
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
interface D1QueryResult {
|
|
16
|
+
success: boolean;
|
|
17
|
+
meta?: Record<string, unknown>;
|
|
18
|
+
results?: unknown[];
|
|
19
|
+
}
|
|
20
|
+
type ScriptBinding = {
|
|
21
|
+
type: "d1";
|
|
22
|
+
name: string;
|
|
23
|
+
id: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "plain_text";
|
|
26
|
+
name: string;
|
|
27
|
+
text: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "secret_text";
|
|
30
|
+
name: string;
|
|
31
|
+
text: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: "service";
|
|
34
|
+
name: string;
|
|
35
|
+
service: string;
|
|
36
|
+
environment?: string;
|
|
37
|
+
};
|
|
38
|
+
interface ScriptUploadOptions {
|
|
39
|
+
/** Script source (JavaScript ES module). */
|
|
40
|
+
script: string;
|
|
41
|
+
/** Main module filename (must match part name in form data). */
|
|
42
|
+
mainModule: string;
|
|
43
|
+
/** Compatibility date, ISO yyyy-mm-dd. */
|
|
44
|
+
compatibilityDate: string;
|
|
45
|
+
/** Compatibility flags (e.g. `["nodejs_compat"]`). */
|
|
46
|
+
compatibilityFlags?: string[];
|
|
47
|
+
/** Bindings to attach (D1, plain_text, etc.). Secrets go via setSecret(). */
|
|
48
|
+
bindings?: ScriptBinding[];
|
|
49
|
+
/** Optional tags, stored on the script for operator-side lookup. */
|
|
50
|
+
tags?: string[];
|
|
51
|
+
}
|
|
52
|
+
declare class CloudflareApiClient {
|
|
53
|
+
private readonly accountId;
|
|
54
|
+
private readonly apiToken;
|
|
55
|
+
private readonly fetchImpl;
|
|
56
|
+
private readonly timeoutMs;
|
|
57
|
+
private readonly baseUrl;
|
|
58
|
+
constructor(options: CfApiClientOptions);
|
|
59
|
+
createD1Database(name: string): Promise<D1Database>;
|
|
60
|
+
listD1Databases(name?: string): Promise<D1Database[]>;
|
|
61
|
+
deleteD1Database(databaseId: string): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Execute a single SQL statement (or batch of `;`-separated statements
|
|
64
|
+
* permitted by D1) against the given database. Use for applying
|
|
65
|
+
* migrations one file at a time — the per-call response size cap means
|
|
66
|
+
* very large single calls can fail; splitting per file keeps each call
|
|
67
|
+
* bounded by the migration author.
|
|
68
|
+
*/
|
|
69
|
+
execD1(databaseId: string, sql: string): Promise<D1QueryResult[]>;
|
|
70
|
+
uploadNamespacedScript(namespace: string, scriptName: string, options: ScriptUploadOptions): Promise<void>;
|
|
71
|
+
deleteNamespacedScript(namespace: string, scriptName: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Set a single secret on a namespaced script. The CF API replaces the
|
|
74
|
+
* value if a secret with that name already exists, so this is safely
|
|
75
|
+
* re-runnable.
|
|
76
|
+
*/
|
|
77
|
+
setNamespacedScriptSecret(namespace: string, scriptName: string, secretName: string, secretValue: string): Promise<void>;
|
|
78
|
+
private request;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface TenantProvisionNames {
|
|
82
|
+
scriptName: string;
|
|
83
|
+
databaseName: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Provider-agnostic contract for the individual, idempotent units of
|
|
87
|
+
* tenant provisioning. Deliberately free of Cloudflare/D1 terminology so
|
|
88
|
+
* the durable operation orchestration (issue #1026 phase 2) — and any
|
|
89
|
+
* future provider (e.g. Bunny with its SQLite databases) — can run against
|
|
90
|
+
* it. `createWfpProvisionerSteps` is the Cloudflare Workers-for-Platforms
|
|
91
|
+
* + D1 implementation.
|
|
92
|
+
*/
|
|
93
|
+
interface TenantProvisionerSteps {
|
|
94
|
+
names(tenantId: string): TenantProvisionNames;
|
|
95
|
+
/**
|
|
96
|
+
* Validate the version token persisted into `tenants.database_version`
|
|
97
|
+
* BEFORE any provider side effects. Returns the recorded versions.
|
|
98
|
+
*/
|
|
99
|
+
validate(): {
|
|
100
|
+
databaseVersion?: string;
|
|
101
|
+
bundleConfiguration?: string;
|
|
102
|
+
workerVersion?: string;
|
|
103
|
+
};
|
|
104
|
+
findOrCreateDatabase(name: string): Promise<{
|
|
105
|
+
id: string;
|
|
106
|
+
created: boolean;
|
|
107
|
+
}>;
|
|
108
|
+
/**
|
|
109
|
+
* Reconcile migrations against the provisioner-owned tracking table
|
|
110
|
+
* (`_authhero_provisioner_migrations`), including the legacy backfill
|
|
111
|
+
* branch for pre-tracking databases.
|
|
112
|
+
*/
|
|
113
|
+
applyMigrations(databaseId: string, created: boolean): Promise<void>;
|
|
114
|
+
uploadScript(scriptName: string, databaseId: string): Promise<void>;
|
|
115
|
+
uploadSecrets(scriptName: string, tenantId: string): Promise<void>;
|
|
116
|
+
/** Best-effort teardown of both resources; throws a combined error. */
|
|
117
|
+
deprovision(tenantId: string): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Structural types for the Cloudflare Workflows engine. Deliberately NOT
|
|
122
|
+
* imported from `cloudflare:workers` / `@cloudflare/workers-types` — this
|
|
123
|
+
* package ships platform-agnostic bundles and the real runtime objects
|
|
124
|
+
* satisfy these shapes structurally (precedent: `@authhero/proxy`'s local
|
|
125
|
+
* binding types). Only the downstream worker's `WorkflowEntrypoint` shell
|
|
126
|
+
* touches `cloudflare:workers` (see `entrypoint.example.ts`).
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* The ONLY data that crosses the enqueue boundary. Workflows persists
|
|
130
|
+
* params (and shows them in the dashboard), so never put secrets here —
|
|
131
|
+
* the workflow re-resolves everything else from its worker env.
|
|
132
|
+
*/
|
|
133
|
+
interface TenantOperationWorkflowParams {
|
|
134
|
+
operation_id: string;
|
|
135
|
+
tenant_id: string;
|
|
136
|
+
/** Widened beyond "provision" in later phases (upgrade, backup, …). */
|
|
137
|
+
kind: "provision";
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Instance status as reported by the engine. The exact value set has
|
|
141
|
+
* drifted across Cloudflare releases; the reconciler only branches on the
|
|
142
|
+
* terminal values and treats anything unrecognized as still-running.
|
|
143
|
+
*/
|
|
144
|
+
interface WorkflowInstanceStatus {
|
|
145
|
+
status: "queued" | "running" | "paused" | "errored" | "terminated" | "complete" | "waiting" | "waitingForPause" | "unknown" | (string & {});
|
|
146
|
+
error?: {
|
|
147
|
+
name?: string;
|
|
148
|
+
message?: string;
|
|
149
|
+
} | string | null;
|
|
150
|
+
output?: unknown;
|
|
151
|
+
}
|
|
152
|
+
/** Structurally satisfied by the handles a `workflows` binding returns. */
|
|
153
|
+
interface WorkflowInstanceHandle {
|
|
154
|
+
id: string;
|
|
155
|
+
status(): Promise<WorkflowInstanceStatus>;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Structurally satisfied by a Workflows binding
|
|
159
|
+
* (e.g. `env.TENANT_OPERATIONS_WORKFLOW`).
|
|
160
|
+
*/
|
|
161
|
+
interface WorkflowsBinding {
|
|
162
|
+
create(options: {
|
|
163
|
+
id: string;
|
|
164
|
+
params: TenantOperationWorkflowParams;
|
|
165
|
+
}): Promise<WorkflowInstanceHandle>;
|
|
166
|
+
get(id: string): Promise<WorkflowInstanceHandle>;
|
|
167
|
+
}
|
|
168
|
+
declare const TENANT_OPERATION_ENGINE = "cloudflare-workflows";
|
|
169
|
+
|
|
170
|
+
type ProvisionStepName = "mark-running" | "create-database" | "apply-migrations" | "upload-script" | "upload-secrets" | "seed-defaults" | "verify" | "mark-ready" | "mark-failed";
|
|
171
|
+
interface ProvisionOperationDeps {
|
|
172
|
+
/** Provider-agnostic provisioner steps (all idempotent) — e.g.
|
|
173
|
+
* `createWfpProvisionerSteps` for Cloudflare WFP + D1. */
|
|
174
|
+
steps: TenantProvisionerSteps;
|
|
175
|
+
/** Control-plane tenants adapter (snapshot writes). */
|
|
176
|
+
tenants: TenantsDataAdapter;
|
|
177
|
+
/** Control-plane operation log stores. */
|
|
178
|
+
stores: TenantOperationStores;
|
|
179
|
+
/**
|
|
180
|
+
* Defaults seed (`createDispatchSyncDefaults(...)`). Runs as a retried
|
|
181
|
+
* step BEFORE `ready`; per-entity errors in the resolved result fail the
|
|
182
|
+
* step. Optional only for parity with the inline hook — WFP control
|
|
183
|
+
* planes should always set it.
|
|
184
|
+
*/
|
|
185
|
+
syncDefaults?: (tenantId: string) => Promise<unknown>;
|
|
186
|
+
/**
|
|
187
|
+
* Post-seed verification (`createProvisionVerifier(...)`). Throws until
|
|
188
|
+
* the tenant database actually holds keys + the tenant row; retried with backoff so a
|
|
189
|
+
* propagation race becomes a few retries instead of a bad `ready`.
|
|
190
|
+
*/
|
|
191
|
+
verify?: (databaseId: string, tenantId: string) => Promise<void>;
|
|
192
|
+
/** Same gate + default as the inline hook: `deployment_type === "wfp"`. */
|
|
193
|
+
shouldProvision?: (tenant: {
|
|
194
|
+
id: string;
|
|
195
|
+
deployment_type?: string;
|
|
196
|
+
storage_kind?: string;
|
|
197
|
+
}) => boolean;
|
|
198
|
+
logger?: Pick<Console, "warn">;
|
|
199
|
+
/** Per-step overrides of the retry/timeout defaults. */
|
|
200
|
+
stepConfig?: Partial<Record<ProvisionStepName, StepConfig>>;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Durable provision-as-workflow (issue #1026 phase 2): the full provision
|
|
204
|
+
* sequence — resources, migrations, script, secrets, seed, verify — as one
|
|
205
|
+
* engine step per unit, writing back to the control-plane operation log at
|
|
206
|
+
* every step boundary. The DB write happens inside the same `step.do` as
|
|
207
|
+
* the side effect, so it is durable and retried with the step; all side
|
|
208
|
+
* effects are idempotent, which makes replay after a retry or eviction
|
|
209
|
+
* safe.
|
|
210
|
+
*
|
|
211
|
+
* Terminal writes: `mark-ready` flips the tenant snapshot to `ready` and
|
|
212
|
+
* the operation to `succeeded`; any failure runs `mark-failed` (persisting
|
|
213
|
+
* whatever resource ids exist, mirroring the inline hook's failed branch)
|
|
214
|
+
* and rethrows so the engine instance ends `errored`. If even `mark-failed`
|
|
215
|
+
* dies, the operation stays `running` and the reconciler sweep copies the
|
|
216
|
+
* engine's terminal state into the DB.
|
|
217
|
+
*
|
|
218
|
+
* Runs against any `StepRunner` — Cloudflare's `WorkflowStep` satisfies it
|
|
219
|
+
* structurally, and tests use fakes.
|
|
220
|
+
*/
|
|
221
|
+
declare function runProvisionOperation(deps: ProvisionOperationDeps, params: TenantOperationWorkflowParams, step: StepRunner): Promise<void>;
|
|
222
|
+
|
|
223
|
+
interface ProvisionVerifierOptions {
|
|
224
|
+
client: CloudflareApiClient;
|
|
225
|
+
/** Minimum number of signing keys the tenant D1 must hold. Default 1. */
|
|
226
|
+
minKeys?: number;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Thrown when the freshly provisioned D1 fails the post-seed checks. The
|
|
230
|
+
* message is self-contained (it survives workflow step serialization) and
|
|
231
|
+
* names exactly which check failed — it ends up in
|
|
232
|
+
* `tenant_operation_events.detail` and, when retries exhaust, in
|
|
233
|
+
* `tenants.provisioning_error`.
|
|
234
|
+
*/
|
|
235
|
+
declare class TenantProvisionVerificationError extends Error {
|
|
236
|
+
readonly checks: {
|
|
237
|
+
keyCount: number;
|
|
238
|
+
tenantRowCount: number;
|
|
239
|
+
};
|
|
240
|
+
constructor(message: string, checks: {
|
|
241
|
+
keyCount: number;
|
|
242
|
+
tenantRowCount: number;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Post-provision verification (issue #1026): assert the tenant D1 actually
|
|
247
|
+
* contains signing keys and its own tenant row before the tenant is marked
|
|
248
|
+
* `ready`. Queries D1 over the same REST path the provisioner's migrations
|
|
249
|
+
* use — this is what turns the 2026-07-02 "ready but empty D1" incident
|
|
250
|
+
* class into a retried workflow step instead of a silent success.
|
|
251
|
+
*
|
|
252
|
+
* Limitation (accepted for v1): this proves the rows landed in D1 via the
|
|
253
|
+
* REST API, not that the tenant worker's binding observes them; a worker
|
|
254
|
+
* HTTP probe can be layered on later.
|
|
255
|
+
*/
|
|
256
|
+
declare function createProvisionVerifier(options: ProvisionVerifierOptions): (databaseId: string, tenantId: string) => Promise<void>;
|
|
257
|
+
|
|
258
|
+
interface CloudflareWorkflowsExecutorOptions {
|
|
259
|
+
binding: WorkflowsBinding;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* `TenantOperationExecutor` backed by a Cloudflare Workflows binding
|
|
263
|
+
* (issue #1026 phase 2). Row-first contract: `enqueueTenantOperation` has
|
|
264
|
+
* already persisted the operation (with its deterministic
|
|
265
|
+
* `engine_instance_id`) before `start` is called, so an instance can never
|
|
266
|
+
* exist without a tracking row.
|
|
267
|
+
*
|
|
268
|
+
* `start` resolves as soon as the instance is created — the workflow owns
|
|
269
|
+
* every subsequent write. Creating an instance whose id already exists is
|
|
270
|
+
* treated as success (idempotent re-enqueue after a crashed caller).
|
|
271
|
+
*/
|
|
272
|
+
declare function createCloudflareWorkflowsExecutor(options: CloudflareWorkflowsExecutorOptions): TenantOperationExecutor;
|
|
273
|
+
|
|
274
|
+
interface ReconcileTenantOperationsOptions {
|
|
275
|
+
stores: TenantOperationStores;
|
|
276
|
+
tenants: TenantsDataAdapter;
|
|
277
|
+
binding: WorkflowsBinding;
|
|
278
|
+
/**
|
|
279
|
+
* Only touch operations whose `updated_at` is older than this — fresh
|
|
280
|
+
* runs write at every step boundary, so a recently-updated operation is
|
|
281
|
+
* alive. Default 10 minutes.
|
|
282
|
+
*/
|
|
283
|
+
minAgeMs?: number;
|
|
284
|
+
/** Max stuck operations per sweep. Default 100. */
|
|
285
|
+
limit?: number;
|
|
286
|
+
logger?: Pick<Console, "warn">;
|
|
287
|
+
}
|
|
288
|
+
interface ReconcileTenantOperationsResult {
|
|
289
|
+
scanned: number;
|
|
290
|
+
markedFailed: number;
|
|
291
|
+
markedSucceeded: number;
|
|
292
|
+
stillRunning: number;
|
|
293
|
+
instanceMissing: number;
|
|
294
|
+
errors: number;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Sweep for operations stuck in `pending`/`running` whose engine instance
|
|
298
|
+
* died before reaching its own terminal write (issue #1026): resolve the
|
|
299
|
+
* instance (via the stored — or re-derived, it's deterministic — id), and
|
|
300
|
+
* copy terminal engine states into the control-plane DB.
|
|
301
|
+
*
|
|
302
|
+
* The engine retains completed-instance state for at most 30 days, so
|
|
303
|
+
* hosts must run this at least daily; every 15–60 minutes is recommended
|
|
304
|
+
* (wire it to the worker's `scheduled` handler). One operation's engine
|
|
305
|
+
* error never aborts the sweep.
|
|
306
|
+
*
|
|
307
|
+
* Decision table per stuck operation:
|
|
308
|
+
* - handle lookup throws (expired / never created) → `failed`
|
|
309
|
+
* - engine `errored` / `terminated` → `failed` (engine error copied)
|
|
310
|
+
* - engine `complete` but operation non-terminal (the final write raced or
|
|
311
|
+
* was lost) → decided from the tenant snapshot, which is the source of
|
|
312
|
+
* truth: `provisioning_state === "ready"` → `succeeded`, else `failed`
|
|
313
|
+
* - anything else → still running, left untouched
|
|
314
|
+
*/
|
|
315
|
+
declare function reconcileTenantOperations(options: ReconcileTenantOperationsOptions): Promise<ReconcileTenantOperationsResult>;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Structural mirror of `@authhero/multi-tenancy`'s `StepReporter` — kept
|
|
319
|
+
* local so this module carries no multi-tenancy dependency. When the
|
|
320
|
+
* control plane records provisions as tenant operations (issue #1026),
|
|
321
|
+
* the hook receives this callback and surfaces coarse step boundaries
|
|
322
|
+
* (`provision-resources`, `seed-defaults`) in the operation history.
|
|
323
|
+
*/
|
|
324
|
+
type WfpProvisioningStepReporter = (step: string, outcome: "started" | "succeeded" | "failed", detail?: Record<string, unknown>) => Promise<void>;
|
|
325
|
+
interface WfpTenantProvisioningHook {
|
|
326
|
+
onProvision(tenantId: string, report?: WfpProvisioningStepReporter): Promise<void>;
|
|
327
|
+
onDeprovision(tenantId: string): Promise<void>;
|
|
328
|
+
/**
|
|
329
|
+
* Re-run provisioning for an already-existing WFP tenant to pull it onto the
|
|
330
|
+
* current bundle + migrations — i.e. an upgrade. Re-uploads the worker
|
|
331
|
+
* script (an upload overwrites), reconciles any migrations not yet applied to
|
|
332
|
+
* the tenant D1, re-runs `syncDefaults`, then rewrites `worker_version`,
|
|
333
|
+
* `bundle_configuration`, and `database_version` so the recorded versions
|
|
334
|
+
* reflect what now runs. Marks `provisioning_state = "pending"` while the
|
|
335
|
+
* upgrade is in flight and `ready` on success (`failed` on error).
|
|
336
|
+
*
|
|
337
|
+
* Throws if the tenant doesn't exist or isn't WFP-provisioned — callers
|
|
338
|
+
* (e.g. a management-API redeploy endpoint) surface that as a 4xx.
|
|
339
|
+
*/
|
|
340
|
+
onUpgrade(tenantId: string, report?: WfpProvisioningStepReporter): Promise<void>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface WfpWorkflowProvisioningHookOptions {
|
|
344
|
+
tenants: TenantsDataAdapter;
|
|
345
|
+
/**
|
|
346
|
+
* Enqueues a provision operation on the durable engine — typically
|
|
347
|
+
* `(input) => enqueueTenantOperation(stores, createCloudflareWorkflowsExecutor({ binding }), input)`.
|
|
348
|
+
* Resolves as soon as the engine instance is created.
|
|
349
|
+
*/
|
|
350
|
+
enqueueOperation: (input: {
|
|
351
|
+
kind: "provision";
|
|
352
|
+
tenant_id: string;
|
|
353
|
+
initiated_by?: string;
|
|
354
|
+
}) => Promise<TenantOperation>;
|
|
355
|
+
/** Same gate + default as the inline hook: `deployment_type === "wfp"`. */
|
|
356
|
+
shouldProvision?: (tenant: {
|
|
357
|
+
id: string;
|
|
358
|
+
deployment_type?: string;
|
|
359
|
+
storage_kind?: string;
|
|
360
|
+
}) => boolean;
|
|
361
|
+
/**
|
|
362
|
+
* The existing inline hook (`createWfpTenantProvisioningHook(...)`).
|
|
363
|
+
* Upgrade and deprovision keep running inline until later phases make
|
|
364
|
+
* them durable.
|
|
365
|
+
*/
|
|
366
|
+
inline: WfpTenantProvisioningHook;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Drop-in replacement for `createWfpTenantProvisioningHook` on control
|
|
370
|
+
* planes that run provisioning through Cloudflare Workflows (issue #1026
|
|
371
|
+
* phase 2): `onProvision` enqueues a durable provision operation and
|
|
372
|
+
* returns immediately, leaving the tenant `pending` — the workflow's
|
|
373
|
+
* `mark-ready` / `mark-failed` steps own the terminal snapshot writes, and
|
|
374
|
+
* the reconciler covers instances that die mid-run.
|
|
375
|
+
*
|
|
376
|
+
* Semantic change to plan for downstream: tenant-create now returns with
|
|
377
|
+
* `provisioning_state: "pending"`; clients poll the tenant row or the
|
|
378
|
+
* operations API. An enqueue failure still throws, so
|
|
379
|
+
* `createProvisioningHooks.afterCreate` rolls the tenant row back exactly
|
|
380
|
+
* like an inline provision failure does today. This also replaces any
|
|
381
|
+
* best-effort post-create seed — the seed is a durable step inside the
|
|
382
|
+
* workflow.
|
|
383
|
+
*
|
|
384
|
+
* Wire it with `databaseIsolation.recordProvisionOperations: false` — this
|
|
385
|
+
* hook's `enqueueOperation` creates the operation row itself, and the
|
|
386
|
+
* multi-tenancy recording wrapper would otherwise write a second row that
|
|
387
|
+
* gets marked succeeded while the engine run is still in flight.
|
|
388
|
+
*/
|
|
389
|
+
declare function createWfpWorkflowProvisioningHook(options: WfpWorkflowProvisioningHookOptions): WfpTenantProvisioningHook;
|
|
390
|
+
|
|
391
|
+
export { TENANT_OPERATION_ENGINE, TenantProvisionVerificationError, createCloudflareWorkflowsExecutor, createProvisionVerifier, createWfpWorkflowProvisioningHook, reconcileTenantOperations, runProvisionOperation };
|
|
392
|
+
export type { CloudflareWorkflowsExecutorOptions, ProvisionOperationDeps, ProvisionStepName, ProvisionVerifierOptions, ReconcileTenantOperationsOptions, ReconcileTenantOperationsResult, TenantOperationWorkflowParams, WfpWorkflowProvisioningHookOptions, WorkflowInstanceHandle, WorkflowInstanceStatus, WorkflowsBinding };
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { r as e, t } from "./sync-defaults-errors-BlLZh6Gt.mjs";
|
|
2
|
+
import { buildEngineInstanceId as n, createOperationRecorder as r, isTerminalStatus as i } from "@authhero/multi-tenancy";
|
|
3
|
+
//#region src/workflows/provision-operation.ts
|
|
4
|
+
var a = {
|
|
5
|
+
retries: {
|
|
6
|
+
limit: 5,
|
|
7
|
+
delay: "5 seconds",
|
|
8
|
+
backoff: "exponential"
|
|
9
|
+
},
|
|
10
|
+
timeout: "2 minutes"
|
|
11
|
+
}, o = {
|
|
12
|
+
retries: {
|
|
13
|
+
limit: 8,
|
|
14
|
+
delay: "10 seconds",
|
|
15
|
+
backoff: "exponential"
|
|
16
|
+
},
|
|
17
|
+
timeout: "1 minute"
|
|
18
|
+
};
|
|
19
|
+
function s(e) {
|
|
20
|
+
return e instanceof Error ? e.message : String(e);
|
|
21
|
+
}
|
|
22
|
+
async function c(e, n, i) {
|
|
23
|
+
let c = r(e.stores), l = e.shouldProvision ?? ((e) => e.deployment_type === "wfp"), u = (t) => e.stepConfig?.[t] ?? (t === "verify" ? o : a), d = n.operation_id, f = n.tenant_id, p = await i.do("mark-running", u("mark-running"), async () => {
|
|
24
|
+
let t = await e.tenants.get(f);
|
|
25
|
+
if (!t || !l(t)) return await c.appendEvent(d, {
|
|
26
|
+
step: "mark-running",
|
|
27
|
+
outcome: "skipped",
|
|
28
|
+
detail: { reason: t ? "tenant is not WFP-provisioned" : "tenant row missing (rolled back?)" }
|
|
29
|
+
}), await c.markSucceeded(d), {
|
|
30
|
+
skipped: !0,
|
|
31
|
+
scriptName: "",
|
|
32
|
+
databaseName: ""
|
|
33
|
+
};
|
|
34
|
+
let n = e.steps.validate(), r = e.steps.names(f);
|
|
35
|
+
return await c.markRunning(d, "mark-running"), await c.appendEvent(d, {
|
|
36
|
+
step: "mark-running",
|
|
37
|
+
outcome: "succeeded"
|
|
38
|
+
}), {
|
|
39
|
+
skipped: !1,
|
|
40
|
+
...r,
|
|
41
|
+
...n
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
if (p.skipped) return;
|
|
45
|
+
let m = {
|
|
46
|
+
worker_script_name: p.scriptName,
|
|
47
|
+
bundle_configuration: p.bundleConfiguration,
|
|
48
|
+
worker_version: p.workerVersion,
|
|
49
|
+
database_version: p.databaseVersion
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
let n = await i.do("create-database", u("create-database"), async () => {
|
|
53
|
+
await c.setCurrentStep(d, "create-database");
|
|
54
|
+
let { id: t, created: n } = await e.steps.findOrCreateDatabase(p.databaseName);
|
|
55
|
+
return await c.appendEvent(d, {
|
|
56
|
+
step: "create-database",
|
|
57
|
+
outcome: "succeeded",
|
|
58
|
+
detail: {
|
|
59
|
+
database_id: t,
|
|
60
|
+
created: n
|
|
61
|
+
}
|
|
62
|
+
}), {
|
|
63
|
+
databaseId: t,
|
|
64
|
+
created: n
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
if (m.d1_database_id = n.databaseId, await i.do("apply-migrations", u("apply-migrations"), async () => {
|
|
68
|
+
await c.setCurrentStep(d, "apply-migrations"), await e.steps.applyMigrations(n.databaseId, n.created), await c.appendEvent(d, {
|
|
69
|
+
step: "apply-migrations",
|
|
70
|
+
outcome: "succeeded",
|
|
71
|
+
detail: { database_version: p.databaseVersion }
|
|
72
|
+
});
|
|
73
|
+
}), await i.do("upload-script", u("upload-script"), async () => {
|
|
74
|
+
await c.setCurrentStep(d, "upload-script"), await e.steps.uploadScript(p.scriptName, n.databaseId), await c.appendEvent(d, {
|
|
75
|
+
step: "upload-script",
|
|
76
|
+
outcome: "succeeded",
|
|
77
|
+
detail: { worker_version: p.workerVersion }
|
|
78
|
+
});
|
|
79
|
+
}), await i.do("upload-secrets", u("upload-secrets"), async () => {
|
|
80
|
+
await c.setCurrentStep(d, "upload-secrets"), await e.steps.uploadSecrets(p.scriptName, f), await c.appendEvent(d, {
|
|
81
|
+
step: "upload-secrets",
|
|
82
|
+
outcome: "succeeded"
|
|
83
|
+
});
|
|
84
|
+
}), e.syncDefaults) {
|
|
85
|
+
let n = e.syncDefaults;
|
|
86
|
+
await i.do("seed-defaults", u("seed-defaults"), async () => {
|
|
87
|
+
await c.setCurrentStep(d, "seed-defaults");
|
|
88
|
+
let e = t(await n(f));
|
|
89
|
+
if (e.length > 0) throw Error(`sync-defaults seed reported ${e.length} error(s): ${e.join("; ")}`);
|
|
90
|
+
await c.appendEvent(d, {
|
|
91
|
+
step: "seed-defaults",
|
|
92
|
+
outcome: "succeeded"
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (e.verify) {
|
|
97
|
+
let t = e.verify;
|
|
98
|
+
await i.do("verify", u("verify"), async () => {
|
|
99
|
+
await c.setCurrentStep(d, "verify"), await t(n.databaseId, f), await c.appendEvent(d, {
|
|
100
|
+
step: "verify",
|
|
101
|
+
outcome: "succeeded"
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
await i.do("mark-ready", u("mark-ready"), async () => {
|
|
106
|
+
await c.setCurrentStep(d, "mark-ready"), await e.tenants.update(f, {
|
|
107
|
+
...m,
|
|
108
|
+
provisioning_state: "ready",
|
|
109
|
+
provisioning_error: void 0,
|
|
110
|
+
provisioning_state_changed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
111
|
+
}), await c.appendEvent(d, {
|
|
112
|
+
step: "mark-ready",
|
|
113
|
+
outcome: "succeeded"
|
|
114
|
+
}), await c.markSucceeded(d);
|
|
115
|
+
});
|
|
116
|
+
} catch (t) {
|
|
117
|
+
throw await i.do("mark-failed", u("mark-failed"), async () => {
|
|
118
|
+
let n = s(t);
|
|
119
|
+
try {
|
|
120
|
+
await e.tenants.update(f, {
|
|
121
|
+
...m,
|
|
122
|
+
provisioning_state: "failed",
|
|
123
|
+
provisioning_error: n.slice(0, 2048),
|
|
124
|
+
provisioning_state_changed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
125
|
+
});
|
|
126
|
+
} catch (t) {
|
|
127
|
+
e.logger?.warn(`Failed to write provisioning_state="failed" for tenant ${f}:`, t);
|
|
128
|
+
}
|
|
129
|
+
await c.appendEvent(d, {
|
|
130
|
+
step: "mark-failed",
|
|
131
|
+
outcome: "failed",
|
|
132
|
+
detail: { message: n }
|
|
133
|
+
}), await c.markFailed(d, t);
|
|
134
|
+
}), t;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/workflows/verify.ts
|
|
139
|
+
var l = class extends Error {
|
|
140
|
+
checks;
|
|
141
|
+
constructor(e, t) {
|
|
142
|
+
super(e), this.name = "TenantProvisionVerificationError", this.checks = t;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
function u(e, t) {
|
|
146
|
+
for (let n of e) for (let e of n.results ?? []) if (!(!e || typeof e != "object")) {
|
|
147
|
+
for (let [n, r] of Object.entries(e)) if (n === t) {
|
|
148
|
+
if (typeof r == "number") return r;
|
|
149
|
+
if (typeof r == "string" && r !== "" && !isNaN(Number(r))) return Number(r);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
function d(t) {
|
|
155
|
+
let n = t.minKeys ?? 1;
|
|
156
|
+
return async (r, i) => {
|
|
157
|
+
let a = await t.client.execD1(r, `SELECT (SELECT COUNT(*) FROM keys) AS key_count, (SELECT COUNT(*) FROM tenants WHERE id = '${e(i)}') AS tenant_count;`), o = u(a, "key_count"), s = u(a, "tenant_count"), c = [];
|
|
158
|
+
if (o < n && c.push(`expected at least ${n} signing key(s) in "keys", found ${o}`), s < 1 && c.push(`tenant row "${i}" missing from "tenants"`), c.length > 0) throw new l(`Tenant D1 verification failed for "${i}": ${c.join("; ")}`, {
|
|
159
|
+
keyCount: o,
|
|
160
|
+
tenantRowCount: s
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/workflows/executor.ts
|
|
166
|
+
function f(e) {
|
|
167
|
+
let t = e instanceof Error ? e.message : String(e ?? "");
|
|
168
|
+
return /already exists|already been created|duplicate/i.test(t);
|
|
169
|
+
}
|
|
170
|
+
function p(e) {
|
|
171
|
+
return {
|
|
172
|
+
engine: "cloudflare-workflows",
|
|
173
|
+
async start(t) {
|
|
174
|
+
if (t.kind !== "provision") throw Error(`The Cloudflare Workflows executor does not support "${t.kind}" operations yet (phase 2 covers provision only)`);
|
|
175
|
+
if (!t.tenant_id) throw Error("provision operations require a tenant_id");
|
|
176
|
+
let r = t.engine_instance_id ?? n(t);
|
|
177
|
+
try {
|
|
178
|
+
await e.binding.create({
|
|
179
|
+
id: r,
|
|
180
|
+
params: {
|
|
181
|
+
operation_id: t.id,
|
|
182
|
+
tenant_id: t.tenant_id,
|
|
183
|
+
kind: t.kind
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (!f(e)) throw e;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/workflows/types.ts
|
|
194
|
+
var m = "cloudflare-workflows";
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/workflows/reconcile.ts
|
|
197
|
+
async function h(e) {
|
|
198
|
+
let { stores: t, tenants: a, binding: o } = e, s = r(t), c = e.minAgeMs ?? 600 * 1e3, l = e.limit ?? 100, u = new Date(Date.now() - c).toISOString(), { operations: d } = await t.tenantOperations.list({
|
|
199
|
+
status: ["pending", "running"],
|
|
200
|
+
engine: m,
|
|
201
|
+
updated_before: u,
|
|
202
|
+
per_page: l
|
|
203
|
+
}), f = {
|
|
204
|
+
scanned: d.length,
|
|
205
|
+
markedFailed: 0,
|
|
206
|
+
markedSucceeded: 0,
|
|
207
|
+
stillRunning: 0,
|
|
208
|
+
instanceMissing: 0,
|
|
209
|
+
errors: 0
|
|
210
|
+
};
|
|
211
|
+
for (let t of d) try {
|
|
212
|
+
await h(t);
|
|
213
|
+
} catch (n) {
|
|
214
|
+
f.errors++, e.logger?.warn(`Failed to reconcile tenant operation ${t.id}:`, n);
|
|
215
|
+
}
|
|
216
|
+
return f;
|
|
217
|
+
async function p(e, t) {
|
|
218
|
+
await s.appendEvent(e.id, {
|
|
219
|
+
step: e.current_step ?? "reconcile",
|
|
220
|
+
outcome: "reconciled",
|
|
221
|
+
detail: {
|
|
222
|
+
resolution: "failed",
|
|
223
|
+
reason: t
|
|
224
|
+
}
|
|
225
|
+
}), await s.markFailed(e.id, t), e.kind === "provision" && e.tenant_id && (await a.get(e.tenant_id))?.provisioning_state === "pending" && await a.update(e.tenant_id, {
|
|
226
|
+
provisioning_state: "failed",
|
|
227
|
+
provisioning_error: t.slice(0, 2048),
|
|
228
|
+
provisioning_state_changed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async function h(e) {
|
|
232
|
+
let r = await t.tenantOperations.get(e.id);
|
|
233
|
+
if (!r || i(r.status)) return;
|
|
234
|
+
let c = r.engine_instance_id ?? n(r), l;
|
|
235
|
+
try {
|
|
236
|
+
l = await o.get(c);
|
|
237
|
+
} catch {
|
|
238
|
+
f.instanceMissing++, f.markedFailed++, await p(r, `engine instance "${c}" not found (expired past retention or never started)`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
let u = await l.status();
|
|
242
|
+
if (u.status === "errored" || u.status === "terminated") {
|
|
243
|
+
let e = typeof u.error == "string" ? u.error : u.error?.message ?? `engine reported ${u.status}`;
|
|
244
|
+
f.markedFailed++, await p(r, e);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (u.status === "complete") {
|
|
248
|
+
(r.tenant_id ? await a.get(r.tenant_id) : null)?.provisioning_state === "ready" ? (f.markedSucceeded++, await s.appendEvent(r.id, {
|
|
249
|
+
step: r.current_step ?? "reconcile",
|
|
250
|
+
outcome: "reconciled",
|
|
251
|
+
detail: { resolution: "succeeded" }
|
|
252
|
+
}), await s.markSucceeded(r.id)) : (f.markedFailed++, await p(r, "engine instance completed but the operation was never finalized and the tenant is not ready"));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
f.stillRunning++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/workflows/enqueue-hook.ts
|
|
260
|
+
function g(e) {
|
|
261
|
+
let t = e.shouldProvision ?? ((e) => e.deployment_type === "wfp");
|
|
262
|
+
return {
|
|
263
|
+
async onProvision(n, r) {
|
|
264
|
+
let i = await e.tenants.get(n);
|
|
265
|
+
i && t(i) && await e.enqueueOperation({
|
|
266
|
+
kind: "provision",
|
|
267
|
+
tenant_id: n
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
async onUpgrade(t, n) {
|
|
271
|
+
await e.inline.onUpgrade(t, n);
|
|
272
|
+
},
|
|
273
|
+
async onDeprovision(t) {
|
|
274
|
+
await e.inline.onDeprovision(t);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
export { m as TENANT_OPERATION_ENGINE, l as TenantProvisionVerificationError, p as createCloudflareWorkflowsExecutor, d as createProvisionVerifier, g as createWfpWorkflowProvisioningHook, h as reconcileTenantOperations, c as runProvisionOperation };
|