payment-kit 1.29.2 → 1.29.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/bootstrap.ts +11 -0
- package/api/src/crons/index.ts +14 -13
- package/api/src/crons/tenant-fanout.ts +82 -0
- package/api/src/host-node/did-connect-runtime-node.ts +33 -0
- package/api/src/host-node/serve-static-arc.ts +68 -0
- package/api/src/host-node/serve-static.ts +41 -0
- package/api/src/libs/auth.ts +166 -27
- package/api/src/libs/context.ts +11 -0
- package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
- package/api/src/libs/did-connect/tenant-identity.ts +221 -0
- package/api/src/libs/drivers/identity.ts +61 -0
- package/api/src/libs/drivers/index.ts +1 -1
- package/api/src/libs/http-fetch-adapter.ts +11 -1
- package/api/src/libs/queue/index.ts +14 -2
- package/api/src/middlewares/hono/context.ts +7 -0
- package/api/src/middlewares/hono/csrf.ts +13 -2
- package/api/src/middlewares/hono/security.ts +6 -11
- package/api/src/queues/checkout-session.ts +21 -9
- package/api/src/queues/event.ts +29 -7
- package/api/src/queues/payment.ts +23 -9
- package/api/src/queues/payout.ts +28 -16
- package/api/src/queues/refund.ts +18 -6
- package/api/src/routes/hono/customers.ts +6 -1
- package/api/src/routes/hono/refunds.ts +2 -3
- package/api/src/service.ts +178 -31
- package/api/src/store/sequelize.ts +16 -1
- package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -0
- package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
- package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
- package/api/tests/libs/service-host.spec.ts +37 -0
- package/api/tests/queues/event-tenant.spec.ts +60 -4
- package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
- package/api/tests/service/fail-closed-http.spec.ts +79 -0
- package/api/tests/service/static-arc-handler.spec.ts +101 -0
- package/api/tests/service/static-externalized.spec.ts +48 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +8 -21
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +10 -5
- package/cloudflare/cf-adapter.ts +419 -0
- package/cloudflare/did-connect-runtime.ts +96 -0
- package/cloudflare/did-connect-token-storage.ts +151 -0
- package/cloudflare/esbuild-cf-config.cjs +407 -0
- package/cloudflare/run-build.js +33 -357
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +98 -56
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +0 -1
- package/cloudflare/wrangler.staging.json +0 -6
- package/package.json +7 -7
- package/scripts/bootstrap-inject.ts +166 -0
- package/src/app.tsx +2 -1
- package/src/libs/service-host.ts +13 -0
- package/vite.arc.config.ts +159 -0
- package/cloudflare/did-connect-auth.ts +0 -310
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +0 -8
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// P2 — runtime-neutral bootstrap helper (README D2 / F2).
|
|
2
|
+
//
|
|
3
|
+
// Pure-function unit tests (no network). Covers the 6 test classes from
|
|
4
|
+
// tasks.md P2: happy / bad-input / security / data-loss / data-damage /
|
|
5
|
+
// data-leak, plus the T2.3 reserved-prefix regression lock.
|
|
6
|
+
import { buildBootstrap, mergeRemote, buildBootstrapScript, PAYMENT_KIT_DID } from '../../../scripts/bootstrap-inject';
|
|
7
|
+
|
|
8
|
+
const baseOpts = {
|
|
9
|
+
uiPrefix: '/.well-known/payment',
|
|
10
|
+
componentId: PAYMENT_KIT_DID,
|
|
11
|
+
remoteBlockletUrl: '/__blocklet__.js?type=json',
|
|
12
|
+
serviceHost: '/.well-known/service',
|
|
13
|
+
localOnly: ['prefix', 'navigation', 'componentMountPoints'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('P2 bootstrap helper — buildBootstrap', () => {
|
|
17
|
+
it('happy: returns prefix/componentId/serviceHost/remoteBlockletUrl skeleton', () => {
|
|
18
|
+
const wb = buildBootstrap(baseOpts);
|
|
19
|
+
expect(wb.prefix).toBe('/.well-known/payment');
|
|
20
|
+
expect(wb.componentId).toBe(PAYMENT_KIT_DID); // getPrefix() precondition (TM-2)
|
|
21
|
+
expect(wb.serviceHost).toBe('/.well-known/service');
|
|
22
|
+
expect(wb.remoteBlockletUrl.startsWith('/__blocklet__.js')).toBe(true); // root-exact, no uiPrefix
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('security/regression (T2.3): throws when remoteBlockletUrl falls under uiPrefix', () => {
|
|
26
|
+
expect(() =>
|
|
27
|
+
buildBootstrap({ ...baseOpts, remoteBlockletUrl: '/.well-known/payment/__blocklet__.js' })
|
|
28
|
+
).toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('bad-input: throws when remoteBlockletUrl is not root-exact', () => {
|
|
32
|
+
expect(() => buildBootstrap({ ...baseOpts, remoteBlockletUrl: '/foo/__blocklet__.js' })).toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('data-leak: bakes no per-tenant data — appName/logo absent until runtime merge', () => {
|
|
36
|
+
const wb = buildBootstrap(baseOpts);
|
|
37
|
+
expect(wb.appName).toBeUndefined();
|
|
38
|
+
expect(wb.appLogo).toBeUndefined();
|
|
39
|
+
// Two distinct tenants merging different remote branding onto the SAME helper
|
|
40
|
+
// output keep an IDENTICAL prefix/componentId — no per-tenant value leaks into
|
|
41
|
+
// the local (build-baked) skeleton.
|
|
42
|
+
const tenantA = mergeRemote(buildBootstrap(baseOpts), { appName: 'Tenant A', prefix: '/a' });
|
|
43
|
+
const tenantB = mergeRemote(buildBootstrap(baseOpts), { appName: 'Tenant B', prefix: '/b' });
|
|
44
|
+
expect(tenantA.appName).toBe('Tenant A');
|
|
45
|
+
expect(tenantB.appName).toBe('Tenant B');
|
|
46
|
+
expect(tenantA.prefix).toBe(tenantB.prefix); // '/.well-known/payment' for both
|
|
47
|
+
expect(tenantA.componentId).toBe(tenantB.componentId);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('P2 bootstrap helper — mergeRemote', () => {
|
|
52
|
+
it('happy: appName/logo/theme come from remote, localOnly preserved', () => {
|
|
53
|
+
const wb = buildBootstrap(baseOpts);
|
|
54
|
+
const merged = mergeRemote(wb, { appName: 'Acme', appLogo: 'https://x/y.png?a=1&b=2', theme: { primary: '#000' } });
|
|
55
|
+
expect(merged.appName).toBe('Acme');
|
|
56
|
+
expect(merged.appLogo).toBe('https://x/y.png?a=1&b=2'); // URL & not corrupted
|
|
57
|
+
expect(merged.theme).toEqual({ primary: '#000' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('data-loss: localOnly fields keep their (local) values after merge', () => {
|
|
61
|
+
const wb = buildBootstrap({ ...baseOpts, extra: { navigation: [{ id: 'a' }] } });
|
|
62
|
+
// remote tries to override prefix + navigation; both are localOnly → ignored,
|
|
63
|
+
// the local values survive (navigation defaults to [] and is set via extra here).
|
|
64
|
+
const merged = mergeRemote(wb, { prefix: '/', navigation: [{ id: 'REMOTE' }], componentMountPoints: [{ x: 1 }] });
|
|
65
|
+
expect(merged.prefix).toBe('/.well-known/payment');
|
|
66
|
+
expect(merged.navigation).toEqual([{ id: 'a' }]); // local nav kept, remote skipped
|
|
67
|
+
expect(merged.componentMountPoints).toEqual([]); // local default kept (extra didn't set it)
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('data-damage: componentId/prefix stay constant regardless of remote', () => {
|
|
71
|
+
const wb = buildBootstrap(baseOpts);
|
|
72
|
+
const merged = mergeRemote(wb, { componentId: 'zEVIL', prefix: '/' });
|
|
73
|
+
expect(merged.componentId).toBe(PAYMENT_KIT_DID);
|
|
74
|
+
expect(merged.prefix).toBe('/.well-known/payment');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('security: rejects prototype pollution (__proto__/constructor/prototype)', () => {
|
|
78
|
+
const wb = buildBootstrap(baseOpts);
|
|
79
|
+
const merged = mergeRemote(wb, JSON.parse('{"__proto__":{"polluted":1},"constructor":2,"safe":3}'));
|
|
80
|
+
expect(({} as any).polluted).toBeUndefined();
|
|
81
|
+
expect((merged as any).polluted).toBeUndefined();
|
|
82
|
+
expect(merged.safe).toBe(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('security: angle-bracket escapes string values (XSS)', () => {
|
|
86
|
+
const wb = buildBootstrap(baseOpts);
|
|
87
|
+
const merged = mergeRemote(wb, { appName: '<svg onload=alert(1)>' });
|
|
88
|
+
expect(merged.appName).not.toContain('<svg');
|
|
89
|
+
expect(merged.appName).toContain('<svg');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('security: refuses oversized payload (>256KB)', () => {
|
|
93
|
+
const wb = buildBootstrap(baseOpts);
|
|
94
|
+
expect(() => mergeRemote(wb, { appName: 'x'.repeat(300 * 1024) })).toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('bad-input: empty/undefined remote does not throw, keeps base intact', () => {
|
|
98
|
+
const wb = buildBootstrap(baseOpts);
|
|
99
|
+
expect(mergeRemote(wb, {} as any).prefix).toBe('/.well-known/payment');
|
|
100
|
+
expect(mergeRemote(wb, undefined as any).componentId).toBe(PAYMENT_KIT_DID);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('P2 bootstrap helper — buildBootstrapScript', () => {
|
|
105
|
+
it('happy: emits a <script> with the baked window.blocklet + embedded mergeRemote', () => {
|
|
106
|
+
const script = buildBootstrapScript(baseOpts);
|
|
107
|
+
expect(script).toContain('window.blocklet =');
|
|
108
|
+
expect(script).toContain(PAYMENT_KIT_DID);
|
|
109
|
+
expect(script).toContain('/__blocklet__.js?type=json');
|
|
110
|
+
// the runtime merge is the same mergeRemote source (single source of truth)
|
|
111
|
+
expect(script).toContain('refusing merge');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('data-damage: emitted script references the root-exact remote url, never under uiPrefix', () => {
|
|
115
|
+
const script = buildBootstrapScript(baseOpts);
|
|
116
|
+
expect(script).not.toContain('/.well-known/payment/__blocklet__');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Dynamic (Layer 2) — run the EMITTED <script> body in a node vm with a stubbed
|
|
121
|
+
// browser window + synchronous XHR, proving it is valid browser JS and that the
|
|
122
|
+
// runtime merge (the embedded mergeRemote) preserves every invariant against a
|
|
123
|
+
// hostile remote __blocklet__.js. Committed + reproducible (replaces the former
|
|
124
|
+
// throwaway `tsx -e` smoke).
|
|
125
|
+
describe('P2 bootstrap helper — emitted script runtime behavior (vm)', () => {
|
|
126
|
+
function runEmittedScript(remote: Record<string, any>) {
|
|
127
|
+
// eslint-disable-next-line global-require
|
|
128
|
+
const vm = require('node:vm');
|
|
129
|
+
const script = buildBootstrapScript(baseOpts);
|
|
130
|
+
// tolerate any <script ...> attributes, not just the bare tag
|
|
131
|
+
const body = script.replace(/^<script[^>]*>/, '').replace(/<\/script>\s*$/, '');
|
|
132
|
+
const win: any = { location: { origin: 'https://t1.example' } };
|
|
133
|
+
const ctx: any = {
|
|
134
|
+
window: win,
|
|
135
|
+
XMLHttpRequest: function (this: any) {
|
|
136
|
+
this.open = () => {};
|
|
137
|
+
this.send = () => {
|
|
138
|
+
this.status = 200;
|
|
139
|
+
this.responseText = JSON.stringify(remote);
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
ctx.global = ctx;
|
|
144
|
+
vm.createContext(ctx);
|
|
145
|
+
vm.runInContext(body, ctx);
|
|
146
|
+
return win.blocklet;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it('runs as valid browser JS and merges a hostile remote while protecting invariants', () => {
|
|
150
|
+
const wb = runEmittedScript({
|
|
151
|
+
appName: '<svg onload=alert(1)>',
|
|
152
|
+
prefix: '/',
|
|
153
|
+
componentId: 'zEVIL',
|
|
154
|
+
appLogo: 'https://cdn/x.png?a=1&b=2',
|
|
155
|
+
});
|
|
156
|
+
expect(wb.prefix).toBe('/.well-known/payment'); // localOnly protected
|
|
157
|
+
expect(wb.componentId).toBe(PAYMENT_KIT_DID); // structural invariant
|
|
158
|
+
expect(wb.appName).not.toContain('<svg'); // XSS escaped
|
|
159
|
+
expect(wb.appLogo).toBe('https://cdn/x.png?a=1&b=2'); // URL query not corrupted
|
|
160
|
+
expect(wb.env.appName).toBe(wb.appName); // env mirror populated
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Phase 7 (S3 arc-payment-embed) — multi-tenant cron fan-out.
|
|
2
|
+
//
|
|
3
|
+
// Background crons that issue a top-level tenant-scoped query have no request
|
|
4
|
+
// to carry a tenant, so in multi mode getInstanceDid() throws
|
|
5
|
+
// TENANT_CONTEXT_MISSING and the pass does nothing. perTenant() wraps such a
|
|
6
|
+
// cron's fn so it runs once per provisioned tenant inside withTenant; single
|
|
7
|
+
// mode runs it as-is (the default tenant auto-resolves).
|
|
8
|
+
//
|
|
9
|
+
// Dynamic require() + jest.resetModules so each case re-reads the tenant mode.
|
|
10
|
+
/* eslint-disable global-require, import/no-dynamic-require */
|
|
11
|
+
|
|
12
|
+
describe('perTenant — multi-tenant cron fan-out', () => {
|
|
13
|
+
const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
|
|
14
|
+
const ORIGINAL_PID = process.env.BLOCKLET_APP_PID;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
|
|
18
|
+
else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
|
|
19
|
+
if (ORIGINAL_PID === undefined) delete process.env.BLOCKLET_APP_PID;
|
|
20
|
+
else process.env.BLOCKLET_APP_PID = ORIGINAL_PID;
|
|
21
|
+
jest.resetModules();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Happy path — multi mode runs the fn once per provisioned tenant, each
|
|
25
|
+
// inside its own tenant context.
|
|
26
|
+
it('multi mode: runs fn once per tenant inside withTenant', async () => {
|
|
27
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
28
|
+
jest.resetModules();
|
|
29
|
+
const { perTenant } = require('../../src/crons/tenant-fanout');
|
|
30
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
31
|
+
|
|
32
|
+
const seen: string[] = [];
|
|
33
|
+
const wrapped = perTenant(
|
|
34
|
+
'test.cron',
|
|
35
|
+
() => seen.push(getInstanceDid()),
|
|
36
|
+
() => Promise.resolve(['did:abt:TENANT_A', 'did:abt:TENANT_B'])
|
|
37
|
+
);
|
|
38
|
+
await wrapped();
|
|
39
|
+
|
|
40
|
+
expect(seen).toEqual(['did:abt:TENANT_A', 'did:abt:TENANT_B']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Single mode — no fan-out: the fn runs exactly once (the default tenant
|
|
44
|
+
// auto-resolves in getInstanceDid). Idempotent: not N times.
|
|
45
|
+
it('single mode: runs fn exactly once (no fan-out, no enumeration)', async () => {
|
|
46
|
+
process.env.PAYMENT_TENANT_MODE = 'single';
|
|
47
|
+
process.env.BLOCKLET_APP_PID = 'did:abt:DEFAULT';
|
|
48
|
+
jest.resetModules();
|
|
49
|
+
const { perTenant } = require('../../src/crons/tenant-fanout');
|
|
50
|
+
|
|
51
|
+
let calls = 0;
|
|
52
|
+
const listTenants = jest.fn(() => Promise.resolve(['did:abt:A', 'did:abt:B']));
|
|
53
|
+
const wrapped = perTenant(
|
|
54
|
+
'test.cron',
|
|
55
|
+
() => {
|
|
56
|
+
calls += 1;
|
|
57
|
+
},
|
|
58
|
+
listTenants
|
|
59
|
+
);
|
|
60
|
+
await wrapped();
|
|
61
|
+
|
|
62
|
+
expect(calls).toBe(1);
|
|
63
|
+
expect(listTenants).not.toHaveBeenCalled(); // single mode never enumerates
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Data loss / error isolation — one tenant's failure must not abort the pass;
|
|
67
|
+
// every other tenant still runs.
|
|
68
|
+
it('multi mode: one tenant failing does not stop the others', async () => {
|
|
69
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
70
|
+
jest.resetModules();
|
|
71
|
+
const { perTenant } = require('../../src/crons/tenant-fanout');
|
|
72
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
73
|
+
|
|
74
|
+
const ran: string[] = [];
|
|
75
|
+
const wrapped = perTenant(
|
|
76
|
+
'test.cron',
|
|
77
|
+
() => {
|
|
78
|
+
const did = getInstanceDid();
|
|
79
|
+
if (did === 'did:abt:B') throw new Error('boom for B');
|
|
80
|
+
ran.push(did);
|
|
81
|
+
},
|
|
82
|
+
() => Promise.resolve(['did:abt:A', 'did:abt:B', 'did:abt:C'])
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// the pass itself resolves (errors isolated), not rejects
|
|
86
|
+
await expect(wrapped()).resolves.toBeUndefined();
|
|
87
|
+
expect(ran).toEqual(['did:abt:A', 'did:abt:C']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Bad input — empty tenant list is a clean no-op (no throw).
|
|
91
|
+
it('multi mode: empty tenant list is a no-op', async () => {
|
|
92
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
93
|
+
jest.resetModules();
|
|
94
|
+
const { perTenant } = require('../../src/crons/tenant-fanout');
|
|
95
|
+
|
|
96
|
+
let calls = 0;
|
|
97
|
+
const wrapped = perTenant(
|
|
98
|
+
'test.cron',
|
|
99
|
+
() => {
|
|
100
|
+
calls += 1;
|
|
101
|
+
},
|
|
102
|
+
() => Promise.resolve([])
|
|
103
|
+
);
|
|
104
|
+
await expect(wrapped()).resolves.toBeUndefined();
|
|
105
|
+
expect(calls).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Data leak — each pass is scoped to exactly its tenant; the fn never sees
|
|
109
|
+
// another tenant's id leaking across iterations.
|
|
110
|
+
it('multi mode: each pass sees only its own tenant id', async () => {
|
|
111
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
112
|
+
jest.resetModules();
|
|
113
|
+
const { perTenant } = require('../../src/crons/tenant-fanout');
|
|
114
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
115
|
+
|
|
116
|
+
const pairs: Array<[string, string]> = [];
|
|
117
|
+
const wrapped = perTenant(
|
|
118
|
+
'test.cron',
|
|
119
|
+
() => {
|
|
120
|
+
// capture the tenant twice within the same pass — must be stable
|
|
121
|
+
pairs.push([getInstanceDid(), getInstanceDid()]);
|
|
122
|
+
},
|
|
123
|
+
() => Promise.resolve(['did:abt:A', 'did:abt:B'])
|
|
124
|
+
);
|
|
125
|
+
await wrapped();
|
|
126
|
+
|
|
127
|
+
expect(pairs).toEqual([
|
|
128
|
+
['did:abt:A', 'did:abt:A'],
|
|
129
|
+
['did:abt:B', 'did:abt:B'],
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('listProvisionedTenants — DISTINCT instance_did from payment-core store', () => {
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
jest.resetModules();
|
|
137
|
+
jest.restoreAllMocks();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('dedupes and filters empty/null instance_did from a cross-tenant read', async () => {
|
|
141
|
+
jest.resetModules();
|
|
142
|
+
// mock the cross-tenant system read so this stays a pure unit test (no DB)
|
|
143
|
+
jest.doMock('../../src/store/scoped', () => ({
|
|
144
|
+
systemFindAll: jest.fn(() =>
|
|
145
|
+
Promise.resolve([
|
|
146
|
+
{ instance_did: 'did:abt:A' },
|
|
147
|
+
{ instance_did: 'did:abt:B' },
|
|
148
|
+
{ instance_did: 'did:abt:A' }, // duplicate
|
|
149
|
+
{ instance_did: null }, // unprovisioned / null
|
|
150
|
+
{ instance_did: '' }, // empty
|
|
151
|
+
])
|
|
152
|
+
),
|
|
153
|
+
}));
|
|
154
|
+
const { listProvisionedTenants } = require('../../src/crons/tenant-fanout');
|
|
155
|
+
const dids = await listProvisionedTenants();
|
|
156
|
+
expect(dids.sort()).toEqual(['did:abt:A', 'did:abt:B']);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — the real @arcblock/did-connect-js runtime.
|
|
2
|
+
//
|
|
3
|
+
// Acceptance: when a host injects createDidConnectJsRuntime (CF / arc-node), the
|
|
4
|
+
// core buildConnectRoutesHono registers the 14 payment DID-Connect actions, and
|
|
5
|
+
// the @blocklet/sdk wallet modules are NEVER constructed (no silent SDK fallback).
|
|
6
|
+
//
|
|
7
|
+
// The @blocklet/sdk wallet mocks throw on construct so any accidental SDK use
|
|
8
|
+
// fails this spec loudly (mirrors the CF fail-fast shims; this is the node-side
|
|
9
|
+
// guard the runtime-boundary requires).
|
|
10
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
11
|
+
import * as Mcrypto from '@ocap/mcrypto';
|
|
12
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
13
|
+
import { fromRandom } from '@ocap/wallet';
|
|
14
|
+
|
|
15
|
+
jest.mock('@blocklet/sdk/lib/wallet-handler', () => ({
|
|
16
|
+
WalletHandlers: class {
|
|
17
|
+
constructor() {
|
|
18
|
+
throw new Error('SDK wallet-handler must not be constructed under the did-connect-js runtime');
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
jest.mock('@blocklet/sdk/lib/wallet-authenticator', () => ({
|
|
23
|
+
WalletAuthenticator: class {
|
|
24
|
+
constructor() {
|
|
25
|
+
throw new Error('SDK wallet-authenticator must not be constructed under the did-connect-js runtime');
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const APP_WALLET_TYPE = {
|
|
31
|
+
role: Mcrypto.types.RoleType.ROLE_APPLICATION,
|
|
32
|
+
pk: Mcrypto.types.KeyType.ED25519,
|
|
33
|
+
hash: Mcrypto.types.HashType.SHA3,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ALL_ACTIONS = [
|
|
37
|
+
'collect',
|
|
38
|
+
'collect-batch',
|
|
39
|
+
'payment',
|
|
40
|
+
'setup',
|
|
41
|
+
'subscription',
|
|
42
|
+
'change-payment',
|
|
43
|
+
'change-plan',
|
|
44
|
+
'recharge',
|
|
45
|
+
'recharge-account',
|
|
46
|
+
'delegation',
|
|
47
|
+
'overdraft-protection',
|
|
48
|
+
're-stake',
|
|
49
|
+
'auto-recharge-auth',
|
|
50
|
+
'change-payer',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
describe('S3-CF DID convergence — @arcblock/did-connect-js runtime', () => {
|
|
54
|
+
it('registers the 14 payment DID actions and never constructs @blocklet/sdk wallets', () => {
|
|
55
|
+
// eslint-disable-next-line global-require
|
|
56
|
+
const { setDidConnectRuntime: setRuntime } = require('../../src/libs/auth');
|
|
57
|
+
// eslint-disable-next-line global-require
|
|
58
|
+
const { setIdentityDriver: setDriver } = require('../../src/libs/drivers');
|
|
59
|
+
// eslint-disable-next-line global-require
|
|
60
|
+
const { createDidConnectJsRuntime } = require('../../src/libs/did-connect/runtime-did-connect-js');
|
|
61
|
+
// eslint-disable-next-line global-require
|
|
62
|
+
const { buildConnectRoutesHono } = require('../../src/service');
|
|
63
|
+
|
|
64
|
+
const signer = fromRandom(APP_WALLET_TYPE);
|
|
65
|
+
setDriver({
|
|
66
|
+
resolveInstanceDidForHost: () => 'zMOCK_DIDJS',
|
|
67
|
+
getInstanceAppIdentity: async () => ({ appSk: signer.secretKey, appInfo: { name: 'T' } }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const memStore: Record<string, any> = {};
|
|
71
|
+
const tokenStorage = {
|
|
72
|
+
create: async (token: string, status = 'created') => {
|
|
73
|
+
memStore[token] = { token, status };
|
|
74
|
+
return memStore[token];
|
|
75
|
+
},
|
|
76
|
+
read: async (token: string) => memStore[token] ?? null,
|
|
77
|
+
update: async (token: string, u: Record<string, any>) => {
|
|
78
|
+
memStore[token] = { ...memStore[token], ...u };
|
|
79
|
+
return memStore[token];
|
|
80
|
+
},
|
|
81
|
+
delete: async (token: string) => {
|
|
82
|
+
delete memStore[token];
|
|
83
|
+
},
|
|
84
|
+
on: () => undefined,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
setRuntime(createDidConnectJsRuntime({ tokenStorage }));
|
|
88
|
+
|
|
89
|
+
const connectApp = buildConnectRoutesHono();
|
|
90
|
+
const paths: Set<string> = new Set(((connectApp as any).routes || []).map((r: any) => r.path));
|
|
91
|
+
|
|
92
|
+
// every payment action exposes at least its /token entry under /api/did/<action>
|
|
93
|
+
for (const action of ALL_ACTIONS) {
|
|
94
|
+
expect([...paths].some((p) => p.startsWith(`/api/did/${action}/`))).toBe(true);
|
|
95
|
+
}
|
|
96
|
+
// no SDK wallet was constructed (the mocks above would have thrown)
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — the shared per-tenant DID-Connect identity resolver.
|
|
2
|
+
//
|
|
3
|
+
// resolveTenantIdentity is the ONE path both the CF and arc-node AUTH_SERVICE
|
|
4
|
+
// runtimes use to derive their DID-Connect signing wallet from the host
|
|
5
|
+
// IdentityDriver.getInstanceAppIdentity — never a fixed isolate key. This spec
|
|
6
|
+
// locks the happy path + the two fail-closed errors.
|
|
7
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
8
|
+
import * as Mcrypto from '@ocap/mcrypto';
|
|
9
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
10
|
+
import { fromRandom } from '@ocap/wallet';
|
|
11
|
+
|
|
12
|
+
import { setIdentityDriver, createDefaultIdentityDriver, type IdentityDriver } from '../../src/libs/drivers';
|
|
13
|
+
import {
|
|
14
|
+
resolveTenantIdentity,
|
|
15
|
+
clearTenantIdentityCache,
|
|
16
|
+
getCachedTenantIdentity,
|
|
17
|
+
hasDynamicIdentity,
|
|
18
|
+
warmTenantIdentity,
|
|
19
|
+
} from '../../src/libs/did-connect/tenant-identity';
|
|
20
|
+
|
|
21
|
+
const INSTANCE = 'zMOCK_TENANT_IDENTITY';
|
|
22
|
+
|
|
23
|
+
// The DID-Connect signer is a ROLE_APPLICATION/ED25519/SHA3 wallet (the DID
|
|
24
|
+
// encoding depends on the type), so the test must mint source keys with the SAME
|
|
25
|
+
// type for the derived address to round-trip.
|
|
26
|
+
const APP_WALLET_TYPE = {
|
|
27
|
+
role: Mcrypto.types.RoleType.ROLE_APPLICATION,
|
|
28
|
+
pk: Mcrypto.types.KeyType.ED25519,
|
|
29
|
+
hash: Mcrypto.types.HashType.SHA3,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('S3-CF DID convergence — resolveTenantIdentity (shared identity resolver)', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
// resolveTenantIdentity now caches per instanceDid (LRU+TTL). These tests
|
|
35
|
+
// reuse one INSTANCE did with swapped drivers, so isolate by clearing.
|
|
36
|
+
clearTenantIdentityCache();
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
setIdentityDriver(createDefaultIdentityDriver()); // reset module-level driver
|
|
40
|
+
clearTenantIdentityCache();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('derives the signing wallet from the driver getInstanceAppIdentity(appSk) + passes appInfo', async () => {
|
|
44
|
+
const signer = fromRandom(APP_WALLET_TYPE);
|
|
45
|
+
const driver: IdentityDriver = {
|
|
46
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
47
|
+
getInstanceAppIdentity: async (did) => {
|
|
48
|
+
expect(did).toBe(INSTANCE);
|
|
49
|
+
return { appSk: signer.secretKey, appInfo: { name: 'Tenant X', icon: 'https://x/i.png' } };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
setIdentityDriver(driver);
|
|
53
|
+
|
|
54
|
+
const resolved = await resolveTenantIdentity(INSTANCE);
|
|
55
|
+
expect(resolved.instanceDid).toBe(INSTANCE);
|
|
56
|
+
// the derived wallet is the same keypair as the source appSk
|
|
57
|
+
expect(resolved.wallet.address).toBe(signer.address);
|
|
58
|
+
// no rotated key → permanentWallet falls back to wallet
|
|
59
|
+
expect(resolved.permanentWallet.address).toBe(signer.address);
|
|
60
|
+
expect(resolved.appInfo).toEqual({ name: 'Tenant X', icon: 'https://x/i.png' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('uses appPsk for the permanent wallet when keys have rotated', async () => {
|
|
64
|
+
const current = fromRandom(APP_WALLET_TYPE);
|
|
65
|
+
const permanent = fromRandom(APP_WALLET_TYPE);
|
|
66
|
+
setIdentityDriver({
|
|
67
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
68
|
+
getInstanceAppIdentity: async () => ({ appSk: current.secretKey, appPsk: permanent.secretKey }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const resolved = await resolveTenantIdentity(INSTANCE);
|
|
72
|
+
expect(resolved.wallet.address).toBe(current.address);
|
|
73
|
+
expect(resolved.permanentWallet.address).toBe(permanent.address);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws (no silent fallback) when the driver lacks getInstanceAppIdentity', async () => {
|
|
77
|
+
setIdentityDriver(createDefaultIdentityDriver()); // default driver has no getInstanceAppIdentity
|
|
78
|
+
await expect(resolveTenantIdentity(INSTANCE)).rejects.toThrow(/does not implement getInstanceAppIdentity/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fails closed when the instance has no app signing key', async () => {
|
|
82
|
+
setIdentityDriver({
|
|
83
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
84
|
+
// @ts-expect-error — intentionally returns no appSk to exercise the fail-closed path
|
|
85
|
+
getInstanceAppIdentity: async () => ({ appInfo: { name: 'no key' } }),
|
|
86
|
+
});
|
|
87
|
+
await expect(resolveTenantIdentity(INSTANCE)).rejects.toThrow(/no app signing key.*fail-closed/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Layer 4 (wallet-authenticator-dynamic.md) — business wallet support.
|
|
91
|
+
it('derives the ethereum business wallet from the same appSk (secp256k1)', async () => {
|
|
92
|
+
const signer = fromRandom(APP_WALLET_TYPE);
|
|
93
|
+
setIdentityDriver({
|
|
94
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
95
|
+
getInstanceAppIdentity: async () => ({ appSk: signer.secretKey }),
|
|
96
|
+
});
|
|
97
|
+
const resolved = await resolveTenantIdentity(INSTANCE);
|
|
98
|
+
// distinct EVM address (0x-prefixed), not the arcblock DID
|
|
99
|
+
expect(resolved.ethWallet.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
100
|
+
expect(resolved.ethWallet.address).not.toBe(resolved.wallet.address);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('hasDynamicIdentity reflects whether the driver implements getInstanceAppIdentity', () => {
|
|
104
|
+
setIdentityDriver(createDefaultIdentityDriver());
|
|
105
|
+
expect(hasDynamicIdentity()).toBe(false);
|
|
106
|
+
setIdentityDriver({
|
|
107
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
108
|
+
getInstanceAppIdentity: async () => ({ appSk: 'x' }),
|
|
109
|
+
});
|
|
110
|
+
expect(hasDynamicIdentity()).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('getCachedTenantIdentity returns the warmed value, and fails closed before warming', async () => {
|
|
114
|
+
const signer = fromRandom(APP_WALLET_TYPE);
|
|
115
|
+
setIdentityDriver({
|
|
116
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
117
|
+
getInstanceAppIdentity: async () => ({ appSk: signer.secretKey }),
|
|
118
|
+
});
|
|
119
|
+
// not warmed yet → fail-closed (no silent default key)
|
|
120
|
+
expect(() => getCachedTenantIdentity(INSTANCE)).toThrow(/not resolved.*fail-closed/);
|
|
121
|
+
// warm (what the request/job middleware does), then the sync read resolves
|
|
122
|
+
await resolveTenantIdentity(INSTANCE);
|
|
123
|
+
expect(getCachedTenantIdentity(INSTANCE).wallet.address).toBe(signer.address);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('caches the derived identity — a second resolve does not re-call the driver', async () => {
|
|
127
|
+
let calls = 0;
|
|
128
|
+
const signer = fromRandom(APP_WALLET_TYPE);
|
|
129
|
+
setIdentityDriver({
|
|
130
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
131
|
+
getInstanceAppIdentity: async () => {
|
|
132
|
+
calls += 1;
|
|
133
|
+
return { appSk: signer.secretKey };
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
await resolveTenantIdentity(INSTANCE);
|
|
137
|
+
await resolveTenantIdentity(INSTANCE);
|
|
138
|
+
expect(calls).toBe(1); // second call served from cache
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('warmTenantIdentity swallows resolve errors and leaves the cache cold (fail-closed on use)', async () => {
|
|
142
|
+
setIdentityDriver({
|
|
143
|
+
resolveInstanceDidForHost: () => INSTANCE,
|
|
144
|
+
getInstanceAppIdentity: async () => {
|
|
145
|
+
throw new Error('AUTH_SERVICE down');
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
// warm must NOT throw (a non-wallet request is not blocked)
|
|
149
|
+
await expect(warmTenantIdentity(INSTANCE)).resolves.toBeUndefined();
|
|
150
|
+
// but the cache stays cold → any wallet access fails closed
|
|
151
|
+
expect(() => getCachedTenantIdentity(INSTANCE)).toThrow(/not resolved.*fail-closed/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('warmTenantIdentity is a no-op on the blocklet-server runtime (no dynamic driver)', async () => {
|
|
155
|
+
setIdentityDriver(createDefaultIdentityDriver()); // no getInstanceAppIdentity
|
|
156
|
+
await expect(warmTenantIdentity(INSTANCE)).resolves.toBeUndefined();
|
|
157
|
+
expect(() => getCachedTenantIdentity(INSTANCE)).toThrow(/not resolved.*fail-closed/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// P5 T5.1 / TM-8 — resolveServiceHost regression lock.
|
|
2
|
+
//
|
|
3
|
+
// The full P5 login E2E (DID Connect against arc /.well-known/service) is blocked
|
|
4
|
+
// on D-4 (arc endpoint) + P4 (arc wiring), so these are the in-workspace locks
|
|
5
|
+
// that DON'T depend on D-4: the blocklet-server fallback (TM-8) and the arc path.
|
|
6
|
+
import { resolveServiceHost } from '../../../src/libs/service-host';
|
|
7
|
+
|
|
8
|
+
describe('P5 T5.1 — resolveServiceHost', () => {
|
|
9
|
+
const realWindow = (global as any).window;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
(global as any).window = realWindow;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('TM-8 (regression): blocklet-server form (no injected serviceHost) → prefix, unchanged', () => {
|
|
15
|
+
(global as any).window = { blocklet: { prefix: '/' } };
|
|
16
|
+
expect(resolveServiceHost('/')).toBe('/');
|
|
17
|
+
(global as any).window = { blocklet: { prefix: '/payment' } };
|
|
18
|
+
expect(resolveServiceHost('/payment')).toBe('/payment');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('arc form: injected window.blocklet.serviceHost wins over prefix', () => {
|
|
22
|
+
(global as any).window = { blocklet: { serviceHost: '/.well-known/service' } };
|
|
23
|
+
expect(resolveServiceHost('/.well-known/payment')).toBe('/.well-known/service');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('bad-input: empty/missing serviceHost falls back to prefix (not undefined/empty)', () => {
|
|
27
|
+
(global as any).window = { blocklet: { serviceHost: '' } };
|
|
28
|
+
expect(resolveServiceHost('/.well-known/payment')).toBe('/.well-known/payment');
|
|
29
|
+
(global as any).window = {};
|
|
30
|
+
expect(resolveServiceHost('/x')).toBe('/x');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('no window at all (SSR/test) → prefix, no throw', () => {
|
|
34
|
+
delete (global as any).window;
|
|
35
|
+
expect(resolveServiceHost('/y')).toBe('/y');
|
|
36
|
+
});
|
|
37
|
+
});
|