payment-kit 1.29.1 → 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/dev.ts +41 -2
- package/api/hono.d.ts +42 -0
- package/api/node-sqlite.d.ts +12 -0
- package/api/src/bootstrap.ts +47 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +41 -37
- package/api/src/crons/metering-subscription-detection.ts +1 -1
- package/api/src/crons/overdue-detection.ts +2 -2
- package/api/src/crons/retry-pending-events.ts +6 -0
- 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/index.ts +22 -161
- package/api/src/integrations/app-store/client.ts +3 -4
- package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
- package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +21 -7
- package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
- package/api/src/integrations/google-play/handlers/voided.ts +2 -2
- package/api/src/integrations/google-play/verify.ts +3 -2
- package/api/src/integrations/iap-reconcile.ts +3 -5
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
- package/api/src/libs/archive/query.ts +19 -0
- package/api/src/libs/audit.ts +61 -4
- package/api/src/libs/auth.ts +247 -47
- package/api/src/libs/context.ts +89 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- 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/auth-storage.ts +118 -0
- package/api/src/libs/drivers/cron.ts +264 -0
- package/api/src/libs/drivers/db.ts +170 -0
- package/api/src/libs/drivers/identity.ts +142 -0
- package/api/src/libs/drivers/index.ts +40 -0
- package/api/src/libs/drivers/locks.ts +226 -0
- package/api/src/libs/drivers/migrate-runner.ts +70 -0
- package/api/src/libs/drivers/queue.ts +104 -0
- package/api/src/libs/drivers/secrets.ts +194 -0
- package/api/src/libs/env.ts +170 -54
- package/api/src/libs/exchange-rate/service.ts +7 -6
- package/api/src/libs/http-fetch-adapter.ts +60 -0
- package/api/src/libs/invoice.ts +1 -1
- package/api/src/libs/lock.ts +51 -47
- package/api/src/libs/logger.ts +48 -8
- package/api/src/libs/notification/index.ts +1 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
- package/api/src/libs/overdraft-protection.ts +1 -1
- package/api/src/libs/payout.ts +1 -1
- package/api/src/libs/queue/index.ts +271 -52
- package/api/src/libs/queue/runtime.ts +175 -0
- package/api/src/libs/resource.ts +3 -3
- package/api/src/libs/secrets.ts +38 -0
- package/api/src/libs/session.ts +3 -2
- package/api/src/libs/subscription.ts +5 -5
- package/api/src/libs/tenant.ts +92 -0
- package/api/src/libs/url.ts +3 -3
- package/api/src/libs/util.ts +21 -13
- package/api/src/middlewares/hono/cdn.ts +63 -0
- package/api/src/middlewares/hono/context.ts +80 -0
- package/api/src/middlewares/hono/csrf.ts +83 -0
- package/api/src/middlewares/hono/fallback.ts +194 -0
- package/api/src/middlewares/hono/pipeline.ts +73 -0
- package/api/src/middlewares/hono/resource-mount.ts +42 -0
- package/api/src/middlewares/hono/resource.ts +63 -0
- package/api/src/middlewares/hono/security.ts +209 -0
- package/api/src/middlewares/hono/session.ts +114 -0
- package/api/src/middlewares/hono/xss.ts +61 -0
- package/api/src/queues/auto-recharge.ts +12 -10
- package/api/src/queues/checkout-session.ts +38 -21
- package/api/src/queues/credit-consume.ts +40 -36
- package/api/src/queues/credit-grant.ts +25 -18
- package/api/src/queues/credit-reconciliation.ts +7 -5
- package/api/src/queues/discount-status.ts +9 -6
- package/api/src/queues/event.ts +41 -11
- package/api/src/queues/exchange-rate-health.ts +49 -30
- package/api/src/queues/invoice.ts +18 -15
- package/api/src/queues/notification.ts +14 -7
- package/api/src/queues/payment.ts +64 -37
- package/api/src/queues/payout.ts +37 -21
- package/api/src/queues/refund.ts +36 -18
- package/api/src/queues/subscription.ts +83 -53
- package/api/src/queues/token-transfer.ts +15 -10
- package/api/src/queues/usage-record.ts +8 -5
- package/api/src/queues/vendors/commission.ts +7 -5
- package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
- package/api/src/queues/vendors/fulfillment.ts +4 -2
- package/api/src/queues/vendors/return-processor.ts +5 -3
- package/api/src/queues/vendors/return-scanner.ts +5 -4
- package/api/src/queues/vendors/status-check.ts +10 -7
- package/api/src/queues/webhook.ts +60 -32
- package/api/src/routes/connect/shared.ts +1 -2
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
- package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
- package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
- package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
- package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
- package/api/src/routes/hono/credit-tokens.ts +43 -0
- package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
- package/api/src/routes/{customers.ts → hono/customers.ts} +199 -224
- package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
- package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
- package/api/src/routes/{events.ts → hono/events.ts} +107 -71
- package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
- package/api/src/routes/hono/exchange-rates.ts +77 -0
- package/api/src/routes/hono/index.ts +115 -0
- package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
- package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
- package/api/src/routes/hono/integrations/stripe.ts +74 -0
- package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
- package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
- package/api/src/routes/hono/meters.ts +288 -0
- package/api/src/routes/hono/passports.ts +73 -0
- package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
- package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
- package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
- package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
- package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
- package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
- package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
- package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
- package/api/src/routes/{products.ts → hono/products.ts} +172 -159
- package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
- package/api/src/routes/hono/redirect.ts +24 -0
- package/api/src/routes/{refunds.ts → hono/refunds.ts} +98 -83
- package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
- package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
- package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
- package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
- package/api/src/routes/hono/tool.ts +69 -0
- package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
- package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
- package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
- package/api/src/routes/hono/webhook-endpoints.ts +126 -0
- package/api/src/service.ts +814 -0
- package/api/src/store/migrations/20230911-seeding.ts +2 -1
- package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
- package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
- package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
- package/api/src/store/models/auto-recharge-config.ts +22 -10
- package/api/src/store/models/checkout-session.ts +15 -14
- package/api/src/store/models/coupon.ts +29 -20
- package/api/src/store/models/credit-grant.ts +38 -29
- package/api/src/store/models/credit-transaction.ts +32 -21
- package/api/src/store/models/customer.ts +19 -17
- package/api/src/store/models/discount.ts +11 -2
- package/api/src/store/models/entitlement-grant.ts +21 -9
- package/api/src/store/models/entitlement-product.ts +21 -9
- package/api/src/store/models/entitlement.ts +19 -10
- package/api/src/store/models/event.ts +18 -9
- package/api/src/store/models/exchange-rate-provider.ts +17 -4
- package/api/src/store/models/invoice-item.ts +18 -9
- package/api/src/store/models/invoice.ts +16 -8
- package/api/src/store/models/meter-event.ts +27 -9
- package/api/src/store/models/meter.ts +31 -22
- package/api/src/store/models/payment-currency.ts +25 -8
- package/api/src/store/models/payment-intent.ts +15 -6
- package/api/src/store/models/payment-link.ts +15 -6
- package/api/src/store/models/payment-method.ts +38 -22
- package/api/src/store/models/payment-stat.ts +18 -9
- package/api/src/store/models/payout.ts +15 -6
- package/api/src/store/models/price-quote.ts +17 -8
- package/api/src/store/models/price.ts +24 -12
- package/api/src/store/models/pricing-table.ts +29 -20
- package/api/src/store/models/product-vendor.ts +20 -10
- package/api/src/store/models/product.ts +15 -6
- package/api/src/store/models/promotion-code.ts +14 -6
- package/api/src/store/models/refund.ts +15 -6
- package/api/src/store/models/revenue-snapshot.ts +21 -9
- package/api/src/store/models/setting.ts +18 -9
- package/api/src/store/models/setup-intent.ts +36 -27
- package/api/src/store/models/subscription-item.ts +21 -9
- package/api/src/store/models/subscription-schedule.ts +21 -9
- package/api/src/store/models/subscription.ts +21 -10
- package/api/src/store/models/tax-rate.ts +29 -21
- package/api/src/store/models/usage-record.ts +11 -2
- package/api/src/store/models/webhook-attempt.ts +18 -9
- package/api/src/store/models/webhook-endpoint.ts +18 -9
- package/api/src/store/scoped-core.ts +55 -0
- package/api/src/store/scoped.ts +247 -0
- package/api/src/store/sequelize.ts +82 -23
- package/api/src/store/sql-migrations.ts +20 -0
- package/api/src/store/tenant-backfill.ts +260 -0
- package/api/src/store/tenant-model.ts +124 -0
- package/api/src/store/tenant-tables.ts +50 -0
- package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -0
- package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
- package/api/tests/fixtures/bare-query-violation.ts +13 -0
- package/api/tests/fixtures/core-env-violation.ts +10 -0
- package/api/tests/fixtures/host-read-violation.ts +19 -0
- package/api/tests/fixtures/tenants.ts +4 -0
- package/api/tests/integrations/iap-tenant.spec.ts +284 -0
- package/api/tests/libs/archive-query.spec.ts +26 -0
- package/api/tests/libs/audit-tenant.spec.ts +153 -0
- package/api/tests/libs/context.spec.ts +204 -0
- package/api/tests/libs/core-config.spec.ts +115 -0
- package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
- package/api/tests/libs/crons-conservation-d2.spec.ts +52 -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/lock-tenant.spec.ts +66 -0
- package/api/tests/libs/scoped.spec.ts +222 -0
- package/api/tests/libs/secrets-facade.spec.ts +52 -0
- package/api/tests/libs/service-host.spec.ts +37 -0
- package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
- package/api/tests/libs/tenant-middleware.spec.ts +42 -0
- package/api/tests/libs/tenant-scanner.spec.ts +120 -0
- package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
- package/api/tests/middlewares/hono/context.spec.ts +113 -0
- package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
- package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
- package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
- package/api/tests/middlewares/hono/security.spec.ts +181 -0
- package/api/tests/middlewares/hono/session.spec.ts +42 -0
- package/api/tests/middlewares/hono/xss.spec.ts +81 -0
- package/api/tests/models/tenant-backfill.spec.ts +287 -0
- package/api/tests/models/tenant-columns-model.spec.ts +46 -0
- package/api/tests/models/tenant-columns.spec.ts +161 -0
- package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
- package/api/tests/queues/credit-consume.spec.ts +8 -1
- package/api/tests/queues/event-tenant.spec.ts +292 -0
- package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
- package/api/tests/queues/queue-parity.spec.ts +249 -0
- package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
- package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
- package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
- package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
- package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
- package/api/tests/service/collapse.spec.ts +96 -0
- 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/api/tests/store/tenant-crosscut.spec.ts +202 -0
- package/api/tests/store/tenant-model-spike.spec.ts +177 -0
- package/api/tests/store/tenant-model.spec.ts +162 -0
- package/api/tests/store/tenant-residual.spec.ts +196 -0
- package/api/third.d.ts +4 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +34 -27
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +33 -13
- 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/migrations/0006_tenant_columns.sql +46 -0
- package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
- package/cloudflare/migrations/0008_schema_parity.sql +16 -0
- package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
- package/cloudflare/queue-runtime-mode.ts +13 -0
- package/cloudflare/run-build.js +33 -403
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
- package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
- package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- package/cloudflare/shims/cron.ts +38 -158
- package/cloudflare/shims/events.ts +124 -0
- package/cloudflare/shims/fastq.ts +15 -1
- package/cloudflare/shims/nedb-storage.ts +16 -8
- package/cloudflare/shims/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +69 -0
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +261 -448
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +25 -0
- package/cloudflare/wrangler.staging.json +0 -6
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/bootstrap-inject.ts +166 -0
- package/scripts/core-env-whitelist.json +1 -0
- package/scripts/e2e-12b-runtime.ts +149 -0
- package/scripts/e2e-core-config.ts +125 -0
- package/scripts/e2e-d1-tenancy.ts +116 -0
- package/scripts/e2e-d2-cron-queue.ts +139 -0
- package/scripts/e2e-d3-embedded-multi.ts +171 -0
- package/scripts/e2e-hono-s2.ts +125 -0
- package/scripts/e2e-hono-s3e.ts +135 -0
- package/scripts/e2e-hono-s4.ts +114 -0
- package/scripts/e2e-migration-contract.ts +100 -0
- package/scripts/e2e-s0.ts +61 -0
- package/scripts/e2e-s1.ts +107 -0
- package/scripts/e2e-s2.ts +178 -0
- package/scripts/e2e-s3.ts +110 -0
- package/scripts/e2e-s4.ts +191 -0
- package/scripts/e2e-s5.ts +139 -0
- package/scripts/e2e-s6.ts +127 -0
- package/scripts/e2e-tenant-model.ts +119 -0
- package/scripts/e2e-tenant-worker.ts +199 -0
- package/scripts/gen-sql-migrations.js +46 -0
- package/scripts/phase8-codemod.js +219 -0
- package/scripts/phase9a-env-getters-codemod.js +82 -0
- package/scripts/scan-core-env.js +109 -0
- package/scripts/scan-tenant-queries.js +235 -0
- package/scripts/schema-drift-guard.ts +210 -0
- package/scripts/tenant-scan-whitelist.json +1 -0
- package/src/app.tsx +2 -1
- package/src/env.d.ts +13 -1
- package/src/libs/service-host.ts +13 -0
- package/tsconfig.json +1 -1
- package/vite.arc.config.ts +159 -0
- package/api/src/libs/did-space.ts +0 -235
- package/api/src/libs/middleware.ts +0 -50
- package/api/src/libs/security.ts +0 -192
- package/api/src/queues/space.ts +0 -662
- package/api/src/routes/credit-tokens.ts +0 -38
- package/api/src/routes/exchange-rates.ts +0 -87
- package/api/src/routes/index.ts +0 -142
- package/api/src/routes/integrations/stripe.ts +0 -61
- package/api/src/routes/meters.ts +0 -274
- package/api/src/routes/passports.ts +0 -68
- package/api/src/routes/redirect.ts +0 -20
- package/api/src/routes/tool.ts +0 -65
- package/api/src/routes/webhook-endpoints.ts +0 -126
- package/api/tests/routes/credit-grants.spec.ts +0 -1261
- package/cloudflare/did-connect-auth.ts +0 -527
- package/cloudflare/shims/did-space-js.ts +0 -17
- package/cloudflare/shims/did-space.ts +0 -11
- package/cloudflare/shims/express-compat/index.ts +0 -80
- package/cloudflare/shims/express-compat/types.ts +0 -41
- package/cloudflare/shims/lock.ts +0 -115
- package/cloudflare/shims/queue.ts +0 -611
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
package/cloudflare/shims/xss.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export function xss(_opts?: any) {
|
|
2
2
|
return (_req: any, _res: any, next: any) => next();
|
|
3
3
|
}
|
|
4
|
+
|
|
5
|
+
// Phase 4 (express→hono): the hono xss middleware (LITE app-shell on CF) calls
|
|
6
|
+
// initSanitize(...)(body) to produce sanitizedBody. The old express-compat worker
|
|
7
|
+
// path set req.body WITHOUT xss sanitization, so an identity sanitizer preserves
|
|
8
|
+
// the worker's exact behavior (the node host uses the real @blocklet/xss).
|
|
9
|
+
export function initSanitize(_opts?: any) {
|
|
10
|
+
return <T>(body: T): T => body;
|
|
11
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Phase 7 (W1′): worker tenant-context wiring.
|
|
2
|
+
//
|
|
3
|
+
// The CF worker's `*` middleware only set up DB/env — it never established a
|
|
4
|
+
// tenant context, so every TenantModel query in the worker fell back to the
|
|
5
|
+
// single-mode default. This Hono middleware mirrors the Express
|
|
6
|
+
// `contextMiddleware`: it resolves the request's raw Host to a tenant via the
|
|
7
|
+
// SINGLE-POINT resolver (`resolveTenantForHost`) and wraps the downstream chain
|
|
8
|
+
// in `context.withTenant` so the resolved tenant flows through AsyncLocalStorage
|
|
9
|
+
// into every handler/query.
|
|
10
|
+
//
|
|
11
|
+
// single mode: resolves to the deployment app DID (standalone worker unchanged).
|
|
12
|
+
// multi mode : unknown/missing Host -> 400 fail-closed, no default-tenant
|
|
13
|
+
// fallback, and the handler never runs.
|
|
14
|
+
|
|
15
|
+
import type { Context, Next } from 'hono';
|
|
16
|
+
|
|
17
|
+
import { context } from '../api/src/libs/context';
|
|
18
|
+
import { TenantError, TENANT_HOST_UNRESOLVED } from '../api/src/libs/tenant';
|
|
19
|
+
import { resolveTenantForHost } from '../api/src/libs/drivers/identity';
|
|
20
|
+
|
|
21
|
+
export function tenantMiddleware() {
|
|
22
|
+
return async (c: Context, next: Next) => {
|
|
23
|
+
let instanceDid: string;
|
|
24
|
+
try {
|
|
25
|
+
// raw Host header only — never a proxy header (X-Forwarded-Host); the CF
|
|
26
|
+
// route is responsible for ensuring it cannot be forged by the client.
|
|
27
|
+
instanceDid = await resolveTenantForHost(c.req.header('host'));
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err instanceof TenantError && err.code === TENANT_HOST_UNRESOLVED) {
|
|
30
|
+
return c.json({ error: { code: err.code, message: err.message } }, 400);
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
return context.withTenant(instanceDid, () => next());
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// S3-CF Phase 3 — the CF payment adapter host glue. These unit-test the
|
|
2
|
+
// request-path behavior (caller strip/inject, tenant context, raw-body fidelity,
|
|
3
|
+
// lazy provision in-flight dedup, scheduled due-dispatch, queue demux) against the
|
|
4
|
+
// REAL helpers with fake services — no Miniflare/D1 needed (the worker bundle
|
|
5
|
+
// build is the integration gate; this is the behavior gate).
|
|
6
|
+
|
|
7
|
+
import { injectCaller, createTenantProvisioner, buildFetch, buildScheduled, buildQueueConsumer } from '../cf-adapter';
|
|
8
|
+
import type { CloudflareCallerIdentity } from '../cf-adapter';
|
|
9
|
+
import { context as requestContext } from '../../api/src/libs/context';
|
|
10
|
+
|
|
11
|
+
const noopBindEnv = () => undefined;
|
|
12
|
+
const noopFlush = async () => undefined;
|
|
13
|
+
|
|
14
|
+
describe('injectCaller — CF API gate caller header glue (decision #5)', () => {
|
|
15
|
+
it('happy: injects canonical x-user-* from the host-resolved caller', () => {
|
|
16
|
+
const h = new Headers();
|
|
17
|
+
injectCaller(h, { did: 'zUSER', role: 'admin', authMethod: 'passkey', displayName: 'Alice' });
|
|
18
|
+
expect(h.get('x-user-did')).toBe('did:abt:zUSER');
|
|
19
|
+
expect(h.get('x-user-role')).toBe('blocklet-admin');
|
|
20
|
+
expect(h.get('x-user-provider')).toBe('passkey');
|
|
21
|
+
expect(h.get('x-user-fullname')).toBe(encodeURIComponent('Alice'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('security: STRIPS a client-forged identity header (no caller = anonymous, not forged)', () => {
|
|
25
|
+
const h = new Headers({ 'x-user-did': 'did:abt:zEVIL', 'x-user-role': 'blocklet-owner' });
|
|
26
|
+
injectCaller(h, null);
|
|
27
|
+
expect(h.get('x-user-did')).toBeNull();
|
|
28
|
+
expect(h.get('x-user-role')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('security: a forged header cannot survive even WITH a real caller (strip-then-inject)', () => {
|
|
32
|
+
const h = new Headers({ 'x-user-did': 'did:abt:zEVIL' });
|
|
33
|
+
injectCaller(h, { did: 'zREAL', role: 'member' });
|
|
34
|
+
expect(h.get('x-user-did')).toBe('did:abt:zREAL'); // not zEVIL
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps an already-canonical did:abt: prefix and prefixes a bare did', () => {
|
|
38
|
+
const a = new Headers();
|
|
39
|
+
injectCaller(a, { did: 'did:abt:zABC' });
|
|
40
|
+
expect(a.get('x-user-did')).toBe('did:abt:zABC');
|
|
41
|
+
const b = new Headers();
|
|
42
|
+
injectCaller(b, { did: 'zABC' });
|
|
43
|
+
expect(b.get('x-user-did')).toBe('did:abt:zABC');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('createTenantProvisioner — lazy first-request provisioning, in-flight dedup', () => {
|
|
48
|
+
it('data-loss: concurrent first-requests for the same tenant provision ONCE', async () => {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const provision = jest.fn(async () => {
|
|
51
|
+
calls += 1;
|
|
52
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
53
|
+
});
|
|
54
|
+
const provisioner = createTenantProvisioner(provision);
|
|
55
|
+
await Promise.all([provisioner('zA'), provisioner('zA'), provisioner('zA'), provisioner('zA')]);
|
|
56
|
+
expect(calls).toBe(1);
|
|
57
|
+
expect(provision).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('different tenants each provision once', async () => {
|
|
61
|
+
const provision = jest.fn(async () => undefined);
|
|
62
|
+
const provisioner = createTenantProvisioner(provision);
|
|
63
|
+
await Promise.all([provisioner('zA'), provisioner('zB'), provisioner('zA')]);
|
|
64
|
+
expect(provision).toHaveBeenCalledTimes(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a failed provision is dropped so the next request RETRIES (not cached as done)', async () => {
|
|
68
|
+
let n = 0;
|
|
69
|
+
const provision = jest.fn(async () => {
|
|
70
|
+
n += 1;
|
|
71
|
+
if (n === 1) throw new Error('transient');
|
|
72
|
+
});
|
|
73
|
+
const provisioner = createTenantProvisioner(provision);
|
|
74
|
+
await expect(provisioner('zA')).rejects.toThrow('transient');
|
|
75
|
+
await expect(provisioner('zA')).resolves.toBeUndefined(); // retried, now succeeds
|
|
76
|
+
expect(provision).toHaveBeenCalledTimes(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('null tenant is a no-op (no provision attempt)', async () => {
|
|
80
|
+
const provision = jest.fn(async () => undefined);
|
|
81
|
+
const provisioner = createTenantProvisioner(provision);
|
|
82
|
+
await provisioner(null);
|
|
83
|
+
expect(provision).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// A fake embedded service: captures what the adapter forwards (headers, raw body,
|
|
88
|
+
// basePath) and the tenant context active at fetch time.
|
|
89
|
+
function fakeSvc() {
|
|
90
|
+
const seen: { did?: string; role?: string; tenant?: string; basePath?: string; body?: string } = {};
|
|
91
|
+
return {
|
|
92
|
+
seen,
|
|
93
|
+
http: {
|
|
94
|
+
async fetch(req: Request, opts?: { basePath?: string }) {
|
|
95
|
+
seen.did = req.headers.get('x-user-did') ?? undefined;
|
|
96
|
+
seen.role = req.headers.get('x-user-role') ?? undefined;
|
|
97
|
+
seen.tenant = requestContext.peekInstanceDid();
|
|
98
|
+
seen.basePath = opts?.basePath;
|
|
99
|
+
seen.body = await req.text();
|
|
100
|
+
return new Response('ok', { status: 200 });
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('buildFetch — drives the single http.fetch under the tenant context', () => {
|
|
107
|
+
it('happy: strips forged x-user-*, injects the real caller, runs under withTenant, passes basePath, flushes', async () => {
|
|
108
|
+
const svc = fakeSvc();
|
|
109
|
+
let flushed = 0;
|
|
110
|
+
const fetch = buildFetch({
|
|
111
|
+
svc: svc as any,
|
|
112
|
+
basePath: '/.well-known/payment',
|
|
113
|
+
resolveTenant: () => 'did:abt:zTENANT',
|
|
114
|
+
resolveCaller: () => ({ did: 'zREAL', role: 'member' } as CloudflareCallerIdentity),
|
|
115
|
+
provision: async () => undefined,
|
|
116
|
+
bindEnv: noopBindEnv,
|
|
117
|
+
flush: async () => {
|
|
118
|
+
flushed += 1;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const req = new Request('https://x/.well-known/payment/api/products', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'x-user-did': 'did:abt:zFORGED' },
|
|
124
|
+
body: 'raw-bytes',
|
|
125
|
+
});
|
|
126
|
+
const res = await fetch(req, {}, { waitUntil() {} } as any);
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(svc.seen.did).toBe('did:abt:zREAL'); // forged stripped, real injected
|
|
129
|
+
expect(svc.seen.tenant).toBe('did:abt:zTENANT'); // ran under withTenant
|
|
130
|
+
expect(svc.seen.basePath).toBe('/.well-known/payment');
|
|
131
|
+
expect(svc.seen.body).toBe('raw-bytes'); // data-damage: raw body preserved
|
|
132
|
+
expect(flushed).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('security: forged x-user-did with NO caller resolved → stripped, anonymous (fail-safe)', async () => {
|
|
136
|
+
const svc = fakeSvc();
|
|
137
|
+
const fetch = buildFetch({
|
|
138
|
+
svc: svc as any,
|
|
139
|
+
basePath: '/.well-known/payment',
|
|
140
|
+
resolveTenant: () => 'did:abt:zT',
|
|
141
|
+
resolveCaller: () => null,
|
|
142
|
+
provision: async () => undefined,
|
|
143
|
+
bindEnv: noopBindEnv,
|
|
144
|
+
flush: noopFlush,
|
|
145
|
+
});
|
|
146
|
+
await fetch(new Request('https://x/api/x', { headers: { 'x-user-did': 'did:abt:zEVIL' } }), {}, { waitUntil() {} } as any);
|
|
147
|
+
expect(svc.seen.did).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('data-leak: an unknown host (tenant null) is NOT wrapped — the core fail-closes tenant-scoped routes', async () => {
|
|
151
|
+
const svc = fakeSvc();
|
|
152
|
+
const fetch = buildFetch({
|
|
153
|
+
svc: svc as any,
|
|
154
|
+
basePath: '/.well-known/payment',
|
|
155
|
+
resolveTenant: () => null,
|
|
156
|
+
provision: async () => undefined,
|
|
157
|
+
bindEnv: noopBindEnv,
|
|
158
|
+
flush: noopFlush,
|
|
159
|
+
});
|
|
160
|
+
await fetch(new Request('https://unknown/api/x'), {}, { waitUntil() {} } as any);
|
|
161
|
+
expect(svc.seen.tenant).toBeUndefined(); // no context leaked; core will reject tenant-scoped routes
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('data-loss: a first request triggers the lazy provisioner exactly once for its tenant', async () => {
|
|
165
|
+
const svc = fakeSvc();
|
|
166
|
+
const provision = jest.fn(async () => undefined);
|
|
167
|
+
const provisioner = createTenantProvisioner(provision);
|
|
168
|
+
const fetch = buildFetch({
|
|
169
|
+
svc: svc as any,
|
|
170
|
+
basePath: '/.well-known/payment',
|
|
171
|
+
resolveTenant: () => 'did:abt:zP',
|
|
172
|
+
provision: provisioner,
|
|
173
|
+
bindEnv: noopBindEnv,
|
|
174
|
+
flush: noopFlush,
|
|
175
|
+
});
|
|
176
|
+
const ctx = { waitUntil() {} } as any;
|
|
177
|
+
await Promise.all([
|
|
178
|
+
fetch(new Request('https://x/api/a'), {}, ctx),
|
|
179
|
+
fetch(new Request('https://x/api/b'), {}, ctx),
|
|
180
|
+
]);
|
|
181
|
+
expect(provision).toHaveBeenCalledTimes(1);
|
|
182
|
+
expect(provision).toHaveBeenCalledWith('did:abt:zP');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('buildScheduled — host-driven due dispatch', () => {
|
|
187
|
+
it('runs crons for the intended minute then dispatches due jobs then flushes (ordered)', async () => {
|
|
188
|
+
const order: string[] = [];
|
|
189
|
+
const scheduled = buildScheduled({
|
|
190
|
+
bindEnv: () => order.push('bind'),
|
|
191
|
+
runCrons: async (when: Date) => {
|
|
192
|
+
order.push(`crons:${when.toISOString()}`);
|
|
193
|
+
},
|
|
194
|
+
dispatchDue: async () => {
|
|
195
|
+
order.push('dispatch');
|
|
196
|
+
},
|
|
197
|
+
flush: async () => {
|
|
198
|
+
order.push('flush');
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const t = Date.UTC(2026, 3, 17, 10, 0, 0);
|
|
202
|
+
await scheduled({ cron: '*/5 * * * *', scheduledTime: t } as any, {}, { waitUntil() {} } as any);
|
|
203
|
+
expect(order).toEqual(['bind', `crons:${new Date(t).toISOString()}`, 'dispatch', 'flush']);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('buildQueueConsumer — payment queue message demux', () => {
|
|
208
|
+
it('routes each message to its core queue handle by name, then acks; flushes once', async () => {
|
|
209
|
+
const ran: Array<{ name: string; id: string }> = [];
|
|
210
|
+
const acked: string[] = [];
|
|
211
|
+
let flushed = 0;
|
|
212
|
+
const handle = { pushAndWait: async (p: any) => ran.push({ name: 'refund', id: p.id }) };
|
|
213
|
+
const queue = buildQueueConsumer({
|
|
214
|
+
bindEnv: noopBindEnv,
|
|
215
|
+
getHandle: (name: string) => (name === 'refund' ? (handle as any) : undefined),
|
|
216
|
+
flush: async () => {
|
|
217
|
+
flushed += 1;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const batch = {
|
|
221
|
+
messages: [
|
|
222
|
+
{ body: { queueName: 'refund', jobId: 'j1', job: {} }, ack: () => acked.push('j1') },
|
|
223
|
+
{ body: { queueName: 'unknown', jobId: 'j2', job: {} }, ack: () => acked.push('j2') },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
await queue(batch as any, {}, { waitUntil() {} } as any);
|
|
227
|
+
expect(ran).toEqual([{ name: 'refund', id: 'j1' }]);
|
|
228
|
+
expect(acked).toEqual(['j1', 'j2']); // unknown handler still acked (no infinite redelivery)
|
|
229
|
+
expect(flushed).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('a throwing handler is acked (not retried via CF Queue — D1 scheduled redispatch owns retries)', async () => {
|
|
233
|
+
const acked: string[] = [];
|
|
234
|
+
const handle = { pushAndWait: async () => { throw new Error('boom'); } };
|
|
235
|
+
const queue = buildQueueConsumer({
|
|
236
|
+
bindEnv: noopBindEnv,
|
|
237
|
+
getHandle: () => handle as any,
|
|
238
|
+
flush: noopFlush,
|
|
239
|
+
});
|
|
240
|
+
const batch = { messages: [{ body: { queueName: 'refund', jobId: 'j1', job: {} }, ack: () => acked.push('j1') }] };
|
|
241
|
+
await queue(batch as any, {}, { waitUntil() {} } as any);
|
|
242
|
+
expect(acked).toEqual(['j1']);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// S3-CF (DID convergence) — CF DID-Connect token store: tenant isolation + waitUntil.
|
|
2
|
+
//
|
|
3
|
+
// Deterministic unit tests over a fake D1 (no chain, no AUTH_SERVICE):
|
|
4
|
+
// - a token minted under tenant A is invisible under tenant B (read/exist/
|
|
5
|
+
// update/delete all not-found) — the isolation the convergence requires.
|
|
6
|
+
// - the fire-and-forget `update` (did-connect-js onProcessError) still lands via
|
|
7
|
+
// __cfWaitUntil__ even when the caller never awaits — the "scanned → succeed"
|
|
8
|
+
// regression the standalone worker's waitUntil hack fixed.
|
|
9
|
+
import { context } from '../../api/src/libs/context';
|
|
10
|
+
import { CloudflareTenantTokenStorage } from '../did-connect-token-storage';
|
|
11
|
+
|
|
12
|
+
const A = 'zMOCK_TENANT_A';
|
|
13
|
+
const B = 'zMOCK_TENANT_B';
|
|
14
|
+
|
|
15
|
+
/** Minimal in-memory D1 double covering the prepare/bind/run/first shape the store uses. */
|
|
16
|
+
function fakeD1() {
|
|
17
|
+
const rows = new Map<string, { data: string; expires_at: number }>();
|
|
18
|
+
const db: any = {
|
|
19
|
+
withSession: () => db,
|
|
20
|
+
prepare(sql: string) {
|
|
21
|
+
const stmt: any = {
|
|
22
|
+
_args: [] as any[],
|
|
23
|
+
bind(...args: any[]) {
|
|
24
|
+
stmt._args = args;
|
|
25
|
+
return stmt;
|
|
26
|
+
},
|
|
27
|
+
async run() {
|
|
28
|
+
if (sql.startsWith('INSERT')) {
|
|
29
|
+
const [token, data, exp] = stmt._args;
|
|
30
|
+
rows.set(token, { data, expires_at: exp });
|
|
31
|
+
} else if (sql.startsWith('UPDATE')) {
|
|
32
|
+
const [data, exp, token] = stmt._args;
|
|
33
|
+
if (rows.has(token)) rows.set(token, { data, expires_at: exp });
|
|
34
|
+
} else if (sql.startsWith('DELETE')) {
|
|
35
|
+
rows.delete(stmt._args[0]);
|
|
36
|
+
}
|
|
37
|
+
return { success: true };
|
|
38
|
+
},
|
|
39
|
+
async first() {
|
|
40
|
+
const [token, now] = stmt._args;
|
|
41
|
+
const r = rows.get(token);
|
|
42
|
+
return r && r.expires_at > now ? { data: r.data } : null;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return stmt;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return { db, rows };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('S3-CF DID convergence — CloudflareTenantTokenStorage', () => {
|
|
52
|
+
let restoreEnv: any;
|
|
53
|
+
let restoreWaitUntil: any;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
restoreEnv = (globalThis as any).__CF_ENV__;
|
|
57
|
+
restoreWaitUntil = (globalThis as any).__cfWaitUntil__;
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
(globalThis as any).__CF_ENV__ = restoreEnv;
|
|
61
|
+
(globalThis as any).__cfWaitUntil__ = restoreWaitUntil;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('isolates tokens by tenant — A token is not-found under tenant B', async () => {
|
|
65
|
+
const { db } = fakeD1();
|
|
66
|
+
(globalThis as any).__CF_ENV__ = { DB: db };
|
|
67
|
+
const store = new CloudflareTenantTokenStorage({ ttl: 300 });
|
|
68
|
+
|
|
69
|
+
// create + read under tenant A
|
|
70
|
+
await context.withTenant(A, () => store.create('tok-1', 'created'));
|
|
71
|
+
const underA = await context.withTenant(A, () => store.read('tok-1'));
|
|
72
|
+
expect(underA?.token).toBe('tok-1');
|
|
73
|
+
expect(underA?.instanceDid).toBe(A);
|
|
74
|
+
expect(await context.withTenant(A, () => store.exist('tok-1'))).toBe(true);
|
|
75
|
+
|
|
76
|
+
// the SAME token is invisible under tenant B
|
|
77
|
+
expect(await context.withTenant(B, () => store.read('tok-1'))).toBeNull();
|
|
78
|
+
expect(await context.withTenant(B, () => store.exist('tok-1'))).toBe(false);
|
|
79
|
+
// B cannot advance A's handshake
|
|
80
|
+
expect(await context.withTenant(B, () => store.update('tok-1', { status: 'hijacked' }))).toBeNull();
|
|
81
|
+
// and B's delete is a no-op (A's token survives)
|
|
82
|
+
await context.withTenant(B, () => store.delete('tok-1'));
|
|
83
|
+
expect((await context.withTenant(A, () => store.read('tok-1')))?.token).toBe('tok-1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('update lands via __cfWaitUntil__ even when the caller never awaits (waitUntil regression)', async () => {
|
|
87
|
+
const { db } = fakeD1();
|
|
88
|
+
(globalThis as any).__CF_ENV__ = { DB: db };
|
|
89
|
+
const pending: Promise<any>[] = [];
|
|
90
|
+
(globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => pending.push(p);
|
|
91
|
+
|
|
92
|
+
const store = new CloudflareTenantTokenStorage({ ttl: 300 });
|
|
93
|
+
await context.withTenant(A, async () => {
|
|
94
|
+
await store.create('tok-2', 'scanned');
|
|
95
|
+
// fire-and-forget, as did-connect-js's onProcessError does — NOT awaited
|
|
96
|
+
store.update('tok-2', { status: 'succeed' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// before draining waitUntil, the write may not have landed; drain then assert
|
|
100
|
+
await Promise.all(pending);
|
|
101
|
+
const after = await context.withTenant(A, () => store.read('tok-2'));
|
|
102
|
+
expect(after?.status).toBe('succeed');
|
|
103
|
+
expect(after?.instanceDid).toBe(A); // owning tenant preserved through update
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Phase 7 (W1′): worker `withTenant` wiring. The CF worker had no tenant
|
|
2
|
+
// context — its `*` middleware only `setDB`. This drives the real Hono tenant
|
|
3
|
+
// middleware (the same factory mounted in worker.ts) over a tiny Hono app so
|
|
4
|
+
// the actual Host -> instanceDid resolution, multi-mode fail-closed 4xx, and
|
|
5
|
+
// `context.withTenant` ALS wrapping are exercised end to end.
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
|
|
9
|
+
import { tenantMiddleware } from '../tenant-middleware';
|
|
10
|
+
import { getInstanceDid, context } from '../../api/src/libs/context';
|
|
11
|
+
import { getDefaultInstanceDid } from '../../api/src/libs/tenant';
|
|
12
|
+
import { setIdentityDriver, createDefaultIdentityDriver, type IdentityDriver } from '../../api/src/libs/drivers/identity';
|
|
13
|
+
|
|
14
|
+
const TENANT_A = 'did:abt:zHOSTA';
|
|
15
|
+
const TENANT_B = 'did:abt:zHOSTB';
|
|
16
|
+
|
|
17
|
+
// Build a Hono app wired exactly like worker.ts: tenant middleware on /api/*,
|
|
18
|
+
// then a handler that reports the tenant resolved from the ALS context.
|
|
19
|
+
function buildApp() {
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.use('/api/*', tenantMiddleware());
|
|
22
|
+
app.get('/api/whoami', (c) => c.json({ tenant: getInstanceDid() }));
|
|
23
|
+
// /health is OUTSIDE /api/* — must never require a tenant (infra health check)
|
|
24
|
+
app.get('/health', (c) => c.json({ status: 'ok' }));
|
|
25
|
+
return app;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function get(app: Hono, path: string, headers: Record<string, string> = {}) {
|
|
29
|
+
const res = await app.request(path, { headers });
|
|
30
|
+
let body: any;
|
|
31
|
+
try {
|
|
32
|
+
body = await res.json();
|
|
33
|
+
} catch {
|
|
34
|
+
body = await res.text();
|
|
35
|
+
}
|
|
36
|
+
return { status: res.status, body };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
|
|
43
|
+
else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
|
|
44
|
+
setIdentityDriver(createDefaultIdentityDriver());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('single mode (standalone worker legacy) — unchanged', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
process.env.PAYMENT_TENANT_MODE = 'single';
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('any host resolves to the deployment app DID', async () => {
|
|
53
|
+
const expected = getDefaultInstanceDid();
|
|
54
|
+
const app = buildApp();
|
|
55
|
+
const r1 = await get(app, '/api/whoami', { Host: 'anything.example.com' });
|
|
56
|
+
expect(r1.status).toBe(200);
|
|
57
|
+
expect(r1.body.tenant).toBe(expected);
|
|
58
|
+
const r2 = await get(app, '/api/whoami', { Host: 'other.example.com' });
|
|
59
|
+
expect(r2.body.tenant).toBe(expected); // default regardless of host
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('a request with no Host still resolves (single-mode default, no crash)', async () => {
|
|
63
|
+
const expected = getDefaultInstanceDid();
|
|
64
|
+
const app = buildApp();
|
|
65
|
+
const r = await get(app, '/api/whoami');
|
|
66
|
+
expect(r.status).toBe(200);
|
|
67
|
+
expect(r.body.tenant).toBe(expected);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('multi mode — Host maps to tenant, fail-closed on unknown', () => {
|
|
72
|
+
const identity: IdentityDriver = {
|
|
73
|
+
resolveInstanceDidForHost(host) {
|
|
74
|
+
if (host === 'a.example.com') return TENANT_A;
|
|
75
|
+
if (host === 'b.example.com') return TENANT_B;
|
|
76
|
+
return null;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
82
|
+
setIdentityDriver(identity);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Happy path — two hosts see two tenants (data isolation)
|
|
86
|
+
it('two hosts resolve to two tenants', async () => {
|
|
87
|
+
const app = buildApp();
|
|
88
|
+
const a = await get(app, '/api/whoami', { Host: 'a.example.com' });
|
|
89
|
+
const b = await get(app, '/api/whoami', { Host: 'b.example.com' });
|
|
90
|
+
expect(a.body.tenant).toBe(TENANT_A);
|
|
91
|
+
expect(b.body.tenant).toBe(TENANT_B);
|
|
92
|
+
expect(a.body.tenant).not.toBe(b.body.tenant);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Bad input — missing Host fails closed (no default-tenant fallback)
|
|
96
|
+
it('missing Host fails closed with 4xx (no APP_PID fallback)', async () => {
|
|
97
|
+
const app = buildApp();
|
|
98
|
+
const r = await get(app, '/api/whoami');
|
|
99
|
+
expect(r.status).toBe(400);
|
|
100
|
+
expect(r.body.error.code).toBe('TENANT_HOST_UNRESOLVED');
|
|
101
|
+
expect(r.body.tenant).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Data leak — unknown (unregistered) host must never fall into a tenant
|
|
105
|
+
it('unknown host fails closed with 4xx + error code', async () => {
|
|
106
|
+
const app = buildApp();
|
|
107
|
+
const r = await get(app, '/api/whoami', { Host: 'unknown.example.com' });
|
|
108
|
+
expect(r.status).toBe(400);
|
|
109
|
+
expect(r.body.error.code).toBe('TENANT_HOST_UNRESOLVED');
|
|
110
|
+
expect(r.body.tenant).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Security — a forged X-Forwarded-Host must not change resolution (raw Host only)
|
|
114
|
+
it('SECURITY: X-Forwarded-Host cannot override the raw Host', async () => {
|
|
115
|
+
const app = buildApp();
|
|
116
|
+
const r = await get(app, '/api/whoami', { Host: 'a.example.com', 'X-Forwarded-Host': 'b.example.com' });
|
|
117
|
+
expect(r.body.tenant).toBe(TENANT_A);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('SECURITY: X-Forwarded-Host pointing at a known tenant cannot rescue an unknown raw Host', async () => {
|
|
121
|
+
const app = buildApp();
|
|
122
|
+
const r = await get(app, '/api/whoami', { Host: 'unknown.example.com', 'X-Forwarded-Host': 'a.example.com' });
|
|
123
|
+
expect(r.status).toBe(400);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Data loss — a 4xx-rejected request must never reach the route handler
|
|
127
|
+
it('a 4xx-rejected request never runs the handler (no write)', async () => {
|
|
128
|
+
const app = new Hono();
|
|
129
|
+
app.use('/api/*', tenantMiddleware());
|
|
130
|
+
let handlerRuns = 0;
|
|
131
|
+
app.get('/api/whoami', (c) => {
|
|
132
|
+
handlerRuns += 1;
|
|
133
|
+
return c.json({ tenant: getInstanceDid() });
|
|
134
|
+
});
|
|
135
|
+
const r = await get(app, '/api/whoami', { Host: 'unknown.example.com' });
|
|
136
|
+
expect(r.status).toBe(400);
|
|
137
|
+
expect(handlerRuns).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ALS propagation — the handler runs INSIDE the withTenant context
|
|
141
|
+
it('the handler observes the resolved tenant via context ALS', async () => {
|
|
142
|
+
const app = new Hono();
|
|
143
|
+
app.use('/api/*', tenantMiddleware());
|
|
144
|
+
let seen: string | undefined;
|
|
145
|
+
app.get('/api/whoami', (c) => {
|
|
146
|
+
seen = context.getInstanceDid();
|
|
147
|
+
return c.json({ ok: true });
|
|
148
|
+
});
|
|
149
|
+
await get(app, '/api/whoami', { Host: 'b.example.com' });
|
|
150
|
+
expect(seen).toBe(TENANT_B);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// /health is outside /api/* — tenant middleware must not gate it
|
|
154
|
+
it('/health is reachable without a Host even in multi mode', async () => {
|
|
155
|
+
const app = buildApp();
|
|
156
|
+
const r = await get(app, '/health');
|
|
157
|
+
expect(r.status).toBe(200);
|
|
158
|
+
expect(r.body.status).toBe('ok');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Phase 12c HARD GATE + S3-CF Phase 1B (scan): the CF worker is a host adapter that
|
|
2
|
+
// drives the core via the SINGLE runtime-neutral surface `svc.http.fetch`. It must
|
|
3
|
+
// NOT read `svc.handler` (the node-convenience getter) and — post Phase 1B — must
|
|
4
|
+
// NOT use the LITE `svc.http.resourceRoutes.fetch` dispatcher (the second surface is
|
|
5
|
+
// gone), and must NOT double-own payment `/api/*` cors / tenant (the core full
|
|
6
|
+
// pipeline owns those). This statically scans the worker source so a regression
|
|
7
|
+
// fails the suite (the runtime Proxy in ensurePaymentService is the second defense).
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
const workerSrc = fs.readFileSync(path.join(__dirname, '../worker.ts'), 'utf8');
|
|
12
|
+
|
|
13
|
+
// non-comment code lines only
|
|
14
|
+
const codeLines = workerSrc
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map((line, n) => ({ line: line.trim(), n: n + 1 }))
|
|
17
|
+
.filter(({ line }) => !line.startsWith('//') && !line.startsWith('*') && !line.startsWith('/*'));
|
|
18
|
+
|
|
19
|
+
describe('S3-CF Phase 1B — worker drives the single svc.http.fetch surface', () => {
|
|
20
|
+
it('forwards payment /api/* through svc.http.fetch', () => {
|
|
21
|
+
expect(workerSrc).toMatch(/service\.http\.fetch\(/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('no longer dispatches through the LITE svc.http.resourceRoutes surface', () => {
|
|
25
|
+
const offending = codeLines.filter(({ line }) => /service\.http\.resourceRoutes/.test(line));
|
|
26
|
+
expect(offending).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('does not double-own payment /api/* cors or tenant (core full pipeline owns them)', () => {
|
|
30
|
+
const doubleCors = codeLines.filter(({ line }) => /app\.use\(\s*['"]\/api\/\*['"]\s*,\s*cors\(\)/.test(line));
|
|
31
|
+
const doubleTenant = codeLines.filter(({ line }) => /app\.use\(\s*['"]\/api\/\*['"]\s*,\s*tenantMiddleware\(\)/.test(line));
|
|
32
|
+
expect(doubleCors).toEqual([]);
|
|
33
|
+
expect(doubleTenant).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('strips client-forged x-user-* before injecting the resolved caller identity', () => {
|
|
37
|
+
expect(workerSrc).toMatch(/for \(const h of USER_HEADERS\) headers\.delete\(h\)/);
|
|
38
|
+
expect(workerSrc).toMatch(/headers\.set\(['"]x-user-did['"]/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Phase 12c — worker host-adapter hard gate', () => {
|
|
43
|
+
it('never reads .handler on the payment service', () => {
|
|
44
|
+
const offending = workerSrc
|
|
45
|
+
.split('\n')
|
|
46
|
+
.map((line, n) => ({ line: line.trim(), n: n + 1 }))
|
|
47
|
+
// ignore comment lines — only actual code may not read svc.handler
|
|
48
|
+
.filter(({ line }) => !line.startsWith('//') && !line.startsWith('*') && !line.startsWith('/*'))
|
|
49
|
+
.filter(({ line }) => /\b(service|paymentService|svc)\.handler\b/.test(line))
|
|
50
|
+
// the hard-gate trap string + the Proxy guard line are the gate itself
|
|
51
|
+
.filter(({ line }) => !/forbidden in the CF worker/.test(line) && !/prop === 'handler'/.test(line));
|
|
52
|
+
expect(offending).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('installs the runtime hard-gate Proxy that throws on handler access', () => {
|
|
56
|
+
expect(workerSrc).toMatch(/new Proxy\(svc,/);
|
|
57
|
+
expect(workerSrc).toMatch(/svc\.handler is forbidden in the CF worker/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not import the deleted legacy shims/queue engine', () => {
|
|
61
|
+
expect(workerSrc).not.toMatch(/from '\.\/shims\/queue'/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('drives the core queue runtime surface instead', () => {
|
|
65
|
+
expect(workerSrc).toMatch(/from '\.\.\/api\/src\/libs\/queue\/runtime'/);
|
|
66
|
+
expect(workerSrc).toMatch(/dispatchDueJobs\(\)/);
|
|
67
|
+
expect(workerSrc).toMatch(/flushQueueWork\(\)/);
|
|
68
|
+
});
|
|
69
|
+
});
|