@authhero/cloudflare-adapter 3.3.0 → 4.0.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.
@@ -23,7 +23,7 @@ export { createAnalyticsEngineOutboxMetricsSink } from "./analytics-engine-outbo
23
23
  export type { AnalyticsEngineOutboxMetricsConfig, OutboxMetricRecord, } from "./analytics-engine-outbox-metrics";
24
24
  export { createR2SQLLogsAdapter } from "./r2-sql-logs";
25
25
  export { createR2SQLStatsAdapter } from "./r2-sql-logs";
26
- export { createCloudflareWfpD1Provisioner, createWfpProvisionerSteps, createWfpTenantProvisioningHook, createWfpForwardMiddleware, CloudflareApiClient, CloudflareApiError, } from "./wfp-provisioner";
26
+ export { createCloudflareWfpD1Provisioner, createWfpProvisionerSteps, createWfpTenantProvisioningHook, createWfpForwardMiddleware, DEFAULT_LOCAL_PATHS, CloudflareApiClient, CloudflareApiError, } from "./wfp-provisioner";
27
27
  export type { CloudflareWfpD1Provisioner, CloudflareWfpD1ProvisionerOptions, TenantProvisionerSteps, TenantProvisionNames, WfpProvisionerSteps, ProvisionResult, ProvisionerMigration, TenantSecretsResolver, WfpTenantProvisioningHook, WfpTenantProvisioningHookOptions, CfApiClientOptions, D1Database, D1QueryResult, ScriptBinding, ScriptUploadOptions, WfpForwardOptions, } from "./wfp-provisioner";
28
28
  export interface CloudflareAdapters {
29
29
  customDomains: CustomDomainsAdapter;
@@ -0,0 +1,31 @@
1
+ import type { DataAdapters } from "@authhero/adapter-interfaces";
2
+ import { type DetachConfigFn } from "@authhero/multi-tenancy";
3
+ import type { DispatchNamespace } from "../code-executor";
4
+ export interface DispatchDetachOptions {
5
+ /** The dispatch namespace binding the tenant workers live in. */
6
+ dispatcher: DispatchNamespace;
7
+ /** Script-name convention. Supports `{tenant_id}`. */
8
+ scriptNameTemplate?: string;
9
+ /**
10
+ * Shared secret the tenant worker checks on `/internal/detach`
11
+ * (`WFP_INTERNAL_SYNC_SECRET`). Sent as a bearer token.
12
+ */
13
+ internalSecret: string;
14
+ /**
15
+ * The control plane's adapters, where the tenant row that decides lives. Use
16
+ * the raw layer.
17
+ */
18
+ controlPlaneAdapters: DataAdapters;
19
+ /** Per-push timeout. Defaults to 120s. */
20
+ timeoutMs?: number;
21
+ }
22
+ /**
23
+ * Builds the `detach` operation's transport for tenants with their own worker
24
+ * and database. The control plane checks that the tenant can be detached from
25
+ * its own tenant row, asks the tenant worker to convert (or remove) the copies
26
+ * in its database, and only when that went through marks its own tenant row
27
+ * detached. A preview or a partial result leaves the row as it was, so a retry
28
+ * can finish the job. Rejects when the push fails or the worker answers non-2xx,
29
+ * which fails the operation.
30
+ */
31
+ export declare function createDispatchDetach(options: DispatchDetachOptions): DetachConfigFn;
@@ -0,0 +1,23 @@
1
+ import type { TenantMemberRequestStore } from "@authhero/multi-tenancy";
2
+ import type { DispatchNamespace } from "../code-executor";
3
+ export declare const TENANT_MEMBER_REQUESTS_PATH = "/internal/tenant-member-requests";
4
+ export interface DispatchTenantMemberRequestStoreOptions {
5
+ /** The dispatch namespace binding the tenant workers live in. */
6
+ dispatcher: DispatchNamespace;
7
+ /** Script-name convention. Supports `{tenant_id}`. */
8
+ scriptNameTemplate?: string;
9
+ /**
10
+ * Shared secret the tenant worker checks on `/internal/*`
11
+ * (`WFP_INTERNAL_SYNC_SECRET`). Sent as a bearer token.
12
+ */
13
+ internalSecret: string;
14
+ /** Per-call timeout. Defaults to 15s. */
15
+ timeoutMs?: number;
16
+ }
17
+ /**
18
+ * Reaches the team-change requests in a tenant worker's own database, for the
19
+ * `tenant-members-request` operation and its reconcile sweep (issue #1550):
20
+ * read a request, answer it, and list the ones still pending. One endpoint,
21
+ * `POST /internal/tenant-member-requests`, takes the action in its body.
22
+ */
23
+ export declare function createDispatchTenantMemberRequestStore(options: DispatchTenantMemberRequestStoreOptions): TenantMemberRequestStore;
@@ -6,5 +6,7 @@
6
6
  */
7
7
  export { createDispatchSyncDefaults, type DispatchSyncDefaultsOptions, } from "./dispatch-sync-defaults";
8
8
  export { createDispatchSyncConfig, type DispatchSyncConfigOptions, } from "./dispatch-sync-config";
9
+ export { createDispatchDetach, type DispatchDetachOptions, } from "./dispatch-detach";
10
+ export { createDispatchTenantMemberRequestStore, TENANT_MEMBER_REQUESTS_PATH, type DispatchTenantMemberRequestStoreOptions, } from "./dispatch-tenant-member-requests";
9
11
  export { createDispatchRenewSigningCertificates, type DispatchRenewSigningCertificatesOptions, type DispatchRenewSigningCertificatesResult, } from "./dispatch-renew-signing-certificates";
10
12
  export { createWfpTenantApp, type WfpTenantEnv, type WfpTenantAppOptions, type WfpSigningCertificateAuthority, } from "./tenant-app";
@@ -1,6 +1,6 @@
1
1
  import { type ExecutionContext } from "hono";
2
2
  import { type AuthHeroConfig, type CertificateIssuer, type DataAdapters, type IssuerResolver } from "authhero";
3
- import { type ControlPlaneDefaultsApplyResult } from "@authhero/multi-tenancy";
3
+ import { type ControlPlaneDefaultsApplyResult, type InheritanceShadowOptions } from "@authhero/multi-tenancy";
4
4
  /**
5
5
  * Env contract for a WFP tenant worker — the bindings + secrets
6
6
  * `createCloudflareWfpD1Provisioner` (and the host's `secrets` resolver) set on
@@ -93,6 +93,22 @@ export interface WfpTenantAppOptions<Env extends WfpTenantEnv = WfpTenantEnv> {
93
93
  * control plane via `createDispatchRenewSigningCertificates`.
94
94
  */
95
95
  signingCertificateAuthority?: (env: Env) => WfpSigningCertificateAuthority | undefined;
96
+ /**
97
+ * Shadow mode for config inheritance, run in this worker: after a request is
98
+ * answered, compare what the projected control-plane rows give this tenant
99
+ * (the runtime fallback) with what its parent chain resolves to, and log the
100
+ * differences as `cfgdiff` in this tenant's own logs
101
+ * (`GET /api/v2/logs?q=type:cfgdiff`, forwarded here). Never changes a
102
+ * response. The comparison runs under `ctx.waitUntil`, so the Worker keeps
103
+ * it alive after the response; it is sampled and rate-limited like the
104
+ * colocated shadow mode. `true` uses the defaults.
105
+ *
106
+ * The worker only knows the control plane's rows (the ones projected into its
107
+ * database) and the tenant's own, so it compares against those. If the
108
+ * tenant's row names a parent this database lacks, the comparison is skipped;
109
+ * a deeper chain is checked by _Verify_ on the control plane.
110
+ */
111
+ inheritanceShadow?: boolean | InheritanceShadowOptions;
96
112
  }
