@getstrata/starter 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/generate.ts
5
5
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
6
- import { join as join2, resolve } from "path";
6
+ import { basename, join as join2, resolve } from "path";
7
7
 
8
8
  // src/copy.ts
9
9
  import {
@@ -110,6 +110,12 @@ function usesTenantTable(tenancy) {
110
110
  function htmlAuthKit(auth) {
111
111
  return authUsesCookie(auth);
112
112
  }
113
+ function needsFrontendBuild(frontend) {
114
+ return frontend === "spa-react" || frontend === "hybrid";
115
+ }
116
+ function nowTimestampLiteral(database) {
117
+ return database === "mysql" ? `new Date().toISOString().slice(0, 19).replace("T", " ")` : "new Date().toISOString()";
118
+ }
113
119
  function extraApplies(extra, auth) {
114
120
  if (extra === "metrics") {
115
121
  return true;
@@ -485,6 +491,20 @@ function applyDockerFlags(layers, flags) {
485
491
  }
486
492
  return layers;
487
493
  }
494
+ function extraFlagName(extra) {
495
+ return extra === "emailVerification" ? "email-verification" : extra;
496
+ }
497
+ function dropInapplicableExtras(layers, flags) {
498
+ for (const key of Object.keys(layers.extras)) {
499
+ if (extraApplies(key, layers.auth)) {
500
+ continue;
501
+ }
502
+ if (flags.extras[key] === true) {
503
+ console.warn(`Ignoring --${extraFlagName(key)}: not available with --auth ${layers.auth}.`);
504
+ }
505
+ layers.extras[key] = false;
506
+ }
507
+ }
488
508
  function applyFlagOverrides(base, flags) {
489
509
  const next = {
490
510
  ...base,
@@ -499,8 +519,10 @@ function applyFlagOverrides(base, flags) {
499
519
  extras: { ...base.extras, ...flags.extras }
500
520
  };
501
521
  if (next.database !== "postgres" && next.tenancy === "rls") {
522
+ console.warn(`Using --tenancy column: rls is Postgres-only (SET LOCAL app.tenant_id) and ${next.database} has no equivalent.`);
502
523
  next.tenancy = "column";
503
524
  }
525
+ dropInapplicableExtras(next, flags);
504
526
  return reconcileDocker(applyDockerFlags(next, flags));
505
527
  }
506
528
  function layersFromFlags(flags) {
@@ -1113,16 +1135,13 @@ function sqlTrue(layers) {
1113
1135
  }
1114
1136
  function renderPendingMfaTs() {
1115
1137
  return `import { createHmac, timingSafeEqual } from "node:crypto";
1138
+ import { sessionSecret } from "./config.ts";
1116
1139
 
1117
1140
  const COOKIE = "strata_mfa_pending";
1118
1141
 
1119
- function secret(): string {
1120
- return process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch";
1121
- }
1122
-
1123
1142
  function sign(userId: number, issuedAt: number): string {
1124
1143
  const payload = \`\${userId}.\${issuedAt}\`;
1125
- const signature = createHmac("sha256", secret()).update(payload).digest("hex");
1144
+ const signature = createHmac("sha256", sessionSecret()).update(payload).digest("hex");
1126
1145
  return \`\${payload}.\${signature}\`;
1127
1146
  }
1128
1147
 
@@ -1203,6 +1222,8 @@ function renderAuthModule(layers) {
1203
1222
  imports.push(`import { hashPassword, verifyPassword } from "@getstrata/core/auth/password";`);
1204
1223
  if (authUsesToken(layers.auth)) {
1205
1224
  imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
1225
+ imports.push(`import { sqlTimestamp } from "@getstrata/core/database/dialect";`);
1226
+ imports.push(`import { resolveDefaultTokenExpiryDays } from "@getstrata/core/security/tokenExpiry";`);
1206
1227
  }
1207
1228
  if (mfa) {
1208
1229
  imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
@@ -1261,11 +1282,22 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1261
1282
  return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
1262
1283
  }
1263
1284
  const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
1285
+ // API_TOKEN_DEFAULT_EXPIRY_DAYS bounds every minted token; unset means no expiry.
1286
+ const expiryDays = resolveDefaultTokenExpiryDays();
1287
+ const expiresAt = expiryDays
1288
+ ? new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000)
1289
+ : null;
1264
1290
  await getSql().unsafe(
1265
- "INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${ph(layers, 4)})",
1266
- [user.id, "spa", hashApiToken(plain), JSON.stringify(["profile:read"])],
1291
+ "INSERT INTO api_tokens (user_id, name, token_hash, abilities, expires_at) VALUES (${ph(layers, 5)})",
1292
+ [
1293
+ user.id,
1294
+ "spa",
1295
+ hashApiToken(plain),
1296
+ JSON.stringify(["profile:read"]),
1297
+ expiresAt ? sqlTimestamp(expiresAt) : null,
1298
+ ],
1267
1299
  );
1268
- return jsonResponse({ token: plain });
1300
+ return jsonResponse({ token: plain, expires_at: expiresAt?.toISOString() ?? null });
1269
1301
  })),
1270
1302
  },
1271
1303
  "/api/v1/auth/me": {
@@ -1372,7 +1404,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1372
1404
  }
1373
1405
  await getSql().unsafe(
1374
1406
  "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1375
- [new Date().toISOString(), id],
1407
+ [${nowTimestampLiteral(layers.database)}, id],
1376
1408
  );
1377
1409
  return jsonResponse({ ok: true });
1378
1410
  })),
@@ -1533,7 +1565,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1533
1565
  if (Number.isInteger(id) && id > 0) {
1534
1566
  await getSql().unsafe(
1535
1567
  "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1536
- [new Date().toISOString(), id],
1568
+ [${nowTimestampLiteral(layers.database)}, id],
1537
1569
  );
1538
1570
  const record = await starterAuthDirectory.findByIdOrThrow(id);
1539
1571
  return flashResponse(
@@ -1668,17 +1700,27 @@ export default authModule;
1668
1700
  function renderSiteModule(_layers) {
1669
1701
  return `import type { AppModule } from "@getstrata/bootstrap/contracts";
1670
1702
  import { withErrorHandling } from "@getstrata/core/http/response";
1671
- import { pingDatabase } from "../../bootstrap/database.ts";
1703
+ import { getSql, pingDatabase } from "../../bootstrap/database.ts";
1672
1704
  import { plainText, renderPage } from "../../lib/view.ts";
1673
1705
 
1706
+ // Proves the database answers and the schema is migrated. Point it at a table your app owns.
1707
+ async function schemaReady(): Promise<boolean> {
1708
+ try {
1709
+ await getSql().unsafe("SELECT 1 FROM notes LIMIT 1");
1710
+ return true;
1711
+ } catch {
1712
+ return false;
1713
+ }
1714
+ }
1715
+
1674
1716
  const siteModule: AppModule = {
1675
1717
  name: "site",
1676
1718
  order: 1,
1677
1719
  routes({ kernel }) {
1678
1720
  return {
1679
1721
  "/health": kernel.wrap("api", withErrorHandling(async () => {
1680
- const dbOk = await pingDatabase();
1681
- return plainText(dbOk ? "ok" : "degraded");
1722
+ const ok = (await pingDatabase()) && (await schemaReady());
1723
+ return plainText(ok ? "ok" : "degraded", ok ? 200 : 503);
1682
1724
  })),
1683
1725
  };
1684
1726
  },
@@ -2009,7 +2051,7 @@ function renderAuthDirectory(layers) {
2009
2051
  }
2010
2052
  const hashed = hashApiToken(token);
2011
2053
  const rows = await getSql().unsafe<
2012
- Array<{
2054
+ {
2013
2055
  id: number;
2014
2056
  user_id: number;
2015
2057
  abilities: string;
@@ -2017,7 +2059,7 @@ function renderAuthDirectory(layers) {
2017
2059
  role?: string;
2018
2060
  is_admin?: number | boolean;
2019
2061
  email_verified_at?: Date | string | null;
2020
- }>
2062
+ }
2021
2063
  >(
2022
2064
  \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
2023
2065
  FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
@@ -2052,83 +2094,74 @@ function renderAuthDirectory(layers) {
2052
2094
  const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
2053
2095
  ` : "";
2054
2096
  const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
2097
+ const mfaColumns = layers.extras.mfa ? `
2098
+ mfa_enabled?: number | boolean;
2099
+ mfa_secret?: string | null;
2100
+ mfa_recovery_codes?: string | null;` : "";
2055
2101
  const mfaReturn = layers.extras.mfa ? `
2056
- mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
2057
- mfa_secret: row.mfa_secret ?? null,
2058
- mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
2102
+ mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
2103
+ mfa_secret: row.mfa_secret ?? null,
2104
+ mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
2105
+ const userColumns = `id, name, email, is_admin, email_verified_at, password${mfaSelect}`;
2059
2106
  return `import type { AuthUser } from "@getstrata/core/auth/authContext";
2060
2107
  import { verifyPassword } from "@getstrata/core/auth/password";
2061
2108
  ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
2062
2109
  import { getSql } from "./database.ts";
2063
2110
 
2111
+ type UserRow = {
2112
+ id: number;
2113
+ name: string;
2114
+ email: string;
2115
+ is_admin: number | boolean;
2116
+ email_verified_at: Date | string | null;
2117
+ password: string;${mfaColumns}
2118
+ };
2119
+
2064
2120
  function mapRole(isAdmin: unknown): string {
2065
2121
  return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
2066
2122
  }
2067
2123
 
2124
+ function mapUserRow(row: UserRow) {
2125
+ return {
2126
+ id: Number(row.id),
2127
+ name: row.name,
2128
+ email: row.email,
2129
+ role: mapRole(row.is_admin),
2130
+ email_verified_at: row.email_verified_at ?? null,
2131
+ password: row.password,${mfaReturn}
2132
+ };
2133
+ }
2134
+
2135
+ async function findUserById(id: number) {
2136
+ const rows = await getSql().unsafe<UserRow>(
2137
+ "SELECT ${userColumns} FROM users WHERE id = ${placeholder}",
2138
+ [id],
2139
+ );
2140
+ const row = rows[0];
2141
+ if (!row) {
2142
+ throw new Error(\`User \${id} not found.\`);
2143
+ }
2144
+ return mapUserRow(row);
2145
+ }
2146
+
2147
+ async function findUserByEmail(email: string) {
2148
+ const rows = await getSql().unsafe<UserRow>(
2149
+ "SELECT ${userColumns} FROM users WHERE email = ${placeholder}",
2150
+ [email.trim().toLowerCase()],
2151
+ );
2152
+ const row = rows[0];
2153
+ return row ? mapUserRow(row) : null;
2154
+ }
2155
+
2068
2156
  export const starterAuthDirectory: AuthUserDirectory = {
2069
2157
  ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
2070
2158
 
2071
- async findByIdOrThrow(id: number) {
2072
- const rows = await getSql().unsafe<
2073
- Array<{
2074
- id: number;
2075
- name: string;
2076
- email: string;
2077
- is_admin: number | boolean;
2078
- email_verified_at: Date | string | null;
2079
- password: string;
2080
- mfa_enabled?: number | boolean;
2081
- mfa_secret?: string | null;
2082
- mfa_recovery_codes?: string | null;
2083
- }>
2084
- >(\`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE id = ${placeholder}\`, [id]);
2085
- const row = rows[0];
2086
- if (!row) {
2087
- throw new Error(\`User \${id} not found.\`);
2088
- }
2089
- return {
2090
- id: Number(row.id),
2091
- name: row.name,
2092
- email: row.email,
2093
- role: mapRole(row.is_admin),
2094
- email_verified_at: row.email_verified_at ?? null,
2095
- password: row.password,${mfaReturn}
2096
- };
2097
- },
2159
+ findByIdOrThrow: findUserById,
2098
2160
 
2099
- async findByEmail(email: string) {
2100
- const rows = await getSql().unsafe<
2101
- Array<{
2102
- id: number;
2103
- name: string;
2104
- email: string;
2105
- is_admin: number | boolean;
2106
- email_verified_at: Date | string | null;
2107
- password: string;
2108
- mfa_enabled?: number | boolean;
2109
- mfa_secret?: string | null;
2110
- mfa_recovery_codes?: string | null;
2111
- }>
2112
- >(
2113
- \`SELECT id, name, email, is_admin, email_verified_at, password${mfaSelect} FROM users WHERE email = ${placeholder}\`,
2114
- [email.trim().toLowerCase()],
2115
- );
2116
- const row = rows[0];
2117
- if (!row) {
2118
- return null;
2119
- }
2120
- return {
2121
- id: Number(row.id),
2122
- name: row.name,
2123
- email: row.email,
2124
- role: mapRole(row.is_admin),
2125
- email_verified_at: row.email_verified_at ?? null,
2126
- password: row.password,${mfaReturn}
2127
- };
2128
- },
2161
+ findByEmail: findUserByEmail,
2129
2162
 
2130
2163
  async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
2131
- const user = await this.findByEmail(email);
2164
+ const user = await findUserByEmail(email);
2132
2165
  if (!user?.password || !(await verifyPassword(password, user.password))) {
2133
2166
  return null;
2134
2167
  }
@@ -2187,7 +2220,7 @@ export default authProvider;
2187
2220
  `;
2188
2221
  }
2189
2222
  const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
2190
- secret: process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch",
2223
+ secret: sessionSecret(),
2191
2224
  cookieName: "strata_session",
2192
2225
  mapUser: (user) => ({
2193
2226
  id: user.id,
@@ -2233,6 +2266,9 @@ export default authProvider;
2233
2266
  `)},
2234
2267
  } from "@getstrata/core/contracts/serviceTokens";`);
2235
2268
  imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
2269
+ if (authUsesCookie(layers.auth)) {
2270
+ imports.push(`import { sessionSecret } from "../config.ts";`);
2271
+ }
2236
2272
  return `${imports.join(`
2237
2273
  `)}
2238
2274
 
@@ -2258,7 +2294,7 @@ function envFlag(value) {
2258
2294
  return value ? "true" : "false";
2259
2295
  }
2260
2296
  function appDatabaseName(projectName) {
2261
- return `${projectName.replace(/[^A-Za-z0-9_]/g, "_")}_test`;
2297
+ return projectName.replace(/[^A-Za-z0-9_]/g, "_");
2262
2298
  }
2263
2299
  function defaultDatabaseUrl(layers, projectName) {
2264
2300
  if (layers.database === "sqlite") {
@@ -2278,6 +2314,10 @@ function renderEnvExample(projectName, layers) {
2278
2314
  "PORT=3000",
2279
2315
  "APP_URL=http://localhost:3000",
2280
2316
  `DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
2317
+ ...layers.database === "sqlite" ? [] : [
2318
+ "# Optional. Migrate and boot against a different database than DATABASE_URL.",
2319
+ "# APP_DATABASE_URL="
2320
+ ],
2281
2321
  `DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
2282
2322
  `FRONTEND_MODE=${layers.frontend}`,
2283
2323
  `SPA_PREFIX=${layers.spaPrefix}`,
@@ -2285,9 +2325,13 @@ function renderEnvExample(projectName, layers) {
2285
2325
  `CACHE_DRIVER=${layers.cache}`,
2286
2326
  `QUEUE_DRIVER=${layers.queue}`,
2287
2327
  `MAIL_DRIVER=${layers.mail}`,
2288
- `AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`,
2289
- `FEATURE_PUBLIC_READS=${envFlag(layers.frontend !== "api")}`
2328
+ `AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
2290
2329
  ];
2330
+ if (layers.frontend === "api") {
2331
+ lines.push("FEATURE_PUBLIC_READS=false");
2332
+ } else {
2333
+ lines.push("# Local convenience so the welcome page reads without a login.", "# Production boot is blocked unless this is false.", "FEATURE_PUBLIC_READS=true");
2334
+ }
2291
2335
  if (needsRedis(layers)) {
2292
2336
  lines.push("REDIS_URL=redis://127.0.0.1:6379");
2293
2337
  } else {
@@ -2338,6 +2382,7 @@ function renderEnvExample(projectName, layers) {
2338
2382
  lines.push("# MAIL_HOST=");
2339
2383
  lines.push("# MAIL_FROM=");
2340
2384
  }
2385
+ lines.push("# Behind a reverse proxy, trust X-Forwarded-For (rightmost public hop) for throttles and session IPs.");
2341
2386
  lines.push("# TRUST_FORWARDED_FOR=true");
2342
2387
  return `${lines.join(`
2343
2388
  `)}
@@ -2424,35 +2469,90 @@ dist
2424
2469
  frontend/dist
2425
2470
  storage/*.sqlite
2426
2471
  storage/*.sqlite-journal
2472
+ storage/*.sqlite-wal
2473
+ storage/*.sqlite-shm
2427
2474
  coverage
2428
2475
  *.tsbuildinfo
2429
2476
  `;
2430
2477
  }
2478
+ function renderDockerfile(layers) {
2479
+ const frontend = needsFrontendBuild(layers.frontend);
2480
+ const lines = [
2481
+ '# Production image. Build once, run with env from your platform; see README "Deploy".',
2482
+ "FROM oven/bun:1.4 AS deps",
2483
+ "WORKDIR /app",
2484
+ "COPY package.json bun.lock ./",
2485
+ "RUN bun install --frozen-lockfile --production",
2486
+ ""
2487
+ ];
2488
+ if (frontend) {
2489
+ lines.push("FROM oven/bun:1.4 AS frontend", "WORKDIR /app/frontend", "COPY frontend/package.json frontend/bun.lock ./", "RUN bun install --frozen-lockfile", "COPY frontend/ ./", "RUN bun run build", "");
2490
+ }
2491
+ lines.push("FROM oven/bun:1.4-slim AS runtime", "WORKDIR /app", "ENV APP_ENV=production", "ENV AUTH_DEV_HEADERS=false", "ENV PORT=3000", "COPY --from=deps /app/node_modules ./node_modules", "COPY . .");
2492
+ if (frontend) {
2493
+ lines.push("COPY --from=frontend /app/frontend/dist ./frontend/dist");
2494
+ }
2495
+ lines.push("RUN mkdir -p storage && chown -R bun:bun /app", "USER bun", "EXPOSE 3000");
2496
+ if (layers.database === "sqlite") {
2497
+ lines.push("# SQLite lives in storage/; mount a volume there or the data dies with the container.", 'VOLUME ["/app/storage"]');
2498
+ }
2499
+ lines.push(`HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD ["bun", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]`, 'CMD ["bun", "run", "start"]');
2500
+ return `${lines.join(`
2501
+ `)}
2502
+ `;
2503
+ }
2504
+ function renderDockerignore(layers) {
2505
+ const lines = [
2506
+ ".git",
2507
+ "node_modules",
2508
+ ".env",
2509
+ ".env.*",
2510
+ "!.env.example",
2511
+ "storage/*.sqlite",
2512
+ "storage/*.sqlite-journal",
2513
+ "storage/*.sqlite-wal",
2514
+ "storage/*.sqlite-shm",
2515
+ "coverage",
2516
+ "docker-compose.yml"
2517
+ ];
2518
+ if (needsFrontendBuild(layers.frontend)) {
2519
+ lines.push("frontend/node_modules", "frontend/dist");
2520
+ }
2521
+ return `${lines.join(`
2522
+ `)}
2523
+ `;
2524
+ }
2431
2525
  function renderPackageJson(projectName, options = {}) {
2432
2526
  const coreDeps = options.workspaceDependencies ? {
2433
2527
  "@getstrata/bootstrap": "workspace:*",
2434
2528
  "@getstrata/cli": "workspace:*",
2435
2529
  "@getstrata/core": "workspace:*"
2436
2530
  } : {
2437
- "@getstrata/bootstrap": "^1.0.0",
2438
- "@getstrata/cli": "^1.0.0",
2439
- "@getstrata/core": "^1.0.0"
2531
+ "@getstrata/bootstrap": "^1.0.1",
2532
+ "@getstrata/cli": "^1.0.1",
2533
+ "@getstrata/core": "^1.0.1"
2440
2534
  };
2441
2535
  if (options.layers?.database === "mysql") {
2442
2536
  coreDeps.mysql2 = "^3.24.3";
2443
2537
  }
2538
+ const scripts = {
2539
+ dev: "strata dev",
2540
+ start: "strata start",
2541
+ "db:migrate": "strata migrate",
2542
+ "db:fresh": "strata migrate:fresh",
2543
+ check: "tsc --noEmit"
2544
+ };
2545
+ if (options.layers && needsFrontendBuild(options.layers.frontend)) {
2546
+ scripts["frontend:install"] = "cd frontend && bun install";
2547
+ scripts["frontend:build"] = "cd frontend && bun run build";
2548
+ scripts["frontend:dev"] = "cd frontend && bun run dev";
2549
+ }
2444
2550
  return `${JSON.stringify({
2445
2551
  name: projectName,
2446
2552
  version: "0.1.0",
2447
2553
  private: true,
2448
2554
  type: "module",
2449
- scripts: {
2450
- dev: "strata dev",
2451
- start: "strata start",
2452
- "db:migrate": "strata migrate",
2453
- "db:fresh": "strata migrate:fresh",
2454
- check: "tsc --noEmit"
2455
- },
2555
+ scripts,
2456
2556
  dependencies: coreDeps,
2457
2557
  devDependencies: {
2458
2558
  "@types/bun": "^1.4.0",
@@ -2524,13 +2624,86 @@ function renderSupportingToolsReadme(layers) {
2524
2624
  `)}
2525
2625
  `;
2526
2626
  }
2627
+ function renderApiDocs(projectName, layers) {
2628
+ const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
2629
+ rows.push("| `GET` | `/health` | Plain text `ok` (200), or `degraded` (503) when the database or the migrated schema is unavailable. `/ready` returns the same checks as JSON. |");
2630
+ rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
2631
+ if (authUsesToken(layers.auth)) {
2632
+ rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
2633
+ }
2634
+ if (authUsesJwt(layers.auth)) {
2635
+ rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. |");
2636
+ }
2637
+ if (authNeedsUsers(layers.auth)) {
2638
+ rows.push("| `POST` | `/api/v1/auth/forgot-password` | Sends a signed reset link through the mail driver. |", "| `POST` | `/api/v1/auth/reset-password` | Consumes the signed link. |", "| `GET` | `/api/user` | Current user for the active guard. |");
2639
+ }
2640
+ if (layers.extras.metrics) {
2641
+ rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
2642
+ }
2643
+ const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`POST /api/v1/auth/login\`, then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
2644
+ return `# ${projectName} API
2645
+
2646
+ \`FRONTEND_MODE=${layers.frontend}\`. ${layers.frontend === "api" ? "No server-rendered views beyond the welcome page and no SPA assets." : `HTML is served alongside this API. The SPA is mounted at \`${layers.spaPrefix}\`.`}
2647
+
2648
+ ## Routes this app serves today
2649
+
2650
+ ${rows.join(`
2651
+ `)}
2652
+
2653
+ There is no CRUD endpoint for the seeded \`notes\` table. Adding your own routes is the first thing you do.
2654
+
2655
+ ## Auth
2656
+
2657
+ ${authNote}
2658
+
2659
+ ## Adding a route
2660
+
2661
+ Create a module under \`src/modules/\` and return a route map. Modules are discovered on boot.
2662
+
2663
+ \`\`\`typescript
2664
+ // src/modules/notes/index.ts
2665
+ import type { AppModule } from "@getstrata/bootstrap/contracts";
2666
+ import { jsonResponse } from "@getstrata/core/http/response";
2667
+ import { getSql } from "../../bootstrap/database.ts";
2668
+
2669
+ const notesModule: AppModule = {
2670
+ name: "notes",
2671
+ order: 2,
2672
+ routes({ kernel }) {
2673
+ return {
2674
+ "/api/v1/notes": kernel.wrap("api", async () => {
2675
+ const rows = await getSql().unsafe<{ id: number; body: string }>(
2676
+ "SELECT id, body FROM notes ORDER BY id DESC",
2677
+ );
2678
+ return jsonResponse({ data: rows });
2679
+ }),
2680
+ };
2681
+ },
2682
+ };
2683
+
2684
+ export default notesModule;
2685
+ \`\`\`
2686
+
2687
+ Import from \`@getstrata/core/...\` subpaths rather than the package root, so singleton state such as the database pool stays shared.
2688
+
2689
+ ## Docs
2690
+
2691
+ - [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md)
2692
+ - [Auth choices](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md)
2693
+ - [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
2694
+ `;
2695
+ }
2527
2696
  function renderReadme(projectName, layers) {
2528
2697
  const docker = renderDockerCompose(projectName, layers);
2529
2698
  const next = [`cd ${projectName}`, "cp .env.example .env"];
2530
2699
  if (docker) {
2531
2700
  next.push("docker compose up -d");
2532
2701
  }
2533
- next.push("bun install", "strata migrate", "strata dev");
2702
+ next.push("bun install");
2703
+ if (needsFrontendBuild(layers.frontend)) {
2704
+ next.push("bun run frontend:install", "bun run frontend:build");
2705
+ }
2706
+ next.push("bun run db:migrate", "bun run dev");
2534
2707
  const extras = Object.entries(layers.extras).filter(([, on]) => on).map(([key]) => key);
2535
2708
  const dockerServices = selectedDockerServices(layers);
2536
2709
  const neededTools = neededDockerServices(layers);
@@ -2585,13 +2758,49 @@ Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
2585
2758
  ` : ""}${authUsesToken(layers.auth) ? `
2586
2759
  Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Register: \`POST /api/v1/auth/register\`. Forgot/reset: \`POST /api/v1/auth/forgot-password\` and signed \`POST /api/v1/auth/reset-password\`. Send \`Authorization: Bearer\` after login.
2587
2760
  ` : ""}${authUsesJwt(layers.auth) ? `
2588
- JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Not a portal session.
2761
+ JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Do not use JWT as an HTML cookie session.
2589
2762
  ` : ""}${layers.extras.metrics ? `
2590
2763
  Prometheus scrape: \`GET /metrics\`. Production requires \`Authorization: Bearer <METRICS_TOKEN>\`.
2591
- ` : ""}
2764
+ ` : ""}${needsFrontendBuild(layers.frontend) ? `
2765
+ ## Frontend
2766
+
2767
+ The React app lives in \`frontend/\` with its own \`package.json\`. It is not built by \`bun install\` at the root.
2768
+
2769
+ \`\`\`bash
2770
+ bun run frontend:install
2771
+ bun run frontend:build
2772
+ \`\`\`
2773
+
2774
+ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun run frontend:dev\` for the Vite-style dev server with hot reload.${layers.frontend === "hybrid" ? ` HTML stays at \`/\` and the SPA is served under \`${layers.spaPrefix}/*\`.` : ""}
2775
+ ` : ""}${layers.database === "sqlite" ? "" : `
2776
+ ## Database
2777
+
2778
+ The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.
2779
+ `}
2780
+ ## Deploy
2781
+
2782
+ \`Dockerfile\` builds a production image from the committed \`bun.lock\` (run \`bun install\` once and commit the lockfile).${needsFrontendBuild(layers.frontend) ? " The React frontend is built inside the image." : ""}
2783
+
2784
+ \`\`\`bash
2785
+ docker build -t ${projectName} .
2786
+ docker run --rm -p 3000:3000 --env-file .env.production ${projectName}
2787
+ \`\`\`
2788
+
2789
+ Migrations are a deploy step, not a boot step: run \`docker run --rm --env-file .env.production ${projectName} bun run db:migrate\` before the new version takes traffic.${layers.database === "sqlite" ? " SQLite stores its file in `/app/storage`; mount a volume there (`-v strata_data:/app/storage`) or the data is lost with the container." : ""}
2790
+ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything else in the Production list below comes from your environment (the \`.env.production\` file above is one way).
2791
+
2592
2792
  ## Production
2593
2793
 
2594
- \`createApp\` calls \`assertProductionSecrets()\` when \`APP_ENV=production\`. Set real secrets before you ship. Cookie HTML apps need \`SESSION_SECRET\` (32+ characters). Token apps need \`TOKEN_HASH_PEPPER\`. Set \`AUTH_DEV_HEADERS=false\`.
2794
+ \`createApp\` calls \`assertProductionSecrets()\` when \`APP_ENV=production\`. That check fails closed, so read this before your first production boot.
2795
+
2796
+ - Replace every \`change-me\` placeholder in \`.env\`. The guard rejects the values this generator wrote, not just empty ones.
2797
+ - Set \`APP_URL\` to the public origin (for example \`https://app.example.com\`). Signed links and redirects are built from it; localhost is rejected.
2798
+ - Set \`AUTH_DEV_HEADERS=false\`.
2799
+ - Set \`FEATURE_PUBLIC_READS=false\`. ${layers.frontend === "api" ? "This app already ships `false`." : "This app ships `true` so the local welcome page reads without a login. Production requires `false`."}
2800
+ - Cross-origin browser calls are off in production until you set \`CORS_ALLOWED_ORIGINS\` to explicit origins. A \`*\` entry is rejected. Non-browser clients are unaffected.
2801
+ - Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
2802
+ ${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}
2803
+ \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` answers 503 until the schema exists, so a fresh deploy stays out of rotation until it is migrated.
2595
2804
  `;
2596
2805
  }
2597
2806
 
@@ -2612,6 +2821,8 @@ function dialectFragments(database) {
2612
2821
  return {
2613
2822
  id: "INTEGER PRIMARY KEY AUTOINCREMENT",
2614
2823
  text: "TEXT",
2824
+ keyText: "TEXT",
2825
+ defaultText: "TEXT",
2615
2826
  timestamp: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
2616
2827
  timestampNull: "TEXT",
2617
2828
  bool: "INTEGER NOT NULL DEFAULT 0"
@@ -2621,6 +2832,8 @@ function dialectFragments(database) {
2621
2832
  return {
2622
2833
  id: "INT AUTO_INCREMENT PRIMARY KEY",
2623
2834
  text: "TEXT",
2835
+ keyText: "VARCHAR(255)",
2836
+ defaultText: "VARCHAR(1024)",
2624
2837
  timestamp: "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
2625
2838
  timestampNull: "DATETIME NULL",
2626
2839
  bool: "TINYINT(1) NOT NULL DEFAULT 0"
@@ -2629,6 +2842,8 @@ function dialectFragments(database) {
2629
2842
  return {
2630
2843
  id: "SERIAL PRIMARY KEY",
2631
2844
  text: "TEXT",
2845
+ keyText: "TEXT",
2846
+ defaultText: "TEXT",
2632
2847
  timestamp: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
2633
2848
  timestampNull: "TIMESTAMPTZ",
2634
2849
  bool: "BOOLEAN NOT NULL DEFAULT FALSE"
@@ -2820,9 +3035,9 @@ function renderMigrateTs(layers) {
2820
3035
  if (tenancyOn) {
2821
3036
  statements.push(`CREATE TABLE IF NOT EXISTS tenant (
2822
3037
  id ${d.id},
2823
- slug ${d.text} NOT NULL UNIQUE,
2824
- plan ${d.text} NOT NULL DEFAULT 'enterprise',
2825
- region ${d.text} NOT NULL DEFAULT 'eu'
3038
+ slug ${d.keyText} NOT NULL UNIQUE,
3039
+ plan ${d.defaultText} NOT NULL DEFAULT 'enterprise',
3040
+ region ${d.defaultText} NOT NULL DEFAULT 'eu'
2826
3041
  )`);
2827
3042
  }
2828
3043
  statements.push(`CREATE TABLE IF NOT EXISTS notes (
@@ -2840,7 +3055,7 @@ function renderMigrateTs(layers) {
2840
3055
  statements.push(`CREATE TABLE IF NOT EXISTS users (
2841
3056
  id ${d.id},
2842
3057
  name ${d.text} NOT NULL,
2843
- email ${d.text} NOT NULL UNIQUE,
3058
+ email ${d.keyText} NOT NULL UNIQUE,
2844
3059
  password ${d.text} NOT NULL,
2845
3060
  is_admin ${d.bool},${tenantColumn}${mfaColumns}
2846
3061
  email_verified_at ${d.timestampNull},
@@ -2849,7 +3064,7 @@ function renderMigrateTs(layers) {
2849
3064
  }
2850
3065
  if (authUsesCookie(layers.auth)) {
2851
3066
  statements.push(`CREATE TABLE IF NOT EXISTS sessions (
2852
- id ${d.text} PRIMARY KEY,
3067
+ id ${d.keyText} PRIMARY KEY,
2853
3068
  user_id INTEGER NOT NULL,
2854
3069
  expires_at ${d.timestamp},
2855
3070
  user_agent ${d.text},
@@ -2862,8 +3077,8 @@ function renderMigrateTs(layers) {
2862
3077
  id ${d.id},
2863
3078
  user_id INTEGER NOT NULL,
2864
3079
  name ${d.text} NOT NULL,
2865
- token_hash ${d.text} NOT NULL UNIQUE,
2866
- abilities ${d.text} NOT NULL DEFAULT '[]',
3080
+ token_hash ${d.keyText} NOT NULL UNIQUE,
3081
+ abilities ${d.defaultText} NOT NULL DEFAULT '[]',
2867
3082
  expires_at ${d.timestampNull},
2868
3083
  last_used_at ${d.timestampNull},
2869
3084
  created_at ${d.timestamp}
@@ -2878,10 +3093,10 @@ function renderMigrateTs(layers) {
2878
3093
  const userPlaceholders = verifyOn ? ph ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
2879
3094
  const adminFlag = ph ? "false" : "0";
2880
3095
  const adminTrue = ph ? "true" : "1";
2881
- const verifiedNow = "new Date().toISOString()";
3096
+ const verifiedNow = nowTimestampLiteral(layers.database);
2882
3097
  const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
2883
3098
  const seedTenant = tenancyOn ? `
2884
- const [{ count: tenantCount }] = await sql.unsafe<Array<{ count: string | number }>>(
3099
+ const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
2885
3100
  "SELECT COUNT(*) AS count FROM tenant",
2886
3101
  );
2887
3102
  if (Number(tenantCount) === 0) {
@@ -2891,7 +3106,7 @@ function renderMigrateTs(layers) {
2891
3106
  );
2892
3107
  }` : "";
2893
3108
  const seedUsers = authNeedsUsers(layers.auth) ? `
2894
- const [{ count: userCount }] = await sql.unsafe<Array<{ count: string | number }>>(
3109
+ const [{ count: userCount }] = await sql.unsafe<{ count: string | number }>(
2895
3110
  "SELECT COUNT(*) AS count FROM users",
2896
3111
  );
2897
3112
  if (Number(userCount) === 0) {
@@ -2904,7 +3119,7 @@ function renderMigrateTs(layers) {
2904
3119
  const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
2905
3120
  ` : "";
2906
3121
  const seedBlock = `${seedTenant}${seedUsers}`;
2907
- return `${hashImport}${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
3122
+ return `${hashImport}${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
2908
3123
 
2909
3124
  const migrations = [
2910
3125
  ${list}
@@ -2912,7 +3127,7 @@ ${list}
2912
3127
 
2913
3128
  export async function seed() {
2914
3129
  ${ensureCall(layers)} const sql = getSql();
2915
- const [{ count }] = await sql.unsafe<Array<{ count: string | number }>>(
3130
+ const [{ count }] = await sql.unsafe<{ count: string | number }>(
2916
3131
  "SELECT COUNT(*) AS count FROM notes",
2917
3132
  );
2918
3133
  if (Number(count) === 0) {
@@ -2930,9 +3145,15 @@ ${ensureCall(layers)} const sql = getSql();
2930
3145
  await seed();
2931
3146
  }
2932
3147
 
3148
+ /** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
3149
+ export async function close() {
3150
+ await closeDatabase();
3151
+ }
3152
+
2933
3153
  if (import.meta.main) {
2934
3154
  await migrate();
2935
3155
  console.log("Database migrated and seeded.");
3156
+ await close();
2936
3157
  process.exit(0);
2937
3158
  }
2938
3159
  `;
@@ -2957,7 +3178,7 @@ function dropTables(layers) {
2957
3178
  function renderFreshTs(layers) {
2958
3179
  const tables = dropTables(layers);
2959
3180
  const cascade = layers.database === "sqlite" ? "" : " CASCADE";
2960
- return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
3181
+ return `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
2961
3182
  import { migrate } from "./migrate.ts";
2962
3183
 
2963
3184
  const tables = ${JSON.stringify(tables)};
@@ -2970,9 +3191,15 @@ ${ensureCall(layers)} const sql = getSql();
2970
3191
  await migrate();
2971
3192
  }
2972
3193
 
3194
+ /** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
3195
+ export async function close() {
3196
+ await closeDatabase();
3197
+ }
3198
+
2973
3199
  if (import.meta.main) {
2974
3200
  await fresh();
2975
3201
  console.log("Database reset, migrated, and seeded.");
3202
+ await close();
2976
3203
  process.exit(0);
2977
3204
  }
2978
3205
  `;
@@ -3000,7 +3227,7 @@ ${ensureCall(layers)} const sql = getSql();
3000
3227
  console.log("Starter schema (inline SQL, not a migration runner):");
3001
3228
  for (const table of tables) {
3002
3229
  try {
3003
- const rows = await sql.unsafe<Array<{ count: string | number }>>(
3230
+ const rows = await sql.unsafe<{ count: string | number }>(
3004
3231
  \`SELECT COUNT(*) AS count FROM \${table}\`,
3005
3232
  );
3006
3233
  console.log(\`- [present] \${table} (rows: \${rows[0]?.count ?? 0})\`);
@@ -3073,6 +3300,17 @@ export function loadConfig(): AppConfig {
3073
3300
  databaseUrl,
3074
3301
  };
3075
3302
  }
3303
+
3304
+ /** Cookie sessions and signed cookies are keyed by this; there is no default. */
3305
+ export function sessionSecret(): string {
3306
+ const secret = process.env.SESSION_SECRET?.trim();
3307
+ if (!secret) {
3308
+ throw new Error(
3309
+ "SESSION_SECRET is required. Copy .env.example to .env and set it (32+ characters).",
3310
+ );
3311
+ }
3312
+ return secret;
3313
+ }
3076
3314
  `;
3077
3315
  }
3078
3316
  function renderConfigProvider(layers) {
@@ -3133,50 +3371,62 @@ function renderEnsureDatabaseTs(layers, projectName) {
3133
3371
  }
3134
3372
  const database = appDatabaseName(projectName);
3135
3373
  const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
3136
- if (layers.database === "mysql") {
3137
- return `const APP_DATABASE = ${JSON.stringify(database)};
3138
-
3374
+ const resolveUrl = `/**
3375
+ * The database name comes from DATABASE_URL. Set APP_DATABASE_URL to point
3376
+ * migrations and the app at a different database than DATABASE_URL.
3377
+ */
3139
3378
  function resolveAppDatabaseUrl(): string {
3140
3379
  const explicit = process.env.APP_DATABASE_URL?.trim();
3141
3380
  if (explicit) {
3142
3381
  return explicit;
3143
3382
  }
3383
+ return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
3384
+ }
3144
3385
 
3145
- const base = process.env.DATABASE_URL?.trim() || ${JSON.stringify(fallback)};
3386
+ /** Reject anything we would have to quote before interpolating into DDL. */
3387
+ function safeDatabaseName(url: string): string {
3388
+ let name = "";
3146
3389
  try {
3147
- const url = new URL(base);
3148
- url.pathname = \`/\${APP_DATABASE}\`;
3149
- return url.toString();
3390
+ name = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
3150
3391
  } catch {
3151
- return base;
3392
+ throw new Error(\`DATABASE_URL is not a valid URL: \${url}\`);
3393
+ }
3394
+ if (!name) {
3395
+ throw new Error("DATABASE_URL is missing a database name.");
3396
+ }
3397
+ if (name.replace(/[^A-Za-z0-9_]/g, "") !== name) {
3398
+ throw new Error(\`Refusing to create a database with an unsafe name: \${name}\`);
3152
3399
  }
3400
+ return name;
3153
3401
  }
3402
+ `;
3403
+ if (layers.database === "mysql") {
3404
+ return `import { createConnection } from "mysql2/promise";
3405
+
3406
+ const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3154
3407
 
3408
+ ${resolveUrl}
3155
3409
  export async function ensureAppDatabase(): Promise<string> {
3156
3410
  const url = resolveAppDatabaseUrl();
3411
+ const name = safeDatabaseName(url);
3412
+
3413
+ const admin = new URL(url);
3414
+ admin.pathname = "/";
3415
+ const connection = await createConnection(admin.toString());
3416
+ try {
3417
+ await connection.query(\`CREATE DATABASE IF NOT EXISTS \${name}\`);
3418
+ } finally {
3419
+ await connection.end();
3420
+ }
3421
+
3157
3422
  process.env.DATABASE_URL = url;
3158
3423
  return url;
3159
3424
  }
3160
3425
  `;
3161
3426
  }
3162
- return `const APP_DATABASE = ${JSON.stringify(database)};
3163
-
3164
- function resolveAppDatabaseUrl(): string {
3165
- const explicit = process.env.APP_DATABASE_URL?.trim();
3166
- if (explicit) {
3167
- return explicit;
3168
- }
3169
-
3170
- const base = process.env.DATABASE_URL?.trim() || ${JSON.stringify(fallback)};
3171
- try {
3172
- const url = new URL(base);
3173
- url.pathname = \`/\${APP_DATABASE}\`;
3174
- return url.toString();
3175
- } catch {
3176
- return base;
3177
- }
3178
- }
3427
+ return `const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3179
3428
 
3429
+ ${resolveUrl}
3180
3430
  function adminCandidateUrls(url: string): string[] {
3181
3431
  const names = ["postgres", "template1"];
3182
3432
  try {
@@ -3212,16 +3462,7 @@ async function openAdminConnection(url: string): Promise<Bun.SQL> {
3212
3462
 
3213
3463
  export async function ensureAppDatabase(): Promise<string> {
3214
3464
  const url = resolveAppDatabaseUrl();
3215
- const parsed = new URL(url);
3216
- const name = decodeURIComponent(parsed.pathname.replace(/^\\//, ""));
3217
- if (!name) {
3218
- throw new Error("DATABASE_URL is missing a database name.");
3219
- }
3220
-
3221
- const identifier = name.replace(/[^A-Za-z0-9_]/g, "");
3222
- if (identifier !== name) {
3223
- throw new Error(\`Refusing to create a database with an unsafe name: \${name}\`);
3224
- }
3465
+ const name = safeDatabaseName(url);
3225
3466
 
3226
3467
  const adminSql = await openAdminConnection(url);
3227
3468
  try {
@@ -3229,14 +3470,13 @@ export async function ensureAppDatabase(): Promise<string> {
3229
3470
  SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
3230
3471
  \`;
3231
3472
  if (rows.length === 0) {
3232
- await adminSql.unsafe(\`CREATE DATABASE \${identifier}\`);
3473
+ await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
3233
3474
  }
3234
3475
  } finally {
3235
3476
  await adminSql.close();
3236
3477
  }
3237
3478
 
3238
3479
  process.env.DATABASE_URL = url;
3239
- process.env.APP_DATABASE_URL = url;
3240
3480
  return url;
3241
3481
  }
3242
3482
  `;
@@ -3350,9 +3590,12 @@ function createAppContext(): AppContext {
3350
3590
  }
3351
3591
 
3352
3592
  export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
3353
- const { migrate: runMigrate = true } = options;
3593
+ const isProduction = process.env.APP_ENV === "production";
3594
+ // Dev boots migrate for convenience. Production must not mutate schema on
3595
+ // start, so run \`strata migrate\` as an explicit deploy step instead.
3596
+ const { migrate: runMigrate = !isProduction } = options;
3354
3597
 
3355
- if (process.env.APP_ENV === "production") {
3598
+ if (isProduction) {
3356
3599
  assertProductionSecrets();
3357
3600
  }
3358
3601
 
@@ -3452,8 +3695,8 @@ ${userBlock}
3452
3695
  return htmlResponse(html, { status });
3453
3696
  }
3454
3697
 
3455
- export function plainText(body: string): Response {
3456
- return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
3698
+ export function plainText(body: string, status = 200): Response {
3699
+ return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
3457
3700
  }
3458
3701
  `;
3459
3702
  }
@@ -3611,12 +3854,12 @@ const scimModule: AppModule = {
3611
3854
  const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
3612
3855
  let rows: UserRow[];
3613
3856
  if (match?.[1]) {
3614
- rows = await getSql().unsafe<UserRow[]>(
3857
+ rows = await getSql().unsafe<UserRow>(
3615
3858
  "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3616
3859
  [match[1].trim().toLowerCase()],
3617
3860
  );
3618
3861
  } else {
3619
- rows = await getSql().unsafe<UserRow[]>("SELECT id, name, email FROM users");
3862
+ rows = await getSql().unsafe<UserRow>("SELECT id, name, email FROM users");
3620
3863
  }
3621
3864
  const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
3622
3865
  const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
@@ -3639,7 +3882,7 @@ const scimModule: AppModule = {
3639
3882
  if (!email) {
3640
3883
  return scimError("userName is required.", 400);
3641
3884
  }
3642
- const existing = await getSql().unsafe<UserRow[]>(
3885
+ const existing = await getSql().unsafe<UserRow>(
3643
3886
  "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3644
3887
  [email],
3645
3888
  );
@@ -3652,7 +3895,7 @@ const scimModule: AppModule = {
3652
3895
  "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
3653
3896
  [name, email, hashed${insertTail}],
3654
3897
  );
3655
- const created = await getSql().unsafe<UserRow[]>(
3898
+ const created = await getSql().unsafe<UserRow>(
3656
3899
  "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3657
3900
  [email],
3658
3901
  );
@@ -3669,7 +3912,7 @@ const scimModule: AppModule = {
3669
3912
  "api",
3670
3913
  wrapScim(async (request) => {
3671
3914
  const id = Number.parseInt(routeParams(request).id ?? "", 10);
3672
- const rows = await getSql().unsafe<UserRow[]>(
3915
+ const rows = await getSql().unsafe<UserRow>(
3673
3916
  "SELECT id, name, email FROM users WHERE id = ${idPh}",
3674
3917
  [id],
3675
3918
  );
@@ -3694,7 +3937,7 @@ const scimModule: AppModule = {
3694
3937
  "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3695
3938
  [name, email, id],
3696
3939
  );
3697
- const rows = await getSql().unsafe<UserRow[]>(
3940
+ const rows = await getSql().unsafe<UserRow>(
3698
3941
  "SELECT id, name, email FROM users WHERE id = ${idPh}",
3699
3942
  [id],
3700
3943
  );
@@ -3709,7 +3952,7 @@ const scimModule: AppModule = {
3709
3952
  "api",
3710
3953
  wrapScim(async (request) => {
3711
3954
  const id = Number.parseInt(routeParams(request).id ?? "", 10);
3712
- const existing = await getSql().unsafe<UserRow[]>(
3955
+ const existing = await getSql().unsafe<UserRow>(
3713
3956
  "SELECT id, name, email FROM users WHERE id = ${idPh}",
3714
3957
  [id],
3715
3958
  );
@@ -3801,13 +4044,16 @@ function assertProjectName(projectName) {
3801
4044
  throw new Error("Project name must contain only letters, numbers, hyphens, and underscores.");
3802
4045
  }
3803
4046
  }
4047
+ function resolveProjectTarget(rawTarget, cwd) {
4048
+ const targetDir = resolve(cwd, rawTarget.trim());
4049
+ const projectName = basename(targetDir);
4050
+ assertProjectName(projectName);
4051
+ return { projectName, targetDir };
4052
+ }
3804
4053
  function applyFrontendOverlays(overlayRoot, targetDir, layers) {
3805
4054
  if (layers.frontend === "hybrid" || layers.frontend === "spa-react") {
3806
4055
  copyOverlayTree(join2(overlayRoot, "spa-react"), targetDir);
3807
4056
  }
3808
- if (layers.frontend === "api") {
3809
- copyOverlayTree(join2(overlayRoot, "api"), targetDir);
3810
- }
3811
4057
  }
3812
4058
  function writeGeneratedFiles(options) {
3813
4059
  const { targetDir, projectName, layers } = options;
@@ -3816,7 +4062,14 @@ function writeGeneratedFiles(options) {
3816
4062
  writeText(join2(targetDir, ".gitignore"), renderGitignore());
3817
4063
  writeText(join2(targetDir, "package.json"), renderPackageJson(projectName, { ...options, layers }));
3818
4064
  writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers));
4065
+ if (layers.frontend === "api") {
4066
+ writeText(join2(targetDir, "docs/API.md"), renderApiDocs(projectName, layers));
4067
+ } else {
4068
+ removeIfExists(join2(targetDir, "docs/API.md"));
4069
+ }
3819
4070
  writeText(join2(targetDir, "strata.layers.json"), renderLayersManifest(projectName, layers));
4071
+ writeText(join2(targetDir, "Dockerfile"), renderDockerfile(layers));
4072
+ writeText(join2(targetDir, ".dockerignore"), renderDockerignore(layers));
3820
4073
  const compose = renderDockerCompose(projectName, layers);
3821
4074
  if (compose) {
3822
4075
  writeText(join2(targetDir, "docker-compose.yml"), compose);
@@ -3887,7 +4140,7 @@ function writeGeneratedFiles(options) {
3887
4140
  mkdirSync2(join2(targetDir, "storage"), { recursive: true });
3888
4141
  writeText(join2(targetDir, "storage/.gitkeep"), "");
3889
4142
  }
3890
- function printNextSteps(projectName, layers, compose) {
4143
+ function printNextSteps(projectName, layers, compose, cdTarget = projectName) {
3891
4144
  const dockerOn = selectedDockerServices(layers);
3892
4145
  const neededTools = neededDockerServices(layers);
3893
4146
  const localOn = neededTools.filter((name) => !dockerOn.includes(name));
@@ -3898,7 +4151,7 @@ Created Strata app in ${projectName}/
3898
4151
  console.log(`frontend=${layers.frontend} db=${layers.database} auth=${layers.auth} docker=${dockerSummary}`);
3899
4152
  console.log(`
3900
4153
  Next steps:`);
3901
- console.log(` cd ${projectName}`);
4154
+ console.log(` cd ${cdTarget}`);
3902
4155
  console.log(" cp .env.example .env");
3903
4156
  if (compose) {
3904
4157
  console.log(" docker compose up -d");
@@ -3910,8 +4163,15 @@ Next steps:`);
3910
4163
  console.log(` Point env at local ${localOn.join(", ")} (see README).`);
3911
4164
  }
3912
4165
  console.log(" bun install");
3913
- console.log(" strata migrate");
3914
- console.log(` strata dev
4166
+ if (needsFrontendBuild(layers.frontend)) {
4167
+ console.log(" bun run frontend:install");
4168
+ console.log(" bun run frontend:build");
4169
+ }
4170
+ console.log(" bun run db:migrate");
4171
+ console.log(` bun run dev
4172
+ `);
4173
+ console.log("The strata binary is local to the app, so use the bun run scripts above.");
4174
+ console.log(`Run it directly with bunx strata <command> from inside the app directory.
3915
4175
  `);
3916
4176
  }
3917
4177
  function generateProject(options) {
@@ -3940,16 +4200,16 @@ async function runCreateStrata(argv, cwd = process.cwd()) {
3940
4200
  }
3941
4201
  try {
3942
4202
  const plan = await resolveStarterPlan(flags);
3943
- const targetDir = resolve(cwd, plan.projectName);
4203
+ const { projectName, targetDir } = resolveProjectTarget(plan.projectName, cwd);
3944
4204
  generateProject({
3945
- projectName: plan.projectName,
4205
+ projectName,
3946
4206
  targetDir,
3947
4207
  layers: plan.layers,
3948
4208
  templateRoot: resolveTemplateRoot(),
3949
4209
  overlayRoot: resolveOverlayRoot(),
3950
4210
  force: flags.force
3951
4211
  });
3952
- printNextSteps(plan.projectName, plan.layers, renderDockerCompose(plan.projectName, plan.layers) !== null);
4212
+ printNextSteps(projectName, plan.layers, renderDockerCompose(projectName, plan.layers) !== null, plan.projectName);
3953
4213
  return 0;
3954
4214
  } catch (error) {
3955
4215
  console.error(error instanceof Error ? error.message : error);