@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 +11 -0
- package/dist/index.js +104 -64
- package/dist/matrix.js +16 -32
- package/package.json +3 -3
- package/src/generators/db-setup.ts +3 -5
- package/src/generators/init-sql.ts +47 -3
- package/src/generators/middleware.ts +30 -21
- package/src/index.ts +18 -12
- package/src/preset-project.ts +8 -3
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
|
-
|
|
42
|
-
if (!config.
|
|
43
|
-
|
|
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
|
-
|
|
70
|
-
if (!
|
|
71
|
-
|
|
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.
|
|
227
|
+
-- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
206
228
|
--
|
|
207
|
-
--
|
|
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
|
|
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
|
|
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>
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
999
|
-
if (preset.framework === "
|
|
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}
|
|
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
|
-
|
|
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>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
if (!config.
|
|
34
|
-
|
|
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
|
-
|
|
61
|
-
if (!
|
|
62
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
for (const
|
|
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
|
-
|
|
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
|
+
"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.
|
|
34
|
-
"vitest": "^1.
|
|
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.
|
|
68
|
+
-- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
27
69
|
--
|
|
28
|
-
--
|
|
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
|
|
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
|
|
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>
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
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>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/preset-project.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|