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
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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',
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/api/src/libs/context.ts
CHANGED
|
@@ -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
|
|
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
|
})
|
package/api/src/libs/env.ts
CHANGED
|
@@ -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 =
|
|
15
|
+
const noop = (): Promise<undefined> => Promise.resolve(undefined);
|
|
16
16
|
for (const target of [mod, mod?.default]) {
|
|
17
|
-
if (
|
|
18
|
-
|
|
17
|
+
if (target) {
|
|
18
|
+
for (const fn of fns) target[fn] = noop;
|
|
19
|
+
}
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
package/api/src/libs/util.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]) {
|