@stratum-hq/create 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,8 @@
1
- #!/usr/bin/env node
2
-
3
1
  // src/index.ts
4
2
  import * as fs2 from "fs";
5
3
  import * as path2 from "path";
6
4
  import crypto2 from "node:crypto";
7
5
  import { execSync as execSync2 } from "child_process";
8
- import { fileURLToPath } from "url";
9
6
 
10
7
  // src/matrix.ts
11
8
  var POSTGRES_STRATEGIES = ["rls", "schema", "database"];
@@ -224,8 +221,11 @@ function generatePostgresInit(projectName, dbName, strategy) {
224
221
  -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
225
222
  -- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
226
223
  -- CREATE POLICY tenant_isolation ON your_table
227
- -- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
224
+ -- USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
228
225
  --
226
+ -- A pooled connection reads the setting as '' after a tenant transaction ends,
227
+ -- or as NULL before the first one. NULLIF makes both return no rows; a bare
228
+ -- ::uuid cast of '' raises an error.
229
229
  -- FORCE makes the policy apply to the table owner too; without it, a table
230
230
  -- created by the application role is not isolated for that role.
231
231
  -- The Stratum db-adapters package sets app.current_tenant_id automatically.
@@ -446,59 +446,106 @@ export default config;
446
446
  },
447
447
  {
448
448
  filename: "src/stratum-knex.ts",
449
- content: `// Knex with Stratum tenant-scoped queries
449
+ content: preset.database === "postgres" ? KNEX_POSTGRES_SCOPE : knexColumnScope(preset.database)
450
+ }
451
+ ];
452
+ }
453
+ var KNEX_POSTGRES_SCOPE = `// Knex with Stratum tenant-scoped queries
454
+ import createKnex, { type Knex } from "knex";
455
+ import config from "../knexfile.js";
456
+
457
+ const knex = createKnex(config);
458
+
459
+ // Run fn in a transaction that sets app.current_tenant_id, the setting that
460
+ // the RLS policies read. The setting ends with the transaction, so run every
461
+ // tenant query through trx, not through knex.
462
+ export async function withTenantScope<T>(
463
+ tenantId: string,
464
+ fn: (trx: Knex.Transaction) => Promise<T>,
465
+ ): Promise<T> {
466
+ return knex.transaction(async (trx) => {
467
+ await trx.raw("SELECT set_config('app.current_tenant_id', ?, true)", [tenantId]);
468
+ return fn(trx);
469
+ });
470
+ }
471
+
472
+ // Usage:
473
+ // const orders = await withTenantScope(currentTenantId, (trx) => trx("orders").select());
474
+
475
+ export { knex };
476
+ `;
477
+ function knexColumnScope(database) {
478
+ return `// Knex with Stratum tenant-scoped queries
450
479
  import Knex from "knex";
451
480
  import config from "../knexfile.js";
452
481
 
453
482
  const knex = Knex(config);
454
483
 
455
484
  // Create a tenant-scoped query builder.
456
- // For RLS strategy, set the session variable before queries.
457
485
  export async function withTenantScope(tenantId: string, fn: (db: typeof knex) => Promise<void>) {
458
- ${preset.database === "postgres" ? ` await knex.raw("SET app.current_tenant = ?", [tenantId]);` : ` // For ${preset.database}, scope queries by tenant_id column`}
459
- try {
460
- await fn(knex);
461
- } finally {
462
- ${preset.database === "postgres" ? ` await knex.raw("RESET app.current_tenant");` : ` // Scope cleanup not needed for column-based isolation`}
463
- }
486
+ // For ${database}, scope queries by tenant_id column
487
+ await fn(knex);
464
488
  }
465
489
 
466
490
  export { knex };
467
- `
468
- }
469
- ];
491
+ `;
492
+ }
493
+ function generateMongooseSetup(preset) {
494
+ const tenantAccess = preset.strategy === "collection" ? `// Return the tenant's model for a base collection. The tenant's documents are
495
+ // in the collection {baseCollection}_{tenantSlug}, the name that
496
+ // MongoCollectionAdapter from @stratum-hq/mongodb uses.
497
+ export function getTenantModel<T>(
498
+ baseCollection: string,
499
+ schema: mongoose.Schema<T>,
500
+ tenantSlug: string,
501
+ ) {
502
+ const name = \`\${baseCollection}_\${assertSlug(tenantSlug)}\`;
503
+ return mainConnection.models[name] ?? mainConnection.model(name, schema, name);
470
504
  }
471
- function generateMongooseSetup(_preset) {
505
+
506
+ // Usage:
507
+ // const Order = getTenantModel("orders", OrderSchema, "tenant_abc");
508
+ // const orders = await Order.find();` : `// Return a connection to the tenant's own database. The database name is
509
+ // stratum_tenant_{tenantSlug}, the name that MongoDatabaseAdapter from
510
+ // @stratum-hq/mongodb uses. useCache returns the same connection on each call.
511
+ export function getTenantConnection(tenantSlug: string) {
512
+ return mainConnection.useDb(\`stratum_tenant_\${assertSlug(tenantSlug)}\`, { useCache: true });
513
+ }
514
+
515
+ // Usage:
516
+ // const conn = getTenantConnection("tenant_abc");
517
+ // const Order = conn.model("Order", OrderSchema);
518
+ // const orders = await Order.find();`;
472
519
  return [
473
520
  {
474
521
  filename: "src/stratum-mongoose.ts",
475
522
  content: `// Mongoose with Stratum multi-tenant support
476
523
  import mongoose from "mongoose";
477
- import { createTenantConnection } from "@stratum-hq/mongodb";
478
524
 
479
525
  // Main connection (used for tenant metadata)
480
526
  const mainConnection = mongoose.createConnection(
481
527
  process.env.MONGODB_URI || "mongodb://localhost:27017/main",
482
528
  );
483
529
 
484
- // Create a tenant-scoped connection.
485
- // Each tenant gets its own database (database-per-tenant strategy)
486
- // or collection prefix (collection strategy).
487
- export function getTenantConnection(tenantId: string) {
488
- return createTenantConnection(mainConnection, tenantId);
530
+ // A tenant slug becomes part of a MongoDB name, so it must match the Stratum
531
+ // slug rule: a lowercase letter, then lowercase letters, digits or underscores.
532
+ const SLUG_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
533
+
534
+ function assertSlug(tenantSlug: string): string {
535
+ if (!SLUG_PATTERN.test(tenantSlug)) {
536
+ throw new Error(\`Invalid tenant slug: "\${tenantSlug}"\`);
537
+ }
538
+ return tenantSlug;
489
539
  }
490
540
 
541
+ ${tenantAccess}
542
+
491
543
  // Define schemas that work across tenant connections
492
544
  export const TenantSchema = new mongoose.Schema({
493
545
  name: { type: String, required: true },
494
546
  createdAt: { type: Date, default: Date.now },
495
547
  });
496
548
 
497
- // Usage:
498
- // const conn = getTenantConnection("tenant-abc");
499
- // const Order = conn.model("Order", OrderSchema);
500
- // const orders = await Order.find();
501
-
502
549
  export { mainConnection };
503
550
  `
504
551
  }
@@ -517,7 +564,11 @@ const pool = mysql.createPool({
517
564
  });
518
565
 
519
566
  // Execute a query scoped to a tenant by filtering on tenant_id
520
- export async function tenantQuery(tenantId: string, sql: string, params: unknown[] = []) {
567
+ export async function tenantQuery(
568
+ tenantId: string,
569
+ sql: string,
570
+ params: (string | number | bigint | boolean | Date | null)[] = [],
571
+ ) {
521
572
  const [rows] = await pool.execute(sql, [...params, tenantId]);
522
573
  return rows;
523
574
  }
@@ -542,9 +593,10 @@ const pool = new Pool({
542
593
  });
543
594
 
544
595
  // Create a tenant-scoped pool.
545
- // Sets app.current_tenant on each connection for RLS enforcement.
596
+ // Each query runs in a transaction that sets app.current_tenant_id, the
597
+ // setting that the RLS policies read.
546
598
  export function getTenantPool(tenantId: string) {
547
- return createTenantPool(pool, tenantId);
599
+ return createTenantPool(pool, () => tenantId);
548
600
  }
549
601
 
550
602
  // Usage:
@@ -558,6 +610,114 @@ export { pool };
558
610
  }
559
611
 
560
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
+ }
561
721
  function generateMiddleware(projectName, preset) {
562
722
  switch (preset.framework) {
563
723
  case "express":
@@ -579,19 +739,21 @@ function generateExpressMiddleware(projectName) {
579
739
  {
580
740
  filename: "src/index.ts",
581
741
  content: `import express from "express";
742
+ ${VERIFIED_TENANT}
582
743
 
583
744
  const app = express();
584
745
  const port = Number(process.env.PORT) || 3000;
585
746
 
586
747
  app.use(express.json());
587
748
 
588
- // Tenant extraction middleware. The tenant comes from the subdomain the
589
- // request was routed to. Do not take it from a client-supplied header such as
590
- // x-tenant-id: any caller can set one and pick another tenant. Once you add
591
- // authentication, check that the signed-in user belongs to this tenant, or
592
- // derive the tenant from the verified session or JWT instead.
593
- app.use((req, _res, next) => {
594
- const tenantId = req.hostname.split(".")[0];
749
+ // Tenant resolution. The tenant comes only from a verified bearer token;
750
+ // a token that does not verify is rejected with 401.
751
+ app.use(async (req, res, next) => {
752
+ const { tenantId, invalid } = await verifiedTenant(req.headers.authorization);
753
+ if (invalid) {
754
+ res.status(401).json(${INVALID_TOKEN});
755
+ return;
756
+ }
595
757
  (req as any).tenantId = tenantId;
596
758
  next();
597
759
  });
@@ -602,6 +764,10 @@ app.get("/health", (_req, res) => {
602
764
 
603
765
  app.get("/tenants", async (req, res) => {
604
766
  const tenantId = (req as any).tenantId;
767
+ if (!tenantId) {
768
+ res.status(401).json(${TENANT_REQUIRED});
769
+ return;
770
+ }
605
771
  res.json({ tenantId, message: "Replace with your tenant queries" });
606
772
  });
607
773
 
@@ -617,18 +783,19 @@ function generateFastifyMiddleware(projectName) {
617
783
  {
618
784
  filename: "src/index.ts",
619
785
  content: `import Fastify from "fastify";
786
+ ${VERIFIED_TENANT}
620
787
 
621
788
  const fastify = Fastify({ logger: true });
622
789
  const port = Number(process.env.PORT) || 3000;
623
790
 
624
- // Tenant extraction hook. The tenant comes from the subdomain the
625
- // request was routed to. Do not take it from a client-supplied header such as
626
- // x-tenant-id: any caller can set one and pick another tenant. Once you add
627
- // authentication, check that the signed-in user belongs to this tenant, or
628
- // derive the tenant from the verified session or JWT instead.
629
- fastify.decorateRequest("tenantId", "");
630
- fastify.addHook("onRequest", async (request) => {
631
- const tenantId = request.hostname?.split(".")[0] ?? "";
791
+ // Tenant resolution. The tenant comes only from a verified bearer token;
792
+ // a token that does not verify is rejected with 401.
793
+ fastify.decorateRequest("tenantId", null);
794
+ fastify.addHook("onRequest", async (request, reply) => {
795
+ const { tenantId, invalid } = await verifiedTenant(request.headers.authorization);
796
+ if (invalid) {
797
+ return reply.status(401).send(${INVALID_TOKEN});
798
+ }
632
799
  (request as any).tenantId = tenantId;
633
800
  });
634
801
 
@@ -636,8 +803,11 @@ fastify.get("/health", async () => {
636
803
  return { status: "ok", project: "${projectName}" };
637
804
  });
638
805
 
639
- fastify.get("/tenants", async (request) => {
806
+ fastify.get("/tenants", async (request, reply) => {
640
807
  const tenantId = (request as any).tenantId;
808
+ if (!tenantId) {
809
+ return reply.status(401).send(${TENANT_REQUIRED});
810
+ }
641
811
  return { tenantId, message: "Replace with your tenant queries" };
642
812
  });
643
813
 
@@ -655,30 +825,7 @@ function generateNextjsMiddleware(projectName) {
655
825
  return [
656
826
  {
657
827
  filename: "middleware.ts",
658
- content: `// Next.js edge middleware for tenant resolution
659
- import { NextRequest, NextResponse } from "next/server";
660
-
661
- export function middleware(request: NextRequest) {
662
- // The tenant comes from the subdomain the request was routed to. Any
663
- // x-tenant-id the client sent is removed first, so server code that reads
664
- // x-tenant-id only ever sees the value set here. Once you add
665
- // authentication, check that the signed-in user belongs to this tenant.
666
- const hostname = request.headers.get("host") || "";
667
- const tenantId = hostname.split(".")[0];
668
-
669
- const requestHeaders = new Headers(request.headers);
670
- requestHeaders.delete("x-tenant-id");
671
- if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
672
- requestHeaders.set("x-tenant-id", tenantId);
673
- }
674
-
675
- return NextResponse.next({ request: { headers: requestHeaders } });
676
- }
677
-
678
- export const config = {
679
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
680
- };
681
- `
828
+ content: nextjsTenantMiddleware()
682
829
  },
683
830
  {
684
831
  filename: "src/app/page.tsx",
@@ -690,7 +837,7 @@ export default function Home() {
690
837
  <p>Multi-tenant app powered by Stratum.</p>
691
838
  <ul>
692
839
  <li>Configure tenants via the Stratum control plane</li>
693
- <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
840
+ <li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
694
841
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
695
842
  </ul>
696
843
  </main>
@@ -706,16 +853,17 @@ function generateHonoMiddleware(projectName) {
706
853
  filename: "src/index.ts",
707
854
  content: `import { Hono } from "hono";
708
855
  import { serve } from "@hono/node-server";
856
+ ${VERIFIED_TENANT}
709
857
 
710
- const app = new Hono();
858
+ const app = new Hono<{ Variables: { tenantId: string | null } }>();
711
859
 
712
- // Tenant extraction middleware. The tenant comes from the subdomain the
713
- // request was routed to. Do not take it from a client-supplied header such as
714
- // x-tenant-id: any caller can set one and pick another tenant. Once you add
715
- // authentication, check that the signed-in user belongs to this tenant, or
716
- // 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.
717
862
  app.use("*", async (c, next) => {
718
- const tenantId = new URL(c.req.url).hostname.split(".")[0];
863
+ const { tenantId, invalid } = await verifiedTenant(c.req.header("authorization"));
864
+ if (invalid) {
865
+ return c.json(${INVALID_TOKEN}, 401);
866
+ }
719
867
  c.set("tenantId", tenantId);
720
868
  await next();
721
869
  });
@@ -726,6 +874,9 @@ app.get("/health", (c) => {
726
874
 
727
875
  app.get("/tenants", (c) => {
728
876
  const tenantId = c.get("tenantId");
877
+ if (!tenantId) {
878
+ return c.json(${TENANT_REQUIRED}, 401);
879
+ }
729
880
  return c.json({ tenantId, message: "Replace with your tenant queries" });
730
881
  });
731
882
 
@@ -770,7 +921,7 @@ export class AppModule {}
770
921
  },
771
922
  {
772
923
  filename: "src/app.controller.ts",
773
- content: `import { Controller, Get, Req } from "@nestjs/common";
924
+ content: `import { Controller, Get, Req, UnauthorizedException } from "@nestjs/common";
774
925
 
775
926
  @Controller()
776
927
  export class AppController {
@@ -781,6 +932,9 @@ export class AppController {
781
932
 
782
933
  @Get("tenants")
783
934
  tenants(@Req() req: any) {
935
+ if (!req.tenantId) {
936
+ throw new UnauthorizedException("A bearer token with a tenant_id claim is required");
937
+ }
784
938
  return { tenantId: req.tenantId, message: "Replace with your tenant queries" };
785
939
  }
786
940
  }
@@ -788,18 +942,22 @@ export class AppController {
788
942
  },
789
943
  {
790
944
  filename: "src/tenant.guard.ts",
791
- content: `import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
945
+ content: `import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
946
+ ${VERIFIED_TENANT}
792
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
+ */
793
952
  @Injectable()
794
953
  export class TenantGuard implements CanActivate {
795
- canActivate(context: ExecutionContext): boolean {
954
+ async canActivate(context: ExecutionContext): Promise<boolean> {
796
955
  const request = context.switchToHttp().getRequest();
797
- // The tenant comes from the subdomain the request was routed to. Do not
798
- // take it from a client-supplied header such as x-tenant-id: any caller
799
- // can set one and pick another tenant. Once you add authentication, check
800
- // that the signed-in user belongs to this tenant here.
801
- const tenantId = request.hostname?.split(".")[0];
802
- 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;
803
961
  return true;
804
962
  }
805
963
  }
@@ -822,16 +980,516 @@ console.log("Add tenant resolution logic for your use case");
822
980
  ];
823
981
  }
824
982
 
983
+ // src/generators/tsconfig.ts
984
+ function rootSources(preset) {
985
+ return preset.orm === "knex" ? ["knexfile.ts"] : [];
986
+ }
987
+ function generateTsconfig(framework, extraSources = []) {
988
+ const rootDir = extraSources.length > 0 ? "." : "src";
989
+ return JSON.stringify(
990
+ {
991
+ compilerOptions: {
992
+ target: "ESNext",
993
+ module: "NodeNext",
994
+ moduleResolution: "NodeNext",
995
+ strict: true,
996
+ outDir: "dist",
997
+ rootDir,
998
+ declaration: true,
999
+ skipLibCheck: true,
1000
+ esModuleInterop: true,
1001
+ ...framework === "nestjs" ? { experimentalDecorators: true, emitDecoratorMetadata: true } : {},
1002
+ ...framework === "nextjs" ? { jsx: "preserve", plugins: [{ name: "next" }] } : {}
1003
+ },
1004
+ include: ["src", ...extraSources],
1005
+ exclude: ["node_modules", "dist"]
1006
+ },
1007
+ null,
1008
+ 2
1009
+ );
1010
+ }
1011
+
1012
+ // ../hono/package.json
1013
+ var package_default = {
1014
+ name: "@stratum-hq/hono",
1015
+ version: "1.2.0",
1016
+ description: "Stratum Hono integration \u2014 tenant extraction middleware with ALS context",
1017
+ keywords: [
1018
+ "multi-tenancy",
1019
+ "multi-tenant",
1020
+ "saas",
1021
+ "tenant",
1022
+ "hono",
1023
+ "middleware",
1024
+ "typescript",
1025
+ "stratum"
1026
+ ],
1027
+ type: "module",
1028
+ main: "./dist/index.js",
1029
+ types: "./dist/index.d.ts",
1030
+ exports: {
1031
+ ".": {
1032
+ types: "./dist/index.d.ts",
1033
+ default: "./dist/index.js"
1034
+ }
1035
+ },
1036
+ files: [
1037
+ "dist",
1038
+ "README.md",
1039
+ "LICENSE"
1040
+ ],
1041
+ scripts: {
1042
+ build: "tsc",
1043
+ test: "vitest run",
1044
+ typecheck: "tsc --noEmit",
1045
+ lint: "eslint .",
1046
+ clean: "rm -rf dist"
1047
+ },
1048
+ publishConfig: {
1049
+ access: "public",
1050
+ registry: "https://registry.npmjs.org/"
1051
+ },
1052
+ peerDependencies: {
1053
+ "@stratum-hq/core": "^1.0.0",
1054
+ "@stratum-hq/sdk": "^1.2.0",
1055
+ hono: ">=4.0.0"
1056
+ },
1057
+ devDependencies: {
1058
+ "@stratum-hq/core": "*",
1059
+ "@stratum-hq/sdk": "*",
1060
+ hono: "^4.13.9",
1061
+ typescript: "^5.4.0",
1062
+ vitest: "^4.1.11"
1063
+ },
1064
+ license: "MIT",
1065
+ author: "Christian Crank",
1066
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/hono#readme",
1067
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1068
+ engines: {
1069
+ node: ">=20.0.0"
1070
+ },
1071
+ repository: {
1072
+ type: "git",
1073
+ url: "https://github.com/stratum-hq/Stratum.git",
1074
+ directory: "packages/hono"
1075
+ }
1076
+ };
1077
+
1078
+ // ../db-adapters/package.json
1079
+ var package_default2 = {
1080
+ name: "@stratum-hq/db-adapters",
1081
+ version: "1.2.0",
1082
+ description: "Stratum PostgreSQL adapters for tenant-scoped query isolation",
1083
+ keywords: [
1084
+ "multi-tenancy",
1085
+ "multi-tenant",
1086
+ "saas",
1087
+ "tenant",
1088
+ "tenant-isolation",
1089
+ "postgresql",
1090
+ "postgres",
1091
+ "row-level-security",
1092
+ "rls",
1093
+ "schema-isolation",
1094
+ "database",
1095
+ "stratum"
1096
+ ],
1097
+ main: "./dist/index.js",
1098
+ types: "./dist/index.d.ts",
1099
+ exports: {
1100
+ ".": {
1101
+ types: "./dist/index.d.ts",
1102
+ default: "./dist/index.js"
1103
+ }
1104
+ },
1105
+ files: [
1106
+ "dist",
1107
+ "README.md",
1108
+ "LICENSE"
1109
+ ],
1110
+ scripts: {
1111
+ build: "tsc",
1112
+ test: "vitest run",
1113
+ typecheck: "tsc --noEmit",
1114
+ lint: "eslint .",
1115
+ clean: "rm -rf dist"
1116
+ },
1117
+ publishConfig: {
1118
+ access: "public",
1119
+ registry: "https://registry.npmjs.org/"
1120
+ },
1121
+ dependencies: {
1122
+ "@stratum-hq/core": "^1.4.0",
1123
+ pg: "^8.11.0"
1124
+ },
1125
+ peerDependencies: {
1126
+ "drizzle-orm": ">=0.29.0"
1127
+ },
1128
+ peerDependenciesMeta: {
1129
+ "drizzle-orm": {
1130
+ optional: true
1131
+ }
1132
+ },
1133
+ devDependencies: {
1134
+ "@prisma/client": "^5.10.0",
1135
+ "@types/pg": "^8.11.0",
1136
+ "drizzle-orm": "^0.45.3",
1137
+ typescript: "^5.4.0",
1138
+ vitest: "^4.1.11"
1139
+ },
1140
+ license: "MIT",
1141
+ author: "Christian Crank",
1142
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/db-adapters#readme",
1143
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1144
+ engines: {
1145
+ node: ">=20.0.0"
1146
+ },
1147
+ repository: {
1148
+ type: "git",
1149
+ url: "https://github.com/stratum-hq/Stratum.git",
1150
+ directory: "packages/db-adapters"
1151
+ }
1152
+ };
1153
+
1154
+ // ../lib/package.json
1155
+ var package_default3 = {
1156
+ name: "@stratum-hq/lib",
1157
+ version: "1.5.0",
1158
+ description: "Stratum tenant management library - framework-agnostic business logic",
1159
+ keywords: [
1160
+ "multi-tenancy",
1161
+ "multi-tenant",
1162
+ "saas",
1163
+ "b2b",
1164
+ "tenant",
1165
+ "tenant-management",
1166
+ "tenant-isolation",
1167
+ "node",
1168
+ "typescript",
1169
+ "postgresql",
1170
+ "row-level-security",
1171
+ "rls",
1172
+ "schema-isolation",
1173
+ "config-inheritance",
1174
+ "gdpr",
1175
+ "stratum"
1176
+ ],
1177
+ main: "./dist/index.js",
1178
+ types: "./dist/index.d.ts",
1179
+ exports: {
1180
+ ".": {
1181
+ types: "./dist/index.d.ts",
1182
+ default: "./dist/index.js"
1183
+ }
1184
+ },
1185
+ files: [
1186
+ "dist",
1187
+ "README.md",
1188
+ "LICENSE"
1189
+ ],
1190
+ scripts: {
1191
+ build: "tsc && node ../../scripts/copy-dir.mjs src/migrations dist/migrations",
1192
+ test: "vitest run",
1193
+ typecheck: "tsc --noEmit",
1194
+ lint: "eslint .",
1195
+ clean: "rm -rf dist"
1196
+ },
1197
+ publishConfig: {
1198
+ access: "public",
1199
+ registry: "https://registry.npmjs.org/"
1200
+ },
1201
+ dependencies: {
1202
+ "@opentelemetry/api": "^1.9.0",
1203
+ "@stratum-hq/core": "^1.5.0",
1204
+ "@stratum-hq/db-adapters": "^1.2.0",
1205
+ "@stratum-hq/sdk": "^1.3.0",
1206
+ pg: "^8.11.0"
1207
+ },
1208
+ peerDependencies: {
1209
+ "@opentelemetry/api": "^1.0.0"
1210
+ },
1211
+ peerDependenciesMeta: {
1212
+ "@opentelemetry/api": {
1213
+ optional: true
1214
+ }
1215
+ },
1216
+ devDependencies: {
1217
+ "@types/pg": "^8.11.0",
1218
+ typescript: "^5.4.0",
1219
+ vitest: "^4.1.11"
1220
+ },
1221
+ license: "MIT",
1222
+ author: "Christian Crank",
1223
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/lib#readme",
1224
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1225
+ engines: {
1226
+ node: ">=20.0.0"
1227
+ },
1228
+ repository: {
1229
+ type: "git",
1230
+ url: "https://github.com/stratum-hq/Stratum.git",
1231
+ directory: "packages/lib"
1232
+ }
1233
+ };
1234
+
1235
+ // ../mongodb/package.json
1236
+ var package_default4 = {
1237
+ name: "@stratum-hq/mongodb",
1238
+ version: "0.5.0",
1239
+ description: "MongoDB tenant isolation for Stratum \u2014 shared collection, collection-per-tenant, and database-per-tenant strategies",
1240
+ keywords: [
1241
+ "multi-tenancy",
1242
+ "multi-tenant",
1243
+ "saas",
1244
+ "tenant",
1245
+ "tenant-isolation",
1246
+ "mongodb",
1247
+ "mongoose",
1248
+ "database",
1249
+ "stratum"
1250
+ ],
1251
+ main: "./dist/index.js",
1252
+ types: "./dist/index.d.ts",
1253
+ exports: {
1254
+ ".": {
1255
+ types: "./dist/index.d.ts",
1256
+ default: "./dist/index.js"
1257
+ }
1258
+ },
1259
+ files: [
1260
+ "dist",
1261
+ "README.md",
1262
+ "LICENSE"
1263
+ ],
1264
+ scripts: {
1265
+ build: "tsc",
1266
+ test: "vitest run",
1267
+ "test:watch": "vitest",
1268
+ "test:integration": "vitest run --config vitest.integration.config.ts",
1269
+ typecheck: "tsc --noEmit",
1270
+ lint: "eslint .",
1271
+ clean: "rm -rf dist"
1272
+ },
1273
+ dependencies: {
1274
+ "@stratum-hq/core": "^1.5.0",
1275
+ "@stratum-hq/sdk": "^1.3.0"
1276
+ },
1277
+ peerDependencies: {
1278
+ mongodb: "^6.0.0 || ^7.0.0"
1279
+ },
1280
+ peerDependenciesMeta: {
1281
+ mongoose: {
1282
+ optional: true
1283
+ }
1284
+ },
1285
+ devDependencies: {
1286
+ "@stratum-hq/test-utils": "*",
1287
+ mongodb: "^7.6.0",
1288
+ mongoose: "^8.0.0",
1289
+ typescript: "^5.9.0",
1290
+ vitest: "^4.1.11"
1291
+ },
1292
+ publishConfig: {
1293
+ access: "public"
1294
+ },
1295
+ license: "MIT",
1296
+ author: "Christian Crank",
1297
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/mongodb#readme",
1298
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1299
+ engines: {
1300
+ node: ">=20.0.0"
1301
+ },
1302
+ repository: {
1303
+ type: "git",
1304
+ url: "https://github.com/stratum-hq/Stratum.git",
1305
+ directory: "packages/mongodb"
1306
+ }
1307
+ };
1308
+
1309
+ // ../mysql/package.json
1310
+ var package_default5 = {
1311
+ name: "@stratum-hq/mysql",
1312
+ version: "0.5.0",
1313
+ description: "MySQL tenant isolation for Stratum \u2014 shared table, table-per-tenant, and database-per-tenant strategies",
1314
+ keywords: [
1315
+ "multi-tenancy",
1316
+ "multi-tenant",
1317
+ "saas",
1318
+ "tenant",
1319
+ "tenant-isolation",
1320
+ "mysql",
1321
+ "database",
1322
+ "typeorm",
1323
+ "knex",
1324
+ "sequelize",
1325
+ "stratum"
1326
+ ],
1327
+ main: "./dist/index.js",
1328
+ types: "./dist/index.d.ts",
1329
+ exports: {
1330
+ ".": {
1331
+ types: "./dist/index.d.ts",
1332
+ default: "./dist/index.js"
1333
+ }
1334
+ },
1335
+ files: [
1336
+ "dist",
1337
+ "README.md",
1338
+ "LICENSE"
1339
+ ],
1340
+ scripts: {
1341
+ build: "tsc",
1342
+ test: "vitest run",
1343
+ "test:watch": "vitest",
1344
+ "test:integration": "vitest run --config vitest.integration.config.ts",
1345
+ typecheck: "tsc --noEmit",
1346
+ lint: "eslint .",
1347
+ clean: "rm -rf dist"
1348
+ },
1349
+ dependencies: {
1350
+ "@stratum-hq/core": "^1.5.0",
1351
+ "@stratum-hq/sdk": "^1.3.0"
1352
+ },
1353
+ peerDependencies: {
1354
+ mysql2: "^3.0.0",
1355
+ knex: ">=3.0.0",
1356
+ typeorm: "^1.0.0",
1357
+ sequelize: "^6.0.0"
1358
+ },
1359
+ peerDependenciesMeta: {
1360
+ typeorm: {
1361
+ optional: true
1362
+ },
1363
+ knex: {
1364
+ optional: true
1365
+ },
1366
+ sequelize: {
1367
+ optional: true
1368
+ }
1369
+ },
1370
+ devDependencies: {
1371
+ knex: "^3.3.0",
1372
+ mysql2: "^3.24.4",
1373
+ typeorm: "^1.1.1",
1374
+ typescript: "^5.9.0",
1375
+ vitest: "^4.1.11"
1376
+ },
1377
+ publishConfig: {
1378
+ access: "public"
1379
+ },
1380
+ license: "MIT",
1381
+ author: "Christian Crank",
1382
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/mysql#readme",
1383
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1384
+ engines: {
1385
+ node: ">=20.0.0"
1386
+ },
1387
+ repository: {
1388
+ type: "git",
1389
+ url: "https://github.com/stratum-hq/Stratum.git",
1390
+ directory: "packages/mysql"
1391
+ }
1392
+ };
1393
+
1394
+ // ../nestjs/package.json
1395
+ var package_default6 = {
1396
+ name: "@stratum-hq/nestjs",
1397
+ version: "1.3.0",
1398
+ description: "Stratum NestJS integration \u2014 StratumGuard, @Tenant() decorator, and StratumModule",
1399
+ keywords: [
1400
+ "multi-tenancy",
1401
+ "multi-tenant",
1402
+ "saas",
1403
+ "tenant",
1404
+ "nestjs",
1405
+ "nest",
1406
+ "guard",
1407
+ "decorator",
1408
+ "middleware",
1409
+ "typescript",
1410
+ "stratum"
1411
+ ],
1412
+ type: "module",
1413
+ main: "./dist/index.js",
1414
+ types: "./dist/index.d.ts",
1415
+ exports: {
1416
+ ".": {
1417
+ types: "./dist/index.d.ts",
1418
+ default: "./dist/index.js"
1419
+ }
1420
+ },
1421
+ files: [
1422
+ "dist",
1423
+ "README.md",
1424
+ "LICENSE"
1425
+ ],
1426
+ scripts: {
1427
+ build: "tsc",
1428
+ test: "vitest run",
1429
+ typecheck: "tsc --noEmit",
1430
+ lint: "eslint .",
1431
+ clean: "rm -rf dist"
1432
+ },
1433
+ publishConfig: {
1434
+ access: "public",
1435
+ registry: "https://registry.npmjs.org/"
1436
+ },
1437
+ peerDependencies: {
1438
+ "@nestjs/common": ">=10.0.0",
1439
+ "@nestjs/core": ">=10.0.0",
1440
+ "@stratum-hq/core": "^1.0.0",
1441
+ "@stratum-hq/sdk": "^1.3.0",
1442
+ "reflect-metadata": ">=0.1.13"
1443
+ },
1444
+ devDependencies: {
1445
+ "@nestjs/common": "^12.1.2",
1446
+ "@nestjs/core": "^12.1.2",
1447
+ "@nestjs/testing": "^12.1.2",
1448
+ "@stratum-hq/core": "*",
1449
+ "@stratum-hq/sdk": "*",
1450
+ "reflect-metadata": "^0.2.2",
1451
+ typescript: "^5.4.0",
1452
+ vitest: "^4.1.11"
1453
+ },
1454
+ license: "MIT",
1455
+ author: "Christian Crank",
1456
+ homepage: "https://github.com/stratum-hq/Stratum/tree/main/packages/nestjs#readme",
1457
+ bugs: "https://github.com/stratum-hq/Stratum/issues",
1458
+ engines: {
1459
+ node: ">=20.0.0"
1460
+ },
1461
+ repository: {
1462
+ type: "git",
1463
+ url: "https://github.com/stratum-hq/Stratum.git",
1464
+ directory: "packages/nestjs"
1465
+ }
1466
+ };
1467
+
1468
+ // src/stratum-versions.ts
1469
+ var STRATUM_RANGES = {
1470
+ "@stratum-hq/db-adapters": `^${package_default2.version}`,
1471
+ "@stratum-hq/hono": `^${package_default.version}`,
1472
+ "@stratum-hq/lib": `^${package_default3.version}`,
1473
+ "@stratum-hq/mongodb": `^${package_default4.version}`,
1474
+ "@stratum-hq/mysql": `^${package_default5.version}`,
1475
+ "@stratum-hq/nestjs": `^${package_default6.version}`
1476
+ };
1477
+
825
1478
  // src/generators/package-json.ts
826
1479
  function generatePresetPackageJson(projectName, preset) {
827
1480
  const deps = {
828
- "@stratum-hq/lib": "^0.2.0"
1481
+ "@stratum-hq/lib": STRATUM_RANGES["@stratum-hq/lib"]
829
1482
  };
830
1483
  const devDeps = {
831
1484
  typescript: "^5.3.0",
832
1485
  "@types/node": "^20.11.0"
833
1486
  };
834
- addDatabaseDeps(deps, preset);
1487
+ if (preset.framework === "nestjs") {
1488
+ devDeps["tsc-watch"] = "^7.2.0";
1489
+ } else if (preset.framework !== "nextjs") {
1490
+ devDeps["tsx"] = "^4.7.0";
1491
+ }
1492
+ addDatabaseDeps(deps, devDeps, preset);
835
1493
  addOrmDeps(deps, devDeps, preset);
836
1494
  addFrameworkDeps(deps, devDeps, preset);
837
1495
  addStratumDeps(deps, preset);
@@ -853,9 +1511,12 @@ function generatePresetPackageJson(projectName, preset) {
853
1511
  2
854
1512
  );
855
1513
  }
856
- function addDatabaseDeps(deps, preset) {
1514
+ function addDatabaseDeps(deps, devDeps, preset) {
857
1515
  switch (preset.database) {
858
1516
  case "postgres":
1517
+ if (preset.orm === "pg" || preset.orm === "prisma" || preset.orm === "drizzle") {
1518
+ devDeps["@types/pg"] = "^8.11.0";
1519
+ }
859
1520
  if (preset.orm !== "prisma" && preset.orm !== "drizzle" && preset.orm !== "sequelize") {
860
1521
  deps["pg"] = "^8.11.0";
861
1522
  }
@@ -913,6 +1574,9 @@ function addOrmDeps(deps, devDeps, preset) {
913
1574
  }
914
1575
  }
915
1576
  function addFrameworkDeps(deps, devDeps, preset) {
1577
+ if (preset.framework !== "none") {
1578
+ deps["jose"] = "^6.2.12";
1579
+ }
916
1580
  switch (preset.framework) {
917
1581
  case "express":
918
1582
  deps["express"] = "^4.18.0";
@@ -945,29 +1609,29 @@ function addFrameworkDeps(deps, devDeps, preset) {
945
1609
  }
946
1610
  function addStratumDeps(deps, preset) {
947
1611
  if (preset.database === "postgres" && preset.orm !== "mongoose") {
948
- deps["@stratum-hq/db-adapters"] = "^0.2.0";
1612
+ deps["@stratum-hq/db-adapters"] = STRATUM_RANGES["@stratum-hq/db-adapters"];
949
1613
  }
950
1614
  if (preset.database === "mongodb") {
951
- deps["@stratum-hq/mongodb"] = "^0.2.0";
1615
+ deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
952
1616
  }
953
1617
  if (preset.database === "mysql") {
954
- deps["@stratum-hq/mysql"] = "^0.2.0";
1618
+ deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
955
1619
  }
956
1620
  if (preset.framework === "hono") {
957
- deps["@stratum-hq/hono"] = "^0.2.0";
1621
+ deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
958
1622
  }
959
1623
  if (preset.framework === "nestjs") {
960
- deps["@stratum-hq/nestjs"] = "^0.2.0";
1624
+ deps["@stratum-hq/nestjs"] = STRATUM_RANGES["@stratum-hq/nestjs"];
961
1625
  }
962
1626
  }
963
1627
  function getScripts(preset) {
964
1628
  if (preset.framework === "nextjs") {
965
1629
  return { dev: "next dev", build: "next build", start: "next start" };
966
1630
  }
967
- if (preset.framework === "nestjs") {
968
- return { dev: "node --watch src/main.ts", build: "tsc", start: "node dist/main.js" };
969
- }
970
- return { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" };
1631
+ const entry = preset.framework === "nestjs" ? "main" : "index";
1632
+ const emittedDir = rootSources(preset).length > 0 ? "dist/src" : "dist";
1633
+ const dev = preset.framework === "nestjs" ? `tsc-watch --onSuccess "node --env-file=.env ${emittedDir}/${entry}.js"` : `tsx watch --env-file=.env src/${entry}.ts`;
1634
+ return { dev, build: "tsc", start: `node ${emittedDir}/${entry}.js` };
971
1635
  }
972
1636
  function sortKeys(obj) {
973
1637
  const sorted = {};
@@ -979,7 +1643,6 @@ function sortKeys(obj) {
979
1643
 
980
1644
  // src/generators/readme.ts
981
1645
  function generatePresetReadme(projectName, preset) {
982
- const devCmd = getDevCommand(preset);
983
1646
  const dbStartCmd = getDbStartInfo(preset);
984
1647
  const dbSetupNote = getDbSetupNote(preset);
985
1648
  return `# ${projectName}
@@ -1014,7 +1677,7 @@ ${dbSetupNote}
1014
1677
  ### 4. Run the app
1015
1678
 
1016
1679
  \`\`\`bash
1017
- ${devCmd}
1680
+ npm run dev
1018
1681
  \`\`\`
1019
1682
 
1020
1683
  ## Multi-tenancy
@@ -1026,12 +1689,6 @@ ${getStrategyDescription(preset.strategy)}
1026
1689
  See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
1027
1690
  `;
1028
1691
  }
1029
- function getDevCommand(preset) {
1030
- if (preset.framework === "nextjs") return "npm run dev";
1031
- if (preset.framework === "nestjs") return "node --env-file=.env src/main.ts";
1032
- if (preset.framework === "none") return "node --env-file=.env src/index.ts";
1033
- return "node --env-file=.env src/index.ts";
1034
- }
1035
1692
  function getDbStartInfo(preset) {
1036
1693
  switch (preset.database) {
1037
1694
  case "postgres":
@@ -1068,7 +1725,7 @@ function getStrategyDescription(strategy) {
1068
1725
  switch (strategy) {
1069
1726
  case "rls":
1070
1727
  return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
1071
- - Each query sets \`app.current_tenant\` and RLS enforces isolation
1728
+ - Each query sets \`app.current_tenant_id\` and RLS enforces isolation
1072
1729
  - All tenants share one database and schema`;
1073
1730
  case "schema":
1074
1731
  return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
@@ -1172,27 +1829,7 @@ Creating ${projectName} with preset ${presetLabel}...
1172
1829
  );
1173
1830
  writeFile(
1174
1831
  path.join(targetDir, "tsconfig.json"),
1175
- JSON.stringify(
1176
- {
1177
- compilerOptions: {
1178
- target: "ESNext",
1179
- module: "NodeNext",
1180
- moduleResolution: "NodeNext",
1181
- strict: true,
1182
- outDir: "dist",
1183
- rootDir: "src",
1184
- declaration: true,
1185
- skipLibCheck: true,
1186
- esModuleInterop: true,
1187
- ...preset.framework === "nestjs" ? { experimentalDecorators: true, emitDecoratorMetadata: true } : {},
1188
- ...preset.framework === "nextjs" ? { jsx: "preserve", plugins: [{ name: "next" }] } : {}
1189
- },
1190
- include: ["src"],
1191
- exclude: ["node_modules", "dist"]
1192
- },
1193
- null,
1194
- 2
1195
- )
1832
+ generateTsconfig(preset.framework, rootSources(preset))
1196
1833
  );
1197
1834
  if (!skipInstall) {
1198
1835
  console.log("\nInstalling dependencies...\n");
@@ -1291,11 +1928,13 @@ function generatePackageJson(projectName, template) {
1291
1928
  react: "^19.0.0",
1292
1929
  "react-dom": "^19.0.0",
1293
1930
  "@types/react": "^19.0.0",
1294
- "@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"
1295
1934
  }
1296
1935
  };
1297
1936
  const deps = {
1298
- "@stratum-hq/lib": "^0.2.0",
1937
+ "@stratum-hq/lib": STRATUM_RANGES["@stratum-hq/lib"],
1299
1938
  pg: "^8.11.0",
1300
1939
  ...frameworkDeps[template]
1301
1940
  };
@@ -1305,11 +1944,15 @@ function generatePackageJson(projectName, template) {
1305
1944
  version: "0.1.0",
1306
1945
  private: true,
1307
1946
  type: "module",
1308
- scripts: template === "nextjs" ? { dev: "next dev", build: "next build", start: "next start" } : { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" },
1947
+ scripts: template === "nextjs" ? { dev: "next dev", build: "next build", start: "next start" } : (
1948
+ // Node 20 cannot run a .ts file, so dev runs the source through tsx.
1949
+ { dev: "tsx watch --env-file=.env src/index.ts", build: "tsc", start: "node dist/index.js" }
1950
+ ),
1309
1951
  dependencies: deps,
1310
1952
  devDependencies: {
1311
1953
  typescript: "^5.3.0",
1312
- "@types/node": "^20.11.0"
1954
+ "@types/node": "^20.11.0",
1955
+ ...template === "nextjs" ? {} : { tsx: "^4.7.0", "@types/pg": "^8.11.0" }
1313
1956
  },
1314
1957
  engines: {
1315
1958
  node: ">=20.0.0"
@@ -1458,7 +2101,7 @@ export default function Home() {
1458
2101
  <p>Multi-tenant app powered by Stratum.</p>
1459
2102
  <ul>
1460
2103
  <li>Configure tenants via the Stratum control plane</li>
1461
- <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
2104
+ <li>The tenant comes from a verified JWT in <code>middleware.ts</code></li>
1462
2105
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
1463
2106
  </ul>
1464
2107
  </main>
@@ -1466,34 +2109,7 @@ export default function Home() {
1466
2109
  }
1467
2110
  `;
1468
2111
  }
1469
- function generateNextjsMiddleware2() {
1470
- return `// middleware.ts \u2014 tenant resolution via subdomain
1471
- import { NextRequest, NextResponse } from "next/server";
1472
-
1473
- export function middleware(request: NextRequest) {
1474
- // The tenant comes from the subdomain the request was routed to. Any
1475
- // x-tenant-id the client sent is removed first, so server code that reads
1476
- // x-tenant-id only ever sees the value set here. Once you add
1477
- // authentication, check that the signed-in user belongs to this tenant.
1478
- const hostname = request.headers.get("host") || "";
1479
- const tenantId = hostname.split(".")[0];
1480
-
1481
- const requestHeaders = new Headers(request.headers);
1482
- requestHeaders.delete("x-tenant-id");
1483
- if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
1484
- requestHeaders.set("x-tenant-id", tenantId);
1485
- }
1486
-
1487
- return NextResponse.next({ request: { headers: requestHeaders } });
1488
- }
1489
-
1490
- export const config = {
1491
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
1492
- };
1493
- `;
1494
- }
1495
2112
  function generateReadme(projectName, template) {
1496
- const devCmd = template === "nextjs" ? "npm run dev" : "node --env-file=.env src/index.ts";
1497
2113
  return `# ${projectName}
1498
2114
 
1499
2115
  A multi-tenant application built with [Stratum](https://github.com/stratum-hq/Stratum).
@@ -1522,7 +2138,7 @@ npm install
1522
2138
  ### 4. Run the app
1523
2139
 
1524
2140
  \`\`\`bash
1525
- ${devCmd}
2141
+ npm run dev
1526
2142
  \`\`\`
1527
2143
 
1528
2144
  ## Project structure
@@ -1533,14 +2149,14 @@ ${projectName}/
1533
2149
  \u251C\u2500\u2500 docker-compose.yml
1534
2150
  \u251C\u2500\u2500 init.sql # DB extensions (uuid-ossp, ltree)
1535
2151
  \u251C\u2500\u2500 .env.example
1536
- \u2514\u2500\u2500 package.json
2152
+ ${template === "nextjs" ? "" : "\u251C\u2500\u2500 tsconfig.json\n"}\u2514\u2500\u2500 package.json
1537
2153
  \`\`\`
1538
2154
 
1539
2155
  ## Multi-tenancy
1540
2156
 
1541
2157
  This project uses Stratum for hierarchical multi-tenancy:
1542
2158
 
1543
- - **Tenant resolution** \u2014 via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
2159
+ - **Tenant resolution** \u2014 from the \`tenant_id\` claim of a bearer token verified with \`JWT_SECRET\` (see \`middleware.ts\`); the subdomain is only a display slug
1544
2160
  - **Config inheritance** \u2014 settings flow down the tenant tree with override support
1545
2161
  - **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
1546
2162
 
@@ -1560,11 +2176,13 @@ Creating ${projectName} with ${template} template...
1560
2176
  writeFile2(path2.join(targetDir, ".env.example"), generateEnv(projectName));
1561
2177
  if (template === "express") {
1562
2178
  writeFile2(path2.join(targetDir, "src", "index.ts"), generateExpressServer(projectName));
2179
+ writeFile2(path2.join(targetDir, "tsconfig.json"), generateTsconfig(template));
1563
2180
  } else if (template === "fastify") {
1564
2181
  writeFile2(path2.join(targetDir, "src", "index.ts"), generateFastifyServer(projectName));
2182
+ writeFile2(path2.join(targetDir, "tsconfig.json"), generateTsconfig(template));
1565
2183
  } else if (template === "nextjs") {
1566
2184
  writeFile2(path2.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
1567
- writeFile2(path2.join(targetDir, "middleware.ts"), generateNextjsMiddleware2());
2185
+ writeFile2(path2.join(targetDir, "middleware.ts"), nextjsTenantMiddleware());
1568
2186
  }
1569
2187
  writeFile2(path2.join(targetDir, "README.md"), generateReadme(projectName, template));
1570
2188
  if (!skipInstall) {
@@ -1639,26 +2257,9 @@ Success! Created ${projectName} at ${targetDir}
1639
2257
  if (skipInstall) {
1640
2258
  console.log(" npm install");
1641
2259
  }
1642
- if (preset) {
1643
- const parsed = parsePresetString(preset);
1644
- if (parsed.framework === "nextjs") {
1645
- console.log(" npm run dev");
1646
- } else if (parsed.framework === "nestjs") {
1647
- console.log(" node --env-file=.env src/main.ts");
1648
- } else {
1649
- console.log(" node --env-file=.env src/index.ts");
1650
- }
1651
- } else if (template === "nextjs") {
1652
- console.log(" npm run dev");
1653
- } else {
1654
- console.log(" node --env-file=.env src/index.ts");
1655
- }
2260
+ console.log(" npm run dev");
1656
2261
  console.log("");
1657
2262
  }
1658
- var __filename = fileURLToPath(import.meta.url);
1659
- if (process.argv[1] === __filename) {
1660
- main(process.argv.slice(2));
1661
- }
1662
2263
  export {
1663
2264
  createProject,
1664
2265
  main,