@getstrata/starter 1.1.5 → 1.1.7

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
@@ -120,7 +120,7 @@ function extraApplies(extra, auth) {
120
120
  if (extra === "metrics" || extra === "billing" || extra === "webhooks") {
121
121
  return true;
122
122
  }
123
- if (extra === "mfa" || extra === "oauthGithub") {
123
+ if (extra === "mfa" || extra === "oauthGithub" || extra === "oidc") {
124
124
  return htmlAuthKit(auth);
125
125
  }
126
126
  return authNeedsUsers(auth);
@@ -196,6 +196,7 @@ var noneExtras = {
196
196
  scim: false,
197
197
  metrics: false,
198
198
  oauthGithub: false,
199
+ oidc: false,
199
200
  billing: false,
200
201
  webhooks: false
201
202
  };
@@ -205,6 +206,7 @@ var enterpriseExtras = {
205
206
  scim: true,
206
207
  metrics: true,
207
208
  oauthGithub: false,
209
+ oidc: false,
208
210
  billing: false,
209
211
  webhooks: false
210
212
  };
@@ -274,9 +276,10 @@ Options:
274
276
  --scim / --no-scim
275
277
  --metrics / --no-metrics
276
278
  --oauth-github / --no-oauth-github
279
+ --oidc / --no-oidc
277
280
  --billing / --no-billing
278
281
  --webhooks / --no-webhooks
279
- --extras Interactive extras list (MFA, email verification, SCIM, metrics, GitHub OAuth, billing, webhooks)
282
+ --extras Interactive extras list (MFA, email verification, SCIM, metrics, GitHub OAuth, OIDC, billing, webhooks)
280
283
  --docker Write Docker Compose for every selected tool that needs a service
281
284
  --no-docker Skip docker-compose.yml; use installs already on this machine
282
285
  --docker-services Subset: postgres, mysql, redis, mailpit, adminer (comma-separated)
@@ -382,6 +385,8 @@ function parseCreateStrataArgs(argv) {
382
385
  ["--no-metrics", "metrics", false],
383
386
  ["--oauth-github", "oauthGithub", true],
384
387
  ["--no-oauth-github", "oauthGithub", false],
388
+ ["--oidc", "oidc", true],
389
+ ["--no-oidc", "oidc", false],
385
390
  ["--billing", "billing", true],
386
391
  ["--no-billing", "billing", false],
387
392
  ["--webhooks", "webhooks", true],
@@ -509,6 +514,9 @@ function extraFlagName(extra) {
509
514
  if (extra === "oauthGithub") {
510
515
  return "oauth-github";
511
516
  }
517
+ if (extra === "oidc") {
518
+ return "oidc";
519
+ }
512
520
  return extra;
513
521
  }
514
522
  function dropInapplicableExtras(layers, flags) {
@@ -912,6 +920,7 @@ var EXTRA_CHOICES = [
912
920
  { value: "scim", label: "scim: /Users adapter" },
913
921
  { value: "metrics", label: "metrics: Prometheus token" },
914
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)" },
915
924
  { value: "billing", label: "billing: Stripe webhook stub + GET /api/v1/billing/subscription" },
916
925
  { value: "webhooks", label: "webhooks: outbound notes.created job (webhook.dispatch)" }
917
926
  ];
@@ -1200,6 +1209,7 @@ function renderAuthModule(layers) {
1200
1209
  }
1201
1210
  const cookie = htmlAuthKit(layers.auth);
1202
1211
  const github = Boolean(layers.extras.oauthGithub && cookie);
1212
+ const oidc = Boolean(layers.extras.oidc && cookie);
1203
1213
  const mfa = Boolean(layers.extras.mfa && cookie);
1204
1214
  const verify = Boolean(layers.extras.emailVerification);
1205
1215
  const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
@@ -1221,6 +1231,11 @@ function renderAuthModule(layers) {
1221
1231
  imports.push(`import { createSamlServiceProvider } from "@getstrata/core/auth/saml/samlServiceProvider";`);
1222
1232
  if (github) {
1223
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) {
1224
1239
  imports.push(`import { appUrl } from "@getstrata/core/runtime/appKeyPrefix";`);
1225
1240
  }
1226
1241
  }
@@ -1251,7 +1266,7 @@ function renderAuthModule(layers) {
1251
1266
  if (mfa) {
1252
1267
  imports.push(`import { sanitizeInternalPath } from "@getstrata/core/http/safeInternalPath";`);
1253
1268
  }
1254
- 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";`);
1255
1270
  }
1256
1271
  if (jsonApi || cookie) {
1257
1272
  imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
@@ -1379,17 +1394,18 @@ async function completeBrowserSsoLogin(
1379
1394
  profile: { email: string; name: string },
1380
1395
  label: string,
1381
1396
  ): Promise<Response> {
1382
- let record = await starterAuthDirectory.findByEmail?.(profile.email);
1397
+ const email = profile.email.trim().toLowerCase();
1398
+ let record = await starterAuthDirectory.findByEmail?.(email);
1383
1399
  if (!record) {
1384
1400
  if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1385
1401
  return jsonResponse({ error: \`\${label} user is not provisioned.\` }, { status: 403 });
1386
1402
  }
1387
1403
  const hashed = await hashPassword(randomBytes(18).toString("hex"));
1388
1404
  try {
1389
- await runAuthWrite(profile.email, async () => {
1405
+ await runAuthWrite(email, async () => {
1390
1406
  await User.create({
1391
1407
  name: profile.name,
1392
- email: profile.email,
1408
+ email,
1393
1409
  password: hashed,
1394
1410
  is_admin: false,${samlTenantCreateField}
1395
1411
  });
@@ -1397,7 +1413,7 @@ async function completeBrowserSsoLogin(
1397
1413
  } catch {
1398
1414
  // Unique email: another request already provisioned this user.
1399
1415
  }
1400
- record = await starterAuthDirectory.findByEmail?.(profile.email);
1416
+ record = await starterAuthDirectory.findByEmail?.(email);
1401
1417
  }
1402
1418
  if (!record) {
1403
1419
  return jsonResponse({ error: \`Could not complete \${label} login.\` }, { status: 500 });
@@ -1692,9 +1708,67 @@ async function completeBrowserSsoLogin(
1692
1708
  return completeBrowserSsoLogin(dependencies, profile, "GitHub");
1693
1709
  })),
1694
1710
  },` : "";
1711
+ const oidcRoutes = oidc ? `
1712
+ "/auth/oidc": {
1713
+ GET: kernel.wrap("api", withErrorHandling(async () => {
1714
+ if (process.env.FEATURE_OAUTH !== "true") {
1715
+ return new Response("Not found", { status: 404 });
1716
+ }
1717
+ const issuer = process.env.OIDC_ISSUER?.trim();
1718
+ const clientId = process.env.OIDC_CLIENT_ID?.trim();
1719
+ const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim();
1720
+ if (!issuer || !clientId || !clientSecret) {
1721
+ return new Response("Not found", { status: 404 });
1722
+ }
1723
+ const redirectUri = process.env.OIDC_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/oidc/callback\`;
1724
+ const issued = createOAuthState();
1725
+ const provider = new OidcProvider({ name: "oidc", issuer, clientId, clientSecret, redirectUri });
1726
+ const { url, handshake } = await provider.createAuthorization(issued.state);
1727
+ const redirect = new Response(null, { status: 302, headers: { location: url } });
1728
+ redirect.headers.append(
1729
+ "set-cookie",
1730
+ sealOidcPkceCookie({ state: issued.state, nonce: handshake.nonce, codeVerifier: handshake.codeVerifier }),
1731
+ );
1732
+ return redirect;
1733
+ })),
1734
+ },
1735
+ "/auth/oidc/callback": {
1736
+ GET: kernel.wrap("api", withErrorHandling(async (request) => {
1737
+ if (process.env.FEATURE_OAUTH !== "true") {
1738
+ return new Response("Not found", { status: 404 });
1739
+ }
1740
+ const issuer = process.env.OIDC_ISSUER?.trim();
1741
+ const clientId = process.env.OIDC_CLIENT_ID?.trim();
1742
+ const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim();
1743
+ if (!issuer || !clientId || !clientSecret) {
1744
+ return new Response("Not found", { status: 404 });
1745
+ }
1746
+ const url = new URL(request.url);
1747
+ const code = url.searchParams.get("code");
1748
+ const state = url.searchParams.get("state");
1749
+ const pkce = readOidcPkceCookie(request, state);
1750
+ if (!code || !verifyOAuthState(request, state) || !pkce) {
1751
+ const denied = jsonResponse({ error: "Invalid OIDC state." }, { status: 403 });
1752
+ denied.headers.append("set-cookie", clearOidcPkceCookie());
1753
+ return denied;
1754
+ }
1755
+ const redirectUri = process.env.OIDC_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/oidc/callback\`;
1756
+ const profile = await new OidcProvider({ name: "oidc", issuer, clientId, clientSecret, redirectUri })
1757
+ .exchangeCode(code, redirectUri, pkce)
1758
+ .catch(() => null);
1759
+ if (!profile) {
1760
+ const failed = jsonResponse({ error: "Invalid OIDC response." }, { status: 400 });
1761
+ failed.headers.append("set-cookie", clearOidcPkceCookie());
1762
+ return failed;
1763
+ }
1764
+ const login = await completeBrowserSsoLogin(dependencies, profile, "OIDC");
1765
+ login.headers.append("set-cookie", clearOidcPkceCookie());
1766
+ return login;
1767
+ })),
1768
+ },` : "";
1695
1769
  const apiRoutes = jsonApi || cookie ? `
1696
1770
  routes({ kernel, dependencies }) {
1697
- return {${csrfRoute}${samlRoutes}${githubRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1771
+ return {${csrfRoute}${samlRoutes}${githubRoutes}${oidcRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1698
1772
  };
1699
1773
  },` : "";
1700
1774
  const mfaLoginBranch = `const mfaResult = completePasswordLogin(user, { mfaCode: mfa_code });
@@ -2311,9 +2385,11 @@ function textField(name, label, type, extra = "") {
2311
2385
  function renderLoginView(layers) {
2312
2386
  const githubLink = layers.extras.oauthGithub && htmlAuthKit(layers.auth) ? `
2313
2387
  <a href="/auth/github">Sign in with GitHub</a>` : "";
2388
+ const oidcLink = layers.extras.oidc && htmlAuthKit(layers.auth) ? `
2389
+ <% if (it.oidcLogin) { %><a href="/auth/oidc">Sign in with OIDC</a><% } %>` : "";
2314
2390
  return renderFormView("Sign in", `${textField("email", "Email", "email", 'required autocomplete="username"')}
2315
2391
  ${textField("password", "Password", "password", 'required autocomplete="current-password"')}`, "Sign in", `<a href="/register">Create account</a>
2316
- <a href="/forgot-password">Forgot password</a>${githubLink}`).replace('action="<%= it.action || "" %>"', 'action="/login"');
2392
+ <a href="/forgot-password">Forgot password</a>${githubLink}${oidcLink}`).replace('action="<%= it.action || "" %>"', 'action="/login"');
2317
2393
  }
2318
2394
  function renderRegisterView() {
2319
2395
  return renderFormView("Create account", `${textField("name", "Name", "text", "required")}
@@ -2734,20 +2810,38 @@ function renderEnvExample(projectName, layers) {
2734
2810
  lines.push("# SAML_IDP_ISSUER=");
2735
2811
  lines.push("# SAML_WANT_RESPONSE_SIGNED=");
2736
2812
  lines.push("# SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=");
2737
- const oauthOn = Boolean(layers.extras.oauthGithub);
2738
- lines.push(`FEATURE_OAUTH=${envFlag(oauthOn)}`);
2739
- if (oauthOn) {
2813
+ const githubOn = Boolean(layers.extras.oauthGithub);
2814
+ const oidcOn = Boolean(layers.extras.oidc);
2815
+ lines.push(`FEATURE_OAUTH=${envFlag(githubOn || oidcOn)}`);
2816
+ if (githubOn) {
2740
2817
  lines.push("GITHUB_CLIENT_ID=");
2741
2818
  lines.push("GITHUB_CLIENT_SECRET=");
2742
2819
  lines.push("GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback");
2743
- lines.push("OAUTH_STATE_SECRET=dev-oauth-state-secret-change-me-please-32");
2744
2820
  } else {
2745
2821
  lines.push("# GITHUB_CLIENT_ID=");
2746
2822
  lines.push("# GITHUB_CLIENT_SECRET=");
2747
2823
  lines.push("# GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback");
2824
+ }
2825
+ if (oidcOn) {
2826
+ lines.push("OIDC_ISSUER=http://127.0.0.1:8080");
2827
+ lines.push("OIDC_CLIENT_ID=");
2828
+ lines.push("OIDC_CLIENT_SECRET=");
2829
+ lines.push("OIDC_REDIRECT_URI=http://localhost:3000/auth/oidc/callback");
2830
+ lines.push("# Loopback issuers are allowed when APP_ENV is not production. Other private IdPs:");
2831
+ lines.push("# OIDC_ALLOW_PRIVATE=true");
2832
+ } else {
2833
+ lines.push("# OIDC_ISSUER=");
2834
+ lines.push("# OIDC_CLIENT_ID=");
2835
+ lines.push("# OIDC_CLIENT_SECRET=");
2836
+ lines.push("# OIDC_REDIRECT_URI=http://localhost:3000/auth/oidc/callback");
2837
+ lines.push("# OIDC_ALLOW_PRIVATE=true");
2838
+ }
2839
+ if (githubOn || oidcOn) {
2840
+ lines.push("OAUTH_STATE_SECRET=dev-oauth-state-secret-change-me-please-32");
2841
+ } else {
2748
2842
  lines.push("# OAUTH_STATE_SECRET=");
2749
2843
  }
2750
- lines.push("# OIDC cookie login is not generated. Use GitHub cookie routes (--oauth-github) or app-owned PKCE.");
2844
+ lines.push("# --oidc (cookie HTML) seals PKCE in the oidc_pkce cookie. Do not call OidcProvider.getAuthorizationUrl().");
2751
2845
  const billingOn = Boolean(layers.extras.billing);
2752
2846
  lines.push(`FEATURE_BILLING=${envFlag(billingOn)}`);
2753
2847
  if (billingOn) {
@@ -2966,9 +3060,9 @@ function renderPackageJson(projectName, options = {}) {
2966
3060
  "@getstrata/cli": "workspace:*",
2967
3061
  "@getstrata/core": "workspace:*"
2968
3062
  } : {
2969
- "@getstrata/bootstrap": "^1.1.5",
2970
- "@getstrata/cli": "^1.1.5",
2971
- "@getstrata/core": "^1.1.5"
3063
+ "@getstrata/bootstrap": "^1.1.7",
3064
+ "@getstrata/cli": "^1.1.7",
3065
+ "@getstrata/core": "^1.1.7"
2972
3066
  };
2973
3067
  coreDeps.eta = "^4.6.0";
2974
3068
  if (options.layers?.database === "mysql") {
@@ -3092,6 +3186,9 @@ function renderApiDocs(projectName, layers) {
3092
3186
  if (layers.extras.oauthGithub && authUsesCookie(layers.auth)) {
3093
3187
  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. |");
3094
3188
  }
3189
+ if (layers.extras.oidc && authUsesCookie(layers.auth)) {
3190
+ 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. |");
3191
+ }
3095
3192
  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.`;
3096
3193
  return `# ${projectName} API
3097
3194
 
@@ -3146,6 +3243,7 @@ Import from \`@getstrata/core/...\` subpaths rather than the package root, so si
3146
3243
  function renderIntegrationsReadme(layers) {
3147
3244
  const enabled = [
3148
3245
  layers.extras.oauthGithub ? "GitHub OAuth cookie login (`/auth/github`)" : null,
3246
+ layers.extras.oidc ? "OIDC cookie login (`/auth/oidc`, PKCE in `oidc_pkce`)" : null,
3149
3247
  layers.extras.billing ? "Stripe billing stub (`POST /billing/webhooks/stripe`, `GET /api/v1/billing/subscription`)" : null,
3150
3248
  layers.extras.webhooks ? "Outbound webhooks (`notes.created` listener + `webhook.dispatch` job)" : null
3151
3249
  ].filter((row) => row !== null);
@@ -3154,7 +3252,7 @@ function renderIntegrationsReadme(layers) {
3154
3252
  "",
3155
3253
  "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).",
3156
3254
  "",
3157
- "Wizard flags (any stack unless noted): `--oauth-github` (cookie HTML), `--billing`, `--webhooks`. They emit modules, migrations, jobs, or listeners. `createApp` still calls `discoverListeners()` and `discoverJobs()`.",
3255
+ "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()`.",
3158
3256
  ""
3159
3257
  ];
3160
3258
  if (enabled.length > 0) {
@@ -3176,7 +3274,9 @@ bunx strata openapi:generate
3176
3274
  bunx strata openapi:check
3177
3275
  \`\`\`
3178
3276
 
3179
- 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
+ Put \`openapi:check\` in CI. A failure means \`docs/openapi.json\` does not match the live JSON API route map (billing, auth, webhooks, and anything else you added).
3278
+
3279
+ \`openapi:generate\` lists API routes only. HTML admin and storefront paths are not in that file.
3180
3280
  `;
3181
3281
  }
3182
3282
  function renderReadmeIntro(projectName, options) {
@@ -3255,7 +3355,7 @@ HTML auth kit (restyle \`views/\` and \`public/assets/site.css\`):
3255
3355
  - Register: \`/register\`
3256
3356
  - Forgot password: \`/forgot-password\`
3257
3357
  - Reset password: signed \`/reset-password\` (mail log when \`MAIL_DRIVER=log\`)
3258
- ${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" : ""}
3358
+ ${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`). Failures on the callback are JSON, not the login page. State is the signed query value from `createOAuthState()`, not `createOAuthStateCookie()`.\n" : ""}${layers.extras.oidc ? "- OIDC: `/auth/oidc` (needs `FEATURE_OAUTH=true` plus `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET`; otherwise the login link stays hidden and the route is 404). PKCE cookie `oidc_pkce`. Callback failures are JSON, not the login page.\n" : ""}
3259
3359
  Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
3260
3360
  ` : ""}${authUsesToken(layers.auth) ? `
3261
3361
  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.
@@ -3302,7 +3402,7 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
3302
3402
  - 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.
3303
3403
  - 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.
3304
3404
  - 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.
3305
- ${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.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\`.
3405
+ ${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\`.
3306
3406
  ` : ""}
3307
3407
  \`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\`).
3308
3408
  `;
@@ -4769,10 +4869,13 @@ export async function renderPage(
4769
4869
  ): Promise<Response> {
4770
4870
  const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
4771
4871
  const flash = currentRequestMeta().flash ?? null;
4772
- ${userBlock}
4872
+ ${userBlock}${layers.extras.oidc ? `
4873
+ const oidcLogin =
4874
+ process.env.FEATURE_OAUTH === "true" &&
4875
+ Boolean(process.env.OIDC_CLIENT_ID?.trim() && process.env.OIDC_CLIENT_SECRET?.trim());` : ""}
4773
4876
  const html = await engine.render(
4774
4877
  template,
4775
- { ...data, csrfToken, flash, currentUser },
4878
+ { ...data, csrfToken, flash, currentUser${layers.extras.oidc ? ", oidcLogin" : ""} },
4776
4879
  { request },
4777
4880
  );
4778
4881
  return htmlResponse(html, { status });
@@ -4894,16 +4997,22 @@ function renderBillingMigration(layers) {
4894
4997
  ];
4895
4998
  return renderFileMigration(name, up, down);
4896
4999
  }
4897
- function renderDispatchOutboundWebhookJob() {
5000
+ function sqlParams(database, count) {
5001
+ return Array.from({ length: count }, (_, index) => database === "postgres" ? `$${index + 1}` : "?").join(", ");
5002
+ }
5003
+ function renderDispatchOutboundWebhookJob(layers) {
5004
+ const values = sqlParams(layers.database, 6);
4898
5005
  return `import { Job } from "@getstrata/core/queue";
4899
5006
  import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
4900
5007
  import { webhookSignatureHeader } from "@getstrata/core/runtime/appKeyPrefix";
4901
5008
  import { safeFetch } from "@getstrata/core/security/safeFetch";
4902
5009
  import { signWebhookBody } from "@getstrata/core/security/webhookSignature";
5010
+ import { runWithMigrationBypass } from "@getstrata/core/tenant/databaseTenantContext";
4903
5011
  import { getSql } from "../bootstrap/database.ts";
4904
5012
 
4905
5013
  interface DispatchOutboundWebhookPayload {
4906
5014
  webhookId: number;
5015
+ event: string;
4907
5016
  url: string;
4908
5017
  secret: string;
4909
5018
  body: string;
@@ -4937,10 +5046,12 @@ class DispatchOutboundWebhookJob extends Job<DispatchOutboundWebhookPayload> {
4937
5046
  error = caught instanceof Error ? caught.message : "webhook dispatch failed";
4938
5047
  }
4939
5048
 
4940
- await getSql().unsafe(
4941
- "INSERT INTO webhook_deliveries (webhook_id, event, payload, response_status, attempt, error) VALUES (?, ?, ?, ?, ?, ?)",
4942
- [payload.webhookId, "notes.created", payload.body, responseStatus, 1, error],
4943
- );
5049
+ await runWithMigrationBypass(async () => {
5050
+ await getSql().unsafe(
5051
+ "INSERT INTO webhook_deliveries (webhook_id, event, payload, response_status, attempt, error) VALUES (${values})",
5052
+ [payload.webhookId, payload.event, payload.body, responseStatus, 1, error],
5053
+ );
5054
+ });
4944
5055
  }
4945
5056
  }
4946
5057
 
@@ -4963,13 +5074,15 @@ function registerNoteCreatedWebhookListener(): void {
4963
5074
  active: number | boolean;
4964
5075
  }>("SELECT id, url, secret, active FROM webhooks");
4965
5076
  const queue = resolveApplicationQueue();
4966
- const body = JSON.stringify({ event: "notes.created", data: payload });
5077
+ const event = "notes.created";
5078
+ const body = JSON.stringify({ event, data: payload });
4967
5079
  for (const hook of rows) {
4968
5080
  if (!hook.active) {
4969
5081
  continue;
4970
5082
  }
4971
5083
  await queue.dispatch(new DispatchOutboundWebhookJob(), {
4972
5084
  webhookId: hook.id,
5085
+ event,
4973
5086
  url: hook.url,
4974
5087
  secret: hook.secret,
4975
5088
  body,
@@ -4981,8 +5094,29 @@ function registerNoteCreatedWebhookListener(): void {
4981
5094
  export default registerNoteCreatedWebhookListener;
4982
5095
  `;
4983
5096
  }
5097
+ function renderApplyStripeWebhook() {
5098
+ return `interface StripeWebhookEvent {
5099
+ id?: string;
5100
+ type?: string;
5101
+ data?: { object?: Record<string, unknown> };
5102
+ }
5103
+
5104
+ /**
5105
+ * Called after the Stripe signature verifies. The default does nothing.
5106
+ * Plan and tenant updates belong in this function, not in framework core.
5107
+ * Stripe POSTs have no tenant ALS. See docs/INTEGRATIONS.md before writing tenant tables.
5108
+ */
5109
+ async function applyStripeWebhook(_event: StripeWebhookEvent): Promise<void> {
5110
+ return;
5111
+ }
5112
+
5113
+ export type { StripeWebhookEvent };
5114
+ export { applyStripeWebhook };
5115
+ `;
5116
+ }
4984
5117
  function renderBillingModule(layers) {
4985
5118
  const tenancyOn = usesTenantTable(layers.tenancy);
5119
+ const tenantPlaceholder = layers.database === "postgres" ? "$1" : "?";
4986
5120
  const tenantImport = tenancyOn ? `import { currentTenant } from "@getstrata/core/tenant/tenantContext";
4987
5121
  ` : "";
4988
5122
  const subscriptionQuery = tenancyOn ? ` const tenant = currentTenant();
@@ -4990,7 +5124,7 @@ function renderBillingModule(layers) {
4990
5124
  return jsonResponse({ error: "Tenant context is required." }, { status: 500 });
4991
5125
  }
4992
5126
  const rows = await getSql().unsafe<Record<string, unknown>>(
4993
- "SELECT plan, status, stripe_subscription_id, current_period_end FROM subscription WHERE tenant_id = ? LIMIT 1",
5127
+ "SELECT plan, status, stripe_subscription_id, current_period_end FROM subscription WHERE tenant_id = ${tenantPlaceholder} LIMIT 1",
4994
5128
  [tenant.id],
4995
5129
  );` : ` const rows = await getSql().unsafe<Record<string, unknown>>(
4996
5130
  "SELECT plan, status, stripe_subscription_id, current_period_end FROM subscription LIMIT 1",
@@ -4998,6 +5132,7 @@ function renderBillingModule(layers) {
4998
5132
  return `import type { AppModule } from "@getstrata/bootstrap/contracts";
4999
5133
  import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";
5000
5134
  import { verifyStripeWebhookSignature } from "@getstrata/core/security/stripeWebhook";
5135
+ import { applyStripeWebhook } from "../../billing/applyStripeWebhook.ts";
5001
5136
  ${tenantImport}import { getSql } from "../../bootstrap/database.ts";
5002
5137
 
5003
5138
  function billingEnabled(): boolean {
@@ -5028,10 +5163,7 @@ const billingModule: AppModule = {
5028
5163
  } catch {
5029
5164
  return jsonResponse({ error: "Invalid JSON." }, { status: 400 });
5030
5165
  }
5031
- // Persist event.id on stripe_webhook_event yourself. Stripe POSTs have no tenant ALS.
5032
- // Wrap those writes in runWithMigrationBypass when TENANCY_DRIVER=rls.
5033
- // See https://github.com/EyK-26/strata/blob/main/docs/INTEGRATIONS.md
5034
- void event.data;
5166
+ await applyStripeWebhook(event);
5035
5167
  return jsonResponse({ received: true, id: event.id ?? null, type: event.type ?? null });
5036
5168
  }),
5037
5169
  ),
@@ -5526,12 +5658,14 @@ function writeGeneratedFiles(options) {
5526
5658
  removeIfExists(join2(src, "modules/scim/index.ts"));
5527
5659
  }
5528
5660
  if (layers.extras.billing) {
5661
+ writeText(join2(src, "billing/applyStripeWebhook.ts"), renderApplyStripeWebhook());
5529
5662
  writeText(join2(src, "modules/billing/index.ts"), renderBillingModule(layers));
5530
5663
  } else {
5664
+ removeIfExists(join2(src, "billing/applyStripeWebhook.ts"));
5531
5665
  removeIfExists(join2(src, "modules/billing/index.ts"));
5532
5666
  }
5533
5667
  if (layers.extras.webhooks) {
5534
- writeText(join2(src, "jobs/dispatchOutboundWebhookJob.ts"), renderDispatchOutboundWebhookJob());
5668
+ writeText(join2(src, "jobs/dispatchOutboundWebhookJob.ts"), renderDispatchOutboundWebhookJob(layers));
5535
5669
  writeText(join2(src, "listeners/noteCreatedWebhookListener.ts"), renderNoteCreatedWebhookListener());
5536
5670
  } else {
5537
5671
  removeIfExists(join2(src, "jobs/dispatchOutboundWebhookJob.ts"));
@@ -32,10 +32,11 @@ export default function HomePage() {
32
32
  </table>
33
33
  </div>
34
34
 
35
- <h2>Add your first resource</h2>
35
+ <h2>Starter-only example</h2>
36
36
  <p>
37
- Create a module under <code>src/modules/</code> and return routes from it. The kernel picks
38
- it up on the next boot.
37
+ This empty app already serves <code>GET /api/v1/auth/me</code>. It does not serve{" "}
38
+ <code>GET /api/v1/notes</code>. The module below is an example you add yourself. Create it
39
+ under <code>src/modules/</code> and the kernel picks it up on the next boot.
39
40
  </p>
40
41
 
41
42
  <pre className="card">
@@ -12,9 +12,9 @@
12
12
  "check": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@getstrata/bootstrap": "^1.1.5",
16
- "@getstrata/cli": "^1.1.5",
17
- "@getstrata/core": "^1.1.5",
15
+ "@getstrata/bootstrap": "^1.1.7",
16
+ "@getstrata/cli": "^1.1.7",
17
+ "@getstrata/core": "^1.1.7",
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.5",
3
+ "version": "1.1.7",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",