@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 +6 -0
- package/README.md +5 -1
- package/dist/index.js +184 -99
- package/package.json +1 -1
- package/src/generators/middleware.ts +177 -57
- package/src/generators/package-json.ts +4 -0
- package/src/index.ts +6 -30
package/CHANGELOG.md
CHANGED
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
|
|
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
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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:
|
|
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>
|
|
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
|
|
765
|
-
//
|
|
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 =
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1203
|
+
"@stratum-hq/core": "^1.5.0",
|
|
1098
1204
|
"@stratum-hq/db-adapters": "^1.2.0",
|
|
1099
|
-
"@stratum-hq/sdk": "^1.
|
|
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.
|
|
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.
|
|
1169
|
-
"@stratum-hq/sdk": "^1.
|
|
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.
|
|
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.
|
|
1245
|
-
"@stratum-hq/sdk": "^1.
|
|
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.
|
|
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.
|
|
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>
|
|
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
|
|
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"),
|
|
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
|
@@ -5,6 +5,128 @@ export interface MiddlewareFile {
|
|
|
5
5
|
content: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Verifies the bearer token and returns its tenant_id claim. Inserted into
|
|
10
|
+
* every generated server so the project needs nothing but jose. The tenant
|
|
11
|
+
* never comes from the hostname or a header such as x-tenant-id: any caller
|
|
12
|
+
* can choose those.
|
|
13
|
+
*/
|
|
14
|
+
const VERIFIED_TENANT = `import { jwtVerify } from "jose";
|
|
15
|
+
|
|
16
|
+
const jwtSecret = process.env.JWT_SECRET;
|
|
17
|
+
if (!jwtSecret) {
|
|
18
|
+
throw new Error("JWT_SECRET must be set: the tenant is taken from a verified JWT.");
|
|
19
|
+
}
|
|
20
|
+
const jwtKey = new TextEncoder().encode(jwtSecret);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The tenant for a request, from the tenant_id claim of a bearer token that
|
|
24
|
+
* verifies with JWT_SECRET. tenantId is null when there is no bearer token.
|
|
25
|
+
* invalid is true when a token was sent but does not verify or has no
|
|
26
|
+
* tenant_id claim. Never take the tenant from the hostname or from a header
|
|
27
|
+
* such as x-tenant-id: any caller can choose those.
|
|
28
|
+
*/
|
|
29
|
+
async function verifiedTenant(
|
|
30
|
+
authorization: string | undefined,
|
|
31
|
+
): Promise<{ tenantId: string | null; invalid: boolean }> {
|
|
32
|
+
if (!authorization?.startsWith("Bearer ")) return { tenantId: null, invalid: false };
|
|
33
|
+
try {
|
|
34
|
+
const { payload } = await jwtVerify(authorization.slice("Bearer ".length), jwtKey, {
|
|
35
|
+
algorithms: ["HS256"],
|
|
36
|
+
});
|
|
37
|
+
if (typeof payload.tenant_id === "string") return { tenantId: payload.tenant_id, invalid: false };
|
|
38
|
+
} catch {
|
|
39
|
+
// Fall through: a token that does not verify is rejected, never ignored.
|
|
40
|
+
}
|
|
41
|
+
return { tenantId: null, invalid: true };
|
|
42
|
+
}`;
|
|
43
|
+
|
|
44
|
+
const INVALID_TOKEN = `{ error: "Bearer token is invalid or has no tenant_id claim" }`;
|
|
45
|
+
const TENANT_REQUIRED = `{ error: "A bearer token with a tenant_id claim is required" }`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Next.js middleware, following examples/with-nextjs: the tenant ID comes only
|
|
49
|
+
* from the tenant_id claim of a verified bearer token and is forwarded as
|
|
50
|
+
* x-tenant-id. The subdomain is forwarded as x-tenant-slug, never as the ID.
|
|
51
|
+
*/
|
|
52
|
+
export function nextjsTenantMiddleware(): string {
|
|
53
|
+
return `// middleware.ts (place in project root)
|
|
54
|
+
// Next.js middleware for Stratum tenant resolution
|
|
55
|
+
//
|
|
56
|
+
// The tenant ID comes only from the tenant_id claim of a bearer token that
|
|
57
|
+
// verifies with JWT_SECRET, and is forwarded as x-tenant-id. Any copy of the
|
|
58
|
+
// tenant headers the client sent is removed first, so server code only ever
|
|
59
|
+
// reads the values set here.
|
|
60
|
+
//
|
|
61
|
+
// The subdomain (acme.app.example.com) is forwarded as x-tenant-slug. It only
|
|
62
|
+
// says which tenant's public pages to show. It does not prove the caller
|
|
63
|
+
// belongs to that tenant, so never use it to read or write tenant data.
|
|
64
|
+
|
|
65
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
66
|
+
import { jwtVerify } from "jose";
|
|
67
|
+
|
|
68
|
+
const TENANT_ID_HEADER = "x-tenant-id";
|
|
69
|
+
const TENANT_SLUG_HEADER = "x-tenant-slug";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The tenant_id claim of a token that verifies with JWT_SECRET, or null when
|
|
73
|
+
* the token is invalid, expired, or has no string tenant_id claim.
|
|
74
|
+
*/
|
|
75
|
+
async function verifiedTenantId(token: string): Promise<string | null> {
|
|
76
|
+
const secret = process.env.JWT_SECRET;
|
|
77
|
+
if (!secret) {
|
|
78
|
+
throw new Error("JWT_SECRET must be set: the tenant is taken from a verified JWT.");
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), {
|
|
82
|
+
algorithms: ["HS256"],
|
|
83
|
+
});
|
|
84
|
+
return typeof payload.tenant_id === "string" ? payload.tenant_id : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
91
|
+
// Only this middleware may set the tenant headers.
|
|
92
|
+
const requestHeaders = new Headers(request.headers);
|
|
93
|
+
requestHeaders.delete("x-tenant-id");
|
|
94
|
+
requestHeaders.delete(TENANT_SLUG_HEADER);
|
|
95
|
+
|
|
96
|
+
// A bearer token that does not verify is rejected, never ignored.
|
|
97
|
+
const authorization = request.headers.get("authorization");
|
|
98
|
+
if (authorization?.startsWith("Bearer ")) {
|
|
99
|
+
const tenantId = await verifiedTenantId(authorization.slice("Bearer ".length));
|
|
100
|
+
if (!tenantId) {
|
|
101
|
+
return NextResponse.json(
|
|
102
|
+
{ error: { code: "INVALID_TOKEN", message: "Bearer token is invalid or has no tenant_id claim" } },
|
|
103
|
+
{ status: 401 },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
requestHeaders.set(TENANT_ID_HEADER, tenantId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Subdomain, e.g. "acme" from "acme.app.example.com": a slug, not an identity.
|
|
110
|
+
const hostname = (request.headers.get("host") ?? "").split(":")[0];
|
|
111
|
+
const rootDomain = process.env.ROOT_DOMAIN ?? "app.example.com";
|
|
112
|
+
if (hostname.endsWith(\`.\${rootDomain}\`)) {
|
|
113
|
+
const subdomain = hostname.slice(0, hostname.length - rootDomain.length - 1);
|
|
114
|
+
if (subdomain && subdomain !== "www") {
|
|
115
|
+
requestHeaders.set(TENANT_SLUG_HEADER, subdomain);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// With no verified tenant the request continues without x-tenant-id. Each
|
|
120
|
+
// route decides whether to require a tenant or serve a public page.
|
|
121
|
+
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const config = {
|
|
125
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
126
|
+
};
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
8
130
|
export function generateMiddleware(projectName: string, preset: StackPreset): MiddlewareFile[] {
|
|
9
131
|
switch (preset.framework) {
|
|
10
132
|
case "express":
|
|
@@ -27,19 +149,21 @@ function generateExpressMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
27
149
|
{
|
|
28
150
|
filename: "src/index.ts",
|
|
29
151
|
content: `import express from "express";
|
|
152
|
+
${VERIFIED_TENANT}
|
|
30
153
|
|
|
31
154
|
const app = express();
|
|
32
155
|
const port = Number(process.env.PORT) || 3000;
|
|
33
156
|
|
|
34
157
|
app.use(express.json());
|
|
35
158
|
|
|
36
|
-
// Tenant
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
159
|
+
// Tenant resolution. The tenant comes only from a verified bearer token;
|
|
160
|
+
// a token that does not verify is rejected with 401.
|
|
161
|
+
app.use(async (req, res, next) => {
|
|
162
|
+
const { tenantId, invalid } = await verifiedTenant(req.headers.authorization);
|
|
163
|
+
if (invalid) {
|
|
164
|
+
res.status(401).json(${INVALID_TOKEN});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
43
167
|
(req as any).tenantId = tenantId;
|
|
44
168
|
next();
|
|
45
169
|
});
|
|
@@ -50,6 +174,10 @@ app.get("/health", (_req, res) => {
|
|
|
50
174
|
|
|
51
175
|
app.get("/tenants", async (req, res) => {
|
|
52
176
|
const tenantId = (req as any).tenantId;
|
|
177
|
+
if (!tenantId) {
|
|
178
|
+
res.status(401).json(${TENANT_REQUIRED});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
53
181
|
res.json({ tenantId, message: "Replace with your tenant queries" });
|
|
54
182
|
});
|
|
55
183
|
|
|
@@ -66,18 +194,19 @@ function generateFastifyMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
66
194
|
{
|
|
67
195
|
filename: "src/index.ts",
|
|
68
196
|
content: `import Fastify from "fastify";
|
|
197
|
+
${VERIFIED_TENANT}
|
|
69
198
|
|
|
70
199
|
const fastify = Fastify({ logger: true });
|
|
71
200
|
const port = Number(process.env.PORT) || 3000;
|
|
72
201
|
|
|
73
|
-
// Tenant
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
202
|
+
// Tenant resolution. The tenant comes only from a verified bearer token;
|
|
203
|
+
// a token that does not verify is rejected with 401.
|
|
204
|
+
fastify.decorateRequest("tenantId", null);
|
|
205
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
206
|
+
const { tenantId, invalid } = await verifiedTenant(request.headers.authorization);
|
|
207
|
+
if (invalid) {
|
|
208
|
+
return reply.status(401).send(${INVALID_TOKEN});
|
|
209
|
+
}
|
|
81
210
|
(request as any).tenantId = tenantId;
|
|
82
211
|
});
|
|
83
212
|
|
|
@@ -85,8 +214,11 @@ fastify.get("/health", async () => {
|
|
|
85
214
|
return { status: "ok", project: "${projectName}" };
|
|
86
215
|
});
|
|
87
216
|
|
|
88
|
-
fastify.get("/tenants", async (request) => {
|
|
217
|
+
fastify.get("/tenants", async (request, reply) => {
|
|
89
218
|
const tenantId = (request as any).tenantId;
|
|
219
|
+
if (!tenantId) {
|
|
220
|
+
return reply.status(401).send(${TENANT_REQUIRED});
|
|
221
|
+
}
|
|
90
222
|
return { tenantId, message: "Replace with your tenant queries" };
|
|
91
223
|
});
|
|
92
224
|
|
|
@@ -105,30 +237,7 @@ function generateNextjsMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
105
237
|
return [
|
|
106
238
|
{
|
|
107
239
|
filename: "middleware.ts",
|
|
108
|
-
content:
|
|
109
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
110
|
-
|
|
111
|
-
export function middleware(request: NextRequest) {
|
|
112
|
-
// The tenant comes from the subdomain the request was routed to. Any
|
|
113
|
-
// x-tenant-id the client sent is removed first, so server code that reads
|
|
114
|
-
// x-tenant-id only ever sees the value set here. Once you add
|
|
115
|
-
// authentication, check that the signed-in user belongs to this tenant.
|
|
116
|
-
const hostname = request.headers.get("host") || "";
|
|
117
|
-
const tenantId = hostname.split(".")[0];
|
|
118
|
-
|
|
119
|
-
const requestHeaders = new Headers(request.headers);
|
|
120
|
-
requestHeaders.delete("x-tenant-id");
|
|
121
|
-
if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
|
|
122
|
-
requestHeaders.set("x-tenant-id", tenantId);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export const config = {
|
|
129
|
-
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
130
|
-
};
|
|
131
|
-
`,
|
|
240
|
+
content: nextjsTenantMiddleware(),
|
|
132
241
|
},
|
|
133
242
|
{
|
|
134
243
|
filename: "src/app/page.tsx",
|
|
@@ -140,7 +249,7 @@ export default function Home() {
|
|
|
140
249
|
<p>Multi-tenant app powered by Stratum.</p>
|
|
141
250
|
<ul>
|
|
142
251
|
<li>Configure tenants via the Stratum control plane</li>
|
|
143
|
-
<li>
|
|
252
|
+
<li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
|
|
144
253
|
<li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
|
|
145
254
|
</ul>
|
|
146
255
|
</main>
|
|
@@ -157,16 +266,17 @@ function generateHonoMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
157
266
|
filename: "src/index.ts",
|
|
158
267
|
content: `import { Hono } from "hono";
|
|
159
268
|
import { serve } from "@hono/node-server";
|
|
269
|
+
${VERIFIED_TENANT}
|
|
160
270
|
|
|
161
|
-
const app = new Hono();
|
|
271
|
+
const app = new Hono<{ Variables: { tenantId: string | null } }>();
|
|
162
272
|
|
|
163
|
-
// Tenant
|
|
164
|
-
//
|
|
165
|
-
// x-tenant-id: any caller can set one and pick another tenant. Once you add
|
|
166
|
-
// authentication, check that the signed-in user belongs to this tenant, or
|
|
167
|
-
// derive the tenant from the verified session or JWT instead.
|
|
273
|
+
// Tenant resolution. The tenant comes only from a verified bearer token;
|
|
274
|
+
// a token that does not verify is rejected with 401.
|
|
168
275
|
app.use("*", async (c, next) => {
|
|
169
|
-
const tenantId =
|
|
276
|
+
const { tenantId, invalid } = await verifiedTenant(c.req.header("authorization"));
|
|
277
|
+
if (invalid) {
|
|
278
|
+
return c.json(${INVALID_TOKEN}, 401);
|
|
279
|
+
}
|
|
170
280
|
c.set("tenantId", tenantId);
|
|
171
281
|
await next();
|
|
172
282
|
});
|
|
@@ -177,6 +287,9 @@ app.get("/health", (c) => {
|
|
|
177
287
|
|
|
178
288
|
app.get("/tenants", (c) => {
|
|
179
289
|
const tenantId = c.get("tenantId");
|
|
290
|
+
if (!tenantId) {
|
|
291
|
+
return c.json(${TENANT_REQUIRED}, 401);
|
|
292
|
+
}
|
|
180
293
|
return c.json({ tenantId, message: "Replace with your tenant queries" });
|
|
181
294
|
});
|
|
182
295
|
|
|
@@ -222,7 +335,7 @@ export class AppModule {}
|
|
|
222
335
|
},
|
|
223
336
|
{
|
|
224
337
|
filename: "src/app.controller.ts",
|
|
225
|
-
content: `import { Controller, Get, Req } from "@nestjs/common";
|
|
338
|
+
content: `import { Controller, Get, Req, UnauthorizedException } from "@nestjs/common";
|
|
226
339
|
|
|
227
340
|
@Controller()
|
|
228
341
|
export class AppController {
|
|
@@ -233,6 +346,9 @@ export class AppController {
|
|
|
233
346
|
|
|
234
347
|
@Get("tenants")
|
|
235
348
|
tenants(@Req() req: any) {
|
|
349
|
+
if (!req.tenantId) {
|
|
350
|
+
throw new UnauthorizedException("A bearer token with a tenant_id claim is required");
|
|
351
|
+
}
|
|
236
352
|
return { tenantId: req.tenantId, message: "Replace with your tenant queries" };
|
|
237
353
|
}
|
|
238
354
|
}
|
|
@@ -240,18 +356,22 @@ export class AppController {
|
|
|
240
356
|
},
|
|
241
357
|
{
|
|
242
358
|
filename: "src/tenant.guard.ts",
|
|
243
|
-
content: `import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
|
359
|
+
content: `import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
360
|
+
${VERIFIED_TENANT}
|
|
244
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Sets request.tenantId from a verified bearer token, or null when there is
|
|
364
|
+
* no token. A token that does not verify is rejected with 401.
|
|
365
|
+
*/
|
|
245
366
|
@Injectable()
|
|
246
367
|
export class TenantGuard implements CanActivate {
|
|
247
|
-
canActivate(context: ExecutionContext): boolean {
|
|
368
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
248
369
|
const request = context.switchToHttp().getRequest();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
request.tenantId = tenantId || null;
|
|
370
|
+
const { tenantId, invalid } = await verifiedTenant(request.headers?.authorization);
|
|
371
|
+
if (invalid) {
|
|
372
|
+
throw new UnauthorizedException("Bearer token is invalid or has no tenant_id claim");
|
|
373
|
+
}
|
|
374
|
+
request.tenantId = tenantId;
|
|
255
375
|
return true;
|
|
256
376
|
}
|
|
257
377
|
}
|
|
@@ -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>
|
|
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** —
|
|
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"),
|
|
405
|
+
writeFile(path.join(targetDir, "middleware.ts"), nextjsTenantMiddleware());
|
|
430
406
|
}
|
|
431
407
|
|
|
432
408
|
// README
|