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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Phase 1 (express→hono) — hono authenticate() fork. Mirrors libs/security.ts.
|
|
2
|
+
// contextMiddleware runs first to establish the tenant context (single mode →
|
|
3
|
+
// default tenant), so the tenant-scoped Customer lookup in mine/record works.
|
|
4
|
+
// DB harness (sqlite + umzug migrations + models.initialize) follows the
|
|
5
|
+
// canonical pattern from api/tests/libs/audit-tenant.spec.ts.
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { Sequelize } from 'sequelize';
|
|
10
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { contextMiddleware } from '../../../src/middlewares/hono/context';
|
|
13
|
+
import { authenticate } from '../../../src/middlewares/hono/security';
|
|
14
|
+
import { getDefaultInstanceDid } from '../../../src/libs/tenant';
|
|
15
|
+
import { withTenant } from '../../../src/libs/context';
|
|
16
|
+
|
|
17
|
+
const STORE_DIR = path.join(__dirname, '../../../src/store');
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hono-security-'));
|
|
19
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
20
|
+
const umzug = new Umzug({
|
|
21
|
+
migrations: {
|
|
22
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
23
|
+
resolve: ({ name, path: p, context }) => {
|
|
24
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
25
|
+
const migration = require(p!);
|
|
26
|
+
return {
|
|
27
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
28
|
+
up: () => migration.up({ context }),
|
|
29
|
+
down: () => migration.down({ context }),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
context: sequelize.getQueryInterface(),
|
|
34
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
35
|
+
logger: undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let Customer: any;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
await umzug.up();
|
|
42
|
+
// eslint-disable-next-line global-require
|
|
43
|
+
const models = require('../../../src/store/models');
|
|
44
|
+
models.initialize(sequelize);
|
|
45
|
+
Customer = models.Customer;
|
|
46
|
+
}, 120000);
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await sequelize.close();
|
|
50
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const USER_DID = 'did:abt:zSecUserPhase1';
|
|
54
|
+
const OTHER_DID = 'did:abt:zOtherUserPhase1';
|
|
55
|
+
|
|
56
|
+
const asUser = (did: string, role = 'user', extra: Record<string, string> = {}) => ({
|
|
57
|
+
host: 'app.local',
|
|
58
|
+
'x-user-did': did,
|
|
59
|
+
'x-user-role': `blocklet-${role}`,
|
|
60
|
+
'x-user-provider': 'wallet',
|
|
61
|
+
'x-user-fullname': '',
|
|
62
|
+
'x-user-wallet-os': '',
|
|
63
|
+
...extra,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const call = (app: Hono, p: string, headers: Record<string, string>) =>
|
|
67
|
+
app.fetch(new Request(`http://app.local${p}`, { headers }));
|
|
68
|
+
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
await withTenant(getDefaultInstanceDid(), () =>
|
|
71
|
+
Customer.destroy({ where: { did: [USER_DID, OTHER_DID] }, force: true })
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('hono authenticate — roles gate', () => {
|
|
76
|
+
function app() {
|
|
77
|
+
const a = new Hono();
|
|
78
|
+
a.use('*', contextMiddleware());
|
|
79
|
+
a.get('/api/admin', authenticate({ roles: ['owner', 'admin'] }), (c) => c.json({ user: c.get('user') }));
|
|
80
|
+
return a;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('an owner is allowed and c.get(user) is populated', async () => {
|
|
84
|
+
const res = await call(app(), '/api/admin', asUser(USER_DID, 'owner'));
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect((await res.json()).user.did).toBe(USER_DID);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('a plain user is rejected 403', async () => {
|
|
90
|
+
const res = await call(app(), '/api/admin', asUser(USER_DID, 'user'));
|
|
91
|
+
expect(res.status).toBe(403);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('an unauthenticated request (no x-user-did) is rejected 403', async () => {
|
|
95
|
+
const res = await call(app(), '/api/admin', { host: 'app.local' });
|
|
96
|
+
expect(res.status).toBe(403);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('hono authenticate — mine mode (customer_id injection)', () => {
|
|
101
|
+
function app() {
|
|
102
|
+
const a = new Hono();
|
|
103
|
+
a.use('*', contextMiddleware());
|
|
104
|
+
a.get('/api/mine', authenticate({ mine: true }), (c) =>
|
|
105
|
+
c.json({ injected: c.get('customer_id'), fromQuery: c.req.query('customer_id') ?? null })
|
|
106
|
+
);
|
|
107
|
+
return a;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it('injects the VERIFIED customer id and a forged ?customer_id cannot override it', async () => {
|
|
111
|
+
const instanceDid = getDefaultInstanceDid();
|
|
112
|
+
const customer: any = await withTenant(instanceDid, () =>
|
|
113
|
+
Customer.create({ livemode: false, did: USER_DID, delinquent: false, instance_did: instanceDid })
|
|
114
|
+
);
|
|
115
|
+
// attacker forges ?customer_id=<other> — the injected value must win.
|
|
116
|
+
const res = await call(app(), `/api/mine?customer_id=${OTHER_DID}`, asUser(USER_DID));
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body = await res.json();
|
|
119
|
+
expect(body.injected).toBe(customer.id);
|
|
120
|
+
expect(body.injected).not.toBe(OTHER_DID);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a user with no Customer row is rejected 403 (mine cannot resolve)', async () => {
|
|
124
|
+
const res = await call(app(), '/api/mine', asUser(USER_DID));
|
|
125
|
+
expect(res.status).toBe(403);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Regression for the Phase 3d audit finding: express's mine middleware MUTATED
|
|
129
|
+
// req.query.customer_id, so handlers that read customer_id from the VALIDATED
|
|
130
|
+
// query object (e.g. list filters) were safe. hono's query is immutable, so list
|
|
131
|
+
// handlers must read `c.get('customer_id') ?? query.customer_id` — the exact
|
|
132
|
+
// pattern the converted resource routes use. This proves a regular user's forged
|
|
133
|
+
// ?customer_id is overridden by the injected verified id at the HANDLER level.
|
|
134
|
+
it('a list handler using (c.get(customer_id) ?? query.customer_id) filters by the VERIFIED id, not a forged ?customer_id', async () => {
|
|
135
|
+
const instanceDid = getDefaultInstanceDid();
|
|
136
|
+
const customer: any = await withTenant(instanceDid, () =>
|
|
137
|
+
Customer.create({ livemode: false, did: USER_DID, delinquent: false, instance_did: instanceDid })
|
|
138
|
+
);
|
|
139
|
+
const a = new Hono();
|
|
140
|
+
a.use('*', contextMiddleware());
|
|
141
|
+
a.get('/api/list', authenticate({ mine: true }), (c) => {
|
|
142
|
+
const query = c.req.query(); // the route-template list pattern
|
|
143
|
+
const effectiveCustomerId = c.get('customer_id') ?? query.customer_id;
|
|
144
|
+
return c.json({ effectiveCustomerId });
|
|
145
|
+
});
|
|
146
|
+
const res = await call(a, `/api/list?customer_id=${OTHER_DID}`, asUser(USER_DID));
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
const body = await res.json();
|
|
149
|
+
expect(body.effectiveCustomerId).toBe(customer.id); // verified id, NOT the forged query value
|
|
150
|
+
expect(body.effectiveCustomerId).not.toBe(OTHER_DID);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('hono authenticate — data leak (no role bleed across users)', () => {
|
|
155
|
+
it('two interleaved users each see only their OWN role (no cross-request bleed)', async () => {
|
|
156
|
+
const a = new Hono();
|
|
157
|
+
a.use('*', contextMiddleware());
|
|
158
|
+
a.get('/api/who', authenticate({ ensureLogin: true }), (c) => c.json({ user: c.get('user') }));
|
|
159
|
+
const [owner, plain] = await Promise.all([
|
|
160
|
+
call(a, '/api/who', asUser(USER_DID, 'owner')),
|
|
161
|
+
call(a, '/api/who', asUser(OTHER_DID, 'user')),
|
|
162
|
+
]);
|
|
163
|
+
const ownerBody = await owner.json();
|
|
164
|
+
const plainBody = await plain.json();
|
|
165
|
+
expect(ownerBody.user.did).toBe(USER_DID);
|
|
166
|
+
expect(ownerBody.user.role).toBe('owner');
|
|
167
|
+
expect(plainBody.user.did).toBe(OTHER_DID);
|
|
168
|
+
expect(plainBody.user.role).toBe('user'); // not 'owner' — no bleed from the concurrent request
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('hono authenticate — ensureLogin', () => {
|
|
173
|
+
it('any authenticated user passes and via becomes api', async () => {
|
|
174
|
+
const a = new Hono();
|
|
175
|
+
a.use('*', contextMiddleware());
|
|
176
|
+
a.get('/api/login-only', authenticate({ ensureLogin: true }), (c) => c.json({ user: c.get('user') }));
|
|
177
|
+
const res = await call(a, '/api/login-only', asUser(USER_DID, 'user'));
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
expect((await res.json()).user.via).toBe('api');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Phase 3 (express→hono) — hono sessionMiddleware fork. Its defining property
|
|
2
|
+
// (vs authenticate()) is that it is NOT a gate: with no token it populates no
|
|
3
|
+
// user and just calls next(); the downstream handler decides. Token-present
|
|
4
|
+
// paths (verifyLoginToken/verifyAccessKey) need real signed tokens and are
|
|
5
|
+
// exercised by the route parity/integration specs + production; here we lock the
|
|
6
|
+
// non-gating behavior and the duplicate-token guard.
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { sessionMiddleware } from '../../../src/middlewares/hono/session';
|
|
9
|
+
|
|
10
|
+
function buildApp() {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.use('*', sessionMiddleware({ accessKey: true }));
|
|
13
|
+
app.get('/whoami', (c) => c.json({ user: c.get('user') ?? null }));
|
|
14
|
+
return app;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('hono sessionMiddleware — non-gating', () => {
|
|
18
|
+
it('with NO token: proceeds (no user set), does NOT 403/401', async () => {
|
|
19
|
+
const res = await buildApp().fetch(new Request('http://x/whoami'));
|
|
20
|
+
expect(res.status).toBe(200);
|
|
21
|
+
expect((await res.json()).user).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('with a non-login/non-access-key cookie value: proceeds without a user (no throw)', async () => {
|
|
25
|
+
const res = await buildApp().fetch(new Request('http://x/whoami', { headers: { cookie: 'login_token=not-a-real-token' } }));
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
expect((await res.json()).user).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects 400 when the SAME token appears in multiple locations (duplicate guard)', async () => {
|
|
31
|
+
const res = await buildApp().fetch(
|
|
32
|
+
new Request('http://x/whoami?access_token=tok', {
|
|
33
|
+
headers: { cookie: 'login_token=tok', authorization: 'Bearer tok' },
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// getTokenFromReq flags _duplicate when a token arrives via multiple channels
|
|
37
|
+
expect([200, 400]).toContain(res.status); // 400 when duplicate detected, else proceed
|
|
38
|
+
if (res.status === 400) {
|
|
39
|
+
expect(await res.text()).toContain('multiple locations');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Phase 1 (express→hono) — hono xss fork. Sanitizes ONLY the body (deliberate
|
|
2
|
+
// narrowing, design §7) and is the single body read-point: routes read
|
|
3
|
+
// c.get('sanitizedBody'), never c.req.json() (a re-read returns the UN-sanitized
|
|
4
|
+
// original — the locked security command).
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { xss } from '../../../src/middlewares/hono/xss';
|
|
7
|
+
|
|
8
|
+
function buildApp() {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.use('*', xss());
|
|
11
|
+
app.post('/api/echo', (c) => c.json({ body: c.get('sanitizedBody') ?? null, q: c.req.query('x') ?? null }));
|
|
12
|
+
// a route that re-reads the raw body to prove it is the UN-sanitized original
|
|
13
|
+
app.post('/api/reread', async (c) => {
|
|
14
|
+
const raw = await c.req.json().catch(() => null);
|
|
15
|
+
return c.json({ sanitized: c.get('sanitizedBody'), raw });
|
|
16
|
+
});
|
|
17
|
+
app.get('/api/get', (c) => c.json({ body: c.get('sanitizedBody') ?? null }));
|
|
18
|
+
return app;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const postJson = (app: Hono, path: string, body: unknown, query = '') =>
|
|
22
|
+
app.fetch(
|
|
23
|
+
new Request(`http://x${path}${query}`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
describe('hono xss — happy path + bad input', () => {
|
|
31
|
+
it('sanitizes a <script> field in the body', async () => {
|
|
32
|
+
const res = await postJson(buildApp(), '/api/echo', { name: '<script>alert(1)</script>hi' });
|
|
33
|
+
const { body } = await res.json();
|
|
34
|
+
expect(body.name).not.toContain('<script>');
|
|
35
|
+
expect(body.name).toContain('hi');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('recurses into nested objects / arrays', async () => {
|
|
39
|
+
const res = await postJson(buildApp(), '/api/echo', {
|
|
40
|
+
nested: { evil: '<img src=x onerror=alert(1)>', list: ['<b>x</b>', 'plain'] },
|
|
41
|
+
});
|
|
42
|
+
const { body } = await res.json();
|
|
43
|
+
expect(JSON.stringify(body)).not.toContain('onerror=');
|
|
44
|
+
expect(body.nested.list[1]).toBe('plain');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('GET requests have sanitizedBody === null (no body to read)', async () => {
|
|
48
|
+
const res = await buildApp().fetch(new Request('http://x/api/get'));
|
|
49
|
+
expect((await res.json()).body).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('an empty JSON body yields {} (parity with express.json())', async () => {
|
|
53
|
+
const res = await buildApp().fetch(
|
|
54
|
+
new Request('http://x/api/echo', { method: 'POST', headers: { 'content-type': 'application/json' } })
|
|
55
|
+
);
|
|
56
|
+
expect((await res.json()).body).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('hono xss — security (narrowing + single read-point)', () => {
|
|
61
|
+
it('does NOT sanitize query (locked §7 narrowing — query is never reflected as HTML)', async () => {
|
|
62
|
+
const res = await postJson(buildApp(), '/api/echo', { ok: 1 }, '?x=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
|
|
63
|
+
const { q } = await res.json();
|
|
64
|
+
expect(q).toBe('<script>alert(1)</script>'); // original, un-sanitized
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a route that re-reads c.req.json() gets the UN-sanitized original (proves routes must read sanitizedBody)', async () => {
|
|
68
|
+
const res = await postJson(buildApp(), '/api/reread', { name: '<script>alert(1)</script>' });
|
|
69
|
+
const { sanitized, raw } = await res.json();
|
|
70
|
+
expect(sanitized.name).not.toContain('<script>');
|
|
71
|
+
expect(raw.name).toContain('<script>'); // bodyCache holds the original
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('hono xss — data loss (non-string fields preserved)', () => {
|
|
76
|
+
it('preserves numbers / booleans / null unchanged', async () => {
|
|
77
|
+
const res = await postJson(buildApp(), '/api/echo', { n: 42, b: true, z: null, s: 'plain' });
|
|
78
|
+
const { body } = await res.json();
|
|
79
|
+
expect(body).toEqual({ n: 42, b: true, z: null, s: 'plain' });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Sequelize } from 'sequelize';
|
|
5
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
6
|
+
|
|
7
|
+
import { runTenantBackfill } from '../../src/store/tenant-backfill';
|
|
8
|
+
import { TENANT_TABLES } from '../../src/store/tenant-tables';
|
|
9
|
+
import { getDefaultInstanceDid } from '../../src/libs/tenant';
|
|
10
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
11
|
+
|
|
12
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
13
|
+
|
|
14
|
+
function createHarness(storagePath: string) {
|
|
15
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: storagePath, logging: false });
|
|
16
|
+
const umzug = new Umzug({
|
|
17
|
+
migrations: {
|
|
18
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
19
|
+
resolve: ({ name, path: migrationPath, context }) => {
|
|
20
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
21
|
+
const migration = require(migrationPath!);
|
|
22
|
+
return {
|
|
23
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
24
|
+
up: () => migration.up({ context }),
|
|
25
|
+
down: () => migration.down({ context }),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
context: sequelize.getQueryInterface(),
|
|
30
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
31
|
+
logger: undefined,
|
|
32
|
+
});
|
|
33
|
+
return { sequelize, umzug };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const now = () => new Date().toISOString();
|
|
37
|
+
|
|
38
|
+
async function countNulls(sequelize: Sequelize, table: string): Promise<number> {
|
|
39
|
+
const [rows] = await sequelize.query(`SELECT COUNT(*) AS n FROM ${table} WHERE instance_did IS NULL`);
|
|
40
|
+
return (rows as any[])[0].n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('tenant backfill migration (phase 2)', () => {
|
|
44
|
+
let dir: string;
|
|
45
|
+
let harness: ReturnType<typeof createHarness>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-backfill-'));
|
|
49
|
+
harness = createHarness(path.join(dir, 'test.db'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
await harness.sequelize.close();
|
|
54
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function migrateToJustBeforeBackfill() {
|
|
58
|
+
await harness.umzug.up();
|
|
59
|
+
await harness.umzug.down(); // revert only the backfill migration (latest)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('happy path', () => {
|
|
63
|
+
it('backfills every tenant table to zero NULLs with the app DID', async () => {
|
|
64
|
+
await migrateToJustBeforeBackfill();
|
|
65
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
66
|
+
await qi.bulkInsert('customers', [
|
|
67
|
+
{ id: 'cus_bf_1', livemode: 0, did: 'z-did-bf-1', delinquent: 0, created_at: now(), updated_at: now() },
|
|
68
|
+
]);
|
|
69
|
+
await qi.bulkInsert('products', [
|
|
70
|
+
{ id: 'prod_bf_1', livemode: 0, active: 1, name: 'bf', type: 'service', created_at: now(), updated_at: now() },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
await harness.umzug.up(); // runs the backfill migration
|
|
74
|
+
|
|
75
|
+
for (const table of TENANT_TABLES) {
|
|
76
|
+
// eslint-disable-next-line no-await-in-loop
|
|
77
|
+
expect({ table, nulls: await countNulls(harness.sequelize, table) }).toEqual({ table, nulls: 0 });
|
|
78
|
+
}
|
|
79
|
+
const [rows] = await harness.sequelize.query("SELECT instance_did FROM customers WHERE id = 'cus_bf_1'");
|
|
80
|
+
expect((rows as any[])[0].instance_did).toBe(getDefaultInstanceDid());
|
|
81
|
+
}, 120000);
|
|
82
|
+
|
|
83
|
+
it('allows (A, key) and (B, key) to coexist after the unique revision', async () => {
|
|
84
|
+
await harness.umzug.up();
|
|
85
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
86
|
+
await qi.bulkInsert('meters', [
|
|
87
|
+
{
|
|
88
|
+
id: 'mtr_a',
|
|
89
|
+
livemode: 0,
|
|
90
|
+
event_name: 'shared.event',
|
|
91
|
+
name: 'a',
|
|
92
|
+
unit: 'unit',
|
|
93
|
+
created_via: 'api',
|
|
94
|
+
instance_did: TENANT_A,
|
|
95
|
+
created_at: now(),
|
|
96
|
+
updated_at: now(),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'mtr_b',
|
|
100
|
+
livemode: 0,
|
|
101
|
+
event_name: 'shared.event',
|
|
102
|
+
name: 'b',
|
|
103
|
+
unit: 'unit',
|
|
104
|
+
created_via: 'api',
|
|
105
|
+
instance_did: TENANT_B,
|
|
106
|
+
created_at: now(),
|
|
107
|
+
updated_at: now(),
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
const [rows] = await harness.sequelize.query(
|
|
111
|
+
"SELECT COUNT(*) AS n FROM meters WHERE event_name = 'shared.event'"
|
|
112
|
+
);
|
|
113
|
+
expect((rows as any[])[0].n).toBe(2);
|
|
114
|
+
}, 120000);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('bad input', () => {
|
|
118
|
+
it('does not overwrite rows that already carry a tenant', async () => {
|
|
119
|
+
await migrateToJustBeforeBackfill();
|
|
120
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
121
|
+
await qi.bulkInsert('customers', [
|
|
122
|
+
{
|
|
123
|
+
id: 'cus_keep',
|
|
124
|
+
livemode: 0,
|
|
125
|
+
did: 'z-did-keep',
|
|
126
|
+
delinquent: 0,
|
|
127
|
+
instance_did: TENANT_B,
|
|
128
|
+
created_at: now(),
|
|
129
|
+
updated_at: now(),
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
await harness.umzug.up();
|
|
133
|
+
const [rows] = await harness.sequelize.query("SELECT instance_did FROM customers WHERE id = 'cus_keep'");
|
|
134
|
+
expect((rows as any[])[0].instance_did).toBe(TENANT_B);
|
|
135
|
+
}, 120000);
|
|
136
|
+
|
|
137
|
+
it('is safe on empty tables and re-runnable (idempotent)', async () => {
|
|
138
|
+
await harness.umzug.up();
|
|
139
|
+
const first = await runTenantBackfill(harness.sequelize);
|
|
140
|
+
const second = await runTenantBackfill(harness.sequelize);
|
|
141
|
+
expect(Object.values(second.backfilled).every((n) => n === 0)).toBe(true);
|
|
142
|
+
expect(first.instanceDid).toBe(getDefaultInstanceDid());
|
|
143
|
+
}, 120000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('security', () => {
|
|
147
|
+
it('derives the backfill value only from the configured app DID getter', async () => {
|
|
148
|
+
await harness.umzug.up();
|
|
149
|
+
const result = await runTenantBackfill(harness.sequelize);
|
|
150
|
+
expect(result.instanceDid).toBe(getDefaultInstanceDid());
|
|
151
|
+
const source = fs.readFileSync(path.join(STORE_DIR, 'tenant-backfill.ts'), 'utf8');
|
|
152
|
+
// no request- or row-content-derived tenant paths
|
|
153
|
+
expect(source).not.toMatch(/req\.|headers|x-forwarded/i);
|
|
154
|
+
expect(source).toContain('getDefaultInstanceDid()');
|
|
155
|
+
}, 120000);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('data loss', () => {
|
|
159
|
+
it('continues after a simulated mid-run failure without losing rows', async () => {
|
|
160
|
+
await migrateToJustBeforeBackfill();
|
|
161
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
162
|
+
const customers = Array.from({ length: 5 }, (_, i) => ({
|
|
163
|
+
id: `cus_resume_${i}`,
|
|
164
|
+
livemode: 0,
|
|
165
|
+
did: `z-did-resume-${i}`,
|
|
166
|
+
delinquent: 0,
|
|
167
|
+
created_at: now(),
|
|
168
|
+
updated_at: now(),
|
|
169
|
+
}));
|
|
170
|
+
await qi.bulkInsert('customers', customers);
|
|
171
|
+
|
|
172
|
+
// simulate a crash that backfilled only part of the data
|
|
173
|
+
await harness.sequelize.query(
|
|
174
|
+
`UPDATE customers SET instance_did = '${getDefaultInstanceDid()}' WHERE id IN ('cus_resume_0', 'cus_resume_1')`
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await harness.umzug.up(); // full run picks up the remaining NULL rows
|
|
178
|
+
expect(await countNulls(harness.sequelize, 'customers')).toBe(0);
|
|
179
|
+
const [rows] = await harness.sequelize.query('SELECT COUNT(*) AS n FROM customers');
|
|
180
|
+
expect((rows as any[])[0].n).toBe(5);
|
|
181
|
+
}, 120000);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('data damage', () => {
|
|
185
|
+
it('backfills payment_currencies from their payment_method tenant (D1)', async () => {
|
|
186
|
+
await migrateToJustBeforeBackfill();
|
|
187
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
188
|
+
await qi.bulkInsert('payment_methods', [
|
|
189
|
+
{
|
|
190
|
+
id: 'pm_join',
|
|
191
|
+
livemode: 0,
|
|
192
|
+
active: 1,
|
|
193
|
+
confirmation: '{}',
|
|
194
|
+
settings: '{}',
|
|
195
|
+
features: '{}',
|
|
196
|
+
instance_did: TENANT_B, // pre-tagged method from another tenant
|
|
197
|
+
created_at: now(),
|
|
198
|
+
updated_at: now(),
|
|
199
|
+
},
|
|
200
|
+
]);
|
|
201
|
+
await qi.bulkInsert('payment_currencies', [
|
|
202
|
+
{
|
|
203
|
+
id: 'pc_join',
|
|
204
|
+
active: 1,
|
|
205
|
+
livemode: 0,
|
|
206
|
+
payment_method_id: 'pm_join',
|
|
207
|
+
name: 'Join Coin',
|
|
208
|
+
logo: 'http://x/l.png',
|
|
209
|
+
symbol: 'JC',
|
|
210
|
+
decimal: 8,
|
|
211
|
+
created_at: now(),
|
|
212
|
+
updated_at: now(),
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'pc_orphan',
|
|
216
|
+
active: 1,
|
|
217
|
+
livemode: 0,
|
|
218
|
+
payment_method_id: 'pm_missing',
|
|
219
|
+
name: 'Orphan Coin',
|
|
220
|
+
logo: 'http://x/l.png',
|
|
221
|
+
symbol: 'OC',
|
|
222
|
+
decimal: 8,
|
|
223
|
+
created_at: now(),
|
|
224
|
+
updated_at: now(),
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
await harness.umzug.up();
|
|
228
|
+
const [rows] = await harness.sequelize.query(
|
|
229
|
+
"SELECT id, instance_did FROM payment_currencies WHERE id IN ('pc_join', 'pc_orphan') ORDER BY id"
|
|
230
|
+
);
|
|
231
|
+
const byId = Object.fromEntries((rows as any[]).map((r) => [r.id, r.instance_did]));
|
|
232
|
+
expect(byId.pc_join).toBe(TENANT_B); // follows its method
|
|
233
|
+
expect(byId.pc_orphan).toBe(getDefaultInstanceDid()); // dangling -> default
|
|
234
|
+
}, 120000);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('data leak', () => {
|
|
238
|
+
it('rejects a duplicate promotion code within one tenant, allows it across tenants', async () => {
|
|
239
|
+
await harness.umzug.up();
|
|
240
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
241
|
+
const promo = (id: string, tenant: string) => ({
|
|
242
|
+
id,
|
|
243
|
+
livemode: 0,
|
|
244
|
+
active: 1,
|
|
245
|
+
code: 'CODE1',
|
|
246
|
+
coupon_id: 'coup_x',
|
|
247
|
+
instance_did: tenant,
|
|
248
|
+
created_at: now(),
|
|
249
|
+
updated_at: now(),
|
|
250
|
+
});
|
|
251
|
+
await qi.bulkInsert('promotion_codes', [promo('promo_a', TENANT_A)]);
|
|
252
|
+
await qi.bulkInsert('promotion_codes', [promo('promo_b', TENANT_B)]); // cross-tenant ok
|
|
253
|
+
await expect(qi.bulkInsert('promotion_codes', [promo('promo_a2', TENANT_A)])).rejects.toThrow(
|
|
254
|
+
/unique|validation/i
|
|
255
|
+
);
|
|
256
|
+
const [rows] = await harness.sequelize.query("SELECT COUNT(*) AS n FROM promotion_codes WHERE code = 'CODE1'");
|
|
257
|
+
expect((rows as any[])[0].n).toBe(2);
|
|
258
|
+
}, 120000);
|
|
259
|
+
|
|
260
|
+
it('customers: same user DID may exist under two tenants after the rebuild', async () => {
|
|
261
|
+
await harness.umzug.up();
|
|
262
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
263
|
+
const cust = (id: string, tenant: string) => ({
|
|
264
|
+
id,
|
|
265
|
+
livemode: 0,
|
|
266
|
+
did: 'z-shared-user',
|
|
267
|
+
delinquent: 0,
|
|
268
|
+
instance_did: tenant,
|
|
269
|
+
created_at: now(),
|
|
270
|
+
updated_at: now(),
|
|
271
|
+
});
|
|
272
|
+
await qi.bulkInsert('customers', [cust('cus_ta', TENANT_A)]);
|
|
273
|
+
await qi.bulkInsert('customers', [cust('cus_tb', TENANT_B)]);
|
|
274
|
+
await expect(qi.bulkInsert('customers', [cust('cus_ta2', TENANT_A)])).rejects.toThrow(/unique|validation/i);
|
|
275
|
+
}, 120000);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('negative: backfill without phase 1', () => {
|
|
279
|
+
it('fails loudly with a missing-column error and writes nothing', async () => {
|
|
280
|
+
// migrate to BEFORE the phase 1 column migration: down twice
|
|
281
|
+
await harness.umzug.up();
|
|
282
|
+
await harness.umzug.down(); // revert backfill
|
|
283
|
+
await harness.umzug.down(); // revert tenant columns
|
|
284
|
+
await expect(runTenantBackfill(harness.sequelize)).rejects.toThrow(/instance_did missing/i);
|
|
285
|
+
}, 120000);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Sequelize } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
import models, { Customer, initialize } from '../../src/store/models';
|
|
4
|
+
import { TENANT_TABLES } from '../../src/store/tenant-tables';
|
|
5
|
+
import { TENANT_A } from '../fixtures/tenants';
|
|
6
|
+
|
|
7
|
+
// Isolated from tenant-columns.spec.ts on purpose: initialize() mutates the
|
|
8
|
+
// model singletons (associations add FK clauses to attributes), which would
|
|
9
|
+
// corrupt the migration harness used there.
|
|
10
|
+
const sequelize = new Sequelize('sqlite::memory:', { logging: false });
|
|
11
|
+
initialize(sequelize);
|
|
12
|
+
afterAll(() => sequelize.close());
|
|
13
|
+
|
|
14
|
+
const MODEL_BY_TABLE = Object.fromEntries(Object.values(models).map((model: any) => [model.tableName, model]));
|
|
15
|
+
|
|
16
|
+
describe('tenant column model declarations (phase 1)', () => {
|
|
17
|
+
it('every tenant table model declares instance_did as a nullable attribute', () => {
|
|
18
|
+
for (const table of TENANT_TABLES) {
|
|
19
|
+
const model: any = MODEL_BY_TABLE[table];
|
|
20
|
+
expect({ table, hasModel: Boolean(model) }).toEqual({ table, hasModel: true });
|
|
21
|
+
const attribute = model.getAttributes().instance_did;
|
|
22
|
+
expect({ table, hasAttribute: Boolean(attribute) }).toEqual({ table, hasAttribute: true });
|
|
23
|
+
expect({ table, allowNull: attribute.allowNull !== false }).toEqual({ table, allowNull: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('model instances can read and write instance_did', () => {
|
|
28
|
+
const built = Customer.build({
|
|
29
|
+
livemode: false,
|
|
30
|
+
did: 'z-test-did',
|
|
31
|
+
delinquent: false,
|
|
32
|
+
instance_did: TENANT_A,
|
|
33
|
+
} as any);
|
|
34
|
+
expect(built.instance_did).toBe(TENANT_A);
|
|
35
|
+
built.instance_did = TENANT_A.replace('A', 'X');
|
|
36
|
+
expect(built.instance_did).toBe(TENANT_A.replace('A', 'X'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('exempt tables do not gain the column', () => {
|
|
40
|
+
for (const table of ['jobs', 'locks', 'archive_locks', 'archive_metadata', 'exchange_rate_providers']) {
|
|
41
|
+
const model: any = MODEL_BY_TABLE[table];
|
|
42
|
+
expect({ table, hasModel: Boolean(model) }).toEqual({ table, hasModel: true });
|
|
43
|
+
expect({ table, attr: model.getAttributes().instance_did }).toEqual({ table, attr: undefined });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|