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
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
// CF FAIL-FAST shim for @blocklet/sdk/lib/wallet-handler.
|
|
2
|
+
//
|
|
3
|
+
// S3-CF (DID convergence): on workerd the @blocklet/sdk wallet wrapper is NOT a
|
|
4
|
+
// working DID-Connect implementation. CF (and arc-node embedded) MUST inject the
|
|
5
|
+
// real @arcblock/did-connect-js runtime via setDidConnectRuntime
|
|
6
|
+
// (createCloudflareDidConnectRuntime). This used to be a silent no-op stub whose
|
|
7
|
+
// `attach()` registered ZERO routes — a CF host that forgot to inject would serve
|
|
8
|
+
// no DID-Connect routes and fail only at request time. It now throws on construct
|
|
9
|
+
// so the misconfiguration fails loudly (a spec asserts the AUTH_SERVICE runtime
|
|
10
|
+
// never reaches this path).
|
|
11
|
+
const FORBIDDEN =
|
|
12
|
+
'CF must inject a DID-Connect runtime via setDidConnectRuntime(createCloudflareDidConnectRuntime); ' +
|
|
13
|
+
'the @blocklet/sdk wallet-handler shim is forbidden on workerd';
|
|
14
|
+
|
|
1
15
|
export class WalletHandlers {
|
|
2
|
-
constructor(_opts?: any) {
|
|
3
|
-
|
|
4
|
-
// TODO: implement DID Connect for CF
|
|
16
|
+
constructor(_opts?: any) {
|
|
17
|
+
throw new Error(`[did-connect] WalletHandlers: ${FORBIDDEN}`);
|
|
5
18
|
}
|
|
6
19
|
}
|
|
20
|
+
|
|
21
|
+
export default { WalletHandlers };
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// S3-CF Phase 3 — the CF payment adapter host glue. These unit-test the
|
|
2
|
+
// request-path behavior (caller strip/inject, tenant context, raw-body fidelity,
|
|
3
|
+
// lazy provision in-flight dedup, scheduled due-dispatch, queue demux) against the
|
|
4
|
+
// REAL helpers with fake services — no Miniflare/D1 needed (the worker bundle
|
|
5
|
+
// build is the integration gate; this is the behavior gate).
|
|
6
|
+
|
|
7
|
+
import { injectCaller, createTenantProvisioner, buildFetch, buildScheduled, buildQueueConsumer } from '../cf-adapter';
|
|
8
|
+
import type { CloudflareCallerIdentity } from '../cf-adapter';
|
|
9
|
+
import { context as requestContext } from '../../api/src/libs/context';
|
|
10
|
+
|
|
11
|
+
const noopBindEnv = () => undefined;
|
|
12
|
+
const noopFlush = async () => undefined;
|
|
13
|
+
|
|
14
|
+
describe('injectCaller — CF API gate caller header glue (decision #5)', () => {
|
|
15
|
+
it('happy: injects canonical x-user-* from the host-resolved caller', () => {
|
|
16
|
+
const h = new Headers();
|
|
17
|
+
injectCaller(h, { did: 'zUSER', role: 'admin', authMethod: 'passkey', displayName: 'Alice' });
|
|
18
|
+
expect(h.get('x-user-did')).toBe('did:abt:zUSER');
|
|
19
|
+
expect(h.get('x-user-role')).toBe('blocklet-admin');
|
|
20
|
+
expect(h.get('x-user-provider')).toBe('passkey');
|
|
21
|
+
expect(h.get('x-user-fullname')).toBe(encodeURIComponent('Alice'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('security: STRIPS a client-forged identity header (no caller = anonymous, not forged)', () => {
|
|
25
|
+
const h = new Headers({ 'x-user-did': 'did:abt:zEVIL', 'x-user-role': 'blocklet-owner' });
|
|
26
|
+
injectCaller(h, null);
|
|
27
|
+
expect(h.get('x-user-did')).toBeNull();
|
|
28
|
+
expect(h.get('x-user-role')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('security: a forged header cannot survive even WITH a real caller (strip-then-inject)', () => {
|
|
32
|
+
const h = new Headers({ 'x-user-did': 'did:abt:zEVIL' });
|
|
33
|
+
injectCaller(h, { did: 'zREAL', role: 'member' });
|
|
34
|
+
expect(h.get('x-user-did')).toBe('did:abt:zREAL'); // not zEVIL
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps an already-canonical did:abt: prefix and prefixes a bare did', () => {
|
|
38
|
+
const a = new Headers();
|
|
39
|
+
injectCaller(a, { did: 'did:abt:zABC' });
|
|
40
|
+
expect(a.get('x-user-did')).toBe('did:abt:zABC');
|
|
41
|
+
const b = new Headers();
|
|
42
|
+
injectCaller(b, { did: 'zABC' });
|
|
43
|
+
expect(b.get('x-user-did')).toBe('did:abt:zABC');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('createTenantProvisioner — lazy first-request provisioning, in-flight dedup', () => {
|
|
48
|
+
it('data-loss: concurrent first-requests for the same tenant provision ONCE', async () => {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const provision = jest.fn(async () => {
|
|
51
|
+
calls += 1;
|
|
52
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
53
|
+
});
|
|
54
|
+
const provisioner = createTenantProvisioner(provision);
|
|
55
|
+
await Promise.all([provisioner('zA'), provisioner('zA'), provisioner('zA'), provisioner('zA')]);
|
|
56
|
+
expect(calls).toBe(1);
|
|
57
|
+
expect(provision).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('different tenants each provision once', async () => {
|
|
61
|
+
const provision = jest.fn(async () => undefined);
|
|
62
|
+
const provisioner = createTenantProvisioner(provision);
|
|
63
|
+
await Promise.all([provisioner('zA'), provisioner('zB'), provisioner('zA')]);
|
|
64
|
+
expect(provision).toHaveBeenCalledTimes(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a failed provision is dropped so the next request RETRIES (not cached as done)', async () => {
|
|
68
|
+
let n = 0;
|
|
69
|
+
const provision = jest.fn(async () => {
|
|
70
|
+
n += 1;
|
|
71
|
+
if (n === 1) throw new Error('transient');
|
|
72
|
+
});
|
|
73
|
+
const provisioner = createTenantProvisioner(provision);
|
|
74
|
+
await expect(provisioner('zA')).rejects.toThrow('transient');
|
|
75
|
+
await expect(provisioner('zA')).resolves.toBeUndefined(); // retried, now succeeds
|
|
76
|
+
expect(provision).toHaveBeenCalledTimes(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('null tenant is a no-op (no provision attempt)', async () => {
|
|
80
|
+
const provision = jest.fn(async () => undefined);
|
|
81
|
+
const provisioner = createTenantProvisioner(provision);
|
|
82
|
+
await provisioner(null);
|
|
83
|
+
expect(provision).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// A fake embedded service: captures what the adapter forwards (headers, raw body,
|
|
88
|
+
// basePath) and the tenant context active at fetch time.
|
|
89
|
+
function fakeSvc() {
|
|
90
|
+
const seen: { did?: string; role?: string; tenant?: string; basePath?: string; body?: string } = {};
|
|
91
|
+
return {
|
|
92
|
+
seen,
|
|
93
|
+
http: {
|
|
94
|
+
async fetch(req: Request, opts?: { basePath?: string }) {
|
|
95
|
+
seen.did = req.headers.get('x-user-did') ?? undefined;
|
|
96
|
+
seen.role = req.headers.get('x-user-role') ?? undefined;
|
|
97
|
+
seen.tenant = requestContext.peekInstanceDid();
|
|
98
|
+
seen.basePath = opts?.basePath;
|
|
99
|
+
seen.body = await req.text();
|
|
100
|
+
return new Response('ok', { status: 200 });
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('buildFetch — drives the single http.fetch under the tenant context', () => {
|
|
107
|
+
it('happy: strips forged x-user-*, injects the real caller, runs under withTenant, passes basePath, flushes', async () => {
|
|
108
|
+
const svc = fakeSvc();
|
|
109
|
+
let flushed = 0;
|
|
110
|
+
const fetch = buildFetch({
|
|
111
|
+
svc: svc as any,
|
|
112
|
+
basePath: '/.well-known/payment',
|
|
113
|
+
resolveTenant: () => 'did:abt:zTENANT',
|
|
114
|
+
resolveCaller: () => ({ did: 'zREAL', role: 'member' } as CloudflareCallerIdentity),
|
|
115
|
+
provision: async () => undefined,
|
|
116
|
+
bindEnv: noopBindEnv,
|
|
117
|
+
flush: async () => {
|
|
118
|
+
flushed += 1;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const req = new Request('https://x/.well-known/payment/api/products', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'x-user-did': 'did:abt:zFORGED' },
|
|
124
|
+
body: 'raw-bytes',
|
|
125
|
+
});
|
|
126
|
+
const res = await fetch(req, {}, { waitUntil() {} } as any);
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(svc.seen.did).toBe('did:abt:zREAL'); // forged stripped, real injected
|
|
129
|
+
expect(svc.seen.tenant).toBe('did:abt:zTENANT'); // ran under withTenant
|
|
130
|
+
expect(svc.seen.basePath).toBe('/.well-known/payment');
|
|
131
|
+
expect(svc.seen.body).toBe('raw-bytes'); // data-damage: raw body preserved
|
|
132
|
+
expect(flushed).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('security: forged x-user-did with NO caller resolved → stripped, anonymous (fail-safe)', async () => {
|
|
136
|
+
const svc = fakeSvc();
|
|
137
|
+
const fetch = buildFetch({
|
|
138
|
+
svc: svc as any,
|
|
139
|
+
basePath: '/.well-known/payment',
|
|
140
|
+
resolveTenant: () => 'did:abt:zT',
|
|
141
|
+
resolveCaller: () => null,
|
|
142
|
+
provision: async () => undefined,
|
|
143
|
+
bindEnv: noopBindEnv,
|
|
144
|
+
flush: noopFlush,
|
|
145
|
+
});
|
|
146
|
+
await fetch(new Request('https://x/api/x', { headers: { 'x-user-did': 'did:abt:zEVIL' } }), {}, { waitUntil() {} } as any);
|
|
147
|
+
expect(svc.seen.did).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('data-leak: an unknown host (tenant null) is NOT wrapped — the core fail-closes tenant-scoped routes', async () => {
|
|
151
|
+
const svc = fakeSvc();
|
|
152
|
+
const fetch = buildFetch({
|
|
153
|
+
svc: svc as any,
|
|
154
|
+
basePath: '/.well-known/payment',
|
|
155
|
+
resolveTenant: () => null,
|
|
156
|
+
provision: async () => undefined,
|
|
157
|
+
bindEnv: noopBindEnv,
|
|
158
|
+
flush: noopFlush,
|
|
159
|
+
});
|
|
160
|
+
await fetch(new Request('https://unknown/api/x'), {}, { waitUntil() {} } as any);
|
|
161
|
+
expect(svc.seen.tenant).toBeUndefined(); // no context leaked; core will reject tenant-scoped routes
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('data-loss: a first request triggers the lazy provisioner exactly once for its tenant', async () => {
|
|
165
|
+
const svc = fakeSvc();
|
|
166
|
+
const provision = jest.fn(async () => undefined);
|
|
167
|
+
const provisioner = createTenantProvisioner(provision);
|
|
168
|
+
const fetch = buildFetch({
|
|
169
|
+
svc: svc as any,
|
|
170
|
+
basePath: '/.well-known/payment',
|
|
171
|
+
resolveTenant: () => 'did:abt:zP',
|
|
172
|
+
provision: provisioner,
|
|
173
|
+
bindEnv: noopBindEnv,
|
|
174
|
+
flush: noopFlush,
|
|
175
|
+
});
|
|
176
|
+
const ctx = { waitUntil() {} } as any;
|
|
177
|
+
await Promise.all([
|
|
178
|
+
fetch(new Request('https://x/api/a'), {}, ctx),
|
|
179
|
+
fetch(new Request('https://x/api/b'), {}, ctx),
|
|
180
|
+
]);
|
|
181
|
+
expect(provision).toHaveBeenCalledTimes(1);
|
|
182
|
+
expect(provision).toHaveBeenCalledWith('did:abt:zP');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('buildScheduled — host-driven due dispatch', () => {
|
|
187
|
+
it('runs crons for the intended minute then dispatches due jobs then flushes (ordered)', async () => {
|
|
188
|
+
const order: string[] = [];
|
|
189
|
+
const scheduled = buildScheduled({
|
|
190
|
+
bindEnv: () => order.push('bind'),
|
|
191
|
+
runCrons: async (when: Date) => {
|
|
192
|
+
order.push(`crons:${when.toISOString()}`);
|
|
193
|
+
},
|
|
194
|
+
dispatchDue: async () => {
|
|
195
|
+
order.push('dispatch');
|
|
196
|
+
},
|
|
197
|
+
flush: async () => {
|
|
198
|
+
order.push('flush');
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const t = Date.UTC(2026, 3, 17, 10, 0, 0);
|
|
202
|
+
await scheduled({ cron: '*/5 * * * *', scheduledTime: t } as any, {}, { waitUntil() {} } as any);
|
|
203
|
+
expect(order).toEqual(['bind', `crons:${new Date(t).toISOString()}`, 'dispatch', 'flush']);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('buildQueueConsumer — payment queue message demux', () => {
|
|
208
|
+
it('routes each message to its core queue handle by name, then acks; flushes once', async () => {
|
|
209
|
+
const ran: Array<{ name: string; id: string }> = [];
|
|
210
|
+
const acked: string[] = [];
|
|
211
|
+
let flushed = 0;
|
|
212
|
+
const handle = { pushAndWait: async (p: any) => ran.push({ name: 'refund', id: p.id }) };
|
|
213
|
+
const queue = buildQueueConsumer({
|
|
214
|
+
bindEnv: noopBindEnv,
|
|
215
|
+
getHandle: (name: string) => (name === 'refund' ? (handle as any) : undefined),
|
|
216
|
+
flush: async () => {
|
|
217
|
+
flushed += 1;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const batch = {
|
|
221
|
+
messages: [
|
|
222
|
+
{ body: { queueName: 'refund', jobId: 'j1', job: {} }, ack: () => acked.push('j1') },
|
|
223
|
+
{ body: { queueName: 'unknown', jobId: 'j2', job: {} }, ack: () => acked.push('j2') },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
await queue(batch as any, {}, { waitUntil() {} } as any);
|
|
227
|
+
expect(ran).toEqual([{ name: 'refund', id: 'j1' }]);
|
|
228
|
+
expect(acked).toEqual(['j1', 'j2']); // unknown handler still acked (no infinite redelivery)
|
|
229
|
+
expect(flushed).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('a throwing handler is acked (not retried via CF Queue — D1 scheduled redispatch owns retries)', async () => {
|
|
233
|
+
const acked: string[] = [];
|
|
234
|
+
const handle = { pushAndWait: async () => { throw new Error('boom'); } };
|
|
235
|
+
const queue = buildQueueConsumer({
|
|
236
|
+
bindEnv: noopBindEnv,
|
|
237
|
+
getHandle: () => handle as any,
|
|
238
|
+
flush: noopFlush,
|
|
239
|
+
});
|
|
240
|
+
const batch = { messages: [{ body: { queueName: 'refund', jobId: 'j1', job: {} }, ack: () => acked.push('j1') }] };
|
|
241
|
+
await queue(batch as any, {}, { waitUntil() {} } as any);
|
|
242
|
+
expect(acked).toEqual(['j1']);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — CF DID-Connect token store: tenant isolation + waitUntil.
|
|
2
|
+
//
|
|
3
|
+
// Deterministic unit tests over a fake D1 (no chain, no AUTH_SERVICE):
|
|
4
|
+
// - a token minted under tenant A is invisible under tenant B (read/exist/
|
|
5
|
+
// update/delete all not-found) — the isolation the convergence requires.
|
|
6
|
+
// - the fire-and-forget `update` (did-connect-js onProcessError) still lands via
|
|
7
|
+
// __cfWaitUntil__ even when the caller never awaits — the "scanned → succeed"
|
|
8
|
+
// regression the standalone worker's waitUntil hack fixed.
|
|
9
|
+
import { context } from '../../api/src/libs/context';
|
|
10
|
+
import { CloudflareTenantTokenStorage } from '../did-connect-token-storage';
|
|
11
|
+
|
|
12
|
+
const A = 'zMOCK_TENANT_A';
|
|
13
|
+
const B = 'zMOCK_TENANT_B';
|
|
14
|
+
|
|
15
|
+
/** Minimal in-memory D1 double covering the prepare/bind/run/first shape the store uses. */
|
|
16
|
+
function fakeD1() {
|
|
17
|
+
const rows = new Map<string, { data: string; expires_at: number }>();
|
|
18
|
+
const db: any = {
|
|
19
|
+
withSession: () => db,
|
|
20
|
+
prepare(sql: string) {
|
|
21
|
+
const stmt: any = {
|
|
22
|
+
_args: [] as any[],
|
|
23
|
+
bind(...args: any[]) {
|
|
24
|
+
stmt._args = args;
|
|
25
|
+
return stmt;
|
|
26
|
+
},
|
|
27
|
+
async run() {
|
|
28
|
+
if (sql.startsWith('INSERT')) {
|
|
29
|
+
const [token, data, exp] = stmt._args;
|
|
30
|
+
rows.set(token, { data, expires_at: exp });
|
|
31
|
+
} else if (sql.startsWith('UPDATE')) {
|
|
32
|
+
const [data, exp, token] = stmt._args;
|
|
33
|
+
if (rows.has(token)) rows.set(token, { data, expires_at: exp });
|
|
34
|
+
} else if (sql.startsWith('DELETE')) {
|
|
35
|
+
rows.delete(stmt._args[0]);
|
|
36
|
+
}
|
|
37
|
+
return { success: true };
|
|
38
|
+
},
|
|
39
|
+
async first() {
|
|
40
|
+
const [token, now] = stmt._args;
|
|
41
|
+
const r = rows.get(token);
|
|
42
|
+
return r && r.expires_at > now ? { data: r.data } : null;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return stmt;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return { db, rows };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('S3-CF DID convergence — CloudflareTenantTokenStorage', () => {
|
|
52
|
+
let restoreEnv: any;
|
|
53
|
+
let restoreWaitUntil: any;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
restoreEnv = (globalThis as any).__CF_ENV__;
|
|
57
|
+
restoreWaitUntil = (globalThis as any).__cfWaitUntil__;
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
(globalThis as any).__CF_ENV__ = restoreEnv;
|
|
61
|
+
(globalThis as any).__cfWaitUntil__ = restoreWaitUntil;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('isolates tokens by tenant — A token is not-found under tenant B', async () => {
|
|
65
|
+
const { db } = fakeD1();
|
|
66
|
+
(globalThis as any).__CF_ENV__ = { DB: db };
|
|
67
|
+
const store = new CloudflareTenantTokenStorage({ ttl: 300 });
|
|
68
|
+
|
|
69
|
+
// create + read under tenant A
|
|
70
|
+
await context.withTenant(A, () => store.create('tok-1', 'created'));
|
|
71
|
+
const underA = await context.withTenant(A, () => store.read('tok-1'));
|
|
72
|
+
expect(underA?.token).toBe('tok-1');
|
|
73
|
+
expect(underA?.instanceDid).toBe(A);
|
|
74
|
+
expect(await context.withTenant(A, () => store.exist('tok-1'))).toBe(true);
|
|
75
|
+
|
|
76
|
+
// the SAME token is invisible under tenant B
|
|
77
|
+
expect(await context.withTenant(B, () => store.read('tok-1'))).toBeNull();
|
|
78
|
+
expect(await context.withTenant(B, () => store.exist('tok-1'))).toBe(false);
|
|
79
|
+
// B cannot advance A's handshake
|
|
80
|
+
expect(await context.withTenant(B, () => store.update('tok-1', { status: 'hijacked' }))).toBeNull();
|
|
81
|
+
// and B's delete is a no-op (A's token survives)
|
|
82
|
+
await context.withTenant(B, () => store.delete('tok-1'));
|
|
83
|
+
expect((await context.withTenant(A, () => store.read('tok-1')))?.token).toBe('tok-1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('update lands via __cfWaitUntil__ even when the caller never awaits (waitUntil regression)', async () => {
|
|
87
|
+
const { db } = fakeD1();
|
|
88
|
+
(globalThis as any).__CF_ENV__ = { DB: db };
|
|
89
|
+
const pending: Promise<any>[] = [];
|
|
90
|
+
(globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => pending.push(p);
|
|
91
|
+
|
|
92
|
+
const store = new CloudflareTenantTokenStorage({ ttl: 300 });
|
|
93
|
+
await context.withTenant(A, async () => {
|
|
94
|
+
await store.create('tok-2', 'scanned');
|
|
95
|
+
// fire-and-forget, as did-connect-js's onProcessError does — NOT awaited
|
|
96
|
+
store.update('tok-2', { status: 'succeed' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// before draining waitUntil, the write may not have landed; drain then assert
|
|
100
|
+
await Promise.all(pending);
|
|
101
|
+
const after = await context.withTenant(A, () => store.read('tok-2'));
|
|
102
|
+
expect(after?.status).toBe('succeed');
|
|
103
|
+
expect(after?.instanceDid).toBe(A); // owning tenant preserved through update
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,20 +1,45 @@
|
|
|
1
|
-
// Phase 12c HARD GATE (scan): the CF worker is a host adapter
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// Phase 12c HARD GATE + S3-CF Phase 1B (scan): the CF worker is a host adapter that
|
|
2
|
+
// drives the core via the SINGLE runtime-neutral surface `svc.http.fetch`. It must
|
|
3
|
+
// NOT read `svc.handler` (the node-convenience getter) and — post Phase 1B — must
|
|
4
|
+
// NOT use the LITE `svc.http.resourceRoutes.fetch` dispatcher (the second surface is
|
|
5
|
+
// gone), and must NOT double-own payment `/api/*` cors / tenant (the core full
|
|
6
|
+
// pipeline owns those). This statically scans the worker source so a regression
|
|
7
|
+
// fails the suite (the runtime Proxy in ensurePaymentService is the second defense).
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
|
|
11
11
|
const workerSrc = fs.readFileSync(path.join(__dirname, '../worker.ts'), 'utf8');
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
// non-comment code lines only
|
|
14
|
+
const codeLines = workerSrc
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map((line, n) => ({ line: line.trim(), n: n + 1 }))
|
|
17
|
+
.filter(({ line }) => !line.startsWith('//') && !line.startsWith('*') && !line.startsWith('/*'));
|
|
18
|
+
|
|
19
|
+
describe('S3-CF Phase 1B — worker drives the single svc.http.fetch surface', () => {
|
|
20
|
+
it('forwards payment /api/* through svc.http.fetch', () => {
|
|
21
|
+
expect(workerSrc).toMatch(/service\.http\.fetch\(/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('no longer dispatches through the LITE svc.http.resourceRoutes surface', () => {
|
|
25
|
+
const offending = codeLines.filter(({ line }) => /service\.http\.resourceRoutes/.test(line));
|
|
26
|
+
expect(offending).toEqual([]);
|
|
16
27
|
});
|
|
17
28
|
|
|
29
|
+
it('does not double-own payment /api/* cors or tenant (core full pipeline owns them)', () => {
|
|
30
|
+
const doubleCors = codeLines.filter(({ line }) => /app\.use\(\s*['"]\/api\/\*['"]\s*,\s*cors\(\)/.test(line));
|
|
31
|
+
const doubleTenant = codeLines.filter(({ line }) => /app\.use\(\s*['"]\/api\/\*['"]\s*,\s*tenantMiddleware\(\)/.test(line));
|
|
32
|
+
expect(doubleCors).toEqual([]);
|
|
33
|
+
expect(doubleTenant).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('strips client-forged x-user-* before injecting the resolved caller identity', () => {
|
|
37
|
+
expect(workerSrc).toMatch(/for \(const h of USER_HEADERS\) headers\.delete\(h\)/);
|
|
38
|
+
expect(workerSrc).toMatch(/headers\.set\(['"]x-user-did['"]/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Phase 12c — worker host-adapter hard gate', () => {
|
|
18
43
|
it('never reads .handler on the payment service', () => {
|
|
19
44
|
const offending = workerSrc
|
|
20
45
|
.split('\n')
|
|
@@ -4,6 +4,8 @@ import svgr from 'vite-plugin-svgr';
|
|
|
4
4
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
|
|
7
|
+
import { buildBootstrapScript, PAYMENT_KIT_DID } from '../scripts/bootstrap-inject';
|
|
8
|
+
|
|
7
9
|
const coreDir = path.resolve(__dirname, '..');
|
|
8
10
|
|
|
9
11
|
// Absolute path to the original session file we want to replace
|
|
@@ -30,54 +32,60 @@ export default defineConfig({
|
|
|
30
32
|
return null;
|
|
31
33
|
},
|
|
32
34
|
},
|
|
33
|
-
// Inject window.blocklet + global polyfills into HTML
|
|
35
|
+
// Inject window.blocklet + global polyfills into HTML via the runtime-neutral
|
|
36
|
+
// bootstrap helper (P2 / README D2 — single source shared with vite.arc.config.ts).
|
|
37
|
+
// CF is a root deploy: uiPrefix='/', remoteBlockletUrl root-exact (the helper's
|
|
38
|
+
// T2.3 lock prevents the old `pfx + '__blocklet__.js'` prefix-join bug), prefix
|
|
39
|
+
// in localOnly (G3). navigation/componentMountPoints are this blocklet's own
|
|
40
|
+
// (AUTH_SERVICE doesn't know them) so they stay local.
|
|
34
41
|
{
|
|
35
42
|
name: 'cf-inject-blocklet',
|
|
36
43
|
transformIndexHtml(html: string) {
|
|
37
|
-
const injection =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
44
|
+
const injection = buildBootstrapScript({
|
|
45
|
+
uiPrefix: '/',
|
|
46
|
+
componentId: PAYMENT_KIT_DID,
|
|
47
|
+
remoteBlockletUrl: '/__blocklet__.js?type=json',
|
|
48
|
+
localOnly: ['prefix', 'navigation', 'componentMountPoints'],
|
|
49
|
+
extra: {
|
|
50
|
+
componentMountPoints: [
|
|
51
|
+
{
|
|
52
|
+
did: 'z8ia1mAXo8ZE7ytGF36L5uBf9kD2kenhqFGp9',
|
|
53
|
+
name: 'Media Kit',
|
|
54
|
+
mountPoint: '/media-kit',
|
|
55
|
+
appId: 'z8ia1mAXo8ZE7ytGF36L5uBf9kD2kenhqFGp9',
|
|
56
|
+
status: 'running',
|
|
57
|
+
capabilities: { component: true },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
navigation: [
|
|
61
|
+
{
|
|
62
|
+
id: 'payments',
|
|
63
|
+
title: { en: 'Payments', zh: '支付管理' },
|
|
64
|
+
icon: 'ion:card-outline',
|
|
65
|
+
link: '/admin',
|
|
66
|
+
section: ['dashboard', 'sessionManager'],
|
|
67
|
+
role: ['admin', 'owner'],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'integrations',
|
|
71
|
+
title: { en: 'Integrations', zh: '快速集成' },
|
|
72
|
+
icon: 'ion:flash-outline',
|
|
73
|
+
link: '/integrations',
|
|
74
|
+
section: ['dashboard', 'sessionManager'],
|
|
75
|
+
role: ['admin', 'owner'],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'billing',
|
|
79
|
+
title: { en: 'Billing', zh: '我的账单' },
|
|
80
|
+
icon: 'ion:receipt-outline',
|
|
81
|
+
link: '/customer',
|
|
82
|
+
private: true,
|
|
83
|
+
section: ['userCenter', 'sessionManager'],
|
|
84
|
+
role: ['owner', 'admin', 'member', 'guest'],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
});
|
|
81
89
|
return html.replace('<head>', '<head>' + injection);
|
|
82
90
|
},
|
|
83
91
|
},
|