@stratum-hq/create 0.3.1 → 0.4.1

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/dist/index.js CHANGED
@@ -1,11 +1,8 @@
1
- #!/usr/bin/env node
2
-
3
1
  // src/index.ts
4
2
  import * as fs2 from "fs";
5
3
  import * as path2 from "path";
6
4
  import crypto2 from "node:crypto";
7
5
  import { execSync as execSync2 } from "child_process";
8
- import { fileURLToPath } from "url";
9
6
 
10
7
  // src/matrix.ts
11
8
  var POSTGRES_STRATEGIES = ["rls", "schema", "database"];
@@ -37,19 +34,14 @@ var ALL_STRATEGIES = ["rls", "schema", "database", "collection", "table-prefix"]
37
34
  var ALL_ORMS = ["prisma", "drizzle", "sequelize", "knex", "mongoose", "pg"];
38
35
  function isValidPreset(preset) {
39
36
  const config = VALID_COMBINATIONS[preset.database];
40
- if (!config)
41
- return false;
42
- if (!config.strategies.includes(preset.strategy))
43
- return false;
44
- if (!config.orms.includes(preset.orm))
45
- return false;
46
- if (!config.frameworks.includes(preset.framework))
47
- return false;
37
+ if (!config) return false;
38
+ if (!config.strategies.includes(preset.strategy)) return false;
39
+ if (!config.orms.includes(preset.orm)) return false;
40
+ if (!config.frameworks.includes(preset.framework)) return false;
48
41
  return true;
49
42
  }
