@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratum-hq/create",
3
- "version": "0.4.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/index.js"
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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ // The bin calls main() without a check on process.argv[1]. npm runs the bin
4
+ // through a symlink, so a compare of argv[1] with the module path is false.
5
+ import { main } from "./index.js";
6
+
7
+ main(process.argv.slice(2));
@@ -203,60 +203,120 @@ export default config;
203
203
  },
204
204
  {
205
205
  filename: "src/stratum-knex.ts",
206
- content: `// Knex with Stratum tenant-scoped queries
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
- ${preset.database === "postgres" ? ` await knex.raw("SET app.current_tenant = ?", [tenantId]);` : ` // For ${preset.database}, scope queries by tenant_id column`}
216
- try {
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
- function generateMongooseSetup(_preset: StackPreset): DbSetupFile[] {
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
- // Create a tenant-scoped connection.
243
- // Each tenant gets its own database (database-per-tenant strategy)
244
- // or collection prefix (collection strategy).
245
- export function getTenantConnection(tenantId: string) {
246
- return createTenantConnection(mainConnection, tenantId);
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(tenantId: string, sql: string, params: unknown[] = []) {
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
- // Sets app.current_tenant on each connection for RLS enforcement.
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 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.
41
- app.use((req, _res, next) => {
42
- const tenantId = req.hostname.split(".")[0];
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 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.
78
- fastify.decorateRequest("tenantId", "");
79
- fastify.addHook("onRequest", async (request) => {
80
- const tenantId = request.hostname?.split(".")[0] ?? "";
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: `// Next.js edge middleware for tenant resolution
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>Tenant is resolved from the subdomain in <code>middleware.ts</code></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 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.
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 = new URL(c.req.url).hostname.split(".")[0];
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
- // 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];
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": "^0.2.0",
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"] = "^0.2.0";
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"] = "^0.2.0";
162
+ deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
146
163
  }
147
164
  if (preset.database === "mysql") {
148
- deps["@stratum-hq/mysql"] = "^0.2.0";
165
+ deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
149
166
  }
150
167
  if (preset.framework === "hono") {
151
- deps["@stratum-hq/hono"] = "^0.2.0";
168
+ deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
152
169
  }
153
170
  if (preset.framework === "nestjs") {
154
- deps["@stratum-hq/nestjs"] = "^0.2.0";
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
- if (preset.framework === "nestjs") {
163
- return { dev: "node --watch src/main.ts", build: "tsc", start: "node dist/main.js" };
164
- }
165
- return { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" };
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> {