payment-kit 1.29.2 → 1.29.3
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/bootstrap.ts +11 -0
- package/api/src/crons/index.ts +14 -13
- package/api/src/crons/tenant-fanout.ts +82 -0
- package/api/src/host-node/did-connect-runtime-node.ts +33 -0
- package/api/src/host-node/serve-static-arc.ts +68 -0
- package/api/src/host-node/serve-static.ts +41 -0
- package/api/src/libs/auth.ts +166 -27
- package/api/src/libs/context.ts +11 -0
- package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
- package/api/src/libs/did-connect/tenant-identity.ts +221 -0
- package/api/src/libs/drivers/identity.ts +61 -0
- package/api/src/libs/drivers/index.ts +1 -1
- package/api/src/libs/http-fetch-adapter.ts +11 -1
- package/api/src/libs/queue/index.ts +14 -2
- package/api/src/middlewares/hono/context.ts +7 -0
- package/api/src/middlewares/hono/csrf.ts +13 -2
- package/api/src/middlewares/hono/security.ts +6 -11
- package/api/src/queues/checkout-session.ts +21 -9
- package/api/src/queues/event.ts +29 -7
- package/api/src/queues/payment.ts +23 -9
- package/api/src/queues/payout.ts +28 -16
- package/api/src/queues/refund.ts +18 -6
- package/api/src/routes/hono/customers.ts +6 -1
- package/api/src/routes/hono/refunds.ts +2 -3
- package/api/src/service.ts +178 -31
- package/api/src/store/sequelize.ts +16 -1
- package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -0
- package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
- package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
- package/api/tests/libs/service-host.spec.ts +37 -0
- package/api/tests/queues/event-tenant.spec.ts +60 -4
- package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
- package/api/tests/service/fail-closed-http.spec.ts +79 -0
- package/api/tests/service/static-arc-handler.spec.ts +101 -0
- package/api/tests/service/static-externalized.spec.ts +48 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +8 -21
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +10 -5
- package/cloudflare/cf-adapter.ts +419 -0
- package/cloudflare/did-connect-runtime.ts +96 -0
- package/cloudflare/did-connect-token-storage.ts +151 -0
- package/cloudflare/esbuild-cf-config.cjs +407 -0
- package/cloudflare/run-build.js +33 -357
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +98 -56
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +0 -1
- package/cloudflare/wrangler.staging.json +0 -6
- package/package.json +7 -7
- package/scripts/bootstrap-inject.ts +166 -0
- package/src/app.tsx +2 -1
- package/src/libs/service-host.ts +13 -0
- package/vite.arc.config.ts +159 -0
- package/cloudflare/did-connect-auth.ts +0 -310
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +0 -8
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — the REAL @arcblock/did-connect-js runtime, SHARED by
|
|
2
|
+
// the non-blocklet-server hosts (CF + arc-node embedded).
|
|
3
|
+
//
|
|
4
|
+
// Both build the same authenticator/handlers; they differ ONLY in the host adapter
|
|
5
|
+
// passed in here (tokenStorage, chain config, txEncoder, timeout). The signing
|
|
6
|
+
// wallet + appInfo are FUNCTION-VALUED and resolved per-request, per-tenant through
|
|
7
|
+
// the shared `resolveTenantIdentity` (AUTH_SERVICE.getInstanceAppIdentity) — never
|
|
8
|
+
// a fixed isolate key — so one isolate serves every tenant with its own appSk.
|
|
9
|
+
//
|
|
10
|
+
// @arcblock/did-connect-js supports function-valued `wallet` (base.js: typeof
|
|
11
|
+
// wallet === 'function' → resolved via getWalletInfo with a timeout before each
|
|
12
|
+
// sign), so per-tenant signing is safe.
|
|
13
|
+
|
|
14
|
+
import type { DidConnectRuntime, DidConnectTokenStorage } from '../auth';
|
|
15
|
+
import { resolveTenantIdentity } from './tenant-identity';
|
|
16
|
+
|
|
17
|
+
/** Chain config for the DID-Connect authenticator (id/type/host), or a resolver. */
|
|
18
|
+
export type ChainInfoOption =
|
|
19
|
+
| { id: string; type: string; host: string }
|
|
20
|
+
| ((params: any) => { id: string; type: string; host: string } | Promise<{ id: string; type: string; host: string }>);
|
|
21
|
+
|
|
22
|
+
export interface DidConnectJsRuntimeOptions {
|
|
23
|
+
/** Host-injected token store (CF: D1 tenant-aware adapter). Omit to let
|
|
24
|
+
* buildTokenStorage fall back to the file-backed nedb default (arc-node). */
|
|
25
|
+
tokenStorage?: DidConnectTokenStorage;
|
|
26
|
+
/** Chain config for `signature`/`prepareTx` claims (omit on chain-less hosts). */
|
|
27
|
+
chainInfo?: ChainInfoOption;
|
|
28
|
+
/** On-chain tx encoder (CF: @ocap/client/encode CBOR encoder). Omit to disable tx claims. */
|
|
29
|
+
txEncoder?: (params: { type: string; data: any; wallet: any; chainHost: string }) => Promise<Buffer>;
|
|
30
|
+
/** Authenticator timeout (CF chain RPC can exceed the 8s default; worker used 30s). */
|
|
31
|
+
timeout?: number;
|
|
32
|
+
/** Branding fallback when an instance's getInstanceAppIdentity omits appInfo. */
|
|
33
|
+
defaultAppInfo?: { name?: string; description?: string; icon?: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_APP_INFO = {
|
|
37
|
+
name: 'Payment Kit',
|
|
38
|
+
description: 'Payment Kit',
|
|
39
|
+
icon: 'https://www.arcblock.io/favicon.ico',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a DID-Connect runtime backed by the real @arcblock/did-connect-js stack.
|
|
44
|
+
* Used by `createCloudflareDidConnectRuntime` (CF) and the arc-node embedded host.
|
|
45
|
+
*/
|
|
46
|
+
export function createDidConnectJsRuntime(opts: DidConnectJsRuntimeOptions): DidConnectRuntime {
|
|
47
|
+
const fallbackAppInfo = { ...DEFAULT_APP_INFO, ...(opts.defaultAppInfo ?? {}) };
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
tokenStorage: opts.tokenStorage,
|
|
51
|
+
|
|
52
|
+
createAuthenticator() {
|
|
53
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
54
|
+
const { WalletAuthenticator } = require('@arcblock/did-connect-js');
|
|
55
|
+
|
|
56
|
+
const config: Record<string, any> = {
|
|
57
|
+
// Function-valued, per-tenant: resolve the signing wallet from the host
|
|
58
|
+
// IdentityDriver.getInstanceAppIdentity each time the authenticator signs.
|
|
59
|
+
// `.toJSON()` matches the standalone worker (the SDK reconstructs the full
|
|
60
|
+
// wallet — incl. sk — via fromJSON before signing).
|
|
61
|
+
wallet: async () => {
|
|
62
|
+
const { wallet } = await resolveTenantIdentity();
|
|
63
|
+
return wallet.toJSON();
|
|
64
|
+
},
|
|
65
|
+
appInfo: async ({ baseUrl }: { baseUrl?: string } = {}) => {
|
|
66
|
+
const { appInfo } = await resolveTenantIdentity();
|
|
67
|
+
return {
|
|
68
|
+
name: appInfo.name || fallbackAppInfo.name,
|
|
69
|
+
description: appInfo.description || fallbackAppInfo.description,
|
|
70
|
+
icon: appInfo.icon || fallbackAppInfo.icon,
|
|
71
|
+
link: appInfo.link || baseUrl,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
timeout: opts.timeout ?? 30000,
|
|
75
|
+
};
|
|
76
|
+
if (opts.chainInfo) config.chainInfo = opts.chainInfo;
|
|
77
|
+
if (opts.txEncoder) config.txEncoder = opts.txEncoder;
|
|
78
|
+
|
|
79
|
+
return new WalletAuthenticator(config);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
createHandlers({ authenticator, tokenStorage }) {
|
|
83
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
84
|
+
const { WalletHandlers } = require('@arcblock/did-connect-js');
|
|
85
|
+
return new WalletHandlers({ authenticator, tokenStorage });
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — the SHARED per-tenant DID-Connect identity resolver.
|
|
2
|
+
//
|
|
3
|
+
// Both non-blocklet-server runtimes (CF + arc-node embedded) build their REAL
|
|
4
|
+
// `@arcblock/did-connect-js` WalletAuthenticator from a per-tenant signing wallet
|
|
5
|
+
// resolved HERE — never from a fixed isolate-level APP_SK. The signing identity
|
|
6
|
+
// comes from the host IdentityDriver's `getInstanceAppIdentity(instanceDid)`
|
|
7
|
+
// (AUTH_SERVICE-backed), which already resolves the arc run-mode (instance app:sk
|
|
8
|
+
// → instance identity; else auth-service root; else fail-closed), so payment-core
|
|
9
|
+
// never reimplements "instance ?? root" and never reads connect-service internals.
|
|
10
|
+
//
|
|
11
|
+
// The wallet is derived from the returned `appSk` with @ocap/wallet (a stable
|
|
12
|
+
// public API) — the same ROLE_APPLICATION/ED25519/SHA3 wallet type the standalone
|
|
13
|
+
// CF worker used. The blocklet-server runtime does NOT use this resolver (it keeps
|
|
14
|
+
// the @blocklet/sdk wallet wrapper); only the AUTH_SERVICE/did-connect-js runtimes do.
|
|
15
|
+
//
|
|
16
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
17
|
+
import * as Mcrypto from '@ocap/mcrypto';
|
|
18
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
19
|
+
import { fromSecretKey, WalletType } from '@ocap/wallet';
|
|
20
|
+
import type { WalletObject } from '@ocap/wallet';
|
|
21
|
+
|
|
22
|
+
import { getInstanceDid } from '../context';
|
|
23
|
+
import { getIdentityDriver, type InstanceAppInfo, type BlockletDirectory } from '../drivers';
|
|
24
|
+
import logger from '../logger';
|
|
25
|
+
|
|
26
|
+
const walletType = {
|
|
27
|
+
role: Mcrypto.types.RoleType.ROLE_APPLICATION,
|
|
28
|
+
pk: Mcrypto.types.KeyType.ED25519,
|
|
29
|
+
hash: Mcrypto.types.HashType.SHA3,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The ethereum business wallet is derived from the SAME instance appSk as the
|
|
33
|
+
// arcblock wallet, mirroring @blocklet/sdk getWallet('ethereum', appSk): the
|
|
34
|
+
// secp256k1/keccak WalletType('ethereum') over the first 66 chars of the appSk.
|
|
35
|
+
// Using @ocap/wallet directly (not the SDK) keeps this CF-worker-safe — the CF
|
|
36
|
+
// build shims the SDK wallet-* modules, but @ocap/wallet is a stable public API.
|
|
37
|
+
const ethWalletType = WalletType('ethereum');
|
|
38
|
+
|
|
39
|
+
export interface ResolvedTenantIdentity {
|
|
40
|
+
instanceDid: string;
|
|
41
|
+
/** App signing wallet derived from the instance appSk (the DID-Connect signer). */
|
|
42
|
+
wallet: WalletObject;
|
|
43
|
+
/** Ethereum business wallet derived from the same appSk (EVM chains / refunds). */
|
|
44
|
+
ethWallet: WalletObject;
|
|
45
|
+
/** Permanent app signing wallet (when keys rotated); falls back to `wallet`. */
|
|
46
|
+
permanentWallet: WalletObject;
|
|
47
|
+
/** Branding for DID-Connect prompts (name/description/icon/link). */
|
|
48
|
+
appInfo: InstanceAppInfo;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Per-instance TTL cache of the DERIVED identity (Layer 2 of
|
|
52
|
+
// wallet-authenticator-dynamic.md). The business wallet proxies (libs/auth.ts)
|
|
53
|
+
// read it SYNCHRONOUSLY via getCachedTenantIdentity, so resolveTenantIdentity
|
|
54
|
+
// (async — it RPCs getInstanceAppIdentity) must run first per request/job (the
|
|
55
|
+
// HTTP contextMiddleware and queue runJobWithTenant warm it). TTL bounds key-
|
|
56
|
+
// rotation staleness; for a single-tenant deployment this holds exactly one entry.
|
|
57
|
+
// A hard size cap (insertion-order eviction via Map iteration) keeps a host with
|
|
58
|
+
// many tenants from growing the map unbounded — expired entries are pruned on
|
|
59
|
+
// read but never proactively, so the cap is the only growth bound.
|
|
60
|
+
const IDENTITY_TTL_MS = 5 * 60 * 1000;
|
|
61
|
+
const IDENTITY_CACHE_MAX = 512;
|
|
62
|
+
const identityCache = new Map<string, { value: ResolvedTenantIdentity; expiry: number }>();
|
|
63
|
+
|
|
64
|
+
function cacheIdentity(instanceDid: string, value: ResolvedTenantIdentity): void {
|
|
65
|
+
identityCache.set(instanceDid, { value, expiry: Date.now() + IDENTITY_TTL_MS });
|
|
66
|
+
// evict the oldest entry (Map preserves insertion order) once over the cap
|
|
67
|
+
if (identityCache.size > IDENTITY_CACHE_MAX) {
|
|
68
|
+
const oldest = identityCache.keys().next().value;
|
|
69
|
+
if (oldest !== undefined) identityCache.delete(oldest);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drop a tenant's cached identity (or all) — key rotation / test isolation. */
|
|
74
|
+
export function clearTenantIdentityCache(instanceDid?: string): void {
|
|
75
|
+
if (instanceDid) identityCache.delete(instanceDid);
|
|
76
|
+
else identityCache.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether the active host runtime resolves app identity dynamically per tenant
|
|
81
|
+
* (arc-node embedded + CF, via AUTH_SERVICE.getInstanceAppIdentity) vs the
|
|
82
|
+
* blocklet-server runtime, which keeps the @blocklet/sdk env wallet. The
|
|
83
|
+
* business wallet proxies branch on this so blocklet-server stays byte-for-byte
|
|
84
|
+
* unchanged (it never touches the resolver / cache).
|
|
85
|
+
*/
|
|
86
|
+
export function hasDynamicIdentity(): boolean {
|
|
87
|
+
return typeof getIdentityDriver().getInstanceAppIdentity === 'function';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the current (or given) tenant's DID-Connect signing identity. Throws a
|
|
92
|
+
* clear error — never silently falls back to a fixed key — when:
|
|
93
|
+
* - the active IdentityDriver does not implement getInstanceAppIdentity (a
|
|
94
|
+
* non-SDK runtime reached an SDK-only driver), or
|
|
95
|
+
* - the instance has no app signing key (fail-closed; AUTH_SERVICE 4.0.3 itself
|
|
96
|
+
* fails closed when neither instance app:sk nor root APP_SK exists).
|
|
97
|
+
*/
|
|
98
|
+
export async function resolveTenantIdentity(instanceDidArg?: string): Promise<ResolvedTenantIdentity> {
|
|
99
|
+
const instanceDid = instanceDidArg ?? getInstanceDid();
|
|
100
|
+
|
|
101
|
+
const cached = identityCache.get(instanceDid);
|
|
102
|
+
if (cached && cached.expiry > Date.now()) return cached.value;
|
|
103
|
+
|
|
104
|
+
const driver = getIdentityDriver();
|
|
105
|
+
if (typeof driver.getInstanceAppIdentity !== 'function') {
|
|
106
|
+
throw new Error(
|
|
107
|
+
'resolveTenantIdentity: the active IdentityDriver does not implement getInstanceAppIdentity — ' +
|
|
108
|
+
'a non-blocklet-server DID-Connect runtime requires an AUTH_SERVICE-backed identity driver'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const identity = await driver.getInstanceAppIdentity(instanceDid);
|
|
112
|
+
if (!identity || !identity.appSk) {
|
|
113
|
+
throw new Error(`resolveTenantIdentity: no app signing key for instance "${instanceDid}" (fail-closed)`);
|
|
114
|
+
}
|
|
115
|
+
// A too-short appSk would silently yield a WRONG ethereum address (slice below)
|
|
116
|
+
// and thus a wrong receiving/signing wallet — fail closed instead. A real
|
|
117
|
+
// ED25519 app sk is 128 hex chars; the eth slice needs at least 66.
|
|
118
|
+
if (identity.appSk.length < 66) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`resolveTenantIdentity: appSk for instance "${instanceDid}" is too short ` +
|
|
121
|
+
`(${identity.appSk.length} chars) to derive a wallet (fail-closed)`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const wallet = fromSecretKey(identity.appSk, walletType) as WalletObject;
|
|
125
|
+
// Mirror @blocklet/sdk getWallet('ethereum', appSk): secp256k1 over appSk[0..66].
|
|
126
|
+
const ethWallet = fromSecretKey(identity.appSk.slice(0, 66), ethWalletType) as WalletObject;
|
|
127
|
+
const permanentWallet = identity.appPsk ? (fromSecretKey(identity.appPsk, walletType) as WalletObject) : wallet;
|
|
128
|
+
const value: ResolvedTenantIdentity = {
|
|
129
|
+
instanceDid,
|
|
130
|
+
wallet,
|
|
131
|
+
ethWallet,
|
|
132
|
+
permanentWallet,
|
|
133
|
+
appInfo: identity.appInfo ?? {},
|
|
134
|
+
};
|
|
135
|
+
cacheIdentity(instanceDid, value);
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* SYNCHRONOUS accessor for the current (or given) tenant's resolved identity —
|
|
141
|
+
* the business wallet proxies (libs/auth.ts) call this on every `wallet.address`
|
|
142
|
+
* / `wallet.sign(...)`. It NEVER resolves (no RPC): it reads the cache that
|
|
143
|
+
* `warmTenantIdentity` populated earlier in the request/job. A miss means the
|
|
144
|
+
* identity was never warmed (fail-closed): throw rather than silently fall back
|
|
145
|
+
* to a wrong/default key.
|
|
146
|
+
*/
|
|
147
|
+
export function getCachedTenantIdentity(instanceDidArg?: string): ResolvedTenantIdentity {
|
|
148
|
+
const instanceDid = instanceDidArg ?? getInstanceDid();
|
|
149
|
+
const cached = identityCache.get(instanceDid);
|
|
150
|
+
if (!cached || cached.expiry <= Date.now()) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`tenant identity for "${instanceDid}" is not resolved (fail-closed) — ` +
|
|
153
|
+
'warmTenantIdentity must run in the request/job scope before any wallet access'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return cached.value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Best-effort warm of the current tenant's identity into the cache so later
|
|
161
|
+
* SYNCHRONOUS wallet access resolves. No-op for the blocklet-server runtime
|
|
162
|
+
* (env wallet, no dynamic driver). Errors are swallowed (logged): a request that
|
|
163
|
+
* never touches a wallet must not be blocked by an identity hiccup, while one
|
|
164
|
+
* that DOES touch it still fails-closed at getCachedTenantIdentity.
|
|
165
|
+
*/
|
|
166
|
+
export async function warmTenantIdentity(instanceDidArg?: string): Promise<void> {
|
|
167
|
+
if (!hasDynamicIdentity()) return;
|
|
168
|
+
try {
|
|
169
|
+
await resolveTenantIdentity(instanceDidArg);
|
|
170
|
+
} catch (err: unknown) {
|
|
171
|
+
logger.warn('[tenant-identity] warm failed — wallet access will fail-closed', {
|
|
172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The host user directory for the embedded runtime. In the DID-Connect world the
|
|
178
|
+
// user's DID IS the wallet DID, so getUser echoes it as a connected wallet account
|
|
179
|
+
// (so getWalletDid(user) resolves to the user's own DID); the rest are no-ops. This
|
|
180
|
+
// is the shared semantics the CF build-alias shim also implements
|
|
181
|
+
// (cloudflare/shims/blocklet-sdk/auth-service.ts).
|
|
182
|
+
const EMBEDDED_DIRECTORY: BlockletDirectory = {
|
|
183
|
+
getUser(did: string) {
|
|
184
|
+
if (!did) return { user: null };
|
|
185
|
+
return {
|
|
186
|
+
user: { did, fullName: did, email: '', phone: '', remark: '', connectedAccounts: [{ provider: 'wallet', did }] },
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
getUsers() {
|
|
190
|
+
return { users: [] };
|
|
191
|
+
},
|
|
192
|
+
getVault() {
|
|
193
|
+
return null;
|
|
194
|
+
},
|
|
195
|
+
getBlocklet() {
|
|
196
|
+
return { id: '', site: { id: '' } };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The embedded host identity services — the AUTH_SERVICE-backed implementations of
|
|
202
|
+
* the optional IdentityDriver methods, derived entirely from getInstanceAppIdentity
|
|
203
|
+
* (so a host need not reimplement them). arc-node spreads BOTH into its identity
|
|
204
|
+
* driver; CF spreads `getBusinessWallet` only (it keeps its build-alias directory
|
|
205
|
+
* shim). Stateless — every call reads the active driver via resolveTenantIdentity /
|
|
206
|
+
* getCachedTenantIdentity, so one instance serves every tenant.
|
|
207
|
+
*/
|
|
208
|
+
export function createEmbeddedIdentityServices(): {
|
|
209
|
+
getBusinessWallet(chain: 'arcblock' | 'ethereum'): WalletObject;
|
|
210
|
+
directory(): BlockletDirectory;
|
|
211
|
+
} {
|
|
212
|
+
return {
|
|
213
|
+
getBusinessWallet(chain: 'arcblock' | 'ethereum'): WalletObject {
|
|
214
|
+
const identity = getCachedTenantIdentity();
|
|
215
|
+
return chain === 'ethereum' ? identity.ethWallet : identity.wallet;
|
|
216
|
+
},
|
|
217
|
+
directory(): BlockletDirectory {
|
|
218
|
+
return EMBEDDED_DIRECTORY;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -13,8 +13,34 @@
|
|
|
13
13
|
// unchanged; multi-tenant hosts inject a driver that maps Host -> instanceDid
|
|
14
14
|
// and returns null for unknown hosts (the middleware then fails closed 4xx).
|
|
15
15
|
|
|
16
|
+
import type { WalletObject } from '@ocap/wallet';
|
|
17
|
+
|
|
16
18
|
import { getDefaultInstanceDid, getTenantMode, TenantError, TENANT_HOST_UNRESOLVED } from '../tenant';
|
|
17
19
|
|
|
20
|
+
/** Branding/profile used by DID-Connect prompts (mirrors did-connect-service InstanceAppInfoDTO). */
|
|
21
|
+
export interface InstanceAppInfo {
|
|
22
|
+
name?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
icon?: string;
|
|
25
|
+
link?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-instance app signing identity used to build the DID-Connect authenticator
|
|
30
|
+
* (mirrors did-connect-service@4.0.3 InstanceAppIdentityDTO). The AUTH_SERVICE
|
|
31
|
+
* RPC `getInstanceAppIdentity(instanceDid)` already resolves the arc run-mode
|
|
32
|
+
* (instance `app:sk` → instance identity; else auth-service root `APP_SK/APP_PSK`;
|
|
33
|
+
* else fail-closed), so the payment runtime never reimplements "instance ?? root".
|
|
34
|
+
*/
|
|
35
|
+
export interface InstanceAppIdentity {
|
|
36
|
+
/** Current app signing key for this instance. */
|
|
37
|
+
appSk: string;
|
|
38
|
+
/** Permanent app signing key, present when the instance has rotated keys. */
|
|
39
|
+
appPsk?: string;
|
|
40
|
+
/** Branding/profile for DID-Connect prompts. */
|
|
41
|
+
appInfo?: InstanceAppInfo;
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
export interface IdentityDriver {
|
|
19
45
|
/**
|
|
20
46
|
* Resolve a request Host to its tenant instanceDid, or null/undefined when
|
|
@@ -28,6 +54,41 @@ export interface IdentityDriver {
|
|
|
28
54
|
* driver uses the process key and never calls it.
|
|
29
55
|
*/
|
|
30
56
|
getAppEk?(instanceDid: string): Promise<string> | string;
|
|
57
|
+
/**
|
|
58
|
+
* S3-CF (DID convergence): the per-instance app signing identity backing the
|
|
59
|
+
* DID-Connect authenticator. The AUTH_SERVICE-backed driver (CF + arc-node
|
|
60
|
+
* embedded) implements this via `AUTH_SERVICE.getInstanceAppIdentity`; the
|
|
61
|
+
* blocklet-server runtime uses the @blocklet/sdk wallet wrapper and never calls
|
|
62
|
+
* it. Optional on the contract for that reason — `resolveTenantIdentity` throws
|
|
63
|
+
* a clear error if a non-SDK runtime reaches a driver that lacks it.
|
|
64
|
+
*/
|
|
65
|
+
getInstanceAppIdentity?(instanceDid: string): Promise<InstanceAppIdentity> | InstanceAppIdentity;
|
|
66
|
+
/**
|
|
67
|
+
* The current tenant's business chain wallet (the receiving address + on-chain
|
|
68
|
+
* tx/refund/payout signer). SYNCHRONOUS — `wallet.address` is read inline on hot
|
|
69
|
+
* paths — so it reads the warmed per-tenant cache (see warmTenantIdentity), never
|
|
70
|
+
* an RPC. Present on AUTH_SERVICE-backed drivers (arc-node + CF, derived from
|
|
71
|
+
* getInstanceAppIdentity); ABSENT on the blocklet-server default, where auth.ts
|
|
72
|
+
* falls back to the @blocklet/sdk env wallet (which alone handles the remote-sign
|
|
73
|
+
* / delegation / migration cases a bare appSk cannot). This is the single seam:
|
|
74
|
+
* a consumer never branches on the runtime, only on "does the driver provide it".
|
|
75
|
+
*/
|
|
76
|
+
getBusinessWallet?(chain: 'arcblock' | 'ethereum'): WalletObject;
|
|
77
|
+
/**
|
|
78
|
+
* The host user directory (`blocklet.getUser` etc.). Present on the arc-node
|
|
79
|
+
* embedded driver (a DID-echo: the DID-Connect DID IS the wallet DID); ABSENT on
|
|
80
|
+
* blocklet-server (real BlockletService) and on CF (its build-alias shim). auth.ts
|
|
81
|
+
* uses `driver.directory?.() ?? <real BlockletService>` — same one-seam pattern.
|
|
82
|
+
*/
|
|
83
|
+
directory?(): BlockletDirectory;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The subset of @blocklet/sdk BlockletService the payment runtime calls. */
|
|
87
|
+
export interface BlockletDirectory {
|
|
88
|
+
getUser(did: string, opts?: any): Promise<{ user: any }> | { user: any };
|
|
89
|
+
getUsers(params?: any): Promise<{ users: any[] }> | { users: any[] };
|
|
90
|
+
getVault(): Promise<any> | any;
|
|
91
|
+
getBlocklet(): Promise<any> | any;
|
|
31
92
|
}
|
|
32
93
|
|
|
33
94
|
/**
|
|
@@ -33,7 +33,7 @@ export function applyPaymentCoreMigrations(driver: DbDriverForMigrations): Promi
|
|
|
33
33
|
return applySql(driver, paymentCoreSqlMigrations);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export type { IdentityDriver } from './identity';
|
|
36
|
+
export type { IdentityDriver, InstanceAppIdentity, InstanceAppInfo, BlockletDirectory } from './identity';
|
|
37
37
|
export { createDefaultIdentityDriver, setIdentityDriver, getIdentityDriver } from './identity';
|
|
38
38
|
|
|
39
39
|
export type { SecretsDriver } from './secrets';
|
|
@@ -37,7 +37,17 @@ export function createFetchHandler(app: Hono): FetchHandler {
|
|
|
37
37
|
const method = request.method.toUpperCase();
|
|
38
38
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
39
39
|
const body = hasBody ? await request.arrayBuffer() : undefined;
|
|
40
|
-
|
|
40
|
+
// ③ re-expose the stripped mount prefix as `x-path-prefix` so internal
|
|
41
|
+
// absolute-URL builders reconstruct the PUBLIC url. The DID-Connect
|
|
42
|
+
// authenticator's wallet callback URL is built by did-connect-js
|
|
43
|
+
// `prepareBaseUrl`, which reads `x-path-prefix`; without it the wallet is
|
|
44
|
+
// told to call <origin>/api/did/payment/auth instead of
|
|
45
|
+
// <origin><basePath>/api/did/payment/auth and the connect times out. The
|
|
46
|
+
// Request's headers are immutable, so set it on a fresh Headers; never
|
|
47
|
+
// clobber a host-provided value (blocklet-server sets its own mount).
|
|
48
|
+
const headers = new Headers(request.headers);
|
|
49
|
+
if (!headers.has('x-path-prefix')) headers.set('x-path-prefix', basePath);
|
|
50
|
+
return app.fetch(new Request(url.toString(), { method, headers, body }));
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
// no strip needed — hand the request straight to hono (it owns body parsing).
|
|
@@ -56,13 +56,25 @@ function injectJobTenant(job: any): any {
|
|
|
56
56
|
* Phase 5 have no instance_did: single mode falls back to the deployment
|
|
57
57
|
* default, multi mode refuses permanently (non-retryable + structured alert).
|
|
58
58
|
*/
|
|
59
|
+
// Warm the tenant identity then run the handler — the queue analogue of the HTTP
|
|
60
|
+
// contextMiddleware warm. Signing queues (payment/refund/payout/...) access the
|
|
61
|
+
// business wallet synchronously; warming inside the tenant span makes `wallet`/
|
|
62
|
+
// `ethWallet` resolve to the job's tenant (no-op on blocklet-server).
|
|
63
|
+
async function warmThenRun<T>(onJob: (job: T) => Promise<any>, job: T): Promise<any> {
|
|
64
|
+
const { warmTenantIdentity } =
|
|
65
|
+
// eslint-disable-next-line global-require
|
|
66
|
+
require('../did-connect/tenant-identity') as typeof import('../did-connect/tenant-identity');
|
|
67
|
+
await warmTenantIdentity();
|
|
68
|
+
return onJob(job);
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
function runJobWithTenant<T>(job: any, onJob: (job: T) => Promise<any>): Promise<any> {
|
|
60
72
|
const tenant = job?.instance_did;
|
|
61
73
|
if (tenant) {
|
|
62
|
-
return withTenant(tenant, () => onJob
|
|
74
|
+
return withTenant(tenant, () => warmThenRun(onJob, job));
|
|
63
75
|
}
|
|
64
76
|
if (getTenantMode() === 'single') {
|
|
65
|
-
return withTenant(getDefaultInstanceDid(), () => onJob
|
|
77
|
+
return withTenant(getDefaultInstanceDid(), () => warmThenRun(onJob, job));
|
|
66
78
|
}
|
|
67
79
|
const err = new TenantError(TENANT_CONTEXT_MISSING, 'legacy job without tenant refused in multi mode');
|
|
68
80
|
(err as any).nonRetryable = true;
|
|
@@ -17,6 +17,7 @@ import { translate } from '../../locales';
|
|
|
17
17
|
import { context } from '../../libs/context';
|
|
18
18
|
import { TenantError, TENANT_HOST_UNRESOLVED } from '../../libs/tenant';
|
|
19
19
|
import { resolveTenantForHost } from '../../libs/drivers/identity';
|
|
20
|
+
import { warmTenantIdentity } from '../../libs/did-connect/tenant-identity';
|
|
20
21
|
|
|
21
22
|
export function ensureI18n(): MiddlewareHandler {
|
|
22
23
|
return (c, next) => {
|
|
@@ -67,6 +68,12 @@ export function contextMiddleware(): MiddlewareHandler {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
return context.run({ requestId, requestedBy, instanceDid }, async () => {
|
|
71
|
+
// Warm the tenant's signing identity (arc/CF dynamic runtime only — no-op on
|
|
72
|
+
// blocklet-server) so the synchronous business wallet proxies (libs/auth.ts:
|
|
73
|
+
// `wallet`/`ethWallet`) resolve to THIS tenant's wallet inside the handler.
|
|
74
|
+
// Best-effort: a request that never touches a wallet is not blocked; one that
|
|
75
|
+
// does fails-closed at getCachedTenantIdentity.
|
|
76
|
+
await warmTenantIdentity(instanceDid);
|
|
70
77
|
await next();
|
|
71
78
|
});
|
|
72
79
|
};
|
|
@@ -13,9 +13,20 @@ import { getCookie, setCookie } from 'hono/cookie';
|
|
|
13
13
|
import { sign, verify, getCsrfSecret } from '@blocklet/sdk/lib/util/csrf';
|
|
14
14
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
15
15
|
import { isDidWalletConnect } from '@blocklet/sdk/lib/util/wallet';
|
|
16
|
+
import { readConfig } from '../../libs/env';
|
|
16
17
|
|
|
17
18
|
const isEmpty = (v: unknown): boolean => v === undefined || v === null || v === '';
|
|
18
19
|
|
|
20
|
+
// The CSRF secret is HOST-GLOBAL, not per-tenant: this middleware runs BEFORE the
|
|
21
|
+
// tenant context is established (pipeline order cors→xss→csrf→…→context), so it
|
|
22
|
+
// has no instanceDid, and it binds the host-global login_token anyway. An embedded
|
|
23
|
+
// host (arc) that has no BLOCKLET_APP_SK env injects a dedicated secret via the
|
|
24
|
+
// config slot (`PAYMENT_CSRF_SECRET`); the standard blocklet server falls back to
|
|
25
|
+
// the SDK's env-based secret. See docs/architecture/payment-credential-sourcing.md.
|
|
26
|
+
function csrfSecret(): string {
|
|
27
|
+
return readConfig('PAYMENT_CSRF_SECRET') || getCsrfSecret();
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
// Express SDK: shouldGenerateToken === GET; shouldVerifyToken === mutating
|
|
20
31
|
// method AND an x-csrf-token cookie already exists AND path is not /mcp AND the
|
|
21
32
|
// caller is not a DID Wallet connect request.
|
|
@@ -43,7 +54,7 @@ export function csrf(): MiddlewareHandler {
|
|
|
43
54
|
|
|
44
55
|
if (method === 'GET') {
|
|
45
56
|
if (loginToken) {
|
|
46
|
-
const newCsrf = sign(
|
|
57
|
+
const newCsrf = sign(csrfSecret(), loginToken);
|
|
47
58
|
if (newCsrf !== existingCsrf) {
|
|
48
59
|
setCookie(c, 'x-csrf-token', newCsrf, { sameSite: 'Strict', secure: true });
|
|
49
60
|
}
|
|
@@ -59,7 +70,7 @@ export function csrf(): MiddlewareHandler {
|
|
|
59
70
|
if (isDidWalletConnect(c.req.header())) return next();
|
|
60
71
|
|
|
61
72
|
const headerCsrf = c.req.header('x-csrf-token');
|
|
62
|
-
if (existingCsrf === headerCsrf && verify(
|
|
73
|
+
if (existingCsrf === headerCsrf && verify(csrfSecret(), existingCsrf as string, loginToken as string)) {
|
|
63
74
|
return next();
|
|
64
75
|
}
|
|
65
76
|
return c.text('Invalid request: csrf token mismatch, please refresh the page try again', 403);
|
|
@@ -16,19 +16,14 @@ import type { Model } from 'sequelize';
|
|
|
16
16
|
import { getVerifyData, verify } from '@blocklet/sdk/lib/util/verify-sign';
|
|
17
17
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
18
18
|
import { verifyLoginToken } from '@blocklet/sdk/lib/util/verify-session';
|
|
19
|
-
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
20
|
-
import { getWallet } from '@blocklet/sdk/lib/wallet';
|
|
21
19
|
import { isDevelopmentEnv, enableDevFakeAuth } from '../../libs/env';
|
|
20
|
+
// The embed-auth wallet (verifies the embed authToken signature) is the dynamic
|
|
21
|
+
// business wallet (libs/auth.ts): the active tenant's wallet on arc/CF, the env
|
|
22
|
+
// wallet on blocklet-server. The embed branch runs inside the warmed request
|
|
23
|
+
// scope, so synchronous access resolves the tenant wallet.
|
|
24
|
+
import { wallet } from '../../libs/auth';
|
|
22
25
|
import { Customer } from '../../store/models/customer';
|
|
23
26
|
|
|
24
|
-
// Phase 13b parity: lazy wallet — getWallet() needs BLOCKLET_APP_PK which a bare
|
|
25
|
-
// host lacks; only the embed branch uses it.
|
|
26
|
-
let cachedWallet: ReturnType<typeof getWallet> | undefined;
|
|
27
|
-
const wallet = () => {
|
|
28
|
-
cachedWallet ??= getWallet();
|
|
29
|
-
return cachedWallet;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
27
|
type PermissionSpec<T extends Model> = {
|
|
33
28
|
component?: boolean;
|
|
34
29
|
roles?: string[];
|
|
@@ -132,7 +127,7 @@ export function authenticate<T extends Model>({
|
|
|
132
127
|
// the express version, which does not await next()).
|
|
133
128
|
let embedOk = false;
|
|
134
129
|
try {
|
|
135
|
-
const w = wallet
|
|
130
|
+
const w = wallet;
|
|
136
131
|
const verified = await w.verify(embedId, embedToken);
|
|
137
132
|
if (!verified) {
|
|
138
133
|
return c.json({ error: `Invalid signature for embed: ${embedId}` }, 401);
|
|
@@ -5,6 +5,7 @@ import { systemFindAll, systemFindByPk, systemFindOne } from '../store/scoped';
|
|
|
5
5
|
import { mintNftForCheckoutSession } from '../integrations/arcblock/nft';
|
|
6
6
|
import { ensurePassportIssued } from '../integrations/blocklet/passport';
|
|
7
7
|
import { ensureInvoiceForCheckout } from '../routes/connect/shared';
|
|
8
|
+
import { withTenant } from '../libs/context';
|
|
8
9
|
import dayjs from '../libs/dayjs';
|
|
9
10
|
import { events } from '../libs/event';
|
|
10
11
|
import logger from '../libs/logger';
|
|
@@ -89,16 +90,27 @@ export async function startCheckoutSessionQueue() {
|
|
|
89
90
|
},
|
|
90
91
|
});
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
// Per-record tenant context: queue push stamps the job tenant via getInstanceDid,
|
|
94
|
+
// which fails closed in multi mode without it. for-of + await (not forEach(async),
|
|
95
|
+
// which leaks an unhandledRejection → FATAL on boot); per-record catch isolates.
|
|
96
|
+
for (const checkoutSession of checkoutSessions) {
|
|
97
|
+
const dispatch = async () => {
|
|
98
|
+
const exist = await checkoutSessionQueue.get(checkoutSession.id);
|
|
99
|
+
if (!exist) {
|
|
100
|
+
checkoutSessionQueue.push({
|
|
101
|
+
id: checkoutSession.id,
|
|
102
|
+
job: { id: checkoutSession.id, action: 'expire' },
|
|
103
|
+
runAt: checkoutSession.expires_at,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
// eslint-disable-next-line no-await-in-loop
|
|
109
|
+
await (checkoutSession.instance_did ? withTenant(checkoutSession.instance_did, dispatch) : dispatch());
|
|
110
|
+
} catch (error) {
|
|
111
|
+
logger.error('startCheckoutSessionQueue: re-queue failed', { id: checkoutSession.id, error });
|
|
100
112
|
}
|
|
101
|
-
}
|
|
113
|
+
}
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
checkoutSessionQueue.on('failed', ({ id, job, error }) => {
|
package/api/src/queues/event.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Op } from 'sequelize';
|
|
|
2
2
|
import { isCfWorker } from '../libs/env';
|
|
3
3
|
import { systemFindAll, systemFindByPk } from '../store/scoped';
|
|
4
4
|
|
|
5
|
+
import { withTenant } from '../libs/context';
|
|
5
6
|
import { events } from '../libs/event';
|
|
6
7
|
import logger from '../libs/logger';
|
|
7
8
|
import createQueue from '../libs/queue';
|
|
@@ -83,22 +84,43 @@ export const eventQueue = createQueue<EventJob>({
|
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
export const startEventQueue = async () => {
|
|
87
|
+
// Cross-tenant recovery scan: fetch instance_did too so the recovered push
|
|
88
|
+
// carries its tenant. In multi mode startup runs with NO tenant context, so a
|
|
89
|
+
// push without instance_did would hit injectJobTenant -> getInstanceDid ->
|
|
90
|
+
// TENANT_CONTEXT_MISSING (a FATAL unhandledRejection from the async forEach
|
|
91
|
+
// below). The handler re-derives the tenant from the row, but the PUSH itself
|
|
92
|
+
// must already be tenant-stamped to survive multi mode.
|
|
86
93
|
const docs = await systemFindAll(Event, {
|
|
87
94
|
where: {
|
|
88
95
|
pending_webhooks: { [Op.gt]: 0 },
|
|
89
96
|
},
|
|
90
|
-
attributes: ['id'],
|
|
97
|
+
attributes: ['id', 'instance_did'],
|
|
91
98
|
});
|
|
92
99
|
|
|
93
100
|
logger.info(`Found ${docs.length} events with pending webhooks`);
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
// Sequential + per-doc try/catch: one bad row must never crash startup
|
|
103
|
+
// recovery (await in the original forEach left rejections unhandled). The
|
|
104
|
+
// push runs inside withTenant(event.instance_did) so injectJobTenant stamps
|
|
105
|
+
// the correct tenant — in multi mode there is no ambient context at startup.
|
|
106
|
+
for (const x of docs) {
|
|
107
|
+
if (!x.instance_did) {
|
|
108
|
+
logger.warn('skip pending-webhook event with no tenant', { id: x.id });
|
|
109
|
+
} else {
|
|
110
|
+
try {
|
|
111
|
+
// eslint-disable-next-line no-await-in-loop -- bounded startup recovery
|
|
112
|
+
await withTenant(x.instance_did, async () => {
|
|
113
|
+
const exist = await eventQueue.get(x.id);
|
|
114
|
+
if (!exist) {
|
|
115
|
+
logger.info(`Pushing event ${x.id} to queue`);
|
|
116
|
+
eventQueue.push({ id: x.id, job: { eventId: x.id }, persist: false });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.error('failed to recover pending-webhook event', { id: x.id, error });
|
|
121
|
+
}
|
|
100
122
|
}
|
|
101
|
-
}
|
|
123
|
+
}
|
|
102
124
|
|
|
103
125
|
logger.info('Finished starting event queue');
|
|
104
126
|
};
|