@authhero/cloudflare-adapter 2.37.6 → 2.38.1
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 +67 -4
- package/dist/cloudflare-adapter.mjs +379 -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/index.d.ts +2 -2
- 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
package/dist/types/index.d.ts
CHANGED
|
@@ -19,8 +19,8 @@ export { createAnalyticsEngineAnalyticsAdapter } from "./analytics-engine-logs";
|
|
|
19
19
|
export { createAnalyticsEngineActionExecutionsAdapter } from "./analytics-engine-action-executions";
|
|
20
20
|
export { createR2SQLLogsAdapter } from "./r2-sql-logs";
|
|
21
21
|
export { createR2SQLStatsAdapter } from "./r2-sql-logs";
|
|
22
|
-
export { createCloudflareWfpD1Provisioner, createWfpTenantProvisioningHook, createWfpForwardMiddleware, CloudflareApiClient, CloudflareApiError, } from "./wfp-provisioner";
|
|
23
|
-
export type { CloudflareWfpD1Provisioner, CloudflareWfpD1ProvisionerOptions, ProvisionResult, ProvisionerMigration, TenantSecretsResolver, WfpTenantProvisioningHook, WfpTenantProvisioningHookOptions, CfApiClientOptions, D1Database, D1QueryResult, ScriptBinding, ScriptUploadOptions, WfpForwardOptions, } from "./wfp-provisioner";
|
|
22
|
+
export { createCloudflareWfpD1Provisioner, createWfpProvisionerSteps, createWfpTenantProvisioningHook, createWfpForwardMiddleware, CloudflareApiClient, CloudflareApiError, } from "./wfp-provisioner";
|
|
23
|
+
export type { CloudflareWfpD1Provisioner, CloudflareWfpD1ProvisionerOptions, TenantProvisionerSteps, TenantProvisionNames, WfpProvisionerSteps, ProvisionResult, ProvisionerMigration, TenantSecretsResolver, WfpTenantProvisioningHook, WfpTenantProvisioningHookOptions, CfApiClientOptions, D1Database, D1QueryResult, ScriptBinding, ScriptUploadOptions, WfpForwardOptions, } from "./wfp-provisioner";
|
|
24
24
|
export interface CloudflareAdapters {
|
|
25
25
|
customDomains: CustomDomainsAdapter;
|
|
26
26
|
cache: CacheAdapter;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { createCloudflareWfpD1Provisioner } from "./provisioner";
|
|
2
|
+
export { createWfpProvisionerSteps, escapeSqlLiteral, } from "./provisioner-steps";
|
|
3
|
+
export type { TenantProvisionerSteps, TenantProvisionNames, WfpProvisionerSteps, } from "./provisioner-steps";
|
|
4
|
+
export { collectSyncDefaultsErrors } from "./sync-defaults-errors";
|
|
2
5
|
export type { CloudflareWfpD1Provisioner, CloudflareWfpD1ProvisionerOptions, ProvisionResult, ProvisionerMigration, TenantSecretsResolver, } from "./types";
|
|
3
6
|
export { createWfpTenantProvisioningHook } from "./tenant-hook";
|
|
4
7
|
export type { WfpTenantProvisioningHook, WfpTenantProvisioningHookOptions, } from "./tenant-hook";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CloudflareApiClient } from "./cf-api";
|
|
2
|
+
import type { CloudflareWfpD1ProvisionerOptions } from "./types";
|
|
3
|
+
export declare function isNotFoundError(err: unknown): boolean;
|
|
4
|
+
export declare function escapeSqlLiteral(value: string): string;
|
|
5
|
+
export interface TenantProvisionNames {
|
|
6
|
+
scriptName: string;
|
|
7
|
+
databaseName: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Provider-agnostic contract for the individual, idempotent units of
|
|
11
|
+
* tenant provisioning. Deliberately free of Cloudflare/D1 terminology so
|
|
12
|
+
* the durable operation orchestration (issue #1026 phase 2) — and any
|
|
13
|
+
* future provider (e.g. Bunny with its SQLite databases) — can run against
|
|
14
|
+
* it. `createWfpProvisionerSteps` is the Cloudflare Workers-for-Platforms
|
|
15
|
+
* + D1 implementation.
|
|
16
|
+
*/
|
|
17
|
+
export interface TenantProvisionerSteps {
|
|
18
|
+
names(tenantId: string): TenantProvisionNames;
|
|
19
|
+
/**
|
|
20
|
+
* Validate the version token persisted into `tenants.database_version`
|
|
21
|
+
* BEFORE any provider side effects. Returns the recorded versions.
|
|
22
|
+
*/
|
|
23
|
+
validate(): {
|
|
24
|
+
databaseVersion?: string;
|
|
25
|
+
bundleConfiguration?: string;
|
|
26
|
+
workerVersion?: string;
|
|
27
|
+
};
|
|
28
|
+
findOrCreateDatabase(name: string): Promise<{
|
|
29
|
+
id: string;
|
|
30
|
+
created: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Reconcile migrations against the provisioner-owned tracking table
|
|
34
|
+
* (`_authhero_provisioner_migrations`), including the legacy backfill
|
|
35
|
+
* branch for pre-tracking databases.
|
|
36
|
+
*/
|
|
37
|
+
applyMigrations(databaseId: string, created: boolean): Promise<void>;
|
|
38
|
+
uploadScript(scriptName: string, databaseId: string): Promise<void>;
|
|
39
|
+
uploadSecrets(scriptName: string, tenantId: string): Promise<void>;
|
|
40
|
+
/** Best-effort teardown of both resources; throws a combined error. */
|
|
41
|
+
deprovision(tenantId: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The Cloudflare WFP + D1 implementation of `TenantProvisionerSteps`,
|
|
45
|
+
* extracted from `createCloudflareWfpD1Provisioner` so each unit can run
|
|
46
|
+
* as its own durable workflow step while the inline provisioner keeps
|
|
47
|
+
* running them as one sequence. No behavior change from the original
|
|
48
|
+
* closures — the provisioner is a thin sequence over this object.
|
|
49
|
+
*/
|
|
50
|
+
export interface WfpProvisionerSteps extends TenantProvisionerSteps {
|
|
51
|
+
/** Underlying REST client — shared with deprovision and the verify step. */
|
|
52
|
+
client: CloudflareApiClient;
|
|
53
|
+
}
|
|
54
|
+
export declare function createWfpProvisionerSteps(options: CloudflareWfpD1ProvisionerOptions): WfpProvisionerSteps;
|
|
@@ -43,5 +43,9 @@ import type { CloudflareWfpD1Provisioner, CloudflareWfpD1ProvisionerOptions } fr
|
|
|
43
43
|
* partial migrations applied) are NOT rolled back. The operator should
|
|
44
44
|
* treat re-running `onProvision(tenantId)` as safe; each step is idempotent
|
|
45
45
|
* on "already exists".
|
|
46
|
+
*
|
|
47
|
+
* The individual steps live in `createWfpProvisionerSteps` so the durable
|
|
48
|
+
* workflow executor (issue #1026 phase 2) can run the same units with
|
|
49
|
+
* per-step retries; this factory is a thin inline sequence over them.
|
|
46
50
|
*/
|
|
47
51
|
export declare function createCloudflareWfpD1Provisioner(options: CloudflareWfpD1ProvisionerOptions): CloudflareWfpD1Provisioner;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects per-entity `errors` from a sync-defaults apply result. The seed runs
|
|
3
|
+
* with `continueOnError`, so the tenant worker returns a 2xx (no rejection)
|
|
4
|
+
* even when individual entities fail — a clean resolve is therefore not proof
|
|
5
|
+
* the seed landed. Walk the result's entity outcomes and surface any collected
|
|
6
|
+
* errors so a partially-seeded tenant isn't marked `ready`.
|
|
7
|
+
*
|
|
8
|
+
* Shared between the inline tenant hook and the durable workflow's
|
|
9
|
+
* seed-defaults step (issue #1026 phase 2).
|
|
10
|
+
*/
|
|
11
|
+
export declare function collectSyncDefaultsErrors(result: unknown): string[];
|
|
@@ -78,8 +78,16 @@ export interface WfpTenantProvisioningHookOptions {
|
|
|
78
78
|
*/
|
|
79
79
|
syncDefaults?: (tenantId: string) => Promise<unknown>;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Structural mirror of `@authhero/multi-tenancy`'s `StepReporter` — kept
|
|
83
|
+
* local so this module carries no multi-tenancy dependency. When the
|
|
84
|
+
* control plane records provisions as tenant operations (issue #1026),
|
|
85
|
+
* the hook receives this callback and surfaces coarse step boundaries
|
|
86
|
+
* (`provision-resources`, `seed-defaults`) in the operation history.
|
|
87
|
+
*/
|
|
88
|
+
export type WfpProvisioningStepReporter = (step: string, outcome: "started" | "succeeded" | "failed", detail?: Record<string, unknown>) => Promise<void>;
|
|
81
89
|
export interface WfpTenantProvisioningHook {
|
|
82
|
-
onProvision(tenantId: string): Promise<void>;
|
|
90
|
+
onProvision(tenantId: string, report?: WfpProvisioningStepReporter): Promise<void>;
|
|
83
91
|
onDeprovision(tenantId: string): Promise<void>;
|
|
84
92
|
/**
|
|
85
93
|
* Re-run provisioning for an already-existing WFP tenant to pull it onto the
|
|
@@ -93,6 +101,6 @@ export interface WfpTenantProvisioningHook {
|
|
|
93
101
|
* Throws if the tenant doesn't exist or isn't WFP-provisioned — callers
|
|
94
102
|
* (e.g. a management-API redeploy endpoint) surface that as a 4xx.
|
|
95
103
|
*/
|
|
96
|
-
onUpgrade(tenantId: string): Promise<void>;
|
|
104
|
+
onUpgrade(tenantId: string, report?: WfpProvisioningStepReporter): Promise<void>;
|
|
97
105
|
}
|
|
98
106
|
export declare function createWfpTenantProvisioningHook(options: WfpTenantProvisioningHookOptions): WfpTenantProvisioningHook;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { TenantOperation, TenantsDataAdapter } from "@authhero/adapter-interfaces";
|
|
2
|
+
import type { WfpTenantProvisioningHook } from "../wfp-provisioner/tenant-hook";
|
|
3
|
+
export interface WfpWorkflowProvisioningHookOptions {
|
|
4
|
+
tenants: TenantsDataAdapter;
|
|
5
|
+
/**
|
|
6
|
+
* Enqueues a provision operation on the durable engine — typically
|
|
7
|
+
* `(input) => enqueueTenantOperation(stores, createCloudflareWorkflowsExecutor({ binding }), input)`.
|
|
8
|
+
* Resolves as soon as the engine instance is created.
|
|
9
|
+
*/
|
|
10
|
+
enqueueOperation: (input: {
|
|
11
|
+
kind: "provision";
|
|
12
|
+
tenant_id: string;
|
|
13
|
+
initiated_by?: string;
|
|
14
|
+
}) => Promise<TenantOperation>;
|
|
15
|
+
/** Same gate + default as the inline hook: `deployment_type === "wfp"`. */
|
|
16
|
+
shouldProvision?: (tenant: {
|
|
17
|
+
id: string;
|
|
18
|
+
deployment_type?: string;
|
|
19
|
+
storage_kind?: string;
|
|
20
|
+
}) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The existing inline hook (`createWfpTenantProvisioningHook(...)`).
|
|
23
|
+
* Upgrade and deprovision keep running inline until later phases make
|
|
24
|
+
* them durable.
|
|
25
|
+
*/
|
|
26
|
+
inline: WfpTenantProvisioningHook;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Drop-in replacement for `createWfpTenantProvisioningHook` on control
|
|
30
|
+
* planes that run provisioning through Cloudflare Workflows (issue #1026
|
|
31
|
+
* phase 2): `onProvision` enqueues a durable provision operation and
|
|
32
|
+
* returns immediately, leaving the tenant `pending` — the workflow's
|
|
33
|
+
* `mark-ready` / `mark-failed` steps own the terminal snapshot writes, and
|
|
34
|
+
* the reconciler covers instances that die mid-run.
|
|
35
|
+
*
|
|
36
|
+
* Semantic change to plan for downstream: tenant-create now returns with
|
|
37
|
+
* `provisioning_state: "pending"`; clients poll the tenant row or the
|
|
38
|
+
* operations API. An enqueue failure still throws, so
|
|
39
|
+
* `createProvisioningHooks.afterCreate` rolls the tenant row back exactly
|
|
40
|
+
* like an inline provision failure does today. This also replaces any
|
|
41
|
+
* best-effort post-create seed — the seed is a durable step inside the
|
|
42
|
+
* workflow.
|
|
43
|
+
*
|
|
44
|
+
* Wire it with `databaseIsolation.recordProvisionOperations: false` — this
|
|
45
|
+
* hook's `enqueueOperation` creates the operation row itself, and the
|
|
46
|
+
* multi-tenancy recording wrapper would otherwise write a second row that
|
|
47
|
+
* gets marked succeeded while the engine run is still in flight.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createWfpWorkflowProvisioningHook(options: WfpWorkflowProvisioningHookOptions): WfpTenantProvisioningHook;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REFERENCE ONLY — not part of the published bundle (nothing imports it).
|
|
3
|
+
*
|
|
4
|
+
* The downstream control-plane worker (the deploy repo) owns the
|
|
5
|
+
* `WorkflowEntrypoint` shell, because `cloudflare:workers` only exists in
|
|
6
|
+
* the Workers runtime. Everything else — orchestration, verify, write-back
|
|
7
|
+
* — comes from `@authhero/cloudflare-adapter/workflows`.
|
|
8
|
+
*
|
|
9
|
+
* wrangler.jsonc:
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {
|
|
12
|
+
* "workflows": [
|
|
13
|
+
* {
|
|
14
|
+
* "name": "tenant-operations",
|
|
15
|
+
* "binding": "TENANT_OPERATIONS_WORKFLOW",
|
|
16
|
+
* "class_name": "TenantOperationWorkflow"
|
|
17
|
+
* }
|
|
18
|
+
* ],
|
|
19
|
+
* // Reconciler cadence: at least daily (engine retention is 30 days);
|
|
20
|
+
* // every 15–60 minutes recommended.
|
|
21
|
+
* "triggers": { "crons": ["0,30 * * * *"] }
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Worker module:
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
28
|
+
* import {
|
|
29
|
+
* createProvisionVerifier,
|
|
30
|
+
* createCloudflareWorkflowsExecutor,
|
|
31
|
+
* createWfpWorkflowProvisioningHook,
|
|
32
|
+
* reconcileTenantOperations,
|
|
33
|
+
* runProvisionOperation,
|
|
34
|
+
* type ProvisionOperationDeps,
|
|
35
|
+
* type TenantOperationWorkflowParams,
|
|
36
|
+
* } from "@authhero/cloudflare-adapter/workflows";
|
|
37
|
+
* import { enqueueTenantOperation } from "@authhero/multi-tenancy";
|
|
38
|
+
* import {
|
|
39
|
+
* createWfpProvisionerSteps,
|
|
40
|
+
* createWfpTenantProvisioningHook,
|
|
41
|
+
* CloudflareApiClient,
|
|
42
|
+
* } from "@authhero/cloudflare-adapter";
|
|
43
|
+
* import { createDispatchSyncDefaults } from "@authhero/cloudflare-adapter/wfp";
|
|
44
|
+
*
|
|
45
|
+
* // Host-owned: build every workflow dependency from the worker env. Params
|
|
46
|
+
* // carry only { operation_id, tenant_id, kind } — never secrets.
|
|
47
|
+
* function buildProvisionDeps(env: Env): ProvisionOperationDeps {
|
|
48
|
+
* const adapters = createControlPlaneAdapters(env); // host's DB wiring
|
|
49
|
+
* const steps = createWfpProvisionerSteps({
|
|
50
|
+
* accountId: env.CLOUDFLARE_ACCOUNT_ID,
|
|
51
|
+
* apiToken: env.CLOUDFLARE_API_TOKEN,
|
|
52
|
+
* dispatchNamespace: "authhero-tenants",
|
|
53
|
+
* controlPlaneBaseUrl: env.PUBLIC_BASE_URL,
|
|
54
|
+
* tenantWorkerScript,
|
|
55
|
+
* migrations,
|
|
56
|
+
* secrets: async (tenantId) => ({ ... }),
|
|
57
|
+
* });
|
|
58
|
+
* return {
|
|
59
|
+
* steps,
|
|
60
|
+
* tenants: adapters.tenants,
|
|
61
|
+
* stores: {
|
|
62
|
+
* tenantOperations: adapters.tenantOperations!,
|
|
63
|
+
* tenantOperationEvents: adapters.tenantOperationEvents!,
|
|
64
|
+
* },
|
|
65
|
+
* syncDefaults: createDispatchSyncDefaults({ ... }),
|
|
66
|
+
* verify: createProvisionVerifier({ client: steps.client }),
|
|
67
|
+
* };
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* export class TenantOperationWorkflow extends WorkflowEntrypoint<Env, TenantOperationWorkflowParams> {
|
|
71
|
+
* async run(event: WorkflowEvent<TenantOperationWorkflowParams>, step: WorkflowStep) {
|
|
72
|
+
* // CF's WorkflowStep satisfies StepRunner structurally.
|
|
73
|
+
* await runProvisionOperation(buildProvisionDeps(this.env), event.payload, step);
|
|
74
|
+
* }
|
|
75
|
+
* }
|
|
76
|
+
*
|
|
77
|
+
* export default {
|
|
78
|
+
* fetch: app.fetch,
|
|
79
|
+
* async scheduled(_controller: ScheduledController, env: Env) {
|
|
80
|
+
* const adapters = createControlPlaneAdapters(env);
|
|
81
|
+
* const result = await reconcileTenantOperations({
|
|
82
|
+
* stores: {
|
|
83
|
+
* tenantOperations: adapters.tenantOperations!,
|
|
84
|
+
* tenantOperationEvents: adapters.tenantOperationEvents!,
|
|
85
|
+
* },
|
|
86
|
+
* tenants: adapters.tenants,
|
|
87
|
+
* binding: env.TENANT_OPERATIONS_WORKFLOW,
|
|
88
|
+
* });
|
|
89
|
+
* console.log("reconcileTenantOperations", result);
|
|
90
|
+
* },
|
|
91
|
+
* };
|
|
92
|
+
*
|
|
93
|
+
* // Tenant create wiring — replaces the inline provision (and any
|
|
94
|
+
* // best-effort post-create seed) with a durable enqueue:
|
|
95
|
+
* const inlineHook = createWfpTenantProvisioningHook({ provisioner, tenants, syncDefaults });
|
|
96
|
+
* const workflowHook = createWfpWorkflowProvisioningHook({
|
|
97
|
+
* tenants: adapters.tenants,
|
|
98
|
+
* inline: inlineHook, // upgrade/deprovision stay inline until phase 3/4
|
|
99
|
+
* enqueueOperation: (input) =>
|
|
100
|
+
* enqueueTenantOperation(
|
|
101
|
+
* stores,
|
|
102
|
+
* createCloudflareWorkflowsExecutor({ binding: env.TENANT_OPERATIONS_WORKFLOW }),
|
|
103
|
+
* input,
|
|
104
|
+
* ),
|
|
105
|
+
* });
|
|
106
|
+
* // databaseIsolation: {
|
|
107
|
+
* // getAdapters,
|
|
108
|
+
* // onProvision: workflowHook.onProvision,
|
|
109
|
+
* // onDeprovision: workflowHook.onDeprovision,
|
|
110
|
+
* // recordProvisionOperations: false, // the workflow owns the operation row
|
|
111
|
+
* // }
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* Rollout order for existing deployments: apply the control-plane
|
|
115
|
+
* migrations first, deploy the worker with both paths available, then flip
|
|
116
|
+
* tenant-create to the workflow hook.
|
|
117
|
+
*/
|
|
118
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type TenantOperationExecutor } from "@authhero/multi-tenancy";
|
|
2
|
+
import type { WorkflowsBinding } from "./types";
|
|
3
|
+
export interface CloudflareWorkflowsExecutorOptions {
|
|
4
|
+
binding: WorkflowsBinding;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* `TenantOperationExecutor` backed by a Cloudflare Workflows binding
|
|
8
|
+
* (issue #1026 phase 2). Row-first contract: `enqueueTenantOperation` has
|
|
9
|
+
* already persisted the operation (with its deterministic
|
|
10
|
+
* `engine_instance_id`) before `start` is called, so an instance can never
|
|
11
|
+
* exist without a tracking row.
|
|
12
|
+
*
|
|
13
|
+
* `start` resolves as soon as the instance is created — the workflow owns
|
|
14
|
+
* every subsequent write. Creating an instance whose id already exists is
|
|
15
|
+
* treated as success (idempotent re-enqueue after a crashed caller).
|
|
16
|
+
*/
|
|
17
|
+
export declare function createCloudflareWorkflowsExecutor(options: CloudflareWorkflowsExecutorOptions): TenantOperationExecutor;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable tenant lifecycle operations on Cloudflare Workflows
|
|
3
|
+
* (issue #1026 phase 2). The control-plane database is the source of
|
|
4
|
+
* truth; the engine is the executor — the workflow writes back to the
|
|
5
|
+
* `tenant_operations` / `tenant_operation_events` log at every step
|
|
6
|
+
* boundary, and `reconcileTenantOperations` sweeps up instances that died
|
|
7
|
+
* before their terminal write.
|
|
8
|
+
*
|
|
9
|
+
* Requires the optional `@authhero/multi-tenancy` peer (like `./wfp`).
|
|
10
|
+
* Nothing here imports `cloudflare:workers`: the downstream worker
|
|
11
|
+
* provides the ~10-line `WorkflowEntrypoint` shell (see
|
|
12
|
+
* `entrypoint.example.ts`) whose `WorkflowStep` satisfies `StepRunner`
|
|
13
|
+
* structurally.
|
|
14
|
+
*/
|
|
15
|
+
export { runProvisionOperation, type ProvisionOperationDeps, type ProvisionStepName, } from "./provision-operation";
|
|
16
|
+
export { createProvisionVerifier, TenantProvisionVerificationError, type ProvisionVerifierOptions, } from "./verify";
|
|
17
|
+
export { createCloudflareWorkflowsExecutor, type CloudflareWorkflowsExecutorOptions, } from "./executor";
|
|
18
|
+
export { reconcileTenantOperations, type ReconcileTenantOperationsOptions, type ReconcileTenantOperationsResult, } from "./reconcile";
|
|
19
|
+
export { createWfpWorkflowProvisioningHook, type WfpWorkflowProvisioningHookOptions, } from "./enqueue-hook";
|
|
20
|
+
export { TENANT_OPERATION_ENGINE, type TenantOperationWorkflowParams, type WorkflowInstanceHandle, type WorkflowInstanceStatus, type WorkflowsBinding, } from "./types";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { TenantsDataAdapter } from "@authhero/adapter-interfaces";
|
|
2
|
+
import { type StepConfig, type StepRunner, type TenantOperationStores } from "@authhero/multi-tenancy";
|
|
3
|
+
import type { TenantProvisionerSteps } from "../wfp-provisioner/provisioner-steps";
|
|
4
|
+
import type { TenantOperationWorkflowParams } from "./types";
|
|
5
|
+
export type ProvisionStepName = "mark-running" | "create-database" | "apply-migrations" | "upload-script" | "upload-secrets" | "seed-defaults" | "verify" | "mark-ready" | "mark-failed";
|
|
6
|
+
export interface ProvisionOperationDeps {
|
|
7
|
+
/** Provider-agnostic provisioner steps (all idempotent) — e.g.
|
|
8
|
+
* `createWfpProvisionerSteps` for Cloudflare WFP + D1. */
|
|
9
|
+
steps: TenantProvisionerSteps;
|
|
10
|
+
/** Control-plane tenants adapter (snapshot writes). */
|
|
11
|
+
tenants: TenantsDataAdapter;
|
|
12
|
+
/** Control-plane operation log stores. */
|
|
13
|
+
stores: TenantOperationStores;
|
|
14
|
+
/**
|
|
15
|
+
* Defaults seed (`createDispatchSyncDefaults(...)`). Runs as a retried
|
|
16
|
+
* step BEFORE `ready`; per-entity errors in the resolved result fail the
|
|
17
|
+
* step. Optional only for parity with the inline hook — WFP control
|
|
18
|
+
* planes should always set it.
|
|
19
|
+
*/
|
|
20
|
+
syncDefaults?: (tenantId: string) => Promise<unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Post-seed verification (`createProvisionVerifier(...)`). Throws until
|
|
23
|
+
* the tenant database actually holds keys + the tenant row; retried with backoff so a
|
|
24
|
+
* propagation race becomes a few retries instead of a bad `ready`.
|
|
25
|
+
*/
|
|
26
|
+
verify?: (databaseId: string, tenantId: string) => Promise<void>;
|
|
27
|
+
/** Same gate + default as the inline hook: `deployment_type === "wfp"`. */
|
|
28
|
+
shouldProvision?: (tenant: {
|
|
29
|
+
id: string;
|
|
30
|
+
deployment_type?: string;
|
|
31
|
+
storage_kind?: string;
|
|
32
|
+
}) => boolean;
|
|
33
|
+
logger?: Pick<Console, "warn">;
|
|
34
|
+
/** Per-step overrides of the retry/timeout defaults. */
|
|
35
|
+
stepConfig?: Partial<Record<ProvisionStepName, StepConfig>>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Durable provision-as-workflow (issue #1026 phase 2): the full provision
|
|
39
|
+
* sequence — resources, migrations, script, secrets, seed, verify — as one
|
|
40
|
+
* engine step per unit, writing back to the control-plane operation log at
|
|
41
|
+
* every step boundary. The DB write happens inside the same `step.do` as
|
|
42
|
+
* the side effect, so it is durable and retried with the step; all side
|
|
43
|
+
* effects are idempotent, which makes replay after a retry or eviction
|
|
44
|
+
* safe.
|
|
45
|
+
*
|
|
46
|
+
* Terminal writes: `mark-ready` flips the tenant snapshot to `ready` and
|
|
47
|
+
* the operation to `succeeded`; any failure runs `mark-failed` (persisting
|
|
48
|
+
* whatever resource ids exist, mirroring the inline hook's failed branch)
|
|
49
|
+
* and rethrows so the engine instance ends `errored`. If even `mark-failed`
|
|
50
|
+
* dies, the operation stays `running` and the reconciler sweep copies the
|
|
51
|
+
* engine's terminal state into the DB.
|
|
52
|
+
*
|
|
53
|
+
* Runs against any `StepRunner` — Cloudflare's `WorkflowStep` satisfies it
|
|
54
|
+
* structurally, and tests use fakes.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runProvisionOperation(deps: ProvisionOperationDeps, params: TenantOperationWorkflowParams, step: StepRunner): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { TenantsDataAdapter } from "@authhero/adapter-interfaces";
|
|
2
|
+
import { type TenantOperationStores } from "@authhero/multi-tenancy";
|
|
3
|
+
import { type WorkflowsBinding } from "./types";
|
|
4
|
+
export interface ReconcileTenantOperationsOptions {
|
|
5
|
+
stores: TenantOperationStores;
|
|
6
|
+
tenants: TenantsDataAdapter;
|
|
7
|
+
binding: WorkflowsBinding;
|
|
8
|
+
/**
|
|
9
|
+
* Only touch operations whose `updated_at` is older than this — fresh
|
|
10
|
+
* runs write at every step boundary, so a recently-updated operation is
|
|
11
|
+
* alive. Default 10 minutes.
|
|
12
|
+
*/
|
|
13
|
+
minAgeMs?: number;
|
|
14
|
+
/** Max stuck operations per sweep. Default 100. */
|
|
15
|
+
limit?: number;
|
|
16
|
+
logger?: Pick<Console, "warn">;
|
|
17
|
+
}
|
|
18
|
+
export interface ReconcileTenantOperationsResult {
|
|
19
|
+
scanned: number;
|
|
20
|
+
markedFailed: number;
|
|
21
|
+
markedSucceeded: number;
|
|
22
|
+
stillRunning: number;
|
|
23
|
+
instanceMissing: number;
|
|
24
|
+
errors: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Sweep for operations stuck in `pending`/`running` whose engine instance
|
|
28
|
+
* died before reaching its own terminal write (issue #1026): resolve the
|
|
29
|
+
* instance (via the stored — or re-derived, it's deterministic — id), and
|
|
30
|
+
* copy terminal engine states into the control-plane DB.
|
|
31
|
+
*
|
|
32
|
+
* The engine retains completed-instance state for at most 30 days, so
|
|
33
|
+
* hosts must run this at least daily; every 15–60 minutes is recommended
|
|
34
|
+
* (wire it to the worker's `scheduled` handler). One operation's engine
|
|
35
|
+
* error never aborts the sweep.
|
|
36
|
+
*
|
|
37
|
+
* Decision table per stuck operation:
|
|
38
|
+
* - handle lookup throws (expired / never created) → `failed`
|
|
39
|
+
* - engine `errored` / `terminated` → `failed` (engine error copied)
|
|
40
|
+
* - engine `complete` but operation non-terminal (the final write raced or
|
|
41
|
+
* was lost) → decided from the tenant snapshot, which is the source of
|
|
42
|
+
* truth: `provisioning_state === "ready"` → `succeeded`, else `failed`
|
|
43
|
+
* - anything else → still running, left untouched
|
|
44
|
+
*/
|
|
45
|
+
export declare function reconcileTenantOperations(options: ReconcileTenantOperationsOptions): Promise<ReconcileTenantOperationsResult>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural types for the Cloudflare Workflows engine. Deliberately NOT
|
|
3
|
+
* imported from `cloudflare:workers` / `@cloudflare/workers-types` — this
|
|
4
|
+
* package ships platform-agnostic bundles and the real runtime objects
|
|
5
|
+
* satisfy these shapes structurally (precedent: `@authhero/proxy`'s local
|
|
6
|
+
* binding types). Only the downstream worker's `WorkflowEntrypoint` shell
|
|
7
|
+
* touches `cloudflare:workers` (see `entrypoint.example.ts`).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The ONLY data that crosses the enqueue boundary. Workflows persists
|
|
11
|
+
* params (and shows them in the dashboard), so never put secrets here —
|
|
12
|
+
* the workflow re-resolves everything else from its worker env.
|
|
13
|
+
*/
|
|
14
|
+
export interface TenantOperationWorkflowParams {
|
|
15
|
+
operation_id: string;
|
|
16
|
+
tenant_id: string;
|
|
17
|
+
/** Widened beyond "provision" in later phases (upgrade, backup, …). */
|
|
18
|
+
kind: "provision";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Instance status as reported by the engine. The exact value set has
|
|
22
|
+
* drifted across Cloudflare releases; the reconciler only branches on the
|
|
23
|
+
* terminal values and treats anything unrecognized as still-running.
|
|
24
|
+
*/
|
|
25
|
+
export interface WorkflowInstanceStatus {
|
|
26
|
+
status: "queued" | "running" | "paused" | "errored" | "terminated" | "complete" | "waiting" | "waitingForPause" | "unknown" | (string & {});
|
|
27
|
+
error?: {
|
|
28
|
+
name?: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
} | string | null;
|
|
31
|
+
output?: unknown;
|
|
32
|
+
}
|
|
33
|
+
/** Structurally satisfied by the handles a `workflows` binding returns. */
|
|
34
|
+
export interface WorkflowInstanceHandle {
|
|
35
|
+
id: string;
|
|
36
|
+
status(): Promise<WorkflowInstanceStatus>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Structurally satisfied by a Workflows binding
|
|
40
|
+
* (e.g. `env.TENANT_OPERATIONS_WORKFLOW`).
|
|
41
|
+
*/
|
|
42
|
+
export interface WorkflowsBinding {
|
|
43
|
+
create(options: {
|
|
44
|
+
id: string;
|
|
45
|
+
params: TenantOperationWorkflowParams;
|
|
46
|
+
}): Promise<WorkflowInstanceHandle>;
|
|
47
|
+
get(id: string): Promise<WorkflowInstanceHandle>;
|
|
48
|
+
}
|
|
49
|
+
export declare const TENANT_OPERATION_ENGINE = "cloudflare-workflows";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CloudflareApiClient } from "../wfp-provisioner/cf-api";
|
|
2
|
+
export interface ProvisionVerifierOptions {
|
|
3
|
+
client: CloudflareApiClient;
|
|
4
|
+
/** Minimum number of signing keys the tenant D1 must hold. Default 1. */
|
|
5
|
+
minKeys?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Thrown when the freshly provisioned D1 fails the post-seed checks. The
|
|
9
|
+
* message is self-contained (it survives workflow step serialization) and
|
|
10
|
+
* names exactly which check failed — it ends up in
|
|
11
|
+
* `tenant_operation_events.detail` and, when retries exhaust, in
|
|
12
|
+
* `tenants.provisioning_error`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class TenantProvisionVerificationError extends Error {
|
|
15
|
+
readonly checks: {
|
|
16
|
+
keyCount: number;
|
|
17
|
+
tenantRowCount: number;
|
|
18
|
+
};
|
|
19
|
+
constructor(message: string, checks: {
|
|
20
|
+
keyCount: number;
|
|
21
|
+
tenantRowCount: number;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Post-provision verification (issue #1026): assert the tenant D1 actually
|
|
26
|
+
* contains signing keys and its own tenant row before the tenant is marked
|
|
27
|
+
* `ready`. Queries D1 over the same REST path the provisioner's migrations
|
|
28
|
+
* use — this is what turns the 2026-07-02 "ready but empty D1" incident
|
|
29
|
+
* class into a retried workflow step instead of a silent success.
|
|
30
|
+
*
|
|
31
|
+
* Limitation (accepted for v1): this proves the rows landed in D1 via the
|
|
32
|
+
* REST API, not that the tenant worker's binding observes them; a worker
|
|
33
|
+
* HTTP probe can be layered on later.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createProvisionVerifier(options: ProvisionVerifierOptions): (databaseId: string, tenantId: string) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./sync-defaults-errors-Htp1NWTq.js");let t=require("@authhero/multi-tenancy");var n={retries:{limit:5,delay:`5 seconds`,backoff:`exponential`},timeout:`2 minutes`},r={retries:{limit:8,delay:`10 seconds`,backoff:`exponential`},timeout:`1 minute`};function i(e){return e instanceof Error?e.message:String(e)}async function a(a,o,s){let c=(0,t.createOperationRecorder)(a.stores),l=a.shouldProvision??(e=>e.deployment_type===`wfp`),u=e=>a.stepConfig?.[e]??(e===`verify`?r:n),d=o.operation_id,f=o.tenant_id,p=await s.do(`mark-running`,u(`mark-running`),async()=>{let e=await a.tenants.get(f);if(!e||!l(e))return await c.appendEvent(d,{step:`mark-running`,outcome:`skipped`,detail:{reason:e?`tenant is not WFP-provisioned`:`tenant row missing (rolled back?)`}}),await c.markSucceeded(d),{skipped:!0,scriptName:``,databaseName:``};let t=a.steps.validate(),n=a.steps.names(f);return await c.markRunning(d,`mark-running`),await c.appendEvent(d,{step:`mark-running`,outcome:`succeeded`}),{skipped:!1,...n,...t}});if(p.skipped)return;let m={worker_script_name:p.scriptName,bundle_configuration:p.bundleConfiguration,worker_version:p.workerVersion,database_version:p.databaseVersion};try{let t=await s.do(`create-database`,u(`create-database`),async()=>{await c.setCurrentStep(d,`create-database`);let{id:e,created:t}=await a.steps.findOrCreateDatabase(p.databaseName);return await c.appendEvent(d,{step:`create-database`,outcome:`succeeded`,detail:{database_id:e,created:t}}),{databaseId:e,created:t}});if(m.d1_database_id=t.databaseId,await s.do(`apply-migrations`,u(`apply-migrations`),async()=>{await c.setCurrentStep(d,`apply-migrations`),await a.steps.applyMigrations(t.databaseId,t.created),await c.appendEvent(d,{step:`apply-migrations`,outcome:`succeeded`,detail:{database_version:p.databaseVersion}})}),await s.do(`upload-script`,u(`upload-script`),async()=>{await c.setCurrentStep(d,`upload-script`),await a.steps.uploadScript(p.scriptName,t.databaseId),await c.appendEvent(d,{step:`upload-script`,outcome:`succeeded`,detail:{worker_version:p.workerVersion}})}),await s.do(`upload-secrets`,u(`upload-secrets`),async()=>{await c.setCurrentStep(d,`upload-secrets`),await a.steps.uploadSecrets(p.scriptName,f),await c.appendEvent(d,{step:`upload-secrets`,outcome:`succeeded`})}),a.syncDefaults){let t=a.syncDefaults;await s.do(`seed-defaults`,u(`seed-defaults`),async()=>{await c.setCurrentStep(d,`seed-defaults`);let n=e.t(await t(f));if(n.length>0)throw Error(`sync-defaults seed reported ${n.length} error(s): ${n.join(`; `)}`);await c.appendEvent(d,{step:`seed-defaults`,outcome:`succeeded`})})}if(a.verify){let e=a.verify;await s.do(`verify`,u(`verify`),async()=>{await c.setCurrentStep(d,`verify`),await e(t.databaseId,f),await c.appendEvent(d,{step:`verify`,outcome:`succeeded`})})}await s.do(`mark-ready`,u(`mark-ready`),async()=>{await c.setCurrentStep(d,`mark-ready`),await a.tenants.update(f,{...m,provisioning_state:`ready`,provisioning_error:void 0,provisioning_state_changed_at:new Date().toISOString()}),await c.appendEvent(d,{step:`mark-ready`,outcome:`succeeded`}),await c.markSucceeded(d)})}catch(e){throw await s.do(`mark-failed`,u(`mark-failed`),async()=>{let t=i(e);try{await a.tenants.update(f,{...m,provisioning_state:`failed`,provisioning_error:t.slice(0,2048),provisioning_state_changed_at:new Date().toISOString()})}catch(e){a.logger?.warn(`Failed to write provisioning_state="failed" for tenant ${f}:`,e)}await c.appendEvent(d,{step:`mark-failed`,outcome:`failed`,detail:{message:t}}),await c.markFailed(d,e)}),e}}var o=class extends Error{checks;constructor(e,t){super(e),this.name=`TenantProvisionVerificationError`,this.checks=t}};function s(e,t){for(let n of e)for(let e of n.results??[])if(!(!e||typeof e!=`object`)){for(let[n,r]of Object.entries(e))if(n===t){if(typeof r==`number`)return r;if(typeof r==`string`&&r!==``&&!isNaN(Number(r)))return Number(r)}}return 0}function c(t){let n=t.minKeys??1;return async(r,i)=>{let a=await t.client.execD1(r,`SELECT (SELECT COUNT(*) FROM keys) AS key_count, (SELECT COUNT(*) FROM tenants WHERE id = '${e.r(i)}') AS tenant_count;`),c=s(a,`key_count`),l=s(a,`tenant_count`),u=[];if(c<n&&u.push(`expected at least ${n} signing key(s) in "keys", found ${c}`),l<1&&u.push(`tenant row "${i}" missing from "tenants"`),u.length>0)throw new o(`Tenant D1 verification failed for "${i}": ${u.join(`; `)}`,{keyCount:c,tenantRowCount:l})}}function l(e){let t=e instanceof Error?e.message:String(e??``);return/already exists|already been created|duplicate/i.test(t)}function u(e){return{engine:`cloudflare-workflows`,async start(n){if(n.kind!==`provision`)throw Error(`The Cloudflare Workflows executor does not support "${n.kind}" operations yet (phase 2 covers provision only)`);if(!n.tenant_id)throw Error(`provision operations require a tenant_id`);let r=n.engine_instance_id??(0,t.buildEngineInstanceId)(n);try{await e.binding.create({id:r,params:{operation_id:n.id,tenant_id:n.tenant_id,kind:n.kind}})}catch(e){if(!l(e))throw e}}}}var d=`cloudflare-workflows`;async function f(e){let{stores:n,tenants:r,binding:i}=e,a=(0,t.createOperationRecorder)(n),o=e.minAgeMs??600*1e3,s=e.limit??100,c=new Date(Date.now()-o).toISOString(),{operations:l}=await n.tenantOperations.list({status:[`pending`,`running`],engine:d,updated_before:c,per_page:s}),u={scanned:l.length,markedFailed:0,markedSucceeded:0,stillRunning:0,instanceMissing:0,errors:0};for(let t of l)try{await p(t)}catch(n){u.errors++,e.logger?.warn(`Failed to reconcile tenant operation ${t.id}:`,n)}return u;async function f(e,t){await a.appendEvent(e.id,{step:e.current_step??`reconcile`,outcome:`reconciled`,detail:{resolution:`failed`,reason:t}}),await a.markFailed(e.id,t),e.kind===`provision`&&e.tenant_id&&(await r.get(e.tenant_id))?.provisioning_state===`pending`&&await r.update(e.tenant_id,{provisioning_state:`failed`,provisioning_error:t.slice(0,2048),provisioning_state_changed_at:new Date().toISOString()})}async function p(e){let o=await n.tenantOperations.get(e.id);if(!o||(0,t.isTerminalStatus)(o.status))return;let s=o.engine_instance_id??(0,t.buildEngineInstanceId)(o),c;try{c=await i.get(s)}catch{u.instanceMissing++,u.markedFailed++,await f(o,`engine instance "${s}" not found (expired past retention or never started)`);return}let l=await c.status();if(l.status===`errored`||l.status===`terminated`){let e=typeof l.error==`string`?l.error:l.error?.message??`engine reported ${l.status}`;u.markedFailed++,await f(o,e);return}if(l.status===`complete`){(o.tenant_id?await r.get(o.tenant_id):null)?.provisioning_state===`ready`?(u.markedSucceeded++,await a.appendEvent(o.id,{step:o.current_step??`reconcile`,outcome:`reconciled`,detail:{resolution:`succeeded`}}),await a.markSucceeded(o.id)):(u.markedFailed++,await f(o,`engine instance completed but the operation was never finalized and the tenant is not ready`));return}u.stillRunning++}}function p(e){let t=e.shouldProvision??(e=>e.deployment_type===`wfp`);return{async onProvision(n,r){let i=await e.tenants.get(n);i&&t(i)&&await e.enqueueOperation({kind:`provision`,tenant_id:n})},async onUpgrade(t,n){await e.inline.onUpgrade(t,n)},async onDeprovision(t){await e.inline.onDeprovision(t)}}}exports.TENANT_OPERATION_ENGINE=d,exports.TenantProvisionVerificationError=o,exports.createCloudflareWorkflowsExecutor=u,exports.createProvisionVerifier=c,exports.createWfpWorkflowProvisioningHook=p,exports.reconcileTenantOperations=f,exports.runProvisionOperation=a;
|