97
113
  export interface WfpSigningCertificateAuthority {
98
114
  issuer: CertificateIssuer;
@@ -5,7 +5,7 @@ export { collectSyncDefaultsErrors } from "./sync-defaults-errors";
5
5
  export type { CloudflareWfpD1Provisioner, CloudflareWfpD1ProvisionerOptions, ProvisionResult, ProvisionerMigration, TenantSecretsResolver, } from "./types";
6
6
  export { createWfpTenantProvisioningHook } from "./tenant-hook";
7
7
  export type { WfpTenantProvisioningHook, WfpTenantProvisioningHookOptions, } from "./tenant-hook";
8
- export { createWfpForwardMiddleware } from "./wfp-forward";
8
+ export { createWfpForwardMiddleware, DEFAULT_LOCAL_PATHS } from "./wfp-forward";
9
9
  export type { WfpForwardOptions } from "./wfp-forward";
10
10
  export { CloudflareApiClient, CloudflareApiError } from "./cf-api";
11
11
  export type { CfApiClientOptions, D1Database, D1QueryResult, ScriptBinding, ScriptUploadOptions, } from "./cf-api";
@@ -1,5 +1,18 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import type { TenantsDataAdapter } from "@authhero/adapter-interfaces";
3
+ /**
4
+ * Path prefixes served from the control plane rather than dispatched to a
5
+ * tenant worker. These are shared static assets that are byte-identical across
6
+ * tenants and only exist on the control plane's deployment.
7
+ *
8
+ * `/u/widget/` is the universal-login (u2) widget bundle. It's a multi-file
9
+ * Stencil bundle the SSR'd pages load as an ES module to hydrate the form. A
10
+ * per-tenant dispatch worker has no static-assets binding and no configured
11
+ * `widgetHandler`, so forwarding these requests 404s and the u2 buttons render
12
+ * inert. Serving them from the control plane — where the assets live — keeps
13
+ * zero per-tenant weight.
14
+ */
15
+ export declare const DEFAULT_LOCAL_PATHS: readonly string[];
3
16
  export interface WfpForwardOptions {
4
17
  /** Tenants adapter, used to look up the resolved tenant's `deployment_type`. */
5
18
  tenants: TenantsDataAdapter;
@@ -29,11 +42,16 @@ export interface WfpForwardOptions {
29
42
  * handlers.
30
43
  *
31
44
  * Use this for shared static assets that only the control plane can serve —
32
- * a per-tenant dispatch worker has no static-assets binding. Defaults to
33
- * `["/u/widget/"]` (the universal-login widget bundle); pass `[]` to disable
34
- * the carve-out entirely, or extend the list for other shared assets.
45
+ * a per-tenant dispatch worker has no static-assets binding. These are added
46
+ * to `DEFAULT_LOCAL_PATHS` (the universal-login widget bundle), so a default
47
+ * added later is not lost by anyone who passes their own list.
35
48
  */
36
49
  localPaths?: string[];
50
+ /**
51
+ * Whether `DEFAULT_LOCAL_PATHS` apply. Defaults to `true`; pass `false` to
52
+ * dispatch even the default paths and carve out only `localPaths`.
53
+ */
54
+ includeDefaultLocalPaths?: boolean;
37
55
  }
38
56
  /**
39
57
  * Hono middleware that forwards a request to its tenant's WFP worker over a
@@ -49,8 +67,8 @@ export interface WfpForwardOptions {
49
67
  * The tenant worker receives the original request verbatim and owns the full
50
68
  * response.
51
69
  *
52
- * Requests whose path matches `localPaths` (default `["/u/widget/"]`) are also
53
- * never dispatched — they are shared static assets that only the control plane
54
- * can serve, so a per-tenant worker would 404 them. See `DEFAULT_LOCAL_PATHS`.
70
+ * Requests whose path matches `DEFAULT_LOCAL_PATHS` (`["/u/widget/"]`) or the
71
+ * extra `localPaths` are also never dispatched — they are shared static assets
72
+ * that only the control plane can serve, so a per-tenant worker would 404 them.
55
73
  */
56
74
  export declare function createWfpForwardMiddleware(options: WfpForwardOptions): MiddlewareHandler;
@@ -0,0 +1,31 @@
1
+ import { type StepConfig, type StepRunner, type DetachConfigFn, type TenantOperationStores } from "@authhero/multi-tenancy";
2
+ import type { TenantOperationWorkflowParams } from "./types";
3
+ export type DetachStepName = "mark-running" | "detach" | "mark-succeeded" | "mark-failed";
4
+ export interface DetachOperationDeps {
5
+ /** Control-plane operation log stores. */
6
+ stores: TenantOperationStores;
7
+ /**
8
+ * How the detach reaches the tenant's database, for example
9
+ * `createDispatchDetach(...)`.
10
+ */
11
+ detachTenant: DetachConfigFn;
12
+ /**
13
+ * Per-step overrides of the retry/timeout defaults. The `detach` step has no
14
+ * automatic retries unless a host sets them.
15
+ */
16
+ stepConfig?: Partial<Record<DetachStepName, StepConfig>>;
17
+ }
18
+ /**
19
+ * Durable `detach` operation: a synced tenant leaves its parent, as a Workflows
20
+ * instance that writes to the control-plane operation log at every step
21
+ * boundary (the same shape as {@link runSyncDefaultsOperation}, and the same
22
+ * step logic as the inline executor). The operation's `input` (`dry_run`,
23
+ * `clean`) is read from its row, so nothing but ids crosses the Workflows
24
+ * boundary.
25
+ *
26
+ * A dry run records what would change in the operation's `result` and writes
27
+ * nothing. Any failure runs `mark-failed` (event + failed row) and rethrows
28
+ * so the instance ends `errored`; if even that dies, the reconciler copies the
29
+ * engine's terminal state into the row.
30
+ */
31
+ export declare function runDetachOperation(deps: DetachOperationDeps, params: TenantOperationWorkflowParams, step: StepRunner): Promise<void>;
@@ -32,6 +32,7 @@
32
32
  * reconcileTenantOperations,
33
33
  * runProvisionOperation,
34
34
  * runSyncDefaultsOperation,
35
+ * runDetachOperation,
35
36
  * type ProvisionOperationDeps,
36
37
  * type TenantOperationWorkflowParams,
37
38
  * } from "@authhero/cloudflare-adapter/workflows";
@@ -43,6 +44,7 @@
43
44
  * } from "@authhero/cloudflare-adapter";
