@stratum-hq/create 0.3.0 → 0.4.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.
@@ -1,5 +1,46 @@
1
1
  import type { StackPreset } from "../matrix.js";
2
2
 
3
+ /** Name of the non-superuser role a generated app connects as. */
4
+ export function postgresAppRole(dbName: string): string {
5
+ return `${dbName}_app`;
6
+ }
7
+
8
+ /** Local development password for the generated application role. */
9
+ export const POSTGRES_APP_PASSWORD = "dev_app_password";
10
+
11
+ /**
12
+ * SQL that creates the role the application connects as. POSTGRES_USER is a
13
+ * superuser, and a superuser (or any BYPASSRLS role) ignores every row-level
14
+ * security policy, FORCE included. So the app gets its own role, and the
15
+ * superuser is kept for bootstrap and migrations.
16
+ */
17
+ export function postgresAppRoleSql(dbName: string, strategy?: string): string {
18
+ const role = postgresAppRole(dbName);
19
+ let strategyGrant = "";
20
+ if (strategy === "schema") {
21
+ strategyGrant = `
22
+ -- schema-per-tenant: the app creates one schema per tenant
23
+ GRANT CREATE ON DATABASE ${dbName} TO ${role};
24
+ `;
25
+ } else if (strategy === "database") {
26
+ strategyGrant = `
27
+ -- database-per-tenant: the app creates one database per tenant
28
+ ALTER ROLE ${role} CREATEDB;
29
+ `;
30
+ }
31
+ return `
32
+ -- Application role. The app connects as ${role} (DATABASE_URL), never as the
33
+ -- bootstrap superuser (DATABASE_ADMIN_URL): a superuser or BYPASSRLS role
34
+ -- ignores every row-level security policy, FORCE included. Use the superuser
35
+ -- only for bootstrap and migrations.
36
+ CREATE ROLE ${role} WITH LOGIN PASSWORD '${POSTGRES_APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS NOCREATEROLE;
37
+ GRANT CONNECT ON DATABASE ${dbName} TO ${role};
38
+ GRANT USAGE, CREATE ON SCHEMA public TO ${role};
39
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role};
40
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role};
41
+ ${strategyGrant}`;
42
+ }
43
+
3
44
  export function generatePresetInitSql(projectName: string, preset: StackPreset): string | null {
4
45
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
5
46
 
@@ -22,10 +63,13 @@ function generatePostgresInit(projectName: string, dbName: string, strategy: str
22
63
  -- Add RLS policies to each tenant-scoped table:
23
64
  --
24
65
  -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
66
+ -- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
25
67
  -- CREATE POLICY tenant_isolation ON your_table
26
- -- USING (tenant_id = current_setting('app.current_tenant')::uuid);
68
+ -- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
27
69
  --
28
- -- The Stratum db-adapters package sets app.current_tenant automatically.
70
+ -- FORCE makes the policy apply to the table owner too; without it, a table
71
+ -- created by the application role is not isolated for that role.
72
+ -- The Stratum db-adapters package sets app.current_tenant_id automatically.
29
73
  `;
30
74
  }
31
75
 
@@ -37,7 +81,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
37
81
  -- The ltree extension enables hierarchical tenant trees
38
82
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
39
83
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
40
- ${rlsBlock}`;
84
+ ${postgresAppRoleSql(dbName, strategy)}${rlsBlock}`;
41
85
  }
42
86
 
43
87
  function generateMysqlInit(projectName: string, dbName: string): string {
@@ -33,11 +33,13 @@ const port = Number(process.env.PORT) || 3000;
33
33
 
34
34
  app.use(express.json());
35
35
 
36
- // Tenant extraction middleware
36
+ // Tenant extraction middleware. The tenant comes from the subdomain the
37
+ // request was routed to. Do not take it from a client-supplied header such as
38
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
39
+ // authentication, check that the signed-in user belongs to this tenant, or
40
+ // derive the tenant from the verified session or JWT instead.
37
41
  app.use((req, _res, next) => {
38
- const tenantId =
39
- req.headers["x-tenant-id"] as string ||
40
- req.hostname.split(".")[0];
42
+ const tenantId = req.hostname.split(".")[0];
41
43
  (req as any).tenantId = tenantId;
42
44
  next();
43
45
  });
@@ -68,12 +70,14 @@ function generateFastifyMiddleware(projectName: string): MiddlewareFile[] {
68
70
  const fastify = Fastify({ logger: true });
69
71
  const port = Number(process.env.PORT) || 3000;
70
72
 
71
- // Tenant extraction plugin
73
+ // Tenant extraction hook. The tenant comes from the subdomain the
74
+ // request was routed to. Do not take it from a client-supplied header such as
75
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
76
+ // authentication, check that the signed-in user belongs to this tenant, or
77
+ // derive the tenant from the verified session or JWT instead.
72
78
  fastify.decorateRequest("tenantId", "");
73
79
  fastify.addHook("onRequest", async (request) => {
74
- const tenantId =
75
- (request.headers["x-tenant-id"] as string) ||
76
- (request.hostname?.split(".")[0] ?? "");
80
+ const tenantId = request.hostname?.split(".")[0] ?? "";
77
81
  (request as any).tenantId = tenantId;
78
82
  });
79
83
 
@@ -105,14 +109,15 @@ function generateNextjsMiddleware(projectName: string): MiddlewareFile[] {
105
109
  import { NextRequest, NextResponse } from "next/server";
106
110
 
107
111
  export function middleware(request: NextRequest) {
112
+ // The tenant comes from the subdomain the request was routed to. Any
113
+ // x-tenant-id the client sent is removed first, so server code that reads
114
+ // x-tenant-id only ever sees the value set here. Once you add
115
+ // authentication, check that the signed-in user belongs to this tenant.
108
116
  const hostname = request.headers.get("host") || "";
109
- const subdomain = hostname.split(".")[0];
110
- const headerTenantId = request.headers.get("x-tenant-id");
111
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
112
-
113
- const tenantId = headerTenantId || pathTenantId || subdomain;
117
+ const tenantId = hostname.split(".")[0];
114
118
 
115
119
  const requestHeaders = new Headers(request.headers);
120
+ requestHeaders.delete("x-tenant-id");
116
121
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
117
122
  requestHeaders.set("x-tenant-id", tenantId);
118
123
  }
@@ -135,7 +140,7 @@ export default function Home() {
135
140
  <p>Multi-tenant app powered by Stratum.</p>
136
141
  <ul>
137
142
  <li>Configure tenants via the Stratum control plane</li>
138
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
143
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
139
144
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
140
145
  </ul>
141
146
  </main>
@@ -155,11 +160,13 @@ import { serve } from "@hono/node-server";
155
160
 
156
161
  const app = new Hono();
157
162
 
158
- // Tenant extraction middleware
163
+ // Tenant extraction middleware. The tenant comes from the subdomain the
164
+ // request was routed to. Do not take it from a client-supplied header such as
165
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
166
+ // authentication, check that the signed-in user belongs to this tenant, or
167
+ // derive the tenant from the verified session or JWT instead.
159
168
  app.use("*", async (c, next) => {
160
- const tenantId =
161
- c.req.header("x-tenant-id") ||
162
- new URL(c.req.url).hostname.split(".")[0];
169
+ const tenantId = new URL(c.req.url).hostname.split(".")[0];
163
170
  c.set("tenantId", tenantId);
164
171
  await next();
165
172
  });
@@ -239,9 +246,11 @@ export class AppController {
239
246
  export class TenantGuard implements CanActivate {
240
247
  canActivate(context: ExecutionContext): boolean {
241
248
  const request = context.switchToHttp().getRequest();
242
- const tenantId =
243
- request.headers["x-tenant-id"] ||
244
- request.hostname?.split(".")[0];
249
+ // The tenant comes from the subdomain the request was routed to. Do not
250
+ // take it from a client-supplied header such as x-tenant-id: any caller
251
+ // can set one and pick another tenant. Once you add authentication, check
252
+ // that the signed-in user belongs to this tenant here.
253
+ const tenantId = request.hostname?.split(".")[0];
245
254
  request.tenantId = tenantId || null;
246
255
  return true;
247
256
  }
package/src/index.ts CHANGED
@@ -5,8 +5,9 @@ import * as path from "path";
5
5
  import crypto from "node:crypto";
6
6
  import { execSync } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
- import { parsePresetString, isValidPreset, formatPresetString } from "./matrix.js";
8
+ import { parsePresetString, isValidPreset } from "./matrix.js";
9
9
  import { createPresetProject } from "./preset-project.js";
10
+ import { postgresAppRole, postgresAppRoleSql, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
10
11
 
11
12
  // ─── Types ────────────────────────────────────────────────────────────────────
12
13
 
@@ -194,7 +195,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
194
195
  -- The ltree extension enables hierarchical tenant trees
195
196
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
196
197
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
197
- `;
198
+ ${postgresAppRoleSql(dbName)}`;
198
199
  }
199
200
 
200
201
  function generateEnv(projectName: string): string {
@@ -203,8 +204,12 @@ function generateEnv(projectName: string): string {
203
204
  return `# Environment variables for ${projectName}
204
205
  # Copy to .env and fill in values
205
206
 
206
- # Database
207
- DATABASE_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
207
+ # Database. The app connects as the non-superuser role created in init.sql,
208
+ # so row-level security applies to it.
209
+ DATABASE_URL=postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}
210
+
211
+ # Superuser: bootstrap and migrations only. It bypasses row-level security.
212
+ DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
208
213
 
209
214
  # Authentication
210
215
  JWT_SECRET=${jwtSecret}
@@ -295,7 +300,7 @@ export default function Home() {
295
300
  <p>Multi-tenant app powered by Stratum.</p>
296
301
  <ul>
297
302
  <li>Configure tenants via the Stratum control plane</li>
298
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
303
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
299
304
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
300
305
  </ul>
301
306
  </main>
@@ -305,18 +310,19 @@ export default function Home() {
305
310
  }
306
311
 
307
312
  function generateNextjsMiddleware(): string {
308
- return `// middleware.ts — tenant resolution via subdomain or header
313
+ return `// middleware.ts — tenant resolution via subdomain
309
314
  import { NextRequest, NextResponse } from "next/server";
310
315
 
311
316
  export function middleware(request: NextRequest) {
317
+ // The tenant comes from the subdomain the request was routed to. Any
318
+ // x-tenant-id the client sent is removed first, so server code that reads
319
+ // x-tenant-id only ever sees the value set here. Once you add
320
+ // authentication, check that the signed-in user belongs to this tenant.
312
321
  const hostname = request.headers.get("host") || "";
313
- const subdomain = hostname.split(".")[0];
314
- const headerTenantId = request.headers.get("x-tenant-id");
315
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
316
-
317
- const tenantId = headerTenantId || pathTenantId || subdomain;
322
+ const tenantId = hostname.split(".")[0];
318
323
 
319
324
  const requestHeaders = new Headers(request.headers);
325
+ requestHeaders.delete("x-tenant-id");
320
326
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
321
327
  requestHeaders.set("x-tenant-id", tenantId);
322
328
  }
@@ -380,7 +386,7 @@ ${projectName}/
380
386
 
381
387
  This project uses Stratum for hierarchical multi-tenancy:
382
388
 
383
- - **Tenant resolution** — via JWT claim, subdomain, or \`x-tenant-id\` header
389
+ - **Tenant resolution** — via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
384
390
  - **Config inheritance** — settings flow down the tenant tree with override support
385
391
  - **Permission ABAC** — role-based permissions with tenant-scoped enforcement
386
392
 
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
4
4
  import { execSync } from "child_process";
5
5
  import type { StackPreset } from "./matrix.js";
6
6
  import { generatePresetDockerCompose } from "./generators/docker-compose.js";
7
- import { generatePresetInitSql } from "./generators/init-sql.js";
7
+ import { generatePresetInitSql, postgresAppRole, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
8
8
  import { generateDbSetup } from "./generators/db-setup.js";
9
9
  import { generateMiddleware } from "./generators/middleware.js";
10
10
  import { generatePresetPackageJson } from "./generators/package-json.js";
@@ -24,9 +24,14 @@ function generatePresetEnv(projectName: string, preset: StackPreset): string {
24
24
  const jwtSecret = crypto.randomBytes(32).toString("base64url");
25
25
 
26
26
  let dbUrl: string;
27
+ let adminUrlLine = "";
27
28
  switch (preset.database) {
28
29
  case "postgres":
29
- dbUrl = `postgres://${dbName}:dev_password@localhost:5432/${dbName}`;
30
+ // The app connects as the non-superuser role created in init.sql, so
31
+ // row-level security applies to it. The superuser URL is for bootstrap
32
+ // and migrations only.
33
+ dbUrl = `postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}`;
34
+ adminUrlLine = `\n# Superuser: bootstrap and migrations only. It bypasses row-level security.\nDATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}\n`;
30
35
  break;
31
36
  case "mongodb":
32
37
  dbUrl = `mongodb://${dbName}:dev_password@localhost:27017/${dbName}?authSource=admin`;
@@ -43,7 +48,7 @@ function generatePresetEnv(projectName: string, preset: StackPreset): string {
43
48
 
44
49
  # Database
45
50
  ${urlKey}=${dbUrl}
46
-
51
+ ${adminUrlLine}
47
52
  # Authentication
48
53
  JWT_SECRET=${jwtSecret}
49
54
 
@@ -1,217 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import * as fs from "fs";
3
- import * as path from "path";
4
- import * as os from "os";
5
- import { parseArgs, createProject, validateProjectName } from "../index.js";
6
-
7
- // ─── parseArgs tests ──────────────────────────────────────────────────────────
8
-
9
- describe("parseArgs", () => {
10
- it("extracts project name from positional arg", () => {
11
- const result = parseArgs(["my-app"]);
12
- expect(result.projectName).toBe("my-app");
13
- });
14
-
15
- it("defaults template to express", () => {
16
- const result = parseArgs(["my-app"]);
17
- expect(result.template).toBe("express");
18
- });
19
-
20
- it("parses --template flag", () => {
21
- const result = parseArgs(["my-app", "--template", "fastify"]);
22
- expect(result.template).toBe("fastify");
23
- });
24
-
25
- it("parses --template=value syntax", () => {
26
- const result = parseArgs(["my-app", "--template=nextjs"]);
27
- expect(result.template).toBe("nextjs");
28
- });
29
-
30
- it("parses --skip-install flag", () => {
31
- const result = parseArgs(["my-app", "--skip-install"]);
32
- expect(result.skipInstall).toBe(true);
33
- });
34
-
35
- it("parses --force flag", () => {
36
- const result = parseArgs(["my-app", "--force"]);
37
- expect(result.force).toBe(true);
38
- });
39
-
40
- it("returns null projectName when no positional arg given", () => {
41
- const result = parseArgs(["--template", "express"]);
42
- expect(result.projectName).toBeNull();
43
- });
44
-
45
- it("skipInstall defaults to false", () => {
46
- const result = parseArgs(["my-app"]);
47
- expect(result.skipInstall).toBe(false);
48
- });
49
-
50
- it("force defaults to false", () => {
51
- const result = parseArgs(["my-app"]);
52
- expect(result.force).toBe(false);
53
- });
54
- });
55
-
56
- // ─── Error cases ──────────────────────────────────────────────────────────────
57
-
58
- describe("main arg validation", () => {
59
- it("errors when no project name given", () => {
60
- // parseArgs returns null projectName, which main() would exit on
61
- const result = parseArgs([]);
62
- expect(result.projectName).toBeNull();
63
- });
64
- });
65
-
66
- // ─── File generation tests ────────────────────────────────────────────────────
67
-
68
- describe("createProject", () => {
69
- let tmpDir: string;
70
- let projectDir: string;
71
-
72
- beforeEach(() => {
73
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stratum-create-test-"));
74
- projectDir = path.join(tmpDir, "test-project");
75
- fs.mkdirSync(projectDir, { recursive: true });
76
- });
77
-
78
- afterEach(() => {
79
- fs.rmSync(tmpDir, { recursive: true, force: true });
80
- });
81
-
82
- it("generates correct files for express template", () => {
83
- createProject("test-project", "express", projectDir, true);
84
-
85
- expect(fs.existsSync(path.join(projectDir, "package.json"))).toBe(true);
86
- expect(fs.existsSync(path.join(projectDir, "docker-compose.yml"))).toBe(true);
87
- expect(fs.existsSync(path.join(projectDir, ".env.example"))).toBe(true);
88
- expect(fs.existsSync(path.join(projectDir, "src", "index.ts"))).toBe(true);
89
- expect(fs.existsSync(path.join(projectDir, "README.md"))).toBe(true);
90
- expect(fs.existsSync(path.join(projectDir, "init.sql"))).toBe(true);
91
- });
92
-
93
- it("generates correct files for fastify template", () => {
94
- createProject("test-project", "fastify", projectDir, true);
95
-
96
- expect(fs.existsSync(path.join(projectDir, "src", "index.ts"))).toBe(true);
97
- const serverContent = fs.readFileSync(
98
- path.join(projectDir, "src", "index.ts"),
99
- "utf8",
100
- );
101
- expect(serverContent).toContain("Fastify");
102
- });
103
-
104
- it("generates correct files for nextjs template", () => {
105
- createProject("test-project", "nextjs", projectDir, true);
106
-
107
- expect(fs.existsSync(path.join(projectDir, "src", "app", "page.tsx"))).toBe(true);
108
- expect(fs.existsSync(path.join(projectDir, "middleware.ts"))).toBe(true);
109
- });
110
-
111
- it("package.json contains @stratum-hq/lib dependency", () => {
112
- createProject("test-project", "express", projectDir, true);
113
-
114
- const pkgContent = fs.readFileSync(path.join(projectDir, "package.json"), "utf8");
115
- const pkg = JSON.parse(pkgContent);
116
- expect(pkg.dependencies["@stratum-hq/lib"]).toBeDefined();
117
- });
118
-
119
- it("package.json contains pg dependency", () => {
120
- createProject("test-project", "express", projectDir, true);
121
-
122
- const pkgContent = fs.readFileSync(path.join(projectDir, "package.json"), "utf8");
123
- const pkg = JSON.parse(pkgContent);
124
- expect(pkg.dependencies["pg"]).toBeDefined();
125
- });
126
-
127
- it("package.json contains express dependency for express template", () => {
128
- createProject("test-project", "express", projectDir, true);
129
-
130
- const pkgContent = fs.readFileSync(path.join(projectDir, "package.json"), "utf8");
131
- const pkg = JSON.parse(pkgContent);
132
- expect(pkg.dependencies["express"]).toBeDefined();
133
- });
134
-
135
- it("docker-compose.yml contains PostgreSQL 16", () => {
136
- createProject("test-project", "express", projectDir, true);
137
-
138
- const dockerContent = fs.readFileSync(
139
- path.join(projectDir, "docker-compose.yml"),
140
- "utf8",
141
- );
142
- expect(dockerContent).toContain("postgres:16-alpine");
143
- });
144
-
145
- it("init.sql enables uuid-ossp and ltree extensions", () => {
146
- createProject("test-project", "express", projectDir, true);
147
-
148
- const sqlContent = fs.readFileSync(path.join(projectDir, "init.sql"), "utf8");
149
- expect(sqlContent).toContain("uuid-ossp");
150
- expect(sqlContent).toContain("ltree");
151
- });
152
-
153
- it(".env.example contains DATABASE_URL", () => {
154
- createProject("test-project", "express", projectDir, true);
155
-
156
- const envContent = fs.readFileSync(path.join(projectDir, ".env.example"), "utf8");
157
- expect(envContent).toContain("DATABASE_URL");
158
- });
159
-
160
- it("default template is express", () => {
161
- const result = parseArgs(["my-app"]);
162
- expect(result.template).toBe("express");
163
- });
164
- });
165
-
166
- // ─── Project name validation ──────────────────────────────────────────────────
167
-
168
- describe("validateProjectName", () => {
169
- it("accepts a valid name", () => {
170
- expect(validateProjectName("valid-name")).toBeNull();
171
- });
172
-
173
- it("accepts names with dots and underscores", () => {
174
- expect(validateProjectName("my.app_1")).toBeNull();
175
- });
176
-
177
- it("rejects empty string", () => {
178
- expect(validateProjectName("")).not.toBeNull();
179
- });
180
-
181
- it("rejects name with spaces", () => {
182
- expect(validateProjectName("name with spaces")).not.toBeNull();
183
- });
184
-
185
- it("rejects path traversal with ../", () => {
186
- expect(validateProjectName("../escape")).not.toBeNull();
187
- });
188
-
189
- it("rejects absolute path", () => {
190
- expect(validateProjectName("/absolute/path")).not.toBeNull();
191
- });
192
-
193
- it("rejects name starting with a dot", () => {
194
- expect(validateProjectName(".hidden")).not.toBeNull();
195
- });
196
-
197
- it("rejects name with path separator embedded", () => {
198
- expect(validateProjectName("foo/bar")).not.toBeNull();
199
- });
200
- });
201
-
202
- // ─── Directory existence error ────────────────────────────────────────────────
203
-
204
- describe("directory existence check", () => {
205
- it("errors when directory exists without --force", () => {
206
- // parseArgs doesn't control this — main() does the fs.existsSync check.
207
- // We verify that parseArgs returns force=false by default, which triggers the error.
208
- const result = parseArgs(["existing-dir"]);
209
- expect(result.force).toBe(false);
210
- // When force is false and dir exists, main() calls process.exit(1)
211
- });
212
-
213
- it("--force flag allows overwriting existing directory", () => {
214
- const result = parseArgs(["existing-dir", "--force"]);
215
- expect(result.force).toBe(true);
216
- });
217
- });