payment-kit 1.29.2 → 1.29.3
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/api/src/bootstrap.ts +11 -0
- package/api/src/crons/index.ts +14 -13
- package/api/src/crons/tenant-fanout.ts +82 -0
- package/api/src/host-node/did-connect-runtime-node.ts +33 -0
- package/api/src/host-node/serve-static-arc.ts +68 -0
- package/api/src/host-node/serve-static.ts +41 -0
- package/api/src/libs/auth.ts +166 -27
- package/api/src/libs/context.ts +11 -0
- package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
- package/api/src/libs/did-connect/tenant-identity.ts +221 -0
- package/api/src/libs/drivers/identity.ts +61 -0
- package/api/src/libs/drivers/index.ts +1 -1
- package/api/src/libs/http-fetch-adapter.ts +11 -1
- package/api/src/libs/queue/index.ts +14 -2
- package/api/src/middlewares/hono/context.ts +7 -0
- package/api/src/middlewares/hono/csrf.ts +13 -2
- package/api/src/middlewares/hono/security.ts +6 -11
- package/api/src/queues/checkout-session.ts +21 -9
- package/api/src/queues/event.ts +29 -7
- package/api/src/queues/payment.ts +23 -9
- package/api/src/queues/payout.ts +28 -16
- package/api/src/queues/refund.ts +18 -6
- package/api/src/routes/hono/customers.ts +6 -1
- package/api/src/routes/hono/refunds.ts +2 -3
- package/api/src/service.ts +178 -31
- package/api/src/store/sequelize.ts +16 -1
- package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -0
- package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
- package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
- package/api/tests/libs/service-host.spec.ts +37 -0
- package/api/tests/queues/event-tenant.spec.ts +60 -4
- package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
- package/api/tests/service/fail-closed-http.spec.ts +79 -0
- package/api/tests/service/static-arc-handler.spec.ts +101 -0
- package/api/tests/service/static-externalized.spec.ts +48 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +8 -21
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +10 -5
- package/cloudflare/cf-adapter.ts +419 -0
- package/cloudflare/did-connect-runtime.ts +96 -0
- package/cloudflare/did-connect-token-storage.ts +151 -0
- package/cloudflare/esbuild-cf-config.cjs +407 -0
- package/cloudflare/run-build.js +33 -357
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +98 -56
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +0 -1
- package/cloudflare/wrangler.staging.json +0 -6
- package/package.json +7 -7
- package/scripts/bootstrap-inject.ts +166 -0
- package/src/app.tsx +2 -1
- package/src/libs/service-host.ts +13 -0
- package/vite.arc.config.ts +159 -0
- package/cloudflare/did-connect-auth.ts +0 -310
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +0 -8
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// @arcblock/payment-service/cf — the Cloudflare host adapter (S3-CF Phase 2/3).
|
|
2
|
+
//
|
|
3
|
+
// This is the LIBRARY entry that did-pay pre-bundles (via packages/payment-core/
|
|
4
|
+
// build-cf.mjs + the shared esbuild-cf-config alias table) into a self-contained,
|
|
5
|
+
// re-bundleable `dist/cf.js`. A consumer (arc's CF worker) does:
|
|
6
|
+
//
|
|
7
|
+
// import { createCloudflarePaymentAdapter } from "@arcblock/payment-service/cf";
|
|
8
|
+
// const payment = await createCloudflarePaymentAdapter({ ...host wiring... });
|
|
9
|
+
// export default { fetch: (req, env, ctx) => payment.fetch(req, env, ctx) };
|
|
10
|
+
//
|
|
11
|
+
// and needs ZERO alias / ZERO define / ZERO external of its own — every Node/server
|
|
12
|
+
// dependency is already inlined behind worker-safe shims in this bundle.
|
|
13
|
+
//
|
|
14
|
+
// The adapter only does HOST GLUE (S3-CF README §Adapter 职责): it drives the
|
|
15
|
+
// runtime-neutral core `http.fetch` (the single payment HTTP surface converged in
|
|
16
|
+
// Phase 1), bridges the host's request-level tenant resolver into the core
|
|
17
|
+
// IdentityDriver, injects the host-resolved caller as `x-user-did` (CF API gate —
|
|
18
|
+
// decision #5, NO in-process login_token verify), lazily provisions a tenant on
|
|
19
|
+
// first request (in-flight deduped), and drives scheduled due-dispatch + the CF
|
|
20
|
+
// Queue consumer. It NEVER re-assembles routes or mounts DID-Connect by hand.
|
|
21
|
+
|
|
22
|
+
// MUST be first: pin the core queue engine to 'workerd' mode BEFORE any business
|
|
23
|
+
// queue module is constructed (createQueue reads the mode at import to disable the
|
|
24
|
+
// node background poll loop a frozen isolate cannot run). ESM runs imported module
|
|
25
|
+
// bodies in source order, so this side-effect import has to lead.
|
|
26
|
+
import './queue-runtime-mode';
|
|
27
|
+
|
|
28
|
+
// Register the queue handlers + cron jobs that are NOT transitively imported by the
|
|
29
|
+
// route graph, so scheduled() due-dispatch and the queue consumer can resolve every
|
|
30
|
+
// handle by name (mirrors worker.ts). Side-effect imports.
|
|
31
|
+
import '../api/src/queues/refund';
|
|
32
|
+
import '../api/src/queues/checkout-session';
|
|
33
|
+
import '../api/src/queues/discount-status';
|
|
34
|
+
import '../api/src/queues/exchange-rate-health';
|
|
35
|
+
import '../api/src/queues/event';
|
|
36
|
+
import '../api/src/queues/webhook';
|
|
37
|
+
import crons from '../api/src/crons/index';
|
|
38
|
+
|
|
39
|
+
import { createEmbeddedPaymentService } from '../api/src/service';
|
|
40
|
+
import type { EmbeddedPaymentService } from '../api/src/service';
|
|
41
|
+
import { context as requestContext } from '../api/src/libs/context';
|
|
42
|
+
import {
|
|
43
|
+
createD1DbDriver,
|
|
44
|
+
createD1LocksDriver,
|
|
45
|
+
createKeyringSecretsDriver,
|
|
46
|
+
createCronRegistry,
|
|
47
|
+
applyPaymentCoreMigrations,
|
|
48
|
+
} from '../api/src/libs/drivers';
|
|
49
|
+
import type { IdentityDriver } from '../api/src/libs/drivers';
|
|
50
|
+
import { flushQueueWork, dispatchDueJobs, getQueueHandler } from '../api/src/libs/queue/runtime';
|
|
51
|
+
import { Sequelize } from './shims/sequelize-d1/sequelize-class';
|
|
52
|
+
import { setDB } from './shims/sequelize-d1/model';
|
|
53
|
+
import { withD1Retry } from './shims/sequelize-d1/retry';
|
|
54
|
+
import { cronInstance } from './shims/cron';
|
|
55
|
+
import { createCloudflareDidConnectRuntime, createCloudflareIdentityDriver } from './did-connect-runtime';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reserved mount prefix for the embedded payment service. The internal
|
|
59
|
+
* payment app serves `/api/*`; the host mounts it under this prefix and the
|
|
60
|
+
* adapter base-strips it before reaching the runtime-neutral core app.
|
|
61
|
+
*/
|
|
62
|
+
export const PAYMENT_PREFIX = '/.well-known/payment';
|
|
63
|
+
|
|
64
|
+
/** A Cloudflare D1 binding (the subset the adapter touches). */
|
|
65
|
+
export interface D1Binding {
|
|
66
|
+
withSession: (constraint?: string) => any;
|
|
67
|
+
prepare: (sql: string) => any;
|
|
68
|
+
batch: (statements: any[]) => Promise<any>;
|
|
69
|
+
exec: (sql: string) => Promise<any>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The caller identity the host resolved (CF API gate — decision #5, no in-process login_token verify). */
|
|
73
|
+
export interface CloudflareCallerIdentity {
|
|
74
|
+
did: string;
|
|
75
|
+
role?: string;
|
|
76
|
+
authMethod?: string;
|
|
77
|
+
displayName?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The CF host-wire contract (S3-CF README §目标 Package Surface + decision #5).
|
|
82
|
+
*
|
|
83
|
+
* Deliberately has NO `sessionSecret`: on CF the caller identity is resolved by
|
|
84
|
+
* the host and injected as `x-user-did` (payment trusts that branch); payment does
|
|
85
|
+
* NOT verify `login_token` in-process (verify-session is a no-op shim). The csrf
|
|
86
|
+
* secret IS host-global (csrf runs before the tenant context).
|
|
87
|
+
*
|
|
88
|
+
* Also NO public `secrets` slot (decision #1): the keyring `SecretsDriver` is
|
|
89
|
+
* constructed INTERNALLY below from `identity.getAppEk`, so a consumer never wires
|
|
90
|
+
* a `SecretsDriver` or imports `@arcblock/payment-service/drivers`.
|
|
91
|
+
*/
|
|
92
|
+
export interface CloudflarePaymentAdapterOptions {
|
|
93
|
+
/** Mount prefix; base-stripped before the core app. Default `/.well-known/payment`. */
|
|
94
|
+
basePath?: string;
|
|
95
|
+
/** Runtime configuration (the config boundary — never raw process.env). */
|
|
96
|
+
config?: Record<string, any>;
|
|
97
|
+
/** The independent payment D1 binding (NOT arc/connect-service DB). */
|
|
98
|
+
db: { d1: D1Binding };
|
|
99
|
+
/** CF embed is always multi-tenant. */
|
|
100
|
+
tenancy?: { mode: 'multi' };
|
|
101
|
+
/** Request-level tenant resolver — delegates to arc's host→instanceDid mapping. */
|
|
102
|
+
tenant: {
|
|
103
|
+
resolve: (request: Request) => Promise<string | null> | string | null;
|
|
104
|
+
};
|
|
105
|
+
/** Per-tenant EK source (semantics aligned with arc-node: connect-service `app:ek` else derive). */
|
|
106
|
+
identity: {
|
|
107
|
+
getAppEk: (instanceDid: string) => Promise<string> | string;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Host-resolved caller (CF API gate). The adapter injects it as `x-user-did`
|
|
111
|
+
* before forwarding. Optional — when absent the request runs anonymous
|
|
112
|
+
* (client-forged identity is always stripped, so this is fail-safe).
|
|
113
|
+
*/
|
|
114
|
+
caller?: {
|
|
115
|
+
resolve: (
|
|
116
|
+
request: Request,
|
|
117
|
+
instanceDid: string | null,
|
|
118
|
+
) => Promise<CloudflareCallerIdentity | null> | CloudflareCallerIdentity | null;
|
|
119
|
+
};
|
|
120
|
+
/** Host-global csrf secret (NOT per-tenant). Injected via the config slot as PAYMENT_CSRF_SECRET. */
|
|
121
|
+
csrfSecret?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The adapter the host drives. */
|
|
125
|
+
export interface CloudflarePaymentAdapter {
|
|
126
|
+
/** Drive the single payment HTTP surface for a `/.well-known/payment/*` request. */
|
|
127
|
+
fetch: (request: Request, env: any, ctx: ExecutionContext) => Promise<Response>;
|
|
128
|
+
/** CF scheduled handler — host wires its `crons` to this; drives cron runAll + D1 due-dispatch. */
|
|
129
|
+
scheduled: (event: ScheduledEvent, env: any, ctx: ExecutionContext) => Promise<void>;
|
|
130
|
+
/** Optional CF Queue consumer — the host demuxes payment messages to this. */
|
|
131
|
+
queue: (batch: MessageBatch<any>, env: any, ctx: ExecutionContext) => Promise<void>;
|
|
132
|
+
/** Lazy per-tenant base-data provisioning (idempotent DB seed; no chain/Stripe calls). */
|
|
133
|
+
provisionTenant: (instanceDid: string) => Promise<void>;
|
|
134
|
+
/** Heavy per-tenant bootstrap (chain stake / Stripe / currency logo) — scheduled/enumeration ONLY, never first-request. */
|
|
135
|
+
bootstrapTenant: (instanceDid: string) => Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Idempotent deploy-time schema application (decision #4: driven by CI/wrangler at
|
|
138
|
+
* deploy, NOT per-request — a per-request auto-sync burns D1 rows_read). Applies the
|
|
139
|
+
* payment-core SQL migrations against PAYMENT_DB; cheap version check once applied.
|
|
140
|
+
*/
|
|
141
|
+
ensureSchema: () => Promise<string[]>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Client-forged identity headers — always stripped before the host injects the real caller. */
|
|
145
|
+
const USER_HEADERS = ['x-user-did', 'x-user-role', 'x-user-provider', 'x-user-fullname', 'x-user-wallet-os'];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* CF API gate caller header glue (decision #5). STRIP any client-supplied
|
|
149
|
+
* `x-user-*` first (a forged `x-user-did` would be an auth bypass), then inject the
|
|
150
|
+
* host-resolved caller as canonical headers the core `authenticate()` trusts. No
|
|
151
|
+
* caller → the request runs anonymous (the strip already happened, so it is never
|
|
152
|
+
* forged). Mirrors the standalone worker's identical strip-then-inject.
|
|
153
|
+
*/
|
|
154
|
+
export function injectCaller(headers: Headers, caller: CloudflareCallerIdentity | null): void {
|
|
155
|
+
for (const h of USER_HEADERS) headers.delete(h);
|
|
156
|
+
if (!caller) return;
|
|
157
|
+
const canonicalDid = caller.did?.startsWith('did:abt:') ? caller.did : `did:abt:${caller.did}`;
|
|
158
|
+
headers.set('x-user-did', canonicalDid);
|
|
159
|
+
headers.set('x-user-role', `blocklet-${caller.role || 'guest'}`);
|
|
160
|
+
headers.set('x-user-provider', caller.authMethod === 'access-key' ? 'access-key' : caller.authMethod || 'wallet');
|
|
161
|
+
headers.set('x-user-fullname', encodeURIComponent(caller.displayName || ''));
|
|
162
|
+
headers.set('x-user-wallet-os', '');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Lazy per-tenant provisioning with in-flight dedup. Concurrent first-requests for
|
|
167
|
+
* the same tenant await the SAME provision() call — a Set would let a burst at
|
|
168
|
+
* isolate start double-seed before the DB idempotency guard commits. A resolved
|
|
169
|
+
* promise stays cached so later requests are instant; a FAILED provision is dropped
|
|
170
|
+
* so the next request retries. Null tenant is a no-op. Copied from the node witness
|
|
171
|
+
* (runtimes/node/src/daemon/payment/index.ts:72-105).
|
|
172
|
+
*/
|
|
173
|
+
export function createTenantProvisioner(
|
|
174
|
+
provision: (instanceDid: string) => Promise<void>,
|
|
175
|
+
): (instanceDid: string | null) => Promise<void> {
|
|
176
|
+
const inflight = new Map<string, Promise<void>>();
|
|
177
|
+
return (instanceDid) => {
|
|
178
|
+
if (!instanceDid) return Promise.resolve();
|
|
179
|
+
let p = inflight.get(instanceDid);
|
|
180
|
+
if (!p) {
|
|
181
|
+
p = provision(instanceDid).catch((err) => {
|
|
182
|
+
inflight.delete(instanceDid); // drop so the next request retries
|
|
183
|
+
throw err;
|
|
184
|
+
});
|
|
185
|
+
inflight.set(instanceDid, p);
|
|
186
|
+
}
|
|
187
|
+
return p;
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Dependencies for the request handler (factored out so it is testable with a fake service). */
|
|
192
|
+
export interface FetchDeps {
|
|
193
|
+
svc: Pick<EmbeddedPaymentService, 'http'>;
|
|
194
|
+
basePath: string;
|
|
195
|
+
resolveTenant: (request: Request) => Promise<string | null> | string | null;
|
|
196
|
+
resolveCaller?: (
|
|
197
|
+
request: Request,
|
|
198
|
+
instanceDid: string | null,
|
|
199
|
+
) => Promise<CloudflareCallerIdentity | null> | CloudflareCallerIdentity | null;
|
|
200
|
+
provision: (instanceDid: string | null) => Promise<void>;
|
|
201
|
+
bindEnv: (env: any, ctx: ExecutionContext, httpContext: boolean) => void;
|
|
202
|
+
flush: () => Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Build the request handler: strip+inject caller, lazy-provision, run http.fetch under the tenant context. */
|
|
206
|
+
export function buildFetch(deps: FetchDeps) {
|
|
207
|
+
return async (request: Request, env: any, ctx: ExecutionContext): Promise<Response> => {
|
|
208
|
+
deps.bindEnv(env, ctx, true);
|
|
209
|
+
|
|
210
|
+
// Resolve the tenant from the real request (arc's host→instanceDid mapping).
|
|
211
|
+
const instanceDid = await deps.resolveTenant(request);
|
|
212
|
+
|
|
213
|
+
// Lazy first-request provisioning. Never block the request on a transient
|
|
214
|
+
// failure: provisionTenant is idempotent and the provisioner drops failures so
|
|
215
|
+
// the next request retries; a true missing-config surfaces downstream.
|
|
216
|
+
try {
|
|
217
|
+
await deps.provision(instanceDid);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error('[payment] lazy provisionTenant failed:', err);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// CF API gate: strip client-forged x-user-*, inject the host-resolved caller.
|
|
223
|
+
const caller = deps.resolveCaller ? await deps.resolveCaller(request, instanceDid) : null;
|
|
224
|
+
const headers = new Headers(request.headers);
|
|
225
|
+
injectCaller(headers, caller);
|
|
226
|
+
// new Request(request, { headers }) carries the raw body bytes UNCONSUMED — the
|
|
227
|
+
// adapter never reads the body, so the Stripe webhook signature stays intact.
|
|
228
|
+
const forwarded = new Request(request, { headers });
|
|
229
|
+
|
|
230
|
+
// Run the core under the tenant context. An unknown host (null) is NOT wrapped:
|
|
231
|
+
// the core's connect/context middleware then resolves via the identity driver,
|
|
232
|
+
// which returns null → TENANT_HOST_UNRESOLVED 4xx (multi-mode fail-closed). The
|
|
233
|
+
// bare health route still answers without a tenant.
|
|
234
|
+
const run = () => deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
|
|
235
|
+
const res = instanceDid ? await requestContext.withTenant(instanceDid, run) : await run();
|
|
236
|
+
|
|
237
|
+
await deps.flush(); // drain workerd deferred queue work before responding
|
|
238
|
+
return res;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Dependencies for scheduled due-dispatch (factored out for testability). */
|
|
243
|
+
export interface ScheduledDeps {
|
|
244
|
+
bindEnv: (env: any, ctx: ExecutionContext, httpContext: boolean) => void;
|
|
245
|
+
runCrons: (intendedMinute: Date) => Promise<void>;
|
|
246
|
+
dispatchDue: () => Promise<unknown>;
|
|
247
|
+
flush: () => Promise<void>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Build the scheduled handler: run the cron jobs for the INTENDED minute
|
|
252
|
+
* (event.scheduledTime, not new Date() — a late delivery must not miss exact-minute
|
|
253
|
+
* crons), then the workerd trigger for the node queue engine's D1 due-job
|
|
254
|
+
* re-dispatch, then drain the re-pushed work before the isolate freezes.
|
|
255
|
+
*/
|
|
256
|
+
export function buildScheduled(deps: ScheduledDeps) {
|
|
257
|
+
return async (event: ScheduledEvent, env: any, ctx: ExecutionContext): Promise<void> => {
|
|
258
|
+
deps.bindEnv(env, ctx, false);
|
|
259
|
+
await deps.runCrons(new Date(event.scheduledTime));
|
|
260
|
+
await deps.dispatchDue();
|
|
261
|
+
await deps.flush();
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A registered core queue handle (the subset the consumer drives). */
|
|
266
|
+
interface QueueHandle {
|
|
267
|
+
pushAndWait: (params: { job: any; id: string; persist?: boolean }) => Promise<unknown>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Dependencies for the CF Queue consumer (factored out for testability). */
|
|
271
|
+
export interface QueueDeps {
|
|
272
|
+
bindEnv: (env: any, ctx: ExecutionContext, httpContext: boolean) => void;
|
|
273
|
+
getHandle: (queueName: string) => QueueHandle | undefined;
|
|
274
|
+
flush: () => Promise<void>;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Build the CF Queue consumer: resolve each message's core queue handle by name and
|
|
279
|
+
* run the job inline (persist:false — the D1 row already exists from the original
|
|
280
|
+
* push). Always ack: an unknown handler must not redeliver forever, and a failed
|
|
281
|
+
* job is owned by the D1 scheduled redispatch, NOT a CF Queue retry. The arc top
|
|
282
|
+
* level demuxes payment messages to this (decision #3) — it never sees the others.
|
|
283
|
+
*/
|
|
284
|
+
export function buildQueueConsumer(deps: QueueDeps) {
|
|
285
|
+
return async (batch: MessageBatch<any>, env: any, ctx: ExecutionContext): Promise<void> => {
|
|
286
|
+
deps.bindEnv(env, ctx, false);
|
|
287
|
+
for (const msg of batch.messages) {
|
|
288
|
+
const { queueName, jobId, job } = msg.body as { queueName: string; jobId: string; job: any };
|
|
289
|
+
const handle = deps.getHandle(queueName);
|
|
290
|
+
if (!handle) {
|
|
291
|
+
console.error(`[queue:consumer] no core queue registered for "${queueName}", acking`);
|
|
292
|
+
msg.ack();
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
// eslint-disable-next-line no-await-in-loop
|
|
297
|
+
await handle.pushAndWait({ job, id: jobId, persist: false });
|
|
298
|
+
msg.ack();
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
console.error(`[queue:consumer] failed ${queueName}:${jobId}:`, err?.message || err);
|
|
301
|
+
msg.ack(); // D1 scheduled() will re-dispatch — do not retry via CF Queue
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await deps.flush();
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Build the Cloudflare payment adapter. Async to match the host-wire contract
|
|
310
|
+
* (`await createCloudflarePaymentAdapter(...)`). Constructs the embedded payment
|
|
311
|
+
* service ONCE; the D1 binding is rebound per request (CF env is per-request).
|
|
312
|
+
*/
|
|
313
|
+
export async function createCloudflarePaymentAdapter(
|
|
314
|
+
options: CloudflarePaymentAdapterOptions,
|
|
315
|
+
): Promise<CloudflarePaymentAdapter> {
|
|
316
|
+
if (!options || typeof options !== 'object') {
|
|
317
|
+
throw new Error('createCloudflarePaymentAdapter: options object is required');
|
|
318
|
+
}
|
|
319
|
+
if (!options.db?.d1) {
|
|
320
|
+
throw new Error('createCloudflarePaymentAdapter: db.d1 (PAYMENT_DB binding) is required');
|
|
321
|
+
}
|
|
322
|
+
if (typeof options.tenant?.resolve !== 'function') {
|
|
323
|
+
throw new Error('createCloudflarePaymentAdapter: tenant.resolve(request) is required (multi-tenant)');
|
|
324
|
+
}
|
|
325
|
+
if (typeof options.identity?.getAppEk !== 'function') {
|
|
326
|
+
throw new Error('createCloudflarePaymentAdapter: identity.getAppEk(instanceDid) is required (multi-tenant)');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const basePath = options.basePath ?? PAYMENT_PREFIX;
|
|
330
|
+
const d1 = options.db.d1;
|
|
331
|
+
|
|
332
|
+
// The shim Sequelize is a thin model registry; the ACTUAL D1 binding is rebound
|
|
333
|
+
// per request via setDB() (CF env is per-request). Models bind to this instance
|
|
334
|
+
// at construction (createEmbeddedPaymentService → initialize).
|
|
335
|
+
const sequelize = new Sequelize();
|
|
336
|
+
|
|
337
|
+
// The core IdentityDriver: the host supplies the per-tenant EK; the DID-Connect
|
|
338
|
+
// signing identity (getInstanceAppIdentity) comes from the AUTH_SERVICE-backed CF
|
|
339
|
+
// driver (the arc binding is finalized in Phase 6). resolveInstanceDidForHost is
|
|
340
|
+
// a fail-closed fallback: the adapter pre-resolves the tenant per request and
|
|
341
|
+
// wraps the core call in withTenant, so this host-only bridge is never the
|
|
342
|
+
// authoritative path (a bare host string cannot reconstruct arc's request-level
|
|
343
|
+
// resolution — returning null fails closed rather than guessing).
|
|
344
|
+
const cfDidConnectIdentity = createCloudflareIdentityDriver();
|
|
345
|
+
const identity: IdentityDriver = {
|
|
346
|
+
resolveInstanceDidForHost: () => null,
|
|
347
|
+
getAppEk: (instanceDid: string) => options.identity.getAppEk(instanceDid),
|
|
348
|
+
getInstanceAppIdentity: cfDidConnectIdentity.getInstanceAppIdentity?.bind(cfDidConnectIdentity),
|
|
349
|
+
// per-tenant business wallet (single seam); directory stays the CF alias shim
|
|
350
|
+
getBusinessWallet: cfDidConnectIdentity.getBusinessWallet,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const svc: EmbeddedPaymentService = createEmbeddedPaymentService({
|
|
354
|
+
// Host-global csrf secret via the config boundary (payment-core's csrf reads
|
|
355
|
+
// PAYMENT_CSRF_SECRET through readConfig). NO sessionSecret (decision #5).
|
|
356
|
+
config: { ...(options.config ?? {}), ...(options.csrfSecret ? { PAYMENT_CSRF_SECRET: options.csrfSecret } : {}) },
|
|
357
|
+
db: { sequelize },
|
|
358
|
+
tenancy: { mode: 'multi' },
|
|
359
|
+
identity,
|
|
360
|
+
// not omittable: a multi-tenant host without the per-tenant keyring would fall
|
|
361
|
+
// back to a single process key (cross-tenant leak) — the factory fails closed.
|
|
362
|
+
secrets: createKeyringSecretsDriver(identity),
|
|
363
|
+
// D1-backed cross-isolate locks (the binding is read lazily at lock time).
|
|
364
|
+
locks: createD1LocksDriver(() => d1),
|
|
365
|
+
// passive cf-cron: never owns a timer; the host drives runDue from scheduled().
|
|
366
|
+
cron: createCronRegistry('cf-cron'),
|
|
367
|
+
// the converged CF DID-Connect runtime (real did-connect-js + D1 token store).
|
|
368
|
+
didConnectRuntime: createCloudflareDidConnectRuntime(),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Per-request env setup: flush deferred init timers, expose env + waitUntil to
|
|
372
|
+
// the createEvent/global shims, and rebind the D1 session (first-primary read
|
|
373
|
+
// policy + transient-error retry). Mirrors the standalone worker's middleware.
|
|
374
|
+
const bindEnv = (env: any, ctx: ExecutionContext, httpContext: boolean) => {
|
|
375
|
+
if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
|
|
376
|
+
(globalThis as any).__flushDeferredTimers();
|
|
377
|
+
}
|
|
378
|
+
(globalThis as any).__CF_ENV__ = env;
|
|
379
|
+
(globalThis as any).__cfHttpContext__ = httpContext;
|
|
380
|
+
(globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => ctx.waitUntil(p);
|
|
381
|
+
setDB(withD1Retry((env.PAYMENT_DB ?? d1).withSession('first-primary')));
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Lazy first-request provisioning (light DB seed only — no chain/Stripe calls).
|
|
385
|
+
const provision = createTenantProvisioner((instanceDid) => svc.provisionTenant(instanceDid));
|
|
386
|
+
|
|
387
|
+
// cron jobs register once per isolate; runAll dispatches those due for the minute.
|
|
388
|
+
let cronsInitialized = false;
|
|
389
|
+
const runCrons = async (intendedMinute: Date) => {
|
|
390
|
+
if (!cronsInitialized) {
|
|
391
|
+
try {
|
|
392
|
+
crons.init();
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error('[payment] cron init failed (non-fatal):', err);
|
|
395
|
+
}
|
|
396
|
+
cronsInitialized = true;
|
|
397
|
+
}
|
|
398
|
+
await cronInstance.runAll(intendedMinute);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
fetch: buildFetch({
|
|
403
|
+
svc,
|
|
404
|
+
basePath,
|
|
405
|
+
resolveTenant: (request) => options.tenant.resolve(request),
|
|
406
|
+
resolveCaller: options.caller ? (request, did) => options.caller!.resolve(request, did) : undefined,
|
|
407
|
+
provision,
|
|
408
|
+
bindEnv,
|
|
409
|
+
flush: flushQueueWork,
|
|
410
|
+
}),
|
|
411
|
+
scheduled: buildScheduled({ bindEnv, runCrons, dispatchDue: dispatchDueJobs, flush: flushQueueWork }),
|
|
412
|
+
queue: buildQueueConsumer({ bindEnv, getHandle: (name) => getQueueHandler(name), flush: flushQueueWork }),
|
|
413
|
+
provisionTenant: (instanceDid: string) => svc.provisionTenant(instanceDid),
|
|
414
|
+
bootstrapTenant: (instanceDid: string) => svc.bootstrapTenant(instanceDid),
|
|
415
|
+
// Deploy-time schema application (decision #4): idempotent SQL migrations against
|
|
416
|
+
// PAYMENT_DB, driven by CI/wrangler — never per-request.
|
|
417
|
+
ensureSchema: () => applyPaymentCoreMigrations(createD1DbDriver(d1)),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — the Cloudflare DID-Connect runtime + identity driver.
|
|
2
|
+
//
|
|
3
|
+
// These are the CF host adapters that turn the shared core runtime
|
|
4
|
+
// (createDidConnectJsRuntime) into a working CF DID-Connect surface:
|
|
5
|
+
// - createCloudflareDidConnectRuntime: real @arcblock/did-connect-js + the CF
|
|
6
|
+
// chain config / @ocap CBOR txEncoder / 30s timeout / chain pre-warm, with the
|
|
7
|
+
// tenant-aware D1 token store.
|
|
8
|
+
// - createCloudflareIdentityDriver: getInstanceAppIdentity via the AUTH_SERVICE
|
|
9
|
+
// binding (did-connect-service@4.0.3) with a short TTL cache; fail-closed.
|
|
10
|
+
//
|
|
11
|
+
// abt-wallet 4.20+ is dual-decode, so we use @ocap/client/encode's CBOR-only
|
|
12
|
+
// txEncoder (drops ~300KB google-protobuf). fetchContext pre-warms the module
|
|
13
|
+
// context cache at isolate init so the first merchant request doesn't eat the 8s
|
|
14
|
+
// chain round-trip.
|
|
15
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
16
|
+
import { createTxEncoder, fetchContext } from '@ocap/client/encode';
|
|
17
|
+
|
|
18
|
+
import type { IdentityDriver, InstanceAppIdentity } from '../api/src/libs/drivers';
|
|
19
|
+
import { createDidConnectJsRuntime } from '../api/src/libs/did-connect/runtime-did-connect-js';
|
|
20
|
+
import { createEmbeddedIdentityServices } from '../api/src/libs/did-connect/tenant-identity';
|
|
21
|
+
import { CloudflareTenantTokenStorage } from './did-connect-token-storage';
|
|
22
|
+
|
|
23
|
+
// Trailing slash REQUIRED (without it the host times out ~50% — verified 2026-04-20);
|
|
24
|
+
// matches D1 payment_methods.settings.arcblock.api_host.
|
|
25
|
+
const CHAIN_HOST = 'https://beta.abtnetwork.io/api/';
|
|
26
|
+
|
|
27
|
+
let prewarmed = false;
|
|
28
|
+
|
|
29
|
+
/** The CF DID-Connect runtime: real did-connect-js + CF chain/txEncoder/storage. */
|
|
30
|
+
export function createCloudflareDidConnectRuntime() {
|
|
31
|
+
if (!prewarmed) {
|
|
32
|
+
prewarmed = true;
|
|
33
|
+
// Non-fatal: on failure the first real request retries via the encoder's own fetchContext.
|
|
34
|
+
fetchContext(CHAIN_HOST).catch((err: any) =>
|
|
35
|
+
console.warn('[did-connect] chain pre-warm failed:', err?.message || err)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return createDidConnectJsRuntime({
|
|
39
|
+
tokenStorage: new CloudflareTenantTokenStorage({ ttl: 300 }),
|
|
40
|
+
chainInfo: { type: 'arcblock', host: CHAIN_HOST, id: 'beta' },
|
|
41
|
+
txEncoder: createTxEncoder(),
|
|
42
|
+
timeout: 30000,
|
|
43
|
+
defaultAppInfo: {
|
|
44
|
+
name: 'Payment Kit',
|
|
45
|
+
description: 'Payment Kit on Cloudflare Workers',
|
|
46
|
+
icon: 'https://www.arcblock.io/favicon.ico',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The CF identity driver: per-instance app signing identity via the AUTH_SERVICE
|
|
53
|
+
* binding. Reads the request env from globalThis.__CF_ENV__ (the service binding is
|
|
54
|
+
* per-request). Short TTL cache to avoid an RPC per DID-Connect sign. Fail-closed
|
|
55
|
+
* when the binding or the identity is missing — never derives a key from env.APP_SK.
|
|
56
|
+
*/
|
|
57
|
+
export function createCloudflareIdentityDriver(): IdentityDriver {
|
|
58
|
+
const cache = new Map<string, { value: InstanceAppIdentity; expires: number }>();
|
|
59
|
+
const TTL_MS = 60_000;
|
|
60
|
+
const authService = (): any => (globalThis as any).__CF_ENV__?.AUTH_SERVICE;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
resolveInstanceDidForHost(host) {
|
|
64
|
+
const svc = authService();
|
|
65
|
+
if (svc && typeof svc.resolveInstanceDidForHost === 'function') {
|
|
66
|
+
return svc.resolveInstanceDidForHost(host ?? '');
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
},
|
|
70
|
+
getAppEk(instanceDid) {
|
|
71
|
+
const svc = authService();
|
|
72
|
+
if (!svc || typeof svc.getAppEk !== 'function') {
|
|
73
|
+
throw new Error('[did-connect] AUTH_SERVICE.getAppEk unavailable — fail-closed');
|
|
74
|
+
}
|
|
75
|
+
return svc.getAppEk(instanceDid);
|
|
76
|
+
},
|
|
77
|
+
async getInstanceAppIdentity(instanceDid: string): Promise<InstanceAppIdentity> {
|
|
78
|
+
const cached = cache.get(instanceDid);
|
|
79
|
+
if (cached && cached.expires > Date.now()) return cached.value;
|
|
80
|
+
const svc = authService();
|
|
81
|
+
if (!svc || typeof svc.getInstanceAppIdentity !== 'function') {
|
|
82
|
+
throw new Error('[did-connect] AUTH_SERVICE.getInstanceAppIdentity unavailable — fail-closed');
|
|
83
|
+
}
|
|
84
|
+
const value: InstanceAppIdentity = await svc.getInstanceAppIdentity(instanceDid);
|
|
85
|
+
if (!value || !value.appSk) {
|
|
86
|
+
throw new Error(`[did-connect] getInstanceAppIdentity returned no appSk for "${instanceDid}" — fail-closed`);
|
|
87
|
+
}
|
|
88
|
+
cache.set(instanceDid, { value, expires: Date.now() + TTL_MS });
|
|
89
|
+
return value;
|
|
90
|
+
},
|
|
91
|
+
// The per-tenant business wallet (derived from getInstanceAppIdentity via the
|
|
92
|
+
// shared resolver) — the single convergence seam. The `directory` is omitted:
|
|
93
|
+
// CF keeps its build-alias BlockletService shim (auth.ts falls back to it).
|
|
94
|
+
getBusinessWallet: createEmbeddedIdentityServices().getBusinessWallet,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — the CF DID-Connect token store, tenant-aware.
|
|
2
|
+
//
|
|
3
|
+
// Carries forward the proven standalone-worker behavior (the `_did_connect_tokens`
|
|
4
|
+
// D1 table + first-primary read-your-writes + the `waitUntil` hack for the
|
|
5
|
+
// fire-and-forget `update` that did-connect-js's onProcessError performs) and ADDS
|
|
6
|
+
// per-tenant isolation: every record is stamped with the creating `instanceDid`
|
|
7
|
+
// (from the TenantContext), and read/update/delete/exist reject a token whose
|
|
8
|
+
// stored instanceDid != the current tenant's (treated as not-found). A token minted
|
|
9
|
+
// under tenant A can never be read/advanced under tenant B's host.
|
|
10
|
+
//
|
|
11
|
+
// Isolation lives in the record JSON (no schema migration): the existing `data`
|
|
12
|
+
// column already holds JSON, so we add an `instanceDid` field. Tokens are short-TTL
|
|
13
|
+
// ephemeral sessions, so older untagged rows simply read as not-found under a tenant
|
|
14
|
+
// once this ships — acceptable per the convergence plan (fail-closed, no migration).
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
|
|
17
|
+
import { getInstanceDid } from '../api/src/libs/context';
|
|
18
|
+
|
|
19
|
+
interface TokenRecord {
|
|
20
|
+
token: string;
|
|
21
|
+
status?: string;
|
|
22
|
+
instanceDid?: string;
|
|
23
|
+
[key: string]: any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CloudflareTenantTokenStorage extends EventEmitter {
|
|
27
|
+
private ttl: number;
|
|
28
|
+
|
|
29
|
+
constructor(options: { ttl?: number } = {}) {
|
|
30
|
+
super();
|
|
31
|
+
this.ttl = options.ttl ?? 300;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private _getDB() {
|
|
35
|
+
const env = (globalThis as any).__CF_ENV__;
|
|
36
|
+
const db = env?.DB;
|
|
37
|
+
if (!db) {
|
|
38
|
+
console.error('[D1TokenStore] DB not available — __CF_ENV__ missing or no DB binding');
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
return db.withSession('first-primary');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The tenant whose context this storage op runs under. Throws (fail-closed) in
|
|
45
|
+
* multi mode without an established context — the caller is misrouted. */
|
|
46
|
+
private _tenant(): string {
|
|
47
|
+
return getInstanceDid();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read the raw row, enforcing tenant isolation: a record stamped with a
|
|
51
|
+
* different instanceDid is invisible (treated as not-found). */
|
|
52
|
+
private async _readScoped(token: string): Promise<TokenRecord | null> {
|
|
53
|
+
const db = this._getDB();
|
|
54
|
+
if (!db) return null;
|
|
55
|
+
const now = Math.floor(Date.now() / 1000);
|
|
56
|
+
const row = await db
|
|
57
|
+
.prepare('SELECT data FROM _did_connect_tokens WHERE token = ? AND expires_at > ?')
|
|
58
|
+
.bind(token, now)
|
|
59
|
+
.first();
|
|
60
|
+
if (!row) return null;
|
|
61
|
+
const record = JSON.parse(row.data as string) as TokenRecord;
|
|
62
|
+
// tenant isolation: untagged (legacy) or cross-tenant tokens are not-found.
|
|
63
|
+
if (record.instanceDid !== this._tenant()) return null;
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async create(token: string, status = 'created') {
|
|
68
|
+
try {
|
|
69
|
+
const db = this._getDB();
|
|
70
|
+
if (!db) throw new Error('DB not available');
|
|
71
|
+
const record: TokenRecord = { token, status, instanceDid: this._tenant() };
|
|
72
|
+
const expiresAt = Math.floor(Date.now() / 1000) + this.ttl;
|
|
73
|
+
await db
|
|
74
|
+
.prepare('INSERT OR REPLACE INTO _did_connect_tokens (token, data, expires_at) VALUES (?, ?, ?)')
|
|
75
|
+
.bind(token, JSON.stringify(record), expiresAt)
|
|
76
|
+
.run();
|
|
77
|
+
this.emit('create', record);
|
|
78
|
+
return record;
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
console.error('[D1TokenStore] create failed:', token, err?.message || err);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async read(token: string) {
|
|
86
|
+
try {
|
|
87
|
+
return await this._readScoped(token);
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
console.error('[D1TokenStore] read failed:', token, err?.message || err);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
update(token: string, updates: Record<string, any>) {
|
|
95
|
+
// did-connect-js's onProcessError calls tokenStorage.update WITHOUT awaiting
|
|
96
|
+
// (fire-and-forget). On workerd a non-awaited Promise is killed when the handler
|
|
97
|
+
// returns, so the UPDATE never lands and the token sticks at "scanned" forever
|
|
98
|
+
// (wallet UI loops on "validating…"). Register the write via ctx.waitUntil
|
|
99
|
+
// (exposed as __cfWaitUntil__) so the isolate stays alive until D1 acks,
|
|
100
|
+
// regardless of whether the caller awaits us. (Carried forward verbatim.)
|
|
101
|
+
const promise = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
const existing = await this._readScoped(token); // tenant-scoped read
|
|
104
|
+
if (!existing) return null;
|
|
105
|
+
delete updates.token;
|
|
106
|
+
delete updates.instanceDid; // never let an update rewrite the owning tenant
|
|
107
|
+
const merged = { ...existing, ...updates };
|
|
108
|
+
const expiresAt = Math.floor(Date.now() / 1000) + this.ttl;
|
|
109
|
+
const db = this._getDB();
|
|
110
|
+
if (!db) throw new Error('DB not available');
|
|
111
|
+
await db
|
|
112
|
+
.prepare('UPDATE _did_connect_tokens SET data = ?, expires_at = ? WHERE token = ?')
|
|
113
|
+
.bind(JSON.stringify(merged), expiresAt, token)
|
|
114
|
+
.run();
|
|
115
|
+
this.emit('update', merged);
|
|
116
|
+
return merged;
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
console.error('[D1TokenStore] update failed:', token, err?.message || err);
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
|
|
123
|
+
const waitUntil = (globalThis as any).__cfWaitUntil__ as ((p: Promise<any>) => void) | undefined;
|
|
124
|
+
if (typeof waitUntil === 'function') {
|
|
125
|
+
waitUntil(promise.catch(() => {}));
|
|
126
|
+
}
|
|
127
|
+
return promise;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async delete(token: string) {
|
|
131
|
+
try {
|
|
132
|
+
const existing = await this._readScoped(token); // tenant-scoped
|
|
133
|
+
if (!existing) return; // cross-tenant / missing → no-op
|
|
134
|
+
this.emit('destroy', existing);
|
|
135
|
+
const db = this._getDB();
|
|
136
|
+
if (!db) return;
|
|
137
|
+
await db.prepare('DELETE FROM _did_connect_tokens WHERE token = ?').bind(token).run();
|
|
138
|
+
} catch (err: any) {
|
|
139
|
+
console.error('[D1TokenStore] delete failed:', token, err?.message || err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async exist(token: string, did?: string) {
|
|
144
|
+
const record = await this._readScoped(token);
|
|
145
|
+
if (!record) return false;
|
|
146
|
+
if (did) return record.did === did;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default CloudflareTenantTokenStorage;
|