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
@@ -64,6 +64,9 @@ let models: any;
64
64
  let handleEvent: any;
65
65
  let handleWebhook: any;
66
66
  let assertEventTenantAccessible: any;
67
+ let startEventQueue: any;
68
+ let eventQueue: any;
69
+ let getInstanceDid: any;
67
70
  let logger: any;
68
71
 
69
72
  beforeAll(async () => {
@@ -72,7 +75,9 @@ beforeAll(async () => {
72
75
  models = require('../../src/store/models');
73
76
  models.initialize(sequelize);
74
77
  // eslint-disable-next-line global-require
75
- ({ handleEvent } = require('../../src/queues/event'));
78
+ ({ handleEvent, startEventQueue, eventQueue } = require('../../src/queues/event'));
79
+ // eslint-disable-next-line global-require
80
+ ({ getInstanceDid } = require('../../src/libs/context'));
76
81
  // eslint-disable-next-line global-require
77
82
  ({ handleWebhook } = jest.requireActual('../../src/queues/webhook'));
78
83
  // eslint-disable-next-line global-require
@@ -167,7 +172,7 @@ describe('event delivery tenant isolation (phase 4)', () => {
167
172
 
168
173
  describe('paths 3+4 manual retry routes: caller tenant guard', () => {
169
174
  it('A caller cannot retry a B event (TENANT_MISMATCH -> 4xx mapping)', async () => {
170
- await withTenant(TENANT_A, async () => {
175
+ await withTenant(TENANT_A, () => {
171
176
  expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
172
177
  expect.objectContaining({ code: TENANT_MISMATCH })
173
178
  );
@@ -186,7 +191,7 @@ describe('event delivery tenant isolation (phase 4)', () => {
186
191
  });
187
192
 
188
193
  it('same-tenant caller passes the guard', async () => {
189
- await withTenant(TENANT_B, async () => {
194
+ await withTenant(TENANT_B, () => {
190
195
  expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).not.toThrow();
191
196
  });
192
197
  });
@@ -196,7 +201,7 @@ describe('event delivery tenant isolation (phase 4)', () => {
196
201
  // query scoped by caller tenant -> B endpoint invisible to A
197
202
  await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
198
203
  await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
199
- const visible = await withTenant(TENANT_A, async () =>
204
+ const visible = await withTenant(TENANT_A, () =>
200
205
  models.WebhookEndpoint.findAll({
201
206
  where: { status: 'enabled', livemode: false, instance_did: TENANT_A },
202
207
  })
@@ -221,6 +226,57 @@ describe('event delivery tenant isolation (phase 4)', () => {
221
226
  });
222
227
  });
223
228
 
229
+ describe('startup recovery (startEventQueue): pending-webhook events re-enqueued under their tenant', () => {
230
+ const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
231
+ afterEach(() => {
232
+ if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
233
+ else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
234
+ });
235
+
236
+ // Regression: in multi mode startup has NO ambient tenant. The old recovery
237
+ // pushed outside withTenant (and fetched only id), so injectJobTenant ->
238
+ // getInstanceDid threw TENANT_CONTEXT_MISSING — a FATAL unhandledRejection
239
+ // that crashed the daemon. The push must run inside withTenant(event tenant).
240
+ it('multi mode: recovers a pending event INSIDE its tenant context (no crash)', async () => {
241
+ process.env.PAYMENT_TENANT_MODE = 'multi';
242
+ const event = await seedEvent(TENANT_A);
243
+
244
+ let tenantAtPush: string | undefined;
245
+ let threwAtPush = false;
246
+ (eventQueue.push as jest.Mock).mockImplementationOnce(() => {
247
+ try {
248
+ tenantAtPush = getInstanceDid(); // would THROW in multi mode if outside withTenant
249
+ } catch {
250
+ threwAtPush = true;
251
+ }
252
+ });
253
+
254
+ await expect(startEventQueue()).resolves.toBeUndefined();
255
+ expect(eventQueue.push).toHaveBeenCalledWith(
256
+ expect.objectContaining({ id: event.id, job: { eventId: event.id } })
257
+ );
258
+ expect(threwAtPush).toBe(false);
259
+ expect(tenantAtPush).toBe(TENANT_A);
260
+ });
261
+
262
+ // A row with no tenant cannot be re-stamped — skip it (warn), never crash.
263
+ it('multi mode: a tenant-less pending event is skipped, not pushed', async () => {
264
+ process.env.PAYMENT_TENANT_MODE = 'multi';
265
+ const event = await seedEvent(TENANT_A);
266
+ // null out the tenant directly (bypass the scoped writer)
267
+ await sequelize.query('UPDATE events SET instance_did = NULL WHERE id = $id', {
268
+ bind: { id: event.id },
269
+ });
270
+
271
+ await expect(startEventQueue()).resolves.toBeUndefined();
272
+ expect(eventQueue.push).not.toHaveBeenCalled();
273
+ expect(logger.warn).toHaveBeenCalledWith(
274
+ 'skip pending-webhook event with no tenant',
275
+ expect.objectContaining({ id: event.id })
276
+ );
277
+ });
278
+ });
279
+
224
280
  describe('data damage: retry keeps the original tenant', () => {
225
281
  it('failed delivery writes a failed attempt under the event tenant', async () => {
226
282
  componentRequest.mockRejectedValueOnce(Object.assign(new Error('boom'), { response: { status: 500 } }));
@@ -0,0 +1,60 @@
1
+ // S3-CF Phase 1 inversion ③ — DID-Connect token storage slot.
2
+ //
3
+ // The DID-Connect token handshake store is now a host-injected slot (same
4
+ // reversal as db/queue/cron). The CF worker injects a D1-backed store so the
5
+ // token state lands in PAYMENT_DB; node hosts omit it and keep the file-backed
6
+ // nedb default. This spec locks the factory→libs/auth wiring and the default.
7
+ import fs from 'fs';
8
+ import os from 'os';
9
+ import path from 'path';
10
+
11
+ import { Sequelize } from 'sequelize';
12
+
13
+ import * as auth from '../../src/libs/auth';
14
+ import { createEmbeddedPaymentService } from '../../src/service';
15
+
16
+ function freshSequelize(): Sequelize {
17
+ const dbFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pay-storage-slot-')), 'test.db');
18
+ return new Sequelize({ dialect: 'sqlite', storage: dbFile, logging: false });
19
+ }
20
+
21
+ const baseConfig = { BLOCKLET_APP_PID: 'zMOCK_STORAGE_SLOT' };
22
+
23
+ describe('Phase 1 (S3-CF) ③ — DID-Connect token storage slot', () => {
24
+ afterEach(() => {
25
+ // reset the module-level injection so specs don't leak into each other
26
+ auth.setDidConnectTokenStorage(null);
27
+ jest.restoreAllMocks();
28
+ });
29
+
30
+ it('wires a host-provided storage slot into libs/auth (setDidConnectTokenStorage)', () => {
31
+ const spy = jest.spyOn(auth, 'setDidConnectTokenStorage');
32
+ const sentinel: auth.DidConnectTokenStorage = {
33
+ read: async () => null,
34
+ create: async () => undefined,
35
+ update: async () => undefined,
36
+ delete: async () => undefined,
37
+ };
38
+
39
+ createEmbeddedPaymentService({
40
+ config: baseConfig,
41
+ db: { sequelize: freshSequelize() },
42
+ tenancy: { mode: 'single', instanceDid: 'zMOCK_STORAGE_SLOT' },
43
+ storage: sentinel,
44
+ });
45
+
46
+ expect(spy).toHaveBeenCalledWith(sentinel);
47
+ });
48
+
49
+ it('omitting the slot leaves the node nedb default (setter never called)', () => {
50
+ const spy = jest.spyOn(auth, 'setDidConnectTokenStorage');
51
+
52
+ createEmbeddedPaymentService({
53
+ config: baseConfig,
54
+ db: { sequelize: freshSequelize() },
55
+ tenancy: { mode: 'single', instanceDid: 'zMOCK_STORAGE_SLOT' },
56
+ });
57
+
58
+ expect(spy).not.toHaveBeenCalled();
59
+ });
60
+ });
@@ -0,0 +1,79 @@
1
+ // TM-4 (frontend-serve P4) — MANDATORY fail-closed regression lock.
2
+ //
3
+ // Project principle #1: in multi-tenant mode a request with no resolvable
4
+ // Host/tenant context MUST fail closed (4xx), never fall back to APP_PID/default
5
+ // tenant. P4 wires a host static/SPA handler onto the full node hono app (after
6
+ // the api/connect routes). This lock proves that slot did NOT weaken the API
7
+ // gate: with createNodeStaticHandler attached, an /api/* request from an unbound
8
+ // host still 400s with TENANT_HOST_UNRESOLVED. Runs in did-pay (no arc dev
9
+ // runtime) — the same buildHonoApp + contextMiddleware the production svc uses.
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+
14
+ import { Hono } from 'hono';
15
+
16
+ import { getInstanceDid } from '../../src/libs/context';
17
+ import { createDefaultIdentityDriver, type IdentityDriver, setIdentityDriver } from '../../src/libs/drivers/identity';
18
+ import { contextMiddleware, ensureI18n } from '../../src/middlewares/hono/context';
19
+ import { createNodeStaticHandler } from '../../src/host-node/serve-static-arc';
20
+ import { buildHonoApp } from '../../src/service';
21
+
22
+ const BOUND = 'did:abt:zBOUNDTENANTAAAAAAAAAAAAAAAAAA';
23
+ const identity: IdentityDriver = {
24
+ resolveInstanceDidForHost: (host) => (host === 'bound.example' ? BOUND : null),
25
+ };
26
+
27
+ let webRoot: string;
28
+ beforeAll(() => {
29
+ webRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tm4-web-'));
30
+ fs.writeFileSync(path.join(webRoot, 'index.html'), '<!doctype html><div id="app"></div>');
31
+ });
32
+ afterAll(() => fs.rmSync(webRoot, { recursive: true, force: true }));
33
+ afterEach(() => {
34
+ delete process.env.PAYMENT_TENANT_MODE;
35
+ setIdentityDriver(createDefaultIdentityDriver());
36
+ });
37
+
38
+ // the production wiring: native pipeline (with the Host→tenant contextMiddleware)
39
+ // + the host static/SPA slot attached last (exactly buildHonoApp's 3rd arg).
40
+ function buildAppWithStatic(): Hono {
41
+ return buildHonoApp(
42
+ (native) => {
43
+ native.use('*', ensureI18n());
44
+ native.use('*', contextMiddleware());
45
+ native.get('/api/settings', (c) => c.json({ tenant: getInstanceDid() }));
46
+ },
47
+ undefined,
48
+ createNodeStaticHandler(webRoot)
49
+ );
50
+ }
51
+
52
+ describe('TM-4 — static slot must not weaken the multi-tenant API gate (P4)', () => {
53
+ it('multi mode, UNBOUND host: /api/settings → 400 fail-closed (no default-tenant fallback)', async () => {
54
+ process.env.PAYMENT_TENANT_MODE = 'multi';
55
+ setIdentityDriver(identity);
56
+ const app = buildAppWithStatic();
57
+ const res = await app.fetch(new Request('http://unbound.example/api/settings', { headers: { host: 'unbound.example' } }));
58
+ expect(res.status).toBe(400);
59
+ expect((await res.json()).error.code).toBe('TENANT_HOST_UNRESOLVED');
60
+ });
61
+
62
+ it('multi mode, BOUND host: /api/settings resolves its tenant (gate passes, slot present)', async () => {
63
+ process.env.PAYMENT_TENANT_MODE = 'multi';
64
+ setIdentityDriver(identity);
65
+ const app = buildAppWithStatic();
66
+ const res = await app.fetch(new Request('http://bound.example/api/settings', { headers: { host: 'bound.example' } }));
67
+ expect(res.status).toBe(200);
68
+ expect((await res.json()).tenant).toBe(BOUND);
69
+ });
70
+
71
+ it('the static slot still serves the SPA shell (html GET) — coexists with the gate', async () => {
72
+ process.env.PAYMENT_TENANT_MODE = 'multi';
73
+ setIdentityDriver(identity);
74
+ const app = buildAppWithStatic();
75
+ const res = await app.fetch(new Request('http://bound.example/admin', { headers: { host: 'bound.example', accept: 'text/html' } }));
76
+ expect(res.status).toBe(200);
77
+ expect(await res.text()).toContain('<div id="app">');
78
+ });
79
+ });
@@ -0,0 +1,101 @@
1
+ // P3b (README D3 / F3) — arc-node clean static/SPA handler.
2
+ //
3
+ // Drives createNodeStaticHandler over a real temp webRoot through buildHonoApp's
4
+ // staticHandler slot (the same wiring arc uses), via in-process app.fetch.
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+
9
+ import { buildHonoApp } from '../../src/service';
10
+ import { createNodeStaticHandler } from '../../src/host-node/serve-static-arc';
11
+
12
+ let webRoot: string;
13
+
14
+ beforeAll(() => {
15
+ webRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'arc-webroot-'));
16
+ fs.mkdirSync(path.join(webRoot, 'assets'), { recursive: true });
17
+ // index.html with a build-time bootstrap already baked in (P2) — the handler
18
+ // must serve this VERBATIM, no server-side re-injection.
19
+ fs.writeFileSync(
20
+ path.join(webRoot, 'index.html'),
21
+ '<!doctype html><html><head><script>window.blocklet={prefix:"/.well-known/payment"}</script></head><body><div id="app"></div></body></html>'
22
+ );
23
+ fs.writeFileSync(path.join(webRoot, 'assets', 'app.js'), 'console.log("asset")');
24
+ });
25
+
26
+ afterAll(() => {
27
+ fs.rmSync(webRoot, { recursive: true, force: true });
28
+ });
29
+
30
+ const appWith = () => buildHonoApp(undefined, undefined, createNodeStaticHandler(webRoot));
31
+
32
+ describe('P3b — createNodeStaticHandler', () => {
33
+ it('bad-input: throws at construction when webRoot has no index.html', () => {
34
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'arc-empty-'));
35
+ expect(() => createNodeStaticHandler(empty)).toThrow(/no index\.html/);
36
+ fs.rmSync(empty, { recursive: true, force: true });
37
+ });
38
+
39
+ it('happy: root + deep link return index.html, asset hits the file', async () => {
40
+ const app = appWith();
41
+ const root = await app.fetch(new Request('http://h/', { headers: { accept: 'text/html' } }));
42
+ expect(root.status).toBe(200);
43
+ expect(await root.text()).toContain('<div id="app">');
44
+
45
+ const deep = await app.fetch(new Request('http://h/admin', { headers: { accept: 'text/html' } }));
46
+ expect(deep.status).toBe(200);
47
+ expect(await deep.text()).toContain('<!doctype html');
48
+
49
+ const asset = await app.fetch(new Request('http://h/assets/app.js'));
50
+ expect(asset.status).toBe(200);
51
+ expect(await asset.text()).toContain('console.log("asset")');
52
+ });
53
+
54
+ it('bad-input: non-GET / non-html accept does not return index.html', async () => {
55
+ const app = appWith();
56
+ const post = await app.fetch(new Request('http://h/admin', { method: 'POST', headers: { accept: 'text/html' } }));
57
+ expect(post.status).not.toBe(200);
58
+ const json = await app.fetch(new Request('http://h/admin', { headers: { accept: 'application/json' } }));
59
+ expect(await json.text()).not.toContain('<!doctype html');
60
+ });
61
+
62
+ it('security: asset miss → 404, NOT index.html (RESOURCE_PATTERN, T3b.2)', async () => {
63
+ const app = appWith();
64
+ const miss = await app.fetch(new Request('http://h/assets/nope.js'));
65
+ expect(miss.status).toBe(404);
66
+ expect(await miss.text()).not.toContain('<!doctype html');
67
+ });
68
+
69
+ it('security: path traversal never leaks out-of-webRoot files (asset-typed → 404; SPA-typed → index.html, never passwd)', async () => {
70
+ const app = appWith();
71
+ const indexHtml = fs.readFileSync(path.join(webRoot, 'index.html'), 'utf8');
72
+ // asset-typed traversal (serveStatic must reject, never serve outside webRoot)
73
+ for (const p of ['/assets/..%2f..%2f..%2f..%2fetc%2fpasswd.js', '/assets/../../../../etc/hosts.css']) {
74
+ const res = await app.fetch(new Request(`http://h${p}`));
75
+ const body = await res.text();
76
+ expect(body).not.toMatch(/root:.*:0:0:/); // no /etc/passwd content
77
+ expect(res.status).toBe(404); // miss, not a leaked file
78
+ }
79
+ // SPA-typed traversal (no asset extension) → safe app shell, NOT a host file
80
+ const spa = await app.fetch(new Request('http://h/../../../../etc/passwd', { headers: { accept: 'text/html' } }));
81
+ const spaBody = await spa.text();
82
+ expect(spaBody).not.toMatch(/root:.*:0:0:/);
83
+ expect(spaBody === indexHtml || spa.status === 404).toBe(true);
84
+ });
85
+
86
+ it('data-loss: /api/* is NOT swallowed by the static handler (wired last)', async () => {
87
+ const app = appWith();
88
+ const res = await app.fetch(new Request('http://h/api/healthz'));
89
+ expect(res.status).toBe(200);
90
+ expect(await res.json()).toEqual({ ok: true });
91
+ });
92
+
93
+ it('data-leak: index.html is served VERBATIM — exactly one window.blocklet (no server-side re-inject, TM-3)', async () => {
94
+ const app = appWith();
95
+ const res = await app.fetch(new Request('http://h/dashboard', { headers: { accept: 'text/html' } }));
96
+ const html = await res.text();
97
+ expect((html.match(/window\.blocklet\s*=/g) || []).length).toBe(1);
98
+ // byte-identical to the build artifact
99
+ expect(html).toBe(fs.readFileSync(path.join(webRoot, 'index.html'), 'utf8'));
100
+ });
101
+ });
@@ -0,0 +1,48 @@
1
+ // S3-CF Phase 1 inversion ① — static/SPA shell externalized from buildHonoApp.
2
+ //
3
+ // The runtime-neutral hono app must NOT wire node-only static serving
4
+ // (@hono/node-server/serve-static + the node:fs fallback) itself — that is what
5
+ // kept `http.fetch` node-bound and forced the CF worker onto a second surface.
6
+ // Instead the HOST injects a `staticHandler` (node blocklet-server replicates
7
+ // today's serving; the CF/standalone worker serves assets via env.ASSETS and
8
+ // injects nothing). This spec locks the delegation.
9
+ import { Hono } from 'hono';
10
+ import { buildHonoApp } from '../../src/service';
11
+
12
+ describe('Phase 1 (S3-CF) — static/SPA shell externalized from buildHonoApp', () => {
13
+ it('serves no static itself — an unmatched html GET 404s when no staticHandler is given', async () => {
14
+ const app = buildHonoApp();
15
+ const res = await app.fetch(
16
+ new Request('http://app.local/some/spa/deeplink', { headers: { accept: 'text/html' } })
17
+ );
18
+ // No SPA fallback is wired into the neutral app; the host owns it.
19
+ expect(res.status).toBe(404);
20
+ });
21
+
22
+ it('invokes the host-provided staticHandler exactly once with the app, after the api/connect routes', async () => {
23
+ let called = 0;
24
+ let received: unknown;
25
+ const app = buildHonoApp(undefined, undefined, (a: Hono) => {
26
+ called += 1;
27
+ received = a;
28
+ a.get('/__host_static_probe', (c) => c.text('served-by-host'));
29
+ });
30
+
31
+ expect(called).toBe(1);
32
+ expect(received).toBe(app);
33
+
34
+ const res = await app.fetch(new Request('http://app.local/__host_static_probe'));
35
+ expect(res.status).toBe(200);
36
+ expect(await res.text()).toBe('served-by-host');
37
+ });
38
+
39
+ it('keeps /api/healthz reachable even with a host staticHandler wired last', async () => {
40
+ const app = buildHonoApp(undefined, undefined, (a: Hono) => {
41
+ a.use('*', async (c) => c.text('static-catchall'));
42
+ });
43
+ const res = await app.fetch(new Request('http://app.local/api/healthz'));
44
+ // healthz is registered before the host static catch-all, so it still wins.
45
+ expect(res.status).toBe(200);
46
+ expect(await res.json()).toEqual({ ok: true });
47
+ });
48
+ });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.29.2
17
+ version: 1.29.3
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -104,15 +104,11 @@ sqlite3 $PAYMENT_DB "SELECT name FROM sqlite_master WHERE type='table' AND name
104
104
  ### 0.3 创建 Cloudflare 资源
105
105
 
106
106
  ```bash
107
- # 1. D1 数据库
107
+ # 1. D1 数据库(业务数据 + DID Connect token 握手态都在这里)
108
108
  wrangler d1 create payment-kit-prod
109
109
  # 记录 database_id,填入 wrangler.json
110
110
 
111
- # 2. KV Namespace(DID Connect session 存储)
112
- wrangler kv namespace create DID_CONNECT_KV
113
- # 记录 id,填入 wrangler.json
114
-
115
- # 3. CF Queue(任务队列)
111
+ # 2. CF Queue(任务队列)
116
112
  wrangler queues create payment-kit-jobs
117
113
 
118
114
  # 4. Dead Letter Queue(可选,用于失败任务追踪)
@@ -376,8 +372,7 @@ CF Worker 使用 `_did_connect_tokens` 表(存在 D1 中)而非 KV 存储 DI
376
372
  以保证强一致性。该表由 schema migration 自动创建,无需从 Blocklet Server 迁移历史 token 数据
377
373
  (用户会重新登录)。
378
374
 
379
- > 注: wrangler.json 中仍保留了 `DID_CONNECT_KV` 配置,但实际 token 已迁移到 D1
380
- > KV 可用于其他缓存用途。
375
+ > 注: KV (`DID_CONNECT_KV`) 已彻底移除——token 握手态完全走 D1,路由挂载以 `env.DB` 为前提。
381
376
 
382
377
  ---
383
378
 
@@ -6,8 +6,7 @@
6
6
 
7
7
  ```
8
8
  Payment Kit Worker
9
- ├── D1 Database # 业务数据(customers/invoices/subscriptions ...)
10
- ├── KV: DID_CONNECT_KV # DID Connect session 临时存储
9
+ ├── D1 Database # 业务数据 + DID Connect token 握手态(_did_connect_tokens)
11
10
  ├── CF Queue # 可选 executor 绑定;队列引擎是 api/src/libs/queue(node 引擎)
12
11
  ├── Hyperdrive # (可选)连接 Postgres,仅在启用时使用
13
12
  ├── Service Binding
@@ -102,19 +101,15 @@ Payment Kit Worker → 本指南涵盖的部分
102
101
  在 Payment Kit 部署前创建以下资源,把生成的 ID 填到 `wrangler.jsonc`:
103
102
 
104
103
  ```bash
105
- # 1. D1 数据库
104
+ # 1. D1 数据库(业务数据 + DID Connect token 握手态都在这里)
106
105
  wrangler d1 create payment-kit-prod
107
106
  # → 记下 database_id
108
107
 
109
- # 2. KV Namespace(DID Connect session 存储)
110
- wrangler kv namespace create DID_CONNECT_KV
111
- # → 记下 id
112
-
113
- # 3. CF Queue + DLQ(任务队列)
108
+ # 2. CF Queue + DLQ(任务队列)
114
109
  wrangler queues create payment-kit-jobs
115
110
  wrangler queues create payment-kit-jobs-dlq
116
111
 
117
- # 4.(可选)Hyperdrive,仅当需要连接外部 Postgres 时
112
+ # 3.(可选)Hyperdrive,仅当需要连接外部 Postgres 时
118
113
  wrangler hyperdrive create payment-kit-hyperdrive --connection-string "postgres://..."
119
114
  ```
120
115
 
@@ -183,13 +178,6 @@ wrangler hyperdrive create payment-kit-hyperdrive --connection-string "postgres:
183
178
  }
184
179
  ],
185
180
 
186
- "kv_namespaces": [
187
- {
188
- "binding": "DID_CONNECT_KV",
189
- "id": "<your-kv-namespace-id>"
190
- }
191
- ],
192
-
193
181
  // 可选:启用 Hyperdrive(连接外部 Postgres)
194
182
  // "hyperdrive": [
195
183
  // { "binding": "HYPERDRIVE", "id": "<your-hyperdrive-id>" }
@@ -255,12 +243,11 @@ wrangler deployments list --name media-kit
255
243
  ```bash
256
244
  cd blocklets/core/cloudflare
257
245
  wrangler d1 create payment-kit-prod
258
- wrangler kv namespace create DID_CONNECT_KV
259
246
  wrangler queues create payment-kit-jobs
260
247
  wrangler queues create payment-kit-jobs-dlq
261
248
  ```
262
249
 
263
- 把返回的 `database_id` / `kv namespace id` 填到 `wrangler.jsonc` 对应位置。
250
+ 把返回的 `database_id` 填到 `wrangler.jsonc` 对应位置。
264
251
 
265
252
  ### Phase 2:填写 `wrangler.jsonc`
266
253
 
@@ -329,7 +316,7 @@ window.blocklet = { appName: 'Payment Kit', appUrl: '...', appPid: '...', ... }
329
316
 
330
317
  1. `ensureModelsInit()` — Sequelize-D1 sync,在 D1 里自动建表
331
318
  2. `initFromAuthService(env)` — 从 `AUTH_SERVICE.getAppEk(APP_PID)` 拉 EK 并初始化 crypto chain(`PBKDF2 → AES password`)
332
- 3. DID Connect 路由挂载 — `APP_SK` + `DID_CONNECT_KV` 都就绪才会挂载
319
+ 3. DID Connect 路由挂载 — `APP_SK` + `DB`(D1)都就绪才会挂载
333
320
 
334
321
  查看首请求日志:
335
322
 
@@ -476,7 +463,7 @@ wrangler d1 execute payment-kit-prod --remote \
476
463
  ### DID Connect 登录卡在 challenge 阶段
477
464
 
478
465
  - `APP_SK` 必须是 hex 字符串,对应的 public key 必须在 blocklet-service 里注册为该 APP_PID 的 key
479
- - `DID_CONNECT_KV` 必须已创建并 binding 正确
466
+ - `DB`(D1)binding 必须正确:DID Connect token 握手态存在 `_did_connect_tokens` 表,路由挂载也以 `env.DB` 为前提
480
467
 
481
468
  ---
482
469
 
@@ -489,7 +476,7 @@ blocklets/core/cloudflare/
489
476
  ├── build.ts # diagnostic/backup 构建(更少优化;不是 deploy build)
490
477
  ├── queue-runtime-mode.ts # 把 core 队列引擎 pin 成 workerd(在业务队列加载前)
491
478
  ├── vite.config.ts # 前端 Vite 构建配置
492
- ├── did-connect-auth.ts # DID Connect 登录路由(挂载需要 APP_SK + DID_CONNECT_KV
479
+ ├── did-connect-auth.ts # DID Connect 登录路由(挂载需要 APP_SK + DB;token 握手态用 D1 _did_connect_tokens
493
480
  ├── wrangler.jsonc # 生产配置模板
494
481
  ├── wrangler.migration-test.json # 迁移测试环境配置
495
482
  ├── index.html # 前端开发入口
@@ -166,15 +166,11 @@ curl https://<MEDIA_KIT_URL>/
166
166
  ```bash
167
167
  cd <payment-kit-repo>/blocklets/core/cloudflare
168
168
 
169
- # 4.1 D1 数据库
169
+ # 4.1 D1 数据库(业务数据 + DID Connect token 握手态都在这里)
170
170
  wrangler d1 create payment-kit-staging
171
171
  # 记下返回的 database_id,记为 $D1_ID
172
172
 
173
- # 4.2 KV Namespace(DID Connect session)
174
- wrangler kv namespace create payment-kit-staging-didconnect
175
- # 记下返回的 id,记为 $KV_ID
176
-
177
- # 4.3 CF Queue 主队列 + DLQ
173
+ # 4.2 CF Queue 主队列 + DLQ
178
174
  wrangler queues create payment-kit-staging-jobs
179
175
  wrangler queues create payment-kit-staging-jobs-dlq
180
176
  ```
@@ -183,7 +179,6 @@ wrangler queues create payment-kit-staging-jobs-dlq
183
179
 
184
180
  ```
185
181
  D1_ID=<...>
186
- KV_ID=<...>
187
182
  ```
188
183
 
189
184
  ## Phase 5:Payment Kit — 导出 AWS 数据(20 min)
@@ -327,13 +322,6 @@ cp wrangler.migration-test.json wrangler.staging.json
327
322
  }
328
323
  ],
329
324
 
330
- "kv_namespaces": [
331
- {
332
- "binding": "DID_CONNECT_KV",
333
- "id": "<Phase 4.2 的 KV_ID>"
334
- }
335
- ],
336
-
337
325
  "services": [
338
326
  {
339
327
  "binding": "MEDIA_KIT",
@@ -529,7 +517,7 @@ wrangler d1 execute payment-kit-staging --remote --command \
529
517
  **如果 Phase 10~12 任何一步发现致命问题**:
530
518
 
531
519
  1. **停止切流**:staging 还在 `*.workers.dev`,AWS 源端仍然运行,无需回滚 DNS
532
- 2. **保留 CF 资源**:D1 / KV / Queue 不删,便于复现问题
520
+ 2. **保留 CF 资源**:D1 / Queue 不删,便于复现问题
533
521
  3. **修复后重跑**:Phase 5~12 是幂等的(`INSERT OR IGNORE`),可以安全重跑
534
522
  4. **确认失败原因**:查 `wrangler tail` 日志 + 对比 AWS 源端行为
535
523
 
@@ -55,13 +55,18 @@ async function main() {
55
55
  '@blocklet/sdk/lib/util/verify-sign': path.resolve(cfDir, 'shims/blocklet-sdk/verify-sign.ts'),
56
56
  '@blocklet/sdk/lib/util/component-api': path.resolve(cfDir, 'shims/blocklet-sdk/component-api.ts'),
57
57
  // Phase 4 (express→hono): the native hono resource routes pull in the forked
58
- // app-shell middleware (session — LIVE on CF; csrf/cdn/fallback node-shell
59
- // only, bundled-but-unreachable on CF). Each needs a util subpath shim so the
60
- // bare `@blocklet/sdk` catch-all below doesn't mangle the import.
58
+ // app-shell middleware (session — LIVE on CF via sessionMiddleware; csrf/cdn —
59
+ // bundled today, and LIVE once the converged http.fetch runs the full pipeline
60
+ // on the worker). Each needs a util subpath resolution so the bare
61
+ // `@blocklet/sdk` catch-all below doesn't mangle the import.
61
62
  '@blocklet/sdk/lib/util/login': path.resolve(cfDir, 'shims/blocklet-sdk/login.ts'),
62
63
  '@blocklet/sdk/lib/util/service-api': path.resolve(cfDir, 'shims/blocklet-sdk/service-api.ts'),
63
- '@blocklet/sdk/lib/util/csrf': path.resolve(cfDir, 'shims/blocklet-sdk/util-csrf.ts'),
64
- '@blocklet/sdk/lib/util/wallet': path.resolve(cfDir, 'shims/blocklet-sdk/util-wallet.ts'),
64
+ // S3-CF Phase 1 ②: csrf/wallet pass through to the REAL worker-safe modules
65
+ // (util/csrf is crypto-only, util/wallet zero-dep), matching run-build.js — the
66
+ // converged single http.fetch runs the full pipeline on the worker, so csrf
67
+ // must REALLY protect write routes (a dead stub would silently disable it).
68
+ '@blocklet/sdk/lib/util/csrf': require.resolve('@blocklet/sdk/lib/util/csrf'),
69
+ '@blocklet/sdk/lib/util/wallet': require.resolve('@blocklet/sdk/lib/util/wallet'),
65
70
  '@blocklet/sdk/lib/util/asset-host-transformer': path.resolve(cfDir, 'shims/blocklet-sdk/asset-host-transformer.ts'),
66
71
  '@blocklet/sdk/lib/util/constants': path.resolve(cfDir, 'shims/blocklet-sdk/util-constants.ts'),
67
72
  '@blocklet/sdk/lib/error-handler': path.resolve(cfDir, 'shims/noop.ts'),