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.
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
@@ -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('&lt;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
+ });