payment-kit 1.29.7 → 1.29.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/host-glue.ts +79 -0
- package/api/src/host-node/serve-static-arc.ts +1 -0
- package/api/src/integrations/app-store/native-asn1.ts +1 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +6 -1
- package/api/src/integrations/stripe/setup.ts +3 -3
- package/api/src/libs/arcblock-transfer.ts +27 -0
- package/api/src/libs/context.ts +10 -2
- package/api/src/libs/did-connect/tenant-identity.ts +19 -1
- package/api/src/libs/env.ts +65 -22
- package/api/src/libs/notification/index.ts +4 -3
- package/api/src/libs/util.ts +31 -2
- package/api/src/middlewares/hono/csrf.ts +6 -0
- package/api/src/middlewares/hono/session.ts +22 -0
- package/api/src/routes/connect/pay.ts +21 -9
- package/api/src/routes/hono/credit-grants.ts +1 -1
- package/api/src/routes/hono/entitlements.ts +8 -1
- package/api/src/routes/hono/payment-methods.ts +6 -2
- package/api/src/service.ts +42 -4
- package/api/src/store/models/payment-currency.ts +7 -1
- package/api/src/store/models/payment-method.ts +30 -6
- package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
- package/api/tests/libs/notification-guard.spec.ts +25 -8
- package/api/tests/libs/util.spec.ts +51 -0
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +46 -50
- package/cloudflare/did-connect-runtime.ts +43 -5
- package/cloudflare/esbuild-cf-config.cjs +12 -7
- package/cloudflare/tests/cf-adapter.spec.ts +12 -2
- package/cloudflare/worker.ts +41 -1
- package/package.json +9 -9
- package/src/components/layout/user.tsx +7 -1
- package/vite.arc.config.ts +19 -0
|
@@ -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
|
|
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,
|
|
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
|
}
|
package/api/src/libs/env.ts
CHANGED
|
@@ -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
|
|
97
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
105
|
-
|
|
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')
|
|
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')
|
|
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')
|
|
142
|
-
export const appStoreSkipSignatureVerify = (): boolean => readConfig('APP_STORE_SKIP_SIGNATURE_VERIFY')
|
|
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')
|
|
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')
|
|
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')
|
|
189
|
+
export const paymentReloadSubscriptionJobs = (): boolean => parseBool(readConfig('PAYMENT_RELOAD_SUBSCRIPTION_JOBS'));
|
|
159
190
|
export const paymentRateVolatilityThreshold = (): string | undefined => readConfig('PAYMENT_RATE_VOLATILITY_THRESHOLD');
|
|
160
|
-
|
|
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')
|
|
176
|
-
export const sqlBenchmark = (): boolean => readConfig('SQL_BENCHMARK')
|
|
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 —
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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('
|
|
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 =
|
|
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');
|
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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) {
|