@stratum-hq/create 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @stratum-hq/create
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - b737034: Improve the npm metadata so that npm search finds the packages. Each `description` now starts with the problem the package solves. Each package carries the same multi-tenancy keywords, including `multitenancy`. The `homepage` field now points at the package's page on https://docs.stratum-hq.org instead of a GitHub folder. The first lines of each README link the documentation. No code changes.
8
+ - a1bd9aa: Replace em dashes in user-visible text with ordinary punctuation. This touches READMEs, package descriptions, CLI output, control plane startup log messages, the text that `@stratum-hq/create` writes into generated projects, and the assertion messages in `@stratum-hq/test-utils`. The CLI `health` and `migrate` tables now print `no` instead of a dash for an unset flag. No behavior changes.
9
+
10
+ ## 0.5.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 4c1686a: Generated servers and Next.js middleware take the tenant from a verified JWT instead of the hostname (GHSA-p4wg-j8hh-9wh8).
15
+
3
16
  ## 0.4.1
4
17
 
5
18
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @stratum-hq/create
2
2
 
3
- Scaffold a complete [Stratum](https://github.com/stratum-hq/Stratum) multi-tenancy project with one command — package.json, Docker Compose, environment files, and framework-specific starter code.
3
+ Scaffold a complete [Stratum](https://github.com/stratum-hq/Stratum) multi-tenancy project with one command: package.json, Docker Compose, environment files, and framework-specific starter code.
4
+
5
+ Read the documentation at [docs.stratum-hq.org/packages/create](https://docs.stratum-hq.org/packages/create/).
4
6
 
5
7
  ## Usage
6
8
 
@@ -28,9 +30,9 @@ npx @stratum-hq/create my-app [options]
28
30
 
29
31
  ## Templates
30
32
 
31
- - **express** (default) — Express server with Stratum middleware, tenant-aware routes, and TypeScript config.
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
+ - **express** (default): Express server with Stratum middleware, tenant-aware routes, and TypeScript config.
34
+ - **fastify**: Fastify server with the Stratum plugin registered.
35
+ - **nextjs**: Next.js project with edge middleware that resolves the tenant from a verified JWT.
34
36
 
35
37
  ## After Scaffolding
36
38
 
@@ -43,6 +45,10 @@ npm run dev # run the app
43
45
 
44
46
  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
47
 
48
+ ## Tenant resolution
49
+
50
+ 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.
51
+
46
52
  ## Links
47
53
 
48
54
  - 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,14 +1012,17 @@ 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",
910
- description: "Stratum Hono integration \u2014 tenant extraction middleware with ALS context",
1015
+ version: "1.3.1",
1016
+ description: "Hono multi-tenancy middleware: tenant resolution with AsyncLocalStorage context",
911
1017
  keywords: [
912
1018
  "multi-tenancy",
1019
+ "multitenancy",
913
1020
  "multi-tenant",
914
1021
  "saas",
915
1022
  "tenant",
1023
+ "tenant-isolation",
916
1024
  "hono",
1025
+ "asynclocalstorage",
917
1026
  "middleware",
918
1027
  "typescript",
919
1028
  "stratum"
@@ -957,7 +1066,7 @@ var package_default = {
957
1066
  },
958
1067
  license: "MIT",
959
1068
  author: "Christian Crank",
960
- homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/hono#readme",
1069
+ homepage: "https://docs.stratum-hq.org/packages/hono/",
961
1070
  bugs: "https://github.com/stratum-hq/Stratum/issues",
962
1071
  engines: {
963
1072
  node: ">=20.0.0"
@@ -972,18 +1081,27 @@ var package_default = {
972
1081
  // ../db-adapters/package.json
973
1082
  var package_default2 = {
974
1083
  name: "@stratum-hq/db-adapters",
975
- version: "1.2.0",
976
- description: "Stratum PostgreSQL adapters for tenant-scoped query isolation",
1084
+ version: "1.4.0",
1085
+ description: "Multi-tenant PostgreSQL row-level security for pg, Prisma, Drizzle, and Sequelize, plus schema-per-tenant and database-per-tenant isolation",
977
1086
  keywords: [
978
1087
  "multi-tenancy",
1088
+ "multitenancy",
979
1089
  "multi-tenant",
980
1090
  "saas",
981
1091
  "tenant",
982
1092
  "tenant-isolation",
983
- "postgresql",
984
- "postgres",
985
1093
  "row-level-security",
986
1094
  "rls",
1095
+ "postgresql",
1096
+ "prisma",
1097
+ "drizzle",
1098
+ "drizzle-orm",
1099
+ "sequelize",
1100
+ "pg",
1101
+ "node-postgres",
1102
+ "schema-per-tenant",
1103
+ "database-per-tenant",
1104
+ "postgres",
987
1105
  "schema-isolation",
988
1106
  "database",
989
1107
  "stratum"
@@ -994,6 +1112,10 @@ var package_default2 = {
994
1112
  ".": {
995
1113
  types: "./dist/index.d.ts",
996
1114
  default: "./dist/index.js"
1115
+ },
1116
+ "./pglite": {
1117
+ types: "./dist/pglite/index.d.ts",
1118
+ default: "./dist/pglite/index.js"
997
1119
  }
998
1120
  },
999
1121
  files: [
@@ -1013,18 +1135,23 @@ var package_default2 = {
1013
1135
  registry: "https://registry.npmjs.org/"
1014
1136
  },
1015
1137
  dependencies: {
1016
- "@stratum-hq/core": "^1.4.0",
1138
+ "@stratum-hq/core": "^1.5.1",
1017
1139
  pg: "^8.11.0"
1018
1140
  },
1019
1141
  peerDependencies: {
1142
+ "@electric-sql/pglite": "^0.4.2",
1020
1143
  "drizzle-orm": ">=0.29.0"
1021
1144
  },
1022
1145
  peerDependenciesMeta: {
1146
+ "@electric-sql/pglite": {
1147
+ optional: true
1148
+ },
1023
1149
  "drizzle-orm": {
1024
1150
  optional: true
1025
1151
  }
1026
1152
  },
1027
1153
  devDependencies: {
1154
+ "@electric-sql/pglite": "^0.4.6",
1028
1155
  "@prisma/client": "^5.10.0",
1029
1156
  "@types/pg": "^8.11.0",
1030
1157
  "drizzle-orm": "^0.45.3",
@@ -1033,7 +1160,7 @@ var package_default2 = {
1033
1160
  },
1034
1161
  license: "MIT",
1035
1162
  author: "Christian Crank",
1036
- homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/db-adapters#readme",
1163
+ homepage: "https://docs.stratum-hq.org/packages/db-adapters/",
1037
1164
  bugs: "https://github.com/stratum-hq/Stratum/issues",
1038
1165
  engines: {
1039
1166
  node: ">=20.0.0"
@@ -1048,21 +1175,28 @@ var package_default2 = {
1048
1175
  // ../lib/package.json
1049
1176
  var package_default3 = {
1050
1177
  name: "@stratum-hq/lib",
1051
- version: "1.4.0",
1052
- description: "Stratum tenant management library - framework-agnostic business logic",
1178
+ version: "1.7.0",
1179
+ description: "Multi-tenancy for Node.js and PostgreSQL: tenant hierarchy, config inheritance, row-level security, ABAC, audit log, and GDPR tooling",
1053
1180
  keywords: [
1054
1181
  "multi-tenancy",
1182
+ "multitenancy",
1055
1183
  "multi-tenant",
1056
1184
  "saas",
1057
- "b2b",
1058
1185
  "tenant",
1059
- "tenant-management",
1060
1186
  "tenant-isolation",
1061
- "node",
1062
- "typescript",
1063
- "postgresql",
1064
1187
  "row-level-security",
1065
1188
  "rls",
1189
+ "postgresql",
1190
+ "tenant-hierarchy",
1191
+ "ltree",
1192
+ "abac",
1193
+ "audit-log",
1194
+ "encryption",
1195
+ "webhooks",
1196
+ "b2b",
1197
+ "tenant-management",
1198
+ "node",
1199
+ "typescript",
1066
1200
  "schema-isolation",
1067
1201
  "config-inheritance",
1068
1202
  "gdpr",
@@ -1093,10 +1227,10 @@ var package_default3 = {
1093
1227
  registry: "https://registry.npmjs.org/"
1094
1228
  },
1095
1229
  dependencies: {
1096
- "@opentelemetry/api": "^1.9.0",
1097
- "@stratum-hq/core": "^1.4.0",
1098
- "@stratum-hq/db-adapters": "^1.2.0",
1099
- "@stratum-hq/sdk": "^1.2.0",
1230
+ "@opentelemetry/api": "^1.0.0",
1231
+ "@stratum-hq/core": "^1.5.1",
1232
+ "@stratum-hq/db-adapters": "^1.4.0",
1233
+ "@stratum-hq/sdk": "^1.3.1",
1100
1234
  pg: "^8.11.0"
1101
1235
  },
1102
1236
  peerDependencies: {
@@ -1108,13 +1242,14 @@ var package_default3 = {
1108
1242
  }
1109
1243
  },
1110
1244
  devDependencies: {
1245
+ "@electric-sql/pglite": "^0.4.6",
1111
1246
  "@types/pg": "^8.11.0",
1112
1247
  typescript: "^5.4.0",
1113
1248
  vitest: "^4.1.11"
1114
1249
  },
1115
1250
  license: "MIT",
1116
1251
  author: "Christian Crank",
1117
- homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/lib#readme",
1252
+ homepage: "https://docs.stratum-hq.org/packages/lib/",
1118
1253
  bugs: "https://github.com/stratum-hq/Stratum/issues",
1119
1254
  engines: {
1120
1255
  node: ">=20.0.0"
@@ -1129,10 +1264,11 @@ var package_default3 = {
1129
1264
  // ../mongodb/package.json
1130
1265
  var package_default4 = {
1131
1266
  name: "@stratum-hq/mongodb",
1132
- version: "0.4.0",
1133
- description: "MongoDB tenant isolation for Stratum \u2014 shared collection, collection-per-tenant, and database-per-tenant strategies",
1267
+ version: "0.6.1",
1268
+ description: "MongoDB tenant isolation for Stratum: shared collection, collection-per-tenant, and database-per-tenant strategies",
1134
1269
  keywords: [
1135
1270
  "multi-tenancy",
1271
+ "multitenancy",
1136
1272
  "multi-tenant",
1137
1273
  "saas",
1138
1274
  "tenant",
@@ -1165,8 +1301,8 @@ var package_default4 = {
1165
1301
  clean: "rm -rf dist"
1166
1302
  },
1167
1303
  dependencies: {
1168
- "@stratum-hq/core": "^1.4.0",
1169
- "@stratum-hq/sdk": "^1.2.0"
1304
+ "@stratum-hq/core": "^1.5.1",
1305
+ "@stratum-hq/sdk": "^1.3.1"
1170
1306
  },
1171
1307
  peerDependencies: {
1172
1308
  mongodb: "^6.0.0 || ^7.0.0"
@@ -1188,7 +1324,7 @@ var package_default4 = {
1188
1324
  },
1189
1325
  license: "MIT",
1190
1326
  author: "Christian Crank",
1191
- homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/mongodb#readme",
1327
+ homepage: "https://docs.stratum-hq.org/packages/mongodb/",
1192
1328
  bugs: "https://github.com/stratum-hq/Stratum/issues",
1193
1329
  engines: {
1194
1330
  node: ">=20.0.0"
@@ -1203,10 +1339,11 @@ var package_default4 = {
1203
1339
  // ../mysql/package.json
1204
1340
  var package_default5 = {
1205
1341
  name: "@stratum-hq/mysql",
1206
- version: "0.4.0",
1207
- description: "MySQL tenant isolation for Stratum \u2014 shared table, table-per-tenant, and database-per-tenant strategies",
1342
+ version: "0.6.1",
1343
+ description: "MySQL tenant isolation for Stratum: shared table, table-per-tenant, and database-per-tenant strategies",
1208
1344
  keywords: [
1209
1345
  "multi-tenancy",
1346
+ "multitenancy",
1210
1347
  "multi-tenant",
1211
1348
  "saas",
1212
1349
  "tenant",
@@ -1241,8 +1378,8 @@ var package_default5 = {
1241
1378
  clean: "rm -rf dist"
1242
1379
  },
1243
1380
  dependencies: {
1244
- "@stratum-hq/core": "^1.4.0",
1245
- "@stratum-hq/sdk": "^1.2.0"
1381
+ "@stratum-hq/core": "^1.5.1",
1382
+ "@stratum-hq/sdk": "^1.3.1"
1246
1383
  },
1247
1384
  peerDependencies: {
1248
1385
  mysql2: "^3.0.0",
@@ -1264,6 +1401,7 @@ var package_default5 = {
1264
1401
  devDependencies: {
1265
1402
  knex: "^3.3.0",
1266
1403
  mysql2: "^3.24.4",
1404
+ sequelize: "^6.37.8",
1267
1405
  typeorm: "^1.1.1",
1268
1406
  typescript: "^5.9.0",
1269
1407
  vitest: "^4.1.11"
@@ -1273,7 +1411,7 @@ var package_default5 = {
1273
1411
  },
1274
1412
  license: "MIT",
1275
1413
  author: "Christian Crank",
1276
- homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/mysql#readme",
1414
+ homepage: "https://docs.stratum-hq.org/packages/mysql/",
1277
1415
  bugs: "https://github.com/stratum-hq/Stratum/issues",
1278
1416
  engines: {
1279
1417
  node: ">=20.0.0"
@@ -1288,13 +1426,15 @@ var package_default5 = {
1288
1426
  // ../nestjs/package.json
1289
1427
  var package_default6 = {
1290
1428
  name: "@stratum-hq/nestjs",
1291
- version: "1.2.0",
1292
- description: "Stratum NestJS integration \u2014 StratumGuard, @Tenant() decorator, and StratumModule",
1429
+ version: "1.3.1",
1430
+ description: "NestJS multi-tenancy: tenant guard, @Tenant() decorator, and module for Stratum",
1293
1431
  keywords: [
1294
1432
  "multi-tenancy",
1433
+ "multitenancy",
1295
1434
  "multi-tenant",
1296
1435
  "saas",
1297
1436
  "tenant",
1437
+ "tenant-isolation",
1298
1438
  "nestjs",
1299
1439
  "nest",
1300
1440
  "guard",
@@ -1332,7 +1472,7 @@ var package_default6 = {
1332
1472
  "@nestjs/common": ">=10.0.0",
1333
1473
  "@nestjs/core": ">=10.0.0",
1334
1474
  "@stratum-hq/core": "^1.0.0",
1335
- "@stratum-hq/sdk": "^1.2.0",
1475
+ "@stratum-hq/sdk": "^1.3.0",
1336
1476
  "reflect-metadata": ">=0.1.13"
1337
1477
  },
1338
1478
  devDependencies: {
@@ -1347,7 +1487,7 @@ var package_default6 = {
1347
1487
  },
1348
1488
  license: "MIT",
1349
1489
  author: "Christian Crank",
1350
- homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/nestjs#readme",
1490
+ homepage: "https://docs.stratum-hq.org/packages/nestjs/",
1351
1491
  bugs: "https://github.com/stratum-hq/Stratum/issues",
1352
1492
  engines: {
1353
1493
  node: ">=20.0.0"
@@ -1468,6 +1608,9 @@ function addOrmDeps(deps, devDeps, preset) {
1468
1608
  }
1469
1609
  }
1470
1610
  function addFrameworkDeps(deps, devDeps, preset) {
1611
+ if (preset.framework !== "none") {
1612
+ deps["jose"] = "^6.2.12";
1613
+ }
1471
1614
  switch (preset.framework) {
1472
1615
  case "express":
1473
1616
  deps["express"] = "^4.18.0";
@@ -1615,23 +1758,23 @@ npx drizzle-kit push
1615
1758
  function getStrategyDescription(strategy) {
1616
1759
  switch (strategy) {
1617
1760
  case "rls":
1618
- return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
1761
+ return `- **Row-Level Security**: PostgreSQL RLS policies filter rows by tenant automatically
1619
1762
  - Each query sets \`app.current_tenant_id\` and RLS enforces isolation
1620
1763
  - All tenants share one database and schema`;
1621
1764
  case "schema":
1622
- return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
1765
+ return `- **Schema-per-tenant**: each tenant gets a dedicated PostgreSQL schema
1623
1766
  - Queries are routed to the correct schema via search_path
1624
1767
  - Shared database, isolated schemas`;
1625
1768
  case "database":
1626
- return `- **Database-per-tenant** -- each tenant gets a fully isolated database
1769
+ return `- **Database-per-tenant**: each tenant gets a fully isolated database
1627
1770
  - Connection routing directs queries to the correct database
1628
1771
  - Maximum isolation at the cost of more resource usage`;
1629
1772
  case "collection":
1630
- return `- **Collection-per-tenant** -- each tenant gets dedicated MongoDB collections
1773
+ return `- **Collection-per-tenant**: each tenant gets dedicated MongoDB collections
1631
1774
  - Collection names are prefixed or namespaced by tenant ID
1632
1775
  - Shared database, isolated collections`;
1633
1776
  case "table-prefix":
1634
- return `- **Table-prefix** -- tenant-specific tables with a naming prefix
1777
+ return `- **Table-prefix**: tenant-specific tables with a naming prefix
1635
1778
  - Tables are prefixed with the tenant identifier
1636
1779
  - Shared database, prefixed table names`;
1637
1780
  default:
@@ -1819,7 +1962,9 @@ function generatePackageJson(projectName, template) {
1819
1962
  react: "^19.0.0",
1820
1963
  "react-dom": "^19.0.0",
1821
1964
  "@types/react": "^19.0.0",
1822
- "@types/react-dom": "^19.0.0"
1965
+ "@types/react-dom": "^19.0.0",
1966
+ // The middleware verifies the tenant JWT with jose.
1967
+ jose: "^6.2.12"
1823
1968
  }
1824
1969
  };
1825
1970
  const deps = {
@@ -1982,7 +2127,7 @@ fastify.listen({ port, host: "0.0.0.0" }, (err) => {
1982
2127
  `;
1983
2128
  }
1984
2129
  function generateNextjsPage(projectName) {
1985
- return `// app/page.tsx \u2014 ${projectName} root page
2130
+ return `// app/page.tsx: ${projectName} root page
1986
2131
  export default function Home() {
1987
2132
  return (
1988
2133
  <main style={{ padding: "2rem", fontFamily: "sans-serif" }}>
@@ -1990,7 +2135,7 @@ export default function Home() {
1990
2135
  <p>Multi-tenant app powered by Stratum.</p>
1991
2136
  <ul>
1992
2137
  <li>Configure tenants via the Stratum control plane</li>
1993
- <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
2138
+ <li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
1994
2139
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
1995
2140
  </ul>
1996
2141
  </main>
@@ -1998,32 +2143,6 @@ export default function Home() {
1998
2143
  }
1999
2144
  `;
2000
2145
  }
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
2146
  function generateReadme(projectName, template) {
2028
2147
  return `# ${projectName}
2029
2148
 
@@ -2041,7 +2160,7 @@ docker compose up -d
2041
2160
 
2042
2161
  \`\`\`bash
2043
2162
  cp .env.example .env
2044
- # Edit .env \u2014 update DATABASE_URL, JWT_SECRET, and STRATUM_API_KEY
2163
+ # Edit .env: update DATABASE_URL, JWT_SECRET, and STRATUM_API_KEY
2045
2164
  \`\`\`
2046
2165
 
2047
2166
  ### 3. Install dependencies
@@ -2071,9 +2190,9 @@ ${template === "nextjs" ? "" : "\u251C\u2500\u2500 tsconfig.json\n"}\u2514\u2500
2071
2190
 
2072
2191
  This project uses Stratum for hierarchical multi-tenancy:
2073
2192
 
2074
- - **Tenant resolution** \u2014 via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
2075
- - **Config inheritance** \u2014 settings flow down the tenant tree with override support
2076
- - **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
2193
+ - **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
2194
+ - **Config inheritance**: settings flow down the tenant tree with override support
2195
+ - **Permission ABAC**: role-based permissions with tenant-scoped enforcement
2077
2196
 
2078
2197
  See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
2079
2198
  `;
@@ -2097,7 +2216,7 @@ Creating ${projectName} with ${template} template...
2097
2216
  writeFile2(path2.join(targetDir, "tsconfig.json"), generateTsconfig(template));
2098
2217
  } else if (template === "nextjs") {
2099
2218
  writeFile2(path2.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
2100
- writeFile2(path2.join(targetDir, "middleware.ts"), generateNextjsMiddleware2());
2219
+ writeFile2(path2.join(targetDir, "middleware.ts"), nextjsTenantMiddleware());
2101
2220
  }
2102
2221
  writeFile2(path2.join(targetDir, "README.md"), generateReadme(projectName, template));
2103
2222
  if (!skipInstall) {
package/package.json CHANGED
@@ -1,12 +1,26 @@
1
1
  {
2
2
  "name": "@stratum-hq/create",
3
- "version": "0.4.1",
4
- "description": "Create a new Stratum multi-tenancy project",
3
+ "version": "0.5.1",
4
+ "description": "Scaffold a multi-tenant SaaS starter for Node.js: PostgreSQL, MySQL, or MongoDB with Express, Fastify, Next.js, Hono, or NestJS",
5
5
  "keywords": [
6
6
  "multi-tenancy",
7
+ "multitenancy",
7
8
  "multi-tenant",
8
9
  "saas",
9
10
  "tenant",
11
+ "tenant-isolation",
12
+ "row-level-security",
13
+ "rls",
14
+ "postgresql",
15
+ "mysql",
16
+ "mongodb",
17
+ "express",
18
+ "fastify",
19
+ "nextjs",
20
+ "hono",
21
+ "nestjs",
22
+ "prisma",
23
+ "drizzle",
10
24
  "create",
11
25
  "scaffolding",
12
26
  "starter",
@@ -40,7 +54,7 @@
40
54
  },
41
55
  "license": "MIT",
42
56
  "author": "Christian Crank",
43
- "homepage": "https://github.com/stratum-hq/Stratum/tree/main/packages/create#readme",
57
+ "homepage": "https://docs.stratum-hq.org/packages/create/",
44
58
  "bugs": "https://github.com/stratum-hq/Stratum/issues",
45
59
  "engines": {
46
60
  "node": ">=20.0.0"
@@ -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";
@@ -86,23 +86,23 @@ npx drizzle-kit push
86
86
  function getStrategyDescription(strategy: string): string {
87
87
  switch (strategy) {
88
88
  case "rls":
89
- return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
89
+ return `- **Row-Level Security**: PostgreSQL RLS policies filter rows by tenant automatically
90
90
  - Each query sets \`app.current_tenant_id\` and RLS enforces isolation
91
91
  - All tenants share one database and schema`;
92
92
  case "schema":
93
- return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
93
+ return `- **Schema-per-tenant**: each tenant gets a dedicated PostgreSQL schema
94
94
  - Queries are routed to the correct schema via search_path
95
95
  - Shared database, isolated schemas`;
96
96
  case "database":
97
- return `- **Database-per-tenant** -- each tenant gets a fully isolated database
97
+ return `- **Database-per-tenant**: each tenant gets a fully isolated database
98
98
  - Connection routing directs queries to the correct database
99
99
  - Maximum isolation at the cost of more resource usage`;
100
100
  case "collection":
101
- return `- **Collection-per-tenant** -- each tenant gets dedicated MongoDB collections
101
+ return `- **Collection-per-tenant**: each tenant gets dedicated MongoDB collections
102
102
  - Collection names are prefixed or namespaced by tenant ID
103
103
  - Shared database, isolated collections`;
104
104
  case "table-prefix":
105
- return `- **Table-prefix** -- tenant-specific tables with a naming prefix
105
+ return `- **Table-prefix**: tenant-specific tables with a naming prefix
106
106
  - Tables are prefixed with the tenant identifier
107
107
  - Shared database, prefixed table names`;
108
108
  default:
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
 
@@ -293,7 +296,7 @@ fastify.listen({ port, host: "0.0.0.0" }, (err) => {
293
296
  }
294
297
 
295
298
  function generateNextjsPage(projectName: string): string {
296
- return `// app/page.tsx — ${projectName} root page
299
+ return `// app/page.tsx: ${projectName} root page
297
300
  export default function Home() {
298
301
  return (
299
302
  <main style={{ padding: "2rem", fontFamily: "sans-serif" }}>
@@ -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
 
@@ -354,7 +330,7 @@ docker compose up -d
354
330
 
355
331
  \`\`\`bash
356
332
  cp .env.example .env
357
- # Edit .env — update DATABASE_URL, JWT_SECRET, and STRATUM_API_KEY
333
+ # Edit .env: update DATABASE_URL, JWT_SECRET, and STRATUM_API_KEY
358
334
  \`\`\`
359
335
 
360
336
  ### 3. Install dependencies
@@ -384,9 +360,9 @@ ${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
388
- - **Config inheritance** — settings flow down the tenant tree with override support
389
- - **Permission ABAC** — role-based permissions with tenant-scoped enforcement
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
364
+ - **Config inheritance**: settings flow down the tenant tree with override support
365
+ - **Permission ABAC**: role-based permissions with tenant-scoped enforcement
390
366
 
391
367
  See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
392
368
  `;
@@ -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
package/src/matrix.ts CHANGED
@@ -75,7 +75,7 @@ export function parsePresetString(s: string): StackPreset | null {
75
75
 
76
76
  const parts = s.toLowerCase().split("-");
77
77
 
78
- // Handle "table-prefix" which contains a hyphen -- it will split into
78
+ // Handle "table-prefix" which contains a hyphen; it will split into
79
79
  // 5 parts: [db, strategy1, "table", "prefix", orm, framework] or similar.
80
80
  // We need to reconstruct multi-word tokens.
81
81
  // Format: {database}-{strategy}-{orm}-{framework}