@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/CHANGELOG.md +23 -0
- package/README.md +7 -2
- package/dist/bin.js +3 -0
- package/dist/index.js +784 -183
- package/package.json +3 -3
- package/src/bin.ts +7 -0
- package/src/generators/db-setup.ts +91 -26
- package/src/generators/init-sql.ts +4 -1
- package/src/generators/middleware.ts +177 -57
- package/src/generators/package-json.ts +35 -12
- package/src/generators/readme.ts +2 -10
- package/src/generators/tsconfig.ts +48 -0
- package/src/index.ts +17 -60
- package/src/preset-project.ts +2 -25
- package/src/stratum-versions.ts +20 -0
- package/turbo.json +19 -0
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:
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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:
|
|
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>
|
|
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
|
|
713
|
-
//
|
|
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 =
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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": "
|
|
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
|
-
|
|
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"] = "
|
|
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"] = "
|
|
1615
|
+
deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
|
|
952
1616
|
}
|
|
953
1617
|
if (preset.database === "mysql") {
|
|
954
|
-
deps["@stratum-hq/mysql"] = "
|
|
1618
|
+
deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
|
|
955
1619
|
}
|
|
956
1620
|
if (preset.framework === "hono") {
|
|
957
|
-
deps["@stratum-hq/hono"] = "
|
|
1621
|
+
deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
|
|
958
1622
|
}
|
|
959
1623
|
if (preset.framework === "nestjs") {
|
|
960
|
-
deps["@stratum-hq/nestjs"] = "
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
return { dev
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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": "
|
|
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" } :
|
|
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>
|
|
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
|
-
|
|
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
|
|
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"),
|
|
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
|
-
|
|
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,
|