44
45
  * import {
45
46
  * createDispatchSyncConfig,
47
+ * createDispatchDetach,
46
48
  * createDispatchSyncDefaults,
47
49
  * } from "@authhero/cloudflare-adapter/wfp";
48
50
  *
@@ -76,6 +78,27 @@
76
78
  * // CF's WorkflowStep satisfies StepRunner structurally. One class runs
77
79
  * // every kind; params carry the kind, never secrets.
78
80
  * switch (event.payload.kind) {
81
+ * case "detach": {
82
+ * const adapters = createControlPlaneAdapters(this.env);
83
+ * return runDetachOperation(
84
+ * {
85
+ * stores: {
86
+ * tenantOperations: adapters.tenantOperations!,
87
+ * tenantOperationEvents: adapters.tenantOperationEvents!,
88
+ * },
89
+ * // Checks the tenant row on the control plane, asks the tenant
90
+ * // worker's /internal/detach to convert its rows, then marks the
91
+ * // control-plane row detached.
92
+ * detachTenant: createDispatchDetach({
93
+ * dispatcher: this.env.DISPATCHER,
94
+ * internalSecret: this.env.WFP_INTERNAL_SYNC_SECRET,
95
+ * controlPlaneAdapters: adapters,
96
+ * }),
97
+ * },
98
+ * event.payload,
99
+ * step,
100
+ * );
101
+ * }
79
102
  * case "sync-defaults": {
80
103
  * const adapters = createControlPlaneAdapters(this.env);
81
104
  * return runSyncDefaultsOperation(
@@ -14,6 +14,7 @@
14
14
  */
15
15
  export { runProvisionOperation, type ProvisionOperationDeps, type ProvisionStepName, } from "./provision-operation";
16
16
  export { runSyncDefaultsOperation, type SyncDefaultsOperationDeps, type SyncDefaultsStepName, } from "./sync-defaults-operation";
17
+ export { runDetachOperation, type DetachOperationDeps, type DetachStepName, } from "./detach-operation";
17
18
  export { createProvisionVerifier, TenantProvisionVerificationError, type ProvisionVerifierOptions, } from "./verify";
18
19
  export { createCloudflareWorkflowsExecutor, type CloudflareWorkflowsExecutorOptions, } from "./executor";
19
20
  export { reconcileTenantOperations, type ReconcileTenantOperationsOptions, type ReconcileTenantOperationsResult, } from "./reconcile";
@@ -15,7 +15,7 @@ export interface TenantOperationWorkflowParams {
15
15
  operation_id: string;
16
16
  tenant_id: string;
17
17
  /** Widened beyond these in later phases (upgrade, backup, …). */
18
- kind: "provision" | "sync-defaults";
18
+ kind: "provision" | "sync-defaults" | "detach";
19
19
  }
20
20
  /**
21
21
  * Instance status as reported by the engine. The exact value set has
package/dist/wfp.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@authhero/multi-tenancy"),t=require("hono"),n=require("authhero");var r=`tenant-{tenant_id}-auth`,i=3e4,a=`/internal/sync-defaults`;function o(e,t){return e.replace(/\{tenant_id\}/g,t)}function s(t){let{dispatcher:n,scriptNameTemplate:s=r,internalSecret:c,controlPlaneTenantId:l,controlPlaneAdapters:u,entities:d,timeoutMs:f=i}=t;return async t=>{let r=await(0,e.buildControlPlaneDefaultsPayload)(u,l,d,t),i=o(s,t),p=new AbortController,m=setTimeout(()=>p.abort(),f),h;try{h=await n.get(i).fetch(`https://tenant.internal${a}`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${c}`},body:JSON.stringify(r),signal:p.signal})}finally{clearTimeout(m)}if(!h.ok){let e=await h.text().catch(()=>``),t=h.headers.get(`x-authhero-error`);throw Error(`sync-defaults push to "${i}" failed: ${h.status}${t?` (${t})`:``} ${e.slice(0,256)}`)}return await h.json()}}var c=`/internal/sync-config`;function l(t){let{dispatcher:n,scriptNameTemplate:a=r,internalSecret:s,controlPlaneTenantId:l,controlPlaneAdapters:u,maxDepth:d,timeoutMs:f=i}=t;return async(t,{dryRun:r,force:i})=>{let p=await(0,e.buildResolvedConfigBlock)(u,t,{legacyControlPlane:l,maxDepth:d}),m=o(a,t),h=new AbortController,g=setTimeout(()=>h.abort(),f),_;try{_=await n.get(m).fetch(`https://tenant.internal${c}`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${s}`},body:JSON.stringify({tenantId:t,resolved:p,dryRun:r,force:i}),signal:h.signal})}finally{clearTimeout(g)}if(!_.ok){let e=await _.text().catch(()=>``),t=_.headers.get(`x-authhero-error`);throw Error(`sync-config push to "${m}" failed: ${_.status}${t?` (${t})`:``} ${e.slice(0,256)}`)}return await _.json()}}var u=`/internal/renew-signing-certificates`;function d(e){let{dispatcher:t,scriptNameTemplate:n=r,internalSecret:a,timeoutMs:s=i}=e;return async e=>{let r=o(n,e),i=new AbortController,c=setTimeout(()=>i.abort(),s);try{let e=await t.get(r).fetch(`https://tenant.internal${u}`,{method:`POST`,headers:{authorization:`Bearer ${a}`},signal:i.signal});if(!e.ok){let t=await e.text().catch(()=>``),n=e.headers.get(`x-authhero-error`);throw Error(`signing-certificate renewal for "${r}" failed: ${e.status}${n?` (${n})`:``} ${t.slice(0,512)}`)}let n=await e.json();if(!f(n))throw Error(`signing-certificate renewal for "${r}" returned an unexpected body`);return{renewed:n.renewed,notDue:n.notDue}}finally{clearTimeout(c)}}}function f(e){return typeof e==`object`&&!!e&&`renewed`in e&&`notDue`in e&&Array.isArray(e.renewed)&&e.renewed.every(e=>typeof e==`string`)&&typeof e.notDue==`number`}var p=`cp`,m=`/internal/sync-defaults`,h=`/internal/sync-config`,g=`/internal/renew-signing-certificates`;function _(e,t,n,r){console.error(`[wfp-tenant] ${t}:`,n);let i=n instanceof Error?n.message:String(n);return e.header(`X-Authhero-Error`,t),e.json({error:t,detail:i,result:r},500)}function v(e){let t=`${n.TENANT_URI_PREFIX}${e.tenantId}`;return{issuer:e.issuer,validityDays:e.validityDays,subjectUri:()=>t}}function y(e){return({tenant_id:t})=>t===e.CONTROL_PLANE_TENANT_ID?[e.CONTROL_PLANE_ISSUER]:[]}async function b(r,i){let a=r.CONTROL_PLANE_TENANT_ID,o=await(0,n.loadEncryptionKey)(r.ENCRYPTION_KEY),s=r.CONTROL_PLANE_ENCRYPTION_KEY?{default:o,keys:{[p]:await(0,n.loadEncryptionKey)(r.CONTROL_PLANE_ENCRYPTION_KEY)}}:{default:o},c=i.createDataAdapter(r),l=(0,n.createEncryptedDataAdapterWithKeyRing)(c,s,{resolveEncryptKeyId:e=>e===a?p:void 0}),u=(0,e.withRuntimeFallback)(l,{controlPlaneTenantId:a}),d=i.signingCertificateAuthority?.(r),f=d?v(d):void 0,b={dataAdapter:u,...f?{signingCertificateAuthority:f}:{},additionalIssuers:(i.additionalIssuers??y)(r)},x=i.configure?i.configure(b,r):b,S=x.signingCertificateAuthority,{app:C}=(0,n.init)(x),w=new t.Hono;return w.post(m,async t=>{let o=r.WFP_INTERNAL_SYNC_SECRET,s=t.req.header(`authorization`);if(!o||s!==`Bearer ${o}`)return t.json({error:`unauthorized`},401);let c;try{c=await t.req.json()}catch{return t.json({error:`invalid JSON`},400)}let u;try{u=await(0,e.applyControlPlaneDefaultsPayload)(c,l,a,{continueOnError:i.continueOnError??!0})}catch(e){return _(t,`sync_defaults_apply_failed`,e)}let d;try{d={created:(await(0,n.ensureSigningKey)(l.keys,{name:r.ISSUER,certificateAuthority:S})).created}}catch(e){return _(t,`sync_defaults_signing_key_failed`,e,u)}if(i.onSyncResult)try{await i.onSyncResult(u,r)}catch(e){return _(t,`sync_defaults_on_result_failed`,e,u)}return t.json({...u,signingKey:d})}),w.post(h,async t=>{let n=r.WFP_INTERNAL_SYNC_SECRET,a=t.req.header(`authorization`);if(!n||a!==`Bearer ${n}`)return t.json({error:`unauthorized`},401);let o;try{o=await t.req.json()}catch{return t.json({error:`invalid JSON`},400)}if(!o.tenantId||!o.resolved)return t.json({error:`tenantId and resolved are required`},400);if(r.TENANT_ID&&o.tenantId!==r.TENANT_ID)return console.error(`[wfp-tenant] sync_config_tenant_mismatch: push for "${o.tenantId}" reached the worker of "${r.TENANT_ID}"`),t.header(`X-Authhero-Error`,`sync_config_tenant_mismatch`),t.json({error:`sync_config_tenant_mismatch`},403);try{let n=await(0,e.applyResolvedConfigBlock)(o.resolved,c,o.tenantId,{dryRun:o.dryRun===!0,force:o.force===!0,keyRing:s,continueOnError:i.continueOnError??!0});return t.json(n)}catch(e){return e instanceof Error&&/key ring cannot decrypt/.test(e.message)?(console.error(`[wfp-tenant] sync_config_key_ring:`,e),t.header(`X-Authhero-Error`,`sync_config_key_ring`),t.json({error:`sync_config_key_ring`,detail:e.message},422)):_(t,`sync_config_apply_failed`,e)}}),w.post(g,async e=>{let t=r.WFP_INTERNAL_SYNC_SECRET,i=e.req.header(`authorization`);if(!t||i!==`Bearer ${t}`)return e.json({error:`unauthorized`},401);if(!S)return e.json({error:`no signing certificate authority`},404);try{let t=await(0,n.renewSigningCertificates)({dataAdapter:{keys:l.keys},certificateAuthority:S});return e.json({renewed:t.renewed,notDue:t.notDue})}catch(t){return t instanceof n.SigningCertificateRenewalError?(console.error(`[wfp-tenant] signing_certificate_renewal_failed:`,t),e.header(`X-Authhero-Error`,`signing_certificate_renewal_failed`),e.json({error:`signing_certificate_renewal_failed`,renewed:t.result.renewed,notDue:t.result.notDue,failed:t.result.failed.map(({kid:e,error:t})=>({kid:e,detail:t instanceof Error?t.message:String(t)}))},500)):_(e,`signing_certificate_renewal_failed`,t)}}),w.route(`/`,C),w}function x(e){let t=new WeakMap;return{fetch(n,r,i){let a=t.get(r);return a||(a=b(r,e),t.set(r,a)),a.then(e=>e.fetch(n,r,i))}}}exports.createDispatchRenewSigningCertificates=d,exports.createDispatchSyncConfig=l,exports.createDispatchSyncDefaults=s,exports.createWfpTenantApp=x;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@authhero/multi-tenancy"),t=require("hono"),n=require("authhero");var r=`tenant-{tenant_id}-auth`,i=3e4,a=`/internal/sync-defaults`;function o(e,t){return e.replace(/\{tenant_id\}/g,t)}function s(t){let{dispatcher:n,scriptNameTemplate:s=r,internalSecret:c,controlPlaneTenantId:l,controlPlaneAdapters:u,entities:d,timeoutMs:f=i}=t;return async t=>{let r=await(0,e.buildControlPlaneDefaultsPayload)(u,l,d,t),i=o(s,t),p=new AbortController,m=setTimeout(()=>p.abort(),f),h;try{h=await n.get(i).fetch(`https://tenant.internal${a}`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${c}`},body:JSON.stringify(r),signal:p.signal})}finally{clearTimeout(m)}if(!h.ok){let e=await h.text().catch(()=>``),t=h.headers.get(`x-authhero-error`);throw Error(`sync-defaults push to "${i}" failed: ${h.status}${t?` (${t})`:``} ${e.slice(0,256)}`)}return await h.json()}}var c=`/internal/sync-config`;function l(t){let{dispatcher:n,scriptNameTemplate:a=r,internalSecret:s,controlPlaneTenantId:l,controlPlaneAdapters:u,maxDepth:d,timeoutMs:f=i}=t;return async(t,{dryRun:r,force:i,prune:p})=>{if((await u.tenants.get(t))?.config_sync===`detached`)throw Error(`Tenant "${t}" is detached from its parent: set config_sync back to "inherit-on-read" or "synced" before syncing it`);let m=await(0,e.buildResolvedConfigBlock)(u,t,{legacyControlPlane:l,maxDepth:d}),h=o(a,t),g=new AbortController,_=setTimeout(()=>g.abort(),f),v;try{v=await n.get(h).fetch(`https://tenant.internal${c}`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${s}`},body:JSON.stringify({tenantId:t,resolved:m,dryRun:r,force:i,prune:p}),signal:g.signal})}finally{clearTimeout(_)}if(!v.ok){let e=await v.text().catch(()=>``),t=v.headers.get(`x-authhero-error`);throw Error(`sync-config push to "${h}" failed: ${v.status}${t?` (${t})`:``} ${e.slice(0,256)}`)}return await v.json()}}var u=`/internal/detach`,d=12e4;function f(t){let{dispatcher:n,scriptNameTemplate:i=r,internalSecret:a,controlPlaneAdapters:s,timeoutMs:c=d}=t;return async(t,{dryRun:r,clean:l})=>{let d=await s.tenants.get(t);if(!d)throw Error(`Tenant "${t}" not found`);if(d.config_sync===`detached`)return(0,e.emptyDetachResult)(`already-detached`);(0,e.assertDetachEligible)(t,d);let f=o(i,t),p=new AbortController,m=setTimeout(()=>p.abort(),c),h;try{h=await n.get(f).fetch(`https://tenant.internal${u}`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${a}`},body:JSON.stringify({tenantId:t,dryRun:r,clean:l}),signal:p.signal})}finally{clearTimeout(m)}if(!h.ok){let e=await h.text().catch(()=>``),t=h.headers.get(`x-authhero-error`);throw Error(`detach push to "${f}" failed: ${h.status}${t?` (${t})`:``} ${e.slice(0,256)}`)}let g=await h.json();return!r&&g.status===`detached`&&await(0,e.markTenantDetached)(s,t),g}}var p=`/internal/tenant-member-requests`,m=15e3;function h(e){let{dispatcher:t,scriptNameTemplate:n=r,internalSecret:i,timeoutMs:a=m}=e;async function s(e,r){let s=o(n,e),c=new AbortController,l=setTimeout(()=>c.abort(),a);try{return await t.get(s).fetch(`https://tenant.internal${p}`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${i}`},body:JSON.stringify({tenantId:e,...r}),signal:c.signal})}finally{clearTimeout(l)}}async function c(e,t){let n=await t.text().catch(()=>``),r=t.headers.get(`x-authhero-error`);return Error(`tenant member requests of "${e}" answered ${t.status}${r?` (${r})`:``} ${n.slice(0,256)}`)}return{async get(e,t){let n=await s(e,{action:`get`,id:t});if(n.status===404)return null;if(!n.ok)throw await c(e,n);return await n.json()},async report(e,t,n){let r=await s(e,{action:`report`,id:t,outcome:n});if(!r.ok)throw await c(e,r);return(await r.json()).updated},async listPending(e,{olderThan:t}){let n=await s(e,{action:`list_pending`,olderThan:t});if(!n.ok)throw await c(e,n);return(await n.json()).requests}}}var g=`/internal/renew-signing-certificates`;function _(e){let{dispatcher:t,scriptNameTemplate:n=r,internalSecret:a,timeoutMs:s=i}=e;return async e=>{let r=o(n,e),i=new AbortController,c=setTimeout(()=>i.abort(),s);try{let e=await t.get(r).fetch(`https://tenant.internal${g}`,{method:`POST`,headers:{authorization:`Bearer ${a}`},signal:i.signal});if(!e.ok){let t=await e.text().catch(()=>``),n=e.headers.get(`x-authhero-error`);throw Error(`signing-certificate renewal for "${r}" failed: ${e.status}${n?` (${n})`:``} ${t.slice(0,512)}`)}let n=await e.json();if(!v(n))throw Error(`signing-certificate renewal for "${r}" returned an unexpected body`);return{renewed:n.renewed,notDue:n.notDue}}finally{clearTimeout(c)}}}function v(e){return typeof e==`object`&&!!e&&`renewed`in e&&`notDue`in e&&Array.isArray(e.renewed)&&e.renewed.every(e=>typeof e==`string`)&&typeof e.notDue==`number`}var y=`cp`,b=`/internal/sync-defaults`,x=`/internal/sync-config`,S=`/internal/detach`,C=`/internal/tenant-member-requests`,w=`/internal/renew-signing-certificates`;function T(e,t,n,r){console.error(`[wfp-tenant] ${t}:`,n);let i=n instanceof Error?n.message:String(n);return e.header(`X-Authhero-Error`,t),e.json({error:t,detail:i,result:r},500)}function E(e){let t=`${n.TENANT_URI_PREFIX}${e.tenantId}`;return{issuer:e.issuer,validityDays:e.validityDays,subjectUri:()=>t}}function D(e){return({tenant_id:t})=>t===e.CONTROL_PLANE_TENANT_ID?[e.CONTROL_PLANE_ISSUER]:[]}async function O(r,i){let a=r.CONTROL_PLANE_TENANT_ID,o=await(0,n.loadEncryptionKey)(r.ENCRYPTION_KEY),s=r.CONTROL_PLANE_ENCRYPTION_KEY?{default:o,keys:{[y]:await(0,n.loadEncryptionKey)(r.CONTROL_PLANE_ENCRYPTION_KEY)}}:{default:o},c=i.createDataAdapter(r),l=(0,n.createEncryptedDataAdapterWithKeyRing)(c,s,{resolveEncryptKeyId:e=>e===a?y:void 0}),u=(0,e.withRuntimeFallback)(l,{controlPlaneTenantId:a}),d=i.signingCertificateAuthority?.(r),f=d?E(d):void 0,p={dataAdapter:u,...f?{signingCertificateAuthority:f}:{},additionalIssuers:(i.additionalIssuers??D)(r)},m=i.configure?i.configure(p,r):p,h=m.signingCertificateAuthority,{app:g}=(0,n.init)(m),_=new t.Hono;if(_.post(b,async t=>{let o=r.WFP_INTERNAL_SYNC_SECRET,s=t.req.header(`authorization`);if(!o||s!==`Bearer ${o}`)return t.json({error:`unauthorized`},401);let c;try{c=await t.req.json()}catch{return t.json({error:`invalid JSON`},400)}let u;try{u=await(0,e.applyControlPlaneDefaultsPayload)(c,l,a,{continueOnError:i.continueOnError??!0})}catch(e){return T(t,`sync_defaults_apply_failed`,e)}let d;try{d={created:(await(0,n.ensureSigningKey)(l.keys,{name:r.ISSUER,certificateAuthority:h})).created}}catch(e){return T(t,`sync_defaults_signing_key_failed`,e,u)}if(i.onSyncResult)try{await i.onSyncResult(u,r)}catch(e){return T(t,`sync_defaults_on_result_failed`,e,u)}return t.json({...u,signingKey:d})}),_.post(x,async t=>{let n=r.WFP_INTERNAL_SYNC_SECRET,a=t.req.header(`authorization`);if(!n||a!==`Bearer ${n}`)return t.json({error:`unauthorized`},401);let o;try{o=await t.req.json()}catch{return t.json({error:`invalid JSON`},400)}if(!o.tenantId||!o.resolved)return t.json({error:`tenantId and resolved are required`},400);if(r.TENANT_ID&&o.tenantId!==r.TENANT_ID)return console.error(`[wfp-tenant] sync_config_tenant_mismatch: push for "${o.tenantId}" reached the worker of "${r.TENANT_ID}"`),t.header(`X-Authhero-Error`,`sync_config_tenant_mismatch`),t.json({error:`sync_config_tenant_mismatch`},403);try{let n=await(0,e.applyResolvedConfigBlock)(o.resolved,c,o.tenantId,{dryRun:o.dryRun===!0,force:o.force===!0,prune:o.prune===!0,keyRing:s,continueOnError:i.continueOnError??!0});return t.json(n)}catch(e){return e instanceof Error&&/key ring cannot decrypt/.test(e.message)?(console.error(`[wfp-tenant] sync_config_key_ring:`,e),t.header(`X-Authhero-Error`,`sync_config_key_ring`),t.json({error:`sync_config_key_ring`,detail:e.message},422)):T(t,`sync_config_apply_failed`,e)}}),_.post(S,async t=>{let n=r.WFP_INTERNAL_SYNC_SECRET,i=t.req.header(`authorization`);if(!n||i!==`Bearer ${n}`)return t.json({error:`unauthorized`},401);let a;try{a=await t.req.json()}catch{return t.json({error:`invalid JSON`},400)}if(!a.tenantId)return t.json({error:`tenantId is required`},400);if(r.TENANT_ID&&a.tenantId!==r.TENANT_ID)return console.error(`[wfp-tenant] detach_tenant_mismatch: detach for "${a.tenantId}" reached the worker of "${r.TENANT_ID}"`),t.header(`X-Authhero-Error`,`detach_tenant_mismatch`),t.json({error:`detach_tenant_mismatch`},403);try{let n=await(0,e.detachTenantRows)(c,a.tenantId,{dryRun:a.dryRun===!0,clean:a.clean===!0});return n.status===`detached`&&await(0,e.markTenantDetached)(c,a.tenantId),t.json(n)}catch(e){return T(t,`detach_failed`,e)}}),_.post(C,async e=>{let t=r.WFP_INTERNAL_SYNC_SECRET,n=e.req.header(`authorization`);if(!t||n!==`Bearer ${t}`)return e.json({error:`unauthorized`},401);let i;try{i=await e.req.json()}catch{return e.json({error:`invalid JSON`},400)}if(!i.tenantId)return e.json({error:`tenantId is required`},400);if(r.TENANT_ID&&i.tenantId!==r.TENANT_ID)return console.error(`[wfp-tenant] member_requests_tenant_mismatch: a request for "${i.tenantId}" reached the worker of "${r.TENANT_ID}"`),e.header(`X-Authhero-Error`,`member_requests_tenant_mismatch`),e.json({error:`member_requests_tenant_mismatch`},403);let a=c.tenantMemberRequests;if(!a)return e.json({error:`tenant member requests are not available`},501);try{switch(i.action){case`get`:{if(!i.id)return e.json({error:`id is required`},400);let t=await a.get(i.tenantId,i.id);return t?e.json(t):e.json({error:`not_found`},404)}case`report`:{let{status:t,result:n,error:r}=i.outcome??{};if(!i.id||t!==`succeeded`&&t!==`failed`)return e.json({error:`id and a final status are required`},400);let o=await a.update(i.tenantId,i.id,{status:t,result:n??null,error:r??null,finished_at:new Date().toISOString()},`pending`);return e.json({updated:o})}case`list_pending`:{let t=await a.list(i.tenantId,{status:`pending`,created_before:i.olderThan,per_page:100});return e.json({requests:t.requests})}default:return e.json({error:`unknown action`},400)}}catch(t){return T(e,`member_requests_failed`,t)}}),_.post(w,async e=>{let t=r.WFP_INTERNAL_SYNC_SECRET,i=e.req.header(`authorization`);if(!t||i!==`Bearer ${t}`)return e.json({error:`unauthorized`},401);if(!h)return e.json({error:`no signing certificate authority`},404);try{let t=await(0,n.renewSigningCertificates)({dataAdapter:{keys:l.keys},certificateAuthority:h});return e.json({renewed:t.renewed,notDue:t.notDue})}catch(t){return t instanceof n.SigningCertificateRenewalError?(console.error(`[wfp-tenant] signing_certificate_renewal_failed:`,t),e.header(`X-Authhero-Error`,`signing_certificate_renewal_failed`),e.json({error:`signing_certificate_renewal_failed`,renewed:t.result.renewed,notDue:t.result.notDue,failed:t.result.failed.map(({kid:e,error:t})=>({kid:e,detail:t instanceof Error?t.message:String(t)}))},500)):T(e,`signing_certificate_renewal_failed`,t)}}),_.route(`/`,g),i.inheritanceShadow){let t=(0,e.createShadowReporter)(l,i.inheritanceShadow===!0?{}:i.inheritanceShadow),n=r.TENANT_ID||new URL(r.ISSUER).hostname.split(`.`)[0],o,s=async()=>{let e=await l.tenants.get(n);for(let t=0;t<5;t++){let t=e?.parent_tenant_id;if(!t||t===a)return!0;if(e=await l.tenants.get(t),!e)return!1}return!1};k.set(_,e=>{if(!new URL(e.url).pathname.startsWith(`/internal/`)&&n!==a)return(async()=>{if(!await(o??=s()))return;let e=t.schedule(n,a);e&&await e})()})}return _}var k=new WeakMap;function A(e){let t=new WeakMap;return{fetch(n,r,i){let a=t.get(r);return a||(a=O(r,e),t.set(r,a)),a.then(async e=>{let t=await e.fetch(n,r,i),a=k.get(e)?.(n);return a&&i.waitUntil(a),t})}}}exports.TENANT_MEMBER_REQUESTS_PATH=p,exports.createDispatchDetach=f,exports.createDispatchRenewSigningCertificates=_,exports.createDispatchSyncConfig=l,exports.createDispatchSyncDefaults=s,exports.createDispatchTenantMemberRequestStore=h,exports.createWfpTenantApp=A;
package/dist/wfp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DataAdapters } from '@authhero/adapter-interfaces';
2
- import { DefaultsPayloadEntities, ControlPlaneDefaultsApplyResult, SyncConfigFn } from '@authhero/multi-tenancy';
2
+ import { DefaultsPayloadEntities, ControlPlaneDefaultsApplyResult, SyncConfigFn, DetachConfigFn, TenantMemberRequestStore, InheritanceShadowOptions } from '@authhero/multi-tenancy';
3
3
  import { ExecutionContext } from 'hono';
4
4
  import { DataAdapters as DataAdapters$1, AuthHeroConfig, IssuerResolver, CertificateIssuer } from 'authhero';
5
5
 
@@ -95,6 +95,57 @@ interface DispatchSyncConfigOptions {
95
95
  */
96
96
  declare function createDispatchSyncConfig(options: DispatchSyncConfigOptions): SyncConfigFn;
97
97
 
98
+ interface DispatchDetachOptions {
99
+ /** The dispatch namespace binding the tenant workers live in. */
100
+ dispatcher: DispatchNamespace;
101
+ /** Script-name convention. Supports `{tenant_id}`. */
102
+ scriptNameTemplate?: string;
103
+ /**
104
+ * Shared secret the tenant worker checks on `/internal/detach`
105
+ * (`WFP_INTERNAL_SYNC_SECRET`). Sent as a bearer token.
106
+ */
107
+ internalSecret: string;
108
+ /**
109
+ * The control plane's adapters, where the tenant row that decides lives. Use
110
+ * the raw layer.
111
+ */
112
+ controlPlaneAdapters: DataAdapters;
113
+ /** Per-push timeout. Defaults to 120s. */
114
+ timeoutMs?: number;
115
+ }
116
+ /**
117
+ * Builds the `detach` operation's transport for tenants with their own worker
118
+ * and database. The control plane checks that the tenant can be detached from
119
+ * its own tenant row, asks the tenant worker to convert (or remove) the copies
120
+ * in its database, and only when that went through marks its own tenant row
121
+ * detached. A preview or a partial result leaves the row as it was, so a retry
122
+ * can finish the job. Rejects when the push fails or the worker answers non-2xx,
123
+ * which fails the operation.
124
+ */
125
+ declare function createDispatchDetach(options: DispatchDetachOptions): DetachConfigFn;
126
+
127
+ declare const TENANT_MEMBER_REQUESTS_PATH = "/internal/tenant-member-requests";
128
+ interface DispatchTenantMemberRequestStoreOptions {
129
+ /** The dispatch namespace binding the tenant workers live in. */
130
+ dispatcher: DispatchNamespace;
131
+ /** Script-name convention. Supports `{tenant_id}`. */
132
+ scriptNameTemplate?: string;
133
+ /**
134
+ * Shared secret the tenant worker checks on `/internal/*`
135
+ * (`WFP_INTERNAL_SYNC_SECRET`). Sent as a bearer token.
136
+ */
137
+ internalSecret: string;
138
+ /** Per-call timeout. Defaults to 15s. */
139
+ timeoutMs?: number;
140
+ }
141
+ /**
142
+ * Reaches the team-change requests in a tenant worker's own database, for the
143
+ * `tenant-members-request` operation and its reconcile sweep (issue #1550):
144
+ * read a request, answer it, and list the ones still pending. One endpoint,
145
+ * `POST /internal/tenant-member-requests`, takes the action in its body.
146
+ */
147
+ declare function createDispatchTenantMemberRequestStore(options: DispatchTenantMemberRequestStoreOptions): TenantMemberRequestStore;
148
+
98
149
  interface DispatchRenewSigningCertificatesOptions {
99
150
  /** The dispatch namespace binding the tenant workers live in. */
100
151
  dispatcher: DispatchNamespace;
@@ -214,6 +265,22 @@ interface WfpTenantAppOptions<Env extends WfpTenantEnv = WfpTenantEnv> {
214
265
  * control plane via `createDispatchRenewSigningCertificates`.
215
266
  */
216
267
  signingCertificateAuthority?: (env: Env) => WfpSigningCertificateAuthority | undefined;
268
+ /**
269
+ * Shadow mode for config inheritance, run in this worker: after a request is
270
+ * answered, compare what the projected control-plane rows give this tenant
271
+ * (the runtime fallback) with what its parent chain resolves to, and log the
272
+ * differences as `cfgdiff` in this tenant's own logs
273
+ * (`GET /api/v2/logs?q=type:cfgdiff`, forwarded here). Never changes a
274
+ * response. The comparison runs under `ctx.waitUntil`, so the Worker keeps
275
+ * it alive after the response; it is sampled and rate-limited like the
276
+ * colocated shadow mode. `true` uses the defaults.
277
+ *
278
+ * The worker only knows the control plane's rows (the ones projected into its
279
+ * database) and the tenant's own, so it compares against those. If the
280
+ * tenant's row names a parent this database lacks, the comparison is skipped;
281
+ * a deeper chain is checked by _Verify_ on the control plane.
282
+ */
283
+ inheritanceShadow?: boolean | InheritanceShadowOptions;
217
284
  }
218
285
  interface WfpSigningCertificateAuthority {
219
286
  issuer: CertificateIssuer;
@@ -237,5 +304,5 @@ declare function createWfpTenantApp<Env extends WfpTenantEnv = WfpTenantEnv>(opt
237
304
  fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;
238
305
  };
239
306
 
240
- export { createDispatchRenewSigningCertificates, createDispatchSyncConfig, createDispatchSyncDefaults, createWfpTenantApp };
241
- export type { DispatchRenewSigningCertificatesOptions, DispatchRenewSigningCertificatesResult, DispatchSyncConfigOptions, DispatchSyncDefaultsOptions, WfpSigningCertificateAuthority, WfpTenantAppOptions, WfpTenantEnv };
307
+ export { TENANT_MEMBER_REQUESTS_PATH, createDispatchDetach, createDispatchRenewSigningCertificates, createDispatchSyncConfig, createDispatchSyncDefaults, createDispatchTenantMemberRequestStore, createWfpTenantApp };
308
+ export type { DispatchDetachOptions, DispatchRenewSigningCertificatesOptions, DispatchRenewSigningCertificatesResult, DispatchSyncConfigOptions, DispatchSyncDefaultsOptions, DispatchTenantMemberRequestStoreOptions, WfpSigningCertificateAuthority, WfpTenantAppOptions, WfpTenantEnv };