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.
Files changed (32) hide show
  1. package/api/src/host-glue.ts +79 -0
  2. package/api/src/host-node/serve-static-arc.ts +1 -0
  3. package/api/src/integrations/app-store/native-asn1.ts +1 -0
  4. package/api/src/integrations/app-store/signed-data-verifier.ts +6 -1
  5. package/api/src/integrations/stripe/setup.ts +3 -3
  6. package/api/src/libs/arcblock-transfer.ts +27 -0
  7. package/api/src/libs/context.ts +10 -2
  8. package/api/src/libs/did-connect/tenant-identity.ts +19 -1
  9. package/api/src/libs/env.ts +65 -22
  10. package/api/src/libs/notification/index.ts +4 -3
  11. package/api/src/libs/util.ts +31 -2
  12. package/api/src/middlewares/hono/csrf.ts +6 -0
  13. package/api/src/middlewares/hono/session.ts +22 -0
  14. package/api/src/routes/connect/pay.ts +21 -9
  15. package/api/src/routes/hono/credit-grants.ts +1 -1
  16. package/api/src/routes/hono/entitlements.ts +8 -1
  17. package/api/src/routes/hono/payment-methods.ts +6 -2
  18. package/api/src/service.ts +42 -4
  19. package/api/src/store/models/payment-currency.ts +7 -1
  20. package/api/src/store/models/payment-method.ts +30 -6
  21. package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
  22. package/api/tests/libs/notification-guard.spec.ts +25 -8
  23. package/api/tests/libs/util.spec.ts +51 -0
  24. package/blocklet.yml +1 -1
  25. package/cloudflare/cf-adapter.ts +46 -50
  26. package/cloudflare/did-connect-runtime.ts +43 -5
  27. package/cloudflare/esbuild-cf-config.cjs +12 -7
  28. package/cloudflare/tests/cf-adapter.spec.ts +12 -2
  29. package/cloudflare/worker.ts +41 -1
  30. package/package.json +9 -9
  31. package/src/components/layout/user.tsx +7 -1
  32. package/vite.arc.config.ts +19 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Runtime-neutral payment host glue (lives in the canonical core source tree).