50
43
  function parsePresetString(s) {
51
- if (!s || typeof s !== "string")
52
- return null;
44
+ if (!s || typeof s !== "string") return null;
53
45
  const parts = s.toLowerCase().split("-");
54
46
  let database;
55
47
  let strategy;
@@ -65,14 +57,10 @@ function parsePresetString(s) {
65
57
  } else {
66
58
  return null;
67
59
  }
68
- if (!ALL_DATABASES.includes(database))
69
- return null;
70
- if (!ALL_STRATEGIES.includes(strategy))
71
- return null;
72
- if (!ALL_ORMS.includes(orm))
73
- return null;
74
- if (!ALL_FRAMEWORKS.includes(framework))
75
- return null;
60
+ if (!ALL_DATABASES.includes(database)) return null;
61
+ if (!ALL_STRATEGIES.includes(strategy)) return null;
62
+ if (!ALL_ORMS.includes(orm)) return null;
63
+ if (!ALL_FRAMEWORKS.includes(framework)) return null;
76
64
  return {
77
65
  database,
78
66
  strategy,
@@ -182,6 +170,36 @@ volumes:
182
170
  }
183
171
 
184
172
  // src/generators/init-sql.ts
173
+ function postgresAppRole(dbName) {
174
+ return `${dbName}_app`;
175
+ }
176
+ var POSTGRES_APP_PASSWORD = "dev_app_password";
177
+ function postgresAppRoleSql(dbName, strategy) {
178
+ const role = postgresAppRole(dbName);
179
+ let strategyGrant = "";
180
+ if (strategy === "schema") {
181
+ strategyGrant = `
182
+ -- schema-per-tenant: the app creates one schema per tenant
183
+ GRANT CREATE ON DATABASE ${dbName} TO ${role};
184
+ `;
185
+ } else if (strategy === "database") {
186
+ strategyGrant = `
187
+ -- database-per-tenant: the app creates one database per tenant
188
+ ALTER ROLE ${role} CREATEDB;
189
+ `;
190
+ }
191
+ return `
192
+ -- Application role. The app connects as ${role} (DATABASE_URL), never as the
193
+ -- bootstrap superuser (DATABASE_ADMIN_URL): a superuser or BYPASSRLS role
194
+ -- ignores every row-level security policy, FORCE included. Use the superuser
195
+ -- only for bootstrap and migrations.
196
+ CREATE ROLE ${role} WITH LOGIN PASSWORD '${POSTGRES_APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS NOCREATEROLE;
197
+ GRANT CONNECT ON DATABASE ${dbName} TO ${role};
198
+ GRANT USAGE, CREATE ON SCHEMA public TO ${role};
199
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role};
200
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role};
201
+ ${strategyGrant}`;
202
+ }
185
203
  function generatePresetInitSql(projectName, preset) {
186
204
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
187
205
  switch (preset.database) {
@@ -201,10 +219,16 @@ function generatePostgresInit(projectName, dbName, strategy) {
201
219
  -- Add RLS policies to each tenant-scoped table:
202
220
  --
203
221
  -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
222
+ -- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
204
223
  -- CREATE POLICY tenant_isolation ON your_table
205
- -- USING (tenant_id = current_setting('app.current_tenant')::uuid);
224
+ -- USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
206
225
  --
207
- -- The Stratum db-adapters package sets app.current_tenant automatically.
226
+ -- A pooled connection reads the setting as '' after a tenant transaction ends,
227
+ -- or as NULL before the first one. NULLIF makes both return no rows; a bare
228
+ -- ::uuid cast of '' raises an error.
229
+ -- FORCE makes the policy apply to the table owner too; without it, a table
230
+ -- created by the application role is not isolated for that role.
231
+ -- The Stratum db-adapters package sets app.current_tenant_id automatically.
208
232
  `;
209
233
  }
210
234
  return `-- Initialize ${projectName} database
@@ -215,7 +239,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
215
239
  -- The ltree extension enables hierarchical tenant trees
216
240
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
217
241
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
218
- ${rlsBlock}`;
242
+ ${postgresAppRoleSql(dbName, strategy)}${rlsBlock}`;
219
243
  }
220
244
  function generateMysqlInit(projectName, dbName) {
221
245
  return `-- Initialize ${projectName} database
@@ -252,7 +276,6 @@ function generateDbSetup(preset) {
252
276
  }
253
277
  function generatePrismaSetup(preset) {
254
278
  const provider = preset.database === "mysql" ? "mysql" : "postgresql";
255
- const envUrl = preset.database === "mysql" ? "mysql://user:password@localhost:3306/dbname" : "postgres://user:password@localhost:5432/dbname";
256
279
  return [
257
280
  {
258
281
  filename: "prisma/schema.prisma",
@@ -344,9 +367,10 @@ export function createTenantDb(getTenantId: () => string) {
344
367
  return drizzleWithTenant(db, getTenantId, pool);
345
368
  }
346
369
 
347
- // Usage:
370
+ // Usage: run tenant queries inside transaction() so the tenant context and
371
+ // the query share one connection.
348
372
  // const tenantDb = createTenantDb(() => currentTenantId);
349
- // const rows = await tenantDb.select().from(orders);
373
+ // const rows = await tenantDb.transaction((tx) => tx.select().from(orders));
350
374
 
351
375
  export { pool };
352
376
  `
@@ -422,59 +446,106 @@ export default config;
422
446
  },
423
447
  {
424
448
  filename: "src/stratum-knex.ts",
425
- content: `// Knex with Stratum tenant-scoped queries
449
+ content: preset.database === "postgres" ? KNEX_POSTGRES_SCOPE : knexColumnScope(preset.database)
450
+ }
451
+ ];
452
+ }
453
+ var KNEX_POSTGRES_SCOPE = `// Knex with Stratum tenant-scoped queries
454
+ import createKnex, { type Knex } from "knex";
455
+ import config from "../knexfile.js";
456
+
457
+ const knex = createKnex(config);
458
+
459
+ // Run fn in a transaction that sets app.current_tenant_id, the setting that
460
+ // the RLS policies read. The setting ends with the transaction, so run every
461
+ // tenant query through trx, not through knex.
462
+ export async function withTenantScope<T>(
463
+ tenantId: string,
464
+ fn: (trx: Knex.Transaction) => Promise<T>,
465
+ ): Promise<T> {
466
+ return knex.transaction(async (trx) => {
467
+ await trx.raw("SELECT set_config('app.current_tenant_id', ?, true)", [tenantId]);
468
+ return fn(trx);
469
+ });
470
+ }
471
+
472
+ // Usage:
473
+ // const orders = await withTenantScope(currentTenantId, (trx) => trx("orders").select());
474
+
475
+ export { knex };
476
+ `;
477
+ function knexColumnScope(database) {
478
+ return `// Knex with Stratum tenant-scoped queries
426
479
  import Knex from "knex";
427
480
  import config from "../knexfile.js";
428
481
 
429
482
  const knex = Knex(config);
430
483
 
431
484
  // Create a tenant-scoped query builder.
432
- // For RLS strategy, set the session variable before queries.
433
485
  export async function withTenantScope(tenantId: string, fn: (db: typeof knex) => Promise<void>) {
434
- ${preset.database === "postgres" ? ` await knex.raw("SET app.current_tenant = ?", [tenantId]);` : ` // For ${preset.database}, scope queries by tenant_id column`}
435
- try {
436
- await fn(knex);
437
- } finally {
438
- ${preset.database === "postgres" ? ` await knex.raw("RESET app.current_tenant");` : ` // Scope cleanup not needed for column-based isolation`}
439
- }
486
+ // For ${database}, scope queries by tenant_id column
487
+ await fn(knex);
440
488
  }
441
489
 
442
490
  export { knex };
443
- `
444
- }
445
- ];
491
+ `;
492
+ }
493
+ function generateMongooseSetup(preset) {
494
+ const tenantAccess = preset.strategy === "collection" ? `// Return the tenant's model for a base collection. The tenant's documents are
495
+ // in the collection {baseCollection}_{tenantSlug}, the name that
496
+ // MongoCollectionAdapter from @stratum-hq/mongodb uses.
497
+ export function getTenantModel<T>(
498
+ baseCollection: string,
499
+ schema: mongoose.Schema<T>,
500
+ tenantSlug: string,
501
+ ) {
502
+ const name = \`\${baseCollection}_\${assertSlug(tenantSlug)}\`;
503
+ return mainConnection.models[name] ?? mainConnection.model(name, schema, name);
504
+ }
505
+
506
+ // Usage:
507
+ // const Order = getTenantModel("orders", OrderSchema, "tenant_abc");
508
+ // const orders = await Order.find();` : `// Return a connection to the tenant's own database. The database name is
509
+ // stratum_tenant_{tenantSlug}, the name that MongoDatabaseAdapter from
510
+ // @stratum-hq/mongodb uses. useCache returns the same connection on each call.
511
+ export function getTenantConnection(tenantSlug: string) {
512
+ return mainConnection.useDb(\`stratum_tenant_\${assertSlug(tenantSlug)}\`, { useCache: true });
446
513
  }
447
- function generateMongooseSetup(_preset) {
514
+
515
+ // Usage:
516
+ // const conn = getTenantConnection("tenant_abc");
517
+ // const Order = conn.model("Order", OrderSchema);
518
+ // const orders = await Order.find();`;
448
519
  return [
449
520
  {
450
521
  filename: "src/stratum-mongoose.ts",
451
522
  content: `// Mongoose with Stratum multi-tenant support
452
523
  import mongoose from "mongoose";
453
- import { createTenantConnection } from "@stratum-hq/mongodb";
454
524
 
455
525
  // Main connection (used for tenant metadata)
456
526
  const mainConnection = mongoose.createConnection(
457
527
  process.env.MONGODB_URI || "mongodb://localhost:27017/main",
458
528
  );
459
529
 
460
- // Create a tenant-scoped connection.
461
- // Each tenant gets its own database (database-per-tenant strategy)
462
- // or collection prefix (collection strategy).
463
- export function getTenantConnection(tenantId: string) {
464
- return createTenantConnection(mainConnection, tenantId);
530
+ // A tenant slug becomes part of a MongoDB name, so it must match the Stratum
531
+ // slug rule: a lowercase letter, then lowercase letters, digits or underscores.
532
+ const SLUG_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
533
+
534
+ function assertSlug(tenantSlug: string): string {
535
+ if (!SLUG_PATTERN.test(tenantSlug)) {
536
+ throw new Error(\`Invalid tenant slug: "\${tenantSlug}"\`);
537
+ }
538
+ return tenantSlug;
465
539
  }
466
540
 
541
+ ${tenantAccess}
542
+
467
543
  // Define schemas that work across tenant connections
468
544
  export const TenantSchema = new mongoose.Schema({
469
545
  name: { type: String, required: true },
470
546
  createdAt: { type: Date, default: Date.now },
471
547
  });
472
548
 
473
- // Usage:
474
- // const conn = getTenantConnection("tenant-abc");
475
- // const Order = conn.model("Order", OrderSchema);
476
- // const orders = await Order.find();
477
-
478
549
  export { mainConnection };
479
550
  `
480
551
  }
@@ -493,7 +564,11 @@ const pool = mysql.createPool({
493
564
  });
494
565
 
495
566
  // Execute a query scoped to a tenant by filtering on tenant_id
496
- export async function tenantQuery(tenantId: string, sql: string, params: unknown[] = []) {
567
+ export async function tenantQuery(
568
+ tenantId: string,
569
+ sql: string,
570
+ params: (string | number | bigint | boolean | Date | null)[] = [],
571
+ ) {
497
572
  const [rows] = await pool.execute(sql, [...params, tenantId]);
498
573
  return rows;
499
574
  }
@@ -518,9 +593,10 @@ const pool = new Pool({
518
593
  });
519
594
 
520
595
  // Create a tenant-scoped pool.
521
- // Sets app.current_tenant on each connection for RLS enforcement.
596
+ // Each query runs in a transaction that sets app.current_tenant_id, the
597
+ // setting that the RLS policies read.
522
598
  export function getTenantPool(tenantId: string) {
523
- return createTenantPool(pool, tenantId);
599
+ return createTenantPool(pool, () => tenantId);
524
600
  }
525
601
 
526
602
  // Usage:
@@ -561,11 +637,13 @@ const port = Number(process.env.PORT) || 3000;
561
637
 
562
638
  app.use(express.json());
563
639
 
564
- // Tenant extraction middleware
640
+ // Tenant extraction middleware. The tenant comes from the subdomain the
641
+ // request was routed to. Do not take it from a client-supplied header such as
642
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
643
+ // authentication, check that the signed-in user belongs to this tenant, or
644
+ // derive the tenant from the verified session or JWT instead.
565
645
  app.use((req, _res, next) => {
566
- const tenantId =
567
- req.headers["x-tenant-id"] as string ||
568
- req.hostname.split(".")[0];
646
+ const tenantId = req.hostname.split(".")[0];
569
647
  (req as any).tenantId = tenantId;
570
648
  next();
571
649
  });
@@ -595,12 +673,14 @@ function generateFastifyMiddleware(projectName) {
595
673
  const fastify = Fastify({ logger: true });
596
674
  const port = Number(process.env.PORT) || 3000;
597
675
 
598
- // Tenant extraction plugin
676
+ // Tenant extraction hook. The tenant comes from the subdomain the
677
+ // request was routed to. Do not take it from a client-supplied header such as
678
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
679
+ // authentication, check that the signed-in user belongs to this tenant, or
680
+ // derive the tenant from the verified session or JWT instead.
599
681
  fastify.decorateRequest("tenantId", "");
600
682
  fastify.addHook("onRequest", async (request) => {
601
- const tenantId =
602
- (request.headers["x-tenant-id"] as string) ||
603
- (request.hostname?.split(".")[0] ?? "");
683
+ const tenantId = request.hostname?.split(".")[0] ?? "";
604
684
  (request as any).tenantId = tenantId;
605
685
  });
606
686
 
@@ -631,14 +711,15 @@ function generateNextjsMiddleware(projectName) {
631
711
  import { NextRequest, NextResponse } from "next/server";
632
712
 
633
713
  export function middleware(request: NextRequest) {
714
+ // The tenant comes from the subdomain the request was routed to. Any
715
+ // x-tenant-id the client sent is removed first, so server code that reads
716
+ // x-tenant-id only ever sees the value set here. Once you add
717
+ // authentication, check that the signed-in user belongs to this tenant.
634
718
  const hostname = request.headers.get("host") || "";
635
- const subdomain = hostname.split(".")[0];
636
- const headerTenantId = request.headers.get("x-tenant-id");
637
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
638
-
639
- const tenantId = headerTenantId || pathTenantId || subdomain;
719
+ const tenantId = hostname.split(".")[0];
640
720
 
641
721
  const requestHeaders = new Headers(request.headers);
722
+ requestHeaders.delete("x-tenant-id");
642
723
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
643
724
  requestHeaders.set("x-tenant-id", tenantId);
644
725
  }
@@ -661,7 +742,7 @@ export default function Home() {
661
742
  <p>Multi-tenant app powered by Stratum.</p>
662
743
  <ul>
663
744
  <li>Configure tenants via the Stratum control plane</li>
664
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
745
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
665
746
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
666
747
  </ul>
667
748
  </main>
@@ -680,11 +761,13 @@ import { serve } from "@hono/node-server";
680
761
 
681
762
  const app = new Hono();
682
763
 
683
- // Tenant extraction middleware
764
+ // Tenant extraction middleware. The tenant comes from the subdomain the
765
+ // request was routed to. Do not take it from a client-supplied header such as
766
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
767
+ // authentication, check that the signed-in user belongs to this tenant, or
768
+ // derive the tenant from the verified session or JWT instead.
684
769
  app.use("*", async (c, next) => {
685
- const tenantId =
686
- c.req.header("x-tenant-id") ||
687
- new URL(c.req.url).hostname.split(".")[0];
770
+ const tenantId = new URL(c.req.url).hostname.split(".")[0];
688
771
  c.set("tenantId", tenantId);
689
772
  await next();
690
773
  });
@@ -763,9 +846,11 @@ export class AppController {
763
846
  export class TenantGuard implements CanActivate {
764
847
  canActivate(context: ExecutionContext): boolean {
765
848
  const request = context.switchToHttp().getRequest();
766
- const tenantId =
767
- request.headers["x-tenant-id"] ||
768
- request.hostname?.split(".")[0];
849
+ // The tenant comes from the subdomain the request was routed to. Do not
850
+ // take it from a client-supplied header such as x-tenant-id: any caller
851
+ // can set one and pick another tenant. Once you add authentication, check
852
+ // that the signed-in user belongs to this tenant here.
853
+ const tenantId = request.hostname?.split(".")[0];
769
854
  request.tenantId = tenantId || null;
770
855
  return true;
771
856
  }
@@ -789,16 +874,516 @@ console.log("Add tenant resolution logic for your use case");
789
874
  ];
790
875
  }
791
876
 
877
+ // src/generators/tsconfig.ts
878
+ function rootSources(preset) {
879
+ return preset.orm === "knex" ? ["knexfile.ts"] : [];
880
+ }
881
+ function generateTsconfig(framework, extraSources = []) {
882
+ const rootDir = extraSources.length > 0 ? "." : "src";
883
+ return JSON.stringify(
884
+ {
885
+ compilerOptions: {
886
+ target: "ESNext",
887
+ module: "NodeNext",
888
+ moduleResolution: "NodeNext",
889
+ strict: true,
890
+ outDir: "dist",
891
+ rootDir,
892
+ declaration: true,
893
+ skipLibCheck: true,
894
+ esModuleInterop: true,
895
+ ...framework === "nestjs" ? { experimentalDecorators: true, emitDecoratorMetadata: true } : {},
896
+ ...framework === "nextjs" ? { jsx: "preserve", plugins: [{ name: "next" }] } : {}
897
+ },
898
+ include: ["src", ...extraSources],
899
+ exclude: ["node_modules", "dist"]
900
+ },
901
+ null,
902
+ 2
903
+ );
904
+ }
905
+
906
+ // ../hono/package.json
907
+ var package_default = {
908
+ name: "@stratum-hq/hono",
909
+ version: "1.1.0",
910
+ description: "Stratum Hono integration \u2014 tenant extraction middleware with ALS context",
911
+ keywords: [
912
+ "multi-tenancy",
913
+ "multi-tenant",
914
+ "saas",
915
+ "tenant",
916
+ "hono",
917
+ "middleware",
918
+ "typescript",
919
+ "stratum"
920
+ ],
921
+ type: "module",
922
+ main: "./dist/index.js",
923
+ types: "./dist/index.d.ts",
924
+ exports: {
925
+ ".": {
926
+ types: "./dist/index.d.ts",
927
+ default: "./dist/index.js"
928
+ }
929
+ },
930
+ files: [
931
+ "dist",
932
+ "README.md",
933
+ "LICENSE"
934
+ ],
935
+ scripts: {
936
+ build: "tsc",
937
+ test: "vitest run",
938
+ typecheck: "tsc --noEmit",
939
+ lint: "eslint .",
940
+ clean: "rm -rf dist"
941
+ },
942
+ publishConfig: {
943
+ access: "public",
944
+ registry: "https://registry.npmjs.org/"
945
+ },
946
+ peerDependencies: {
947
+ "@stratum-hq/core": "^1.0.0",
948
+ "@stratum-hq/sdk": "^1.2.0",
949
+ hono: ">=4.0.0"
950
+ },
951
+ devDependencies: {
952
+ "@stratum-hq/core": "*",
953
+ "@stratum-hq/sdk": "*",
954
+ hono: "^4.13.9",
955
+ typescript: "^5.4.0",
956
+ vitest: "^4.1.11"
957
+ },
958
+ license: "MIT",
959
+ author: "Christian Crank",
960
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/hono#readme",
961
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
962
+ engines: {
963
+ node: ">=20.0.0"
964
+ },
965
+ repository: {
966
+ type: "git",
967
+ url: "https://github.com/stratum-hq/Stratum.git",
968
+ directory: "packages/hono"
969
+ }
970
+ };
971
+
972
+ // ../db-adapters/package.json
973
+ var package_default2 = {
974
+ name: "@stratum-hq/db-adapters",
975
+ version: "1.2.0",
976
+ description: "Stratum PostgreSQL adapters for tenant-scoped query isolation",
977
+ keywords: [
978
+ "multi-tenancy",
979
+ "multi-tenant",
980
+ "saas",
981
+ "tenant",
982
+ "tenant-isolation",
983
+ "postgresql",
984
+ "postgres",
985
+ "row-level-security",
986
+ "rls",
987
+ "schema-isolation",
988
+ "database",
989
+ "stratum"
990
+ ],
991
+ main: "./dist/index.js",
992
+ types: "./dist/index.d.ts",
993
+ exports: {
994
+ ".": {
995
+ types: "./dist/index.d.ts",
996
+ default: "./dist/index.js"
997
+ }
998
+ },
999
+ files: [
1000
+ "dist",
1001
+ "README.md",
1002
+ "LICENSE"
1003
+ ],
1004
+ scripts: {
1005
+ build: "tsc",
1006
+ test: "vitest run",
1007
+ typecheck: "tsc --noEmit",
1008
+ lint: "eslint .",
1009
+ clean: "rm -rf dist"
1010
+ },
1011
+ publishConfig: {
1012
+ access: "public",
1013
+ registry: "https://registry.npmjs.org/"
1014
+ },
1015
+ dependencies: {
1016
+ "@stratum-hq/core": "^1.4.0",
1017
+ pg: "^8.11.0"
1018
+ },
1019
+ peerDependencies: {
1020
+ "drizzle-orm": ">=0.29.0"
1021
+ },
1022
+ peerDependenciesMeta: {
1023
+ "drizzle-orm": {
1024
+ optional: true
1025
+ }
1026
+ },
1027
+ devDependencies: {
1028
+ "@prisma/client": "^5.10.0",
1029
+ "@types/pg": "^8.11.0",
1030
+ "drizzle-orm": "^0.45.3",
1031
+ typescript: "^5.4.0",
1032
+ vitest: "^4.1.11"
1033
+ },
1034
+ license: "MIT",
1035
+ author: "Christian Crank",
1036
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/db-adapters#readme",
1037
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1038
+ engines: {
1039
+ node: ">=20.0.0"
1040
+ },
1041
+ repository: {
1042
+ type: "git",
1043
+ url: "https://github.com/stratum-hq/Stratum.git",
1044
+ directory: "packages/db-adapters"
1045
+ }
1046
+ };
1047
+
1048
+ // ../lib/package.json
1049
+ var package_default3 = {
1050
+ name: "@stratum-hq/lib",
1051
+ version: "1.4.0",
1052
+ description: "Stratum tenant management library - framework-agnostic business logic",
1053
+ keywords: [
1054
+ "multi-tenancy",
1055
+ "multi-tenant",
1056
+ "saas",
1057
+ "b2b",
1058
+ "tenant",
1059
+ "tenant-management",
1060
+ "tenant-isolation",
1061
+ "node",
1062
+ "typescript",
1063
+ "postgresql",
1064
+ "row-level-security",
1065
+ "rls",
1066
+ "schema-isolation",
1067
+ "config-inheritance",
1068
+ "gdpr",
1069
+ "stratum"
1070
+ ],
1071
+ main: "./dist/index.js",
1072
+ types: "./dist/index.d.ts",
1073
+ exports: {
1074
+ ".": {
1075
+ types: "./dist/index.d.ts",
1076
+ default: "./dist/index.js"
1077
+ }
1078
+ },
1079
+ files: [
1080
+ "dist",
1081
+ "README.md",
1082
+ "LICENSE"
1083
+ ],
1084
+ scripts: {
1085
+ build: "tsc && node ../../scripts/copy-dir.mjs src/migrations dist/migrations",
1086
+ test: "vitest run",
1087
+ typecheck: "tsc --noEmit",
1088
+ lint: "eslint .",
1089
+ clean: "rm -rf dist"
1090
+ },
1091
+ publishConfig: {
1092
+ access: "public",
1093
+ registry: "https://registry.npmjs.org/"
1094
+ },
1095
+ dependencies: {
1096
+ "@opentelemetry/api": "^1.9.0",
1097
+ "@stratum-hq/core": "^1.4.0",
1098
+ "@stratum-hq/db-adapters": "^1.2.0",
1099
+ "@stratum-hq/sdk": "^1.2.0",
1100
+ pg: "^8.11.0"
1101
+ },
1102
+ peerDependencies: {
1103
+ "@opentelemetry/api": "^1.0.0"
1104
+ },
1105
+ peerDependenciesMeta: {
1106
+ "@opentelemetry/api": {
1107
+ optional: true
1108
+ }
1109
+ },
1110
+ devDependencies: {
1111
+ "@types/pg": "^8.11.0",
1112
+ typescript: "^5.4.0",
1113
+ vitest: "^4.1.11"
1114
+ },
1115
+ license: "MIT",
1116
+ author: "Christian Crank",
1117
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/lib#readme",
1118
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1119
+ engines: {
1120
+ node: ">=20.0.0"
1121
+ },
1122
+ repository: {
1123
+ type: "git",
1124
+ url: "https://github.com/stratum-hq/Stratum.git",
1125
+ directory: "packages/lib"
1126
+ }
1127
+ };
1128
+
1129
+ // ../mongodb/package.json
1130
+ var package_default4 = {
1131
+ name: "@stratum-hq/mongodb",
1132
+ version: "0.4.0",
1133
+ description: "MongoDB tenant isolation for Stratum \u2014 shared collection, collection-per-tenant, and database-per-tenant strategies",
1134
+ keywords: [
1135
+ "multi-tenancy",
1136
+ "multi-tenant",
1137
+ "saas",
1138
+ "tenant",
1139
+ "tenant-isolation",
1140
+ "mongodb",
1141
+ "mongoose",
1142
+ "database",
1143
+ "stratum"
1144
+ ],
1145
+ main: "./dist/index.js",
1146
+ types: "./dist/index.d.ts",
1147
+ exports: {
1148
+ ".": {
1149
+ types: "./dist/index.d.ts",
1150
+ default: "./dist/index.js"
1151
+ }
1152
+ },
1153
+ files: [
1154
+ "dist",
1155
+ "README.md",
1156
+ "LICENSE"
1157
+ ],
1158
+ scripts: {
1159
+ build: "tsc",
1160
+ test: "vitest run",
1161
+ "test:watch": "vitest",
1162
+ "test:integration": "vitest run --config vitest.integration.config.ts",
1163
+ typecheck: "tsc --noEmit",
1164
+ lint: "eslint .",
1165
+ clean: "rm -rf dist"
1166
+ },
1167
+ dependencies: {
1168
+ "@stratum-hq/core": "^1.4.0",
1169
+ "@stratum-hq/sdk": "^1.2.0"
1170
+ },
1171
+ peerDependencies: {
1172
+ mongodb: "^6.0.0 || ^7.0.0"
1173
+ },
1174
+ peerDependenciesMeta: {
1175
+ mongoose: {
1176
+ optional: true
1177
+ }
1178
+ },
1179
+ devDependencies: {
1180
+ "@stratum-hq/test-utils": "*",
1181
+ mongodb: "^7.6.0",
1182
+ mongoose: "^8.0.0",
1183
+ typescript: "^5.9.0",
1184
+ vitest: "^4.1.11"
1185
+ },
1186
+ publishConfig: {
1187
+ access: "public"
1188
+ },
1189
+ license: "MIT",
1190
+ author: "Christian Crank",
1191
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/mongodb#readme",
1192
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1193
+ engines: {
1194
+ node: ">=20.0.0"
1195
+ },
1196
+ repository: {
1197
+ type: "git",
1198
+ url: "https://github.com/stratum-hq/Stratum.git",
1199
+ directory: "packages/mongodb"
1200
+ }
1201
+ };
1202
+
1203
+ // ../mysql/package.json
1204
+ var package_default5 = {
1205
+ name: "@stratum-hq/mysql",
1206
+ version: "0.4.0",
1207
+ description: "MySQL tenant isolation for Stratum \u2014 shared table, table-per-tenant, and database-per-tenant strategies",
1208
+ keywords: [
1209
+ "multi-tenancy",
1210
+ "multi-tenant",
1211
+ "saas",
1212
+ "tenant",
1213
+ "tenant-isolation",
1214
+ "mysql",
1215
+ "database",
1216
+ "typeorm",
1217
+ "knex",
1218
+ "sequelize",
1219
+ "stratum"
1220
+ ],
1221
+ main: "./dist/index.js",
1222
+ types: "./dist/index.d.ts",
1223
+ exports: {
1224
+ ".": {
1225
+ types: "./dist/index.d.ts",
1226
+ default: "./dist/index.js"
1227
+ }
1228
+ },
1229
+ files: [
1230
+ "dist",
1231
+ "README.md",
1232
+ "LICENSE"
1233
+ ],
1234
+ scripts: {
1235
+ build: "tsc",
1236
+ test: "vitest run",
1237
+ "test:watch": "vitest",
1238
+ "test:integration": "vitest run --config vitest.integration.config.ts",
1239
+ typecheck: "tsc --noEmit",
1240
+ lint: "eslint .",
1241
+ clean: "rm -rf dist"
1242
+ },
1243
+ dependencies: {
1244
+ "@stratum-hq/core": "^1.4.0",
1245
+ "@stratum-hq/sdk": "^1.2.0"
1246
+ },
1247
+ peerDependencies: {
1248
+ mysql2: "^3.0.0",
1249
+ knex: ">=3.0.0",
1250
+ typeorm: "^1.0.0",
1251
+ sequelize: "^6.0.0"
1252
+ },
1253
+ peerDependenciesMeta: {
1254
+ typeorm: {
1255
+ optional: true
1256
+ },
1257
+ knex: {
1258
+ optional: true
1259
+ },
1260
+ sequelize: {
1261
+ optional: true
1262
+ }
1263
+ },
1264
+ devDependencies: {
1265
+ knex: "^3.3.0",
1266
+ mysql2: "^3.24.4",
1267
+ typeorm: "^1.1.1",
1268
+ typescript: "^5.9.0",
1269
+ vitest: "^4.1.11"
1270
+ },
1271
+ publishConfig: {
1272
+ access: "public"
1273
+ },
1274
+ license: "MIT",
1275
+ author: "Christian Crank",
1276
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/mysql#readme",
1277
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1278
+ engines: {
1279
+ node: ">=20.0.0"
1280
+ },
1281
+ repository: {
1282
+ type: "git",
1283
+ url: "https://github.com/stratum-hq/Stratum.git",
1284
+ directory: "packages/mysql"
1285
+ }
1286
+ };
1287
+
1288
+ // ../nestjs/package.json
1289
+ var package_default6 = {
1290
+ name: "@stratum-hq/nestjs",
1291
+ version: "1.2.0",
1292
+ description: "Stratum NestJS integration \u2014 StratumGuard, @Tenant() decorator, and StratumModule",
1293
+ keywords: [
1294
+ "multi-tenancy",
1295
+ "multi-tenant",
1296
+ "saas",
1297
+ "tenant",
1298
+ "nestjs",
1299
+ "nest",
1300
+ "guard",
1301
+ "decorator",
1302
+ "middleware",
1303
+ "typescript",
1304
+ "stratum"
1305
+ ],
1306
+ type: "module",
1307
+ main: "./dist/index.js",
1308
+ types: "./dist/index.d.ts",
1309
+ exports: {
1310
+ ".": {
1311
+ types: "./dist/index.d.ts",
1312
+ default: "./dist/index.js"
1313
+ }
1314
+ },
1315
+ files: [
1316
+ "dist",
1317
+ "README.md",
1318
+ "LICENSE"
1319
+ ],
1320
+ scripts: {
1321
+ build: "tsc",
1322
+ test: "vitest run",
1323
+ typecheck: "tsc --noEmit",
1324
+ lint: "eslint .",
1325
+ clean: "rm -rf dist"
1326
+ },
1327
+ publishConfig: {
1328
+ access: "public",
1329
+ registry: "https://registry.npmjs.org/"
1330
+ },
1331
+ peerDependencies: {
1332
+ "@nestjs/common": ">=10.0.0",
1333
+ "@nestjs/core": ">=10.0.0",
1334
+ "@stratum-hq/core": "^1.0.0",
1335
+ "@stratum-hq/sdk": "^1.2.0",
1336
+ "reflect-metadata": ">=0.1.13"
1337
+ },
1338
+ devDependencies: {
1339
+ "@nestjs/common": "^12.1.2",
1340
+ "@nestjs/core": "^12.1.2",
1341
+ "@nestjs/testing": "^12.1.2",
1342
+ "@stratum-hq/core": "*",
1343
+ "@stratum-hq/sdk": "*",
1344
+ "reflect-metadata": "^0.2.2",
1345
+ typescript: "^5.4.0",
1346
+ vitest: "^4.1.11"
1347
+ },
1348
+ license: "MIT",
1349
+ author: "Christian Crank",
1350
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/nestjs#readme",
1351
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1352
+ engines: {
1353
+ node: ">=20.0.0"
1354
+ },
1355
+ repository: {
1356
+ type: "git",
1357
+ url: "https://github.com/stratum-hq/Stratum.git",
1358
+ directory: "packages/nestjs"
1359
+ }
1360
+ };
1361
+
1362
+ // src/stratum-versions.ts
1363
+ var STRATUM_RANGES = {
1364
+ "@stratum-hq/db-adapters": `^${package_default2.version}`,
1365
+ "@stratum-hq/hono": `^${package_default.version}`,
1366
+ "@stratum-hq/lib": `^${package_default3.version}`,
1367
+ "@stratum-hq/mongodb": `^${package_default4.version}`,
1368
+ "@stratum-hq/mysql": `^${package_default5.version}`,
1369
+ "@stratum-hq/nestjs": `^${package_default6.version}`
1370
+ };
1371
+
792
1372
  // src/generators/package-json.ts
793
1373
  function generatePresetPackageJson(projectName, preset) {
794
1374
  const deps = {
795
- "@stratum-hq/lib": "^0.2.0"
1375
+ "@stratum-hq/lib": STRATUM_RANGES["@stratum-hq/lib"]
796
1376
  };
797
1377
  const devDeps = {
798
1378
  typescript: "^5.3.0",
799
1379
  "@types/node": "^20.11.0"
800
1380
  };
801
- addDatabaseDeps(deps, preset);
1381
+ if (preset.framework === "nestjs") {
1382
+ devDeps["tsc-watch"] = "^7.2.0";
1383
+ } else if (preset.framework !== "nextjs") {
1384
+ devDeps["tsx"] = "^4.7.0";
1385
+ }
1386
+ addDatabaseDeps(deps, devDeps, preset);
802
1387
  addOrmDeps(deps, devDeps, preset);
803
1388
  addFrameworkDeps(deps, devDeps, preset);
804
1389
  addStratumDeps(deps, preset);
@@ -820,9 +1405,12 @@ function generatePresetPackageJson(projectName, preset) {
820
1405
  2
821
1406
  );
822
1407
  }
823
- function addDatabaseDeps(deps, preset) {
1408
+ function addDatabaseDeps(deps, devDeps, preset) {
824
1409
  switch (preset.database) {
825
1410
  case "postgres":
1411
+ if (preset.orm === "pg" || preset.orm === "prisma" || preset.orm === "drizzle") {
1412
+ devDeps["@types/pg"] = "^8.11.0";
1413
+ }
826
1414
  if (preset.orm !== "prisma" && preset.orm !== "drizzle" && preset.orm !== "sequelize") {
827
1415
  deps["pg"] = "^8.11.0";
828
1416
  }
@@ -912,29 +1500,29 @@ function addFrameworkDeps(deps, devDeps, preset) {
912
1500
  }
913
1501
  function addStratumDeps(deps, preset) {
914
1502
  if (preset.database === "postgres" && preset.orm !== "mongoose") {
915
- deps["@stratum-hq/db-adapters"] = "^0.2.0";
1503
+ deps["@stratum-hq/db-adapters"] = STRATUM_RANGES["@stratum-hq/db-adapters"];
916
1504
  }
917
1505
  if (preset.database === "mongodb") {
918
- deps["@stratum-hq/mongodb"] = "^0.2.0";
1506
+ deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
919
1507
  }
920
1508
  if (preset.database === "mysql") {
921
- deps["@stratum-hq/mysql"] = "^0.2.0";
1509
+ deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
922
1510
  }
923
1511
  if (preset.framework === "hono") {
924
- deps["@stratum-hq/hono"] = "^0.2.0";
1512
+ deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
925
1513
  }
926
1514
  if (preset.framework === "nestjs") {
927
- deps["@stratum-hq/nestjs"] = "^0.2.0";
1515
+ deps["@stratum-hq/nestjs"] = STRATUM_RANGES["@stratum-hq/nestjs"];
928
1516
  }
929
1517
  }
930
1518
  function getScripts(preset) {
931
1519
  if (preset.framework === "nextjs") {
932
1520
  return { dev: "next dev", build: "next build", start: "next start" };
933
1521
  }
934
- if (preset.framework === "nestjs") {
935
- return { dev: "node --watch src/main.ts", build: "tsc", start: "node dist/main.js" };
936
- }
937
- return { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" };
1522
+ const entry = preset.framework === "nestjs" ? "main" : "index";
1523
+ const emittedDir = rootSources(preset).length > 0 ? "dist/src" : "dist";
1524
+ const dev = preset.framework === "nestjs" ? `tsc-watch --onSuccess "node --env-file=.env ${emittedDir}/${entry}.js"` : `tsx watch --env-file=.env src/${entry}.ts`;
1525
+ return { dev, build: "tsc", start: `node ${emittedDir}/${entry}.js` };
938
1526
  }
939
1527
  function sortKeys(obj) {
940
1528
  const sorted = {};
@@ -946,7 +1534,6 @@ function sortKeys(obj) {
946
1534
 
947
1535
  // src/generators/readme.ts
948
1536
  function generatePresetReadme(projectName, preset) {
949
- const devCmd = getDevCommand(preset);
950
1537
  const dbStartCmd = getDbStartInfo(preset);
951
1538
  const dbSetupNote = getDbSetupNote(preset);
952
1539
  return `# ${projectName}
@@ -981,7 +1568,7 @@ ${dbSetupNote}
981
1568
  ### 4. Run the app
982
1569
 
983
1570
  \`\`\`bash
984
- ${devCmd}
1571
+ npm run dev
985
1572
  \`\`\`
986
1573
 
987
1574
  ## Multi-tenancy
@@ -993,15 +1580,6 @@ ${getStrategyDescription(preset.strategy)}
993
1580
  See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
994
1581
  `;
995
1582
  }
996
- function getDevCommand(preset) {
997
- if (preset.framework === "nextjs")
998
- return "npm run dev";
999
- if (preset.framework === "nestjs")
1000
- return "node --env-file=.env src/main.ts";
1001
- if (preset.framework === "none")
1002
- return "node --env-file=.env src/index.ts";
1003
- return "node --env-file=.env src/index.ts";
1004
- }
1005
1583
  function getDbStartInfo(preset) {
1006
1584
  switch (preset.database) {
1007
1585
  case "postgres":
@@ -1038,7 +1616,7 @@ function getStrategyDescription(strategy) {
1038
1616
  switch (strategy) {
1039
1617
  case "rls":
1040
1618
  return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
1041
- - Each query sets \`app.current_tenant\` and RLS enforces isolation
1619
+ - Each query sets \`app.current_tenant_id\` and RLS enforces isolation
1042
1620
  - All tenants share one database and schema`;
1043
1621
  case "schema":
1044
1622
  return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
@@ -1074,9 +1652,14 @@ function generatePresetEnv(projectName, preset) {
1074
1652
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
1075
1653
  const jwtSecret = crypto.randomBytes(32).toString("base64url");
1076
1654
  let dbUrl;
1655
+ let adminUrlLine = "";
1077
1656
  switch (preset.database) {
1078
1657
  case "postgres":
1079
- dbUrl = `postgres://${dbName}:dev_password@localhost:5432/${dbName}`;
1658
+ dbUrl = `postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}`;
1659
+ adminUrlLine = `
1660
+ # Superuser: bootstrap and migrations only. It bypasses row-level security.
1661
+ DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1662
+ `;
1080
1663
  break;
1081
1664
  case "mongodb":
1082
1665
  dbUrl = `mongodb://${dbName}:dev_password@localhost:27017/${dbName}?authSource=admin`;
@@ -1091,7 +1674,7 @@ function generatePresetEnv(projectName, preset) {
1091
1674
 
1092
1675
  # Database
1093
1676
  ${urlKey}=${dbUrl}
1094
-
1677
+ ${adminUrlLine}
1095
1678
  # Authentication
1096
1679
  JWT_SECRET=${jwtSecret}
1097
1680
 
@@ -1137,27 +1720,7 @@ Creating ${projectName} with preset ${presetLabel}...
1137
1720
  );
1138
1721
  writeFile(
1139
1722
  path.join(targetDir, "tsconfig.json"),
1140
- JSON.stringify(
1141
- {
1142
- compilerOptions: {
1143
- target: "ESNext",
1144
- module: "NodeNext",
1145
- moduleResolution: "NodeNext",
1146
- strict: true,
1147
- outDir: "dist",
1148
- rootDir: "src",
1149
- declaration: true,
1150
- skipLibCheck: true,
1151
- esModuleInterop: true,
1152
- ...preset.framework === "nestjs" ? { experimentalDecorators: true, emitDecoratorMetadata: true } : {},
1153
- ...preset.framework === "nextjs" ? { jsx: "preserve", plugins: [{ name: "next" }] } : {}
1154
- },
1155
- include: ["src"],
1156
- exclude: ["node_modules", "dist"]
1157
- },
1158
- null,
1159
- 2
1160
- )
1723
+ generateTsconfig(preset.framework, rootSources(preset))
1161
1724
  );
1162
1725
  if (!skipInstall) {
1163
1726
  console.log("\nInstalling dependencies...\n");
@@ -1260,7 +1823,7 @@ function generatePackageJson(projectName, template) {
1260
1823
  }
1261
1824
  };
1262
1825
  const deps = {
1263
- "@stratum-hq/lib": "^0.2.0",
1826
+ "@stratum-hq/lib": STRATUM_RANGES["@stratum-hq/lib"],
1264
1827
  pg: "^8.11.0",
1265
1828
  ...frameworkDeps[template]
1266
1829
  };
@@ -1270,11 +1833,15 @@ function generatePackageJson(projectName, template) {
1270
1833
  version: "0.1.0",
1271
1834
  private: true,
1272
1835
  type: "module",
1273
- scripts: template === "nextjs" ? { dev: "next dev", build: "next build", start: "next start" } : { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" },
1836
+ scripts: template === "nextjs" ? { dev: "next dev", build: "next build", start: "next start" } : (
1837
+ // Node 20 cannot run a .ts file, so dev runs the source through tsx.
1838
+ { dev: "tsx watch --env-file=.env src/index.ts", build: "tsc", start: "node dist/index.js" }
1839
+ ),
1274
1840
  dependencies: deps,
1275
1841
  devDependencies: {
1276
1842
  typescript: "^5.3.0",
1277
- "@types/node": "^20.11.0"
1843
+ "@types/node": "^20.11.0",
1844
+ ...template === "nextjs" ? {} : { tsx: "^4.7.0", "@types/pg": "^8.11.0" }
1278
1845
  },
1279
1846
  engines: {
1280
1847
  node: ">=20.0.0"
@@ -1322,7 +1889,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
1322
1889
  -- The ltree extension enables hierarchical tenant trees
1323
1890
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
1324
1891
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
1325
- `;
1892
+ ${postgresAppRoleSql(dbName)}`;
1326
1893
  }
1327
1894
  function generateEnv(projectName) {
1328
1895
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
@@ -1330,8 +1897,12 @@ function generateEnv(projectName) {
1330
1897
  return `# Environment variables for ${projectName}
1331
1898
  # Copy to .env and fill in values
1332
1899
 
1333
- # Database
1334
- DATABASE_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1900
+ # Database. The app connects as the non-superuser role created in init.sql,
1901
+ # so row-level security applies to it.
1902
+ DATABASE_URL=postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}
1903
+
1904
+ # Superuser: bootstrap and migrations only. It bypasses row-level security.
1905
+ DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1335
1906
 
1336
1907
  # Authentication
1337
1908
  JWT_SECRET=${jwtSecret}
@@ -1419,7 +1990,7 @@ export default function Home() {
1419
1990
  <p>Multi-tenant app powered by Stratum.</p>
1420
1991
  <ul>
1421
1992
  <li>Configure tenants via the Stratum control plane</li>
1422
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
1993
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
1423
1994
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
1424
1995
  </ul>
1425
1996
  </main>
@@ -1428,18 +1999,19 @@ export default function Home() {
1428
1999
  `;
1429
2000
  }
1430
2001
  function generateNextjsMiddleware2() {
1431
- return `// middleware.ts \u2014 tenant resolution via subdomain or header
2002
+ return `// middleware.ts \u2014 tenant resolution via subdomain
1432
2003
  import { NextRequest, NextResponse } from "next/server";
1433
2004
 
1434
2005
  export function middleware(request: NextRequest) {
2006
+ // The tenant comes from the subdomain the request was routed to. Any
2007
+ // x-tenant-id the client sent is removed first, so server code that reads
2008
+ // x-tenant-id only ever sees the value set here. Once you add
2009
+ // authentication, check that the signed-in user belongs to this tenant.
1435
2010
  const hostname = request.headers.get("host") || "";
1436
- const subdomain = hostname.split(".")[0];
1437
- const headerTenantId = request.headers.get("x-tenant-id");
1438
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
1439
-
1440
- const tenantId = headerTenantId || pathTenantId || subdomain;
2011
+ const tenantId = hostname.split(".")[0];
1441
2012
 
1442
2013
  const requestHeaders = new Headers(request.headers);
2014
+ requestHeaders.delete("x-tenant-id");
1443
2015
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
1444
2016
  requestHeaders.set("x-tenant-id", tenantId);
1445
2017
  }
@@ -1453,7 +2025,6 @@ export const config = {
1453
2025
  `;
1454
2026
  }
1455
2027
  function generateReadme(projectName, template) {
1456
- const devCmd = template === "nextjs" ? "npm run dev" : "node --env-file=.env src/index.ts";
1457
2028
  return `# ${projectName}
1458
2029
 
1459
2030
  A multi-tenant application built with [Stratum](https://github.com/stratum-hq/Stratum).
@@ -1482,7 +2053,7 @@ npm install
1482
2053
  ### 4. Run the app
1483
2054
 
1484
2055
  \`\`\`bash
1485
- ${devCmd}
2056
+ npm run dev
1486
2057
  \`\`\`
1487
2058
 
1488
2059
  ## Project structure
@@ -1493,14 +2064,14 @@ ${projectName}/
1493
2064
  \u251C\u2500\u2500 docker-compose.yml
1494
2065
  \u251C\u2500\u2500 init.sql # DB extensions (uuid-ossp, ltree)
1495
2066
  \u251C\u2500\u2500 .env.example
1496
- \u2514\u2500\u2500 package.json
2067
+ ${template === "nextjs" ? "" : "\u251C\u2500\u2500 tsconfig.json\n"}\u2514\u2500\u2500 package.json
1497
2068
  \`\`\`
1498
2069
 
1499
2070
  ## Multi-tenancy
1500
2071
 
1501
2072
  This project uses Stratum for hierarchical multi-tenancy:
1502
2073
 
1503
- - **Tenant resolution** \u2014 via JWT claim, subdomain, or \`x-tenant-id\` header
2074
+ - **Tenant resolution** \u2014 via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
1504
2075
  - **Config inheritance** \u2014 settings flow down the tenant tree with override support
1505
2076
  - **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
1506
2077
 
@@ -1520,8 +2091,10 @@ Creating ${projectName} with ${template} template...
1520
2091
  writeFile2(path2.join(targetDir, ".env.example"), generateEnv(projectName));
1521
2092
  if (template === "express") {
1522
2093
  writeFile2(path2.join(targetDir, "src", "index.ts"), generateExpressServer(projectName));
2094
+ writeFile2(path2.join(targetDir, "tsconfig.json"), generateTsconfig(template));
1523
2095
  } else if (template === "fastify") {
1524
2096
  writeFile2(path2.join(targetDir, "src", "index.ts"), generateFastifyServer(projectName));
2097
+ writeFile2(path2.join(targetDir, "tsconfig.json"), generateTsconfig(template));
1525
2098
  } else if (template === "nextjs") {
1526
2099
  writeFile2(path2.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
1527
2100
  writeFile2(path2.join(targetDir, "middleware.ts"), generateNextjsMiddleware2());
@@ -1599,26 +2172,9 @@ Success! Created ${projectName} at ${targetDir}
1599
2172
  if (skipInstall) {
1600
2173
  console.log(" npm install");
1601
2174
  }
1602
- if (preset) {
1603
- const parsed = parsePresetString(preset);
1604
- if (parsed.framework === "nextjs") {
1605
- console.log(" npm run dev");
1606
- } else if (parsed.framework === "nestjs") {
1607
- console.log(" node --env-file=.env src/main.ts");
1608
- } else {
1609
- console.log(" node --env-file=.env src/index.ts");
1610
- }
1611
- } else if (template === "nextjs") {
1612
- console.log(" npm run dev");
1613
- } else {
1614
- console.log(" node --env-file=.env src/index.ts");
1615
- }
2175
+ console.log(" npm run dev");
1616
2176
  console.log("");
1617
2177
  }
1618
- var __filename = fileURLToPath(import.meta.url);
1619
- if (process.argv[1] === __filename) {
1620
- main(process.argv.slice(2));
1621
- }
1622
2178
  export {
1623
2179
  createProject,
1624
2180
  main,