payment-kit 1.29.7 → 1.29.9
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/host-glue.ts +79 -0
- package/api/src/host-node/serve-static-arc.ts +1 -0
- package/api/src/integrations/app-store/native-asn1.ts +1 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +6 -1
- package/api/src/integrations/stripe/setup.ts +3 -3
- package/api/src/libs/arcblock-transfer.ts +27 -0
- package/api/src/libs/context.ts +10 -2
- package/api/src/libs/did-connect/tenant-identity.ts +19 -1
- package/api/src/libs/env.ts +65 -22
- package/api/src/libs/notification/index.ts +4 -3
- package/api/src/libs/util.ts +31 -2
- package/api/src/middlewares/hono/csrf.ts +6 -0
- package/api/src/middlewares/hono/session.ts +22 -0
- package/api/src/routes/connect/pay.ts +21 -9
- package/api/src/routes/hono/credit-grants.ts +1 -1
- package/api/src/routes/hono/entitlements.ts +8 -1
- package/api/src/routes/hono/payment-methods.ts +6 -2
- package/api/src/service.ts +42 -4
- package/api/src/store/models/payment-currency.ts +7 -1
- package/api/src/store/models/payment-method.ts +30 -6
- package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
- package/api/tests/libs/notification-guard.spec.ts +25 -8
- package/api/tests/libs/util.spec.ts +51 -0
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +46 -50
- package/cloudflare/did-connect-runtime.ts +43 -5
- package/cloudflare/esbuild-cf-config.cjs +12 -7
- package/cloudflare/tests/cf-adapter.spec.ts +12 -2
- package/cloudflare/worker.ts +41 -1
- package/package.json +9 -9
- package/src/components/layout/user.tsx +7 -1
- package/vite.arc.config.ts +19 -0
package/api/src/service.ts
CHANGED
|
@@ -275,10 +275,30 @@ export function buildConnectRoutesHono(): Hono {
|
|
|
275
275
|
// withTenant) we pass through; otherwise we resolve Host→tenant once and wrap.
|
|
276
276
|
connectApp.use('*', async (c, next) => {
|
|
277
277
|
const { context: requestCtx } = require('./libs/context');
|
|
278
|
-
|
|
278
|
+
const { warmTenantIdentity } = require('./libs/did-connect/tenant-identity');
|
|
279
|
+
// Warm the tenant's signing identity + secrets keyring BEFORE the DID handler so
|
|
280
|
+
// the synchronous secrets hot path (connect/pay → getStripeClient →
|
|
281
|
+
// PaymentMethod.decryptSettings) resolves — else it throws "secrets: key for
|
|
282
|
+
// tenant <did> is not warmed". Mirrors contextMiddleware (context.ts); the native
|
|
283
|
+
// resource group warms there, but connectApp is mounted ALONGSIDE it (no shared
|
|
284
|
+
// pipeline), so DID routes must warm themselves. Best-effort + no-op on
|
|
285
|
+
// blocklet-server (hasDynamicIdentity() === false).
|
|
286
|
+
const preResolved = requestCtx.peekInstanceDid();
|
|
287
|
+
if (preResolved) {
|
|
288
|
+
// Host already scoped this request (e.g. the CF worker wrapped /api/* in
|
|
289
|
+
// withTenant before the core ran) — run inside that context; warm is idempotent
|
|
290
|
+
// (cache hit if the host already warmed).
|
|
291
|
+
await warmTenantIdentity(preResolved);
|
|
292
|
+
return next();
|
|
293
|
+
}
|
|
294
|
+
// arc-node embedded (svc.http.fetch) never pre-wraps: resolve Host→tenant once,
|
|
295
|
+
// run inside the tenant context, and warm before the handler.
|
|
279
296
|
const { resolveTenantForHost } = require('./libs/drivers/identity');
|
|
280
297
|
const instanceDid = await resolveTenantForHost(c.req.header('host'));
|
|
281
|
-
return requestCtx.withTenant(instanceDid, () =>
|
|
298
|
+
return requestCtx.withTenant(instanceDid, async () => {
|
|
299
|
+
await warmTenantIdentity(instanceDid);
|
|
300
|
+
return next();
|
|
301
|
+
});
|
|
282
302
|
});
|
|
283
303
|
|
|
284
304
|
for (const h of connectHandlerModules()) handlers.attach(Object.assign({ app: connectApp }, h));
|
|
@@ -455,8 +475,20 @@ async function provisionTenant(instanceDid: string): Promise<void> {
|
|
|
455
475
|
const { PaymentMethod, PaymentCurrency } = require('./store/models');
|
|
456
476
|
|
|
457
477
|
const CHAINS = [
|
|
458
|
-
{
|
|
459
|
-
|
|
478
|
+
{
|
|
479
|
+
chainId: 'main',
|
|
480
|
+
livemode: true,
|
|
481
|
+
symbol: 'ABT',
|
|
482
|
+
label: 'ArcBlock Main',
|
|
483
|
+
contract: 'z35nNRvYxBoHitx9yZ5ATS88psfShzPPBLxYD',
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
chainId: 'beta',
|
|
487
|
+
livemode: false,
|
|
488
|
+
symbol: 'TBA',
|
|
489
|
+
label: 'ArcBlock Beta',
|
|
490
|
+
contract: 'z35n6UoHSi9MED4uaQy6ozFgKPaZj2UKrurBG',
|
|
491
|
+
},
|
|
460
492
|
];
|
|
461
493
|
|
|
462
494
|
await requestContext.withTenant(instanceDid, async () => {
|
|
@@ -464,6 +496,11 @@ async function provisionTenant(instanceDid: string): Promise<void> {
|
|
|
464
496
|
const existing = await PaymentMethod.findOne({ where: { type: 'arcblock' } });
|
|
465
497
|
if (existing) return;
|
|
466
498
|
|
|
499
|
+
// Sequential by design: only 2 chains, and within each the currency needs the
|
|
500
|
+
// method id and the method's default_currency_id needs the currency id — no
|
|
501
|
+
// intra-chain parallelism is possible, and inter-chain ordering is irrelevant
|
|
502
|
+
// for two one-time seed inserts.
|
|
503
|
+
/* eslint-disable no-await-in-loop */
|
|
467
504
|
for (const chain of CHAINS) {
|
|
468
505
|
const logo = '/methods/arcblock.png';
|
|
469
506
|
const method = await PaymentMethod.create({
|
|
@@ -507,6 +544,7 @@ async function provisionTenant(instanceDid: string): Promise<void> {
|
|
|
507
544
|
});
|
|
508
545
|
await method.update({ default_currency_id: currency.id });
|
|
509
546
|
}
|
|
547
|
+
/* eslint-enable no-await-in-loop */
|
|
510
548
|
logger.info('provisionTenant: seeded arcblock payment methods + currencies', { instanceDid });
|
|
511
549
|
});
|
|
512
550
|
}
|
|
@@ -19,7 +19,7 @@ import type { LiteralUnion } from 'type-fest';
|
|
|
19
19
|
import { fromTokenToUnit } from '@ocap/util';
|
|
20
20
|
import { TenantModel } from '../tenant-model';
|
|
21
21
|
import { getInstanceDid } from '../../libs/context';
|
|
22
|
-
import { createIdGenerator } from '../../libs/util';
|
|
22
|
+
import { createIdGenerator, getPublicAssetUrl } from '../../libs/util';
|
|
23
23
|
import { RechargeConfig, VaultConfig } from './types';
|
|
24
24
|
|
|
25
25
|
const nextId = createIdGenerator('pc', 12);
|
|
@@ -57,6 +57,12 @@ export class PaymentCurrency extends TenantModel<InferAttributes<PaymentCurrency
|
|
|
57
57
|
declare recharge_config?: RechargeConfig;
|
|
58
58
|
declare token_config?: Record<string, any> | null;
|
|
59
59
|
|
|
60
|
+
public override toJSON(): any {
|
|
61
|
+
const data = super.toJSON() as any;
|
|
62
|
+
if (data.logo) data.logo = getPublicAssetUrl(data.logo);
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
public static readonly GENESIS_ATTRIBUTES = {
|
|
61
67
|
id: {
|
|
62
68
|
type: DataTypes.STRING(15),
|
|
@@ -16,7 +16,7 @@ import { encryptSecret, decryptSecret } from '../../libs/secrets';
|
|
|
16
16
|
|
|
17
17
|
import { AppStoreClient } from '../../integrations/app-store/client';
|
|
18
18
|
import { GooglePlayClient } from '../../integrations/google-play/client';
|
|
19
|
-
import { STRIPE_API_VERSION, createIdGenerator } from '../../libs/util';
|
|
19
|
+
import { STRIPE_API_VERSION, createIdGenerator, getPublicAssetUrl } from '../../libs/util';
|
|
20
20
|
import { sequelize } from '../sequelize';
|
|
21
21
|
import type { PaymentMethodSettings } from './types';
|
|
22
22
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
@@ -73,6 +73,12 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
|
|
|
73
73
|
declare created_at: CreationOptional<Date>;
|
|
74
74
|
declare updated_at: CreationOptional<Date>;
|
|
75
75
|
|
|
76
|
+
public override toJSON(): any {
|
|
77
|
+
const data = super.toJSON() as any;
|
|
78
|
+
if (data.logo) data.logo = getPublicAssetUrl(data.logo);
|
|
79
|
+
return data;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
public static readonly GENESIS_ATTRIBUTES = {
|
|
77
83
|
id: {
|
|
78
84
|
type: DataTypes.STRING(30),
|
|
@@ -170,11 +176,21 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
|
|
|
170
176
|
|
|
171
177
|
public static encryptSettings(settings: PaymentMethodSettings) {
|
|
172
178
|
const tmp = cloneDeep(settings);
|
|
173
|
-
|
|
179
|
+
// Per-field truthy guards: admin UI submits *all* channel stubs (empty
|
|
180
|
+
// strings for the ones the user is not configuring), so a parent-level
|
|
181
|
+
// `if (tmp.stripe)` would gladly enter the branch and call
|
|
182
|
+
// encryptSecret('') for every empty stub. Under the multi-tenant
|
|
183
|
+
// KeyringSecretsDriver, CryptoJS.AES.encrypt(undefined, …) throws
|
|
184
|
+
// "Cannot read properties of undefined (reading 'encrypt')" — the
|
|
185
|
+
// single-tenant DefaultSecretsDriver swallowed it silently. Field-level
|
|
186
|
+
// guards make the function robust to either caller shape.
|
|
187
|
+
if (tmp.stripe?.secret_key) {
|
|
174
188
|
tmp.stripe.secret_key = encryptSecret(tmp.stripe.secret_key);
|
|
189
|
+
}
|
|
190
|
+
if (tmp.stripe?.webhook_signing_secret) {
|
|
175
191
|
tmp.stripe.webhook_signing_secret = encryptSecret(tmp.stripe.webhook_signing_secret);
|
|
176
192
|
}
|
|
177
|
-
if (tmp.google_play) {
|
|
193
|
+
if (tmp.google_play?.service_account_json) {
|
|
178
194
|
tmp.google_play.service_account_json = encryptSecret(tmp.google_play.service_account_json);
|
|
179
195
|
}
|
|
180
196
|
if (tmp.app_store?.private_key_pem) {
|
|
@@ -189,11 +205,16 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
|
|
|
189
205
|
|
|
190
206
|
public static decryptSettings(settings: PaymentMethodSettings) {
|
|
191
207
|
const tmp = cloneDeep(settings);
|
|
192
|
-
|
|
208
|
+
// Same per-field guard as encryptSettings — empty stubs from the admin
|
|
209
|
+
// UI roundtrip would otherwise hit decryptSecret('') and explode under
|
|
210
|
+
// the keyring driver.
|
|
211
|
+
if (tmp.stripe?.secret_key) {
|
|
193
212
|
tmp.stripe.secret_key = decryptSecret(tmp.stripe.secret_key);
|
|
213
|
+
}
|
|
214
|
+
if (tmp.stripe?.webhook_signing_secret) {
|
|
194
215
|
tmp.stripe.webhook_signing_secret = decryptSecret(tmp.stripe.webhook_signing_secret);
|
|
195
216
|
}
|
|
196
|
-
if (tmp.google_play) {
|
|
217
|
+
if (tmp.google_play?.service_account_json) {
|
|
197
218
|
tmp.google_play.service_account_json = decryptSecret(tmp.google_play.service_account_json);
|
|
198
219
|
}
|
|
199
220
|
if (tmp.app_store?.private_key_pem) {
|
|
@@ -250,7 +271,10 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
|
|
|
250
271
|
// wallets only decode protobuf-encoded tx bytes. Use `@ocap/client/legacy`
|
|
251
272
|
// (protobuf client) so encode / decode / broadcast all stay protobuf,
|
|
252
273
|
// matching the wallet signatures and what the chain node expects to hash.
|
|
253
|
-
|
|
274
|
+
// Disable the client's implicit getContext() warmup. Its zero-delay timer
|
|
275
|
+
// can be classified as a permanently hung request by workerd. Callers that
|
|
276
|
+
// need chain context fetch it explicitly through Worker-safe I/O.
|
|
277
|
+
const created = new OcapClient(host, false);
|
|
254
278
|
ocapClients.set(host, created);
|
|
255
279
|
return created;
|
|
256
280
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const fetchContext = jest.fn();
|
|
2
|
+
const encodeTx = jest.fn();
|
|
3
|
+
|
|
4
|
+
jest.mock('@ocap/client/encode', () => ({
|
|
5
|
+
fetchContext,
|
|
6
|
+
encodeTx,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { encodeArcblockTransfer } from '../../src/libs/arcblock-transfer';
|
|
10
|
+
|
|
11
|
+
describe('encodeArcblockTransfer', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
fetchContext.mockReset();
|
|
14
|
+
encodeTx.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('uses the worker-safe context fetcher instead of the OCAP client context timer', async () => {
|
|
18
|
+
fetchContext.mockResolvedValue({
|
|
19
|
+
chainId: 'beta',
|
|
20
|
+
txFee: [{ typeUrl: 'fg:t:transfer_v3', fee: '0' }],
|
|
21
|
+
});
|
|
22
|
+
const encoded = {
|
|
23
|
+
object: { signature: Buffer.from('signed') },
|
|
24
|
+
buffer: Buffer.from('encoded'),
|
|
25
|
+
};
|
|
26
|
+
encodeTx.mockReturnValue(encoded);
|
|
27
|
+
|
|
28
|
+
const client = {
|
|
29
|
+
getContext: jest.fn(() => {
|
|
30
|
+
throw new Error('must not call timer-backed getContext');
|
|
31
|
+
}),
|
|
32
|
+
encodeTransferV3Tx: jest.fn(() => {
|
|
33
|
+
throw new Error('must not call timer-backed client encoder');
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
const tx = { chainId: '', itx: { typeUrl: 'fg:t:transfer_v3', value: { outputs: [] } } };
|
|
37
|
+
const wallet = { address: 'zUser', publicKey: 'zPk' };
|
|
38
|
+
|
|
39
|
+
await expect(
|
|
40
|
+
encodeArcblockTransfer({
|
|
41
|
+
chainHost: 'https://beta.abtnetwork.io/api/',
|
|
42
|
+
tx,
|
|
43
|
+
wallet,
|
|
44
|
+
})
|
|
45
|
+
).resolves.toBe(encoded);
|
|
46
|
+
|
|
47
|
+
expect(fetchContext).toHaveBeenCalledWith('https://beta.abtnetwork.io/api/');
|
|
48
|
+
expect(encodeTx).toHaveBeenCalledWith({
|
|
49
|
+
type: 'TransferV3Tx',
|
|
50
|
+
tx,
|
|
51
|
+
wallet,
|
|
52
|
+
chainId: 'beta',
|
|
53
|
+
feeConfig: [{ typeUrl: 'fg:t:transfer_v3', fee: '0' }],
|
|
54
|
+
});
|
|
55
|
+
expect(client.getContext).not.toHaveBeenCalled();
|
|
56
|
+
expect(client.encodeTransferV3Tx).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
// environments" off Blocklet Server (arc-node / CF). The guard no-ops both SDK
|
|
6
6
|
// transports there — at the module level, so app-side logic still runs and only
|
|
7
7
|
// the send/publish is skipped — and leaves Blocklet Server untouched. Gated on the
|
|
8
|
-
// single reusable predicate isBlockletServer() (=
|
|
8
|
+
// single reusable predicate isBlockletServer() (= BLOCKLET_DID present; the CF
|
|
9
|
+
// worker injects BLOCKLET_APP_ID = APP_PID so that can NOT be the marker).
|
|
9
10
|
describe('ensureBlockletSdkTransportGuard', () => {
|
|
10
|
-
const ORIG = process.env.
|
|
11
|
+
const ORIG = process.env.BLOCKLET_DID;
|
|
12
|
+
const ORIG_APP_ID = process.env.BLOCKLET_APP_ID;
|
|
11
13
|
|
|
12
14
|
afterEach(() => {
|
|
13
|
-
if (ORIG === undefined) delete process.env.
|
|
14
|
-
else process.env.
|
|
15
|
+
if (ORIG === undefined) delete process.env.BLOCKLET_DID;
|
|
16
|
+
else process.env.BLOCKLET_DID = ORIG;
|
|
17
|
+
if (ORIG_APP_ID === undefined) delete process.env.BLOCKLET_APP_ID;
|
|
18
|
+
else process.env.BLOCKLET_APP_ID = ORIG_APP_ID;
|
|
15
19
|
jest.resetModules();
|
|
16
20
|
});
|
|
17
21
|
|
|
@@ -33,8 +37,8 @@ describe('ensureBlockletSdkTransportGuard', () => {
|
|
|
33
37
|
return { notification, eventbus };
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
it('off Blocklet Server (no
|
|
37
|
-
delete process.env.
|
|
40
|
+
it('off Blocklet Server (no BLOCKLET_DID): no-ops notification + eventbus on module + default', async () => {
|
|
41
|
+
delete process.env.BLOCKLET_DID;
|
|
38
42
|
const { notification, eventbus } = loadAndGuard();
|
|
39
43
|
for (const target of [notification, notification.default]) {
|
|
40
44
|
expect(target.sendToUser).not.toBe('REAL');
|
|
@@ -50,12 +54,25 @@ describe('ensureBlockletSdkTransportGuard', () => {
|
|
|
50
54
|
}
|
|
51
55
|
});
|
|
52
56
|
|
|
53
|
-
it('on Blocklet Server (
|
|
54
|
-
process.env.
|
|
57
|
+
it('on Blocklet Server (BLOCKLET_DID set): leaves both transports intact', () => {
|
|
58
|
+
process.env.BLOCKLET_DID = 'z_node_did';
|
|
55
59
|
const { notification, eventbus } = loadAndGuard();
|
|
56
60
|
expect(notification.sendToUser).toBe('REAL');
|
|
57
61
|
expect(notification.default.sendToUser).toBe('REAL');
|
|
58
62
|
expect(eventbus.publish).toBe('REAL');
|
|
59
63
|
expect(eventbus.default.publish).toBe('REAL');
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
// Regression: the CF worker injects BLOCKLET_APP_ID = APP_PID for app identity,
|
|
67
|
+
// which used to make isBlockletServer() spuriously true and skip the guard — then
|
|
68
|
+
// the SDK transport crashed at send time. With the marker on BLOCKLET_DID, an
|
|
69
|
+
// APP_ID without a DID is correctly treated as off-Blocklet-Server.
|
|
70
|
+
it('CF worker (BLOCKLET_APP_ID set, no BLOCKLET_DID): still no-ops the transports', async () => {
|
|
71
|
+
delete process.env.BLOCKLET_DID;
|
|
72
|
+
process.env.BLOCKLET_APP_ID = 'z_app_pid';
|
|
73
|
+
const { notification, eventbus } = loadAndGuard();
|
|
74
|
+
expect(notification.sendToUser).not.toBe('REAL');
|
|
75
|
+
expect(eventbus.publish).not.toBe('REAL');
|
|
76
|
+
await expect(notification.sendToUser('zUser', { title: 't' })).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
61
78
|
});
|
|
@@ -19,12 +19,63 @@ import {
|
|
|
19
19
|
resolveAddressChainTypes,
|
|
20
20
|
formatCurrencyInfo,
|
|
21
21
|
getExplorerTxUrl,
|
|
22
|
+
getPublicAssetUrl,
|
|
23
|
+
stripeEndpoint,
|
|
22
24
|
} from '../../src/libs/util';
|
|
25
|
+
import { setCoreConfig } from '../../src/libs/env';
|
|
23
26
|
import type { Subscription, PaymentMethod, PaymentCurrency } from '../../src/store/models';
|
|
24
27
|
|
|
25
28
|
import { blocklet } from '../../src/libs/auth';
|
|
29
|
+
import { context } from '../../src/libs/context';
|
|
26
30
|
import logger from '../../src/libs/logger';
|
|
27
31
|
|
|
32
|
+
describe('stripeEndpoint', () => {
|
|
33
|
+
afterEach(() => setCoreConfig(undefined));
|
|
34
|
+
|
|
35
|
+
it('uses the request-scoped embedded payment public base URL', async () => {
|
|
36
|
+
const endpoint = await context.run(
|
|
37
|
+
{ publicBaseUrl: 'https://cf-aside.ofind.cn/.well-known/payment' },
|
|
38
|
+
async () => stripeEndpoint()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(endpoint).toBe(
|
|
42
|
+
'https://cf-aside.ofind.cn/.well-known/payment/api/integrations/stripe/webhook'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('prefers the explicit webhook URL override', async () => {
|
|
47
|
+
setCoreConfig({ STRIPE_WEBHOOK_URL: 'https://hooks.example.com/stripe' });
|
|
48
|
+
|
|
49
|
+
const endpoint = await context.run(
|
|
50
|
+
{ publicBaseUrl: 'https://cf-aside.ofind.cn/.well-known/payment' },
|
|
51
|
+
async () => stripeEndpoint()
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(endpoint).toBe('https://hooks.example.com/stripe');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('getPublicAssetUrl', () => {
|
|
59
|
+
afterEach(() => setCoreConfig(undefined));
|
|
60
|
+
|
|
61
|
+
it('prefixes root-relative assets with the embedded payment mount', () => {
|
|
62
|
+
setCoreConfig({ PAYMENT_PUBLIC_BASE_PATH: '/.well-known/payment' });
|
|
63
|
+
expect(getPublicAssetUrl('/methods/arcblock.png')).toBe('/.well-known/payment/methods/arcblock.png');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps external asset URLs unchanged', () => {
|
|
67
|
+
setCoreConfig({ PAYMENT_PUBLIC_BASE_PATH: '/.well-known/payment' });
|
|
68
|
+
expect(getPublicAssetUrl('https://cdn.example.com/arcblock.png')).toBe('https://cdn.example.com/arcblock.png');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not duplicate an existing embedded payment mount', () => {
|
|
72
|
+
setCoreConfig({ PAYMENT_PUBLIC_BASE_PATH: '/.well-known/payment' });
|
|
73
|
+
expect(getPublicAssetUrl('/.well-known/payment/methods/arcblock.png')).toBe(
|
|
74
|
+
'/.well-known/payment/methods/arcblock.png'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
28
79
|
describe('createIdGenerator', () => {
|
|
29
80
|
it('should return a function that generates an ID with the specified prefix and size', () => {
|
|
30
81
|
const generateId = createIdGenerator('test', 10);
|
package/blocklet.yml
CHANGED
package/cloudflare/cf-adapter.ts
CHANGED
|
@@ -47,13 +47,18 @@ import {
|
|
|
47
47
|
createCronRegistry,
|
|
48
48
|
applyPaymentCoreMigrations,
|
|
49
49
|
} from '../api/src/libs/drivers';
|
|
50
|
-
import type { IdentityDriver } from '../api/src/libs/drivers';
|
|
50
|
+
import type { IdentityDriver, InstanceAppIdentity } from '../api/src/libs/drivers';
|
|
51
51
|
import { flushQueueWork, dispatchDueJobs, getQueueHandler } from '../api/src/libs/queue/runtime';
|
|
52
52
|
import { Sequelize } from './shims/sequelize-d1/sequelize-class';
|
|
53
53
|
import { setDB } from './shims/sequelize-d1/model';
|
|
54
54
|
import { withD1Retry } from './shims/sequelize-d1/retry';
|
|
55
55
|
import { cronInstance } from './shims/cron';
|
|
56
56
|
import { createCloudflareDidConnectRuntime, createCloudflareIdentityDriver } from './did-connect-runtime';
|
|
57
|
+
// Runtime-neutral host glue — single source of truth in the core source tree,
|
|
58
|
+
// shared with the arc-node daemon (via the package main entry's `as string`
|
|
59
|
+
// facade). Re-exported below so existing importers (cf-adapter.spec.ts) keep
|
|
60
|
+
// resolving them from this module.
|
|
61
|
+
import { injectCaller, createTenantProvisioner } from '../api/src/host-glue';
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
64
|
* The reserved mount prefix for the embedded payment service. The internal
|
|
@@ -106,6 +111,14 @@ export interface CloudflarePaymentAdapterOptions {
|
|
|
106
111
|
/** Per-tenant EK source (semantics aligned with arc-node: connect-service `app:ek` else derive). */
|
|
107
112
|
identity: {
|
|
108
113
|
getAppEk: (instanceDid: string) => Promise<string> | string;
|
|
114
|
+
/**
|
|
115
|
+
* Per-instance app signing identity (S3-CF). When the host provides it — arc
|
|
116
|
+
* reads connect-service settings `app:sk`/`app:psk`, the SAME authority as
|
|
117
|
+
* getAppEk — it is the authoritative DID-Connect signer source. Optional: when
|
|
118
|
+
* the host doesn't wire it, the adapter falls back to the AUTH_SERVICE-backed
|
|
119
|
+
* CF driver (which fails closed if AUTH_SERVICE is also absent).
|
|
120
|
+
*/
|
|
121
|
+
getInstanceAppIdentity?: (instanceDid: string) => Promise<InstanceAppIdentity> | InstanceAppIdentity;
|
|
109
122
|
};
|
|
110
123
|
/**
|
|
111
124
|
* Host-resolved caller (CF API gate). The adapter injects it as `x-user-did`
|
|
@@ -142,52 +155,12 @@ export interface CloudflarePaymentAdapter {
|
|
|
142
155
|
ensureSchema: () => Promise<string[]>;
|
|
143
156
|
}
|
|
144
157
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* host-resolved caller as canonical headers the core `authenticate()` trusts. No
|
|
152
|
-
* caller → the request runs anonymous (the strip already happened, so it is never
|
|
153
|
-
* forged). Mirrors the standalone worker's identical strip-then-inject.
|
|
154
|
-
*/
|
|
155
|
-
export function injectCaller(headers: Headers, caller: CloudflareCallerIdentity | null): void {
|
|
156
|
-
for (const h of USER_HEADERS) headers.delete(h);
|
|
157
|
-
if (!caller) return;
|
|
158
|
-
const canonicalDid = caller.did?.startsWith('did:abt:') ? caller.did : `did:abt:${caller.did}`;
|
|
159
|
-
headers.set('x-user-did', canonicalDid);
|
|
160
|
-
headers.set('x-user-role', `blocklet-${caller.role || 'guest'}`);
|
|
161
|
-
headers.set('x-user-provider', caller.authMethod === 'access-key' ? 'access-key' : caller.authMethod || 'wallet');
|
|
162
|
-
headers.set('x-user-fullname', encodeURIComponent(caller.displayName || ''));
|
|
163
|
-
headers.set('x-user-wallet-os', '');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Lazy per-tenant provisioning with in-flight dedup. Concurrent first-requests for
|
|
168
|
-
* the same tenant await the SAME provision() call — a Set would let a burst at
|
|
169
|
-
* isolate start double-seed before the DB idempotency guard commits. A resolved
|
|
170
|
-
* promise stays cached so later requests are instant; a FAILED provision is dropped
|
|
171
|
-
* so the next request retries. Null tenant is a no-op. Copied from the node witness
|
|
172
|
-
* (runtimes/node/src/daemon/payment/index.ts:72-105).
|
|
173
|
-
*/
|
|
174
|
-
export function createTenantProvisioner(
|
|
175
|
-
provision: (instanceDid: string) => Promise<void>,
|
|
176
|
-
): (instanceDid: string | null) => Promise<void> {
|
|
177
|
-
const inflight = new Map<string, Promise<void>>();
|
|
178
|
-
return (instanceDid) => {
|
|
179
|
-
if (!instanceDid) return Promise.resolve();
|
|
180
|
-
let p = inflight.get(instanceDid);
|
|
181
|
-
if (!p) {
|
|
182
|
-
p = provision(instanceDid).catch((err) => {
|
|
183
|
-
inflight.delete(instanceDid); // drop so the next request retries
|
|
184
|
-
throw err;
|
|
185
|
-
});
|
|
186
|
-
inflight.set(instanceDid, p);
|
|
187
|
-
}
|
|
188
|
-
return p;
|
|
189
|
-
};
|
|
190
|
-
}
|
|
158
|
+
// `injectCaller` (CF API gate caller header glue) and `createTenantProvisioner`
|
|
159
|
+
// (lazy-provision in-flight dedup) now live in the runtime-neutral
|
|
160
|
+
// `../api/src/host-glue` — a single source shared with the arc-node daemon (via
|
|
161
|
+
// the package main entry). Re-exported here so existing importers keep resolving
|
|
162
|
+
// them from this module.
|
|
163
|
+
export { injectCaller, createTenantProvisioner };
|
|
191
164
|
|
|
192
165
|
/** Dependencies for the request handler (factored out so it is testable with a fake service). */
|
|
193
166
|
export interface FetchDeps {
|
|
@@ -245,7 +218,11 @@ export function buildFetch(deps: FetchDeps) {
|
|
|
245
218
|
if (instanceDid) await warmTenantIdentity(instanceDid);
|
|
246
219
|
return deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
|
|
247
220
|
};
|
|
248
|
-
const
|
|
221
|
+
const requestOrigin = new URL(request.url).origin;
|
|
222
|
+
const publicBaseUrl = new URL(deps.basePath, `${requestOrigin}/`).href.replace(/\/$/, '');
|
|
223
|
+
const res = await requestContext.run({ publicBaseUrl }, () =>
|
|
224
|
+
instanceDid ? requestContext.withTenant(instanceDid, run) : run()
|
|
225
|
+
);
|
|
249
226
|
|
|
250
227
|
await deps.flush(); // drain workerd deferred queue work before responding
|
|
251
228
|
return res;
|
|
@@ -358,7 +335,13 @@ export async function createCloudflarePaymentAdapter(
|
|
|
358
335
|
const identity: IdentityDriver = {
|
|
359
336
|
resolveInstanceDidForHost: () => null,
|
|
360
337
|
getAppEk: (instanceDid: string) => options.identity.getAppEk(instanceDid),
|
|
361
|
-
|
|
338
|
+
// Prefer the host-supplied per-instance identity (arc reads connect-service
|
|
339
|
+
// settings app:sk/psk — same authority as getAppEk); fall back to the
|
|
340
|
+
// AUTH_SERVICE-backed CF driver when the host doesn't wire it (the Phase 6
|
|
341
|
+
// binding). This is what lets arc-embedded CF resolve the DID-Connect signer
|
|
342
|
+
// without an AUTH_SERVICE service binding.
|
|
343
|
+
getInstanceAppIdentity:
|
|
344
|
+
options.identity.getInstanceAppIdentity ?? cfDidConnectIdentity.getInstanceAppIdentity?.bind(cfDidConnectIdentity),
|
|
362
345
|
// per-tenant business wallet (single seam); directory stays the CF alias shim
|
|
363
346
|
getBusinessWallet: cfDidConnectIdentity.getBusinessWallet,
|
|
364
347
|
};
|
|
@@ -366,7 +349,11 @@ export async function createCloudflarePaymentAdapter(
|
|
|
366
349
|
const svc: EmbeddedPaymentService = createEmbeddedPaymentService({
|
|
367
350
|
// Host-global csrf secret via the config boundary (payment-core's csrf reads
|
|
368
351
|
// PAYMENT_CSRF_SECRET through readConfig). NO sessionSecret (decision #5).
|
|
369
|
-
config: {
|
|
352
|
+
config: {
|
|
353
|
+
...(options.config ?? {}),
|
|
354
|
+
PAYMENT_PUBLIC_BASE_PATH: basePath,
|
|
355
|
+
...(options.csrfSecret ? { PAYMENT_CSRF_SECRET: options.csrfSecret } : {}),
|
|
356
|
+
},
|
|
370
357
|
db: { sequelize },
|
|
371
358
|
tenancy: { mode: 'multi' },
|
|
372
359
|
identity,
|
|
@@ -388,6 +375,15 @@ export async function createCloudflarePaymentAdapter(
|
|
|
388
375
|
if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
|
|
389
376
|
(globalThis as any).__flushDeferredTimers();
|
|
390
377
|
}
|
|
378
|
+
// Embedded binding alias: the standalone worker exposes the payment D1 as `DB`,
|
|
379
|
+
// but under arc the binding is `PAYMENT_DB`. Paths that read `__CF_ENV__.DB`
|
|
380
|
+
// directly — notably the DID-Connect token store (did-connect-token-storage.ts),
|
|
381
|
+
// which mints the wallet-pay QR auth token — would otherwise find no `DB` binding
|
|
382
|
+
// and fail token creation, leaving the QR blank. Alias it to the same payment
|
|
383
|
+
// database (where `_did_connect_tokens` lives) so those paths match standalone.
|
|
384
|
+
if (env && !env.DB && env.PAYMENT_DB) {
|
|
385
|
+
env.DB = env.PAYMENT_DB;
|
|
386
|
+
}
|
|
391
387
|
(globalThis as any).__CF_ENV__ = env;
|
|
392
388
|
(globalThis as any).__cfHttpContext__ = httpContext;
|
|
393
389
|
(globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => ctx.waitUntil(p);
|
|
@@ -78,13 +78,51 @@ export function createCloudflareIdentityDriver(): IdentityDriver {
|
|
|
78
78
|
const cached = cache.get(instanceDid);
|
|
79
79
|
if (cached && cached.expires > Date.now()) return cached.value;
|
|
80
80
|
const svc = authService();
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Preferred: the AUTH_SERVICE RPC (multi-tenant / newer blocklet-service resolves
|
|
82
|
+
// the per-instance app:sk). Fall back to the worker's OWN APP_SK/APP_PSK when the
|
|
83
|
+
// binding does not implement the method — which is exactly what blocklet-service's
|
|
84
|
+
// own getInstanceAppIdentity returns for the deployment app (sign.js:
|
|
85
|
+
// appSk = env.BLOCKLET_APP_SK). In single-tenant mode the instance IS the
|
|
86
|
+
// deployment app (instanceDid == APP_PID), so the worker's APP_SK is the correct
|
|
87
|
+
// signer. This makes the standalone worker (and embedded arc CF, which has no
|
|
88
|
+
// getInstanceAppIdentity RPC) self-sufficient for DID-Connect QR signing.
|
|
89
|
+
// Try the AUTH_SERVICE RPC first. NOTE: `typeof svc.getInstanceAppIdentity`
|
|
90
|
+
// is 'function' even on OLD blocklet-service builds (the RPC stub exists) — it
|
|
91
|
+
// only throws "does not implement the method" at CALL time. So we must catch the
|
|
92
|
+
// call itself and fall back, not gate on typeof.
|
|
93
|
+
if (svc && typeof svc.getInstanceAppIdentity === 'function') {
|
|
94
|
+
try {
|
|
95
|
+
const value: InstanceAppIdentity = await svc.getInstanceAppIdentity(instanceDid);
|
|
96
|
+
if (value && value.appSk) {
|
|
97
|
+
cache.set(instanceDid, { value, expires: Date.now() + TTL_MS });
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
// RPC reachable but returned nothing usable → fall through to local APP_SK.
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
// RPC not implemented on this AUTH_SERVICE version (or transport error) →
|
|
103
|
+
// fall through to the local APP_SK identity below.
|
|
104
|
+
console.warn(
|
|
105
|
+
'[did-connect] AUTH_SERVICE.getInstanceAppIdentity failed, falling back to local APP_SK:',
|
|
106
|
+
err?.message || err
|
|
107
|
+
);
|
|
108
|
+
}
|
|
83
109
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
// Fallback: the worker's OWN APP_SK/APP_PSK — exactly what blocklet-service's
|
|
111
|
+
// getInstanceAppIdentity returns for the deployment app (sign.js: appSk =
|
|
112
|
+
// env.BLOCKLET_APP_SK). In single-tenant mode the instance IS the deployment app
|
|
113
|
+
// (instanceDid == APP_PID), so this is the correct DID-Connect signer. Makes the
|
|
114
|
+
// standalone worker + embedded arc CF (no getInstanceAppIdentity RPC) self-sufficient.
|
|
115
|
+
const env = (globalThis as any).__CF_ENV__ || {};
|
|
116
|
+
const appSk: string | undefined = env.APP_SK;
|
|
117
|
+
if (!appSk) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'[did-connect] getInstanceAppIdentity unavailable — AUTH_SERVICE lacks the RPC and no local APP_SK to fall back on — fail-closed'
|
|
120
|
+
);
|
|
87
121
|
}
|
|
122
|
+
const value: InstanceAppIdentity = {
|
|
123
|
+
appSk,
|
|
124
|
+
...(env.APP_PSK ? { appPsk: env.APP_PSK as string } : {}),
|
|
125
|
+
};
|
|
88
126
|
cache.set(instanceDid, { value, expires: Date.now() + TTL_MS });
|
|
89
127
|
return value;
|
|
90
128
|
},
|
|
@@ -280,13 +280,18 @@ const alias = {
|
|
|
280
280
|
'mime-types': s('shims/mime-types.ts'), // 150KB — lightweight shim with common MIME types
|
|
281
281
|
'mime-db': s('shims/noop.ts'),
|
|
282
282
|
ws: s('shims/ws-lite.ts'), // 66KB — native WebSocket wrapper for CF Workers
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
'
|
|
283
|
+
// crypto-js previously shimmed under the assumption "AES legacy not used".
|
|
284
|
+
// FALSE since Phase 11 multi-tenant: KeyringSecretsDriver
|
|
285
|
+
// (api/src/libs/drivers/secrets.ts) uses CryptoJS.AES.encrypt/decrypt for
|
|
286
|
+
// per-tenant PaymentMethod settings encryption — shimmed-to-warn made
|
|
287
|
+
// `CryptoJS.AES` undefined at runtime, and admin-side PaymentMethod
|
|
288
|
+
// create/update crashed with "Cannot read properties of undefined (reading
|
|
289
|
+
// 'encrypt')". The real bundle is ~115 KB (pure JS, Workers compatible);
|
|
290
|
+
// shipping the real module is the only correct fix because the same
|
|
291
|
+
// ciphertext must remain decryptable across node and CF tenants. Sub-path
|
|
292
|
+
// imports are not used by the payment graph, so we don't need to re-alias
|
|
293
|
+
// each `crypto-js/...` entry — they'll just no-op-resolve through the main
|
|
294
|
+
// module if anything ever pulls them.
|
|
290
295
|
|
|
291
296
|
// Force ESM-only to avoid CJS+ESM double-bundling. These packages ship an
|
|
292
297
|
// exports map with separate import/require conditions, so esbuild picks the
|
|
@@ -16,7 +16,8 @@ describe('injectCaller — CF API gate caller header glue (decision #5)', () =>
|
|
|
16
16
|
const h = new Headers();
|
|
17
17
|
injectCaller(h, { did: 'zUSER', role: 'admin', authMethod: 'passkey', displayName: 'Alice' });
|
|
18
18
|
expect(h.get('x-user-did')).toBe('did:abt:zUSER');
|
|
19
|
-
|
|
19
|
+
// raw role — NO `blocklet-` prefix (the core strips it anyway; a prefixed role is wrong)
|
|
20
|
+
expect(h.get('x-user-role')).toBe('admin');
|
|
20
21
|
expect(h.get('x-user-provider')).toBe('passkey');
|
|
21
22
|
expect(h.get('x-user-fullname')).toBe(encodeURIComponent('Alice'));
|
|
22
23
|
});
|
|
@@ -87,7 +88,14 @@ describe('createTenantProvisioner — lazy first-request provisioning, in-flight
|
|
|
87
88
|
// A fake embedded service: captures what the adapter forwards (headers, raw body,
|
|
88
89
|
// basePath) and the tenant context active at fetch time.
|
|
89
90
|
function fakeSvc() {
|
|
90
|
-
const seen: {
|
|
91
|
+
const seen: {
|
|
92
|
+
did?: string;
|
|
93
|
+
role?: string;
|
|
94
|
+
tenant?: string;
|
|
95
|
+
publicBaseUrl?: string;
|
|
96
|
+
basePath?: string;
|
|
97
|
+
body?: string;
|
|
98
|
+
} = {};
|
|
91
99
|
return {
|
|
92
100
|
seen,
|
|
93
101
|
http: {
|
|
@@ -95,6 +103,7 @@ function fakeSvc() {
|
|
|
95
103
|
seen.did = req.headers.get('x-user-did') ?? undefined;
|
|
96
104
|
seen.role = req.headers.get('x-user-role') ?? undefined;
|
|
97
105
|
seen.tenant = requestContext.peekInstanceDid();
|
|
106
|
+
seen.publicBaseUrl = requestContext.peekPublicBaseUrl();
|
|
98
107
|
seen.basePath = opts?.basePath;
|
|
99
108
|
seen.body = await req.text();
|
|
100
109
|
return new Response('ok', { status: 200 });
|
|
@@ -127,6 +136,7 @@ describe('buildFetch — drives the single http.fetch under the tenant context',
|
|
|
127
136
|
expect(res.status).toBe(200);
|
|
128
137
|
expect(svc.seen.did).toBe('did:abt:zREAL'); // forged stripped, real injected
|
|
129
138
|
expect(svc.seen.tenant).toBe('did:abt:zTENANT'); // ran under withTenant
|
|
139
|
+
expect(svc.seen.publicBaseUrl).toBe('https://x/.well-known/payment');
|
|
130
140
|
expect(svc.seen.basePath).toBe('/.well-known/payment');
|
|
131
141
|
expect(svc.seen.body).toBe('raw-bytes'); // data-damage: raw body preserved
|
|
132
142
|
expect(flushed).toBe(1);
|