@stratum-hq/create 0.3.0 → 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 +40 -0
- package/dist/index.js +108 -68
- package/dist/matrix.js +115 -0
- package/package.json +13 -8
- package/src/generators/db-setup.ts +7 -9
- 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/src/__tests__/create.test.ts +0 -217
- package/src/__tests__/matrix.test.ts +0 -283
- package/src/__tests__/preset.test.ts +0 -266
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
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
|
+
|
|
14
|
+
## 0.3.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- b55ae70: Correct and complete package metadata for the npm registry listing.
|
|
19
|
+
|
|
20
|
+
Every published package now declares `license` (MIT), `author`, `homepage`, and
|
|
21
|
+
`bugs`. Runtime packages declare `engines` (Node >=20) to match the project's
|
|
22
|
+
support policy; this fixes `@stratum-hq/cli`, which previously declared Node >=18.
|
|
23
|
+
`@stratum-hq/mysql` and `@stratum-hq/mongodb` gain the `keywords` array they were
|
|
24
|
+
missing. No runtime code changes.
|
|
25
|
+
|
|
26
|
+
- c17b1a5: Point `@stratum-hq/create`'s `exports["./matrix"]` at built output instead of raw source (#219, from the #133 v1.0 surface review).
|
|
27
|
+
|
|
28
|
+
The `./matrix` subpath previously resolved (and shipped) `./src/matrix.ts` for both the `import` and `types` conditions, blessing a raw-source subpath unlike every other package. The build now emits `dist/matrix.js` and `./matrix` resolves there, matching the package's `.` entry. The stack-combination matrix API is unchanged.
|
|
29
|
+
|
|
30
|
+
- c17b1a5: Give the `@stratum-hq/db-adapters` barrel one consistent naming scheme for the tenant-context helpers (#219, from the #133 v1.0 surface review).
|
|
31
|
+
|
|
32
|
+
The barrel previously exposed the same "run in tenant context" concept under colliding names patched over with `as` aliases. The 1.0 names use an `<orm>` prefix so no export is an alias workaround:
|
|
33
|
+
- `withTenant` (Prisma) -> `prismaWithTenant`
|
|
34
|
+
- `withDrizzleTenant` -> `drizzleWithTenant`
|
|
35
|
+
- `withTenantScope` (Sequelize) -> `sequelizeWithTenantScope`
|
|
36
|
+
- `withDrizzleTenantScope` -> `drizzleWithTenantScope`
|
|
37
|
+
- `enableRLSMigration` (migration helper) -> `enableRLSForMigration` (distinct from the runtime `enableRLS`)
|
|
38
|
+
|
|
39
|
+
Behavior is identical; only the exported names change. The `@stratum-hq/cli` and `@stratum-hq/create` scaffolding templates emit the new `prismaWithTenant` name. Update your imports to the new names.
|
|
40
|
+
|
|
41
|
+
- 4adcbb5: Stop shipping test files in published tarballs. tsc-built packages now exclude **tests** directories and .test/.spec files from compilation, so dist and the tarball contain only real package output. The create package, which ships source for its ./matrix export, excludes tests via .npmignore instead. The vitest runner is unaffected and still runs tests from src.
|
|
42
|
+
|
|
3
43
|
## 0.3.0
|
|
4
44
|
|
|
5
45
|
### Minor 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",
|
|
@@ -283,7 +306,7 @@ model Tenant {
|
|
|
283
306
|
content: `// Prisma client with Stratum tenant-scoped queries
|
|
284
307
|
import { PrismaClient } from "@prisma/client";
|
|
285
308
|
import { Pool } from "pg";
|
|
286
|
-
import {
|
|
309
|
+
import { prismaWithTenant } from "@stratum-hq/db-adapters";
|
|
287
310
|
|
|
288
311
|
const prisma = new PrismaClient();
|
|
289
312
|
const pool = new Pool({
|
|
@@ -293,7 +316,7 @@ const pool = new Pool({
|
|
|
293
316
|
// Create a tenant-scoped Prisma client.
|
|
294
317
|
// All queries through this client are automatically filtered by RLS.
|
|
295
318
|
export function createTenantPrisma(getTenantId: () => string) {
|
|
296
|
-
return
|
|
319
|
+
return prismaWithTenant(prisma, getTenantId, pool);
|
|
297
320
|
}
|
|
298
321
|
|
|
299
322
|
// Usage:
|
|
@@ -330,7 +353,7 @@ export const db = drizzle(connection);
|
|
|
330
353
|
content: `// Drizzle ORM with Stratum tenant-scoped queries
|
|
331
354
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
332
355
|
import { Pool } from "pg";
|
|
333
|
-
import {
|
|
356
|
+
import { drizzleWithTenant } from "@stratum-hq/db-adapters";
|
|
334
357
|
|
|
335
358
|
const pool = new Pool({
|
|
336
359
|
connectionString: process.env.DATABASE_URL,
|
|
@@ -341,12 +364,13 @@ export const db = drizzle(pool);
|
|
|
341
364
|
// Create a tenant-scoped Drizzle instance.
|
|
342
365
|
// Queries are automatically filtered by RLS policy.
|
|
343
366
|
export function createTenantDb(getTenantId: () => string) {
|
|
344
|
-
return
|
|
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
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/matrix.ts
|
|
2
|
+
var POSTGRES_STRATEGIES = ["rls", "schema", "database"];
|
|
3
|
+
var POSTGRES_ORMS = ["prisma", "drizzle", "sequelize", "knex", "pg"];
|
|
4
|
+
var MONGODB_STRATEGIES = ["database", "collection"];
|
|
5
|
+
var MONGODB_ORMS = ["mongoose"];
|
|
6
|
+
var MYSQL_STRATEGIES = ["database", "table-prefix"];
|
|
7
|
+
var MYSQL_ORMS = ["sequelize", "knex", "pg"];
|
|
8
|
+
var ALL_FRAMEWORKS = ["express", "fastify", "nextjs", "hono", "nestjs", "none"];
|
|
9
|
+
var VALID_COMBINATIONS = {
|
|
10
|
+
postgres: {
|
|
11
|
+
strategies: POSTGRES_STRATEGIES,
|
|
12
|
+
orms: POSTGRES_ORMS,
|
|
13
|
+
frameworks: ALL_FRAMEWORKS
|
|
14
|
+
},
|
|
15
|
+
mongodb: {
|
|
16
|
+
strategies: MONGODB_STRATEGIES,
|
|
17
|
+
orms: MONGODB_ORMS,
|
|
18
|
+
frameworks: ALL_FRAMEWORKS
|
|
19
|
+
},
|
|
20
|
+
mysql: {
|
|
21
|
+
strategies: MYSQL_STRATEGIES,
|
|
22
|
+
orms: MYSQL_ORMS,
|
|
23
|
+
frameworks: ALL_FRAMEWORKS
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var ALL_DATABASES = ["postgres", "mongodb", "mysql"];
|
|
27
|
+
var ALL_STRATEGIES = ["rls", "schema", "database", "collection", "table-prefix"];
|
|
28
|
+
var ALL_ORMS = ["prisma", "drizzle", "sequelize", "knex", "mongoose", "pg"];
|
|
29
|
+
function isValidPreset(preset) {
|
|
30
|
+
const config = VALID_COMBINATIONS[preset.database];
|
|
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;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
function parsePresetString(s) {
|
|
38
|
+
if (!s || typeof s !== "string") return null;
|
|
39
|
+
const parts = s.toLowerCase().split("-");
|
|
40
|
+
let database;
|
|
41
|
+
let strategy;
|
|
42
|
+
let orm;
|
|
43
|
+
let framework;
|
|
44
|
+
if (parts.length === 4) {
|
|
45
|
+
[database, strategy, orm, framework] = parts;
|
|
46
|
+
} else if (parts.length === 5) {
|
|
47
|
+
database = parts[0];
|
|
48
|
+
strategy = `${parts[1]}-${parts[2]}`;
|
|
49
|
+
orm = parts[3];
|
|
50
|
+
framework = parts[4];
|
|
51
|
+
} else {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
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;
|
|
58
|
+
return {
|
|
59
|
+
database,
|
|
60
|
+
strategy,
|
|
61
|
+
orm,
|
|
62
|
+
framework
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function formatPresetString(preset) {
|
|
66
|
+
return `${preset.database}-${preset.strategy}-${preset.orm}-${preset.framework}`;
|
|
67
|
+
}
|
|
68
|
+
function getValidOptions(partial) {
|
|
69
|
+
let databases = [...ALL_DATABASES];
|
|
70
|
+
let strategies = [];
|
|
71
|
+
let orms = [];
|
|
72
|
+
let frameworks = [];
|
|
73
|
+
if (partial.database) {
|
|
74
|
+
databases = [partial.database];
|
|
75
|
+
}
|
|
76
|
+
const strategySet = /* @__PURE__ */ new Set();
|
|
77
|
+
const ormSet = /* @__PURE__ */ new Set();
|
|
78
|
+
const frameworkSet = /* @__PURE__ */ new Set();
|
|
79
|
+
for (const db of databases) {
|
|
80
|
+
const config = VALID_COMBINATIONS[db];
|
|
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);
|
|
86
|
+
}
|
|
87
|
+
strategies = [...strategySet];
|
|
88
|
+
orms = [...ormSet];
|
|
89
|
+
frameworks = [...frameworkSet];
|
|
90
|
+
if (partial.strategy) {
|
|
91
|
+
strategies = strategies.filter((s) => s === partial.strategy);
|
|
92
|
+
}
|
|
93
|
+
if (partial.orm) {
|
|
94
|
+
orms = orms.filter((o) => o === partial.orm);
|
|
95
|
+
}
|
|
96
|
+
if (partial.framework) {
|
|
97
|
+
frameworks = frameworks.filter((f) => f === partial.framework);
|
|
98
|
+
}
|
|
99
|
+
if (partial.strategy || partial.orm) {
|
|
100
|
+
databases = databases.filter((db) => {
|
|
101
|
+
const config = VALID_COMBINATIONS[db];
|
|
102
|
+
if (partial.strategy && !config.strategies.includes(partial.strategy)) return false;
|
|
103
|
+
if (partial.orm && !config.orms.includes(partial.orm)) return false;
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { databases, strategies, orms, frameworks };
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
VALID_COMBINATIONS,
|
|
111
|
+
formatPresetString,
|
|
112
|
+
getValidOptions,
|
|
113
|
+
isValidPreset,
|
|
114
|
+
parsePresetString
|
|
115
|
+
};
|
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",
|
|
@@ -20,19 +20,18 @@
|
|
|
20
20
|
},
|
|
21
21
|
"exports": {
|
|
22
22
|
".": "./dist/index.js",
|
|
23
|
-
"./matrix":
|
|
24
|
-
"import": "./src/matrix.ts",
|
|
25
|
-
"types": "./src/matrix.ts"
|
|
26
|
-
}
|
|
23
|
+
"./matrix": "./dist/matrix.js"
|
|
27
24
|
},
|
|
28
25
|
"type": "module",
|
|
29
26
|
"scripts": {
|
|
30
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@stratum-hq/*",
|
|
27
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@stratum-hq/* && esbuild src/matrix.ts --bundle --platform=node --format=esm --outfile=dist/matrix.js --external:@stratum-hq/*",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"lint": "eslint .",
|
|
31
30
|
"test": "vitest run"
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|
|
34
|
-
"esbuild": "^0.
|
|
35
|
-
"vitest": "^1.
|
|
33
|
+
"esbuild": "^0.28.2",
|
|
34
|
+
"vitest": "^4.1.11",
|
|
36
35
|
"typescript": "^5.3.0"
|
|
37
36
|
},
|
|
38
37
|
"publishConfig": {
|
|
@@ -40,6 +39,12 @@
|
|
|
40
39
|
"registry": "https://registry.npmjs.org/"
|
|
41
40
|
},
|
|
42
41
|
"license": "MIT",
|
|
42
|
+
"author": "Christian Crank",
|
|
43
|
+
"homepage": "https://github.com/stratum-hq/Stratum/tree/main/packages/create#readme",
|
|
44
|
+
"bugs": "https://github.com/stratum-hq/Stratum/issues",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20.0.0"
|
|
47
|
+
},
|
|
43
48
|
"repository": {
|
|
44
49
|
"type": "git",
|
|
45
50
|
"url": "https://github.com/stratum-hq/Stratum.git",
|
|
@@ -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
|
{
|
|
@@ -58,7 +55,7 @@ model Tenant {
|
|
|
58
55
|
content: `// Prisma client with Stratum tenant-scoped queries
|
|
59
56
|
import { PrismaClient } from "@prisma/client";
|
|
60
57
|
import { Pool } from "pg";
|
|
61
|
-
import {
|
|
58
|
+
import { prismaWithTenant } from "@stratum-hq/db-adapters";
|
|
62
59
|
|
|
63
60
|
const prisma = new PrismaClient();
|
|
64
61
|
const pool = new Pool({
|
|
@@ -68,7 +65,7 @@ const pool = new Pool({
|
|
|
68
65
|
// Create a tenant-scoped Prisma client.
|
|
69
66
|
// All queries through this client are automatically filtered by RLS.
|
|
70
67
|
export function createTenantPrisma(getTenantId: () => string) {
|
|
71
|
-
return
|
|
68
|
+
return prismaWithTenant(prisma, getTenantId, pool);
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
// Usage:
|
|
@@ -107,7 +104,7 @@ export const db = drizzle(connection);
|
|
|
107
104
|
content: `// Drizzle ORM with Stratum tenant-scoped queries
|
|
108
105
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
109
106
|
import { Pool } from "pg";
|
|
110
|
-
import {
|
|
107
|
+
import { drizzleWithTenant } from "@stratum-hq/db-adapters";
|
|
111
108
|
|
|
112
109
|
const pool = new Pool({
|
|
113
110
|
connectionString: process.env.DATABASE_URL,
|
|
@@ -118,12 +115,13 @@ export const db = drizzle(pool);
|
|
|
118
115
|
// Create a tenant-scoped Drizzle instance.
|
|
119
116
|
// Queries are automatically filtered by RLS policy.
|
|
120
117
|
export function createTenantDb(getTenantId: () => string) {
|
|
121
|
-
return
|
|
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
|
`,
|