@getstrata/starter 1.1.4 → 1.1.6

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
@@ -117,10 +117,10 @@ function nowTimestampLiteral(database) {
117
117
  return database === "mysql" ? `new Date().toISOString().slice(0, 19).replace("T", " ")` : "new Date().toISOString()";
118
118
  }
119
119
  function extraApplies(extra, auth) {
120
- if (extra === "metrics") {
120
+ if (extra === "metrics" || extra === "billing" || extra === "webhooks") {
121
121
  return true;
122
122
  }
123
- if (extra === "mfa") {
123
+ if (extra === "mfa" || extra === "oauthGithub" || extra === "oidc") {
124
124
  return htmlAuthKit(auth);
125
125
  }
126
126
  return authNeedsUsers(auth);
@@ -194,13 +194,21 @@ var noneExtras = {
194
194
  mfa: false,
195
195
  emailVerification: false,
196
196
  scim: false,
197
- metrics: false
197
+ metrics: false,
198
+ oauthGithub: false,
199
+ oidc: false,
200
+ billing: false,
201
+ webhooks: false
198
202
  };
199
203
  var enterpriseExtras = {
200
204
  mfa: true,
201
205
  emailVerification: true,
202
206
  scim: true,
203
- metrics: true
207
+ metrics: true,
208
+ oauthGithub: false,
209
+ oidc: false,
210
+ billing: false,
211
+ webhooks: false
204
212
  };
205
213
  function withDocker(layers, composeForNeededTools) {
206
214
  return {
@@ -267,7 +275,11 @@ Options:
267
275
  --email-verification / --no-email-verification
268
276
  --scim / --no-scim
269
277
  --metrics / --no-metrics
270
- --extras Interactive extras list (MFA, email verification, SCIM, metrics)
278
+ --oauth-github / --no-oauth-github
279
+ --oidc / --no-oidc
280
+ --billing / --no-billing
281
+ --webhooks / --no-webhooks
282
+ --extras Interactive extras list (MFA, email verification, SCIM, metrics, GitHub OAuth, OIDC, billing, webhooks)
271
283
  --docker Write Docker Compose for every selected tool that needs a service
272
284
  --no-docker Skip docker-compose.yml; use installs already on this machine
273
285
  --docker-services Subset: postgres, mysql, redis, mailpit, adminer (comma-separated)
@@ -370,7 +382,15 @@ function parseCreateStrataArgs(argv) {
370
382
  ["--scim", "scim", true],
371
383
  ["--no-scim", "scim", false],
372
384
  ["--metrics", "metrics", true],
373
- ["--no-metrics", "metrics", false]
385
+ ["--no-metrics", "metrics", false],
386
+ ["--oauth-github", "oauthGithub", true],
387
+ ["--no-oauth-github", "oauthGithub", false],
388
+ ["--oidc", "oidc", true],
389
+ ["--no-oidc", "oidc", false],
390
+ ["--billing", "billing", true],
391
+ ["--no-billing", "billing", false],
392
+ ["--webhooks", "webhooks", true],
393
+ ["--no-webhooks", "webhooks", false]
374
394
  ];
375
395
  const boolMatch = boolFlags.find(([name]) => name === arg);
376
396
  if (boolMatch) {
@@ -488,7 +508,16 @@ function applyDockerFlags(layers, flags) {
488
508
  return layers;
489
509
  }
490
510
  function extraFlagName(extra) {
491
- return extra === "emailVerification" ? "email-verification" : extra;
511
+ if (extra === "emailVerification") {
512
+ return "email-verification";
513
+ }
514
+ if (extra === "oauthGithub") {
515
+ return "oauth-github";
516
+ }
517
+ if (extra === "oidc") {
518
+ return "oidc";
519
+ }
520
+ return extra;
492
521
  }
493
522
  function dropInapplicableExtras(layers, flags) {
494
523
  for (const key of Object.keys(layers.extras)) {
@@ -889,7 +918,11 @@ var EXTRA_CHOICES = [
889
918
  { value: "mfa", label: "mfa: authenticator challenge + setup pages" },
890
919
  { value: "emailVerification", label: "email-verification: signed links + /email/verify" },
891
920
  { value: "scim", label: "scim: /Users adapter" },
892
- { value: "metrics", label: "metrics: Prometheus token" }
921
+ { value: "metrics", label: "metrics: Prometheus token" },
922
+ { value: "oauthGithub", label: "oauth-github: GitHub cookie login at /auth/github" },
923
+ { value: "oidc", label: "oidc: OIDC cookie login at /auth/oidc (PKCE cookie)" },
924
+ { value: "billing", label: "billing: Stripe webhook stub + GET /api/v1/billing/subscription" },
925
+ { value: "webhooks", label: "webhooks: outbound notes.created job (webhook.dispatch)" }
893
926
  ];
894
927
  function isInteractive(flags) {
895
928
  if (flags.yes || flags.noInteractive) {
@@ -1175,6 +1208,8 @@ function renderAuthModule(layers) {
1175
1208
  return null;
1176
1209
  }
1177
1210
  const cookie = htmlAuthKit(layers.auth);
1211
+ const github = Boolean(layers.extras.oauthGithub && cookie);
1212
+ const oidc = Boolean(layers.extras.oidc && cookie);
1178
1213
  const mfa = Boolean(layers.extras.mfa && cookie);
1179
1214
  const verify = Boolean(layers.extras.emailVerification);
1180
1215
  const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
@@ -1194,6 +1229,15 @@ function renderAuthModule(layers) {
1194
1229
  imports.push(`import { wrapWebLogin, wrapWebRegister } from "@getstrata/bootstrap/web/routing";`);
1195
1230
  imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
1196
1231
  imports.push(`import { createSamlServiceProvider } from "@getstrata/core/auth/saml/samlServiceProvider";`);
1232
+ if (github) {
1233
+ imports.push(`import { GitHubOAuthProvider } from "@getstrata/core/auth/oauth/providers";`);
1234
+ }
1235
+ if (oidc) {
1236
+ imports.push(`import { OidcProvider } from "@getstrata/core/auth/oauth/oidcProvider";`);
1237
+ }
1238
+ if (github || oidc) {
1239
+ imports.push(`import { appUrl } from "@getstrata/core/runtime/appKeyPrefix";`);
1240
+ }
1197
1241
  }
1198
1242
  if (jsonApi) {
1199
1243
  imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
@@ -1222,7 +1266,7 @@ function renderAuthModule(layers) {
1222
1266
  if (mfa) {
1223
1267
  imports.push(`import { sanitizeInternalPath } from "@getstrata/core/http/safeInternalPath";`);
1224
1268
  }
1225
- imports.push(`import { createOAuthState, verifyOAuthState } from "@getstrata/core/security/oauthState";`);
1269
+ imports.push(oidc ? `import { clearOidcPkceCookie, createOAuthState, readOidcPkceCookie, sealOidcPkceCookie, verifyOAuthState } from "@getstrata/core/security/oauthState";` : `import { createOAuthState, verifyOAuthState } from "@getstrata/core/security/oauthState";`);
1226
1270
  }
1227
1271
  if (jsonApi || cookie) {
1228
1272
  imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
@@ -1344,6 +1388,43 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1344
1388
  is_admin: user.role === "admin",
1345
1389
  };
1346
1390
  }
1391
+
1392
+ async function completeBrowserSsoLogin(
1393
+ dependencies: { container: { resolve: <T>(token: string) => T } },
1394
+ profile: { email: string; name: string },
1395
+ label: string,
1396
+ ): Promise<Response> {
1397
+ let record = await starterAuthDirectory.findByEmail?.(profile.email);
1398
+ if (!record) {
1399
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1400
+ return jsonResponse({ error: \`\${label} user is not provisioned.\` }, { status: 403 });
1401
+ }
1402
+ const hashed = await hashPassword(randomBytes(18).toString("hex"));
1403
+ try {
1404
+ await runAuthWrite(profile.email, async () => {
1405
+ await User.create({
1406
+ name: profile.name,
1407
+ email: profile.email,
1408
+ password: hashed,
1409
+ is_admin: false,${samlTenantCreateField}
1410
+ });
1411
+ });
1412
+ } catch {
1413
+ // Unique email: another request already provisioned this user.
1414
+ }
1415
+ record = await starterAuthDirectory.findByEmail?.(profile.email);
1416
+ }
1417
+ if (!record) {
1418
+ return jsonResponse({ error: \`Could not complete \${label} login.\` }, { status: 500 });
1419
+ }
1420
+ ${mfa ? `if (record.mfa_enabled) {
1421
+ const pending = redirectTo("/login/mfa");
1422
+ pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
1423
+ return pending;
1424
+ }
1425
+ ` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1426
+ return auth.signInRedirect(sessionUser(record), "/");
1427
+ }
1347
1428
  ` : ""}`;
1348
1429
  const tokenLogin = authUsesToken(layers.auth) ? `
1349
1430
  "/api/v1/auth/login": {
@@ -1580,41 +1661,113 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1580
1661
  if (!profile) {
1581
1662
  return jsonResponse({ error: "Invalid SAML response." }, { status: 400 });
1582
1663
  }
1583
- let record = await starterAuthDirectory.findByEmail?.(profile.email);
1584
- if (!record) {
1585
- if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1586
- return jsonResponse({ error: "SAML user is not provisioned." }, { status: 403 });
1587
- }
1588
- const hashed = await hashPassword(randomBytes(18).toString("hex"));
1589
- try {
1590
- await runAuthWrite(profile.email, async () => {
1591
- await User.create({
1592
- name: profile.name,
1593
- email: profile.email,
1594
- password: hashed,
1595
- is_admin: false,${samlTenantCreateField}
1596
- });
1597
- });
1598
- } catch {
1599
- // Unique email: another request already provisioned this user.
1600
- }
1601
- record = await starterAuthDirectory.findByEmail?.(profile.email);
1664
+ return completeBrowserSsoLogin(dependencies, profile, "SAML");
1665
+ })),
1666
+ },` : "";
1667
+ const githubRoutes = github ? `
1668
+ "/auth/github": {
1669
+ GET: kernel.wrap("api", withErrorHandling(async () => {
1670
+ if (process.env.FEATURE_OAUTH !== "true") {
1671
+ return new Response("Not found", { status: 404 });
1672
+ }
1673
+ const clientId = process.env.GITHUB_CLIENT_ID?.trim();
1674
+ const clientSecret = process.env.GITHUB_CLIENT_SECRET?.trim();
1675
+ if (!clientId || !clientSecret) {
1676
+ return new Response("Not found", { status: 404 });
1677
+ }
1678
+ const redirectUri = process.env.GITHUB_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/github/callback\`;
1679
+ const issued = createOAuthState();
1680
+ const url = new GitHubOAuthProvider({ clientId, clientSecret, redirectUri }).getAuthorizationUrl(issued.state);
1681
+ return new Response(null, { status: 302, headers: { location: url } });
1682
+ })),
1683
+ },
1684
+ "/auth/github/callback": {
1685
+ GET: kernel.wrap("api", withErrorHandling(async (request) => {
1686
+ if (process.env.FEATURE_OAUTH !== "true") {
1687
+ return new Response("Not found", { status: 404 });
1688
+ }
1689
+ const clientId = process.env.GITHUB_CLIENT_ID?.trim();
1690
+ const clientSecret = process.env.GITHUB_CLIENT_SECRET?.trim();
1691
+ if (!clientId || !clientSecret) {
1692
+ return new Response("Not found", { status: 404 });
1693
+ }
1694
+ const url = new URL(request.url);
1695
+ const code = url.searchParams.get("code");
1696
+ const state = url.searchParams.get("state");
1697
+ if (!code || !verifyOAuthState(request, state)) {
1698
+ return jsonResponse({ error: "Invalid GitHub OAuth state." }, { status: 403 });
1699
+ }
1700
+ const redirectUri = process.env.GITHUB_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/github/callback\`;
1701
+ const profile = await new GitHubOAuthProvider({ clientId, clientSecret, redirectUri })
1702
+ .exchangeCode(code)
1703
+ .catch(() => null);
1704
+ if (!profile) {
1705
+ return jsonResponse({ error: "Invalid GitHub OAuth response." }, { status: 400 });
1706
+ }
1707
+ return completeBrowserSsoLogin(dependencies, profile, "GitHub");
1708
+ })),
1709
+ },` : "";
1710
+ const oidcRoutes = oidc ? `
1711
+ "/auth/oidc": {
1712
+ GET: kernel.wrap("api", withErrorHandling(async () => {
1713
+ if (process.env.FEATURE_OAUTH !== "true") {
1714
+ return new Response("Not found", { status: 404 });
1715
+ }
1716
+ const issuer = process.env.OIDC_ISSUER?.trim();
1717
+ const clientId = process.env.OIDC_CLIENT_ID?.trim();
1718
+ const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim();
1719
+ if (!issuer || !clientId || !clientSecret) {
1720
+ return new Response("Not found", { status: 404 });
1721
+ }
1722
+ const redirectUri = process.env.OIDC_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/oidc/callback\`;
1723
+ const issued = createOAuthState();
1724
+ const provider = new OidcProvider({ name: "oidc", issuer, clientId, clientSecret, redirectUri });
1725
+ const { url, handshake } = await provider.createAuthorization(issued.state);
1726
+ const redirect = new Response(null, { status: 302, headers: { location: url } });
1727
+ redirect.headers.append(
1728
+ "set-cookie",
1729
+ sealOidcPkceCookie({ state: issued.state, nonce: handshake.nonce, codeVerifier: handshake.codeVerifier }),
1730
+ );
1731
+ return redirect;
1732
+ })),
1733
+ },
1734
+ "/auth/oidc/callback": {
1735
+ GET: kernel.wrap("api", withErrorHandling(async (request) => {
1736
+ if (process.env.FEATURE_OAUTH !== "true") {
1737
+ return new Response("Not found", { status: 404 });
1602
1738
  }
1603
- if (!record) {
1604
- return jsonResponse({ error: "Could not complete SAML login." }, { status: 500 });
1739
+ const issuer = process.env.OIDC_ISSUER?.trim();
1740
+ const clientId = process.env.OIDC_CLIENT_ID?.trim();
1741
+ const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim();
1742
+ if (!issuer || !clientId || !clientSecret) {
1743
+ return new Response("Not found", { status: 404 });
1605
1744
  }
1606
- ${mfa ? `if (record.mfa_enabled) {
1607
- const pending = redirectTo("/login/mfa");
1608
- pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
1609
- return pending;
1745
+ const url = new URL(request.url);
1746
+ const code = url.searchParams.get("code");
1747
+ const state = url.searchParams.get("state");
1748
+ const pkce = readOidcPkceCookie(request, state);
1749
+ if (!code || !verifyOAuthState(request, state) || !pkce) {
1750
+ const denied = jsonResponse({ error: "Invalid OIDC state." }, { status: 403 });
1751
+ denied.headers.append("set-cookie", clearOidcPkceCookie());
1752
+ return denied;
1610
1753
  }
1611
- ` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1612
- return auth.signInRedirect(sessionUser(record), "/");
1754
+ const redirectUri = process.env.OIDC_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/oidc/callback\`;
1755
+ const profile = await new OidcProvider({ name: "oidc", issuer, clientId, clientSecret, redirectUri })
1756
+ .exchangeCode(code, redirectUri, pkce)
1757
+ .catch(() => null);
1758
+ if (!profile) {
1759
+ const failed = jsonResponse({ error: "Invalid OIDC response." }, { status: 400 });
1760
+ failed.headers.append("set-cookie", clearOidcPkceCookie());
1761
+ return failed;
1762
+ }
1763
+ const login = await completeBrowserSsoLogin(dependencies, profile, "OIDC");
1764
+ login.headers.append("set-cookie", clearOidcPkceCookie());
1765
+ return login;
1613
1766
  })),
1614
1767
  },` : "";
1615
1768
  const apiRoutes = jsonApi || cookie ? `
1616
1769
  routes({ kernel, dependencies }) {
1617
- return {${csrfRoute}${samlRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1770
+ return {${csrfRoute}${samlRoutes}${githubRoutes}${oidcRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1618
1771
  };
1619
1772
  },` : "";
1620
1773
  const mfaLoginBranch = `const mfaResult = completePasswordLogin(user, { mfaCode: mfa_code });
@@ -2228,10 +2381,14 @@ function textField(name, label, type, extra = "") {
2228
2381
  <input type="${type}" name="${name}" value="<%= it.${name} || "" %>" ${extra} />
2229
2382
  </label>`;
2230
2383
  }
2231
- function renderLoginView() {
2384
+ function renderLoginView(layers) {
2385
+ const githubLink = layers.extras.oauthGithub && htmlAuthKit(layers.auth) ? `
2386
+ <a href="/auth/github">Sign in with GitHub</a>` : "";
2387
+ const oidcLink = layers.extras.oidc && htmlAuthKit(layers.auth) ? `
2388
+ <a href="/auth/oidc">Sign in with OIDC</a>` : "";
2232
2389
  return renderFormView("Sign in", `${textField("email", "Email", "email", 'required autocomplete="username"')}
2233
2390
  ${textField("password", "Password", "password", 'required autocomplete="current-password"')}`, "Sign in", `<a href="/register">Create account</a>
2234
- <a href="/forgot-password">Forgot password</a>`).replace('action="<%= it.action || "" %>"', 'action="/login"');
2391
+ <a href="/forgot-password">Forgot password</a>${githubLink}${oidcLink}`).replace('action="<%= it.action || "" %>"', 'action="/login"');
2235
2392
  }
2236
2393
  function renderRegisterView() {
2237
2394
  return renderFormView("Create account", `${textField("name", "Name", "text", "required")}
@@ -2641,6 +2798,8 @@ function renderEnvExample(projectName, layers) {
2641
2798
  lines.push("# wrapWebPublicRead / wrapPublicRead require login when false (production-safe default).");
2642
2799
  lines.push("# Local storefronts/catalogs may set true in .env. Production boot rejects FEATURE_PUBLIC_READS=true.");
2643
2800
  lines.push("FEATURE_SIEM_EXPORT=false");
2801
+ lines.push("# SIEM_EXPORT_URL=");
2802
+ lines.push("# SIEM_EXPORT_TOKEN=");
2644
2803
  lines.push("FEATURE_REGISTRATION=true");
2645
2804
  lines.push("FEATURE_SAML=false");
2646
2805
  lines.push("# SAML_IDP_SSO_URL=");
@@ -2650,6 +2809,48 @@ function renderEnvExample(projectName, layers) {
2650
2809
  lines.push("# SAML_IDP_ISSUER=");
2651
2810
  lines.push("# SAML_WANT_RESPONSE_SIGNED=");
2652
2811
  lines.push("# SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=");
2812
+ const githubOn = Boolean(layers.extras.oauthGithub);
2813
+ const oidcOn = Boolean(layers.extras.oidc);
2814
+ lines.push(`FEATURE_OAUTH=${envFlag(githubOn || oidcOn)}`);
2815
+ if (githubOn) {
2816
+ lines.push("GITHUB_CLIENT_ID=");
2817
+ lines.push("GITHUB_CLIENT_SECRET=");
2818
+ lines.push("GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback");
2819
+ } else {
2820
+ lines.push("# GITHUB_CLIENT_ID=");
2821
+ lines.push("# GITHUB_CLIENT_SECRET=");
2822
+ lines.push("# GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback");
2823
+ }
2824
+ if (oidcOn) {
2825
+ lines.push("OIDC_ISSUER=http://127.0.0.1:8080");
2826
+ lines.push("OIDC_CLIENT_ID=");
2827
+ lines.push("OIDC_CLIENT_SECRET=");
2828
+ lines.push("OIDC_REDIRECT_URI=http://localhost:3000/auth/oidc/callback");
2829
+ lines.push("# Loopback issuers are allowed when APP_ENV is not production. Other private IdPs:");
2830
+ lines.push("# OIDC_ALLOW_PRIVATE=true");
2831
+ } else {
2832
+ lines.push("# OIDC_ISSUER=");
2833
+ lines.push("# OIDC_CLIENT_ID=");
2834
+ lines.push("# OIDC_CLIENT_SECRET=");
2835
+ lines.push("# OIDC_REDIRECT_URI=http://localhost:3000/auth/oidc/callback");
2836
+ lines.push("# OIDC_ALLOW_PRIVATE=true");
2837
+ }
2838
+ if (githubOn || oidcOn) {
2839
+ lines.push("OAUTH_STATE_SECRET=dev-oauth-state-secret-change-me-please-32");
2840
+ } else {
2841
+ lines.push("# OAUTH_STATE_SECRET=");
2842
+ }
2843
+ lines.push("# --oidc (cookie HTML) seals PKCE in the oidc_pkce cookie. Do not call OidcProvider.getAuthorizationUrl().");
2844
+ const billingOn = Boolean(layers.extras.billing);
2845
+ lines.push(`FEATURE_BILLING=${envFlag(billingOn)}`);
2846
+ if (billingOn) {
2847
+ lines.push("STRIPE_WEBHOOK_SECRET=whsec_change-me");
2848
+ } else {
2849
+ lines.push("# STRIPE_WEBHOOK_SECRET=");
2850
+ }
2851
+ lines.push("# Outbound webhook deliveries (create-strata --webhooks). Local receivers:");
2852
+ lines.push("# WEBHOOK_ALLOW_PRIVATE=true");
2853
+ lines.push("# WEBHOOK_SIGNATURE_HEADER=");
2653
2854
  if (needsRedis(layers)) {
2654
2855
  lines.push("REDIS_PASSWORD=dev-redis-change-me");
2655
2856
  lines.push("REDIS_URL=redis://:dev-redis-change-me@127.0.0.1:6379");
@@ -2858,9 +3059,9 @@ function renderPackageJson(projectName, options = {}) {
2858
3059
  "@getstrata/cli": "workspace:*",
2859
3060
  "@getstrata/core": "workspace:*"
2860
3061
  } : {
2861
- "@getstrata/bootstrap": "^1.1.4",
2862
- "@getstrata/cli": "^1.1.4",
2863
- "@getstrata/core": "^1.1.4"
3062
+ "@getstrata/bootstrap": "^1.1.6",
3063
+ "@getstrata/cli": "^1.1.6",
3064
+ "@getstrata/core": "^1.1.6"
2864
3065
  };
2865
3066
  coreDeps.eta = "^4.6.0";
2866
3067
  if (options.layers?.database === "mysql") {
@@ -2975,6 +3176,18 @@ function renderApiDocs(projectName, layers) {
2975
3176
  if (layers.extras.metrics) {
2976
3177
  rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
2977
3178
  }
3179
+ if (layers.extras.billing) {
3180
+ rows.push("| `POST` | `/billing/webhooks/stripe` | Stripe signature verification. CSRF skipped by path. Plan sync is yours. |", "| `GET` | `/api/v1/billing/subscription` | Current tenant (or first) subscription row. |");
3181
+ }
3182
+ if (layers.extras.webhooks) {
3183
+ rows.push("| (job) | `webhook.dispatch` | Discovered from `src/jobs/dispatchOutboundWebhookJob.ts`. No HTTP route. |");
3184
+ }
3185
+ if (layers.extras.oauthGithub && authUsesCookie(layers.auth)) {
3186
+ rows.push("| `GET` | `/auth/github` | Redirects to GitHub. 404 unless `FEATURE_OAUTH=true`. |", "| `GET` | `/auth/github/callback` | Exchanges the code, JIT user, MFA gate, cookie session. |");
3187
+ }
3188
+ if (layers.extras.oidc && authUsesCookie(layers.auth)) {
3189
+ rows.push("| `GET` | `/auth/oidc` | OIDC `createAuthorization()` plus `oidc_pkce` cookie. 404 unless `FEATURE_OAUTH=true`. |", "| `GET` | `/auth/oidc/callback` | Reads PKCE, exchanges the code, JIT user, MFA gate, cookie session. |");
3190
+ }
2978
3191
  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 \`GET /api/v1/auth/csrf\` then \`POST /api/v1/auth/login\` (send \`X-CSRF-Token\`), 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.`;
2979
3192
  return `# ${projectName} API
2980
3193
 
@@ -3026,6 +3239,43 @@ Import from \`@getstrata/core/...\` subpaths rather than the package root, so si
3026
3239
  - [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
3027
3240
  `;
3028
3241
  }
3242
+ function renderIntegrationsReadme(layers) {
3243
+ const enabled = [
3244
+ layers.extras.oauthGithub ? "GitHub OAuth cookie login (`/auth/github`)" : null,
3245
+ layers.extras.oidc ? "OIDC cookie login (`/auth/oidc`, PKCE in `oidc_pkce`)" : null,
3246
+ layers.extras.billing ? "Stripe billing stub (`POST /billing/webhooks/stripe`, `GET /api/v1/billing/subscription`)" : null,
3247
+ layers.extras.webhooks ? "Outbound webhooks (`notes.created` listener + `webhook.dispatch` job)" : null
3248
+ ].filter((row) => row !== null);
3249
+ const lines = [
3250
+ "## Integrations",
3251
+ "",
3252
+ "OAuth, billing, outbound webhooks, SIEM, and SAML are **off by default**. Commented placeholders live in `.env.example`. Framework map: [INTEGRATIONS.md](https://github.com/EyK-26/strata/blob/main/docs/INTEGRATIONS.md).",
3253
+ "",
3254
+ "Wizard flags (any stack unless noted): `--oauth-github` and `--oidc` (cookie HTML), `--billing`, `--webhooks`. They emit modules, migrations, jobs, or listeners. `createApp` still calls `discoverListeners()` and `discoverJobs()`.",
3255
+ ""
3256
+ ];
3257
+ if (enabled.length > 0) {
3258
+ lines.push("Enabled in this app:", "", ...enabled.map((row) => `- ${row}`), "");
3259
+ } else {
3260
+ lines.push("This app did not pass those flags. Turn a flag on and re-run `create-strata`, or copy the commented env block and add a module yourself.", "");
3261
+ }
3262
+ return `${lines.join(`
3263
+ `)}
3264
+ `;
3265
+ }
3266
+ function renderOpenApiReadme() {
3267
+ return `## OpenAPI
3268
+
3269
+ After you add routes, regenerate and commit the spec so CI can catch drift:
3270
+
3271
+ \`\`\`bash
3272
+ bunx strata openapi:generate
3273
+ bunx strata openapi:check
3274
+ \`\`\`
3275
+
3276
+ Put \`openapi:check\` in CI. A failure means \`docs/openapi.json\` does not match the live route map (billing, auth, webhooks, and anything else you added).
3277
+ `;
3278
+ }
3029
3279
  function renderReadmeIntro(projectName, options) {
3030
3280
  if (options.dogfood) {
3031
3281
  return `# ${projectName}
@@ -3075,8 +3325,9 @@ function renderReadme(projectName, layers, options = {}) {
3075
3325
  | Docker Compose | ${dockerLabel} |
3076
3326
  ${extras.length > 0 ? `| Extras | ${extras.join(", ")} |
3077
3327
  ` : ""}
3078
- This file is the map for this app. Framework guides: [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md), [Auth](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md), [Starter](https://github.com/EyK-26/strata/blob/main/docs/STARTER.md).
3328
+ This file is the map for this app. Framework guides: [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md), [Auth](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md), [Starter](https://github.com/EyK-26/strata/blob/main/docs/STARTER.md), [Integrations](https://github.com/EyK-26/strata/blob/main/docs/INTEGRATIONS.md).
3079
3329
 
3330
+ ${renderIntegrationsReadme(layers)}
3080
3331
  ## Run it
3081
3332
 
3082
3333
  \`\`\`bash
@@ -3101,7 +3352,7 @@ HTML auth kit (restyle \`views/\` and \`public/assets/site.css\`):
3101
3352
  - Register: \`/register\`
3102
3353
  - Forgot password: \`/forgot-password\`
3103
3354
  - Reset password: signed \`/reset-password\` (mail log when \`MAIL_DRIVER=log\`)
3104
- ${layers.extras.emailVerification ? "- Verify email: `/email/verify`\n" : ""}${layers.extras.mfa ? "- MFA challenge: `/login/mfa` and setup: `/account/mfa`\n" : ""}
3355
+ ${layers.extras.emailVerification ? "- Verify email: `/email/verify`\n" : ""}${layers.extras.mfa ? "- MFA challenge: `/login/mfa` and setup: `/account/mfa`\n" : ""}${layers.extras.oauthGithub ? "- GitHub: `/auth/github` (needs `FEATURE_OAUTH=true`)\n" : ""}${layers.extras.oidc ? "- OIDC: `/auth/oidc` (needs `FEATURE_OAUTH=true`; PKCE cookie `oidc_pkce`)\n" : ""}
3105
3356
  Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
3106
3357
  ` : ""}${authUsesToken(layers.auth) ? `
3107
3358
  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.
@@ -3125,6 +3376,7 @@ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun r
3125
3376
 
3126
3377
  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\`.${usesComposePostgres(layers) ? ` \`.env.example\` points \`DATABASE_URL\` at \`${GENERATED_POSTGRES_APP_ROLE}\` (\`NOSUPERUSER\` \`NOBYPASSRLS\`), including \`--no-docker\`. \`db/ensure-postgres-app-role.sql\` is repeatable on an existing volume; Compose init still runs on first empty volume. The \`postgres\` superuser is for CREATE ROLE / GRANT / migrate and Adminer.` : ""}
3127
3378
  `}
3379
+ ${renderOpenApiReadme()}
3128
3380
  ## Deploy
3129
3381
 
3130
3382
  \`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." : ""}
@@ -3147,7 +3399,7 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
3147
3399
  - Set \`FEATURE_PUBLIC_READS=false\`. Generated \`.env.example\` already ships \`false\` so \`wrapWebPublicRead\` requires a login. Local storefronts may set \`true\` in \`.env\` for an anonymous catalog; \`assertProductionSecrets()\` rejects \`true\` in production. Do not ship a public-reads production boot.
3148
3400
  - 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.
3149
3401
  - 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.
3150
- ${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" : ""}${layers.tenancy === "rls" ? `- \`DATABASE_URL\` must be a \`NOBYPASSRLS\` role, not the \`postgres\` superuser. Generated apps create \`${GENERATED_POSTGRES_APP_ROLE}\` via \`db/ensure-postgres-app-role.sql\` (and Compose init on first empty volume). \`MIGRATION_DATABASE_URL\` may stay the superuser for CREATE ROLE / GRANT / migrate. Production boot rejects username \`postgres\` or \`root\`, then inspects \`pg_roles\` for \`rolsuper\` / \`rolbypassrls\`.
3402
+ ${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" : ""}${layers.extras.oauthGithub ? "- Set `OAUTH_STATE_SECRET` and `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`.\n" : ""}${layers.extras.oidc ? "- Set `OAUTH_STATE_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET`. Loopback issuers work outside production; set `OIDC_ALLOW_PRIVATE=true` for other private IdPs.\n" : ""}${layers.extras.billing ? "- Set `STRIPE_WEBHOOK_SECRET` when `FEATURE_BILLING=true`.\n" : ""}${layers.tenancy === "rls" ? `- \`DATABASE_URL\` must be a \`NOBYPASSRLS\` role, not the \`postgres\` superuser. Generated apps create \`${GENERATED_POSTGRES_APP_ROLE}\` via \`db/ensure-postgres-app-role.sql\` (and Compose init on first empty volume). \`MIGRATION_DATABASE_URL\` may stay the superuser for CREATE ROLE / GRANT / migrate. Production boot rejects username \`postgres\` or \`root\`, then inspects \`pg_roles\` for \`rolsuper\` / \`rolbypassrls\`.
3151
3403
  ` : ""}
3152
3404
  \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` is 200 when \`notes\` is readable (including zero rows) and 503 when that read fails. Redis jobs need a worker: \`bun run queue:work\` (requires \`REDIS_URL\`).
3153
3405
  `;
@@ -3931,6 +4183,29 @@ export type { AuthOneTimeTokenRecord };
3931
4183
  export { AuthOneTimeToken };
3932
4184
  `;
3933
4185
  }
4186
+ function renderModelsRegister(layers) {
4187
+ const lines = [
4188
+ "// Side-effect imports run registerModelRepository() in each model file.",
4189
+ "// registerModelClass() is only for a string alias that is neither constructor.name nor $morphClass.",
4190
+ 'import { registerModelClass } from "@getstrata/core/database/model";',
4191
+ 'import "./Note.ts";'
4192
+ ];
4193
+ if (authNeedsUsers(layers.auth)) {
4194
+ lines.push('import "./User.ts";');
4195
+ lines.push('import "./AuthOneTimeToken.ts";');
4196
+ }
4197
+ if (authUsesToken(layers.auth)) {
4198
+ lines.push('import "./ApiToken.ts";');
4199
+ }
4200
+ lines.push("");
4201
+ lines.push("// Example after you add a Product model with belongsTo('Category'):");
4202
+ lines.push('// registerModelClass("Category", Category);');
4203
+ lines.push("void registerModelClass;");
4204
+ lines.push("");
4205
+ return `${lines.join(`
4206
+ `)}
4207
+ `;
4208
+ }
3934
4209
  function dropTables(layers) {
3935
4210
  const ordered = ["failed_job"];
3936
4211
  if (authUsesToken(layers.auth)) {
@@ -4040,6 +4315,7 @@ function renderPreloadTs(layers, projectName) {
4040
4315
  ` : "";
4041
4316
  return `import { join } from "node:path";
4042
4317
  import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
4318
+ import "../models/register.ts";
4043
4319
 
4044
4320
  process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
4045
4321
  ${migrationLine}process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
@@ -4605,6 +4881,286 @@ export function plainText(body: string, status = 200): Response {
4605
4881
  `;
4606
4882
  }
4607
4883
 
4884
+ // src/renderIntegrations.ts
4885
+ function activeBoolColumn(database) {
4886
+ if (database === "postgres") {
4887
+ return "BOOLEAN NOT NULL DEFAULT TRUE";
4888
+ }
4889
+ if (database === "mysql") {
4890
+ return "TINYINT(1) NOT NULL DEFAULT 1";
4891
+ }
4892
+ return "INTEGER NOT NULL DEFAULT 1";
4893
+ }
4894
+ function webhookMigrationName(layers) {
4895
+ if (!layers.extras.webhooks) {
4896
+ return null;
4897
+ }
4898
+ return "0002_webhooks_schema";
4899
+ }
4900
+ function billingMigrationName(layers) {
4901
+ if (!layers.extras.billing) {
4902
+ return null;
4903
+ }
4904
+ return layers.extras.webhooks ? "0003_billing_schema" : "0002_billing_schema";
4905
+ }
4906
+ function renderFileMigration(name, upSql, downSql) {
4907
+ const upCalls = upSql.map((sql) => ` await db.unsafe(${JSON.stringify(sql)});`).join(`
4908
+ `);
4909
+ const downCalls = downSql.map((sql) => ` await db.unsafe(${JSON.stringify(sql)});`).join(`
4910
+ `);
4911
+ return `import type { Migration } from "@getstrata/core/database/migrations/types";
4912
+
4913
+ const migration: Migration = {
4914
+ name: ${JSON.stringify(name)},
4915
+ async up(db) {
4916
+ ${upCalls}
4917
+ },
4918
+ async down(db) {
4919
+ ${downCalls}
4920
+ },
4921
+ };
4922
+
4923
+ export default migration;
4924
+ `;
4925
+ }
4926
+ function renderWebhooksMigration(layers) {
4927
+ const name = webhookMigrationName(layers);
4928
+ if (!name) {
4929
+ return null;
4930
+ }
4931
+ const d = dialectFragments(layers.database);
4932
+ const payloadType = layers.database === "postgres" ? "JSONB NOT NULL" : `${d.text} NOT NULL`;
4933
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
4934
+ return renderFileMigration(name, [
4935
+ `CREATE TABLE IF NOT EXISTS webhooks (
4936
+ id ${d.id},
4937
+ url ${d.text} NOT NULL,
4938
+ secret ${d.text} NOT NULL,
4939
+ events ${d.defaultText} NOT NULL DEFAULT '*',
4940
+ active ${activeBoolColumn(layers.database)},
4941
+ created_at ${d.timestamp}
4942
+ )`,
4943
+ `CREATE TABLE IF NOT EXISTS webhook_deliveries (
4944
+ id ${d.id},
4945
+ webhook_id INTEGER NOT NULL,
4946
+ event ${d.keyText} NOT NULL,
4947
+ payload ${payloadType},
4948
+ response_status INTEGER,
4949
+ attempt INTEGER NOT NULL DEFAULT 1,
4950
+ error ${d.text},
4951
+ created_at ${d.timestamp}
4952
+ )`
4953
+ ], [
4954
+ `DROP TABLE IF EXISTS webhook_deliveries${cascade}`,
4955
+ `DROP TABLE IF EXISTS webhooks${cascade}`
4956
+ ]);
4957
+ }
4958
+ function renderBillingMigration(layers) {
4959
+ const name = billingMigrationName(layers);
4960
+ if (!name) {
4961
+ return null;
4962
+ }
4963
+ const d = dialectFragments(layers.database);
4964
+ const tenancyOn = usesTenantTable(layers.tenancy);
4965
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
4966
+ const up = [];
4967
+ if (tenancyOn) {
4968
+ up.push(`ALTER TABLE tenant ADD COLUMN stripe_customer_id ${d.keyText}`);
4969
+ }
4970
+ const tenantColumn = tenancyOn ? `
4971
+ tenant_id INTEGER NOT NULL,` : "";
4972
+ up.push(`CREATE TABLE IF NOT EXISTS subscription (
4973
+ id ${d.id},${tenantColumn}
4974
+ stripe_subscription_id ${d.keyText} UNIQUE,
4975
+ plan ${d.defaultText} NOT NULL DEFAULT 'free',
4976
+ status ${d.defaultText} NOT NULL DEFAULT 'active',
4977
+ current_period_end ${d.timestampNull},
4978
+ created_at ${d.timestamp},
4979
+ updated_at ${d.timestamp}
4980
+ )`);
4981
+ const eventTenant = tenancyOn ? `
4982
+ tenant_id INTEGER,` : "";
4983
+ up.push(`CREATE TABLE IF NOT EXISTS stripe_webhook_event (
4984
+ id ${d.keyText} PRIMARY KEY,
4985
+ event_type ${d.keyText} NOT NULL,${eventTenant}
4986
+ processed_at ${d.timestamp}
4987
+ )`);
4988
+ const down = [
4989
+ `DROP TABLE IF EXISTS stripe_webhook_event${cascade}`,
4990
+ `DROP TABLE IF EXISTS subscription${cascade}`
4991
+ ];
4992
+ return renderFileMigration(name, up, down);
4993
+ }
4994
+ function renderDispatchOutboundWebhookJob() {
4995
+ return `import { Job } from "@getstrata/core/queue";
4996
+ import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
4997
+ import { webhookSignatureHeader } from "@getstrata/core/runtime/appKeyPrefix";
4998
+ import { safeFetch } from "@getstrata/core/security/safeFetch";
4999
+ import { signWebhookBody } from "@getstrata/core/security/webhookSignature";
5000
+ import { getSql } from "../bootstrap/database.ts";
5001
+
5002
+ interface DispatchOutboundWebhookPayload {
5003
+ webhookId: number;
5004
+ url: string;
5005
+ secret: string;
5006
+ body: string;
5007
+ }
5008
+
5009
+ class DispatchOutboundWebhookJob extends Job<DispatchOutboundWebhookPayload> {
5010
+ static readonly jobName = "webhook.dispatch";
5011
+
5012
+ override async handle(payload: DispatchOutboundWebhookPayload): Promise<void> {
5013
+ const allowPrivate = !isProductionEnv() && process.env.WEBHOOK_ALLOW_PRIVATE === "true";
5014
+ let responseStatus: number | null = null;
5015
+ let error: string | null = null;
5016
+ try {
5017
+ const response = await safeFetch(
5018
+ payload.url,
5019
+ {
5020
+ method: "POST",
5021
+ headers: {
5022
+ "content-type": "application/json",
5023
+ [webhookSignatureHeader()]: signWebhookBody(payload.secret, payload.body),
5024
+ },
5025
+ body: payload.body,
5026
+ },
5027
+ { timeoutMs: 10_000, maxRedirects: 0, allowPrivate, allowHttp: allowPrivate },
5028
+ );
5029
+ responseStatus = response.status;
5030
+ if (!response.ok) {
5031
+ error = \`HTTP \${response.status}\`;
5032
+ }
5033
+ } catch (caught) {
5034
+ error = caught instanceof Error ? caught.message : "webhook dispatch failed";
5035
+ }
5036
+
5037
+ await getSql().unsafe(
5038
+ "INSERT INTO webhook_deliveries (webhook_id, event, payload, response_status, attempt, error) VALUES (?, ?, ?, ?, ?, ?)",
5039
+ [payload.webhookId, "notes.created", payload.body, responseStatus, 1, error],
5040
+ );
5041
+ }
5042
+ }
5043
+
5044
+ export default DispatchOutboundWebhookJob;
5045
+ export type { DispatchOutboundWebhookPayload };
5046
+ `;
5047
+ }
5048
+ function renderNoteCreatedWebhookListener() {
5049
+ return `import { eventBus, modelEventName } from "@getstrata/core/events";
5050
+ import { resolveApplicationQueue } from "@getstrata/core/runtime/applicationRegistry";
5051
+ import { getSql } from "../bootstrap/database.ts";
5052
+ import DispatchOutboundWebhookJob from "../jobs/dispatchOutboundWebhookJob.ts";
5053
+
5054
+ function registerNoteCreatedWebhookListener(): void {
5055
+ eventBus.listen(modelEventName("notes", "created"), async (payload) => {
5056
+ const rows = await getSql().unsafe<{
5057
+ id: number;
5058
+ url: string;
5059
+ secret: string;
5060
+ active: number | boolean;
5061
+ }>("SELECT id, url, secret, active FROM webhooks");
5062
+ const queue = resolveApplicationQueue();
5063
+ const body = JSON.stringify({ event: "notes.created", data: payload });
5064
+ for (const hook of rows) {
5065
+ if (!hook.active) {
5066
+ continue;
5067
+ }
5068
+ await queue.dispatch(new DispatchOutboundWebhookJob(), {
5069
+ webhookId: hook.id,
5070
+ url: hook.url,
5071
+ secret: hook.secret,
5072
+ body,
5073
+ });
5074
+ }
5075
+ });
5076
+ }
5077
+
5078
+ export default registerNoteCreatedWebhookListener;
5079
+ `;
5080
+ }
5081
+ function renderBillingModule(layers) {
5082
+ const tenancyOn = usesTenantTable(layers.tenancy);
5083
+ const tenantImport = tenancyOn ? `import { currentTenant } from "@getstrata/core/tenant/tenantContext";
5084
+ ` : "";
5085
+ const subscriptionQuery = tenancyOn ? ` const tenant = currentTenant();
5086
+ if (!tenant) {
5087
+ return jsonResponse({ error: "Tenant context is required." }, { status: 500 });
5088
+ }
5089
+ const rows = await getSql().unsafe<Record<string, unknown>>(
5090
+ "SELECT plan, status, stripe_subscription_id, current_period_end FROM subscription WHERE tenant_id = ? LIMIT 1",
5091
+ [tenant.id],
5092
+ );` : ` const rows = await getSql().unsafe<Record<string, unknown>>(
5093
+ "SELECT plan, status, stripe_subscription_id, current_period_end FROM subscription LIMIT 1",
5094
+ );`;
5095
+ return `import type { AppModule } from "@getstrata/bootstrap/contracts";
5096
+ import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";
5097
+ import { verifyStripeWebhookSignature } from "@getstrata/core/security/stripeWebhook";
5098
+ ${tenantImport}import { getSql } from "../../bootstrap/database.ts";
5099
+
5100
+ function billingEnabled(): boolean {
5101
+ return process.env.FEATURE_BILLING === "true";
5102
+ }
5103
+
5104
+ const billingModule: AppModule = {
5105
+ name: "billing",
5106
+ order: 40,
5107
+ routes({ kernel }) {
5108
+ return {
5109
+ "/billing/webhooks/stripe": {
5110
+ POST: kernel.wrap(
5111
+ "api",
5112
+ withErrorHandling(async (request) => {
5113
+ if (!billingEnabled()) {
5114
+ return new Response("Not found", { status: 404 });
5115
+ }
5116
+ const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
5117
+ if (!secret) {
5118
+ return jsonResponse({ error: "STRIPE_WEBHOOK_SECRET is not set." }, { status: 503 });
5119
+ }
5120
+ const rawBody = await request.text();
5121
+ verifyStripeWebhookSignature(rawBody, request.headers.get("stripe-signature"), secret);
5122
+ let event: { id?: string; type?: string; data?: { object?: Record<string, unknown> } };
5123
+ try {
5124
+ event = JSON.parse(rawBody) as typeof event;
5125
+ } catch {
5126
+ return jsonResponse({ error: "Invalid JSON." }, { status: 400 });
5127
+ }
5128
+ // Persist event.id on stripe_webhook_event yourself. Stripe POSTs have no tenant ALS.
5129
+ // Wrap those writes in runWithMigrationBypass when TENANCY_DRIVER=rls.
5130
+ // See https://github.com/EyK-26/strata/blob/main/docs/INTEGRATIONS.md
5131
+ void event.data;
5132
+ return jsonResponse({ received: true, id: event.id ?? null, type: event.type ?? null });
5133
+ }),
5134
+ ),
5135
+ },
5136
+ "/api/v1/billing/subscription": {
5137
+ GET: kernel.wrapApi(
5138
+ withErrorHandling(async () => {
5139
+ if (!billingEnabled()) {
5140
+ return new Response("Not found", { status: 404 });
5141
+ }
5142
+ ${subscriptionQuery}
5143
+ const row = rows[0];
5144
+ if (!row) {
5145
+ return jsonResponse({ plan: "free", status: "none" });
5146
+ }
5147
+ return jsonResponse({
5148
+ plan: row.plan ?? "free",
5149
+ status: row.status ?? "none",
5150
+ stripe_subscription_id: row.stripe_subscription_id ?? null,
5151
+ current_period_end: row.current_period_end ?? null,
5152
+ });
5153
+ }),
5154
+ ),
5155
+ },
5156
+ };
5157
+ },
5158
+ };
5159
+
5160
+ export default billingModule;
5161
+ `;
5162
+ }
5163
+
4608
5164
  // src/renderScim.ts
4609
5165
  function renderScimModule(layers) {
4610
5166
  if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
@@ -5026,9 +5582,23 @@ function writeGeneratedFiles(options) {
5026
5582
  } else {
5027
5583
  removeIfExists(join2(src, "models/ApiToken.ts"));
5028
5584
  }
5585
+ writeText(join2(src, "models/register.ts"), renderModelsRegister(layers));
5029
5586
  writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
5030
5587
  writeText(join2(src, "db/migrationRuntime.ts"), renderMigrationRuntimeTs(layers));
5031
5588
  writeText(join2(src, "db/migrations/0001_starter_schema.ts"), renderStarterSchemaMigration(layers));
5589
+ removeIfExists(join2(src, "db/migrations/0002_webhooks_schema.ts"));
5590
+ removeIfExists(join2(src, "db/migrations/0002_billing_schema.ts"));
5591
+ removeIfExists(join2(src, "db/migrations/0003_billing_schema.ts"));
5592
+ const webhooksMigration = renderWebhooksMigration(layers);
5593
+ const webhooksName = webhookMigrationName(layers);
5594
+ if (webhooksMigration && webhooksName) {
5595
+ writeText(join2(src, `db/migrations/${webhooksName}.ts`), webhooksMigration);
5596
+ }
5597
+ const billingMigration = renderBillingMigration(layers);
5598
+ const billingName = billingMigrationName(layers);
5599
+ if (billingMigration && billingName) {
5600
+ writeText(join2(src, `db/migrations/${billingName}.ts`), billingMigration);
5601
+ }
5032
5602
  writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
5033
5603
  writeText(join2(src, "db/seed.ts"), renderSeedTs());
5034
5604
  writeText(join2(src, "db/status.ts"), renderStatusTs(layers));
@@ -5052,6 +5622,18 @@ function writeGeneratedFiles(options) {
5052
5622
  } else {
5053
5623
  removeIfExists(join2(src, "modules/scim/index.ts"));
5054
5624
  }
5625
+ if (layers.extras.billing) {
5626
+ writeText(join2(src, "modules/billing/index.ts"), renderBillingModule(layers));
5627
+ } else {
5628
+ removeIfExists(join2(src, "modules/billing/index.ts"));
5629
+ }
5630
+ if (layers.extras.webhooks) {
5631
+ writeText(join2(src, "jobs/dispatchOutboundWebhookJob.ts"), renderDispatchOutboundWebhookJob());
5632
+ writeText(join2(src, "listeners/noteCreatedWebhookListener.ts"), renderNoteCreatedWebhookListener());
5633
+ } else {
5634
+ removeIfExists(join2(src, "jobs/dispatchOutboundWebhookJob.ts"));
5635
+ removeIfExists(join2(src, "listeners/noteCreatedWebhookListener.ts"));
5636
+ }
5055
5637
  if (layers.extras.mfa && htmlAuthKit(layers.auth)) {
5056
5638
  writeText(join2(src, "bootstrap/pendingMfa.ts"), renderPendingMfaTs());
5057
5639
  } else {
@@ -5072,7 +5654,7 @@ function writeGeneratedFiles(options) {
5072
5654
  writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
5073
5655
  writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
5074
5656
  if (htmlAuthKit(layers.auth)) {
5075
- writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView());
5657
+ writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView(layers));
5076
5658
  writeText(join2(targetDir, "views/auth/register.eta"), renderRegisterView());
5077
5659
  writeText(join2(targetDir, "views/auth/forgot-password.eta"), renderForgotPasswordView());
5078
5660
  writeText(join2(targetDir, "views/auth/reset-password.eta"), renderResetPasswordView());
@@ -12,9 +12,9 @@
12
12
  "check": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@getstrata/bootstrap": "^1.1.4",
16
- "@getstrata/cli": "^1.1.4",
17
- "@getstrata/core": "^1.1.4",
15
+ "@getstrata/bootstrap": "^1.1.6",
16
+ "@getstrata/cli": "^1.1.6",
17
+ "@getstrata/core": "^1.1.6",
18
18
  "eta": "^4.6.0"
19
19
  },
20
20
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",