payment-kit 1.29.1 → 1.29.2
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 +36 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +27 -24
- 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/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 +99 -38
- package/api/src/libs/context.ts +78 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- 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 +81 -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 +50 -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 +259 -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 +73 -0
- package/api/src/middlewares/hono/csrf.ts +72 -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 +214 -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 +17 -12
- 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 +12 -4
- 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 +41 -28
- package/api/src/queues/payout.ts +9 -5
- package/api/src/queues/refund.ts +18 -12
- 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} +193 -223
- 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} +96 -80
- 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 +667 -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 +66 -22
- 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/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/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/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 +236 -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/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/README.md +26 -6
- package/cloudflare/build.ts +28 -13
- package/cloudflare/did-connect-auth.ts +0 -217
- 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 +10 -56
- 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/util-csrf.ts +13 -0
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
- 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/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
- package/cloudflare/worker.ts +204 -433
- package/cloudflare/wrangler.local-e2e.jsonc +26 -0
- package/jest.config.js +3 -1
- package/package.json +33 -38
- 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/env.d.ts +13 -1
- package/tsconfig.json +1 -1
- 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/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,177 @@
|
|
|
1
|
+
// SPIKE: validate the TenantModel base-class mechanism against BOTH query
|
|
2
|
+
// engines in one run — real Sequelize (Node) and the sequelize-d1 shim
|
|
3
|
+
// (worker). Go/No-Go for `TENANT-ISOLATION-DESIGN.md` §12.
|
|
4
|
+
//
|
|
5
|
+
// Proves: (a) static override + super works on both bases; (b) `this`
|
|
6
|
+
// resolves to the concrete model through the extra inheritance layer (shim);
|
|
7
|
+
// (c) scopeWhere idempotency absorbs the findByPk->findOne->findAll double
|
|
8
|
+
// injection; (d) cross-tenant read/write/aggregate are isolated; (e) the
|
|
9
|
+
// worker reads the tenant via ALS (withTenant).
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
import { DataTypes, Model as RealModel, Sequelize } from 'sequelize';
|
|
12
|
+
|
|
13
|
+
import { withTenant } from '../../src/libs/context';
|
|
14
|
+
import { makeTenantModel } from '../../src/store/tenant-model';
|
|
15
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
16
|
+
|
|
17
|
+
// 'coupons' is a real tenant table (in TENANT_TABLES) -> _isTenantTable() true.
|
|
18
|
+
// We give it a minimal schema we fully control, on a throwaway DB.
|
|
19
|
+
const TABLE = 'coupons';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Base 1: real Sequelize (Node runtime)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
describe('TenantModel over real Sequelize', () => {
|
|
25
|
+
let sequelize: Sequelize;
|
|
26
|
+
let Widget: any;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });
|
|
30
|
+
Widget = class extends makeTenantModel(RealModel) {};
|
|
31
|
+
Widget.init(
|
|
32
|
+
{
|
|
33
|
+
id: { type: DataTypes.STRING, primaryKey: true },
|
|
34
|
+
instance_did: { type: DataTypes.STRING },
|
|
35
|
+
name: { type: DataTypes.STRING },
|
|
36
|
+
qty: { type: DataTypes.INTEGER },
|
|
37
|
+
},
|
|
38
|
+
{ sequelize, modelName: 'Widget', tableName: TABLE, timestamps: false }
|
|
39
|
+
);
|
|
40
|
+
await sequelize.sync();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await sequelize.close();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
await sequelize.query(`DELETE FROM ${TABLE}`);
|
|
49
|
+
await withTenant(TENANT_A, () => Widget.create({ id: 'a1', name: 'a-one', qty: 10 }));
|
|
50
|
+
await withTenant(TENANT_A, () => Widget.create({ id: 'a2', name: 'a-two', qty: 5 }));
|
|
51
|
+
await withTenant(TENANT_B, () => Widget.create({ id: 'b1', name: 'b-one', qty: 100 }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('findAll returns only the active tenant', async () => {
|
|
55
|
+
const a = await withTenant(TENANT_A, () => Widget.findAll());
|
|
56
|
+
expect(a.map((r: any) => r.id).sort()).toEqual(['a1', 'a2']);
|
|
57
|
+
const b = await withTenant(TENANT_B, () => Widget.findAll());
|
|
58
|
+
expect(b.map((r: any) => r.id)).toEqual(['b1']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('findByPk across tenants returns null (delegation + idempotent double-scope)', async () => {
|
|
62
|
+
expect(await withTenant(TENANT_A, () => Widget.findByPk('a1'))).not.toBeNull();
|
|
63
|
+
expect(await withTenant(TENANT_A, () => Widget.findByPk('b1'))).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('create stamps the active tenant', async () => {
|
|
67
|
+
const row = await withTenant(TENANT_A, () => Widget.findByPk('a1'));
|
|
68
|
+
expect(row.instance_did).toBe(TENANT_A);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sum aggregates only the active tenant', async () => {
|
|
72
|
+
expect(await withTenant(TENANT_A, () => Widget.sum('qty'))).toBe(15);
|
|
73
|
+
expect(await withTenant(TENANT_B, () => Widget.sum('qty'))).toBe(100);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('update never crosses tenants', async () => {
|
|
77
|
+
await withTenant(TENANT_A, () => Widget.update({ name: 'a-upd' }, { where: {} }));
|
|
78
|
+
const b1 = await withTenant(TENANT_B, () => Widget.findByPk('b1'));
|
|
79
|
+
expect(b1.name).toBe('b-one');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('explicit matching instance_did is idempotent; conflicting fails closed', async () => {
|
|
83
|
+
await expect(withTenant(TENANT_A, () => Widget.findAll({ where: { instance_did: TENANT_A } }))).resolves.toHaveLength(2);
|
|
84
|
+
await expect(withTenant(TENANT_A, () => Widget.findAll({ where: { instance_did: TENANT_B } }))).rejects.toThrow(/conflicts with the active tenant/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Base 2: sequelize-d1 shim (worker runtime), backed by node:sqlite as a
|
|
90
|
+
// minimal D1-compatible fake.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
function makeFakeD1(db: DatabaseSync) {
|
|
93
|
+
const prepare = (sql: string) => {
|
|
94
|
+
let bound: any[] = [];
|
|
95
|
+
const stmt: any = {
|
|
96
|
+
__sql: sql,
|
|
97
|
+
bind: (...vals: any[]) => {
|
|
98
|
+
bound = vals;
|
|
99
|
+
return stmt;
|
|
100
|
+
},
|
|
101
|
+
all: () => ({ results: db.prepare(sql).all(...bound), meta: {} }),
|
|
102
|
+
run: () => {
|
|
103
|
+
const r = db.prepare(sql).run(...bound);
|
|
104
|
+
return { meta: { changes: Number(r.changes), last_row_id: Number(r.lastInsertRowid) } };
|
|
105
|
+
},
|
|
106
|
+
first: () => db.prepare(sql).get(...bound) ?? null,
|
|
107
|
+
};
|
|
108
|
+
return stmt;
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
prepare,
|
|
112
|
+
batch: async (stmts: any[]) =>
|
|
113
|
+
stmts.map((s) => {
|
|
114
|
+
if (/^\s*SELECT/i.test(s.__sql)) return s.all();
|
|
115
|
+
s.run();
|
|
116
|
+
return { results: [], meta: {} };
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('TenantModel over sequelize-d1 shim', () => {
|
|
122
|
+
let ShimModel: any;
|
|
123
|
+
let setDB: any;
|
|
124
|
+
let Widget: any;
|
|
125
|
+
|
|
126
|
+
beforeAll(() => {
|
|
127
|
+
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
|
|
128
|
+
const shim = require('../../../cloudflare/shims/sequelize-d1');
|
|
129
|
+
ShimModel = shim.Model;
|
|
130
|
+
setDB = shim.setDB;
|
|
131
|
+
|
|
132
|
+
const db = new DatabaseSync(':memory:');
|
|
133
|
+
db.exec(`CREATE TABLE ${TABLE} (id TEXT PRIMARY KEY, instance_did TEXT, name TEXT, qty INTEGER)`);
|
|
134
|
+
setDB(makeFakeD1(db));
|
|
135
|
+
|
|
136
|
+
Widget = class extends makeTenantModel(ShimModel) {};
|
|
137
|
+
Widget.init(
|
|
138
|
+
{ id: {}, instance_did: {}, name: {}, qty: {} },
|
|
139
|
+
{ sequelize: { models: {} }, modelName: 'Widget', tableName: TABLE }
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
await withTenant(TENANT_A, () => Widget.destroy({ where: {} }));
|
|
145
|
+
await withTenant(TENANT_B, () => Widget.destroy({ where: {} }));
|
|
146
|
+
await withTenant(TENANT_A, () => Widget.create({ id: 'a1', name: 'a-one', qty: 10 }));
|
|
147
|
+
await withTenant(TENANT_A, () => Widget.create({ id: 'a2', name: 'a-two', qty: 5 }));
|
|
148
|
+
await withTenant(TENANT_B, () => Widget.create({ id: 'b1', name: 'b-one', qty: 100 }));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('findAll returns only the active tenant', async () => {
|
|
152
|
+
const a = await withTenant(TENANT_A, () => Widget.findAll());
|
|
153
|
+
expect(a.map((r: any) => r.id).sort()).toEqual(['a1', 'a2']);
|
|
154
|
+
const b = await withTenant(TENANT_B, () => Widget.findAll());
|
|
155
|
+
expect(b.map((r: any) => r.id)).toEqual(['b1']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('findByPk across tenants returns null (this-resolution + delegation through extra layer)', async () => {
|
|
159
|
+
expect(await withTenant(TENANT_A, () => Widget.findByPk('a1'))).not.toBeNull();
|
|
160
|
+
expect(await withTenant(TENANT_A, () => Widget.findByPk('b1'))).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('create stamps the active tenant', async () => {
|
|
164
|
+
const row = await withTenant(TENANT_A, () => Widget.findByPk('a1'));
|
|
165
|
+
expect(row.instance_did).toBe(TENANT_A);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('update never crosses tenants', async () => {
|
|
169
|
+
await withTenant(TENANT_A, () => Widget.update({ name: 'a-upd' }, { where: {} }));
|
|
170
|
+
const b1 = await withTenant(TENANT_B, () => Widget.findByPk('b1'));
|
|
171
|
+
expect(b1.name).toBe('b-one');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('conflicting explicit instance_did fails closed', async () => {
|
|
175
|
+
await expect(withTenant(TENANT_A, () => Widget.findAll({ where: { instance_did: TENANT_B } }))).rejects.toThrow(/conflicts with the active tenant/);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Phase 3 (W1′) — TenantModel landed on the REAL models.
|
|
2
|
+
//
|
|
3
|
+
// The spike (tenant-model-spike.spec.ts) proved the mechanism on controlled
|
|
4
|
+
// same-named tables against both engines. This proves the PRODUCTION wiring:
|
|
5
|
+
// the real Coupon class (with its init/associations/hooks) extends TenantModel
|
|
6
|
+
// and is transparently tenant-scoped across the full 6-class matrix.
|
|
7
|
+
import { Sequelize } from 'sequelize';
|
|
8
|
+
|
|
9
|
+
import { withTenant } from '../../src/libs/context';
|
|
10
|
+
import { TENANT_MISMATCH } from '../../src/libs/tenant';
|
|
11
|
+
import { Coupon, initialize } from '../../src/store/models';
|
|
12
|
+
import { isTenantTable, scopeWhere, stampTenant } from '../../src/store/scoped-core';
|
|
13
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
14
|
+
|
|
15
|
+
const sequelize = new Sequelize('sqlite::memory:', { logging: false });
|
|
16
|
+
initialize(sequelize);
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
await sequelize.sync({ force: true });
|
|
20
|
+
});
|
|
21
|
+
afterAll(() => sequelize.close());
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
// truncate between tests so counts are deterministic
|
|
25
|
+
await sequelize.query('DELETE FROM coupons');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function makeCoupon(overrides: Record<string, any> = {}) {
|
|
29
|
+
return {
|
|
30
|
+
livemode: false,
|
|
31
|
+
duration: 'once',
|
|
32
|
+
name: 'spring-sale',
|
|
33
|
+
created_via: 'api',
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('TenantModel on the real Coupon model', () => {
|
|
39
|
+
describe('Happy path', () => {
|
|
40
|
+
it('findAll under tenant A returns only A rows; create stamps instance_did', async () => {
|
|
41
|
+
await withTenant(TENANT_A, () => Coupon.create(makeCoupon({ name: 'a-1' }) as any));
|
|
42
|
+
await withTenant(TENANT_B, () => Coupon.create(makeCoupon({ name: 'b-1' }) as any));
|
|
43
|
+
|
|
44
|
+
const aRows = await withTenant(TENANT_A, () => Coupon.findAll());
|
|
45
|
+
expect(aRows.map((r: any) => r.name)).toEqual(['a-1']);
|
|
46
|
+
expect(aRows.every((r: any) => r.instance_did === TENANT_A)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Bad input', () => {
|
|
51
|
+
it('explicit conflicting where.instance_did fails closed (reject, not resolve)', async () => {
|
|
52
|
+
await expect(
|
|
53
|
+
withTenant(TENANT_A, () => Coupon.findOne({ where: { instance_did: TENANT_B } as any }))
|
|
54
|
+
).rejects.toMatchObject({ code: TENANT_MISMATCH });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('undefined / empty where does not crash', async () => {
|
|
58
|
+
await withTenant(TENANT_A, () => Coupon.create(makeCoupon() as any));
|
|
59
|
+
await expect(withTenant(TENANT_A, () => Coupon.findAll())).resolves.toBeDefined();
|
|
60
|
+
await expect(withTenant(TENANT_A, () => Coupon.findOne({}))).resolves.toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('Security', () => {
|
|
65
|
+
it('prototype-pollution keys in a where payload do not poison scope injection', async () => {
|
|
66
|
+
// scopeWhere spreads the caller where into a fresh object; a malicious
|
|
67
|
+
// __proto__ own-key must not leak onto Object.prototype.
|
|
68
|
+
const malicious = JSON.parse('{"__proto__": {"polluted": true}, "name": "x"}');
|
|
69
|
+
const scoped = await withTenant(TENANT_A, async () => scopeWhere(malicious));
|
|
70
|
+
expect(({} as any).polluted).toBeUndefined();
|
|
71
|
+
expect(scoped.instance_did).toBe(TENANT_A);
|
|
72
|
+
expect(scoped.name).toBe('x');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('create cannot stamp a foreign tenant via explicit instance_did', async () => {
|
|
76
|
+
await expect(
|
|
77
|
+
withTenant(TENANT_A, () => Coupon.create(makeCoupon({ instance_did: TENANT_B }) as any))
|
|
78
|
+
).rejects.toMatchObject({ code: TENANT_MISMATCH });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('Data loss', () => {
|
|
83
|
+
it('update/destroy never cross tenants', async () => {
|
|
84
|
+
await withTenant(TENANT_A, () => Coupon.create(makeCoupon({ name: 'keep' }) as any));
|
|
85
|
+
await withTenant(TENANT_B, () => Coupon.create(makeCoupon({ name: 'victim' }) as any));
|
|
86
|
+
|
|
87
|
+
// A updates "everything" — must not touch B's row
|
|
88
|
+
await withTenant(TENANT_A, () => Coupon.update({ name: 'renamed' }, { where: {} }));
|
|
89
|
+
const bRow = await withTenant(TENANT_B, () => Coupon.findOne());
|
|
90
|
+
expect(bRow!.name).toBe('victim');
|
|
91
|
+
|
|
92
|
+
// A destroys "everything" — B's row survives
|
|
93
|
+
await withTenant(TENANT_A, () => Coupon.destroy({ where: {} }));
|
|
94
|
+
const bCount = await withTenant(TENANT_B, () => Coupon.count());
|
|
95
|
+
const aCount = await withTenant(TENANT_A, () => Coupon.count());
|
|
96
|
+
expect({ aCount, bCount }).toEqual({ aCount: 0, bCount: 1 });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('Data damage', () => {
|
|
101
|
+
it('roundtrip create -> findByPk preserves unicode name + metadata', async () => {
|
|
102
|
+
const created: any = await withTenant(TENANT_A, () =>
|
|
103
|
+
Coupon.create(makeCoupon({ name: '春季促销 🎉', metadata: { k: 'välue', n: 1 } }) as any)
|
|
104
|
+
);
|
|
105
|
+
const fetched: any = await withTenant(TENANT_A, () => Coupon.findByPk(created.id));
|
|
106
|
+
expect(fetched.name).toBe('春季促销 🎉');
|
|
107
|
+
expect(fetched.metadata).toEqual({ k: 'välue', n: 1 });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('Data leak', () => {
|
|
112
|
+
it('cross-tenant findByPk returns null; aggregates do not cross', async () => {
|
|
113
|
+
const bRow: any = await withTenant(TENANT_B, () => Coupon.create(makeCoupon({ name: 'b-only' }) as any));
|
|
114
|
+
await withTenant(TENANT_A, () => Coupon.create(makeCoupon({ name: 'a-only' }) as any));
|
|
115
|
+
|
|
116
|
+
// A cannot fetch B's row by its (globally unique) id
|
|
117
|
+
const leaked = await withTenant(TENANT_A, () => Coupon.findByPk(bRow.id));
|
|
118
|
+
expect(leaked).toBeNull();
|
|
119
|
+
|
|
120
|
+
// findAndCountAll count excludes the other tenant
|
|
121
|
+
const { count } = await withTenant(TENANT_A, () => Coupon.findAndCountAll());
|
|
122
|
+
expect(count).toBe(1);
|
|
123
|
+
|
|
124
|
+
const aCount = await withTenant(TENANT_A, () => Coupon.count());
|
|
125
|
+
expect(aCount).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('sum/max aggregates never cross tenants', async () => {
|
|
129
|
+
// A: two coupons (10 + 30 = 40, max 30); B: one coupon (99)
|
|
130
|
+
await withTenant(TENANT_A, () => Coupon.create(makeCoupon({ name: 'a-10', percent_off: 10 }) as any));
|
|
131
|
+
await withTenant(TENANT_A, () => Coupon.create(makeCoupon({ name: 'a-30', percent_off: 30 }) as any));
|
|
132
|
+
await withTenant(TENANT_B, () => Coupon.create(makeCoupon({ name: 'b-99', percent_off: 99 }) as any));
|
|
133
|
+
|
|
134
|
+
const aSum = await withTenant(TENANT_A, () => Coupon.sum('percent_off'));
|
|
135
|
+
const aMax = await withTenant(TENANT_A, () => Coupon.max('percent_off'));
|
|
136
|
+
const bSum = await withTenant(TENANT_B, () => Coupon.sum('percent_off'));
|
|
137
|
+
|
|
138
|
+
// A's aggregates exclude B's 99 entirely
|
|
139
|
+
expect(aSum).toBe(40);
|
|
140
|
+
expect(aMax).toBe(30);
|
|
141
|
+
expect(bSum).toBe(99);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('scoped-core is engine-agnostic (Phase 3 cross-engine import assertion)', () => {
|
|
147
|
+
// The same scopeWhere/stampTenant/isTenantTable the worker shim imports.
|
|
148
|
+
// No Node-only dependency is required to inject the tenant.
|
|
149
|
+
it('scopeWhere injects instance_did for tenant tables under withTenant', async () => {
|
|
150
|
+
await withTenant(TENANT_A, async () => {
|
|
151
|
+
expect(isTenantTable('coupons')).toBe(true);
|
|
152
|
+
expect(scopeWhere({ name: 'x' })).toEqual({ name: 'x', instance_did: TENANT_A });
|
|
153
|
+
expect(stampTenant({ name: 'x' })).toEqual({ name: 'x', instance_did: TENANT_A });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('scopeWhere fails closed on a conflicting explicit tenant', async () => {
|
|
158
|
+
await expect(withTenant(TENANT_A, async () => scopeWhere({ instance_did: TENANT_B }))).rejects.toMatchObject({
|
|
159
|
+
code: TENANT_MISMATCH,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Phase 4 (W1′) — residual-hole closure on top of Phase 3's TenantModel.
|
|
2
|
+
//
|
|
3
|
+
// Covers the three Phase 4 deliverables that close the escapes the base class
|
|
4
|
+
// can't reach on its own:
|
|
5
|
+
// - 洞 G: raw sequelize.query on tenant tables is instance_did-guarded.
|
|
6
|
+
// - 洞 H observability: a forged cross-tenant job emits a structured
|
|
7
|
+
// TENANT_VIOLATION alert (not a silent scoped null).
|
|
8
|
+
// - system* security: the cross-tenant bypass is explicit-only — a normal
|
|
9
|
+
// route/tenant context can never read across tenants.
|
|
10
|
+
import { Sequelize } from 'sequelize';
|
|
11
|
+
|
|
12
|
+
import { context, isSystemContext, withTenant } from '../../src/libs/context';
|
|
13
|
+
import { assertJobObjectTenant } from '../../src/libs/queue';
|
|
14
|
+
import { TENANT_CONTEXT_MISSING, TENANT_MISMATCH } from '../../src/libs/tenant';
|
|
15
|
+
import logger from '../../src/libs/logger';
|
|
16
|
+
import { Coupon, MeterEvent, PromotionCode, initialize } from '../../src/store/models';
|
|
17
|
+
import { systemFindByPk } from '../../src/store/scoped';
|
|
18
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
19
|
+
|
|
20
|
+
jest.mock('../../src/libs/logger');
|
|
21
|
+
|
|
22
|
+
const sequelize = new Sequelize('sqlite::memory:', { logging: false });
|
|
23
|
+
initialize(sequelize);
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
await sequelize.sync({ force: true });
|
|
27
|
+
});
|
|
28
|
+
afterAll(() => sequelize.close());
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
await sequelize.query('DELETE FROM meter_events');
|
|
33
|
+
await sequelize.query('DELETE FROM coupons');
|
|
34
|
+
await sequelize.query('DELETE FROM promotion_codes');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('洞 G — raw query instance_did guard (MeterEvent.getEventStats)', () => {
|
|
38
|
+
let seq = 0;
|
|
39
|
+
const seedEvent = (tenant: string, value: number) => {
|
|
40
|
+
seq += 1;
|
|
41
|
+
return withTenant(tenant, () =>
|
|
42
|
+
MeterEvent.create({
|
|
43
|
+
event_name: 'api.calls',
|
|
44
|
+
identifier: `evt-${tenant}-${seq}`,
|
|
45
|
+
timestamp: 1700000000 + seq,
|
|
46
|
+
created_via: 'api',
|
|
47
|
+
livemode: true,
|
|
48
|
+
status: 'completed',
|
|
49
|
+
payload: { value: String(value), customer_id: 'c-1' },
|
|
50
|
+
} as any)
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it('the raw SUM aggregate never crosses tenants', async () => {
|
|
55
|
+
await seedEvent(TENANT_A, 10);
|
|
56
|
+
await seedEvent(TENANT_A, 30);
|
|
57
|
+
await seedEvent(TENANT_B, 999);
|
|
58
|
+
|
|
59
|
+
const aStats = await withTenant(TENANT_A, () => MeterEvent.getEventStats('api.calls'));
|
|
60
|
+
const bStats = await withTenant(TENANT_B, () => MeterEvent.getEventStats('api.calls'));
|
|
61
|
+
|
|
62
|
+
// A's total_value (raw SUM) excludes B's 999 entirely
|
|
63
|
+
expect(Number(aStats.total_value)).toBe(40);
|
|
64
|
+
expect(aStats.total_events).toBe(2);
|
|
65
|
+
expect(Number(bStats.total_value)).toBe(999);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('Bad input: a guarded read fails closed with no tenant context (multi mode) — never full-scans', async () => {
|
|
69
|
+
// The raw guards bind getInstanceDid() into the WHERE; that same getter is
|
|
70
|
+
// the fail-closed foundation. In multi mode with no withTenant context it
|
|
71
|
+
// throws TENANT_CONTEXT_MISSING, so the guarded query is REFUSED rather than
|
|
72
|
+
// run unbound (which would scan every tenant's rows). Proven here on a single
|
|
73
|
+
// scoped op (no Promise.all fan-out -> no orphaned rejections).
|
|
74
|
+
const saved = process.env.PAYMENT_TENANT_MODE;
|
|
75
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
76
|
+
try {
|
|
77
|
+
await expect(Coupon.count()).rejects.toMatchObject({ code: TENANT_CONTEXT_MISSING });
|
|
78
|
+
} finally {
|
|
79
|
+
if (saved === undefined) delete process.env.PAYMENT_TENANT_MODE;
|
|
80
|
+
else process.env.PAYMENT_TENANT_MODE = saved;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('洞 H observability — assertJobObjectTenant emits TENANT_VIOLATION', () => {
|
|
86
|
+
it('a forged cross-tenant job logs a structured violation AND throws', async () => {
|
|
87
|
+
await withTenant(TENANT_A, () => {
|
|
88
|
+
// row belongs to B, job is running under A -> forged
|
|
89
|
+
let thrown: any;
|
|
90
|
+
try {
|
|
91
|
+
assertJobObjectTenant({ instance_did: TENANT_B });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
thrown = err;
|
|
94
|
+
}
|
|
95
|
+
expect(thrown?.code).toBe(TENANT_MISMATCH);
|
|
96
|
+
expect((thrown as any)?.nonRetryable).toBe(true);
|
|
97
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
98
|
+
'TENANT_VIOLATION',
|
|
99
|
+
expect.objectContaining({ code: TENANT_MISMATCH, rowTenant: TENANT_B, jobTenant: TENANT_A })
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a same-tenant job neither logs nor throws', async () => {
|
|
105
|
+
await withTenant(TENANT_A, () => {
|
|
106
|
+
expect(() => assertJobObjectTenant({ instance_did: TENANT_A })).not.toThrow();
|
|
107
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('an absent object is the handler no-op path (no violation)', async () => {
|
|
112
|
+
await withTenant(TENANT_A, () => {
|
|
113
|
+
expect(() => assertJobObjectTenant(null)).not.toThrow();
|
|
114
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('system* security — the bypass is explicit-only', () => {
|
|
120
|
+
it('a normal tenant context is NOT a system context', async () => {
|
|
121
|
+
await withTenant(TENANT_A, () => {
|
|
122
|
+
expect(isSystemContext()).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('a normal context query stays scoped (cannot read cross-tenant)', async () => {
|
|
127
|
+
const bRow: any = await withTenant(TENANT_B, () =>
|
|
128
|
+
Coupon.create({ livemode: false, duration: 'once', name: 'b', created_via: 'api' } as any)
|
|
129
|
+
);
|
|
130
|
+
// bare findByPk under A -> scoped -> null (no leak)
|
|
131
|
+
const leaked = await withTenant(TENANT_A, () => Coupon.findByPk(bRow.id));
|
|
132
|
+
expect(leaked).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('only the system* helper enters runAsSystem; it restores afterwards', async () => {
|
|
136
|
+
const bRow: any = await withTenant(TENANT_B, () =>
|
|
137
|
+
Coupon.create({ livemode: false, duration: 'once', name: 'b2', created_via: 'api' } as any)
|
|
138
|
+
);
|
|
139
|
+
await withTenant(TENANT_A, async () => {
|
|
140
|
+
// inside systemFindByPk the bypass is active -> cross-tenant row loads
|
|
141
|
+
const viaSystem = await systemFindByPk(Coupon, bRow.id);
|
|
142
|
+
expect(viaSystem).not.toBeNull();
|
|
143
|
+
// and the flag is restored the moment the helper settles
|
|
144
|
+
expect(isSystemContext()).toBe(false);
|
|
145
|
+
// so a subsequent bare read is scoped again
|
|
146
|
+
const leaked = await Coupon.findByPk(bRow.id);
|
|
147
|
+
expect(leaked).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('runAsSystem is reentrant-safe and does not leak across withTenant siblings', async () => {
|
|
152
|
+
let insideFlag = false;
|
|
153
|
+
await context.runAsSystem(async () => {
|
|
154
|
+
insideFlag = isSystemContext();
|
|
155
|
+
});
|
|
156
|
+
expect(insideFlag).toBe(true);
|
|
157
|
+
// outside the span, flag is gone
|
|
158
|
+
expect(isSystemContext()).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('洞 F — eager include transitive safety (scoped root => same-tenant children)', () => {
|
|
163
|
+
// Base-class override does not fire for the JOIN itself, but the ROOT query is
|
|
164
|
+
// scoped, and associations join by globally-unique FK ids that belong to one
|
|
165
|
+
// tenant, so children follow the (scoped) root — no cross-tenant child rows.
|
|
166
|
+
const seedPromo = async (tenant: string, name: string) =>
|
|
167
|
+
withTenant(tenant, async () => {
|
|
168
|
+
const coupon: any = await Coupon.create({
|
|
169
|
+
livemode: false,
|
|
170
|
+
duration: 'once',
|
|
171
|
+
name,
|
|
172
|
+
created_via: 'api',
|
|
173
|
+
} as any);
|
|
174
|
+
return PromotionCode.create({
|
|
175
|
+
livemode: false,
|
|
176
|
+
active: true,
|
|
177
|
+
code: `CODE-${name}`,
|
|
178
|
+
coupon_id: coupon.id,
|
|
179
|
+
} as any);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('findAll with include returns only the active tenant rows + their own coupon', async () => {
|
|
183
|
+
await seedPromo(TENANT_A, 'a');
|
|
184
|
+
await seedPromo(TENANT_B, 'b');
|
|
185
|
+
|
|
186
|
+
const aRows: any = await withTenant(TENANT_A, () =>
|
|
187
|
+
PromotionCode.findAll({ include: [{ model: Coupon, as: 'coupon' }] })
|
|
188
|
+
);
|
|
189
|
+
expect(aRows).toHaveLength(1);
|
|
190
|
+
expect(aRows[0].code).toBe('CODE-a');
|
|
191
|
+
expect(aRows[0].instance_did).toBe(TENANT_A);
|
|
192
|
+
// the eager-loaded child belongs to the same tenant (FK-consistent)
|
|
193
|
+
expect(aRows[0].coupon.instance_did).toBe(TENANT_A);
|
|
194
|
+
expect(aRows[0].coupon.name).toBe('a');
|
|
195
|
+
});
|
|
196
|
+
});
|
package/api/third.d.ts
CHANGED
package/blocklet.yml
CHANGED
package/cloudflare/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
Payment Kit Worker
|
|
9
9
|
├── D1 Database # 业务数据(customers/invoices/subscriptions ...)
|
|
10
10
|
├── KV: DID_CONNECT_KV # DID Connect session 临时存储
|
|
11
|
-
├── CF Queue #
|
|
11
|
+
├── CF Queue # 可选 executor 绑定;队列引擎是 api/src/libs/queue(node 引擎)
|
|
12
12
|
├── Hyperdrive # (可选)连接 Postgres,仅在启用时使用
|
|
13
13
|
├── Service Binding
|
|
14
14
|
│ ├── AUTH_SERVICE → blocklet-service (BlockletServiceRPC)
|
|
@@ -20,9 +20,29 @@ Payment Kit Worker
|
|
|
20
20
|
|
|
21
21
|
- **Hono** 替代 Express(路由 + 中间件适配层)
|
|
22
22
|
- **Sequelize-D1 shim** 替代 SQLite,表结构自动 sync
|
|
23
|
-
- **CF Queue** 替代 fastq,加 D1-native cron fallback
|
|
24
23
|
- **wrapAsyncListener** 追踪 `EventEmitter` async listener 的 Promise,防止 CF runtime 提前回收
|
|
25
24
|
|
|
25
|
+
### worker 是 host adapter(Phase 12,2026-06)
|
|
26
|
+
|
|
27
|
+
worker **不再自带**一套业务引擎。它只是一个 host adapter:
|
|
28
|
+
|
|
29
|
+
- **业务 `/api` 入口统一经 `createEmbeddedPaymentService({ config, db, slots })`**,worker 只挂载
|
|
30
|
+
`svc.http.resourceRoutes`,DID-Connect 走 worker 自己的 Hono shell。worker **绝不**访问 `svc.handler`
|
|
31
|
+
(那是 node-only Express app 壳)——`ensurePaymentService` 用 Proxy 把 `.handler` 访问 trap 成 hard error。
|
|
32
|
+
- **队列只有一套引擎** = `api/src/libs/queue`(node 引擎)。worker 不再用已删除的 `shims/queue.ts`。host 只替换
|
|
33
|
+
trigger / executor / flush:executor = `shims/fastq`,trigger = `scheduled()` 调 `dispatchDueJobs()`,
|
|
34
|
+
flush = `flushQueueWork()`,全部经 `api/src/libs/queue/runtime.ts` 这个 host-facing surface 驱动。
|
|
35
|
+
workerd 模式下 node 引擎的后台 `loop()` 被禁用(冻结的 isolate 不能跑后台 timer)。
|
|
36
|
+
- **config 经 config slot 授权**:`envToPaymentCoreConfig(env)` → `setCoreConfig`,HTTP 与 scheduled/queue 两条
|
|
37
|
+
路径都不再有 `process.env` mirror。
|
|
38
|
+
- **构建**:**`run-build.js` 是 standalone 的 CF deploy build**(`node run-build.js` → `wrangler deploy`),
|
|
39
|
+
带全部 bundle 优化(stripe-cf、axios-lite、native fetch、cbor-only、ethers wordlists drop、noop packages、
|
|
40
|
+
node builtin shims;见 `docs/2026-06-10-bundle-size-analysis.md`,gzip ~1 MiB,实测部署 + 支付路由验证过)。
|
|
41
|
+
Phase 12b/12c 只从中移除了两个已死的 plugin:`queue-shim`(option A:队列引擎统一为 `api/src/libs/queue`,
|
|
42
|
+
`shims/queue.ts` 已删)和 `lock-shim`(lock 已改成 Phase 8 driver,`shims/lock.ts` 已删)。
|
|
43
|
+
**`build.ts` 是 diagnostic/backup 构建**(`npx tsx build.ts`,arc-integration 诊断用)——它**不**带上述优化、
|
|
44
|
+
用不同的 node-builtin 策略,**不是** deploy build,部署一律用 `run-build.js`。
|
|
45
|
+
|
|
26
46
|
---
|
|
27
47
|
|
|
28
48
|
## ⚠️ 必须先部署的前置服务
|
|
@@ -465,8 +485,9 @@ wrangler d1 execute payment-kit-prod --remote \
|
|
|
465
485
|
```
|
|
466
486
|
blocklets/core/cloudflare/
|
|
467
487
|
├── worker.ts # Worker 入口(Hono 路由 + Express 适配)
|
|
468
|
-
├── run-build.js # esbuild
|
|
469
|
-
├── build.ts #
|
|
488
|
+
├── run-build.js # standalone CF deploy build(esbuild + 全部 bundle 优化)← 部署用这个
|
|
489
|
+
├── build.ts # diagnostic/backup 构建(更少优化;不是 deploy build)
|
|
490
|
+
├── queue-runtime-mode.ts # 把 core 队列引擎 pin 成 workerd(在业务队列加载前)
|
|
470
491
|
├── vite.config.ts # 前端 Vite 构建配置
|
|
471
492
|
├── did-connect-auth.ts # DID Connect 登录路由(挂载需要 APP_SK + DID_CONNECT_KV)
|
|
472
493
|
├── wrangler.jsonc # 生产配置模板
|
|
@@ -480,8 +501,7 @@ blocklets/core/cloudflare/
|
|
|
480
501
|
│ │ ├── verify-sign.ts # 组件签名验证(委托 AUTH_SERVICE)
|
|
481
502
|
│ │ └── ...
|
|
482
503
|
│ ├── sequelize-d1/ # Sequelize → D1 适配
|
|
483
|
-
│ ├──
|
|
484
|
-
│ ├── lock.ts # 分布式锁(D1 实现)
|
|
504
|
+
│ ├── fastq.ts # fastq executor 替身(队列引擎仍是 api/src/libs/queue)
|
|
485
505
|
│ ├── cron.ts # 定时任务
|
|
486
506
|
│ └── ...
|
|
487
507
|
└── test-aigne-hub/ # Gateway 集成参考实现
|