payment-kit 1.29.2 → 1.29.4
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
package/api/src/bootstrap.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import logger from './libs/logger';
|
|
11
11
|
import { getDefaultInstanceDid, getTenantMode } from './libs/tenant';
|
|
12
|
+
import { createBlockletServerDidConnectRuntime } from './libs/auth';
|
|
12
13
|
import { createEmbeddedPaymentService, type EmbeddedPaymentService } from './service';
|
|
14
|
+
import { attachNodeStatic } from './host-node/serve-static';
|
|
13
15
|
import { sequelize } from './store/sequelize';
|
|
14
16
|
import { blockletPort } from './libs/env';
|
|
15
17
|
|
|
@@ -23,6 +25,15 @@ export function buildService(): { service: EmbeddedPaymentService; port: number
|
|
|
23
25
|
config: { ...process.env }, // Phase 12 narrows this to an explicit schema
|
|
24
26
|
db: { sequelize },
|
|
25
27
|
tenancy,
|
|
28
|
+
// S3-CF Phase 1 ①: the blocklet-server host owns static/SPA serving (moved out
|
|
29
|
+
// of the runtime-neutral buildHonoApp). attachNodeStatic is production-gated, so
|
|
30
|
+
// dev (this same bootstrap) is unaffected — identical to the prior behavior.
|
|
31
|
+
staticHandler: attachNodeStatic,
|
|
32
|
+
// S3-CF (DID convergence): blocklet-server is the ONE host that uses the
|
|
33
|
+
// @blocklet/sdk DID-Connect wrapper (autoConnect / notification relay /
|
|
34
|
+
// federated login). Injected explicitly via the same host-injection entry as
|
|
35
|
+
// CF/arc-node — not relied on as an implicit libs/auth default.
|
|
36
|
+
didConnectRuntime: createBlockletServerDidConnectRuntime(),
|
|
26
37
|
});
|
|
27
38
|
|
|
28
39
|
const port = parseInt(blockletPort()!, 10);
|
package/api/src/crons/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { CheckoutSession } from '../store/models';
|
|
|
34
34
|
import { createOverdueDetection } from './overdue-detection';
|
|
35
35
|
import { createPaymentStat } from './payment-stat';
|
|
36
36
|
import { retryPendingEvents } from './retry-pending-events';
|
|
37
|
+
import { perTenant } from './tenant-fanout';
|
|
37
38
|
import { SubscriptionTrialWillEndSchedule } from './subscription-trial-will-end';
|
|
38
39
|
import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
|
|
39
40
|
import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
|
|
@@ -72,19 +73,19 @@ function init() {
|
|
|
72
73
|
{
|
|
73
74
|
name: 'subscription.will.renew',
|
|
74
75
|
time: notificationCronTime(),
|
|
75
|
-
fn: () => new SubscriptionWillRenewSchedule().run(),
|
|
76
|
+
fn: perTenant('subscription.will.renew', () => new SubscriptionWillRenewSchedule().run()),
|
|
76
77
|
options: { runOnInit: true },
|
|
77
78
|
},
|
|
78
79
|
{
|
|
79
80
|
name: 'subscription.trial.will.end',
|
|
80
81
|
time: notificationCronTime(),
|
|
81
|
-
fn: () => new SubscriptionTrialWillEndSchedule().run(),
|
|
82
|
+
fn: perTenant('subscription.trial.will.end', () => new SubscriptionTrialWillEndSchedule().run()),
|
|
82
83
|
options: { runOnInit: true },
|
|
83
84
|
},
|
|
84
85
|
{
|
|
85
86
|
name: 'customer.subscription.will_canceled',
|
|
86
87
|
time: notificationCronTime(),
|
|
87
|
-
fn: () => new SubscriptionWillCanceledSchedule().run(),
|
|
88
|
+
fn: perTenant('customer.subscription.will_canceled', () => new SubscriptionWillCanceledSchedule().run()),
|
|
88
89
|
options: { runOnInit: true },
|
|
89
90
|
},
|
|
90
91
|
{
|
|
@@ -105,34 +106,34 @@ function init() {
|
|
|
105
106
|
{
|
|
106
107
|
name: 'checkoutSession.cleanup.expired',
|
|
107
108
|
time: expiredSessionCleanupCronTime(),
|
|
108
|
-
fn: async () => {
|
|
109
|
+
fn: perTenant('checkoutSession.cleanup.expired', async () => {
|
|
109
110
|
const removedCount = await CheckoutSession.cleanupExpiredSessions();
|
|
110
111
|
logger.info('CheckoutSession.cleanupExpiredSessions', { removedCount });
|
|
111
|
-
},
|
|
112
|
+
}),
|
|
112
113
|
options: { runOnInit: true },
|
|
113
114
|
},
|
|
114
115
|
{
|
|
115
116
|
name: 'stripe.invoice.sync',
|
|
116
117
|
time: stripeInvoiceCronTime(),
|
|
117
|
-
fn: batchHandleStripeInvoices,
|
|
118
|
+
fn: perTenant('stripe.invoice.sync', batchHandleStripeInvoices),
|
|
118
119
|
options: { runOnInit: false },
|
|
119
120
|
},
|
|
120
121
|
{
|
|
121
122
|
name: 'stripe.payment.sync',
|
|
122
123
|
time: stripePaymentCronTime(),
|
|
123
|
-
fn: batchHandleStripePayments,
|
|
124
|
+
fn: perTenant('stripe.payment.sync', batchHandleStripePayments),
|
|
124
125
|
options: { runOnInit: false },
|
|
125
126
|
},
|
|
126
127
|
{
|
|
127
128
|
name: 'stripe.subscription.sync',
|
|
128
129
|
time: stripeSubscriptionCronTime(),
|
|
129
|
-
fn: batchHandleStripeSubscriptions,
|
|
130
|
+
fn: perTenant('stripe.subscription.sync', batchHandleStripeSubscriptions),
|
|
130
131
|
options: { runOnInit: false },
|
|
131
132
|
},
|
|
132
133
|
{
|
|
133
134
|
name: 'customer.stake.revoked',
|
|
134
135
|
time: revokeStakeCronTime(),
|
|
135
|
-
fn: checkStakeRevokeTx,
|
|
136
|
+
fn: perTenant('customer.stake.revoked', checkStakeRevokeTx),
|
|
136
137
|
options: { runOnInit: false },
|
|
137
138
|
},
|
|
138
139
|
{
|
|
@@ -141,7 +142,7 @@ function init() {
|
|
|
141
142
|
// webhook missed). See blocklets/core/api/src/integrations/iap-reconcile.ts.
|
|
142
143
|
name: 'iap.reconcile',
|
|
143
144
|
time: iapReconcileCronTime(),
|
|
144
|
-
fn: () => runIapReconcile(),
|
|
145
|
+
fn: perTenant('iap.reconcile', () => runIapReconcile()),
|
|
145
146
|
options: { runOnInit: false },
|
|
146
147
|
},
|
|
147
148
|
{
|
|
@@ -150,19 +151,19 @@ function init() {
|
|
|
150
151
|
// See blocklets/core/api/src/crons/retry-pending-events.ts.
|
|
151
152
|
name: 'event.retry',
|
|
152
153
|
time: eventRetryCronTime(),
|
|
153
|
-
fn: () => retryPendingEvents(),
|
|
154
|
+
fn: perTenant('event.retry', () => retryPendingEvents()),
|
|
154
155
|
options: { runOnInit: false },
|
|
155
156
|
},
|
|
156
157
|
{
|
|
157
158
|
name: 'payment.stat',
|
|
158
159
|
time: paymentStatCronTime(),
|
|
159
|
-
fn: () => createPaymentStat(),
|
|
160
|
+
fn: perTenant('payment.stat', () => createPaymentStat()),
|
|
160
161
|
options: { runOnInit: false },
|
|
161
162
|
},
|
|
162
163
|
{
|
|
163
164
|
name: 'payment.daily.report',
|
|
164
165
|
time: overdueDetectionCronTime(),
|
|
165
|
-
fn: () => createOverdueDetection(),
|
|
166
|
+
fn: perTenant('payment.daily.report', () => createOverdueDetection()),
|
|
166
167
|
options: { runOnInit: false },
|
|
167
168
|
},
|
|
168
169
|
{
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Multi-tenant cron fan-out (S3 arc-payment-embed — Phase 7).
|
|
2
|
+
//
|
|
3
|
+
// Background crons that issue a top-level tenant-scoped model query
|
|
4
|
+
// (subscription schedules, reconciliation, payment stat, overdue detection,
|
|
5
|
+
// stake-revoke, expired-session cleanup, stripe sync, event retry) have NO
|
|
6
|
+
// request to carry a tenant. In multi mode `getInstanceDid()` throws
|
|
7
|
+
// TENANT_CONTEXT_MISSING and the whole pass does nothing. Single mode is
|
|
8
|
+
// unaffected — the default tenant auto-resolves.
|
|
9
|
+
//
|
|
10
|
+
// Tenant enumeration is SELF-CONTAINED (decision 2026-06-13): payment-core owns
|
|
11
|
+
// its own scope, so we iterate the tenants that have payment configuration in
|
|
12
|
+
// OUR OWN store (`provisionTenant` seeds a PaymentMethod row per tenant) rather
|
|
13
|
+
// than asking the host for a registry. A tenant with no payment rows has
|
|
14
|
+
// nothing for these crons to do, and this fits a lazily-provisioned host
|
|
15
|
+
// (arc-node) where a tenant appears in payment.db only after its first request.
|
|
16
|
+
//
|
|
17
|
+
// Queue-starter crons (startXQueue) are NOT wrapped: each persisted job already
|
|
18
|
+
// carries its own instance_did and runs inside withTenant(job.tenant) via the
|
|
19
|
+
// queue runtime (libs/queue runJobWithTenant). Wrapping them would re-scan and
|
|
20
|
+
// double-process.
|
|
21
|
+
|
|
22
|
+
import { withTenant } from '../libs/context';
|
|
23
|
+
import logger from '../libs/logger';
|
|
24
|
+
import { getTenantMode } from '../libs/tenant';
|
|
25
|
+
import { systemFindAll } from '../store/scoped';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tenants that have payment configuration in payment-core's own store. Reads
|
|
29
|
+
* across tenants (systemFindAll runs inside runAsSystem so the TenantModel base
|
|
30
|
+
* does not re-scope to a — here absent — active tenant). DISTINCT is done in JS
|
|
31
|
+
* to stay compatible with both real Sequelize and the worker's sequelize-d1
|
|
32
|
+
* shim. PaymentMethod is the canonical "provisioned tenant" marker; a tenant
|
|
33
|
+
* that has any payment data necessarily has a payment method.
|
|
34
|
+
*/
|
|
35
|
+
export async function listProvisionedTenants(): Promise<string[]> {
|
|
36
|
+
// eslint-disable-next-line global-require
|
|
37
|
+
const { PaymentMethod } = require('../store/models');
|
|
38
|
+
// raw:true returns plain rows, not Model instances — cast through unknown.
|
|
39
|
+
const rows = (await systemFindAll(PaymentMethod, {
|
|
40
|
+
attributes: ['instance_did'],
|
|
41
|
+
raw: true,
|
|
42
|
+
})) as unknown as Array<{ instance_did?: string | null }>;
|
|
43
|
+
const dids = new Set<string>();
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (row?.instance_did) dids.add(row.instance_did);
|
|
46
|
+
}
|
|
47
|
+
return [...dids];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wrap a tenant-scoped cron fn so it runs once per provisioned tenant inside
|
|
52
|
+
* `withTenant`. Single mode runs the fn as-is (the default tenant auto-resolves
|
|
53
|
+
* in getInstanceDid). Multi mode enumerates provisioned tenants and runs the fn
|
|
54
|
+
* per tenant; per-tenant errors are ISOLATED (caught + logged) so one tenant's
|
|
55
|
+
* failure never aborts the rest of the pass.
|
|
56
|
+
*
|
|
57
|
+
* @param listTenants enumeration seam (defaults to listProvisionedTenants;
|
|
58
|
+
* injected in tests so the fan-out logic is exercised without a DB).
|
|
59
|
+
*/
|
|
60
|
+
export function perTenant(
|
|
61
|
+
name: string,
|
|
62
|
+
fn: () => Promise<unknown> | unknown,
|
|
63
|
+
listTenants: () => Promise<string[]> = listProvisionedTenants
|
|
64
|
+
): () => Promise<void> {
|
|
65
|
+
return async () => {
|
|
66
|
+
if (getTenantMode() === 'single') {
|
|
67
|
+
await fn();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const dids = await listTenants();
|
|
71
|
+
if (dids.length === 0) return;
|
|
72
|
+
logger.info('cron.tenant.fanout', { cron: name, tenantCount: dids.length });
|
|
73
|
+
for (const instanceDid of dids) {
|
|
74
|
+
// async callback so a SYNCHRONOUS throw from fn surfaces as a rejection
|
|
75
|
+
// (and stays inside the tenant's ALS scope) — then .catch isolates it.
|
|
76
|
+
// eslint-disable-next-line no-await-in-loop, require-await -- sequential per-tenant pass; async wraps sync throws
|
|
77
|
+
await withTenant(instanceDid, async () => fn()).catch((error: unknown) =>
|
|
78
|
+
logger.error('cron tenant pass failed', { cron: name, instanceDid, error })
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Node host adapter — the AUTH_SERVICE-backed DID-Connect runtime for the
|
|
2
|
+
// arc-node embedded host. The node analog of cloudflare/did-connect-runtime.ts:
|
|
3
|
+
// the shared @arcblock/did-connect-js runtime whose signing wallet/appInfo are
|
|
4
|
+
// resolved per-tenant via resolveTenantIdentity (getInstanceAppIdentity), NOT a
|
|
5
|
+
// fixed isolate key.
|
|
6
|
+
//
|
|
7
|
+
// Without this, a bare host falls back to createBlockletServerDidConnectRuntime,
|
|
8
|
+
// whose WalletAuthenticator is constructed with `makeWallet()` (env BLOCKLET_APP_SK)
|
|
9
|
+
// — which arc never sets — so the first DID-Connect sign (e.g. /api/did/payment/token
|
|
10
|
+
// → generateSession) throws "Missing public key for SK wallet: BLOCKLET_APP_PK".
|
|
11
|
+
//
|
|
12
|
+
// Differences from the CF runtime are node-only:
|
|
13
|
+
// - txEncoder = the @ocap/client/encode CBOR encoder (same one the
|
|
14
|
+
// blocklet-server runtime passes), resolved from the host's node_modules.
|
|
15
|
+
// - tokenStorage omitted → buildTokenStorage's file-backed nedb default
|
|
16
|
+
// (os.tmpdir on a bare host; DID-Connect session tokens are ephemeral).
|
|
17
|
+
// - chainInfo omitted → the 14 payment DID handlers resolve it per-payment in
|
|
18
|
+
// onConnect, exactly as the blocklet-server runtime (which passes none).
|
|
19
|
+
import type { DidConnectRuntime, DidConnectTokenStorage } from '../libs/auth';
|
|
20
|
+
import { createDidConnectJsRuntime } from '../libs/did-connect/runtime-did-connect-js';
|
|
21
|
+
|
|
22
|
+
export function createNodeDidConnectRuntime(opts?: {
|
|
23
|
+
tokenStorage?: DidConnectTokenStorage;
|
|
24
|
+
timeout?: number;
|
|
25
|
+
}): DidConnectRuntime {
|
|
26
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
27
|
+
const { createTxEncoder } = require('@ocap/client/encode');
|
|
28
|
+
return createDidConnectJsRuntime({
|
|
29
|
+
tokenStorage: opts?.tokenStorage,
|
|
30
|
+
txEncoder: createTxEncoder(),
|
|
31
|
+
timeout: opts?.timeout ?? 30000,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// P3b (README D3 / F3) — clean node static + SPA handler for embedded hosts (arc).
|
|
2
|
+
//
|
|
3
|
+
// Unlike `attachNodeStatic` (serve-static.ts), this is NOT blocklet-server bound:
|
|
4
|
+
// - no isProduction gate, no blockletAppDir (host passes an explicit webRoot)
|
|
5
|
+
// - does NOT reuse the @blocklet/sdk-bound `fallback` middleware, which injects
|
|
6
|
+
// getBlockletJs / theme server-side. This handler serves index.html VERBATIM
|
|
7
|
+
// — the window.blocklet bootstrap is already baked in at BUILD time (P2), so
|
|
8
|
+
// there is ZERO server-side injection (T3b.3).
|
|
9
|
+
//
|
|
10
|
+
// Wired via the host `staticHandler` slot (P3a / service.ts:81), AFTER the
|
|
11
|
+
// api/connect routes. Order (T3b.1): SPA fallback FIRST (html GET/HEAD that is
|
|
12
|
+
// not a RESOURCE_PATTERN asset → index.html), serveStatic AFTER (real asset
|
|
13
|
+
// files; a miss falls through to 404, never index.html — T3b.2).
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
import type { Hono } from 'hono';
|
|
18
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
19
|
+
import { RESOURCE_PATTERN } from '@blocklet/constant';
|
|
20
|
+
|
|
21
|
+
function acceptsHtml(accept: string): boolean {
|
|
22
|
+
if (!accept) return true;
|
|
23
|
+
return accept.includes('text/html') || accept.includes('application/xhtml+xml') || accept.includes('*/*');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a host-injectable static/SPA handler over `webRoot` (the arc webRoot =
|
|
28
|
+
* `@arcblock/payment-service/web`). Returns a `(app) => void` for the
|
|
29
|
+
* staticHandler slot. Throws at construction if webRoot has no index.html
|
|
30
|
+
* (fail fast — a misconfigured webRoot must not silently 404 every navigation).
|
|
31
|
+
*/
|
|
32
|
+
export function createNodeStaticHandler(webRoot: string): (app: Hono) => void {
|
|
33
|
+
const indexPath = path.join(webRoot, 'index.html');
|
|
34
|
+
if (!fs.existsSync(indexPath)) {
|
|
35
|
+
throw new Error(`createNodeStaticHandler: webRoot has no index.html: ${indexPath}`);
|
|
36
|
+
}
|
|
37
|
+
// index.html is an immutable build artifact for the process lifetime — read once.
|
|
38
|
+
let cachedIndex: string | null = null;
|
|
39
|
+
|
|
40
|
+
return (app: Hono) => {
|
|
41
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
42
|
+
const { serveStatic } = require('@hono/node-server/serve-static');
|
|
43
|
+
|
|
44
|
+
// SPA fallback FIRST — html navigations (not assets) get index.html VERBATIM.
|
|
45
|
+
app.use('*', async (c, next) => {
|
|
46
|
+
const method = c.req.method.toUpperCase();
|
|
47
|
+
if (
|
|
48
|
+
(method !== 'GET' && method !== 'HEAD') ||
|
|
49
|
+
!acceptsHtml(c.req.header('accept') || '') ||
|
|
50
|
+
RESOURCE_PATTERN.test(c.req.path)
|
|
51
|
+
) {
|
|
52
|
+
return next();
|
|
53
|
+
}
|
|
54
|
+
if (cachedIndex == null) cachedIndex = fs.readFileSync(indexPath, 'utf8');
|
|
55
|
+
return c.html(cachedIndex);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Real asset files. Pass the ABSOLUTE webRoot: serveStatic does
|
|
59
|
+
// `join(root, filename)` + existsSync, so an absolute root resolves the file
|
|
60
|
+
// regardless of process.cwd(). (attachNodeStatic mapped to a cwd-relative
|
|
61
|
+
// path; that breaks in an embedded host whose cwd is not the app dir — e.g.
|
|
62
|
+
// the arc daemon, whose cwd ≠ the node_modules webRoot.) A miss falls
|
|
63
|
+
// through (→ 404).
|
|
64
|
+
app.use('*', serveStatic({ root: webRoot }));
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default createNodeStaticHandler;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Node host (blocklet-server) static + SPA serving.
|
|
2
|
+
//
|
|
3
|
+
// S3-CF Phase 1 inversion ①: this is the node-only static/SPA shell that used to
|
|
4
|
+
// live INSIDE buildHonoApp (service.ts). It is moved here so the runtime-neutral
|
|
5
|
+
// hono app carries ZERO node:fs / @hono/node-server — that is what let `http.fetch`
|
|
6
|
+
// converge to a single surface shared by node, CF, and the standalone worker.
|
|
7
|
+
//
|
|
8
|
+
// The blocklet-server entry (bootstrap.ts) injects this as the `staticHandler`
|
|
9
|
+
// slot. It is a verbatim move of the old block (serveStatic + the node:fs
|
|
10
|
+
// fallback), still production-gated, so blocklet-server behavior is unchanged. The
|
|
11
|
+
// CF/standalone worker serves assets via env.ASSETS and injects nothing here, so
|
|
12
|
+
// this module is never reached by the worker bundle.
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
import type { Hono } from 'hono';
|
|
16
|
+
|
|
17
|
+
import { isProduction, blockletAppDir } from '../libs/env';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wire production static + SPA fallback onto the full node hono app. No-op outside
|
|
21
|
+
* production (identical to the old `if (isProduction())` gate in buildHonoApp).
|
|
22
|
+
*
|
|
23
|
+
* `fallback` runs first and skips asset paths (RESOURCE_PATTERN) + non-html, so
|
|
24
|
+
* real files fall through to serveStatic and html navigations get the injected
|
|
25
|
+
* index.html. Both modules are required lazily so this file's import stays cheap.
|
|
26
|
+
*/
|
|
27
|
+
export function attachNodeStatic(app: Hono): void {
|
|
28
|
+
if (!isProduction()) return;
|
|
29
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
30
|
+
const { serveStatic } = require('@hono/node-server/serve-static');
|
|
31
|
+
// eslint-disable-next-line global-require
|
|
32
|
+
const { fallback } = require('../middlewares/hono/fallback');
|
|
33
|
+
const staticDir = path.resolve(blockletAppDir()!, 'dist');
|
|
34
|
+
// serveStatic resolves `root` relative to process.cwd(); map the absolute app
|
|
35
|
+
// dist dir back to a cwd-relative path so it resolves to the same place.
|
|
36
|
+
const staticRoot = path.relative(process.cwd(), staticDir) || '.';
|
|
37
|
+
app.use('*', fallback('index.html', { root: staticDir })); // injected index.html for html GET (skips assets)
|
|
38
|
+
app.use('*', serveStatic({ root: staticRoot })); // real asset files
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default attachNodeStatic;
|
package/api/src/libs/auth.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import os from 'os';
|
|
1
2
|
import path from 'path';
|
|
2
3
|
|
|
3
4
|
import type { LiteralUnion } from 'type-fest';
|
|
4
5
|
import type { WalletObject } from '@ocap/wallet';
|
|
5
6
|
import env, { blockletAppId } from './env';
|
|
7
|
+
import { getIdentityDriver } from './drivers';
|
|
6
8
|
|
|
7
9
|
import logger from './logger';
|
|
8
10
|
|
|
@@ -91,42 +93,179 @@ function makeWallet(...args: any[]): WalletObject {
|
|
|
91
93
|
return getWallet(...args);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
// The blocklet-server business wallets — env-derived (BLOCKLET_APP_SK), lazy as
|
|
97
|
+
// before. They are the FALLBACK when the active IdentityDriver provides no
|
|
98
|
+
// getBusinessWallet (blocklet-server / tests); the @blocklet/sdk getWallet alone
|
|
99
|
+
// handles the remote-sign / delegation / migration cases a bare appSk cannot.
|
|
100
|
+
const envWallet: WalletObject = lazyProxy(() => makeWallet());
|
|
101
|
+
const envEthWallet: WalletObject = lazyProxy(() => makeWallet('ethereum'));
|
|
96
102
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
103
|
+
// THE SINGLE SEAM (wallet-authenticator-dynamic.md §0 — "差异收敛在 IdentityDriver"):
|
|
104
|
+
// the active business wallet is whatever the active IdentityDriver provides, else
|
|
105
|
+
// the env wallet. arc-node + CF inject a driver whose getBusinessWallet resolves the
|
|
106
|
+
// current request/job tenant's wallet from the warmed cache (fail-closed outside a
|
|
107
|
+
// warmed scope); blocklet-server's default driver provides none → env wallet,
|
|
108
|
+
// unchanged. No consumer branches on the runtime — only on driver capability. The
|
|
109
|
+
// driver is consulted on EVERY access so two concurrent tenants never share a wallet.
|
|
110
|
+
function activeBusinessWallet(chain: 'arcblock' | 'ethereum'): WalletObject {
|
|
111
|
+
const driver = getIdentityDriver();
|
|
112
|
+
if (typeof driver.getBusinessWallet === 'function') {
|
|
113
|
+
return driver.getBusinessWallet(chain);
|
|
114
|
+
}
|
|
115
|
+
return chain === 'ethereum' ? envEthWallet : envWallet;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Per-access proxy onto the active tenant's business wallet (see activeBusinessWallet). */
|
|
119
|
+
function businessWalletProxy(chain: 'arcblock' | 'ethereum'): WalletObject {
|
|
120
|
+
return new Proxy({} as WalletObject, {
|
|
121
|
+
get(_t, prop) {
|
|
122
|
+
const w = activeBusinessWallet(chain) as any;
|
|
123
|
+
const value = w[prop];
|
|
124
|
+
if (typeof value !== 'function') return value;
|
|
125
|
+
if (value._isMockFunction) return value; // keep jest spies usable (parity with lazyProxy)
|
|
126
|
+
return value.bind(w);
|
|
127
|
+
},
|
|
128
|
+
set(_t, prop, value) {
|
|
129
|
+
(activeBusinessWallet(chain) as any)[prop] = value;
|
|
130
|
+
return true;
|
|
131
|
+
},
|
|
132
|
+
has(_t, prop) {
|
|
133
|
+
return prop in (activeBusinessWallet(chain) as any);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const wallet: WalletObject = businessWalletProxy('arcblock');
|
|
139
|
+
export const ethWallet: WalletObject = businessWalletProxy('ethereum');
|
|
140
|
+
|
|
141
|
+
// S3-CF Phase 1 (DID convergence): the DID-Connect token storage is a host slot.
|
|
142
|
+
export interface DidConnectTokenStorage {
|
|
143
|
+
read(token: string): Promise<any> | any;
|
|
144
|
+
create(token: string, status?: string): Promise<any> | any;
|
|
145
|
+
update(token: string, updates: Record<string, any>): Promise<any> | any;
|
|
146
|
+
delete(token: string): Promise<any> | any;
|
|
147
|
+
[key: string]: any;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// S3-CF Phase 1 (DID convergence) — the DID-Connect RUNTIME is host-injectable.
|
|
104
151
|
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
// The boundary is by HOST, not by node-vs-CF:
|
|
153
|
+
// - blocklet-server → the @blocklet/sdk WalletAuthenticator/WalletHandlers
|
|
154
|
+
// wrapper (autoConnect / notification relay /
|
|
155
|
+
// federated login). The ONLY runtime that uses SDK.
|
|
156
|
+
// - arc-node embedded + CF → the REAL @arcblock/did-connect-js stack, identity
|
|
157
|
+
// from AUTH_SERVICE.getInstanceAppIdentity, host-
|
|
158
|
+
// injected tokenStorage. They differ ONLY in the host
|
|
159
|
+
// adapter (storage/DB/event/waitUntil, CF chain
|
|
160
|
+
// config/txEncoder/timeout), NOT in SDK-vs-non-SDK.
|
|
161
|
+
// Unified = the host-injection + identity-resolution mechanism; SDK is one runtime
|
|
162
|
+
// implementation, not the node default. Every host injects via setDidConnectRuntime
|
|
163
|
+
// BEFORE `handlers` first materializes (lazyProxy → buildConnectRoutesHono).
|
|
164
|
+
export interface DidConnectRuntime {
|
|
165
|
+
/** Build the DID-Connect authenticator (signs sessions/certs). */
|
|
166
|
+
createAuthenticator(): any;
|
|
167
|
+
/** Wrap the authenticator + tokenStorage into the WalletHandlers used to attach routes. */
|
|
168
|
+
createHandlers(opts: { authenticator: any; tokenStorage: DidConnectTokenStorage }): any;
|
|
169
|
+
/** The DID-Connect token store; falls back to the blocklet-server nedb default when omitted. */
|
|
170
|
+
tokenStorage?: DidConnectTokenStorage;
|
|
171
|
+
}
|
|
113
172
|
|
|
114
|
-
|
|
173
|
+
let injectedRuntime: DidConnectRuntime | null = null;
|
|
174
|
+
let injectedTokenStorage: DidConnectTokenStorage | null = null;
|
|
175
|
+
|
|
176
|
+
/** Inject the full DID-Connect runtime (CF: real @arcblock/did-connect-js stack). */
|
|
177
|
+
export function setDidConnectRuntime(runtime: DidConnectRuntime | null): void {
|
|
178
|
+
injectedRuntime = runtime;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Inject only the DID-Connect token storage (storage slot; runtime keeps its default authenticator/handlers). */
|
|
182
|
+
export function setDidConnectTokenStorage(storage: DidConnectTokenStorage | null): void {
|
|
183
|
+
injectedTokenStorage = storage;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The blocklet-server runtime — the ONLY runtime that uses the @blocklet/sdk
|
|
187
|
+
// wallet wrapper (autoConnect / notification relay / memberAppInfo / federated
|
|
188
|
+
// login / BlockletService). This is NOT "the node default": arc-node embedded and
|
|
189
|
+
// CF use the AUTH_SERVICE + @arcblock/did-connect-js runtime instead. The
|
|
190
|
+
// blocklet-server bootstrap injects this explicitly; it is also the legacy
|
|
191
|
+
// fallback when no runtime is injected (tests / a bare blocklet-server start).
|
|
192
|
+
//
|
|
193
|
+
// The signing wallet is derived from BLOCKLET_APP_PK (permanent app pk → address =
|
|
194
|
+
// appId) to fix the migrated-blocklet rotating-session-key cert mismatch
|
|
195
|
+
// ("Agent did does not match with certificate issuer", @blocklet/sdk PR #12810).
|
|
196
|
+
export function createBlockletServerDidConnectRuntime(): DidConnectRuntime {
|
|
197
|
+
return {
|
|
198
|
+
createAuthenticator() {
|
|
199
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
200
|
+
const { WalletAuthenticator } = require('@blocklet/sdk/lib/wallet-authenticator');
|
|
201
|
+
// @blocklet/sdk bundles @arcblock/did-connect-js@4.0.2, which made `txEncoder`
|
|
202
|
+
// mandatory for encoding *Tx prepareTx claims (scan-to-pay → TransferV3Tx) and
|
|
203
|
+
// dropped the old internal @ocap/client fallback (now only a devDependency; the
|
|
204
|
+
// shipped dist never requires it). The SDK's WalletAuthenticator wrapper does not
|
|
205
|
+
// inject one, so we pass the same CBOR encoder the CF/arc runtime uses
|
|
206
|
+
// (`createTxEncoder` from @ocap/client/encode). It passes through the wrapper's
|
|
207
|
+
// `...getAuthenticatorProps(options)` spread untouched.
|
|
208
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
209
|
+
const { createTxEncoder } = require('@ocap/client/encode');
|
|
210
|
+
return new WalletAuthenticator({ wallet: makeWallet(undefined, '', 'sk'), txEncoder: createTxEncoder() });
|
|
211
|
+
},
|
|
212
|
+
createHandlers({ authenticator: auth, tokenStorage }) {
|
|
213
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
214
|
+
const { WalletHandlers } = require('@blocklet/sdk/lib/wallet-handler');
|
|
215
|
+
return new WalletHandlers({ authenticator: auth, tokenStorage });
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Consume the injected runtime. When none is injected we fall back to the
|
|
221
|
+
// blocklet-server (SDK) runtime as a LEGACY compat for a bare blocklet-server /
|
|
222
|
+
// test start — NOT as a generic node default. CF and arc-node embedded hosts MUST
|
|
223
|
+
// inject their AUTH_SERVICE runtime; if a CF host ever reaches this fallback, the
|
|
224
|
+
// workerd @blocklet/sdk wallet-* shims are fail-fast (they throw on construct), so
|
|
225
|
+
// it can never silently register zero DID routes. (A spec also asserts the
|
|
226
|
+
// AUTH_SERVICE runtime never imports the @blocklet/sdk wallet modules.)
|
|
227
|
+
function activeRuntime(): DidConnectRuntime {
|
|
228
|
+
return injectedRuntime ?? createBlockletServerDidConnectRuntime();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildTokenStorage(): DidConnectTokenStorage {
|
|
232
|
+
// priority: runtime-provided store → explicit storage slot → blocklet-server
|
|
233
|
+
// nedb default (file-backed; the node blocklet-server host only).
|
|
234
|
+
if (injectedRuntime?.tokenStorage) return injectedRuntime.tokenStorage;
|
|
235
|
+
if (injectedTokenStorage) return injectedTokenStorage;
|
|
115
236
|
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
116
237
|
const AuthStorage = require('@arcblock/did-connect-storage-nedb');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
238
|
+
return new AuthStorage({
|
|
239
|
+
// `env.dataDir` (the blocklet runtime data dir) is undefined in a bare
|
|
240
|
+
// embedded host like arc — `store/sequelize.ts` already guards its own
|
|
241
|
+
// use, but this DID-Connect token store was the one unguarded path and
|
|
242
|
+
// crashed `buildConnectRoutesHono` with `path.join(undefined, …)`. Fall
|
|
243
|
+
// back to the OS temp dir (DID-Connect session tokens are ephemeral; the
|
|
244
|
+
// blocklet server still gets its real dataDir natively).
|
|
245
|
+
dbPath: path.join(env.dataDir || os.tmpdir(), 'auth.db'),
|
|
246
|
+
// @ts-ignore
|
|
247
|
+
onload: console.warn,
|
|
126
248
|
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const authenticator: any = lazyProxy(() => activeRuntime().createAuthenticator());
|
|
252
|
+
|
|
253
|
+
export const handlers: any = lazyProxy(() => {
|
|
254
|
+
const tokenStorage = buildTokenStorage();
|
|
255
|
+
return activeRuntime().createHandlers({ authenticator, tokenStorage });
|
|
127
256
|
});
|
|
128
257
|
|
|
258
|
+
// The user directory (`blocklet.getUser` etc.). SAME single seam as the wallet:
|
|
259
|
+
// the active IdentityDriver's `directory()` if it provides one, else the real
|
|
260
|
+
// @blocklet/sdk BlockletService. arc-node injects a DID-echo directory (the real
|
|
261
|
+
// BlockletService can't construct without BLOCKLET_APP_ID); CF resolves the real
|
|
262
|
+
// BlockletService to its build-alias shim; blocklet-server gets the real one. No
|
|
263
|
+
// `isCfWorker`/runtime branch — only "does the driver provide a directory".
|
|
129
264
|
export const blocklet: any = lazyProxy(() => {
|
|
265
|
+
const driver = getIdentityDriver();
|
|
266
|
+
if (typeof driver.directory === 'function') {
|
|
267
|
+
return driver.directory();
|
|
268
|
+
}
|
|
130
269
|
ensureNotificationPatch();
|
|
131
270
|
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
132
271
|
const { BlockletService } = require('@blocklet/sdk/service/auth');
|
package/api/src/libs/context.ts
CHANGED
|
@@ -63,6 +63,17 @@ class RequestContextManager {
|
|
|
63
63
|
throw new TenantError(TENANT_CONTEXT_MISSING, 'tenant context is missing in multi-tenant mode');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Non-throwing peek at the established tenant context — returns the stored
|
|
68
|
+
* instanceDid or undefined when no context is set (regardless of tenant mode).
|
|
69
|
+
* Used by the DID-Connect tenant-context middleware to decide whether the
|
|
70
|
+
* request is already scoped (e.g. the CF worker wrapped /api/* in withTenant) or
|
|
71
|
+
* needs its own Host→tenant resolution.
|
|
72
|
+
*/
|
|
73
|
+
peekInstanceDid(): string | undefined {
|
|
74
|
+
return this.storage.getStore()?.instanceDid;
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
/**
|
|
67
78
|
* Run fn as a system operation: TenantModel scoping is bypassed for the span
|
|
68
79
|
* of fn so legitimate cross-tenant reads can load rows regardless of tenant.
|