@stratum-hq/create 0.4.1 → 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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @stratum-hq/create
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4c1686a: Generated servers and Next.js middleware take the tenant from a verified JWT instead of the hostname (GHSA-p4wg-j8hh-9wh8).
8
+
3
9
  ## 0.4.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -30,7 +30,7 @@ npx @stratum-hq/create my-app [options]
30
30
 
31
31
  - **express** (default) — Express server with Stratum middleware, tenant-aware routes, and TypeScript config.
32
32
  - **fastify** — Fastify server with the Stratum plugin registered.
33
- - **nextjs** — Next.js project with edge middleware for tenant resolution and server-side helpers.
33
+ - **nextjs** — Next.js project with edge middleware that resolves the tenant from a verified JWT.
34
34
 
35
35
  ## After Scaffolding
36
36
 
@@ -43,6 +43,10 @@ npm run dev # run the app
43
43
 
44
44
  The generated starter code does not create a `Stratum` instance, so it does not create Stratum's tables. To create them, construct `Stratum` with `autoMigrate: true` and call `initialize()` once at startup.
45
45
 
46
+ ## Tenant resolution
47
+
48
+ Generated servers (the Express, Fastify, Hono and NestJS presets) and the Next.js middleware take the tenant ID only from the `tenant_id` claim of a bearer token that verifies with `JWT_SECRET` (HS256, using `jose`, which the generated `package.json` lists). A token that does not verify, or has no `tenant_id` claim, is rejected with 401. The tenant is never taken from the hostname or from a client-supplied header such as `x-tenant-id`. In the Next.js middleware the subdomain is forwarded as `x-tenant-slug`, a display hint that does not identify the caller's tenant.
49
+
46
50
  ## Links
47
51
 
48
52
  - Documentation: https://docs.stratum-hq.org/packages/create/
package/dist/index.js CHANGED
@@ -610,6 +610,114 @@ export { pool };
610
610
  }
611
611
 
612
612
  // src/generators/middleware.ts
