@stratum-hq/create 0.4.0 → 0.5.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 +23 -0
- package/README.md +7 -2
- package/dist/bin.js +3 -0
- package/dist/index.js +784 -183
- package/package.json +3 -3
- package/src/bin.ts +7 -0
- package/src/generators/db-setup.ts +91 -26
- package/src/generators/init-sql.ts +4 -1
- package/src/generators/middleware.ts +177 -57
- package/src/generators/package-json.ts +35 -12
- package/src/generators/readme.ts +2 -10
- package/src/generators/tsconfig.ts +48 -0
- package/src/index.ts +17 -60
- package/src/preset-project.ts +2 -25
- package/src/stratum-versions.ts +20 -0
- package/turbo.json +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratum-hq/create",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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,7 +24,7 @@
|
|
|
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"
|
package/src/bin.ts
ADDED
|
@@ -203,60 +203,120 @@ export default config;
|
|
|
203
203
|
},
|
|
204
204
|
{
|
|
205
205
|
filename: "src/stratum-knex.ts",
|
|
206
|
-
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
|
|
207
241
|
import Knex from "knex";
|
|
208
242
|
import config from "../knexfile.js";
|
|
209
243
|
|
|
210
244
|
const knex = Knex(config);
|
|
211
245
|
|
|
212
246
|
// Create a tenant-scoped query builder.
|
|
213
|
-
// For RLS strategy, set the session variable before queries.
|
|
214
247
|
export async function withTenantScope(tenantId: string, fn: (db: typeof knex) => Promise<void>) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await fn(knex);
|
|
218
|
-
} finally {
|
|
219
|
-
${preset.database === "postgres" ? ` await knex.raw("RESET app.current_tenant");` : ` // Scope cleanup not needed for column-based isolation`}
|
|
220
|
-
}
|
|
248
|
+
// For ${database}, scope queries by tenant_id column
|
|
249
|
+
await fn(knex);
|
|
221
250
|
}
|
|
222
251
|
|
|
223
252
|
export { knex };
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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);
|
|
273
|
+
}
|
|
274
|
+
|
|
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 });
|
|
227
283
|
}
|
|
228
284
|
|
|
229
|
-
|
|
285
|
+
// Usage:
|
|
286
|
+
// const conn = getTenantConnection("tenant_abc");
|
|
287
|
+
// const Order = conn.model("Order", OrderSchema);
|
|
288
|
+
// const orders = await Order.find();`;
|
|
289
|
+
|
|
230
290
|
return [
|
|
231
291
|
{
|
|
232
292
|
filename: "src/stratum-mongoose.ts",
|
|
233
293
|
content: `// Mongoose with Stratum multi-tenant support
|
|
234
294
|
import mongoose from "mongoose";
|
|
235
|
-
import { createTenantConnection } from "@stratum-hq/mongodb";
|
|
236
295
|
|
|
237
296
|
// Main connection (used for tenant metadata)
|
|
238
297
|
const mainConnection = mongoose.createConnection(
|
|
239
298
|
process.env.MONGODB_URI || "mongodb://localhost:27017/main",
|
|
240
299
|
);
|
|
241
300
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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;
|
|
247
310
|
}
|
|
248
311
|
|
|
312
|
+
${tenantAccess}
|
|
313
|
+
|
|
249
314
|
// Define schemas that work across tenant connections
|
|
250
315
|
export const TenantSchema = new mongoose.Schema({
|
|
251
316
|
name: { type: String, required: true },
|
|
252
317
|
createdAt: { type: Date, default: Date.now },
|
|
253
318
|
});
|
|
254
319
|
|
|
255
|
-
// Usage:
|
|
256
|
-
// const conn = getTenantConnection("tenant-abc");
|
|
257
|
-
// const Order = conn.model("Order", OrderSchema);
|
|
258
|
-
// const orders = await Order.find();
|
|
259
|
-
|
|
260
320
|
export { mainConnection };
|
|
261
321
|
`,
|
|
262
322
|
},
|
|
@@ -276,7 +336,11 @@ const pool = mysql.createPool({
|
|
|
276
336
|
});
|
|
277
337
|
|
|
278
338
|
// Execute a query scoped to a tenant by filtering on tenant_id
|
|
279
|
-
export async function tenantQuery(
|
|
339
|
+
export async function tenantQuery(
|
|
340
|
+
tenantId: string,
|
|
341
|
+
sql: string,
|
|
342
|
+
params: (string | number | bigint | boolean | Date | null)[] = [],
|
|
343
|
+
) {
|
|
280
344
|
const [rows] = await pool.execute(sql, [...params, tenantId]);
|
|
281
345
|
return rows;
|
|
282
346
|
}
|
|
@@ -302,9 +366,10 @@ const pool = new Pool({
|
|
|
302
366
|
});
|
|
303
367
|
|
|
304
368
|
// Create a tenant-scoped pool.
|
|
305
|
-
//
|
|
369
|
+
// Each query runs in a transaction that sets app.current_tenant_id, the
|
|
370
|
+
// setting that the RLS policies read.
|
|
306
371
|
export function getTenantPool(tenantId: string) {
|
|
307
|
-
return createTenantPool(pool, tenantId);
|
|
372
|
+
return createTenantPool(pool, () => tenantId);
|
|
308
373
|
}
|
|
309
374
|
|
|
310
375
|
// Usage:
|
|
@@ -65,8 +65,11 @@ function generatePostgresInit(projectName: string, dbName: string, strategy: str
|
|
|
65
65
|
-- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
|
|
66
66
|
-- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
|
|
67
67
|
-- CREATE POLICY tenant_isolation ON your_table
|
|
68
|
-
-- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
68
|
+
-- USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
|
69
69
|
--
|
|
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.
|
|
70
73
|
-- FORCE makes the policy apply to the table owner too; without it, a table
|
|
71
74
|
-- created by the application role is not isolated for that role.
|
|
72
75
|
-- The Stratum db-adapters package sets app.current_tenant_id automatically.
|
|
@@ -5,6 +5,128 @@ export interface MiddlewareFile {
|
|
|
5
5
|
content: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Verifies the bearer token and returns its tenant_id claim. Inserted into
|
|
10
|
+
* every generated server so the project needs nothing but jose. The tenant
|
|
11
|
+
* never comes from the hostname or a header such as x-tenant-id: any caller
|
|
12
|
+
* can choose those.
|
|
13
|
+
*/
|
|
14
|
+
const VERIFIED_TENANT = `import { jwtVerify } from "jose";
|
|
15
|
+
|
|
16
|
+
const jwtSecret = process.env.JWT_SECRET;
|
|
17
|
+
if (!jwtSecret) {
|
|
18
|
+
throw new Error("JWT_SECRET must be set: the tenant is taken from a verified JWT.");
|
|
19
|
+
}
|
|
20
|
+
const jwtKey = new TextEncoder().encode(jwtSecret);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The tenant for a request, from the tenant_id claim of a bearer token that
|
|
24
|
+
* verifies with JWT_SECRET. tenantId is null when there is no bearer token.
|
|
25
|
+
* invalid is true when a token was sent but does not verify or has no
|
|
26
|
+
* tenant_id claim. Never take the tenant from the hostname or from a header
|
|
27
|
+
* such as x-tenant-id: any caller can choose those.
|
|
28
|
+
*/
|
|
29
|
+
async function verifiedTenant(
|
|
30
|
+
authorization: string | undefined,
|
|
31
|
+
): Promise<{ tenantId: string | null; invalid: boolean }> {
|
|
32
|
+
if (!authorization?.startsWith("Bearer ")) return { tenantId: null, invalid: false };
|
|
33
|
+
try {
|
|
34
|
+
const { payload } = await jwtVerify(authorization.slice("Bearer ".length), jwtKey, {
|
|
35
|
+
algorithms: ["HS256"],
|
|
36
|
+
});
|
|
37
|
+
if (typeof payload.tenant_id === "string") return { tenantId: payload.tenant_id, invalid: false };
|
|
38
|
+
} catch {
|
|
39
|
+
// Fall through: a token that does not verify is rejected, never ignored.
|
|
40
|
+
}
|
|
41
|
+
return { tenantId: null, invalid: true };
|
|
42
|
+
}`;
|
|
43
|
+
|
|
44
|
+
const INVALID_TOKEN = `{ error: "Bearer token is invalid or has no tenant_id claim" }`;
|
|
45
|
+
const TENANT_REQUIRED = `{ error: "A bearer token with a tenant_id claim is required" }`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Next.js middleware, following examples/with-nextjs: the tenant ID comes only
|
|
49
|
+
* from the tenant_id claim of a verified bearer token and is forwarded as
|
|
50
|
+
* x-tenant-id. The subdomain is forwarded as x-tenant-slug, never as the ID.
|
|
51
|
+
*/
|
|
52
|
+
export function nextjsTenantMiddleware(): string {
|
|
53
|
+
return `// middleware.ts (place in project root)
|
|
54
|
+
// Next.js middleware for Stratum tenant resolution
|
|
55
|
+
//
|
|
56
|
+
// The tenant ID comes only from the tenant_id claim of a bearer token that
|
|
57
|
+
// verifies with JWT_SECRET, and is forwarded as x-tenant-id. Any copy of the
|
|
58
|
+
// tenant headers the client sent is removed first, so server code only ever
|
|
59
|
+
// reads the values set here.
|
|
60
|
+
//
|
|
61
|
+
// The subdomain (acme.app.example.com) is forwarded as x-tenant-slug. It only
|
|
62
|
+
// says which tenant's public pages to show. It does not prove the caller
|
|
63
|
+
// belongs to that tenant, so never use it to read or write tenant data.
|
|
64
|
+
|
|
65
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
66
|
+
import { jwtVerify } from "jose";
|
|
67
|
+
|
|
68
|
+
const TENANT_ID_HEADER = "x-tenant-id";
|
|
69
|
+
const TENANT_SLUG_HEADER = "x-tenant-slug";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The tenant_id claim of a token that verifies with JWT_SECRET, or null when
|
|
73
|
+
* the token is invalid, expired, or has no string tenant_id claim.
|
|
74
|
+
*/
|
|
75
|
+
async function verifiedTenantId(token: string): Promise<string | null> {
|
|
76
|
+
const secret = process.env.JWT_SECRET;
|
|
77
|
+
if (!secret) {
|
|
78
|
+
throw new Error("JWT_SECRET must be set: the tenant is taken from a verified JWT.");
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), {
|
|
82
|
+
algorithms: ["HS256"],
|
|
83
|
+
});
|
|
84
|
+
return typeof payload.tenant_id === "string" ? payload.tenant_id : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
91
|
+
// Only this middleware may set the tenant headers.
|
|
92
|
+
const requestHeaders = new Headers(request.headers);
|
|
93
|
+
requestHeaders.delete("x-tenant-id");
|
|
94
|
+
requestHeaders.delete(TENANT_SLUG_HEADER);
|
|
95
|
+
|
|
96
|
+
// A bearer token that does not verify is rejected, never ignored.
|
|
97
|
+
const authorization = request.headers.get("authorization");
|
|
98
|
+
if (authorization?.startsWith("Bearer ")) {
|
|
99
|
+
const tenantId = await verifiedTenantId(authorization.slice("Bearer ".length));
|
|
100
|
+
if (!tenantId) {
|
|
101
|
+
return NextResponse.json(
|
|
102
|
+
{ error: { code: "INVALID_TOKEN", message: "Bearer token is invalid or has no tenant_id claim" } },
|
|
103
|
+
{ status: 401 },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
requestHeaders.set(TENANT_ID_HEADER, tenantId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Subdomain, e.g. "acme" from "acme.app.example.com": a slug, not an identity.
|
|
110
|
+
const hostname = (request.headers.get("host") ?? "").split(":")[0];
|
|
111
|
+
const rootDomain = process.env.ROOT_DOMAIN ?? "app.example.com";
|
|
112
|
+
if (hostname.endsWith(\`.\${rootDomain}\`)) {
|
|
113
|
+
const subdomain = hostname.slice(0, hostname.length - rootDomain.length - 1);
|
|
114
|
+
if (subdomain && subdomain !== "www") {
|
|
115
|
+
requestHeaders.set(TENANT_SLUG_HEADER, subdomain);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// With no verified tenant the request continues without x-tenant-id. Each
|
|
120
|
+
// route decides whether to require a tenant or serve a public page.
|
|
121
|
+
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const config = {
|
|
125
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
126
|
+
};
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
8
130
|
export function generateMiddleware(projectName: string, preset: StackPreset): MiddlewareFile[] {
|
|
9
131
|
switch (preset.framework) {
|
|
10
132
|
case "express":
|
|
@@ -27,19 +149,21 @@ function generateExpressMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
27
149
|
{
|
|
28
150
|
filename: "src/index.ts",
|
|
29
151
|
content: `import express from "express";
|
|
152
|
+
${VERIFIED_TENANT}
|
|
30
153
|
|
|
31
154
|
const app = express();
|
|
32
155
|
const port = Number(process.env.PORT) || 3000;
|
|
33
156
|
|
|
34
157
|
app.use(express.json());
|
|
35
158
|
|
|
36
|
-
// Tenant
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
159
|
+
// Tenant resolution. The tenant comes only from a verified bearer token;
|
|
160
|
+
// a token that does not verify is rejected with 401.
|
|
161
|
+
app.use(async (req, res, next) => {
|
|
162
|
+
const { tenantId, invalid } = await verifiedTenant(req.headers.authorization);
|
|
163
|
+
if (invalid) {
|
|
164
|
+
res.status(401).json(${INVALID_TOKEN});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
43
167
|
(req as any).tenantId = tenantId;
|
|
44
168
|
next();
|
|
45
169
|
});
|
|
@@ -50,6 +174,10 @@ app.get("/health", (_req, res) => {
|
|
|
50
174
|
|
|
51
175
|
app.get("/tenants", async (req, res) => {
|
|
52
176
|
const tenantId = (req as any).tenantId;
|
|
177
|
+
if (!tenantId) {
|
|
178
|
+
res.status(401).json(${TENANT_REQUIRED});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
53
181
|
res.json({ tenantId, message: "Replace with your tenant queries" });
|
|
54
182
|
});
|
|
55
183
|
|
|
@@ -66,18 +194,19 @@ function generateFastifyMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
66
194
|
{
|
|
67
195
|
filename: "src/index.ts",
|
|
68
196
|
content: `import Fastify from "fastify";
|
|
197
|
+
${VERIFIED_TENANT}
|
|
69
198
|
|
|
70
199
|
const fastify = Fastify({ logger: true });
|
|
71
200
|
const port = Number(process.env.PORT) || 3000;
|
|
72
201
|
|
|
73
|
-
// Tenant
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
202
|
+
// Tenant resolution. The tenant comes only from a verified bearer token;
|
|
203
|
+
// a token that does not verify is rejected with 401.
|
|
204
|
+
fastify.decorateRequest("tenantId", null);
|
|
205
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
206
|
+
const { tenantId, invalid } = await verifiedTenant(request.headers.authorization);
|
|
207
|
+
if (invalid) {
|
|
208
|
+
return reply.status(401).send(${INVALID_TOKEN});
|
|
209
|
+
}
|
|
81
210
|
(request as any).tenantId = tenantId;
|
|
82
211
|
});
|
|
83
212
|
|
|
@@ -85,8 +214,11 @@ fastify.get("/health", async () => {
|
|
|
85
214
|
return { status: "ok", project: "${projectName}" };
|
|
86
215
|
});
|
|
87
216
|
|
|
88
|
-
fastify.get("/tenants", async (request) => {
|
|
217
|
+
fastify.get("/tenants", async (request, reply) => {
|
|
89
218
|
const tenantId = (request as any).tenantId;
|
|
219
|
+
if (!tenantId) {
|
|
220
|
+
return reply.status(401).send(${TENANT_REQUIRED});
|
|
221
|
+
}
|
|
90
222
|
return { tenantId, message: "Replace with your tenant queries" };
|
|
91
223
|
});
|
|
92
224
|
|
|
@@ -105,30 +237,7 @@ function generateNextjsMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
105
237
|
return [
|
|
106
238
|
{
|
|
107
239
|
filename: "middleware.ts",
|
|
108
|
-
content:
|
|
109
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
110
|
-
|
|
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.
|
|
116
|
-
const hostname = request.headers.get("host") || "";
|
|
117
|
-
const tenantId = hostname.split(".")[0];
|
|
118
|
-
|
|
119
|
-
const requestHeaders = new Headers(request.headers);
|
|
120
|
-
requestHeaders.delete("x-tenant-id");
|
|
121
|
-
if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
|
|
122
|
-
requestHeaders.set("x-tenant-id", tenantId);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export const config = {
|
|
129
|
-
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
130
|
-
};
|
|
131
|
-
`,
|
|
240
|
+
content: nextjsTenantMiddleware(),
|
|
132
241
|
},
|
|
133
242
|
{
|
|
134
243
|
filename: "src/app/page.tsx",
|
|
@@ -140,7 +249,7 @@ export default function Home() {
|
|
|
140
249
|
<p>Multi-tenant app powered by Stratum.</p>
|
|
141
250
|
<ul>
|
|
142
251
|
<li>Configure tenants via the Stratum control plane</li>
|
|
143
|
-
<li>
|
|
252
|
+
<li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
|
|
144
253
|
<li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
|
|
145
254
|
</ul>
|
|
146
255
|
</main>
|
|
@@ -157,16 +266,17 @@ function generateHonoMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
157
266
|
filename: "src/index.ts",
|
|
158
267
|
content: `import { Hono } from "hono";
|
|
159
268
|
import { serve } from "@hono/node-server";
|
|
269
|
+
${VERIFIED_TENANT}
|
|
160
270
|
|
|
161
|
-
const app = new Hono();
|
|
271
|
+
const app = new Hono<{ Variables: { tenantId: string | null } }>();
|
|
162
272
|
|
|
163
|
-
// Tenant
|
|
164
|
-
//
|
|
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.
|
|
273
|
+
// Tenant resolution. The tenant comes only from a verified bearer token;
|
|
274
|
+
// a token that does not verify is rejected with 401.
|
|
168
275
|
app.use("*", async (c, next) => {
|
|
169
|
-
const tenantId =
|
|
276
|
+
const { tenantId, invalid } = await verifiedTenant(c.req.header("authorization"));
|
|
277
|
+
if (invalid) {
|
|
278
|
+
return c.json(${INVALID_TOKEN}, 401);
|
|
279
|
+
}
|
|
170
280
|
c.set("tenantId", tenantId);
|
|
171
281
|
await next();
|
|
172
282
|
});
|
|
@@ -177,6 +287,9 @@ app.get("/health", (c) => {
|
|
|
177
287
|
|
|
178
288
|
app.get("/tenants", (c) => {
|
|
179
289
|
const tenantId = c.get("tenantId");
|
|
290
|
+
if (!tenantId) {
|
|
291
|
+
return c.json(${TENANT_REQUIRED}, 401);
|
|
292
|
+
}
|
|
180
293
|
return c.json({ tenantId, message: "Replace with your tenant queries" });
|
|
181
294
|
});
|
|
182
295
|
|
|
@@ -222,7 +335,7 @@ export class AppModule {}
|
|
|
222
335
|
},
|
|
223
336
|
{
|
|
224
337
|
filename: "src/app.controller.ts",
|
|
225
|
-
content: `import { Controller, Get, Req } from "@nestjs/common";
|
|
338
|
+
content: `import { Controller, Get, Req, UnauthorizedException } from "@nestjs/common";
|
|
226
339
|
|
|
227
340
|
@Controller()
|
|
228
341
|
export class AppController {
|
|
@@ -233,6 +346,9 @@ export class AppController {
|
|
|
233
346
|
|
|
234
347
|
@Get("tenants")
|
|
235
348
|
tenants(@Req() req: any) {
|
|
349
|
+
if (!req.tenantId) {
|
|
350
|
+
throw new UnauthorizedException("A bearer token with a tenant_id claim is required");
|
|
351
|
+
}
|
|
236
352
|
return { tenantId: req.tenantId, message: "Replace with your tenant queries" };
|
|
237
353
|
}
|
|
238
354
|
}
|
|
@@ -240,18 +356,22 @@ export class AppController {
|
|
|
240
356
|
},
|
|
241
357
|
{
|
|
242
358
|
filename: "src/tenant.guard.ts",
|
|
243
|
-
content: `import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
|
359
|
+
content: `import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
360
|
+
${VERIFIED_TENANT}
|
|
244
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Sets request.tenantId from a verified bearer token, or null when there is
|
|
364
|
+
* no token. A token that does not verify is rejected with 401.
|
|
365
|
+
*/
|
|
245
366
|
@Injectable()
|
|
246
367
|
export class TenantGuard implements CanActivate {
|
|
247
|
-
canActivate(context: ExecutionContext): boolean {
|
|
368
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
248
369
|
const request = context.switchToHttp().getRequest();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
request.tenantId = tenantId || null;
|
|
370
|
+
const { tenantId, invalid } = await verifiedTenant(request.headers?.authorization);
|
|
371
|
+
if (invalid) {
|
|
372
|
+
throw new UnauthorizedException("Bearer token is invalid or has no tenant_id claim");
|
|
373
|
+
}
|
|
374
|
+
request.tenantId = tenantId;
|
|
255
375
|
return true;
|
|
256
376
|
}
|
|
257
377
|
}
|
|
@@ -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
|
}
|
|
@@ -106,6 +119,10 @@ function addOrmDeps(deps: Record<string, string>, devDeps: Record<string, string
|
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
function addFrameworkDeps(deps: Record<string, string>, devDeps: Record<string, string>, preset: StackPreset): void {
|
|
122
|
+
// The generated tenant resolution verifies the tenant JWT with jose.
|
|
123
|
+
if (preset.framework !== "none") {
|
|
124
|
+
deps["jose"] = "^6.2.12";
|
|
125
|
+
}
|
|
109
126
|
switch (preset.framework) {
|
|
110
127
|
case "express":
|
|
111
128
|
deps["express"] = "^4.18.0";
|
|
@@ -139,19 +156,19 @@ function addFrameworkDeps(deps: Record<string, string>, devDeps: Record<string,
|
|
|
139
156
|
|
|
140
157
|
function addStratumDeps(deps: Record<string, string>, preset: StackPreset): void {
|
|
141
158
|
if (preset.database === "postgres" && preset.orm !== "mongoose") {
|
|
142
|
-
deps["@stratum-hq/db-adapters"] = "
|
|
159
|
+
deps["@stratum-hq/db-adapters"] = STRATUM_RANGES["@stratum-hq/db-adapters"];
|
|
143
160
|
}
|
|
144
161
|
if (preset.database === "mongodb") {
|
|
145
|
-
deps["@stratum-hq/mongodb"] = "
|
|
162
|
+
deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
|
|
146
163
|
}
|
|
147
164
|
if (preset.database === "mysql") {
|
|
148
|
-
deps["@stratum-hq/mysql"] = "
|
|
165
|
+
deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
|
|
149
166
|
}
|
|
150
167
|
if (preset.framework === "hono") {
|
|
151
|
-
deps["@stratum-hq/hono"] = "
|
|
168
|
+
deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
|
|
152
169
|
}
|
|
153
170
|
if (preset.framework === "nestjs") {
|
|
154
|
-
deps["@stratum-hq/nestjs"] = "
|
|
171
|
+
deps["@stratum-hq/nestjs"] = STRATUM_RANGES["@stratum-hq/nestjs"];
|
|
155
172
|
}
|
|
156
173
|
}
|
|
157
174
|
|
|
@@ -159,10 +176,16 @@ function getScripts(preset: StackPreset): Record<string, string> {
|
|
|
159
176
|
if (preset.framework === "nextjs") {
|
|
160
177
|
return { dev: "next dev", build: "next build", start: "next start" };
|
|
161
178
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
179
|
+
const entry = preset.framework === "nestjs" ? "main" : "index";
|
|
180
|
+
// When tsc compiles from the project root, src/ is emitted to dist/src/.
|
|
181
|
+
const emittedDir = rootSources(preset).length > 0 ? "dist/src" : "dist";
|
|
182
|
+
// Node 20 cannot run a .ts file. NestJS compiles with tsc, which keeps the
|
|
183
|
+
// decorator metadata its injection needs; the other frameworks run through tsx.
|
|
184
|
+
const dev =
|
|
185
|
+
preset.framework === "nestjs"
|
|
186
|
+
? `tsc-watch --onSuccess "node --env-file=.env ${emittedDir}/${entry}.js"`
|
|
187
|
+
: `tsx watch --env-file=.env src/${entry}.ts`;
|
|
188
|
+
return { dev, build: "tsc", start: `node ${emittedDir}/${entry}.js` };
|
|
166
189
|
}
|
|
167
190
|
|
|
168
191
|
function sortKeys(obj: Record<string, string>): Record<string, string> {
|