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
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
@@ -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.
|
|
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
|
-
> 注:
|
|
380
|
-
> KV 可用于其他缓存用途。
|
|
375
|
+
> 注: KV (`DID_CONNECT_KV`) 已彻底移除——token 握手态完全走 D1,路由挂载以 `env.DB` 为前提。
|
|
381
376
|
|
|
382
377
|
---
|
|
383
378
|
|
package/cloudflare/README.md
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
Payment Kit Worker
|
|
9
|
-
├── D1 Database #
|
|
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.
|
|
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
|
-
#
|
|
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`
|
|
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` + `
|
|
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
|
-
- `
|
|
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 +
|
|
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
|
|
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 /
|
|
520
|
+
2. **保留 CF 资源**:D1 / Queue 不删,便于复现问题
|
|
533
521
|
3. **修复后重跑**:Phase 5~12 是幂等的(`INSERT OR IGNORE`),可以安全重跑
|
|
534
522
|
4. **确认失败原因**:查 `wrangler tail` 日志 + 对比 AWS 源端行为
|
|
535
523
|
|
package/cloudflare/build.ts
CHANGED
|
@@ -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
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
|
|
64
|
-
|
|
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'),
|