payment-kit 1.29.8 → 1.29.10
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-node/serve-static-arc.ts +1 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +55 -4
- 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/google-play/handlers/subscription.ts +1 -1
- package/api/src/integrations/google-play/handlers/voided.ts +1 -1
- package/api/src/integrations/stripe/handlers/payment-intent.ts +4 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +1 -1
- package/api/src/integrations/stripe/reconcile-checkout-core.ts +60 -0
- package/api/src/integrations/stripe/reconcile-checkout.ts +40 -0
- 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/entitlement.ts +12 -0
- package/api/src/libs/env.ts +1 -0
- 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/security.ts +47 -2
- package/api/src/middlewares/hono/session.ts +22 -0
- package/api/src/routes/connect/pay.ts +21 -9
- package/api/src/routes/hono/checkout-sessions.ts +71 -32
- 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/src/store/models/subscription.ts +3 -1
- package/api/tests/integrations/app-store/handlers.spec.ts +13 -2
- package/api/tests/integrations/stripe/reconcile-checkout.spec.ts +80 -0
- package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
- package/api/tests/libs/util.spec.ts +51 -0
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +35 -4
- package/cloudflare/did-connect-runtime.ts +43 -5
- package/cloudflare/esbuild-cf-config.cjs +12 -7
- package/cloudflare/tests/cf-adapter.spec.ts +10 -1
- package/cloudflare/worker.ts +41 -1
- package/package.json +9 -9
- package/src/components/layout/user.tsx +7 -1
- package/src/libs/did.ts +27 -0
- package/src/pages/customer/credit-grant/detail.tsx +2 -1
- package/src/pages/customer/credit-transaction/detail.tsx +2 -1
- package/src/pages/customer/invoice/detail.tsx +2 -1
- package/src/pages/customer/recharge/subscription.tsx +2 -1
- package/src/pages/customer/subscription/detail.tsx +2 -1
- package/tests/libs/did.spec.ts +43 -0
- package/vite.arc.config.ts +19 -0
|
@@ -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,7 +47,7 @@ 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';
|
|
@@ -111,6 +111,14 @@ export interface CloudflarePaymentAdapterOptions {
|
|
|
111
111
|
/** Per-tenant EK source (semantics aligned with arc-node: connect-service `app:ek` else derive). */
|
|
112
112
|
identity: {
|
|
113
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;
|
|
114
122
|
};
|
|
115
123
|
/**
|
|
116
124
|
* Host-resolved caller (CF API gate). The adapter injects it as `x-user-did`
|
|
@@ -210,7 +218,11 @@ export function buildFetch(deps: FetchDeps) {
|
|
|
210
218
|
if (instanceDid) await warmTenantIdentity(instanceDid);
|
|
211
219
|
return deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
|
|
212
220
|
};
|
|
213
|
-
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
|
+
);
|
|
214
226
|
|
|
215
227
|
await deps.flush(); // drain workerd deferred queue work before responding
|
|
216
228
|
return res;
|
|
@@ -323,7 +335,13 @@ export async function createCloudflarePaymentAdapter(
|
|
|
323
335
|
const identity: IdentityDriver = {
|
|
324
336
|
resolveInstanceDidForHost: () => null,
|
|
325
337
|
getAppEk: (instanceDid: string) => options.identity.getAppEk(instanceDid),
|
|
326
|
-
|
|
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),
|
|
327
345
|
// per-tenant business wallet (single seam); directory stays the CF alias shim
|
|
328
346
|
getBusinessWallet: cfDidConnectIdentity.getBusinessWallet,
|
|
329
347
|
};
|
|
@@ -331,7 +349,11 @@ export async function createCloudflarePaymentAdapter(
|
|
|
331
349
|
const svc: EmbeddedPaymentService = createEmbeddedPaymentService({
|
|
332
350
|
// Host-global csrf secret via the config boundary (payment-core's csrf reads
|
|
333
351
|
// PAYMENT_CSRF_SECRET through readConfig). NO sessionSecret (decision #5).
|
|
334
|
-
config: {
|
|
352
|
+
config: {
|
|
353
|
+
...(options.config ?? {}),
|
|
354
|
+
PAYMENT_PUBLIC_BASE_PATH: basePath,
|
|
355
|
+
...(options.csrfSecret ? { PAYMENT_CSRF_SECRET: options.csrfSecret } : {}),
|
|
356
|
+
},
|
|
335
357
|
db: { sequelize },
|
|
336
358
|
tenancy: { mode: 'multi' },
|
|
337
359
|
identity,
|
|
@@ -353,6 +375,15 @@ export async function createCloudflarePaymentAdapter(
|
|
|
353
375
|
if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
|
|
354
376
|
(globalThis as any).__flushDeferredTimers();
|
|
355
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
|
+
}
|
|
356
387
|
(globalThis as any).__CF_ENV__ = env;
|
|
357
388
|
(globalThis as any).__cfHttpContext__ = httpContext;
|
|
358
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
|
|
@@ -88,7 +88,14 @@ describe('createTenantProvisioner — lazy first-request provisioning, in-flight
|
|
|
88
88
|
// A fake embedded service: captures what the adapter forwards (headers, raw body,
|
|
89
89
|
// basePath) and the tenant context active at fetch time.
|
|
90
90
|
function fakeSvc() {
|
|
91
|
-
const seen: {
|
|
91
|
+
const seen: {
|
|
92
|
+
did?: string;
|
|
93
|
+
role?: string;
|
|
94
|
+
tenant?: string;
|
|
95
|
+
publicBaseUrl?: string;
|
|
96
|
+
basePath?: string;
|
|
97
|
+
body?: string;
|
|
98
|
+
} = {};
|
|
92
99
|
return {
|
|
93
100
|
seen,
|
|
94
101
|
http: {
|
|
@@ -96,6 +103,7 @@ function fakeSvc() {
|
|
|
96
103
|
seen.did = req.headers.get('x-user-did') ?? undefined;
|
|
97
104
|
seen.role = req.headers.get('x-user-role') ?? undefined;
|
|
98
105
|
seen.tenant = requestContext.peekInstanceDid();
|
|
106
|
+
seen.publicBaseUrl = requestContext.peekPublicBaseUrl();
|
|
99
107
|
seen.basePath = opts?.basePath;
|
|
100
108
|
seen.body = await req.text();
|
|
101
109
|
return new Response('ok', { status: 200 });
|
|
@@ -128,6 +136,7 @@ describe('buildFetch — drives the single http.fetch under the tenant context',
|
|
|
128
136
|
expect(res.status).toBe(200);
|
|
129
137
|
expect(svc.seen.did).toBe('did:abt:zREAL'); // forged stripped, real injected
|
|
130
138
|
expect(svc.seen.tenant).toBe('did:abt:zTENANT'); // ran under withTenant
|
|
139
|
+
expect(svc.seen.publicBaseUrl).toBe('https://x/.well-known/payment');
|
|
131
140
|
expect(svc.seen.basePath).toBe('/.well-known/payment');
|
|
132
141
|
expect(svc.seen.body).toBe('raw-bytes'); // data-damage: raw body preserved
|
|
133
142
|
expect(flushed).toBe(1);
|
package/cloudflare/worker.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { Client as PgClient } from 'pg';
|
|
|
10
10
|
import postgres from 'postgres';
|
|
11
11
|
import { Hono } from 'hono';
|
|
12
12
|
import { cors } from 'hono/cors';
|
|
13
|
+
import { getCookie } from 'hono/cookie';
|
|
14
|
+
// Worker-safe CSRF crypto (the SAME module the csrf middleware verifies with —
|
|
15
|
+
// util/csrf passes through to the real package in the CF bundle, see build.ts).
|
|
16
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
17
|
+
import { sign as signCsrf, getCsrfSecret } from '@blocklet/sdk/lib/util/csrf';
|
|
13
18
|
import { setDB } from './shims/sequelize-d1/model';
|
|
14
19
|
import { initialize } from '../api/src/store/models';
|
|
15
20
|
import { Sequelize } from './shims/sequelize-d1/sequelize-class';
|
|
@@ -386,6 +391,20 @@ function buildApp(env: Env): Hono<HonoEnv> {
|
|
|
386
391
|
// Flag: HTTP request context. createEvent uses waitUntil (non-blocking).
|
|
387
392
|
// In queue consumer/cron, this flag is absent — createEvent uses __cfPendingJobs__ (blocking).
|
|
388
393
|
(globalThis as any).__cfHttpContext__ = true;
|
|
394
|
+
// The @blocklet/sdk CSRF util reads its signing secret DIRECTLY from
|
|
395
|
+
// process.env (`BLOCKLET_APP_ASK || BLOCKLET_APP_SK`), not the config slot. The
|
|
396
|
+
// Phase-12a removal of the per-request process.env mirror left it undefined, so
|
|
397
|
+
// every CSRF-signed response hit `createHmac(<undefined key>)` → 500 (e.g.
|
|
398
|
+
// GET /api/settings). Restore just that one var from the APP_SK binding (the
|
|
399
|
+
// standalone worker's app secret key). Idempotent within the isolate.
|
|
400
|
+
if (c.env.APP_SK && !process.env.BLOCKLET_APP_SK) {
|
|
401
|
+
process.env.BLOCKLET_APP_SK = c.env.APP_SK;
|
|
402
|
+
}
|
|
403
|
+
// Test/staging escape hatch: forward PAYMENT_DISABLE_CSRF to the config boundary
|
|
404
|
+
// so the csrf middleware (readConfig) can honor it. Default-absent → CSRF stays on.
|
|
405
|
+
if ((c.env as any).PAYMENT_DISABLE_CSRF && !process.env.PAYMENT_DISABLE_CSRF) {
|
|
406
|
+
process.env.PAYMENT_DISABLE_CSRF = (c.env as any).PAYMENT_DISABLE_CSRF;
|
|
407
|
+
}
|
|
389
408
|
setDB(withD1Retry(c.env.DB.withSession('first-primary')));
|
|
390
409
|
ensureModelsInit();
|
|
391
410
|
|
|
@@ -496,6 +515,28 @@ function buildApp(env: Env): Hono<HonoEnv> {
|
|
|
496
515
|
// Health check
|
|
497
516
|
app.get('/health', (c) => c.json({ status: 'ok' }));
|
|
498
517
|
|
|
518
|
+
// did/csrfToken — the @blocklet/js-sdk axios adapter fetches its CSRF header from
|
|
519
|
+
// THIS endpoint (getCSRFTokenByLoginToken), not from the cookie. The shared
|
|
520
|
+
// blocklet-service (AUTH_SERVICE) only implements it on newer versions, so the
|
|
521
|
+
// catch-all proxy below 404s on older AUTH_SERVICE — leaving the SDK with no CSRF
|
|
522
|
+
// header and every mutating request 403'ing ("csrf token mismatch"). Sign it
|
|
523
|
+
// LOCALLY with the SAME secret + algorithm the payment csrf middleware verifies
|
|
524
|
+
// with (util/csrf sign over login_token), so token == cookie == what verify()
|
|
525
|
+
// expects. Must be registered BEFORE the /.well-known/service/* catch-all. This
|
|
526
|
+
// also makes the embedded arc-node CF runtime self-sufficient (no AUTH_SERVICE
|
|
527
|
+
// csrfToken endpoint required).
|
|
528
|
+
app.get('/.well-known/service/api/did/csrfToken', (c) => {
|
|
529
|
+
const loginToken = getCookie(c, 'login_token');
|
|
530
|
+
if (!loginToken) return c.json({ loginToken: null, csrfToken: null });
|
|
531
|
+
// Same secret resolution as the csrf middleware's csrfSecret() (PAYMENT_CSRF_SECRET
|
|
532
|
+
// override else the app secret key). Read APP_SK from the binding directly so we
|
|
533
|
+
// do not depend on the process.env.BLOCKLET_APP_SK mirror timing; getCsrfSecret()
|
|
534
|
+
// is the final fallback.
|
|
535
|
+
const secret = (c.env as any).PAYMENT_CSRF_SECRET || c.env.APP_SK || getCsrfSecret();
|
|
536
|
+
if (!secret) return c.json({ loginToken, csrfToken: null });
|
|
537
|
+
return c.json({ loginToken, csrfToken: signCsrf(secret, loginToken) });
|
|
538
|
+
});
|
|
539
|
+
|
|
499
540
|
// user-session under /.well-known/service — proxy to blocklet-service session API
|
|
500
541
|
// Returns full session data including connectedAccounts (needed by @arcblock/ux SessionUser)
|
|
501
542
|
app.get('/.well-known/service/api/user-session', async (c) => {
|
|
@@ -691,7 +732,6 @@ function buildApp(env: Env): Hono<HonoEnv> {
|
|
|
691
732
|
startsWithZ: sk.startsWith('z'),
|
|
692
733
|
});
|
|
693
734
|
});
|
|
694
|
-
|
|
695
735
|
// === DID Auth Login routes ===
|
|
696
736
|
// Only proxy login-related /api/did/* paths to blocklet-service.
|
|
697
737
|
// Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.10",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@abtnode/cron": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
50
50
|
"@arcblock/did": "^1.30.24",
|
|
51
|
-
"@arcblock/did-connect-js": "^4.1.
|
|
51
|
+
"@arcblock/did-connect-js": "^4.1.2",
|
|
52
52
|
"@arcblock/did-connect-react": "^3.5.4",
|
|
53
53
|
"@arcblock/did-connect-storage-nedb": "^1.8.0",
|
|
54
54
|
"@arcblock/did-util": "^1.30.24",
|
|
@@ -60,13 +60,13 @@
|
|
|
60
60
|
"@blocklet/error": "^0.3.5",
|
|
61
61
|
"@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
62
62
|
"@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
63
|
-
"@blocklet/payment-broker-client": "1.29.
|
|
64
|
-
"@blocklet/payment-react": "1.29.
|
|
65
|
-
"@blocklet/payment-vendor": "1.29.
|
|
63
|
+
"@blocklet/payment-broker-client": "1.29.10",
|
|
64
|
+
"@blocklet/payment-react": "1.29.10",
|
|
65
|
+
"@blocklet/payment-vendor": "1.29.10",
|
|
66
66
|
"@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
67
67
|
"@blocklet/ui-react": "^3.5.4",
|
|
68
|
-
"@blocklet/uploader": "^0.
|
|
69
|
-
"@blocklet/xss": "^0.
|
|
68
|
+
"@blocklet/uploader": "^0.15.4",
|
|
69
|
+
"@blocklet/xss": "^0.15.4",
|
|
70
70
|
"@hono/node-server": "2.0.4",
|
|
71
71
|
"@mui/icons-material": "^7.1.2",
|
|
72
72
|
"@mui/lab": "7.0.0-beta.14",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
135
135
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
136
|
-
"@blocklet/payment-types": "1.29.
|
|
136
|
+
"@blocklet/payment-types": "1.29.10",
|
|
137
137
|
"@types/connect": "^3.4.38",
|
|
138
138
|
"@types/debug": "^4.1.12",
|
|
139
139
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -180,5 +180,5 @@
|
|
|
180
180
|
"parser": "typescript"
|
|
181
181
|
}
|
|
182
182
|
},
|
|
183
|
-
"gitHead": "
|
|
183
|
+
"gitHead": "18c831f5dbdfb271a96640befb06f589adbeeab0"
|
|
184
184
|
}
|
|
@@ -34,7 +34,13 @@ export default function UserLayout(props: any) {
|
|
|
34
34
|
return (
|
|
35
35
|
<PaymentProvider session={session} connect={connectApi}>
|
|
36
36
|
<UserCenter
|
|
37
|
-
|
|
37
|
+
// Must match the userCenter nav link declared in blocklet.yml (`link: /customer`).
|
|
38
|
+
// UserCenter resolves both this prop and the nav link via `new URL(x, origin).pathname`,
|
|
39
|
+
// which strips any mount prefix — so the nav tab always resolves to the bare `/customer`.
|
|
40
|
+
// Prefixing here (e.g. `${prefix}customer`) makes the active tab fail to match, which
|
|
41
|
+
// triggers UserCenter's auto-redirect to the bare nav link and 404s the embedded mount
|
|
42
|
+
// (#1394). The bare path also works in the non-embedded form, where prefix is `/`.
|
|
43
|
+
currentTab="/customer"
|
|
38
44
|
hideFooter
|
|
39
45
|
embed={embed === '1'}
|
|
40
46
|
notLoginContent="undefined">
|
package/src/libs/did.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// DID comparison helpers shared by the customer portal pages.
|
|
2
|
+
//
|
|
3
|
+
// A single ArcBlock identity shows up in two string shapes across the stack:
|
|
4
|
+
// - bare base58 address (e.g. `z1xxx`)
|
|
5
|
+
// - canonical URI form (e.g. `did:abt:z1xxx`)
|
|
6
|
+
//
|
|
7
|
+
// On CF Workers the host AUTH_SERVICE hands the frontend a bare address while
|
|
8
|
+
// the backend canonicalizes login-token DIDs to the `did:abt:` form before it
|
|
9
|
+
// stores `customer.did` (see api/src/middlewares/hono/security.ts and
|
|
10
|
+
// api/src/routes/hono/entitlements.ts `canonicalDid`/`isSelf`). A raw `!==`
|
|
11
|
+
// between `session.user.did` and `customer.did` therefore flags the very owner
|
|
12
|
+
// of the record as "another customer". Normalize both sides before comparing.
|
|
13
|
+
|
|
14
|
+
const DID_PREFIX = 'did:abt:';
|
|
15
|
+
|
|
16
|
+
export function canonicalizeDid(did: string | undefined | null): string {
|
|
17
|
+
if (!did) return '';
|
|
18
|
+
return did.startsWith(DID_PREFIX) ? did.slice(DID_PREFIX.length) : did;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// True when both DIDs resolve to the same identity, ignoring the `did:abt:`
|
|
22
|
+
// prefix difference. Empty/missing inputs are never considered a match.
|
|
23
|
+
export function isSameDid(a: string | undefined | null, b: string | undefined | null): boolean {
|
|
24
|
+
const x = canonicalizeDid(a);
|
|
25
|
+
const y = canonicalizeDid(b);
|
|
26
|
+
return !!x && x === y;
|
|
27
|
+
}
|
|
@@ -24,6 +24,7 @@ import CreditGrantItemList from '../../../components/customer/credit-grant-item-
|
|
|
24
24
|
import InfoRow from '../../../components/info-row';
|
|
25
25
|
import InfoRowGroup from '../../../components/info-row-group';
|
|
26
26
|
import { useArcsphere } from '../../../hooks/browser';
|
|
27
|
+
import { isSameDid } from '../../../libs/did';
|
|
27
28
|
|
|
28
29
|
const fetchData = (id: string | undefined): Promise<TCreditGrantExpanded> => {
|
|
29
30
|
return api.get(`/api/credit-grants/${id}`).then((res: any) => res.data);
|
|
@@ -41,7 +42,7 @@ export default function CustomerCreditGrantDetail() {
|
|
|
41
42
|
navigate('/customer', { replace: true });
|
|
42
43
|
}, [navigate]);
|
|
43
44
|
|
|
44
|
-
if (data?.customer?.did && session?.user?.did && data.customer.did
|
|
45
|
+
if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
|
|
45
46
|
return <Alert severity="error">{t('common.accessDenied')}</Alert>;
|
|
46
47
|
}
|
|
47
48
|
if (error) {
|
|
@@ -21,6 +21,7 @@ import SectionHeader from '../../../components/section/header';
|
|
|
21
21
|
import InfoRow from '../../../components/info-row';
|
|
22
22
|
import InfoRowGroup from '../../../components/info-row-group';
|
|
23
23
|
import { useArcsphere } from '../../../hooks/browser';
|
|
24
|
+
import { isSameDid } from '../../../libs/did';
|
|
24
25
|
|
|
25
26
|
const fetchData = (id: string | undefined): Promise<TCreditTransactionExpanded> => {
|
|
26
27
|
return api.get(`/api/credit-transactions/${id}`).then((res: any) => res.data);
|
|
@@ -38,7 +39,7 @@ export default function CustomerCreditTransactionDetail() {
|
|
|
38
39
|
navigate('/customer', { replace: true });
|
|
39
40
|
}, [navigate]);
|
|
40
41
|
|
|
41
|
-
if (data?.customer?.did && session?.user?.did && data.customer.did
|
|
42
|
+
if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
|
|
42
43
|
return <Alert severity="error">{t('common.accessDenied')}</Alert>;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -34,6 +34,7 @@ import InfoRow from '../../../components/info-row';
|
|
|
34
34
|
import { Download } from '../../../components/invoice-pdf/pdf';
|
|
35
35
|
import InvoiceTable from '../../../components/invoice/table';
|
|
36
36
|
import { goBackOrFallback } from '../../../libs/util';
|
|
37
|
+
import { isSameDid } from '../../../libs/did';
|
|
37
38
|
import CustomerRefundList from '../refund/list';
|
|
38
39
|
import InfoMetric from '../../../components/info-metric';
|
|
39
40
|
import InfoRowGroup from '../../../components/info-row-group';
|
|
@@ -121,7 +122,7 @@ export default function CustomerInvoiceDetail() {
|
|
|
121
122
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
123
|
}, [error]);
|
|
123
124
|
|
|
124
|
-
if (data?.customer?.did && session?.user?.did && data.customer.did
|
|
125
|
+
if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
|
|
125
126
|
return <Alert severity="error">{t('common.accessDenied')}</Alert>;
|
|
126
127
|
}
|
|
127
128
|
|
|
@@ -36,6 +36,7 @@ import InfoRow from '../../../components/info-row';
|
|
|
36
36
|
import Currency from '../../../components/currency';
|
|
37
37
|
import SubscriptionMetrics from '../../../components/subscription/metrics';
|
|
38
38
|
import { goBackOrFallback, MAX_SAFE_AMOUNT } from '../../../libs/util';
|
|
39
|
+
import { isSameDid } from '../../../libs/did';
|
|
39
40
|
import CustomerLink from '../../../components/customer/link';
|
|
40
41
|
import { useSessionContext } from '../../../contexts/session';
|
|
41
42
|
import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
|
|
@@ -227,7 +228,7 @@ export default function RechargePage() {
|
|
|
227
228
|
return <Alert severity="info">{t('common.dataNotFound')}</Alert>;
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
if (subscription?.customer?.did && session?.user?.did && subscription.customer.did
|
|
231
|
+
if (subscription?.customer?.did && session?.user?.did && !isSameDid(subscription.customer.did, session.user.did)) {
|
|
231
232
|
return <Alert severity="error">{t('common.accessDenied')}</Alert>;
|
|
232
233
|
}
|
|
233
234
|
const currentBalance = formatBNStr(payerValue?.token || '0', paymentCurrency?.decimal, 6, false);
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
} from '../../../hooks/subscription';
|
|
68
68
|
import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
|
|
69
69
|
import { canChangePaymentMethod } from '../../../libs/util';
|
|
70
|
+
import { isSameDid } from '../../../libs/did';
|
|
70
71
|
import PaymentMethodInfo from '../../../components/subscription/payment-method-info';
|
|
71
72
|
import { useArcsphere } from '../../../hooks/browser';
|
|
72
73
|
|
|
@@ -260,7 +261,7 @@ export default function CustomerSubscriptionDetail() {
|
|
|
260
261
|
}
|
|
261
262
|
};
|
|
262
263
|
|
|
263
|
-
if (data?.customer?.did && session?.user?.did && data.customer.did
|
|
264
|
+
if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
|
|
264
265
|
return <Alert severity="error">{t('common.accessDenied')}</Alert>;
|
|
265
266
|
}
|
|
266
267
|
if (error) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { canonicalizeDid, isSameDid } from '../../src/libs/did';
|
|
2
|
+
|
|
3
|
+
describe('canonicalizeDid', () => {
|
|
4
|
+
it('strips the did:abt: prefix', () => {
|
|
5
|
+
expect(canonicalizeDid('did:abt:z1xyz')).toBe('z1xyz');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('leaves bare addresses untouched', () => {
|
|
9
|
+
expect(canonicalizeDid('z1xyz')).toBe('z1xyz');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns empty string for nullish input', () => {
|
|
13
|
+
expect(canonicalizeDid(undefined)).toBe('');
|
|
14
|
+
expect(canonicalizeDid(null)).toBe('');
|
|
15
|
+
expect(canonicalizeDid('')).toBe('');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('isSameDid', () => {
|
|
20
|
+
// The regression: CF stores customer.did canonical while the host session
|
|
21
|
+
// reports a bare address. The old `data.customer.did !== session.user.did`
|
|
22
|
+
// check wrongly denied the owner. isSameDid must treat these as equal.
|
|
23
|
+
it('matches a canonical DID against the same bare address', () => {
|
|
24
|
+
expect(isSameDid('did:abt:z1xyz', 'z1xyz')).toBe(true);
|
|
25
|
+
expect(isSameDid('z1xyz', 'did:abt:z1xyz')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('matches identical shapes', () => {
|
|
29
|
+
expect(isSameDid('z1xyz', 'z1xyz')).toBe(true);
|
|
30
|
+
expect(isSameDid('did:abt:z1xyz', 'did:abt:z1xyz')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects genuinely different identities', () => {
|
|
34
|
+
expect(isSameDid('did:abt:z1xyz', 'z1abc')).toBe(false);
|
|
35
|
+
expect(isSameDid('z1xyz', 'z1abc')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never matches when either side is missing', () => {
|
|
39
|
+
expect(isSameDid('', 'z1xyz')).toBe(false);
|
|
40
|
+
expect(isSameDid('z1xyz', undefined)).toBe(false);
|
|
41
|
+
expect(isSameDid(null, null)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/vite.arc.config.ts
CHANGED
|
@@ -29,6 +29,25 @@ export default defineConfig({
|
|
|
29
29
|
root: coreDir,
|
|
30
30
|
base: `${UI_PREFIX}/`,
|
|
31
31
|
plugins: [
|
|
32
|
+
// Inject the Buffer polyfill at the top of the entry — the did-connect
|
|
33
|
+
// SessionProvider.refresh path serializes request bodies with Buffer, which is
|
|
34
|
+
// undefined in the browser, so without this the arc-embedded payment SPA throws
|
|
35
|
+
// "ReferenceError: Buffer is not defined" on session refresh and never renders.
|
|
36
|
+
// The standalone cloudflare/vite.config.ts already does this (cf-buffer-polyfill);
|
|
37
|
+
// the arc build was missing it. Reuses the same shim.
|
|
38
|
+
{
|
|
39
|
+
name: 'arc-buffer-polyfill',
|
|
40
|
+
enforce: 'pre' as const,
|
|
41
|
+
transform(code: string, id: string) {
|
|
42
|
+
if (id.endsWith('/src/index.tsx')) {
|
|
43
|
+
const shim = path
|
|
44
|
+
.resolve(coreDir, 'cloudflare/frontend-shims/buffer-polyfill.ts')
|
|
45
|
+
.replace(/\\/g, '/');
|
|
46
|
+
return `import '${shim}';\n` + code;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
},
|
|
50
|
+
},
|
|
32
51
|
tsconfigPaths({ root: coreDir }),
|
|
33
52
|
react(),
|
|
34
53
|
// Build-time window.blocklet bootstrap (P2 helper, single source). The
|