@stratum-hq/create 0.3.1 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @stratum-hq/create
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1669fd7: Harden defaults in generated projects, CLI checks, React hooks and test helpers (GHSA-rrrp-gww6-44gr). Behavior changes: `StratumProvider`'s `apiKey` is optional and generated React code uses a server-side proxy instead; `TenantThemeProvider` ignores `customCss` that is not plain declarations; `assertConfigInheritance` now takes a Stratum instance instead of a pg pool.
8
+
9
+ ### Patch Changes
10
+
11
+ - 9de2ddb: Remove unused code from the generator. No change in generated output.
12
+ - dca0826: Harden tenant isolation in the schema-per-tenant and database-per-tenant strategies and the Prisma and Drizzle adapters (GHSA-jhhc-cm2c-jh27). Behavior change: the schema-per-tenant `search_path` is now the tenant schema alone, without `public`; queries that call extension functions or types from another schema must schema-qualify them or opt that schema in with the new `extraSearchPath` option.
13
+
3
14
  ## 0.3.1
4
15
 
5
16
  ### Patch Changes
package/dist/index.js CHANGED
@@ -37,19 +37,14 @@ var ALL_STRATEGIES = ["rls", "schema", "database", "collection", "table-prefix"]
37
37
  var ALL_ORMS = ["prisma", "drizzle", "sequelize", "knex", "mongoose", "pg"];
38
38
  function isValidPreset(preset) {
39
39
  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;
40
+ if (!config) return false;
41
+ if (!config.strategies.includes(preset.strategy)) return false;
42
+ if (!config.orms.includes(preset.orm)) return false;
43
+ if (!config.frameworks.includes(preset.framework)) return false;
48
44
  return true;
49
45
  }
