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.
Files changed (49) hide show
  1. package/api/src/host-node/serve-static-arc.ts +1 -0
  2. package/api/src/integrations/app-store/handlers/subscription.ts +55 -4
  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/google-play/handlers/subscription.ts +1 -1
  6. package/api/src/integrations/google-play/handlers/voided.ts +1 -1
  7. package/api/src/integrations/stripe/handlers/payment-intent.ts +4 -2
  8. package/api/src/integrations/stripe/handlers/subscription.ts +1 -1
  9. package/api/src/integrations/stripe/reconcile-checkout-core.ts +60 -0
  10. package/api/src/integrations/stripe/reconcile-checkout.ts +40 -0
  11. package/api/src/integrations/stripe/setup.ts +3 -3
  12. package/api/src/libs/arcblock-transfer.ts +27 -0
  13. package/api/src/libs/context.ts +10 -2
  14. package/api/src/libs/did-connect/tenant-identity.ts +19 -1
  15. package/api/src/libs/entitlement.ts +12 -0
  16. package/api/src/libs/env.ts +1 -0
  17. package/api/src/libs/notification/index.ts +4 -3
  18. package/api/src/libs/util.ts +31 -2
  19. package/api/src/middlewares/hono/csrf.ts +6 -0
  20. package/api/src/middlewares/hono/security.ts +47 -2
  21. package/api/src/middlewares/hono/session.ts +22 -0
  22. package/api/src/routes/connect/pay.ts +21 -9
  23. package/api/src/routes/hono/checkout-sessions.ts +71 -32
  24. package/api/src/routes/hono/entitlements.ts +8 -1
  25. package/api/src/routes/hono/payment-methods.ts +6 -2
  26. package/api/src/service.ts +42 -4
  27. package/api/src/store/models/payment-currency.ts +7 -1
  28. package/api/src/store/models/payment-method.ts +30 -6
  29. package/api/src/store/models/subscription.ts +3 -1
  30. package/api/tests/integrations/app-store/handlers.spec.ts +13 -2
  31. package/api/tests/integrations/stripe/reconcile-checkout.spec.ts +80 -0
  32. package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
  33. package/api/tests/libs/util.spec.ts +51 -0
  34. package/blocklet.yml +1 -1
  35. package/cloudflare/cf-adapter.ts +35 -4
  36. package/cloudflare/did-connect-runtime.ts +43 -5
  37. package/cloudflare/esbuild-cf-config.cjs +12 -7
  38. package/cloudflare/tests/cf-adapter.spec.ts +10 -1
  39. package/cloudflare/worker.ts +41 -1
  40. package/package.json +9 -9
  41. package/src/components/layout/user.tsx +7 -1
  42. package/src/libs/did.ts +27 -0
  43. package/src/pages/customer/credit-grant/detail.tsx +2 -1
  44. package/src/pages/customer/credit-transaction/detail.tsx +2 -1
  45. package/src/pages/customer/invoice/detail.tsx +2 -1
  46. package/src/pages/customer/recharge/subscription.tsx +2 -1
  47. package/src/pages/customer/subscription/detail.tsx +2 -1
  48. package/tests/libs/did.spec.ts +43 -0
  49. package/vite.arc.config.ts +19 -0
