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.
Files changed (67) hide show
  1. package/api/src/bootstrap.ts +11 -0
  2. package/api/src/crons/index.ts +14 -13
  3. package/api/src/crons/tenant-fanout.ts +82 -0
  4. package/api/src/host-node/did-connect-runtime-node.ts +33 -0
  5. package/api/src/host-node/serve-static-arc.ts +68 -0
  6. package/api/src/host-node/serve-static.ts +41 -0
  7. package/api/src/libs/auth.ts +166 -27
  8. package/api/src/libs/context.ts +11 -0
  9. package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
  10. package/api/src/libs/did-connect/tenant-identity.ts +221 -0
  11. package/api/src/libs/drivers/identity.ts +61 -0
  12. package/api/src/libs/drivers/index.ts +1 -1
  13. package/api/src/libs/http-fetch-adapter.ts +11 -1
  14. package/api/src/libs/queue/index.ts +14 -2
  15. package/api/src/middlewares/hono/context.ts +7 -0
  16. package/api/src/middlewares/hono/csrf.ts +13 -2
  17. package/api/src/middlewares/hono/security.ts +6 -11
  18. package/api/src/queues/checkout-session.ts +21 -9
  19. package/api/src/queues/event.ts +29 -7
  20. package/api/src/queues/payment.ts +23 -9
  21. package/api/src/queues/payout.ts +28 -16
  22. package/api/src/queues/refund.ts +18 -6
  23. package/api/src/routes/hono/customers.ts +6 -1
  24. package/api/src/routes/hono/refunds.ts +2 -3
  25. package/api/src/service.ts +178 -31
  26. package/api/src/store/sequelize.ts +16 -1
  27. package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
  28. package/api/tests/crons/tenant-fanout.spec.ts +158 -0
  29. package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
  30. package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
  31. package/api/tests/libs/service-host.spec.ts +37 -0
  32. package/api/tests/queues/event-tenant.spec.ts +60 -4
  33. package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
  34. package/api/tests/service/fail-closed-http.spec.ts +79 -0
  35. package/api/tests/service/static-arc-handler.spec.ts +101 -0
  36. package/api/tests/service/static-externalized.spec.ts +48 -0
  37. package/blocklet.yml +1 -1
  38. package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
  39. package/cloudflare/README.md +8 -21
  40. package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
  41. package/cloudflare/build.ts +10 -5
  42. package/cloudflare/cf-adapter.ts +419 -0
  43. package/cloudflare/did-connect-runtime.ts +96 -0
  44. package/cloudflare/did-connect-token-storage.ts +151 -0
  45. package/cloudflare/esbuild-cf-config.cjs +407 -0
  46. package/cloudflare/run-build.js +33 -357
  47. package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
  48. package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
  49. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
  50. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
  51. package/cloudflare/tests/cf-adapter.spec.ts +244 -0
  52. package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
  53. package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
  54. package/cloudflare/vite.config.ts +53 -45
  55. package/cloudflare/worker.ts +98 -56
  56. package/cloudflare/wrangler.json +0 -6
  57. package/cloudflare/wrangler.jsonc +0 -6
  58. package/cloudflare/wrangler.local-e2e.jsonc +0 -1
  59. package/cloudflare/wrangler.staging.json +0 -6
  60. package/package.json +7 -7
  61. package/scripts/bootstrap-inject.ts +166 -0
  62. package/src/app.tsx +2 -1
  63. package/src/libs/service-host.ts +13 -0
  64. package/vite.arc.config.ts +159 -0
  65. package/cloudflare/did-connect-auth.ts +0 -310
  66. package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
  67. 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
- attach(_opts: any) {
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. It must mount the
2
- // embedded service's resource-route surface (svc.http.resourceRoutes) and NEVER
3
- // access svc.handler whose lazy getter builds the node-only Express app shell
4
- // that does not belong under workerd. It also must not import the deleted legacy
5
- // shims/queue.ts duplicate engine. This statically scans the worker source so a
6
- // regression fails the suite (the runtime Proxy in ensurePaymentService is the
7
- // second line of defense).
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
- describe('Phase 12c — worker host-adapter hard gate', () => {
14
- it('mounts the resource-route surface (svc.http.resourceRoutes)', () => {
15
- expect(workerSrc).toMatch(/service\.http\.resourceRoutes/);
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 = `<script>
38
- window.global = globalThis;
39
- // Minimal bootstrap — full config loaded from __blocklet__.js
40
- if (!window.blocklet) {
41
- window.blocklet = {
42
- prefix: '/',
43
- groupPrefix: '/',
44
- appUrl: window.location.origin,
45
- componentMountPoints: [{did:'z8ia1mAXo8ZE7ytGF36L5uBf9kD2kenhqFGp9',name:'Media Kit',mountPoint:'/media-kit',appId:'z8ia1mAXo8ZE7ytGF36L5uBf9kD2kenhqFGp9',status:'running',capabilities:{component:true}}],
46
- navigation: [
47
- {id:'payments',title:{en:'Payments',zh:'支付管理'},icon:'ion:card-outline',link:'/admin',section:['dashboard','sessionManager'],role:['admin','owner']},
48
- {id:'integrations',title:{en:'Integrations',zh:'快速集成'},icon:'ion:flash-outline',link:'/integrations',section:['dashboard','sessionManager'],role:['admin','owner']},
49
- {id:'billing',title:{en:'Billing',zh:'我的账单'},icon:'ion:receipt-outline',link:'/customer',private:true,section:['userCenter','sessionManager'],role:['owner','admin','member','guest']},
50
- ],
51
- };
52
- }
53
- // Load full config from /__blocklet__.js?type=json (served by worker, includes auth/branding/theme from AUTH_SERVICE).
54
- // Using type=json lets us JSON.parse the response directly instead of slicing out a { } substring from a JS assignment.
55
- (function() {
56
- try {
57
- var xhr = new XMLHttpRequest();
58
- var pfx = (window.blocklet && window.blocklet.prefix) || '/';
59
- xhr.open('GET', pfx + '__blocklet__.js?type=json&_t=' + Date.now(), false);
60
- xhr.send();
61
- if (xhr.status === 200) {
62
- var remote = JSON.parse(xhr.responseText);
63
- // navigation / componentMountPoints are owned by this blocklet — AUTH_SERVICE
64
- // doesn't know about them, so we never want remote values to clobber the
65
- // ones the bootstrap above set.
66
- var localOnly = ['navigation', 'componentMountPoints'];
67
- Object.keys(remote).forEach(function(k) {
68
- if (localOnly.indexOf(k) === -1) {
69
- window.blocklet[k] = remote[k];
70
- }
71
- });
72
- if (!window.blocklet.env) window.blocklet.env = {};
73
- window.blocklet.env.appName = window.blocklet.appName || '';
74
- window.blocklet.env.appDescription = window.blocklet.appDescription || '';
75
- window.blocklet.env.appLogo = window.blocklet.appLogo || '';
76
- window.blocklet.env.appUrl = window.blocklet.appUrl || '';
77
- }
78
- } catch(e) { /* ignore */ }
79
- })();
80
- </script>`;
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
  },