50
46
  function parsePresetString(s) {
51
- if (!s || typeof s !== "string")
52
- return null;
47
+ if (!s || typeof s !== "string") return null;
53
48
  const parts = s.toLowerCase().split("-");
54
49
  let database;
55
50
  let strategy;
@@ -65,14 +60,10 @@ function parsePresetString(s) {
65
60
  } else {
66
61
  return null;
67
62
  }
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;
63
+ if (!ALL_DATABASES.includes(database)) return null;
64
+ if (!ALL_STRATEGIES.includes(strategy)) return null;
65
+ if (!ALL_ORMS.includes(orm)) return null;
66
+ if (!ALL_FRAMEWORKS.includes(framework)) return null;
76
67
  return {
77
68
  database,
78
69
  strategy,
@@ -182,6 +173,36 @@ volumes:
182
173
  }
183
174
 
184
175
  // src/generators/init-sql.ts
176
+ function postgresAppRole(dbName) {
177
+ return `${dbName}_app`;
178
+ }
179
+ var POSTGRES_APP_PASSWORD = "dev_app_password";
180
+ function postgresAppRoleSql(dbName, strategy) {
181
+ const role = postgresAppRole(dbName);
182
+ let strategyGrant = "";
183
+ if (strategy === "schema") {
184
+ strategyGrant = `
185
+ -- schema-per-tenant: the app creates one schema per tenant
186
+ GRANT CREATE ON DATABASE ${dbName} TO ${role};
187
+ `;
188
+ } else if (strategy === "database") {
189
+ strategyGrant = `
190
+ -- database-per-tenant: the app creates one database per tenant
191
+ ALTER ROLE ${role} CREATEDB;
192
+ `;
193
+ }
194
+ return `
195
+ -- Application role. The app connects as ${role} (DATABASE_URL), never as the
196
+ -- bootstrap superuser (DATABASE_ADMIN_URL): a superuser or BYPASSRLS role
197
+ -- ignores every row-level security policy, FORCE included. Use the superuser
198
+ -- only for bootstrap and migrations.
199
+ CREATE ROLE ${role} WITH LOGIN PASSWORD '${POSTGRES_APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS NOCREATEROLE;
200
+ GRANT CONNECT ON DATABASE ${dbName} TO ${role};
201
+ GRANT USAGE, CREATE ON SCHEMA public TO ${role};
202
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role};
203
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role};
204
+ ${strategyGrant}`;
205
+ }
185
206
  function generatePresetInitSql(projectName, preset) {
186
207
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
187
208
  switch (preset.database) {
@@ -201,10 +222,13 @@ function generatePostgresInit(projectName, dbName, strategy) {
201
222
  -- Add RLS policies to each tenant-scoped table:
202
223
  --
203
224
  -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
225
+ -- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
204
226
  -- CREATE POLICY tenant_isolation ON your_table
205
- -- USING (tenant_id = current_setting('app.current_tenant')::uuid);
227
+ -- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
206
228
  --
207
- -- The Stratum db-adapters package sets app.current_tenant automatically.
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
  `
@@ -561,11 +585,13 @@ const port = Number(process.env.PORT) || 3000;
561
585
 
562
586
  app.use(express.json());
563
587
 
564
- // Tenant extraction middleware
588
+ // Tenant extraction middleware. The tenant comes from the subdomain the
589
+ // request was routed to. Do not take it from a client-supplied header such as
590
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
591
+ // authentication, check that the signed-in user belongs to this tenant, or
592
+ // derive the tenant from the verified session or JWT instead.
565
593
  app.use((req, _res, next) => {
566
- const tenantId =
567
- req.headers["x-tenant-id"] as string ||
568
- req.hostname.split(".")[0];
594
+ const tenantId = req.hostname.split(".")[0];
569
595
  (req as any).tenantId = tenantId;
570
596
  next();
571
597
  });
@@ -595,12 +621,14 @@ function generateFastifyMiddleware(projectName) {
595
621
  const fastify = Fastify({ logger: true });
596
622
  const port = Number(process.env.PORT) || 3000;
597
623
 
598
- // Tenant extraction plugin
624
+ // Tenant extraction hook. The tenant comes from the subdomain the
625
+ // request was routed to. Do not take it from a client-supplied header such as
626
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
627
+ // authentication, check that the signed-in user belongs to this tenant, or
628
+ // derive the tenant from the verified session or JWT instead.
599
629
  fastify.decorateRequest("tenantId", "");
600
630
  fastify.addHook("onRequest", async (request) => {
601
- const tenantId =
602
- (request.headers["x-tenant-id"] as string) ||
603
- (request.hostname?.split(".")[0] ?? "");
631
+ const tenantId = request.hostname?.split(".")[0] ?? "";
604
632
  (request as any).tenantId = tenantId;
605
633
  });
606
634
 
@@ -631,14 +659,15 @@ function generateNextjsMiddleware(projectName) {
631
659
  import { NextRequest, NextResponse } from "next/server";
632
660
 
633
661
  export function middleware(request: NextRequest) {
662
+ // The tenant comes from the subdomain the request was routed to. Any
663
+ // x-tenant-id the client sent is removed first, so server code that reads
664
+ // x-tenant-id only ever sees the value set here. Once you add
665
+ // authentication, check that the signed-in user belongs to this tenant.
634
666
  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;
667
+ const tenantId = hostname.split(".")[0];
640
668
 
641
669
  const requestHeaders = new Headers(request.headers);
670
+ requestHeaders.delete("x-tenant-id");
642
671
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
643
672
  requestHeaders.set("x-tenant-id", tenantId);
644
673
  }
@@ -661,7 +690,7 @@ export default function Home() {
661
690
  <p>Multi-tenant app powered by Stratum.</p>
662
691
  <ul>
663
692
  <li>Configure tenants via the Stratum control plane</li>
664
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
693
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
665
694
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
666
695
  </ul>
667
696
  </main>
@@ -680,11 +709,13 @@ import { serve } from "@hono/node-server";
680
709
 
681
710
  const app = new Hono();
682
711
 
683
- // Tenant extraction middleware
712
+ // Tenant extraction middleware. The tenant comes from the subdomain the
713
+ // request was routed to. Do not take it from a client-supplied header such as
714
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
715
+ // authentication, check that the signed-in user belongs to this tenant, or
716
+ // derive the tenant from the verified session or JWT instead.
684
717
  app.use("*", async (c, next) => {
685
- const tenantId =
686
- c.req.header("x-tenant-id") ||
687
- new URL(c.req.url).hostname.split(".")[0];
718
+ const tenantId = new URL(c.req.url).hostname.split(".")[0];
688
719
  c.set("tenantId", tenantId);
689
720
  await next();
690
721
  });
@@ -763,9 +794,11 @@ export class AppController {
763
794
  export class TenantGuard implements CanActivate {
764
795
  canActivate(context: ExecutionContext): boolean {
765
796
  const request = context.switchToHttp().getRequest();
766
- const tenantId =
767
- request.headers["x-tenant-id"] ||
768
- request.hostname?.split(".")[0];
797
+ // The tenant comes from the subdomain the request was routed to. Do not
798
+ // take it from a client-supplied header such as x-tenant-id: any caller
799
+ // can set one and pick another tenant. Once you add authentication, check
800
+ // that the signed-in user belongs to this tenant here.
801
+ const tenantId = request.hostname?.split(".")[0];
769
802
  request.tenantId = tenantId || null;
770
803
  return true;
771
804
  }
@@ -994,12 +1027,9 @@ See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference
994
1027
  `;
995
1028
  }
996
1029
  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";
1030
+ if (preset.framework === "nextjs") return "npm run dev";
1031
+ if (preset.framework === "nestjs") return "node --env-file=.env src/main.ts";
1032
+ if (preset.framework === "none") return "node --env-file=.env src/index.ts";
1003
1033
  return "node --env-file=.env src/index.ts";
1004
1034
  }
1005
1035
  function getDbStartInfo(preset) {
@@ -1074,9 +1104,14 @@ function generatePresetEnv(projectName, preset) {
1074
1104
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
1075
1105
  const jwtSecret = crypto.randomBytes(32).toString("base64url");
1076
1106
  let dbUrl;
1107
+ let adminUrlLine = "";
1077
1108
  switch (preset.database) {
1078
1109
  case "postgres":
1079
- dbUrl = `postgres://${dbName}:dev_password@localhost:5432/${dbName}`;
1110
+ dbUrl = `postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}`;
1111
+ adminUrlLine = `
1112
+ # Superuser: bootstrap and migrations only. It bypasses row-level security.
1113
+ DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1114
+ `;
1080
1115
  break;
1081
1116
  case "mongodb":
1082
1117
  dbUrl = `mongodb://${dbName}:dev_password@localhost:27017/${dbName}?authSource=admin`;
@@ -1091,7 +1126,7 @@ function generatePresetEnv(projectName, preset) {
1091
1126
 
1092
1127
  # Database
1093
1128
  ${urlKey}=${dbUrl}
1094
-
1129
+ ${adminUrlLine}
1095
1130
  # Authentication
1096
1131
  JWT_SECRET=${jwtSecret}
1097
1132
 
@@ -1322,7 +1357,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
1322
1357
  -- The ltree extension enables hierarchical tenant trees
1323
1358
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
1324
1359
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
1325
- `;
1360
+ ${postgresAppRoleSql(dbName)}`;
1326
1361
  }
1327
1362
  function generateEnv(projectName) {
1328
1363
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
@@ -1330,8 +1365,12 @@ function generateEnv(projectName) {
1330
1365
  return `# Environment variables for ${projectName}
1331
1366
  # Copy to .env and fill in values
1332
1367
 
1333
- # Database
1334
- DATABASE_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1368
+ # Database. The app connects as the non-superuser role created in init.sql,
1369
+ # so row-level security applies to it.
1370
+ DATABASE_URL=postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}
1371
+
1372
+ # Superuser: bootstrap and migrations only. It bypasses row-level security.
1373
+ DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1335
1374
 
1336
1375
  # Authentication
1337
1376
  JWT_SECRET=${jwtSecret}
@@ -1419,7 +1458,7 @@ export default function Home() {
1419
1458
  <p>Multi-tenant app powered by Stratum.</p>
1420
1459
  <ul>
1421
1460
  <li>Configure tenants via the Stratum control plane</li>
1422
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
1461
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
1423
1462
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
1424
1463
  </ul>
1425
1464
  </main>
@@ -1428,18 +1467,19 @@ export default function Home() {
1428
1467
  `;
1429
1468
  }
1430
1469
  function generateNextjsMiddleware2() {
1431
- return `// middleware.ts \u2014 tenant resolution via subdomain or header
1470
+ return `// middleware.ts \u2014 tenant resolution via subdomain
1432
1471
  import { NextRequest, NextResponse } from "next/server";
1433
1472
 
1434
1473
  export function middleware(request: NextRequest) {
1474
+ // The tenant comes from the subdomain the request was routed to. Any
1475
+ // x-tenant-id the client sent is removed first, so server code that reads
1476
+ // x-tenant-id only ever sees the value set here. Once you add
1477
+ // authentication, check that the signed-in user belongs to this tenant.
1435
1478
  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;
1479
+ const tenantId = hostname.split(".")[0];
1441
1480
 
1442
1481
  const requestHeaders = new Headers(request.headers);
1482
+ requestHeaders.delete("x-tenant-id");
1443
1483
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
1444
1484
  requestHeaders.set("x-tenant-id", tenantId);
1445
1485
  }
@@ -1500,7 +1540,7 @@ ${projectName}/
1500
1540
 
1501
1541
  This project uses Stratum for hierarchical multi-tenancy:
1502
1542
 
1503
- - **Tenant resolution** \u2014 via JWT claim, subdomain, or \`x-tenant-id\` header
1543
+ - **Tenant resolution** \u2014 via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
1504
1544
  - **Config inheritance** \u2014 settings flow down the tenant tree with override support
1505
1545
  - **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
1506
1546
 
package/dist/matrix.js CHANGED
@@ -28,19 +28,14 @@ var ALL_STRATEGIES = ["rls", "schema", "database", "collection", "table-prefix"]
28
28
  var ALL_ORMS = ["prisma", "drizzle", "sequelize", "knex", "mongoose", "pg"];
29
29
  function isValidPreset(preset) {
30
30
  const config = VALID_COMBINATIONS[preset.database];
31
- if (!config)
32
- return false;
33
- if (!config.strategies.includes(preset.strategy))
34
- return false;
35
- if (!config.orms.includes(preset.orm))
36
- return false;
37
- if (!config.frameworks.includes(preset.framework))
38
- return false;
31
+ if (!config) return false;
32
+ if (!config.strategies.includes(preset.strategy)) return false;
33
+ if (!config.orms.includes(preset.orm)) return false;
34
+ if (!config.frameworks.includes(preset.framework)) return false;
39
35
  return true;
40
36
  }
41
37
  function parsePresetString(s) {
42
- if (!s || typeof s !== "string")
43
- return null;
38
+ if (!s || typeof s !== "string") return null;
44
39
  const parts = s.toLowerCase().split("-");
45
40
  let database;
46
41
  let strategy;
@@ -56,14 +51,10 @@ function parsePresetString(s) {
56
51
  } else {
57
52
  return null;
58
53
  }
59
- if (!ALL_DATABASES.includes(database))
60
- return null;
61
- if (!ALL_STRATEGIES.includes(strategy))
62
- return null;
63
- if (!ALL_ORMS.includes(orm))
64
- return null;
65
- if (!ALL_FRAMEWORKS.includes(framework))
66
- return null;
54
+ if (!ALL_DATABASES.includes(database)) return null;
55
+ if (!ALL_STRATEGIES.includes(strategy)) return null;
56
+ if (!ALL_ORMS.includes(orm)) return null;
57
+ if (!ALL_FRAMEWORKS.includes(framework)) return null;
67
58
  return {
68
59
  database,
69
60
  strategy,
@@ -87,16 +78,11 @@ function getValidOptions(partial) {
87
78
  const frameworkSet = /* @__PURE__ */ new Set();
88
79
  for (const db of databases) {
89
80
  const config = VALID_COMBINATIONS[db];
90
- if (partial.strategy && !config.strategies.includes(partial.strategy))
91
- continue;
92
- if (partial.orm && !config.orms.includes(partial.orm))
93
- continue;
94
- for (const s of config.strategies)
95
- strategySet.add(s);
96
- for (const o of config.orms)
97
- ormSet.add(o);
98
- for (const f of config.frameworks)
99
- frameworkSet.add(f);
81
+ if (partial.strategy && !config.strategies.includes(partial.strategy)) continue;
82
+ if (partial.orm && !config.orms.includes(partial.orm)) continue;
83
+ for (const s of config.strategies) strategySet.add(s);
84
+ for (const o of config.orms) ormSet.add(o);
85
+ for (const f of config.frameworks) frameworkSet.add(f);
100
86
  }
101
87
  strategies = [...strategySet];
102
88
  orms = [...ormSet];
@@ -113,10 +99,8 @@ function getValidOptions(partial) {
113
99
  if (partial.strategy || partial.orm) {
114
100
  databases = databases.filter((db) => {
115
101
  const config = VALID_COMBINATIONS[db];
116
- if (partial.strategy && !config.strategies.includes(partial.strategy))
117
- return false;
118
- if (partial.orm && !config.orms.includes(partial.orm))
119
- return false;
102
+ if (partial.strategy && !config.strategies.includes(partial.strategy)) return false;
103
+ if (partial.orm && !config.orms.includes(partial.orm)) return false;
120
104
  return true;
121
105
  });
122
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratum-hq/create",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Create a new Stratum multi-tenancy project",
5
5
  "keywords": [
6
6
  "multi-tenancy",
@@ -30,8 +30,8 @@
30
30
  "test": "vitest run"
31
31
  },
32
32
  "devDependencies": {
33
- "esbuild": "^0.20.0",
34
- "vitest": "^1.6.0",
33
+ "esbuild": "^0.28.2",
34
+ "vitest": "^4.1.11",
35
35
  "typescript": "^5.3.0"
36
36
  },
37
37
  "publishConfig": {
@@ -24,9 +24,6 @@ export function generateDbSetup(preset: StackPreset): DbSetupFile[] {
24
24
 
25
25
  function generatePrismaSetup(preset: StackPreset): DbSetupFile[] {
26
26
  const provider = preset.database === "mysql" ? "mysql" : "postgresql";
27
- const envUrl = preset.database === "mysql"
28
- ? "mysql://user:password@localhost:3306/dbname"
29
- : "postgres://user:password@localhost:5432/dbname";
30
27
 
31
28
  return [
32
29
  {
@@ -121,9 +118,10 @@ export function createTenantDb(getTenantId: () => string) {
121
118
  return drizzleWithTenant(db, getTenantId, pool);
122
119
  }
123
120
 
124
- // Usage:
121
+ // Usage: run tenant queries inside transaction() so the tenant context and
122
+ // the query share one connection.
125
123
  // const tenantDb = createTenantDb(() => currentTenantId);
126
- // const rows = await tenantDb.select().from(orders);
124
+ // const rows = await tenantDb.transaction((tx) => tx.select().from(orders));
127
125
 
128
126
  export { pool };
129
127
  `,
@@ -1,5 +1,46 @@
1
1
  import type { StackPreset } from "../matrix.js";
2
2
 
3
+ /** Name of the non-superuser role a generated app connects as. */
4
+ export function postgresAppRole(dbName: string): string {
5
+ return `${dbName}_app`;
6
+ }
7
+
8
+ /** Local development password for the generated application role. */
9
+ export const POSTGRES_APP_PASSWORD = "dev_app_password";
10
+
11
+ /**
12
+ * SQL that creates the role the application connects as. POSTGRES_USER is a
13
+ * superuser, and a superuser (or any BYPASSRLS role) ignores every row-level
14
+ * security policy, FORCE included. So the app gets its own role, and the
15
+ * superuser is kept for bootstrap and migrations.
16
+ */
17
+ export function postgresAppRoleSql(dbName: string, strategy?: string): string {
18
+ const role = postgresAppRole(dbName);
19
+ let strategyGrant = "";
20
+ if (strategy === "schema") {
21
+ strategyGrant = `
22
+ -- schema-per-tenant: the app creates one schema per tenant
23
+ GRANT CREATE ON DATABASE ${dbName} TO ${role};
24
+ `;
25
+ } else if (strategy === "database") {
26
+ strategyGrant = `
27
+ -- database-per-tenant: the app creates one database per tenant
28
+ ALTER ROLE ${role} CREATEDB;
29
+ `;
30
+ }
31
+ return `
32
+ -- Application role. The app connects as ${role} (DATABASE_URL), never as the
33
+ -- bootstrap superuser (DATABASE_ADMIN_URL): a superuser or BYPASSRLS role
34
+ -- ignores every row-level security policy, FORCE included. Use the superuser
35
+ -- only for bootstrap and migrations.
36
+ CREATE ROLE ${role} WITH LOGIN PASSWORD '${POSTGRES_APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS NOCREATEROLE;
37
+ GRANT CONNECT ON DATABASE ${dbName} TO ${role};
38
+ GRANT USAGE, CREATE ON SCHEMA public TO ${role};
39
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role};
40
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role};
41
+ ${strategyGrant}`;
42
+ }
43
+
3
44
  export function generatePresetInitSql(projectName: string, preset: StackPreset): string | null {
4
45
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
5
46
 
@@ -22,10 +63,13 @@ function generatePostgresInit(projectName: string, dbName: string, strategy: str
22
63
  -- Add RLS policies to each tenant-scoped table:
23
64
  --
24
65
  -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
66
+ -- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
25
67
  -- CREATE POLICY tenant_isolation ON your_table
26
- -- USING (tenant_id = current_setting('app.current_tenant')::uuid);
68
+ -- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
27
69
  --
28
- -- The Stratum db-adapters package sets app.current_tenant automatically.
70
+ -- FORCE makes the policy apply to the table owner too; without it, a table
71
+ -- created by the application role is not isolated for that role.
72
+ -- The Stratum db-adapters package sets app.current_tenant_id automatically.
29
73
  `;
30
74
  }
31
75
 
@@ -37,7 +81,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
37
81
  -- The ltree extension enables hierarchical tenant trees
38
82
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
39
83
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
40
- ${rlsBlock}`;
84
+ ${postgresAppRoleSql(dbName, strategy)}${rlsBlock}`;
41
85
  }
42
86
 
43
87
  function generateMysqlInit(projectName: string, dbName: string): string {
@@ -33,11 +33,13 @@ const port = Number(process.env.PORT) || 3000;
33
33
 
34
34
  app.use(express.json());
35
35
 
36
- // Tenant extraction middleware
36
+ // Tenant extraction middleware. The tenant comes from the subdomain the
37
+ // request was routed to. Do not take it from a client-supplied header such as
38
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
39
+ // authentication, check that the signed-in user belongs to this tenant, or
40
+ // derive the tenant from the verified session or JWT instead.
37
41
  app.use((req, _res, next) => {
38
- const tenantId =
39
- req.headers["x-tenant-id"] as string ||
40
- req.hostname.split(".")[0];
42
+ const tenantId = req.hostname.split(".")[0];
41
43
  (req as any).tenantId = tenantId;
42
44
  next();
43
45
  });
@@ -68,12 +70,14 @@ function generateFastifyMiddleware(projectName: string): MiddlewareFile[] {
68
70
  const fastify = Fastify({ logger: true });
69
71
  const port = Number(process.env.PORT) || 3000;
70
72
 
71
- // Tenant extraction plugin
73
+ // Tenant extraction hook. The tenant comes from the subdomain the
74
+ // request was routed to. Do not take it from a client-supplied header such as
75
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
76
+ // authentication, check that the signed-in user belongs to this tenant, or
77
+ // derive the tenant from the verified session or JWT instead.
72
78
  fastify.decorateRequest("tenantId", "");
73
79
  fastify.addHook("onRequest", async (request) => {
74
- const tenantId =
75
- (request.headers["x-tenant-id"] as string) ||
76
- (request.hostname?.split(".")[0] ?? "");
80
+ const tenantId = request.hostname?.split(".")[0] ?? "";
77
81
  (request as any).tenantId = tenantId;
78
82
  });
79
83
 
@@ -105,14 +109,15 @@ function generateNextjsMiddleware(projectName: string): MiddlewareFile[] {
105
109
  import { NextRequest, NextResponse } from "next/server";
106
110
 
107
111
  export function middleware(request: NextRequest) {
112
+ // The tenant comes from the subdomain the request was routed to. Any
113
+ // x-tenant-id the client sent is removed first, so server code that reads
114
+ // x-tenant-id only ever sees the value set here. Once you add
115
+ // authentication, check that the signed-in user belongs to this tenant.
108
116
  const hostname = request.headers.get("host") || "";
109
- const subdomain = hostname.split(".")[0];
110
- const headerTenantId = request.headers.get("x-tenant-id");
111
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
112
-
113
- const tenantId = headerTenantId || pathTenantId || subdomain;
117
+ const tenantId = hostname.split(".")[0];
114
118
 
115
119
  const requestHeaders = new Headers(request.headers);
120
+ requestHeaders.delete("x-tenant-id");
116
121
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
117
122
  requestHeaders.set("x-tenant-id", tenantId);
118
123
  }
@@ -135,7 +140,7 @@ export default function Home() {
135
140
  <p>Multi-tenant app powered by Stratum.</p>
136
141
  <ul>
137
142
  <li>Configure tenants via the Stratum control plane</li>
138
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
143
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
139
144
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
140
145
  </ul>
141
146
  </main>
@@ -155,11 +160,13 @@ import { serve } from "@hono/node-server";
155
160
 
156
161
  const app = new Hono();
157
162
 
158
- // Tenant extraction middleware
163
+ // Tenant extraction middleware. The tenant comes from the subdomain the
164
+ // request was routed to. Do not take it from a client-supplied header such as
165
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
166
+ // authentication, check that the signed-in user belongs to this tenant, or
167
+ // derive the tenant from the verified session or JWT instead.
159
168
  app.use("*", async (c, next) => {
160
- const tenantId =
161
- c.req.header("x-tenant-id") ||
162
- new URL(c.req.url).hostname.split(".")[0];
169
+ const tenantId = new URL(c.req.url).hostname.split(".")[0];
163
170
  c.set("tenantId", tenantId);
164
171
  await next();
165
172
  });
@@ -239,9 +246,11 @@ export class AppController {
239
246
  export class TenantGuard implements CanActivate {
240
247
  canActivate(context: ExecutionContext): boolean {
241
248
  const request = context.switchToHttp().getRequest();
242
- const tenantId =
243
- request.headers["x-tenant-id"] ||
244
- request.hostname?.split(".")[0];
249
+ // The tenant comes from the subdomain the request was routed to. Do not
250
+ // take it from a client-supplied header such as x-tenant-id: any caller
251
+ // can set one and pick another tenant. Once you add authentication, check
252
+ // that the signed-in user belongs to this tenant here.
253
+ const tenantId = request.hostname?.split(".")[0];
245
254
  request.tenantId = tenantId || null;
246
255
  return true;
247
256
  }
package/src/index.ts CHANGED
@@ -5,8 +5,9 @@ import * as path from "path";
5
5
  import crypto from "node:crypto";
6
6
  import { execSync } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
- import { parsePresetString, isValidPreset, formatPresetString } from "./matrix.js";
8
+ import { parsePresetString, isValidPreset } from "./matrix.js";
9
9
  import { createPresetProject } from "./preset-project.js";
10
+ import { postgresAppRole, postgresAppRoleSql, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
10
11
 
11
12
  // ─── Types ────────────────────────────────────────────────────────────────────
12
13
 
@@ -194,7 +195,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
194
195
  -- The ltree extension enables hierarchical tenant trees
195
196
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
196
197
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
197
- `;
198
+ ${postgresAppRoleSql(dbName)}`;
198
199
  }
199
200
 
200
201
  function generateEnv(projectName: string): string {
@@ -203,8 +204,12 @@ function generateEnv(projectName: string): string {
203
204
  return `# Environment variables for ${projectName}
204
205
  # Copy to .env and fill in values
205
206
 
206
- # Database
207
- DATABASE_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
207
+ # Database. The app connects as the non-superuser role created in init.sql,
208
+ # so row-level security applies to it.
209
+ DATABASE_URL=postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}
210
+
211
+ # Superuser: bootstrap and migrations only. It bypasses row-level security.
212
+ DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
208
213
 
209
214
  # Authentication
210
215
  JWT_SECRET=${jwtSecret}
@@ -295,7 +300,7 @@ export default function Home() {
295
300
  <p>Multi-tenant app powered by Stratum.</p>
296
301
  <ul>
297
302
  <li>Configure tenants via the Stratum control plane</li>
298
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
303
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
299
304
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
300
305
  </ul>
301
306
  </main>
@@ -305,18 +310,19 @@ export default function Home() {
305
310
  }
306
311
 
307
312
  function generateNextjsMiddleware(): string {
308
- return `// middleware.ts — tenant resolution via subdomain or header
313
+ return `// middleware.ts — tenant resolution via subdomain
309
314
  import { NextRequest, NextResponse } from "next/server";
310
315
 
311
316
  export function middleware(request: NextRequest) {
317
+ // The tenant comes from the subdomain the request was routed to. Any
318
+ // x-tenant-id the client sent is removed first, so server code that reads
319
+ // x-tenant-id only ever sees the value set here. Once you add
320
+ // authentication, check that the signed-in user belongs to this tenant.
312
321
  const hostname = request.headers.get("host") || "";
313
- const subdomain = hostname.split(".")[0];
314
- const headerTenantId = request.headers.get("x-tenant-id");
315
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
316
-
317
- const tenantId = headerTenantId || pathTenantId || subdomain;
322
+ const tenantId = hostname.split(".")[0];
318
323
 
319
324
  const requestHeaders = new Headers(request.headers);
325
+ requestHeaders.delete("x-tenant-id");
320
326
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
321
327
  requestHeaders.set("x-tenant-id", tenantId);
322
328
  }
@@ -380,7 +386,7 @@ ${projectName}/
380
386
 
381
387
  This project uses Stratum for hierarchical multi-tenancy:
382
388
 
383
- - **Tenant resolution** — via JWT claim, subdomain, or \`x-tenant-id\` header
389
+ - **Tenant resolution** — via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
384
390
  - **Config inheritance** — settings flow down the tenant tree with override support
385
391
  - **Permission ABAC** — role-based permissions with tenant-scoped enforcement
386
392
 
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
4
4
  import { execSync } from "child_process";
5
5
  import type { StackPreset } from "./matrix.js";
6
6
  import { generatePresetDockerCompose } from "./generators/docker-compose.js";
7
- import { generatePresetInitSql } from "./generators/init-sql.js";
7
+ import { generatePresetInitSql, postgresAppRole, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
8
8
  import { generateDbSetup } from "./generators/db-setup.js";
9
9
  import { generateMiddleware } from "./generators/middleware.js";
10
10
  import { generatePresetPackageJson } from "./generators/package-json.js";
@@ -24,9 +24,14 @@ function generatePresetEnv(projectName: string, preset: StackPreset): string {
24
24
  const jwtSecret = crypto.randomBytes(32).toString("base64url");
25
25
 
26
26
  let dbUrl: string;
27
+ let adminUrlLine = "";
27
28
  switch (preset.database) {
28
29
  case "postgres":
29
- dbUrl = `postgres://${dbName}:dev_password@localhost:5432/${dbName}`;
30
+ // The app connects as the non-superuser role created in init.sql, so
31
+ // row-level security applies to it. The superuser URL is for bootstrap
32
+ // and migrations only.
33
+ dbUrl = `postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}`;
34
+ adminUrlLine = `\n# Superuser: bootstrap and migrations only. It bypasses row-level security.\nDATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}\n`;
30
35
  break;
31
36
  case "mongodb":
32
37
  dbUrl = `mongodb://${dbName}:dev_password@localhost:27017/${dbName}?authSource=admin`;
@@ -43,7 +48,7 @@ function generatePresetEnv(projectName: string, preset: StackPreset): string {
43
48
 
44
49
  # Database
45
50
  ${urlKey}=${dbUrl}
46
-
51
+ ${adminUrlLine}
47
52
  # Authentication
48
53
  JWT_SECRET=${jwtSecret}
49
54