@@ -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 (
@@ -110,7 +110,19 @@ export async function ingestVerifiedAppStorePurchase({
110
110
 
111
111
  const newExpiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
112
112
  const newTxnValid = !!newExpiresAt && newExpiresAt * 1000 > Date.now();
113
- const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string);
113
+ // A subscription is "lapsed" when one of two things is true:
114
+ // (a) The stored status is already terminal (canceled / past_due / …).
115
+ // (b) Status is still 'active' but stored current_period_end is in the
116
+ // past. This is the Sandbox steady state — Apple doesn't send the
117
+ // RTDN that would flip the status on expiry, so a freshly bought
118
+ // 5-minute Sandbox sub goes silently stale. Without case (b), a
119
+ // re-Subscribe tap hits the idempotency `return existing` branch
120
+ // below and the row never gets the new expires_at — the user
121
+ // stays in the "Free" state because entitlement filters
122
+ // current_period_end > now.
123
+ const storedExpiresAt = existing.current_period_end ?? 0;
124
+ const storedExpired = !!storedExpiresAt && storedExpiresAt * 1000 < Date.now();
125
+ const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string) || storedExpired;
114
126
  // Sandbox re-subscribe / renewal arriving via client verify reuses the SAME
115
127
  // originalTransactionId. If the stored sub had lapsed (expired→canceled) but
116
128
  // the new transaction is valid, reactivate it instead of returning the stale
@@ -121,7 +133,7 @@ export async function ingestVerifiedAppStorePurchase({
121
133
  const updatePatch: any = {
122
134
  status: 'active',
123
135
  current_period_end: newExpiresAt,
124
- ended_at: null,
136
+ end_at: null,
125
137
  cancel_at_period_end: false,
126
138
  payment_details: {
127
139
  ...(existing.payment_details || {}),
@@ -165,6 +177,45 @@ export async function ingestVerifiedAppStorePurchase({
165
177
  return { subscription: existing, isFirstSubscribe: false, transaction };
166
178
  }
167
179
 
180
+ // Lapsed + new transaction ALSO expired (Apple Sandbox steady state
181
+ // once a sub has cycled past its max auto-renewals: Apple keeps
182
+ // replaying the final expired transaction through Transaction.updates
183
+ // forever — both stored and replay are stale). Without an explicit
184
+ // terminal write here, the row stays status='active' with a past
185
+ // current_period_end, entitlement.check filters it out (correct), but
186
+ // the verify route keeps returning success: true on every replay so
187
+ // PaymentKit.handle() finishes the transaction successfully — yet
188
+ // Apple Sandbox redelivers it on the next tick anyway, producing the
189
+ // hundreds-per-minute verify spam seen in cf-aside logs. Marking
190
+ // incomplete_expired + ended_at converges the local truth, and a
191
+ // subsequent fresh purchase will land in the reactivate branch above
192
+ // (lapsed + newTxnValid) and revive the row cleanly.
193
+ if (lapsed && !newTxnValid) {
194
+ await existing.update({
195
+ status: 'incomplete_expired',
196
+ end_at: Math.floor(Date.now() / 1000),
197
+ cancel_at_period_end: false,
198
+ payment_details: {
199
+ ...(existing.payment_details || {}),
200
+ app_store: {
201
+ ...(existing.payment_details?.app_store || {}),
202
+ transaction_id: transaction.transactionId,
203
+ expires_at: newExpiresAt || (existing.payment_details?.app_store as any)?.expires_at,
204
+ },
205
+ },
206
+ } as any);
207
+ logger.info(
208
+ 'app_store verify: subscription fully lapsed (stored + replay both expired) — marked incomplete_expired',
209
+ {
210
+ subscriptionId: existing.id,
211
+ originalTransactionId: transaction.originalTransactionId,
212
+ storedExpiresAt,
213
+ newExpiresAt,
214
+ }
215
+ );
216
+ return { subscription: existing, isFirstSubscribe: false, transaction };
217
+ }
218
+
168
219
  // SKU drift sync — when the StoreKit-side product_id differs from
169
220
  // what we have stored (Apple crossgrade / downgrade landed in
170
221
  // Sandbox immediately; or the production DID_RENEW webhook hadn't
@@ -579,7 +630,7 @@ async function handleAppStoreRenewed(
579
630
 
580
631
  async function markAppStoreExpired(subscription: Subscription): Promise<void> {
581
632
  if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
582
- await subscription.update({ status: 'canceled', ended_at: Math.floor(Date.now() / 1000) });
633
+ await subscription.update({ status: 'canceled', end_at: Math.floor(Date.now() / 1000) });
583
634
  createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(reportAuditFailure);
584
635
  }
585
636
 
@@ -607,7 +658,7 @@ async function handleAppStoreRevoked(
607
658
  const now = Math.floor(Date.now() / 1000);
608
659
  await subscription.update({
609
660
  status: 'canceled',
610
- ended_at: now,
661
+ end_at: now,
611
662
  cancelation_details: {
612
663
  ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
613
664
  reason: 'cancellation_requested',
@@ -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> {
@@ -541,7 +541,7 @@ async function handleRevoked(subscription: Subscription): Promise<void> {
541
541
  const now = Math.floor(Date.now() / 1000);
542
542
  await subscription.update({
543
543
  status: 'canceled',
544
- ended_at: now,
544
+ end_at: now,
545
545
  cancelation_details: {
546
546
  ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
547
547
  reason: 'cancellation_requested',
@@ -80,7 +80,7 @@ export async function handleGooglePlayVoidedPurchase({
80
80
  if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
81
81
  await subscription.update({
82
82
  status: 'canceled',
83
- ended_at: Math.floor(Date.now() / 1000),
83
+ end_at: Math.floor(Date.now() / 1000),
84
84
  cancelation_details: {
85
85
  ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
86
86
  reason: 'cancellation_requested',
@@ -41,7 +41,9 @@ export async function handleStripePaymentSucceed(paymentIntent: PaymentIntent, e
41
41
  }
42
42
 
43
43
  export async function syncStripePayment(paymentIntent: PaymentIntent) {
44
- if (!paymentIntent?.metadata?.stripe_id) {
44
+ const stripePaymentIntentId =
45
+ paymentIntent?.payment_details?.stripe?.payment_intent_id || paymentIntent?.metadata?.stripe_id;
46
+ if (!stripePaymentIntentId) {
45
47
  return;
46
48
  }
47
49
 
@@ -52,7 +54,7 @@ export async function syncStripePayment(paymentIntent: PaymentIntent) {
52
54
 
53
55
  const triggerRenew = paymentIntent.status !== 'succeeded';
54
56
  const client = await method.getStripeClient();
55
- const stripeIntent = await client.paymentIntents.retrieve(paymentIntent.metadata.stripe_id);
57
+ const stripeIntent = await client.paymentIntents.retrieve(stripePaymentIntentId);
56
58
  if (stripeIntent) {
57
59
  // @ts-ignore
58
60
  await paymentIntent.update({
@@ -148,7 +148,7 @@ export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe)
148
148
  if (['incomplete', 'incomplete_expired'].includes(subscription.status)) {
149
149
  logger.warn('subscription not ended on stripe event', { id: subscription.id });
150
150
  } else {
151
- await subscription.update({ status: 'canceled', ended_at: event.data.object.ended_at });
151
+ await subscription.update({ status: 'canceled', end_at: event.data.object.ended_at });
152
152
  logger.info('subscription ended on stripe event', { id: subscription.id });
153
153
  }
154
154
  }
@@ -0,0 +1,60 @@
1
+ export type ReconcileResult = {
2
+ status: string;
3
+ paymentStatus: string;
4
+ reconciled: boolean;
5
+ };
6
+
7
+ export type ReconcileDeps = {
8
+ findCheckoutSession: (id: string) => Promise<any>;
9
+ findPaymentIntent: (id: string) => Promise<any>;
10
+ getSubscriptionIds: (checkoutSession: any) => string[];
11
+ findSubscriptions: (ids: string[]) => Promise<any[]>;
12
+ syncPayment: (paymentIntent: any) => Promise<void>;
13
+ syncSubscription: (subscription: any) => Promise<void>;
14
+ };
15
+
16
+ export async function reconcileStripeCheckoutSessionWithDeps(
17
+ sessionId: string,
18
+ deps: ReconcileDeps
19
+ ): Promise<ReconcileResult> {
20
+ const checkoutSession = await deps.findCheckoutSession(sessionId);
21
+ if (!checkoutSession) {
22
+ throw new Error('Checkout session not found');
23
+ }
24
+ if (checkoutSession.status === 'complete') {
25
+ return {
26
+ status: checkoutSession.status,
27
+ paymentStatus: checkoutSession.payment_status,
28
+ reconciled: false,
29
+ };
30
+ }
31
+
32
+ let reconciled = false;
33
+ if (checkoutSession.payment_intent_id) {
34
+ const paymentIntent = await deps.findPaymentIntent(checkoutSession.payment_intent_id);
35
+ if (paymentIntent && paymentIntent.status !== 'succeeded') {
36
+ await deps.syncPayment(paymentIntent);
37
+ reconciled = true;
38
+ }
39
+ }
40
+
41
+ const subscriptionIds = deps.getSubscriptionIds(checkoutSession);
42
+ if (subscriptionIds.length > 0) {
43
+ const subscriptions = await deps.findSubscriptions(subscriptionIds);
44
+ for (const subscription of subscriptions) {
45
+ if (subscription.status === 'incomplete') {
46
+ // Sequential reconciliation avoids racing checkout success counters.
47
+ // eslint-disable-next-line no-await-in-loop
48
+ await deps.syncSubscription(subscription);
49
+ reconciled = true;
50
+ }
51
+ }
52
+ }
53
+
54
+ await checkoutSession.reload();
55
+ return {
56
+ status: checkoutSession.status,
57
+ paymentStatus: checkoutSession.payment_status,
58
+ reconciled,
59
+ };
60
+ }
@@ -0,0 +1,40 @@
1
+ import { getCheckoutSessionSubscriptionIds } from '../../libs/session';
2
+ import { CheckoutSession, PaymentIntent, PaymentMethod, Subscription } from '../../store/models';
3
+ import { syncStripePayment } from './handlers/payment-intent';
4
+ import { handleStripeSubscriptionSucceed } from './handlers/subscription';
5
+ import {
6
+ reconcileStripeCheckoutSessionWithDeps,
7
+ type ReconcileDeps,
8
+ type ReconcileResult,
9
+ } from './reconcile-checkout-core';
10
+
11
+ async function syncSubscription(subscription: Subscription): Promise<void> {
12
+ if (!subscription.payment_details?.stripe?.subscription_id) return;
13
+ const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
14
+ if (!method || method.type !== 'stripe') return;
15
+
16
+ const remote = await method
17
+ .getStripeClient()
18
+ .subscriptions.retrieve(subscription.payment_details.stripe.subscription_id, {
19
+ expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
20
+ });
21
+ if (['active', 'trialing'].includes(remote.status)) {
22
+ await handleStripeSubscriptionSucceed(subscription, remote.status);
23
+ }
24
+ }
25
+
26
+ const defaultDeps: ReconcileDeps = {
27
+ findCheckoutSession: (id) => CheckoutSession.findByPk(id),
28
+ findPaymentIntent: (id) => PaymentIntent.findByPk(id),
29
+ getSubscriptionIds: getCheckoutSessionSubscriptionIds,
30
+ findSubscriptions: (ids) => Subscription.findAll({ where: { id: ids } }),
31
+ syncPayment: syncStripePayment,
32
+ syncSubscription,
33
+ };
34
+
35
+ export function reconcileStripeCheckoutSession(
36
+ sessionId: string,
37
+ deps: ReconcileDeps = defaultDeps
38
+ ): Promise<ReconcileResult> {
39
+ return reconcileStripeCheckoutSessionWithDeps(sessionId, deps);
40
+ }
@@ -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
  }
@@ -49,6 +49,15 @@ export type EntitlementCheckResult = {
49
49
  subscription_id: string | null;
50
50
  source: 'subscription' | 'one_time' | null;
51
51
  credit_remaining?: string;
52
+ /**
53
+ * For subscription source: the customer has asked the platform (Apple,
54
+ * Google, Stripe) to STOP auto-renewing. The entitlement is still
55
+ * active until `expires_at` but won't renew. UIs should mark the card
56
+ * as "canceled, ends on X" and offer a re-enable action that deep-
57
+ * links to the platform's subscription management. Always omitted for
58
+ * one-time / credit-grant sources.
59
+ */
60
+ cancel_at_period_end?: boolean;
52
61
  };
53
62
 
54
63
  const SUBSCRIPTION_STATUS_PRIORITY: Record<string, number> = {
@@ -231,6 +240,7 @@ export async function checkEntitlement({
231
240
  expires_at: best.current_period_end ?? null,
232
241
  subscription_id: best.id,
233
242
  source: 'subscription',
243
+ cancel_at_period_end: !!best.cancel_at_period_end,
234
244
  };
235
245
  }
236
246
 
@@ -258,6 +268,7 @@ export type EntitlementListItem = {
258
268
  subscription_id: string | null;
259
269
  source: 'subscription' | 'one_time';
260
270
  credit_remaining?: string;
271
+ cancel_at_period_end?: boolean;
261
272
  };
262
273
 
263
274
  /**
@@ -367,6 +378,7 @@ export async function listEntitlements({
367
378
  expires_at: best.current_period_end ?? null,
368
379
  subscription_id: best.id,
369
380
  source: 'subscription',
381
+ cancel_at_period_end: !!best.cancel_at_period_end,
370
382
  };
371
383
  return item;
372
384
  })
@@ -178,6 +178,7 @@ export const googlePubsubAllowUnverifiedSender = (): boolean =>
178
178
  parseBool(readConfig('GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER'));
179
179
  export const googlePlayWebhookUrl = (): string | undefined => readConfig('GOOGLE_PLAY_WEBHOOK_URL');
180
180
  export const stripeWebhookSecret = (): string | undefined => readConfig('STRIPE_WEBHOOK_SECRET');
181
+ export const stripeWebhookUrl = (): string | undefined => readConfig('STRIPE_WEBHOOK_URL');
181
182
  export const iapReconcileBatchSize = (): number => Number(readConfig('IAP_RECONCILE_BATCH_SIZE') ?? '100');
182
183
 
183
184
  // -- payment params --
@@ -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');
@@ -24,6 +24,43 @@ import { isDevelopmentEnv, enableDevFakeAuth } from '../../libs/env';
24
24
  import { wallet } from '../../libs/auth';
25
25
  import { Customer } from '../../store/models/customer';
26
26
 
27
+ // Bare ↔ prefixed DID coexist in this codebase:
28
+ // - Bearer-token branch stores `did:abt:<base58>` (token-path normalisation).
29
+ // - BS-injected `x-user-did` headers carry whichever shape the host emits.
30
+ // - Customer.did rows on multi-tenant deploys are mixed (older rows bare,
31
+ // CF-seeded rows prefixed).
32
+ // Comparing `user.did === customer.did` with strict equality silently
33
+ // false-negatives for half of these combinations, so every cross-DID match
34
+ // goes through this helper. `findCustomerByEitherDid` runs a single
35
+ // disjunction query so a row in either shape resolves.
36
+ export function stripDidPrefix(did: string | undefined | null): string {
37
+ if (!did) return '';
38
+ return did.startsWith('did:abt:') ? did.slice('did:abt:'.length) : did;
39
+ }
40
+
41
+ /** True when both DIDs resolve to the same identity ignoring the `did:abt:`
42
+ * prefix. Use this for any cross-DID equality check on multi-tenant deploys
43
+ * (auth/record-access guards). Mirrors `blocklets/core/src/libs/did.ts`
44
+ * isSameDid — keep behaviour in sync. */
45
+ export function isSameDid(a: string | undefined | null, b: string | undefined | null): boolean {
46
+ const x = stripDidPrefix(a);
47
+ const y = stripDidPrefix(b);
48
+ return !!x && x === y;
49
+ }
50
+
51
+ async function findCustomerByEitherDid(did: string): Promise<Customer | null> {
52
+ if (!did) return null;
53
+ const bare = stripDidPrefix(did);
54
+ if (!bare) return null;
55
+ const prefixed = `did:abt:${bare}`;
56
+ // Sequelize Op.or via plain object form; either shape resolves to the same
57
+ // logical user. Kept minimal to avoid an extra Op.in import path here.
58
+ // Bound to a local so the function body satisfies require-await without
59
+ // tripping no-return-await (returning the awaited binding, not the await).
60
+ const found = await Customer.findOne({ where: { did: [bare, prefixed] as any } });
61
+ return found;
62
+ }
63
+
27
64
  type PermissionSpec<T extends Model> = {
28
65
  component?: boolean;
29
66
  roles?: string[];
@@ -173,7 +210,12 @@ export function authenticate<T extends Model>({
173
210
  }
174
211
 
175
212
  if (mine) {
176
- const customer = await Customer.findOne({ where: { did: user.did } });
213
+ // Match Customer regardless of which DID shape the row was stored as
214
+ // (bare base58 from older single-tenant writes, did:abt: prefixed from
215
+ // CF multi-tenant flows). A strict findOne({did: user.did}) silently
216
+ // returned null when shapes mismatched, falling through to the 403
217
+ // even for the real owner.
218
+ const customer = await findCustomerByEitherDid(user.did);
177
219
  if (customer) {
178
220
  c.set('customer', customer);
179
221
  // hono query is immutable — inject the VERIFIED id into context so a
@@ -190,7 +232,10 @@ export function authenticate<T extends Model>({
190
232
  const doc: T | null =
191
233
  findById && typeof findById === 'function' ? await findById(id) : await (model as any).findByPk(id);
192
234
  if (doc && doc[field as keyof T]) {
193
- const customer = await Customer.findOne({ where: { did: user.did } });
235
+ // Same shape-tolerant lookup as the mine branch record-ownership
236
+ // checks were silently failing for users whose customer row lives in
237
+ // the other DID shape.
238
+ const customer = await findCustomerByEitherDid(user.did);
194
239
  c.set('doc', doc);
195
240
  c.set('customer', customer);
196
241
  if (customer && customer.id === doc[field as keyof T]) {