613
+ var VERIFIED_TENANT = `import { jwtVerify } from "jose";
614
+
615
+ const jwtSecret = process.env.JWT_SECRET;
616
+ if (!jwtSecret) {
617
+ throw new Error("JWT_SECRET must be set: the tenant is taken from a verified JWT.");
618
+ }
619
+ const jwtKey = new TextEncoder().encode(jwtSecret);
620
+
621
+ /**
622
+ * The tenant for a request, from the tenant_id claim of a bearer token that
623
+ * verifies with JWT_SECRET. tenantId is null when there is no bearer token.
624
+ * invalid is true when a token was sent but does not verify or has no
625
+ * tenant_id claim. Never take the tenant from the hostname or from a header
626
+ * such as x-tenant-id: any caller can choose those.
627
+ */
628
+ async function verifiedTenant(
629
+ authorization: string | undefined,
630
+ ): Promise<{ tenantId: string | null; invalid: boolean }> {
631
+ if (!authorization?.startsWith("Bearer ")) return { tenantId: null, invalid: false };
632
+ try {
633
+ const { payload } = await jwtVerify(authorization.slice("Bearer ".length), jwtKey, {
634
+ algorithms: ["HS256"],
635
+ });
636
+ if (typeof payload.tenant_id === "string") return { tenantId: payload.tenant_id, invalid: false };
637
+ } catch {
638
+ // Fall through: a token that does not verify is rejected, never ignored.
639
+ }
640
+ return { tenantId: null, invalid: true };
641
+ }`;
642
+ var INVALID_TOKEN = `{ error: "Bearer token is invalid or has no tenant_id claim" }`;
643
+ var TENANT_REQUIRED = `{ error: "A bearer token with a tenant_id claim is required" }`;
644
+ function nextjsTenantMiddleware() {
645
+ return `// middleware.ts (place in project root)
646
+ // Next.js middleware for Stratum tenant resolution
647
+ //
648
+ // The tenant ID comes only from the tenant_id claim of a bearer token that
649
+ // verifies with JWT_SECRET, and is forwarded as x-tenant-id. Any copy of the
650
+ // tenant headers the client sent is removed first, so server code only ever
651
+ // reads the values set here.
652
+ //
653
+ // The subdomain (acme.app.example.com) is forwarded as x-tenant-slug. It only
654
+ // says which tenant's public pages to show. It does not prove the caller
655
+ // belongs to that tenant, so never use it to read or write tenant data.
656
+
657
+ import { NextRequest, NextResponse } from "next/server";
658
+ import { jwtVerify } from "jose";
659
+
660
+ const TENANT_ID_HEADER = "x-tenant-id";
661
+ const TENANT_SLUG_HEADER = "x-tenant-slug";
662
+
663
+ /**
664
+ * The tenant_id claim of a token that verifies with JWT_SECRET, or null when
665
+ * the token is invalid, expired, or has no string tenant_id claim.
666
+ */
667
+ async function verifiedTenantId(token: string): Promise<string | null> {
668
+ const secret = process.env.JWT_SECRET;
669
+ if (!secret) {
670
+ throw new Error("JWT_SECRET must be set: the tenant is taken from a verified JWT.");
671
+ }
672
+ try {
673
+ const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), {
674
+ algorithms: ["HS256"],
675
+ });
676
+ return typeof payload.tenant_id === "string" ? payload.tenant_id : null;
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+
682
+ export async function middleware(request: NextRequest): Promise<NextResponse> {
683
+ // Only this middleware may set the tenant headers.
684
+ const requestHeaders = new Headers(request.headers);
685
+ requestHeaders.delete("x-tenant-id");
686
+ requestHeaders.delete(TENANT_SLUG_HEADER);
687
+
688
+ // A bearer token that does not verify is rejected, never ignored.
689
+ const authorization = request.headers.get("authorization");
690
+ if (authorization?.startsWith("Bearer ")) {
691
+ const tenantId = await verifiedTenantId(authorization.slice("Bearer ".length));
692
+ if (!tenantId) {
693
+ return NextResponse.json(
694
+ { error: { code: "INVALID_TOKEN", message: "Bearer token is invalid or has no tenant_id claim" } },
695
+ { status: 401 },
696
+ );
697
+ }
698
+ requestHeaders.set(TENANT_ID_HEADER, tenantId);
699
+ }
700
+
701
+ // Subdomain, e.g. "acme" from "acme.app.example.com": a slug, not an identity.
702
+ const hostname = (request.headers.get("host") ?? "").split(":")[0];
703
+ const rootDomain = process.env.ROOT_DOMAIN ?? "app.example.com";
704
+ if (hostname.endsWith(\`.\${rootDomain}\`)) {
705
+ const subdomain = hostname.slice(0, hostname.length - rootDomain.length - 1);
706
+ if (subdomain && subdomain !== "www") {
707
+ requestHeaders.set(TENANT_SLUG_HEADER, subdomain);
708
+ }
709
+ }
710
+
711
+ // With no verified tenant the request continues without x-tenant-id. Each
712
+ // route decides whether to require a tenant or serve a public page.
713
+ return NextResponse.next({ request: { headers: requestHeaders } });
714
+ }
715
+
716
+ export const config = {
717
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
718
+ };
719
+ `;
720
+ }
613
721
  function generateMiddleware(projectName, preset) {
614
722
  switch (preset.framework) {
615
723
  case "express":
@@ -631,19 +739,21 @@ function generateExpressMiddleware(projectName) {
631
739
  {
632
740
  filename: "src/index.ts",
633
741
  content: `import express from "express";
742
+ ${VERIFIED_TENANT}
634
743
 
635
744
  const app = express();
636
745
  const port = Number(process.env.PORT) || 3000;
637
746
 
638
747
  app.use(express.json());
639
748
 
640
- // Tenant extraction middleware. The tenant comes from the subdomain the
641
- // request was routed to. Do not take it from a client-supplied header such as
642
- // x-tenant-id: any caller can set one and pick another tenant. Once you add
643
- // authentication, check that the signed-in user belongs to this tenant, or
644
- // derive the tenant from the verified session or JWT instead.
645
- app.use((req, _res, next) => {
646
- const tenantId = req.hostname.split(".")[0];
749
+ // Tenant resolution. The tenant comes only from a verified bearer token;
750
+ // a token that does not verify is rejected with 401.
751
+ app.use(async (req, res, next) => {
752
+ const { tenantId, invalid } = await verifiedTenant(req.headers.authorization);
753
+ if (invalid) {
754
+ res.status(401).json(${INVALID_TOKEN});
755
+ return;
756
+ }
647
757
  (req as any).tenantId = tenantId;
648
758
  next();
649
759
  });
@@ -654,6 +764,10 @@ app.get("/health", (_req, res) => {
654
764
 
655
765
  app.get("/tenants", async (req, res) => {
656
766
  const tenantId = (req as any).tenantId;
767
+ if (!tenantId) {
768
+ res.status(401).json(${TENANT_REQUIRED});
769
+ return;
770
+ }
657
771
  res.json({ tenantId, message: "Replace with your tenant queries" });
658
772
  });
659
773
 
@@ -669,18 +783,19 @@ function generateFastifyMiddleware(projectName) {
669
783
  {
670
784
  filename: "src/index.ts",
671
785
  content: `import Fastify from "fastify";
786
+ ${VERIFIED_TENANT}
672
787
 
673
788
  const fastify = Fastify({ logger: true });
674
789
  const port = Number(process.env.PORT) || 3000;
675
790
 
676
- // Tenant extraction hook. The tenant comes from the subdomain the
677
- // request was routed to. Do not take it from a client-supplied header such as
678
- // x-tenant-id: any caller can set one and pick another tenant. Once you add
679
- // authentication, check that the signed-in user belongs to this tenant, or
680
- // derive the tenant from the verified session or JWT instead.
681
- fastify.decorateRequest("tenantId", "");
682
- fastify.addHook("onRequest", async (request) => {
683
- const tenantId = request.hostname?.split(".")[0] ?? "";
791
+ // Tenant resolution. The tenant comes only from a verified bearer token;
792
+ // a token that does not verify is rejected with 401.
793
+ fastify.decorateRequest("tenantId", null);
794
+ fastify.addHook("onRequest", async (request, reply) => {
795
+ const { tenantId, invalid } = await verifiedTenant(request.headers.authorization);
796
+ if (invalid) {
797
+ return reply.status(401).send(${INVALID_TOKEN});
798
+ }
684
799
  (request as any).tenantId = tenantId;
685
800
  });
686
801
 
@@ -688,8 +803,11 @@ fastify.get("/health", async () => {
688
803
  return { status: "ok", project: "${projectName}" };
689
804
  });
690
805
 
691
- fastify.get("/tenants", async (request) => {
806
+ fastify.get("/tenants", async (request, reply) => {
692
807
  const tenantId = (request as any).tenantId;
808
+ if (!tenantId) {
809
+ return reply.status(401).send(${TENANT_REQUIRED});
810
+ }
693
811
  return { tenantId, message: "Replace with your tenant queries" };
694
812
  });
695
813
 
@@ -707,30 +825,7 @@ function generateNextjsMiddleware(projectName) {
707
825
  return [
708
826
  {
709
827
  filename: "middleware.ts",
710
- content: `// Next.js edge middleware for tenant resolution
711
- import { NextRequest, NextResponse } from "next/server";
712
-
713
- export function middleware(request: NextRequest) {
714
- // The tenant comes from the subdomain the request was routed to. Any
715
- // x-tenant-id the client sent is removed first, so server code that reads
716
- // x-tenant-id only ever sees the value set here. Once you add
717
- // authentication, check that the signed-in user belongs to this tenant.
718
- const hostname = request.headers.get("host") || "";
719
- const tenantId = hostname.split(".")[0];
720
-
721
- const requestHeaders = new Headers(request.headers);
722
- requestHeaders.delete("x-tenant-id");
723
- if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
724
- requestHeaders.set("x-tenant-id", tenantId);
725
- }
726
-
727
- return NextResponse.next({ request: { headers: requestHeaders } });
728
- }
729
-
730
- export const config = {
731
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
732
- };
733
- `
828
+ content: nextjsTenantMiddleware()
734
829
  },
735
830
  {
736
831
  filename: "src/app/page.tsx",
@@ -742,7 +837,7 @@ export default function Home() {
742
837
  <p>Multi-tenant app powered by Stratum.</p>
743
838
  <ul>
744
839
  <li>Configure tenants via the Stratum control plane</li>
745
- <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
840
+ <li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
746
841
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
747
842
  </ul>
748
843
  </main>
@@ -758,16 +853,17 @@ function generateHonoMiddleware(projectName) {
758
853
  filename: "src/index.ts",
759
854
  content: `import { Hono } from "hono";
760
855
  import { serve } from "@hono/node-server";
856
+ ${VERIFIED_TENANT}
761
857
 
762
- const app = new Hono();
858
+ const app = new Hono<{ Variables: { tenantId: string | null } }>();
763
859
 
764
- // Tenant extraction middleware. The tenant comes from the subdomain the
765
- // request was routed to. Do not take it from a client-supplied header such as
766
- // x-tenant-id: any caller can set one and pick another tenant. Once you add
767
- // authentication, check that the signed-in user belongs to this tenant, or
768
- // derive the tenant from the verified session or JWT instead.
860
+ // Tenant resolution. The tenant comes only from a verified bearer token;
861
+ // a token that does not verify is rejected with 401.
769
862
  app.use("*", async (c, next) => {
770
- const tenantId = new URL(c.req.url).hostname.split(".")[0];
863
+ const { tenantId, invalid } = await verifiedTenant(c.req.header("authorization"));
864
+ if (invalid) {
865
+ return c.json(${INVALID_TOKEN}, 401);
866
+ }
771
867
  c.set("tenantId", tenantId);
772
868
  await next();
773
869
  });
@@ -778,6 +874,9 @@ app.get("/health", (c) => {
778
874
 
779
875
  app.get("/tenants", (c) => {
780
876
  const tenantId = c.get("tenantId");
877
+ if (!tenantId) {
878
+ return c.json(${TENANT_REQUIRED}, 401);
879
+ }
781
880
  return c.json({ tenantId, message: "Replace with your tenant queries" });
782
881
  });
783
882
 
@@ -822,7 +921,7 @@ export class AppModule {}
822
921
  },
823
922
  {
824
923
  filename: "src/app.controller.ts",
825
- content: `import { Controller, Get, Req } from "@nestjs/common";
924
+ content: `import { Controller, Get, Req, UnauthorizedException } from "@nestjs/common";
826
925
 
827
926
  @Controller()
828
927
  export class AppController {
@@ -833,6 +932,9 @@ export class AppController {
833
932
 
834
933
  @Get("tenants")
835
934
  tenants(@Req() req: any) {
935
+ if (!req.tenantId) {
936
+ throw new UnauthorizedException("A bearer token with a tenant_id claim is required");
937
+ }
836
938
  return { tenantId: req.tenantId, message: "Replace with your tenant queries" };
837
939
  }
838
940
  }
@@ -840,18 +942,22 @@ export class AppController {
840
942
  },
841
943
  {
842
944
  filename: "src/tenant.guard.ts",
843
- content: `import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
945
+ content: `import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
946
+ ${VERIFIED_TENANT}
844
947
 
948
+ /**
949
+ * Sets request.tenantId from a verified bearer token, or null when there is
950
+ * no token. A token that does not verify is rejected with 401.
951
+ */
845
952
  @Injectable()
846
953
  export class TenantGuard implements CanActivate {
847
- canActivate(context: ExecutionContext): boolean {
954
+ async canActivate(context: ExecutionContext): Promise<boolean> {
848
955
  const request = context.switchToHttp().getRequest();
849
- // The tenant comes from the subdomain the request was routed to. Do not
850
- // take it from a client-supplied header such as x-tenant-id: any caller
851
- // can set one and pick another tenant. Once you add authentication, check
852
- // that the signed-in user belongs to this tenant here.
853
- const tenantId = request.hostname?.split(".")[0];
854
- request.tenantId = tenantId || null;
956
+ const { tenantId, invalid } = await verifiedTenant(request.headers?.authorization);
957
+ if (invalid) {
958
+ throw new UnauthorizedException("Bearer token is invalid or has no tenant_id claim");
959
+ }
960
+ request.tenantId = tenantId;
855
961
  return true;
856
962
  }
857
963
  }
@@ -906,7 +1012,7 @@ function generateTsconfig(framework, extraSources = []) {
906
1012
  // ../hono/package.json
907
1013
  var package_default = {
908
1014
  name: "@stratum-hq/hono",
909
- version: "1.1.0",
1015
+ version: "1.2.0",
910
1016
  description: "Stratum Hono integration \u2014 tenant extraction middleware with ALS context",
911
1017
  keywords: [
912
1018
  "multi-tenancy",
@@ -1048,7 +1154,7 @@ var package_default2 = {
1048
1154
  // ../lib/package.json
1049
1155
  var package_default3 = {
1050
1156
  name: "@stratum-hq/lib",
1051
- version: "1.4.0",
1157
+ version: "1.5.0",
1052
1158
  description: "Stratum tenant management library - framework-agnostic business logic",
1053
1159
  keywords: [
1054
1160
  "multi-tenancy",
@@ -1094,9 +1200,9 @@ var package_default3 = {
1094
1200
  },
1095
1201
  dependencies: {
1096
1202
  "@opentelemetry/api": "^1.9.0",
1097
- "@stratum-hq/core": "^1.4.0",
1203
+ "@stratum-hq/core": "^1.5.0",
1098
1204
  "@stratum-hq/db-adapters": "^1.2.0",
1099
- "@stratum-hq/sdk": "^1.2.0",
1205
+ "@stratum-hq/sdk": "^1.3.0",
1100
1206
  pg: "^8.11.0"
1101
1207
  },
1102
1208
  peerDependencies: {
@@ -1129,7 +1235,7 @@ var package_default3 = {
1129
1235
  // ../mongodb/package.json
1130
1236
  var package_default4 = {
1131
1237
  name: "@stratum-hq/mongodb",
1132
- version: "0.4.0",
1238
+ version: "0.5.0",
1133
1239
  description: "MongoDB tenant isolation for Stratum \u2014 shared collection, collection-per-tenant, and database-per-tenant strategies",
1134
1240
  keywords: [
1135
1241
  "multi-tenancy",
@@ -1165,8 +1271,8 @@ var package_default4 = {
1165
1271
  clean: "rm -rf dist"
1166
1272
  },
1167
1273
  dependencies: {
1168
- "@stratum-hq/core": "^1.4.0",
1169
- "@stratum-hq/sdk": "^1.2.0"
1274
+ "@stratum-hq/core": "^1.5.0",
1275
+ "@stratum-hq/sdk": "^1.3.0"
1170
1276
  },
1171
1277
  peerDependencies: {
1172
1278
  mongodb: "^6.0.0 || ^7.0.0"
@@ -1203,7 +1309,7 @@ var package_default4 = {
1203
1309
  // ../mysql/package.json
1204
1310
  var package_default5 = {
1205
1311
  name: "@stratum-hq/mysql",
1206
- version: "0.4.0",
1312
+ version: "0.5.0",
1207
1313
  description: "MySQL tenant isolation for Stratum \u2014 shared table, table-per-tenant, and database-per-tenant strategies",
1208
1314
  keywords: [
1209
1315
  "multi-tenancy",
@@ -1241,8 +1347,8 @@ var package_default5 = {
1241
1347
  clean: "rm -rf dist"
1242
1348
  },
1243
1349
  dependencies: {
1244
- "@stratum-hq/core": "^1.4.0",
1245
- "@stratum-hq/sdk": "^1.2.0"
1350
+ "@stratum-hq/core": "^1.5.0",
1351
+ "@stratum-hq/sdk": "^1.3.0"
1246
1352
  },
1247
1353
  peerDependencies: {
1248
1354
  mysql2: "^3.0.0",
@@ -1288,7 +1394,7 @@ var package_default5 = {
1288
1394
  // ../nestjs/package.json
1289
1395
  var package_default6 = {
1290
1396
  name: "@stratum-hq/nestjs",
1291
- version: "1.2.0",
1397
+ version: "1.3.0",
1292
1398
  description: "Stratum NestJS integration \u2014 StratumGuard, @Tenant() decorator, and StratumModule",
1293
1399
  keywords: [
1294
1400
  "multi-tenancy",
@@ -1332,7 +1438,7 @@ var package_default6 = {
1332
1438
  "@nestjs/common": ">=10.0.0",
1333
1439
  "@nestjs/core": ">=10.0.0",
1334
1440
  "@stratum-hq/core": "^1.0.0",
1335
- "@stratum-hq/sdk": "^1.2.0",
1441
+ "@stratum-hq/sdk": "^1.3.0",
1336
1442
  "reflect-metadata": ">=0.1.13"
1337
1443
  },
1338
1444
  devDependencies: {
@@ -1468,6 +1574,9 @@ function addOrmDeps(deps, devDeps, preset) {
1468
1574
  }
1469
1575
  }
1470
1576
  function addFrameworkDeps(deps, devDeps, preset) {
1577
+ if (preset.framework !== "none") {
1578
+ deps["jose"] = "^6.2.12";
1579
+ }
1471
1580
  switch (preset.framework) {
1472
1581
  case "express":
1473
1582
  deps["express"] = "^4.18.0";
@@ -1819,7 +1928,9 @@ function generatePackageJson(projectName, template) {
1819
1928
  react: "^19.0.0",
1820
1929
  "react-dom": "^19.0.0",
1821
1930
  "@types/react": "^19.0.0",
1822
- "@types/react-dom": "^19.0.0"
1931
+ "@types/react-dom": "^19.0.0",
1932
+ // The middleware verifies the tenant JWT with jose.
1933
+ jose: "^6.2.12"
1823
1934
  }
1824
1935
  };
1825
1936
  const deps = {
@@ -1990,7 +2101,7 @@ export default function Home() {
1990
2101
  <p>Multi-tenant app powered by Stratum.</p>
1991
2102
  <ul>
1992
2103
  <li>Configure tenants via the Stratum control plane</li>
1993
- <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
2104
+ <li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
1994
2105
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
1995
2106
  </ul>
1996
2107
  </main>
@@ -1998,32 +2109,6 @@ export default function Home() {
1998
2109
  }
1999
2110
  `;
2000
2111
  }
2001
- function generateNextjsMiddleware2() {
2002
- return `// middleware.ts \u2014 tenant resolution via subdomain
2003
- import { NextRequest, NextResponse } from "next/server";
2004
-
2005
- export function middleware(request: NextRequest) {
2006
- // The tenant comes from the subdomain the request was routed to. Any
2007
- // x-tenant-id the client sent is removed first, so server code that reads
2008
- // x-tenant-id only ever sees the value set here. Once you add
2009
- // authentication, check that the signed-in user belongs to this tenant.
2010
- const hostname = request.headers.get("host") || "";
2011
- const tenantId = hostname.split(".")[0];
2012
-
2013
- const requestHeaders = new Headers(request.headers);
2014
- requestHeaders.delete("x-tenant-id");
2015
- if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
2016
- requestHeaders.set("x-tenant-id", tenantId);
2017
- }
2018
-
2019
- return NextResponse.next({ request: { headers: requestHeaders } });
2020
- }
2021
-
2022
- export const config = {
2023
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
2024
- };
2025
- `;
2026
- }
2027
2112
  function generateReadme(projectName, template) {
2028
2113
  return `# ${projectName}
2029
2114
 
@@ -2071,7 +2156,7 @@ ${template === "nextjs" ? "" : "\u251C\u2500\u2500 tsconfig.json\n"}\u2514\u2500
2071
2156
 
2072
2157
  This project uses Stratum for hierarchical multi-tenancy:
2073
2158
 
2074
- - **Tenant resolution** \u2014 via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
2159
+ - **Tenant resolution** \u2014 from the \`tenant_id\` claim of a bearer token verified with \`JWT_SECRET\` (see \`middleware.ts\`); the subdomain is only a display slug
2075
2160
  - **Config inheritance** \u2014 settings flow down the tenant tree with override support
2076
2161
  - **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
2077
2162
 
@@ -2097,7 +2182,7 @@ Creating ${projectName} with ${template} template...
2097
2182
  writeFile2(path2.join(targetDir, "tsconfig.json"), generateTsconfig(template));
2098
2183
  } else if (template === "nextjs") {
2099
2184
  writeFile2(path2.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
2100
- writeFile2(path2.join(targetDir, "middleware.ts"), generateNextjsMiddleware2());
2185
+ writeFile2(path2.join(targetDir, "middleware.ts"), nextjsTenantMiddleware());
2101
2186
  }
2102
2187
  writeFile2(path2.join(targetDir, "README.md"), generateReadme(projectName, template));
2103
2188
  if (!skipInstall) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratum-hq/create",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Create a new Stratum multi-tenancy project",
5
5
  "keywords": [
6
6
  "multi-tenancy",
@@ -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
  }
@@ -119,6 +119,10 @@ function addOrmDeps(deps: Record<string, string>, devDeps: Record<string, string
119
119
  }
120
120
 
121
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
+ }
122
126
  switch (preset.framework) {
123
127
  case "express":
124
128
  deps["express"] = "^4.18.0";
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { createPresetProject } from "./preset-project.js";
7
7
  import { STRATUM_RANGES } from "./stratum-versions.js";
8
8
  import { postgresAppRole, postgresAppRoleSql, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
9
9
  import { generateTsconfig } from "./generators/tsconfig.js";
10
+ import { nextjsTenantMiddleware } from "./generators/middleware.js";
10
11
 
11
12
  // ─── Types ────────────────────────────────────────────────────────────────────
12
13
 
@@ -122,6 +123,8 @@ function generatePackageJson(projectName: string, template: Template): string {
122
123
  "react-dom": "^19.0.0",
123
124
  "@types/react": "^19.0.0",
124
125
  "@types/react-dom": "^19.0.0",
126
+ // The middleware verifies the tenant JWT with jose.
127
+ jose: "^6.2.12",
125
128
  },
126
129
  };
127
130
 
@@ -301,7 +304,7 @@ export default function Home() {
301
304
  <p>Multi-tenant app powered by Stratum.</p>
302
305
  <ul>
303
306
  <li>Configure tenants via the Stratum control plane</li>
304
- <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
307
+ <li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
305
308
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
306
309
  </ul>
307
310
  </main>
@@ -310,33 +313,6 @@ export default function Home() {
310
313
  `;
311
314
  }
312
315
 
313
- function generateNextjsMiddleware(): string {
314
- return `// middleware.ts — tenant resolution via subdomain
315
- import { NextRequest, NextResponse } from "next/server";
316
-
317
- export function middleware(request: NextRequest) {
318
- // The tenant comes from the subdomain the request was routed to. Any
319
- // x-tenant-id the client sent is removed first, so server code that reads
320
- // x-tenant-id only ever sees the value set here. Once you add
321
- // authentication, check that the signed-in user belongs to this tenant.
322
- const hostname = request.headers.get("host") || "";
323
- const tenantId = hostname.split(".")[0];
324
-
325
- const requestHeaders = new Headers(request.headers);
326
- requestHeaders.delete("x-tenant-id");
327
- if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
328
- requestHeaders.set("x-tenant-id", tenantId);
329
- }
330
-
331
- return NextResponse.next({ request: { headers: requestHeaders } });
332
- }
333
-
334
- export const config = {
335
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
336
- };
337
- `;
338
- }
339
-
340
316
  function generateReadme(projectName: string, template: Template): string {
341
317
  return `# ${projectName}
342
318
 
@@ -384,7 +360,7 @@ ${template === "nextjs" ? "" : "├── tsconfig.json\n"}└── package.jso
384
360
 
385
361
  This project uses Stratum for hierarchical multi-tenancy:
386
362
 
387
- - **Tenant resolution** — via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
363
+ - **Tenant resolution** — from the \`tenant_id\` claim of a bearer token verified with \`JWT_SECRET\` (see \`middleware.ts\`); the subdomain is only a display slug
388
364
  - **Config inheritance** — settings flow down the tenant tree with override support
389
365
  - **Permission ABAC** — role-based permissions with tenant-scoped enforcement
390
366
 
@@ -426,7 +402,7 @@ export function createProject(
426
402
  writeFile(path.join(targetDir, "tsconfig.json"), generateTsconfig(template));
427
403
  } else if (template === "nextjs") {
428
404
  writeFile(path.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
429
- writeFile(path.join(targetDir, "middleware.ts"), generateNextjsMiddleware());
405
+ writeFile(path.join(targetDir, "middleware.ts"), nextjsTenantMiddleware());
430
406
  }
431
407
 
432
408
  // README