@stratum-hq/create 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +2 -1
- package/dist/bin.js +3 -0
- package/dist/index.js +712 -156
- package/dist/matrix.js +16 -32
- package/package.json +5 -5
- package/src/bin.ts +7 -0
- package/src/generators/db-setup.ts +94 -31
- package/src/generators/init-sql.ts +50 -3
- package/src/generators/middleware.ts +30 -21
- package/src/generators/package-json.ts +31 -12
- package/src/generators/readme.ts +2 -10
- package/src/generators/tsconfig.ts +48 -0
- package/src/index.ts +29 -42
- package/src/preset-project.ts +10 -28
- package/src/stratum-versions.ts +20 -0
- package/turbo.json +19 -0
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.1",
|
|
4
4
|
"description": "Create a new Stratum multi-tenancy project",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"multi-tenancy",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"stratum"
|
|
17
17
|
],
|
|
18
18
|
"bin": {
|
|
19
|
-
"create-stratum": "./dist/
|
|
19
|
+
"create-stratum": "./dist/bin.js"
|
|
20
20
|
},
|
|
21
21
|
"exports": {
|
|
22
22
|
".": "./dist/index.js",
|
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
},
|
|
25
25
|
"type": "module",
|
|
26
26
|
"scripts": {
|
|
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/*",
|
|
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/* && esbuild src/bin.ts --platform=node --format=esm --outfile=dist/bin.js",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
29
|
"lint": "eslint .",
|
|
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": {
|
package/src/bin.ts
ADDED
|
@@ -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
|
`,
|
|
@@ -205,60 +203,120 @@ export default config;
|
|
|
205
203
|
},
|
|
206
204
|
{
|
|
207
205
|
filename: "src/stratum-knex.ts",
|
|
208
|
-
content:
|
|
206
|
+
content: preset.database === "postgres" ? KNEX_POSTGRES_SCOPE : knexColumnScope(preset.database),
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// PostgreSQL does not accept a bind parameter in SET, so the generated code
|
|
212
|
+
// calls set_config. The third argument (is_local = true) ends the setting with
|
|
213
|
+
// the transaction, so a pooled connection never keeps another tenant's ID.
|
|
214
|
+
const KNEX_POSTGRES_SCOPE = `// Knex with Stratum tenant-scoped queries
|
|
215
|
+
import createKnex, { type Knex } from "knex";
|
|
216
|
+
import config from "../knexfile.js";
|
|
217
|
+
|
|
218
|
+
const knex = createKnex(config);
|
|
219
|
+
|
|
220
|
+
// Run fn in a transaction that sets app.current_tenant_id, the setting that
|
|
221
|
+
// the RLS policies read. The setting ends with the transaction, so run every
|
|
222
|
+
// tenant query through trx, not through knex.
|
|
223
|
+
export async function withTenantScope<T>(
|
|
224
|
+
tenantId: string,
|
|
225
|
+
fn: (trx: Knex.Transaction) => Promise<T>,
|
|
226
|
+
): Promise<T> {
|
|
227
|
+
return knex.transaction(async (trx) => {
|
|
228
|
+
await trx.raw("SELECT set_config('app.current_tenant_id', ?, true)", [tenantId]);
|
|
229
|
+
return fn(trx);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Usage:
|
|
234
|
+
// const orders = await withTenantScope(currentTenantId, (trx) => trx("orders").select());
|
|
235
|
+
|
|
236
|
+
export { knex };
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
function knexColumnScope(database: string): string {
|
|
240
|
+
return `// Knex with Stratum tenant-scoped queries
|
|
209
241
|
import Knex from "knex";
|
|
210
242
|
import config from "../knexfile.js";
|
|
211
243
|
|
|
212
244
|
const knex = Knex(config);
|
|
213
245
|
|
|
214
246
|
// Create a tenant-scoped query builder.
|
|
215
|
-
// For RLS strategy, set the session variable before queries.
|
|
216
247
|
export async function withTenantScope(tenantId: string, fn: (db: typeof knex) => Promise<void>) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
await fn(knex);
|
|
220
|
-
} finally {
|
|
221
|
-
${preset.database === "postgres" ? ` await knex.raw("RESET app.current_tenant");` : ` // Scope cleanup not needed for column-based isolation`}
|
|
222
|
-
}
|
|
248
|
+
// For ${database}, scope queries by tenant_id column
|
|
249
|
+
await fn(knex);
|
|
223
250
|
}
|
|
224
251
|
|
|
225
252
|
export { knex };
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// @stratum-hq/mongodb has no Mongoose connection helper. The generated code uses
|
|
257
|
+
// Mongoose directly and copies the tenant names of the @stratum-hq/mongodb
|
|
258
|
+
// adapters, so MongoDatabaseAdapter and MongoCollectionAdapter find the same
|
|
259
|
+
// data, for example for purgeTenantData.
|
|
260
|
+
function generateMongooseSetup(preset: StackPreset): DbSetupFile[] {
|
|
261
|
+
const tenantAccess =
|
|
262
|
+
preset.strategy === "collection"
|
|
263
|
+
? `// Return the tenant's model for a base collection. The tenant's documents are
|
|
264
|
+
// in the collection {baseCollection}_{tenantSlug}, the name that
|
|
265
|
+
// MongoCollectionAdapter from @stratum-hq/mongodb uses.
|
|
266
|
+
export function getTenantModel<T>(
|
|
267
|
+
baseCollection: string,
|
|
268
|
+
schema: mongoose.Schema<T>,
|
|
269
|
+
tenantSlug: string,
|
|
270
|
+
) {
|
|
271
|
+
const name = \`\${baseCollection}_\${assertSlug(tenantSlug)}\`;
|
|
272
|
+
return mainConnection.models[name] ?? mainConnection.model(name, schema, name);
|
|
229
273
|
}
|
|
230
274
|
|
|
231
|
-
|
|
275
|
+
// Usage:
|
|
276
|
+
// const Order = getTenantModel("orders", OrderSchema, "tenant_abc");
|
|
277
|
+
// const orders = await Order.find();`
|
|
278
|
+
: `// Return a connection to the tenant's own database. The database name is
|
|
279
|
+
// stratum_tenant_{tenantSlug}, the name that MongoDatabaseAdapter from
|
|
280
|
+
// @stratum-hq/mongodb uses. useCache returns the same connection on each call.
|
|
281
|
+
export function getTenantConnection(tenantSlug: string) {
|
|
282
|
+
return mainConnection.useDb(\`stratum_tenant_\${assertSlug(tenantSlug)}\`, { useCache: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Usage:
|
|
286
|
+
// const conn = getTenantConnection("tenant_abc");
|
|
287
|
+
// const Order = conn.model("Order", OrderSchema);
|
|
288
|
+
// const orders = await Order.find();`;
|
|
289
|
+
|
|
232
290
|
return [
|
|
233
291
|
{
|
|
234
292
|
filename: "src/stratum-mongoose.ts",
|
|
235
293
|
content: `// Mongoose with Stratum multi-tenant support
|
|
236
294
|
import mongoose from "mongoose";
|
|
237
|
-
import { createTenantConnection } from "@stratum-hq/mongodb";
|
|
238
295
|
|
|
239
296
|
// Main connection (used for tenant metadata)
|
|
240
297
|
const mainConnection = mongoose.createConnection(
|
|
241
298
|
process.env.MONGODB_URI || "mongodb://localhost:27017/main",
|
|
242
299
|
);
|
|
243
300
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
301
|
+
// A tenant slug becomes part of a MongoDB name, so it must match the Stratum
|
|
302
|
+
// slug rule: a lowercase letter, then lowercase letters, digits or underscores.
|
|
303
|
+
const SLUG_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
|
|
304
|
+
|
|
305
|
+
function assertSlug(tenantSlug: string): string {
|
|
306
|
+
if (!SLUG_PATTERN.test(tenantSlug)) {
|
|
307
|
+
throw new Error(\`Invalid tenant slug: "\${tenantSlug}"\`);
|
|
308
|
+
}
|
|
309
|
+
return tenantSlug;
|
|
249
310
|
}
|
|
250
311
|
|
|
312
|
+
${tenantAccess}
|
|
313
|
+
|
|
251
314
|
// Define schemas that work across tenant connections
|
|
252
315
|
export const TenantSchema = new mongoose.Schema({
|
|
253
316
|
name: { type: String, required: true },
|
|
254
317
|
createdAt: { type: Date, default: Date.now },
|
|
255
318
|
});
|
|
256
319
|
|
|
257
|
-
// Usage:
|
|
258
|
-
// const conn = getTenantConnection("tenant-abc");
|
|
259
|
-
// const Order = conn.model("Order", OrderSchema);
|
|
260
|
-
// const orders = await Order.find();
|
|
261
|
-
|
|
262
320
|
export { mainConnection };
|
|
263
321
|
`,
|
|
264
322
|
},
|
|
@@ -278,7 +336,11 @@ const pool = mysql.createPool({
|
|
|
278
336
|
});
|
|
279
337
|
|
|
280
338
|
// Execute a query scoped to a tenant by filtering on tenant_id
|
|
281
|
-
export async function tenantQuery(
|
|
339
|
+
export async function tenantQuery(
|
|
340
|
+
tenantId: string,
|
|
341
|
+
sql: string,
|
|
342
|
+
params: (string | number | bigint | boolean | Date | null)[] = [],
|
|
343
|
+
) {
|
|
282
344
|
const [rows] = await pool.execute(sql, [...params, tenantId]);
|
|
283
345
|
return rows;
|
|
284
346
|
}
|
|
@@ -304,9 +366,10 @@ const pool = new Pool({
|
|
|
304
366
|
});
|
|
305
367
|
|
|
306
368
|
// Create a tenant-scoped pool.
|
|
307
|
-
//
|
|
369
|
+
// Each query runs in a transaction that sets app.current_tenant_id, the
|
|
370
|
+
// setting that the RLS policies read.
|
|
308
371
|
export function getTenantPool(tenantId: string) {
|
|
309
|
-
return createTenantPool(pool, tenantId);
|
|
372
|
+
return createTenantPool(pool, () => tenantId);
|
|
310
373
|
}
|
|
311
374
|
|
|
312
375
|
// Usage:
|
|
@@ -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,16 @@ 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 = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
|
27
69
|
--
|
|
28
|
-
--
|
|
70
|
+
-- A pooled connection reads the setting as '' after a tenant transaction ends,
|
|
71
|
+
-- or as NULL before the first one. NULLIF makes both return no rows; a bare
|
|
72
|
+
-- ::uuid cast of '' raises an error.
|
|
73
|
+
-- FORCE makes the policy apply to the table owner too; without it, a table
|
|
74
|
+
-- created by the application role is not isolated for that role.
|
|
75
|
+
-- The Stratum db-adapters package sets app.current_tenant_id automatically.
|
|
29
76
|
`;
|
|
30
77
|
}
|
|
31
78
|
|
|
@@ -37,7 +84,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
|
|
|
37
84
|
-- The ltree extension enables hierarchical tenant trees
|
|
38
85
|
-- uuid-ossp provides uuid_generate_v4() for tenant IDs
|
|
39
86
|
COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
|
|
40
|
-
${rlsBlock}`;
|
|
87
|
+
${postgresAppRoleSql(dbName, strategy)}${rlsBlock}`;
|
|
41
88
|
}
|
|
42
89
|
|
|
43
90
|
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
|
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import type { StackPreset } from "../matrix.js";
|
|
2
|
+
import { rootSources } from "./tsconfig.js";
|
|
3
|
+
import { STRATUM_RANGES } from "../stratum-versions.js";
|
|
2
4
|
|
|
3
5
|
export function generatePresetPackageJson(projectName: string, preset: StackPreset): string {
|
|
4
6
|
const deps: Record<string, string> = {
|
|
5
|
-
"@stratum-hq/lib": "
|
|
7
|
+
"@stratum-hq/lib": STRATUM_RANGES["@stratum-hq/lib"],
|
|
6
8
|
};
|
|
7
9
|
const devDeps: Record<string, string> = {
|
|
8
10
|
typescript: "^5.3.0",
|
|
9
11
|
"@types/node": "^20.11.0",
|
|
10
12
|
};
|
|
13
|
+
// NestJS injection needs the decorator metadata that tsc emits and tsx does not.
|
|
14
|
+
if (preset.framework === "nestjs") {
|
|
15
|
+
devDeps["tsc-watch"] = "^7.2.0";
|
|
16
|
+
} else if (preset.framework !== "nextjs") {
|
|
17
|
+
devDeps["tsx"] = "^4.7.0";
|
|
18
|
+
}
|
|
11
19
|
|
|
12
20
|
// Database driver deps
|
|
13
|
-
addDatabaseDeps(deps, preset);
|
|
21
|
+
addDatabaseDeps(deps, devDeps, preset);
|
|
14
22
|
|
|
15
23
|
// ORM deps
|
|
16
24
|
addOrmDeps(deps, devDeps, preset);
|
|
@@ -41,9 +49,14 @@ export function generatePresetPackageJson(projectName: string, preset: StackPres
|
|
|
41
49
|
);
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
function addDatabaseDeps(deps: Record<string, string>, preset: StackPreset): void {
|
|
52
|
+
function addDatabaseDeps(deps: Record<string, string>, devDeps: Record<string, string>, preset: StackPreset): void {
|
|
45
53
|
switch (preset.database) {
|
|
46
54
|
case "postgres":
|
|
55
|
+
// The generated code for these ORMs imports pg itself. The strict tsc
|
|
56
|
+
// build fails with TS7016 when the pg types are not installed.
|
|
57
|
+
if (preset.orm === "pg" || preset.orm === "prisma" || preset.orm === "drizzle") {
|
|
58
|
+
devDeps["@types/pg"] = "^8.11.0";
|
|
59
|
+
}
|
|
47
60
|
if (preset.orm !== "prisma" && preset.orm !== "drizzle" && preset.orm !== "sequelize") {
|
|
48
61
|
deps["pg"] = "^8.11.0";
|
|
49
62
|
}
|
|
@@ -139,19 +152,19 @@ function addFrameworkDeps(deps: Record<string, string>, devDeps: Record<string,
|
|
|
139
152
|
|
|
140
153
|
function addStratumDeps(deps: Record<string, string>, preset: StackPreset): void {
|
|
141
154
|
if (preset.database === "postgres" && preset.orm !== "mongoose") {
|
|
142
|
-
deps["@stratum-hq/db-adapters"] = "
|
|
155
|
+
deps["@stratum-hq/db-adapters"] = STRATUM_RANGES["@stratum-hq/db-adapters"];
|
|
143
156
|
}
|
|
144
157
|
if (preset.database === "mongodb") {
|
|
145
|
-
deps["@stratum-hq/mongodb"] = "
|
|
158
|
+
deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
|
|
146
159
|
}
|
|
147
160
|
if (preset.database === "mysql") {
|
|
148
|
-
deps["@stratum-hq/mysql"] = "
|
|
161
|
+
deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
|
|
149
162
|
}
|
|
150
163
|
if (preset.framework === "hono") {
|
|
151
|
-
deps["@stratum-hq/hono"] = "
|
|
164
|
+
deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
|
|
152
165
|
}
|
|
153
166
|
if (preset.framework === "nestjs") {
|
|
154
|
-
deps["@stratum-hq/nestjs"] = "
|
|
167
|
+
deps["@stratum-hq/nestjs"] = STRATUM_RANGES["@stratum-hq/nestjs"];
|
|
155
168
|
}
|
|
156
169
|
}
|
|
157
170
|
|
|
@@ -159,10 +172,16 @@ function getScripts(preset: StackPreset): Record<string, string> {
|
|
|
159
172
|
if (preset.framework === "nextjs") {
|
|
160
173
|
return { dev: "next dev", build: "next build", start: "next start" };
|
|
161
174
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
const entry = preset.framework === "nestjs" ? "main" : "index";
|
|
176
|
+
// When tsc compiles from the project root, src/ is emitted to dist/src/.
|
|
177
|
+
const emittedDir = rootSources(preset).length > 0 ? "dist/src" : "dist";
|
|
178
|
+
// Node 20 cannot run a .ts file. NestJS compiles with tsc, which keeps the
|
|
179
|
+
// decorator metadata its injection needs; the other frameworks run through tsx.
|
|
180
|
+
const dev =
|
|
181
|
+
preset.framework === "nestjs"
|
|
182
|
+
? `tsc-watch --onSuccess "node --env-file=.env ${emittedDir}/${entry}.js"`
|
|
183
|
+
: `tsx watch --env-file=.env src/${entry}.ts`;
|
|
184
|
+
return { dev, build: "tsc", start: `node ${emittedDir}/${entry}.js` };
|
|
166
185
|
}
|
|
167
186
|
|
|
168
187
|
function sortKeys(obj: Record<string, string>): Record<string, string> {
|
package/src/generators/readme.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { StackPreset } from "../matrix.js";
|
|
2
2
|
|
|
3
3
|
export function generatePresetReadme(projectName: string, preset: StackPreset): string {
|
|
4
|
-
const devCmd = getDevCommand(preset);
|
|
5
4
|
const dbStartCmd = getDbStartInfo(preset);
|
|
6
5
|
const dbSetupNote = getDbSetupNote(preset);
|
|
7
6
|
|
|
@@ -37,7 +36,7 @@ ${dbSetupNote}
|
|
|
37
36
|
### 4. Run the app
|
|
38
37
|
|
|
39
38
|
\`\`\`bash
|
|
40
|
-
|
|
39
|
+
npm run dev
|
|
41
40
|
\`\`\`
|
|
42
41
|
|
|
43
42
|
## Multi-tenancy
|
|
@@ -50,13 +49,6 @@ See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference
|
|
|
50
49
|
`;
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
function getDevCommand(preset: StackPreset): string {
|
|
54
|
-
if (preset.framework === "nextjs") return "npm run dev";
|
|
55
|
-
if (preset.framework === "nestjs") return "node --env-file=.env src/main.ts";
|
|
56
|
-
if (preset.framework === "none") return "node --env-file=.env src/index.ts";
|
|
57
|
-
return "node --env-file=.env src/index.ts";
|
|
58
|
-
}
|
|
59
|
-
|
|
60
52
|
function getDbStartInfo(preset: StackPreset): string {
|
|
61
53
|
switch (preset.database) {
|
|
62
54
|
case "postgres":
|
|
@@ -95,7 +87,7 @@ function getStrategyDescription(strategy: string): string {
|
|
|
95
87
|
switch (strategy) {
|
|
96
88
|
case "rls":
|
|
97
89
|
return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
|
|
98
|
-
- Each query sets \`app.
|
|
90
|
+
- Each query sets \`app.current_tenant_id\` and RLS enforces isolation
|
|
99
91
|
- All tenants share one database and schema`;
|
|
100
92
|
case "schema":
|
|
101
93
|
return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Framework, StackPreset } from "../matrix.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return the source files that a preset writes at the project root and that code in `src/` imports.
|
|
5
|
+
*
|
|
6
|
+
* @param preset - The stack preset of the generated project.
|
|
7
|
+
*/
|
|
8
|
+
export function rootSources(preset: StackPreset): string[] {
|
|
9
|
+
// The knex CLI reads knexfile.ts from the project root, and src/stratum-knex.ts imports it.
|
|
10
|
+
return preset.orm === "knex" ? ["knexfile.ts"] : [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return the tsconfig.json content for a generated project.
|
|
15
|
+
*
|
|
16
|
+
* @param framework - The framework of the project. It adds the compiler options that the framework needs.
|
|
17
|
+
* @param extraSources - Source files at the project root that code in `src/` imports.
|
|
18
|
+
* When there are any, tsc compiles from the project root, so the output of `src/` goes to `dist/src/`.
|
|
19
|
+
*/
|
|
20
|
+
export function generateTsconfig(framework: Framework, extraSources: string[] = []): string {
|
|
21
|
+
// tsc rejects an imported file outside rootDir with TS6059.
|
|
22
|
+
const rootDir = extraSources.length > 0 ? "." : "src";
|
|
23
|
+
return JSON.stringify(
|
|
24
|
+
{
|
|
25
|
+
compilerOptions: {
|
|
26
|
+
target: "ESNext",
|
|
27
|
+
module: "NodeNext",
|
|
28
|
+
moduleResolution: "NodeNext",
|
|
29
|
+
strict: true,
|
|
30
|
+
outDir: "dist",
|
|
31
|
+
rootDir,
|
|
32
|
+
declaration: true,
|
|
33
|
+
skipLibCheck: true,
|
|
34
|
+
esModuleInterop: true,
|
|
35
|
+
...(framework === "nestjs"
|
|
36
|
+
? { experimentalDecorators: true, emitDecoratorMetadata: true }
|
|
37
|
+
: {}),
|
|
38
|
+
...(framework === "nextjs"
|
|
39
|
+
? { jsx: "preserve", plugins: [{ name: "next" }] }
|
|
40
|
+
: {}),
|
|
41
|
+
},
|
|
42
|
+
include: ["src", ...extraSources],
|
|
43
|
+
exclude: ["node_modules", "dist"],
|
|
44
|
+
},
|
|
45
|
+
null,
|
|
46
|
+
2,
|
|
47
|
+
);
|
|
48
|
+
}
|