@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 +13 -0
- package/README.md +10 -4
- package/dist/index.js +250 -131
- package/package.json +17 -3
- package/src/generators/middleware.ts +177 -57
- package/src/generators/package-json.ts +4 -0
- package/src/generators/readme.ts +5 -5
- package/src/index.ts +10 -34
- package/src/matrix.ts +1 -1
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
|
|
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)
|
|
32
|
-
- **fastify
|
|
33
|
-
- **nextjs
|
|
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
|
|
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,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
|
|
910
|
-
description: "
|
|
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://
|
|
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.
|
|
976
|
-
description: "
|
|
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.
|
|
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://
|
|
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.
|
|
1052
|
-
description: "
|
|
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.
|
|
1097
|
-
"@stratum-hq/core": "^1.
|
|
1098
|
-
"@stratum-hq/db-adapters": "^1.
|
|
1099
|
-
"@stratum-hq/sdk": "^1.
|
|
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://
|
|
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.
|
|
1133
|
-
description: "MongoDB tenant isolation for Stratum
|
|
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.
|
|
1169
|
-
"@stratum-hq/sdk": "^1.
|
|
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://
|
|
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.
|
|
1207
|
-
description: "MySQL tenant isolation for Stratum
|
|
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.
|
|
1245
|
-
"@stratum-hq/sdk": "^1.
|
|
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://
|
|
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.
|
|
1292
|
-
description: "
|
|
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.
|
|
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://
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
2075
|
-
- **Config inheritance
|
|
2076
|
-
- **Permission ABAC
|
|
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"),
|
|
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
|
-
"description": "
|
|
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://
|
|
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
|
|
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/generators/readme.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
388
|
-
- **Config inheritance
|
|
389
|
-
- **Permission ABAC
|
|
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"),
|
|
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
|
|
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}
|