3
+ *
4
+ * Zero runtime dependency (only the Web `Headers` global and `Map`), so it is the
5
+ * SINGLE source of truth for the host API gate header glue and the lazy-provision
6
+ * dedup. Two hosts consume it:
7
+ * - the CF adapter (`../../cloudflare/cf-adapter.ts`, sibling import), and
8
+ * - the arc-node daemon, via the published `@arcblock/payment-service` main
9
+ * entry (the barrel re-exposes these through the same `as string` facade it
10
+ * uses for the other node-side helpers).
11
+ * They previously kept hand-mirrored copies that had already drifted (the node
12
+ * host injected a raw role, the CF adapter prefixed it with `blocklet-`); a single
13
+ * source removes that drift class.
14
+ */
15
+
16
+ /** The reserved mount prefix for the embedded payment service. */
17
+ export const PAYMENT_PREFIX = '/.well-known/payment';
18
+
19
+ /**
20
+ * The caller identity the host resolved (host injects it as `x-user-*`; the core
21
+ * never verifies a `login_token` in-process — the host owns caller resolution).
22
+ */
23
+ export interface HostCallerIdentity {
24
+ did: string;
25
+ role?: string;
26
+ authMethod?: string;
27
+ displayName?: string;
28
+ }
29
+
30
+ /** Client-forged identity headers — always stripped before the host injects the real caller. */
31
+ export const USER_HEADERS = ['x-user-did', 'x-user-role', 'x-user-provider', 'x-user-fullname', 'x-user-wallet-os'];
32
+
33
+ /**
34
+ * Host API gate caller header glue (decision #5). STRIP any client-supplied
35
+ * `x-user-*` first (a forged `x-user-did` would be an auth bypass), then inject the
36
+ * host-resolved caller as the canonical headers the core `authenticate()` trusts.
37
+ * No caller → the request runs anonymous (the strip already happened, so it is
38
+ * never forged).
39
+ *
40
+ * `x-user-role` carries the RAW role (e.g. `owner` / `admin`). The core normalizes
41
+ * it as `(role || '').replace('blocklet-', '') || 'guest'`, so the legacy
42
+ * `blocklet-` prefix is deliberately NOT added — it was vestigial (the core strips
43
+ * it either way) and a prefixed role is wrong.
44
+ */
45
+ export function injectCaller(headers: Headers, caller: HostCallerIdentity | null): void {
46
+ for (const h of USER_HEADERS) headers.delete(h);
47
+ if (!caller) return;
48
+ const canonicalDid = caller.did?.startsWith('did:abt:') ? caller.did : `did:abt:${caller.did}`;
49
+ headers.set('x-user-did', canonicalDid);
50
+ headers.set('x-user-role', caller.role || 'guest');
51
+ headers.set('x-user-provider', caller.authMethod === 'access-key' ? 'access-key' : caller.authMethod || 'wallet');
52
+ headers.set('x-user-fullname', encodeURIComponent(caller.displayName || ''));
53
+ headers.set('x-user-wallet-os', '');
54
+ }
55
+
56
+ /**
57
+ * Lazy per-tenant provisioning with in-flight dedup. Concurrent first-requests for
58
+ * the same tenant await the SAME provision() call — a Set would let a burst at
59
+ * isolate/daemon start double-seed before the DB idempotency guard commits. A
60
+ * resolved promise stays cached so later requests are instant; a FAILED provision
61
+ * is dropped so the next request retries. Null tenant is a no-op.
62
+ */
63
+ export function createTenantProvisioner(
64
+ provision: (instanceDid: string) => Promise<void>
65
+ ): (instanceDid: string | null) => Promise<void> {
66
+ const inflight = new Map<string, Promise<void>>();
67
+ return (instanceDid) => {
68
+ if (!instanceDid) return Promise.resolve();
69
+ let p = inflight.get(instanceDid);
70
+ if (!p) {
71
+ p = provision(instanceDid).catch((err) => {
72
+ inflight.delete(instanceDid); // drop so the next request retries
73
+ throw err;
74
+ });
75
+ inflight.set(instanceDid, p);
76
+ }
77
+ return p;
78
+ };
79
+ }
@@ -42,6 +42,7 @@ export function createNodeStaticHandler(webRoot: string): (app: Hono) => void {
42
42
  const { serveStatic } = require('@hono/node-server/serve-static');
43
43
 
44
44
  // SPA fallback FIRST — html navigations (not assets) get index.html VERBATIM.
45
+ // eslint-disable-next-line require-await -- Hono's app.use overload requires a Promise-returning handler
45
46
  app.use('*', async (c, next) => {
46
47
  const method = c.req.method.toUpperCase();
47
48
  if (
@@ -12,6 +12,7 @@
12
12
  // is the chain-to-pinned-Apple-root (native-jws.ts §5.3–5.4). A spoofed cert could
13
13
  // embed these public OID bytes, but it cannot forge the chain signature.
14
14
 
15
+ /* eslint-disable no-bitwise -- DER base-128 OID encoding requires 7-bit-group bitwise ops (ASN.1) */
15
16
  import type { X509Certificate } from 'node:crypto';
16
17
 
17
18
  /**
@@ -43,6 +43,10 @@ export function isSignatureVerificationSkipped(): boolean {
43
43
  return true;
44
44
  }
45
45
 
46
+ /* eslint-disable require-await, @typescript-eslint/no-unused-vars --
47
+ async: the skip-verify branch returns a sync decodeUnsafe<T>(), so async keeps the
48
+ signature Promise<T>; _bundleId/_environment stay in the signature for caller-layer
49
+ enforcement (client.ts) + Apple API parity. */
46
50
  export async function verifySignedTransaction(
47
51
  signedTransaction: string,
48
52
  _bundleId: string,
@@ -69,6 +73,7 @@ export async function verifySignedNotification(
69
73
  }
70
74
  return verifyAppleJws<JwsNotificationPayload>(signedPayload);
71
75
  }
76
+ /* eslint-enable require-await, @typescript-eslint/no-unused-vars */
72
77
 
73
78
  /** Decode-only fallback for when signature verification is intentionally bypassed. */
74
79
  function decodeUnsafe<T>(jws: string): T {
@@ -104,7 +109,7 @@ export type AppStoreApiCredentials = {
104
109
  * StatusResponse, whose `signedTransactionInfo` JWS strings are verified by the
105
110
  * caller (client.ts) with verifyAppleJws.
106
111
  */
107
- export async function getAllSubscriptionStatuses(
112
+ export function getAllSubscriptionStatuses(
108
113
  originalTransactionId: string,
109
114
  creds: AppStoreApiCredentials
110
115
  ): Promise<StatusResponse> {
@@ -4,7 +4,7 @@ import { env } from '@blocklet/sdk/lib/env';
4
4
 
5
5
  import Stripe from 'stripe';
6
6
  import logger from '../../libs/logger';
7
- import { STRIPE_API_VERSION, STRIPE_ENDPOINT, STRIPE_EVENTS } from '../../libs/util';
7
+ import { STRIPE_API_VERSION, stripeEndpoint, STRIPE_EVENTS } from '../../libs/util';
8
8
  import { PaymentMethod } from '../../store/models';
9
9
 
10
10
  // register stripe webhooks on start/create if there are any stripe payment methods
@@ -23,7 +23,7 @@ export async function ensureWebhookRegistered() {
23
23
  const exist = data.find((webhook) => webhook.metadata?.appPid === env.appPid);
24
24
  if (exist) {
25
25
  await stripe.webhookEndpoints.update(exist.id, {
26
- url: STRIPE_ENDPOINT,
26
+ url: stripeEndpoint(),
27
27
  description: env.appName,
28
28
  enabled_events: STRIPE_EVENTS,
29
29
  disabled: false,
@@ -31,7 +31,7 @@ export async function ensureWebhookRegistered() {
31
31
  logger.info('stripe webhook updated');
32
32
  } else {
33
33
  const result = await stripe.webhookEndpoints.create({
34
- url: STRIPE_ENDPOINT,
34
+ url: stripeEndpoint(),
35
35
  description: env.appName,
36
36
  enabled_events: STRIPE_EVENTS,
37
37
  api_version: STRIPE_API_VERSION,
@@ -0,0 +1,27 @@
1
+ import type { Transaction } from '@ocap/client';
2
+ import { encodeTx, fetchContext } from '@ocap/client/encode';
3
+
4
+ type TransferWallet = {
5
+ address: string;
6
+ publicKey?: string;
7
+ pk?: string;
8
+ };
9
+
10
+ export async function encodeArcblockTransfer({
11
+ chainHost,
12
+ tx,
13
+ wallet,
14
+ }: {
15
+ chainHost: string;
16
+ tx: Partial<Transaction>;
17
+ wallet: TransferWallet;
18
+ }) {
19
+ const context = await fetchContext(chainHost);
20
+ return encodeTx({
21
+ type: 'TransferV3Tx',
22
+ tx: tx as any,
23
+ wallet,
24
+ chainId: context.chainId,
25
+ feeConfig: context.txFee,
26
+ });
27
+ }
@@ -14,6 +14,7 @@ interface RequestContext {
14
14
  requestedBy?: string;
15
15
  requestId?: string;
16
16
  instanceDid?: string;
17
+ publicBaseUrl?: string;
17
18
  }
18
19
 
19
20
  class RequestContextManager {
@@ -74,6 +75,11 @@ class RequestContextManager {
74
75
  return this.storage.getStore()?.instanceDid;
75
76
  }
76
77
 
78
+ /** Request-scoped externally reachable payment base URL, when supplied by an embedded host. */
79
+ peekPublicBaseUrl(): string | undefined {
80
+ return this.storage.getStore()?.publicBaseUrl;
81
+ }
82
+
77
83
  /**
78
84
  * Run fn as a system operation: TenantModel scoping is bypassed for the span
79
85
  * of fn so legitimate cross-tenant reads can load rows regardless of tenant.
@@ -92,17 +98,19 @@ class RequestContextManager {
92
98
 
93
99
  run<T>(context: RequestContext, fn: () => Promise<T> | T): Promise<T> {
94
100
  const requestId = context.requestId || `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
95
- // inherit tenant from the enclosing scope unless explicitly provided
101
+ // inherit host-established request fields unless explicitly provided
96
102
  const instanceDid = context.instanceDid ?? this.storage.getStore()?.instanceDid;
103
+ const publicBaseUrl = context.publicBaseUrl ?? this.storage.getStore()?.publicBaseUrl;
97
104
 
98
105
  this.contexts.set(requestId, {
99
106
  ...context,
100
107
  instanceDid,
108
+ publicBaseUrl,
101
109
  requestId,
102
110
  });
103
111
 
104
112
  return new Promise((resolve, reject) => {
105
- this.storage.run({ ...context, instanceDid, requestId }, async () => {
113
+ this.storage.run({ ...context, instanceDid, publicBaseUrl, requestId }, async () => {
106
114
  const resource = new AsyncResource('RequestContext');
107
115
  try {
108
116
  const result = await resource.runInAsyncScope(fn);
@@ -22,6 +22,7 @@ import type { WalletObject } from '@ocap/wallet';
22
22
  import { getInstanceDid } from '../context';
23
23
  import { getIdentityDriver, type InstanceAppInfo, type BlockletDirectory } from '../drivers';
24
24
  import logger from '../logger';
25
+ import { warmupSecrets } from '../secrets';
25
26
 
26
27
  const walletType = {
27
28
  role: Mcrypto.types.RoleType.ROLE_APPLICATION,
@@ -165,10 +166,27 @@ export function getCachedTenantIdentity(instanceDidArg?: string): ResolvedTenant
165
166
  */
166
167
  export async function warmTenantIdentity(instanceDidArg?: string): Promise<void> {
167
168
  if (!hasDynamicIdentity()) return;
169
+ // The wallet signing identity (getInstanceAppIdentity) and the secrets keyring EK
170
+ // (getAppEk) are INDEPENDENT host concerns. Warm them in SEPARATE try blocks: a
171
+ // wallet-identity failure (e.g. an AUTH_SERVICE that does not implement
172
+ // getInstanceAppIdentity) must NOT also skip the secrets warm. Coupling them left
173
+ // the keyring cold whenever wallet identity was unavailable, so every PaymentMethod
174
+ // settings/Stripe decrypt 500'd on an undefined crypto key — even though the
175
+ // Stripe/USD path never needs the wallet identity.
168
176
  try {
169
177
  await resolveTenantIdentity(instanceDidArg);
170
178
  } catch (err: unknown) {
171
- logger.warn('[tenant-identity] warm failed — wallet access will fail-closed', {
179
+ logger.warn('[tenant-identity] wallet identity warm failed — wallet access will fail-closed', {
180
+ error: err instanceof Error ? err.message : String(err),
181
+ });
182
+ }
183
+ // The keyring secrets driver warms the tenant EK so PaymentMethod settings
184
+ // encrypt/decrypt + getStripeClient resolve on the sync hot path (else "secrets:
185
+ // key for tenant <did> is not warmed"). No-op for the default single-key driver.
186
+ try {
187
+ await warmupSecrets(instanceDidArg);
188
+ } catch (err: unknown) {
189
+ logger.warn('[tenant-identity] secrets warm failed — settings/Stripe decrypt will fail-closed', {
172
190
  error: err instanceof Error ? err.message : String(err),
173
191
  });
174
192
  }
@@ -57,6 +57,31 @@ const numConfig = (key: string, fallback: number): number => {
57
57
  return v ? +v : fallback;
58
58
  };
59
59
 
60
+ // Unified boolean env parsing. Accepts 1/true/yes/on (truthy) and 0/false/no/off
61
+ // (falsy), case-insensitive; unset / empty / unrecognized → `fallback`. Replaces
62
+ // the ad-hoc `=== '1'` / `=== 'true'` / `!== 'false'` conventions that used to be
63
+ // scattered across these accessors, so operators no longer have to guess whether a
64
+ // flag wants `1` or `true`.
65
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
66
+ const FALSY = new Set(['0', 'false', 'no', 'off']);
67
+ const parseBool = (value: string | undefined, fallback = false): boolean => {
68
+ if (value === undefined || value === '') return fallback;
69
+ const v = value.trim().toLowerCase();
70
+ if (TRUTHY.has(v)) return true;
71
+ if (FALSY.has(v)) return false;
72
+ return fallback;
73
+ };
74
+
75
+ // First non-empty value among keys — used to honor a renamed key while still
76
+ // reading the deprecated alias (new name wins, old name kept as fallback).
77
+ const readConfigAny = (...keys: string[]): string | undefined => {
78
+ for (const key of keys) {
79
+ const v = readConfig(key);
80
+ if (v !== undefined && v !== '') return v;
81
+ }
82
+ return undefined;
83
+ };
84
+
60
85
  export const paymentStatCronTime = (): string => '0 1 0 * * *'; // 默认每天一次,计算前一天的
61
86
  export const subscriptionCronTime = (): string => readConfig('SUBSCRIPTION_CRON_TIME') || '0 */30 * * * *';
62
87
  export const notificationCronTime = (): string => readConfig('NOTIFICATION_CRON_TIME') || '0 5 */6 * * *';
@@ -67,7 +92,6 @@ export const stripeInvoiceCronTime = (): string => readConfig('STRIPE_INVOICE_CR
67
92
  export const stripePaymentCronTime = (): string => readConfig('STRIPE_PAYMENT_CRON_TIME') || '0 */20 * * * *';
68
93
  export const stripeSubscriptionCronTime = (): string => readConfig('STRIPE_SUBSCRIPTION_CRON_TIME') || '0 10 */8 * * *';
69
94
  export const revokeStakeCronTime = (): string => readConfig('REVOKE_STAKE_CRON_TIME') || '0 */5 * * * *';
70
- export const daysUntilCancel = (): string | undefined => readConfig('DAYS_UNTIL_CANCEL');
71
95
  export const meteringSubscriptionDetectionCronTime = (): string =>
72
96
  readConfig('METERING_SUBSCRIPTION_DETECTION_CRON_TIME') || '0 0 10 * * *';
73
97
  export const overdueDetectionCronTime = (): string => readConfig('OVERDUE_DETECTION_CRON_TIME') || '0 0 10 * * *';
@@ -93,20 +117,26 @@ export const sequelizeOptionsPoolIdle = (): number => numConfig('SEQUELIZE_OPTIO
93
117
 
94
118
  export const updateDataConcurrency = (): number => numConfig('UPDATE_DATA_CONCURRENCY', 5);
95
119
 
96
- // When set to 'true' or '1', the system stops accepting new orders.
97
- // Existing checkout sessions can still be viewed but new submissions will be rejected.
120
+ // When truthy, the system stops accepting new orders. Existing checkout sessions
121
+ // can still be viewed but new submissions will be rejected.
122
+ // Renamed PAYMENT_KIT_STOP_ACCEPTING_ORDERS → PAYMENT_STOP_ACCEPTING_ORDERS; the
123
+ // old name is still honored as a deprecated alias.
98
124
  export const stopAcceptingOrders = (): boolean =>
99
- readConfig('PAYMENT_KIT_STOP_ACCEPTING_ORDERS') === 'true' || readConfig('PAYMENT_KIT_STOP_ACCEPTING_ORDERS') === '1';
125
+ parseBool(readConfigAny('PAYMENT_STOP_ACCEPTING_ORDERS', 'PAYMENT_KIT_STOP_ACCEPTING_ORDERS'));
100
126
 
101
127
  export const exchangeRateCacheTTLSeconds = (): number => numConfig('EXCHANGE_RATE_CACHE_TTL_SECONDS', 10 * 60);
102
128
 
103
- // System-level maximum pending amount limit (in token format, e.g., "10")
104
- // Default is 0 (disabled). Set PAYMENT_KIT_MAX_PENDING_AMOUNT to enable this limit.
105
- export const systemMaxPendingAmount = (): number => numConfig('PAYMENT_KIT_MAX_PENDING_AMOUNT', 5);
129
+ // System-level maximum pending amount limit (in token format, e.g., "10"). Default 5.
130
+ // Renamed PAYMENT_KIT_MAX_PENDING_AMOUNT PAYMENT_MAX_PENDING_AMOUNT; the old name
131
+ // is still honored as a deprecated alias.
132
+ export const systemMaxPendingAmount = (): number => {
133
+ const v = readConfigAny('PAYMENT_MAX_PENDING_AMOUNT', 'PAYMENT_KIT_MAX_PENDING_AMOUNT');
134
+ return v ? +v : 5;
135
+ };
106
136
 
107
137
  // Whether a locked price may still be edited. Lazy (reads injected config via
108
138
  // the boundary at call time) like every other Phase 8 accessor.
109
- export const allowChangeLockedPrice = (): boolean => readConfig('PAYMENT_CHANGE_LOCKED_PRICE') === '1';
139
+ export const allowChangeLockedPrice = (): boolean => parseBool(readConfig('PAYMENT_CHANGE_LOCKED_PRICE'));
110
140
 
111
141
  // ──────────────────────────────────────────────────────────────────────────
112
142
  // Phase 8 (W2′): converged accessors. Every read below used to live inline in
@@ -122,7 +152,7 @@ export const isProduction = (): boolean => blockletMode() === 'production';
122
152
  export const nodeEnv = (): string | undefined => readConfig('NODE_ENV');
123
153
  export const isTestEnv = (): boolean => nodeEnv() === 'test';
124
154
  export const isDevelopmentEnv = (): boolean => nodeEnv() === 'development';
125
- export const enableDevFakeAuth = (): boolean => readConfig('ENABLE_DEV_FAKE_AUTH') === '1';
155
+ export const enableDevFakeAuth = (): boolean => parseBool(readConfig('ENABLE_DEV_FAKE_AUTH'));
126
156
 
127
157
  // -- tenant mode-source (getTenantMode / getDefaultInstanceDid read these) --
128
158
  export const tenantModeRaw = (): string | undefined => readConfig('PAYMENT_TENANT_MODE');
@@ -138,16 +168,17 @@ export const blockletPort = (): string | undefined => readConfig('BLOCKLET_PORT'
138
168
  export const blockletMountPoints = (): string | undefined => readConfig('BLOCKLET_MOUNT_POINTS');
139
169
 
140
170
  // -- integrations --
141
- export const appStoreWriteEnabled = (): boolean => readConfig('APP_STORE_WRITE_ENABLED') === 'true';
142
- export const appStoreSkipSignatureVerify = (): boolean => readConfig('APP_STORE_SKIP_SIGNATURE_VERIFY') === 'true';
171
+ export const appStoreWriteEnabled = (): boolean => parseBool(readConfig('APP_STORE_WRITE_ENABLED'));
172
+ export const appStoreSkipSignatureVerify = (): boolean => parseBool(readConfig('APP_STORE_SKIP_SIGNATURE_VERIFY'));
143
173
  export const googlePubsubSkipSignatureVerify = (): boolean =>
144
- readConfig('GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY') === 'true';
174
+ parseBool(readConfig('GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY'));
145
175
  export const googlePubsubPushServiceAccount = (): string | undefined =>
146
176
  readConfig('GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT');
147
177
  export const googlePubsubAllowUnverifiedSender = (): boolean =>
148
- readConfig('GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER') === 'true';
178
+ parseBool(readConfig('GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER'));
149
179
  export const googlePlayWebhookUrl = (): string | undefined => readConfig('GOOGLE_PLAY_WEBHOOK_URL');
150
180
  export const stripeWebhookSecret = (): string | undefined => readConfig('STRIPE_WEBHOOK_SECRET');
181
+ export const stripeWebhookUrl = (): string | undefined => readConfig('STRIPE_WEBHOOK_URL');
151
182
  export const iapReconcileBatchSize = (): number => Number(readConfig('IAP_RECONCILE_BATCH_SIZE') ?? '100');
152
183
 
153
184
  // -- payment params --
@@ -155,9 +186,12 @@ export const paymentBillingThreshold = (): number => +(readConfig('PAYMENT_BILLI
155
186
  export const paymentMinStakeAmount = (): number => +(readConfig('PAYMENT_MIN_STAKE_AMOUNT') as string);
156
187
  export const paymentDaysUntilDue = (): string | undefined => readConfig('PAYMENT_DAYS_UNTIL_DUE');
157
188
  export const paymentDaysUntilCancel = (): string | undefined => readConfig('PAYMENT_DAYS_UNTIL_CANCEL');
158
- export const paymentReloadSubscriptionJobs = (): boolean => readConfig('PAYMENT_RELOAD_SUBSCRIPTION_JOBS') === '1';
189
+ export const paymentReloadSubscriptionJobs = (): boolean => parseBool(readConfig('PAYMENT_RELOAD_SUBSCRIPTION_JOBS'));
159
190
  export const paymentRateVolatilityThreshold = (): string | undefined => readConfig('PAYMENT_RATE_VOLATILITY_THRESHOLD');
160
- export const paymentLivemode = (): boolean => readConfig('PAYMENT_LIVEMODE') !== 'false';
191
+ // Defaults to livemode (true) when unset. NOTE: with the unified parser, an
192
+ // explicit falsy value (false/0/no/off) now means testmode — previously ONLY the
193
+ // literal string 'false' did, so PAYMENT_LIVEMODE=0 used to (mis)read as livemode.
194
+ export const paymentLivemode = (): boolean => parseBool(readConfig('PAYMENT_LIVEMODE'), true);
161
195
 
162
196
  // -- credit queue --
163
197
  export const creditLowBalanceThresholdPercentage = (): number =>
@@ -172,8 +206,8 @@ export const creditQueueConcurrency = (): number =>
172
206
  export const exchangeRateCacheTTLFromEnv = (): boolean => hasConfig('EXCHANGE_RATE_CACHE_TTL_SECONDS');
173
207
 
174
208
  // -- store / sequelize logging --
175
- export const sqlLog = (): boolean => readConfig('SQL_LOG') === '1';
176
- export const sqlBenchmark = (): boolean => readConfig('SQL_BENCHMARK') === '1';
209
+ export const sqlLog = (): boolean => parseBool(readConfig('SQL_LOG'));
210
+ export const sqlBenchmark = (): boolean => parseBool(readConfig('SQL_BENCHMARK'));
177
211
 
178
212
  // -- CF worker runtime detection + env mirror (set by cloudflare/worker.ts on
179
213
  // globalThis; the boundary so core never reads the global directly) --
@@ -184,17 +218,26 @@ export const isCfWorker = (): boolean => !!cfEnv();
184
218
  * THE single, reusable gate for Blocklet-Server-ONLY features.
185
219
  *
186
220
  * True only under Blocklet Server, where the full @blocklet/sdk host runtime is
187
- * present — BLOCKLET_APP_ID plus the rest of the env the SDK's
188
- * `checkBlockletEnvironment` requires (BLOCKLET_DID / BLOCKLET_APP_EK / ABT_NODE_*).
189
- * BLOCKLET_APP_ID is the canonical marker; arc-node and the CF worker run the
190
- * embedded core with NONE of it.
221
+ * present — the env the SDK's `checkBlockletEnvironment` requires (BLOCKLET_APP_ID /
222
+ * BLOCKLET_DID / BLOCKLET_APP_EK / ABT_NODE_*).
223
+ *
224
+ * Keyed on BLOCKLET_DID, NOT BLOCKLET_APP_ID: the CF worker injects
225
+ * BLOCKLET_APP_ID = APP_PID into the config slot (cloudflare/worker.ts
226
+ * envToPaymentCoreConfig — blockletAppId() legitimately needs it for app identity
227
+ * in auth.ts / util.ts), which made the old BLOCKLET_APP_ID check spuriously true
228
+ * on CF and defeated the transport guard below. BLOCKLET_DID is supplied only by
229
+ * the real Blocklet Server runtime — the CF worker uses APP_PID for the node DID
230
+ * and never sets BLOCKLET_DID in config, and arc-node never injects it — so it
231
+ * cleanly distinguishes the three hosts. (BLOCKLET_APP_EK would also be BS-only in
232
+ * production, but the test harness sets it for the crypto shim, so DID is the
233
+ * unambiguous discriminator.)
191
234
  *
192
235
  * Any integration that only works on Blocklet Server (the @blocklet/sdk
193
236
  * notification / eventbus / relay transports, and any future host-service feature)
194
237
  * MUST gate on this and skip itself off Blocklet Server — never branch on
195
238
  * isCfWorker()/hasDynamicIdentity() ad hoc, so "what is BS-only" stays in one place.
196
239
  */
197
- export const isBlockletServer = (): boolean => hasConfig('BLOCKLET_APP_ID');
240
+ export const isBlockletServer = (): boolean => hasConfig('BLOCKLET_DID');
198
241
 
199
242
  export default {
200
243
  ...env,
@@ -12,10 +12,11 @@ let blockletSdkTransportGuarded = false;
12
12
 
13
13
  /** Replace `fns` with no-ops on both the module namespace and its `default` export. */
14
14
  function noopSdkTransport(mod: any, fns: string[]): void {
15
- const noop = async (): Promise<undefined> => undefined;
15
+ const noop = (): Promise<undefined> => Promise.resolve(undefined);
16
16
  for (const target of [mod, mod?.default]) {
17
- if (!target) continue;
18
- for (const fn of fns) target[fn] = noop;
17
+ if (target) {
18
+ for (const fn of fns) target[fn] = noop;
19
+ }
19
20
  }
20
21
  }
21
22
 
@@ -13,7 +13,16 @@ import axios from 'axios';
13
13
  import { fromUnitToToken } from '@ocap/util';
14
14
  import get from 'lodash/get';
15
15
  import trimEnd from 'lodash/trimEnd';
16
- import { googlePlayWebhookUrl, blockletAppUrl, blockletMountPoints, blockletAppId, blockletAppName } from './env';
16
+ import {
17
+ googlePlayWebhookUrl,
18
+ stripeWebhookUrl,
19
+ blockletAppUrl,
20
+ blockletMountPoints,
21
+ blockletAppId,
22
+ blockletAppName,
23
+ readConfig,
24
+ } from './env';
25
+ import { context } from './context';
17
26
  import dayjs from './dayjs';
18
27
  import { blocklet, wallet } from './auth';
19
28
  import type { PaymentCurrency, PaymentMethod, Subscription } from '../store/models';
@@ -29,7 +38,19 @@ export const MIN_RETRY_MAIL = 13; // total retry time before sending first mail:
29
38
  export const CHECKOUT_SESSION_TTL = 6 * 60 * 60; // expires in 6 hours, then removed after 12 hours
30
39
 
31
40
  export const STRIPE_API_VERSION = '2023-08-16';
32
- export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
41
+ // Stripe webhook must be an ABSOLUTE https URL. In the embedded (arc-node) runtime
42
+ // getUrl() returns a relative path (no host) which Stripe rejects — registration
43
+ // then fails (and is currently swallowed) so payments never reconcile (session
44
+ // stuck unpaid). Mirror googlePlayEndpoint: prefer the STRIPE_WEBHOOK_URL override,
45
+ // fall back to getUrl. Lazy-eval (function) because dotenv loads env AFTER this
46
+ // module is imported — a module-load constant would only ever see the relative path.
47
+ export const stripeEndpoint = (): string => {
48
+ const override = stripeWebhookUrl();
49
+ if (override) return override;
50
+ const publicBaseUrl = context.peekPublicBaseUrl();
51
+ if (publicBaseUrl) return joinURL(publicBaseUrl, '/api/integrations/stripe/webhook');
52
+ return getUrl('/api/integrations/stripe/webhook');
53
+ };
33
54
  // Pub/Sub OIDC tokens carry the push-subscription endpoint as `aud` claim. When
34
55
  // the dev server is reached via a custom domain (e.g. a Cloudflare tunnel), the
35
56
  // BLOCKLET_APP_URL-derived default won't match the URL Google signs against.
@@ -95,6 +116,14 @@ export const api = axios.create({
95
116
  timeout: 10 * 1000,
96
117
  });
97
118
 
119
+ export function getPublicAssetUrl(assetUrl: string): string {
120
+ if (!assetUrl || !assetUrl.startsWith('/') || assetUrl.startsWith('//')) return assetUrl;
121
+ const basePath = readConfig('PAYMENT_PUBLIC_BASE_PATH');
122
+ if (!basePath) return getUrl(assetUrl);
123
+ if (assetUrl === basePath || assetUrl.startsWith(`${basePath}/`)) return assetUrl;
124
+ return joinURL(basePath, assetUrl);
125
+ }
126
+
98
127
  export function md5(input: string) {
99
128
  return crypto.createHash('md5').update(input).digest('hex');
100
129
  }
@@ -48,6 +48,12 @@ export function csrf(): MiddlewareHandler {
48
48
  // type to the MiddlewareHandler contract.
49
49
  // eslint-disable-next-line require-await
50
50
  return async (c, next) => {
51
+ // Escape hatch (TEST / STAGING ONLY): PAYMENT_DISABLE_CSRF=true bypasses CSRF
52
+ // verification entirely. This removes cross-site request forgery protection on
53
+ // ALL mutating payment routes — NEVER set it in production. Defaults off, so the
54
+ // standard secure behavior is unchanged unless a deployment opts out explicitly.
55
+ if (readConfig('PAYMENT_DISABLE_CSRF') === 'true') return next();
56
+
51
57
  const method = c.req.method.toUpperCase();
52
58
  const loginToken = getCookie(c, 'login_token');
53
59
  const existingCsrf = getCookie(c, 'x-csrf-token');
@@ -104,6 +104,28 @@ export function sessionMiddleware(options: SessionOptions = {}): MiddlewareHandl
104
104
  return c.json({ error: err.message }, 401);
105
105
  }
106
106
 
107
+ // Embedded / blocklet-server PRIMARY auth path. The host adapter (arc-node CF
108
+ // gate via cf-adapter.injectCaller, or blocklet-server) verifies the caller
109
+ // upstream, STRIPS any client-forged `x-user-*`, then injects the real caller as
110
+ // `x-user-*` headers — exactly what authenticate() (security.ts) already trusts.
111
+ // sessionMiddleware historically only checked the login_token cookie, so under
112
+ // embedded (where the login_token is arc's, NOT verifiable by payment's
113
+ // @blocklet/sdk verifyLoginToken) session-scoped routes like /customers/me saw
114
+ // no user and 403'd even for a logged-in caller — which left the checkout
115
+ // customer form unpopulated. Honor the injected identity as a fallback.
116
+ if (!result) {
117
+ const injectedDid = c.req.header('x-user-did');
118
+ if (injectedDid) {
119
+ result = {
120
+ did: injectedDid,
121
+ role: c.req.header('x-user-role'),
122
+ provider: c.req.header('x-user-provider'),
123
+ fullName: decodeURIComponent(c.req.header('x-user-fullname') || ''),
124
+ walletOS: c.req.header('x-user-wallet-os') || '',
125
+ };
126
+ }
127
+ }
128
+
107
129
  if (result) {
108
130
  c.set('user', result);
109
131
  }
@@ -5,6 +5,7 @@ import { fromAddress } from '@ocap/wallet';
5
5
  import { encodeTransferItx } from '../../integrations/ethereum/token';
6
6
  import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
7
7
  import { CallbackArgs, ethWallet } from '../../libs/auth';
8
+ import { encodeArcblockTransfer } from '../../libs/arcblock-transfer';
8
9
  import logger from '../../libs/logger';
9
10
  import { parseChainError } from '../../libs/chain-error';
10
11
  import { getGasPayerExtra } from '../../libs/payment';
@@ -127,25 +128,36 @@ export default {
127
128
 
128
129
  if (paymentMethod.type === 'arcblock') {
129
130
  try {
130
- await paymentIntent.update({ status: 'processing' });
131
131
  const client = paymentMethod.getOcapClient();
132
132
  const claim = claims.find((x) => x.type === 'prepareTx');
133
133
 
134
- // Warm up chain context cache to avoid setTimeout(resolve,0) hang in workerd
135
- await client.getContext();
136
-
137
134
  const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
138
135
  if (claim.delegator && claim.from) {
139
136
  tx.delegator = claim.delegator;
140
137
  tx.from = claim.from;
141
138
  }
142
139
 
143
- // @ts-ignore
144
- const { buffer } = await client.encodeTransferV3Tx({ tx });
140
+ const chainHost = paymentMethod.settings.arcblock?.api_host;
141
+ if (!chainHost) {
142
+ throw new Error('ArcBlock payment method API host is missing');
143
+ }
144
+ const signingWallet = fromAddress(userDid);
145
+ const { object: encodedTx, buffer } = await encodeArcblockTransfer({
146
+ chainHost,
147
+ tx,
148
+ wallet: signingWallet,
149
+ });
150
+ const gasPayerExtra = await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client);
151
+
152
+ // Only expose processing after all cold-start preparation has completed.
153
+ // If context fetching/encoding is canceled by workerd, the intent remains
154
+ // retryable instead of being stranded in processing without a tx hash.
155
+ await paymentIntent.update({ status: 'processing' });
145
156
  const txHash = await client.sendTransferV3Tx(
146
- // @ts-ignore
147
- { tx, wallet: fromAddress(userDid) },
148
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
157
+ // Passing the already-encoded signature skips the client's timer-backed
158
+ // encodeTransferV3Tx/getContext path in Cloudflare Workers.
159
+ { tx: encodedTx as any, wallet: signingWallet, signature: encodedTx.signature },
160
+ gasPayerExtra
149
161
  );
150
162
 
151
163
  const quoteValidation = await validateQuoteForPayment({
@@ -394,7 +394,7 @@ app.get('/verify-availability', authMine, async (c) => {
394
394
  const pendingAmountBN = new BN(pendingAmount);
395
395
 
396
396
  // 5. Check system-level maximum pending amount limit (highest priority, cannot be bypassed)
397
- // systemMaxPendingAmount() is configured via PAYMENT_KIT_MAX_PENDING_AMOUNT (in token format)
397
+ // systemMaxPendingAmount() is configured via PAYMENT_MAX_PENDING_AMOUNT (in token format)
398
398
  if (systemMaxPendingAmount() > 0 && pendingAmountBN.gt(new BN(0))) {
399
399
  const systemMaxPendingAmountBN = fromTokenToUnit(
400
400
  trimDecimals(String(systemMaxPendingAmount()), currency.decimal),
@@ -26,7 +26,14 @@ import { PaymentMethod } from '../../store/models';
26
26
  const app = new Hono();
27
27
  // component+owner/admin for cross-blocklet calls; ensureLogin for end users —
28
28
  // handler then enforces that non-admin users can only query their own DID.
29
- const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
29
+ // Any signed-in user can query their OWN entitlements (the handler bodies
30
+ // below already gate cross-customer reads via isSelf(...) + isAdminUser).
31
+ // A roles-list here would block normal wallet users at the middleware (their
32
+ // role is "user", not owner/admin) before isSelf ever runs, so the IAP
33
+ // "Subscribe + check my own entitlement" loop returned 403 for every real
34
+ // end user. Keep `component: true` so server-to-server signed calls bypass
35
+ // auth as before; keep `ensureLogin` so unauthenticated requests still 401.
36
+ const auth = authenticate<PaymentMethod>({ component: true, ensureLogin: true });
30
37
 
31
38
  function isAdminUser(role?: string): boolean {
32
39
  return role === 'owner' || role === 'admin';
@@ -109,7 +109,9 @@ app.post('/', auth, async (c) => {
109
109
 
110
110
  await method.update({ default_currency_id: currency.id });
111
111
 
112
- ensureWebhookRegistered().catch(console.error);
112
+ ensureWebhookRegistered().catch((err) =>
113
+ logger.error('ensureWebhookRegistered failed (payments may not reconcile in real-time)', err)
114
+ );
113
115
 
114
116
  return c.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
115
117
  }
@@ -532,7 +534,9 @@ app.put('/:id', auth, async (c) => {
532
534
 
533
535
  const updatedMethod = await method.update(updateData);
534
536
  if (method.type === 'stripe') {
535
- ensureWebhookRegistered().catch(console.error);
537
+ ensureWebhookRegistered().catch((err) =>
538
+ logger.error('ensureWebhookRegistered failed (payments may not reconcile in real-time)', err)
539
+ );
536
540
  }
537
541
  return c.json(updatedMethod);
538
542
  } catch (err) {