@getstrata/starter 1.1.4 → 1.1.5

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") {
124
124
  return htmlAuthKit(auth);
125
125
  }
126
126
  return authNeedsUsers(auth);
@@ -194,13 +194,19 @@ var noneExtras = {
194
194
  mfa: false,
195
195
  emailVerification: false,
196
196
  scim: false,
197
- metrics: false
197
+ metrics: false,
198
+ oauthGithub: false,
199
+ billing: false,
200
+ webhooks: false
198
201
  };
199
202
  var enterpriseExtras = {
200
203
  mfa: true,
201
204
  emailVerification: true,
202
205
  scim: true,
203
- metrics: true
206
+ metrics: true,
207
+ oauthGithub: false,
208
+ billing: false,
209
+ webhooks: false
204
210
  };
205
211
  function withDocker(layers, composeForNeededTools) {
206
212
  return {
@@ -267,7 +273,10 @@ Options:
267
273
  --email-verification / --no-email-verification
268
274
  --scim / --no-scim
269
275
  --metrics / --no-metrics
270
- --extras Interactive extras list (MFA, email verification, SCIM, metrics)
276
+ --oauth-github / --no-oauth-github
277
+ --billing / --no-billing
278
+ --webhooks / --no-webhooks
279
+ --extras Interactive extras list (MFA, email verification, SCIM, metrics, GitHub OAuth, billing, webhooks)
271
280
  --docker Write Docker Compose for every selected tool that needs a service
272
281
  --no-docker Skip docker-compose.yml; use installs already on this machine
273
282
  --docker-services Subset: postgres, mysql, redis, mailpit, adminer (comma-separated)
@@ -370,7 +379,13 @@ function parseCreateStrataArgs(argv) {
370
379
  ["--scim", "scim", true],
371
380
  ["--no-scim", "scim", false],
372
381
  ["--metrics", "metrics", true],
373
- ["--no-metrics", "metrics", false]
382
+ ["--no-metrics", "metrics", false],
383
+ ["--oauth-github", "oauthGithub", true],
384
+ ["--no-oauth-github", "oauthGithub", false],
385
+ ["--billing", "billing", true],
386
+ ["--no-billing", "billing", false],
387
+ ["--webhooks", "webhooks", true],
388
+ ["--no-webhooks", "webhooks", false]
374
389
  ];
375
390
  const boolMatch = boolFlags.find(([name]) => name === arg);
376
391
  if (boolMatch) {
@@ -488,7 +503,13 @@ function applyDockerFlags(layers, flags) {
488
503
  return layers;
489
504
  }
490
505
  function extraFlagName(extra) {
491
- return extra === "emailVerification" ? "email-verification" : extra;
506
+ if (extra === "emailVerification") {
507
+ return "email-verification";
508
+ }
509
+ if (extra === "oauthGithub") {
510
+ return "oauth-github";
511
+ }
512
+ return extra;
492
513
  }
493
514
  function dropInapplicableExtras(layers, flags) {
494
515
  for (const key of Object.keys(layers.extras)) {
@@ -889,7 +910,10 @@ var EXTRA_CHOICES = [
889
910
  { value: "mfa", label: "mfa: authenticator challenge + setup pages" },
890
911
  { value: "emailVerification", label: "email-verification: signed links + /email/verify" },
891
912
  { value: "scim", label: "scim: /Users adapter" },
892
- { value: "metrics", label: "metrics: Prometheus token" }
913
+ { value: "metrics", label: "metrics: Prometheus token" },
914
+ { value: "oauthGithub", label: "oauth-github: GitHub cookie login at /auth/github" },
915
+ { value: "billing", label: "billing: Stripe webhook stub + GET /api/v1/billing/subscription" },
916
+ { value: "webhooks", label: "webhooks: outbound notes.created job (webhook.dispatch)" }
893
917
  ];
894
918
  function isInteractive(flags) {
895
919
  if (flags.yes || flags.noInteractive) {
@@ -1175,6 +1199,7 @@ function renderAuthModule(layers) {
1175
1199
  return null;
1176
1200
  }
1177
1201
  const cookie = htmlAuthKit(layers.auth);
1202
+ const github = Boolean(layers.extras.oauthGithub && cookie);
1178
1203
  const mfa = Boolean(layers.extras.mfa && cookie);
1179
1204
  const verify = Boolean(layers.extras.emailVerification);
1180
1205
  const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
@@ -1194,6 +1219,10 @@ function renderAuthModule(layers) {
1194
1219
  imports.push(`import { wrapWebLogin, wrapWebRegister } from "@getstrata/bootstrap/web/routing";`);
1195
1220
  imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
1196
1221
  imports.push(`import { createSamlServiceProvider } from "@getstrata/core/auth/saml/samlServiceProvider";`);
1222
+ if (github) {
1223
+ imports.push(`import { GitHubOAuthProvider } from "@getstrata/core/auth/oauth/providers";`);
1224
+ imports.push(`import { appUrl } from "@getstrata/core/runtime/appKeyPrefix";`);
1225
+ }
1197
1226
  }
1198
1227
  if (jsonApi) {
1199
1228
  imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
@@ -1344,6 +1373,43 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1344
1373
  is_admin: user.role === "admin",
1345
1374
  };
1346
1375
  }
1376
+
1377
+ async function completeBrowserSsoLogin(
1378
+ dependencies: { container: { resolve: <T>(token: string) => T } },
1379
+ profile: { email: string; name: string },
1380
+ label: string,
1381
+ ): Promise<Response> {
1382
+ let record = await starterAuthDirectory.findByEmail?.(profile.email);
1383
+ if (!record) {
1384
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1385
+ return jsonResponse({ error: \`\${label} user is not provisioned.\` }, { status: 403 });
1386
+ }
1387
+ const hashed = await hashPassword(randomBytes(18).toString("hex"));
1388
+ try {
1389
+ await runAuthWrite(profile.email, async () => {
1390
+ await User.create({
1391
+ name: profile.name,
1392
+ email: profile.email,
1393
+ password: hashed,
1394
+ is_admin: false,${samlTenantCreateField}
1395
+ });
1396
+ });
1397
+ } catch {
1398
+ // Unique email: another request already provisioned this user.
1399
+ }
1400
+ record = await starterAuthDirectory.findByEmail?.(profile.email);
1401
+ }
1402
+ if (!record) {
1403
+ return jsonResponse({ error: \`Could not complete \${label} login.\` }, { status: 500 });
1404
+ }
1405
+ ${mfa ? `if (record.mfa_enabled) {
1406
+ const pending = redirectTo("/login/mfa");
1407
+ pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
1408
+ return pending;
1409
+ }
1410
+ ` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1411
+ return auth.signInRedirect(sessionUser(record), "/");
1412
+ }
1347
1413
  ` : ""}`;
1348
1414
  const tokenLogin = authUsesToken(layers.auth) ? `
1349
1415
  "/api/v1/auth/login": {
@@ -1580,41 +1646,55 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1580
1646
  if (!profile) {
1581
1647
  return jsonResponse({ error: "Invalid SAML response." }, { status: 400 });
1582
1648
  }
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);
1649
+ return completeBrowserSsoLogin(dependencies, profile, "SAML");
1650
+ })),
1651
+ },` : "";
1652
+ const githubRoutes = github ? `
1653
+ "/auth/github": {
1654
+ GET: kernel.wrap("api", withErrorHandling(async () => {
1655
+ if (process.env.FEATURE_OAUTH !== "true") {
1656
+ return new Response("Not found", { status: 404 });
1657
+ }
1658
+ const clientId = process.env.GITHUB_CLIENT_ID?.trim();
1659
+ const clientSecret = process.env.GITHUB_CLIENT_SECRET?.trim();
1660
+ if (!clientId || !clientSecret) {
1661
+ return new Response("Not found", { status: 404 });
1602
1662
  }
1603
- if (!record) {
1604
- return jsonResponse({ error: "Could not complete SAML login." }, { status: 500 });
1663
+ const redirectUri = process.env.GITHUB_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/github/callback\`;
1664
+ const issued = createOAuthState();
1665
+ const url = new GitHubOAuthProvider({ clientId, clientSecret, redirectUri }).getAuthorizationUrl(issued.state);
1666
+ return new Response(null, { status: 302, headers: { location: url } });
1667
+ })),
1668
+ },
1669
+ "/auth/github/callback": {
1670
+ GET: kernel.wrap("api", withErrorHandling(async (request) => {
1671
+ if (process.env.FEATURE_OAUTH !== "true") {
1672
+ return new Response("Not found", { status: 404 });
1605
1673
  }
1606
- ${mfa ? `if (record.mfa_enabled) {
1607
- const pending = redirectTo("/login/mfa");
1608
- pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
1609
- return pending;
1674
+ const clientId = process.env.GITHUB_CLIENT_ID?.trim();
1675
+ const clientSecret = process.env.GITHUB_CLIENT_SECRET?.trim();
1676
+ if (!clientId || !clientSecret) {
1677
+ return new Response("Not found", { status: 404 });
1678
+ }
1679
+ const url = new URL(request.url);
1680
+ const code = url.searchParams.get("code");
1681
+ const state = url.searchParams.get("state");
1682
+ if (!code || !verifyOAuthState(request, state)) {
1683
+ return jsonResponse({ error: "Invalid GitHub OAuth state." }, { status: 403 });
1684
+ }
1685
+ const redirectUri = process.env.GITHUB_REDIRECT_URI?.trim() || \`\${appUrl()}/auth/github/callback\`;
1686
+ const profile = await new GitHubOAuthProvider({ clientId, clientSecret, redirectUri })
1687
+ .exchangeCode(code)
1688
+ .catch(() => null);
1689
+ if (!profile) {
1690
+ return jsonResponse({ error: "Invalid GitHub OAuth response." }, { status: 400 });
1610
1691
  }
1611
- ` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1612
- return auth.signInRedirect(sessionUser(record), "/");
1692
+ return completeBrowserSsoLogin(dependencies, profile, "GitHub");
1613
1693
  })),
1614
1694
  },` : "";
1615
1695
  const apiRoutes = jsonApi || cookie ? `
1616
1696
  routes({ kernel, dependencies }) {
1617
- return {${csrfRoute}${samlRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1697
+ return {${csrfRoute}${samlRoutes}${githubRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1618
1698
  };
1619
1699
  },` : "";
1620
1700
  const mfaLoginBranch = `const mfaResult = completePasswordLogin(user, { mfaCode: mfa_code });
@@ -2228,10 +2308,12 @@ function textField(name, label, type, extra = "") {
2228
2308
  <input type="${type}" name="${name}" value="<%= it.${name} || "" %>" ${extra} />
2229
2309
  </label>`;
2230
2310
  }
2231
- function renderLoginView() {
2311
+ function renderLoginView(layers) {
2312
+ const githubLink = layers.extras.oauthGithub && htmlAuthKit(layers.auth) ? `
2313
+ <a href="/auth/github">Sign in with GitHub</a>` : "";
2232
2314
  return renderFormView("Sign in", `${textField("email", "Email", "email", 'required autocomplete="username"')}
2233
2315
  ${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"');
2316
+ <a href="/forgot-password">Forgot password</a>${githubLink}`).replace('action="<%= it.action || "" %>"', 'action="/login"');
2235
2317
  }
2236
2318
  function renderRegisterView() {
2237
2319
  return renderFormView("Create account", `${textField("name", "Name", "text", "required")}
@@ -2641,6 +2723,8 @@ function renderEnvExample(projectName, layers) {
2641
2723
  lines.push("# wrapWebPublicRead / wrapPublicRead require login when false (production-safe default).");
2642
2724
  lines.push("# Local storefronts/catalogs may set true in .env. Production boot rejects FEATURE_PUBLIC_READS=true.");
2643
2725
  lines.push("FEATURE_SIEM_EXPORT=false");
2726
+ lines.push("# SIEM_EXPORT_URL=");
2727
+ lines.push("# SIEM_EXPORT_TOKEN=");
2644
2728
  lines.push("FEATURE_REGISTRATION=true");
2645
2729
  lines.push("FEATURE_SAML=false");
2646
2730
  lines.push("# SAML_IDP_SSO_URL=");
@@ -2650,6 +2734,30 @@ function renderEnvExample(projectName, layers) {
2650
2734
  lines.push("# SAML_IDP_ISSUER=");
2651
2735
  lines.push("# SAML_WANT_RESPONSE_SIGNED=");
2652
2736
  lines.push("# SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=");
2737
+ const oauthOn = Boolean(layers.extras.oauthGithub);
2738
+ lines.push(`FEATURE_OAUTH=${envFlag(oauthOn)}`);
2739
+ if (oauthOn) {
2740
+ lines.push("GITHUB_CLIENT_ID=");
2741
+ lines.push("GITHUB_CLIENT_SECRET=");
2742
+ 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
+ } else {
2745
+ lines.push("# GITHUB_CLIENT_ID=");
2746
+ lines.push("# GITHUB_CLIENT_SECRET=");
2747
+ lines.push("# GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback");
2748
+ lines.push("# OAUTH_STATE_SECRET=");
2749
+ }
2750
+ lines.push("# OIDC cookie login is not generated. Use GitHub cookie routes (--oauth-github) or app-owned PKCE.");
2751
+ const billingOn = Boolean(layers.extras.billing);
2752
+ lines.push(`FEATURE_BILLING=${envFlag(billingOn)}`);
2753
+ if (billingOn) {
2754
+ lines.push("STRIPE_WEBHOOK_SECRET=whsec_change-me");
2755
+ } else {
2756
+ lines.push("# STRIPE_WEBHOOK_SECRET=");
2757
+ }
2758
+ lines.push("# Outbound webhook deliveries (create-strata --webhooks). Local receivers:");
2759
+ lines.push("# WEBHOOK_ALLOW_PRIVATE=true");
2760
+ lines.push("# WEBHOOK_SIGNATURE_HEADER=");
2653
2761
  if (needsRedis(layers)) {
2654
2762
  lines.push("REDIS_PASSWORD=dev-redis-change-me");
2655
2763
  lines.push("REDIS_URL=redis://:dev-redis-change-me@127.0.0.1:6379");
@@ -2858,9 +2966,9 @@ function renderPackageJson(projectName, options = {}) {
2858
2966
  "@getstrata/cli": "workspace:*",
2859
2967
  "@getstrata/core": "workspace:*"
2860
2968
  } : {
2861
- "@getstrata/bootstrap": "^1.1.4",
2862
- "@getstrata/cli": "^1.1.4",
2863
- "@getstrata/core": "^1.1.4"
2969
+ "@getstrata/bootstrap": "^1.1.5",
2970
+ "@getstrata/cli": "^1.1.5",
2971
+ "@getstrata/core": "^1.1.5"
2864
2972
  };
2865
2973
  coreDeps.eta = "^4.6.0";
2866
2974
  if (options.layers?.database === "mysql") {
@@ -2975,6 +3083,15 @@ function renderApiDocs(projectName, layers) {
2975
3083
  if (layers.extras.metrics) {
2976
3084
  rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
2977
3085
  }
3086
+ if (layers.extras.billing) {
3087
+ 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. |");
3088
+ }
3089
+ if (layers.extras.webhooks) {
3090
+ rows.push("| (job) | `webhook.dispatch` | Discovered from `src/jobs/dispatchOutboundWebhookJob.ts`. No HTTP route. |");
3091
+ }
3092
+ if (layers.extras.oauthGithub && authUsesCookie(layers.auth)) {
3093
+ 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
+ }
2978
3095
  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
3096
  return `# ${projectName} API
2980
3097
 
@@ -3026,6 +3143,42 @@ Import from \`@getstrata/core/...\` subpaths rather than the package root, so si
3026
3143
  - [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
3027
3144
  `;
3028
3145
  }
3146
+ function renderIntegrationsReadme(layers) {
3147
+ const enabled = [
3148
+ layers.extras.oauthGithub ? "GitHub OAuth cookie login (`/auth/github`)" : null,
3149
+ layers.extras.billing ? "Stripe billing stub (`POST /billing/webhooks/stripe`, `GET /api/v1/billing/subscription`)" : null,
3150
+ layers.extras.webhooks ? "Outbound webhooks (`notes.created` listener + `webhook.dispatch` job)" : null
3151
+ ].filter((row) => row !== null);
3152
+ const lines = [
3153
+ "## Integrations",
3154
+ "",
3155
+ "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
+ "",
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()`.",
3158
+ ""
3159
+ ];
3160
+ if (enabled.length > 0) {
3161
+ lines.push("Enabled in this app:", "", ...enabled.map((row) => `- ${row}`), "");
3162
+ } else {
3163
+ 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.", "");
3164
+ }
3165
+ return `${lines.join(`
3166
+ `)}
3167
+ `;
3168
+ }
3169
+ function renderOpenApiReadme() {
3170
+ return `## OpenAPI
3171
+
3172
+ After you add routes, regenerate and commit the spec so CI can catch drift:
3173
+
3174
+ \`\`\`bash
3175
+ bunx strata openapi:generate
3176
+ bunx strata openapi:check
3177
+ \`\`\`
3178
+
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).
3180
+ `;
3181
+ }
3029
3182
  function renderReadmeIntro(projectName, options) {
3030
3183
  if (options.dogfood) {
3031
3184
  return `# ${projectName}
@@ -3075,8 +3228,9 @@ function renderReadme(projectName, layers, options = {}) {
3075
3228
  | Docker Compose | ${dockerLabel} |
3076
3229
  ${extras.length > 0 ? `| Extras | ${extras.join(", ")} |
3077
3230
  ` : ""}
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).
3231
+ 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
3232
 
3233
+ ${renderIntegrationsReadme(layers)}
3080
3234
  ## Run it
3081
3235
 
3082
3236
  \`\`\`bash
@@ -3101,7 +3255,7 @@ HTML auth kit (restyle \`views/\` and \`public/assets/site.css\`):
3101
3255
  - Register: \`/register\`
3102
3256
  - Forgot password: \`/forgot-password\`
3103
3257
  - 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" : ""}
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" : ""}
3105
3259
  Cookie name is \`strata_session\`. Forms send CSRF as \`_token\`.
3106
3260
  ` : ""}${authUsesToken(layers.auth) ? `
3107
3261
  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 +3279,7 @@ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun r
3125
3279
 
3126
3280
  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
3281
  `}
3282
+ ${renderOpenApiReadme()}
3128
3283
  ## Deploy
3129
3284
 
3130
3285
  \`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 +3302,7 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
3147
3302
  - 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
3303
  - 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
3304
  - 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\`.
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\`.
3151
3306
  ` : ""}
3152
3307
  \`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
3308
  `;
@@ -3931,6 +4086,29 @@ export type { AuthOneTimeTokenRecord };
3931
4086
  export { AuthOneTimeToken };
3932
4087
  `;
3933
4088
  }
4089
+ function renderModelsRegister(layers) {
4090
+ const lines = [
4091
+ "// Side-effect imports run registerModelRepository() in each model file.",
4092
+ "// registerModelClass() is only for a string alias that is neither constructor.name nor $morphClass.",
4093
+ 'import { registerModelClass } from "@getstrata/core/database/model";',
4094
+ 'import "./Note.ts";'
4095
+ ];
4096
+ if (authNeedsUsers(layers.auth)) {
4097
+ lines.push('import "./User.ts";');
4098
+ lines.push('import "./AuthOneTimeToken.ts";');
4099
+ }
4100
+ if (authUsesToken(layers.auth)) {
4101
+ lines.push('import "./ApiToken.ts";');
4102
+ }
4103
+ lines.push("");
4104
+ lines.push("// Example after you add a Product model with belongsTo('Category'):");
4105
+ lines.push('// registerModelClass("Category", Category);');
4106
+ lines.push("void registerModelClass;");
4107
+ lines.push("");
4108
+ return `${lines.join(`
4109
+ `)}
4110
+ `;
4111
+ }
3934
4112
  function dropTables(layers) {
3935
4113
  const ordered = ["failed_job"];
3936
4114
  if (authUsesToken(layers.auth)) {
@@ -4040,6 +4218,7 @@ function renderPreloadTs(layers, projectName) {
4040
4218
  ` : "";
4041
4219
  return `import { join } from "node:path";
4042
4220
  import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
4221
+ import "../models/register.ts";
4043
4222
 
4044
4223
  process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
4045
4224
  ${migrationLine}process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
@@ -4605,6 +4784,286 @@ export function plainText(body: string, status = 200): Response {
4605
4784
  `;
4606
4785
  }
4607
4786
 
4787
+ // src/renderIntegrations.ts
4788
+ function activeBoolColumn(database) {
4789
+ if (database === "postgres") {
4790
+ return "BOOLEAN NOT NULL DEFAULT TRUE";
4791
+ }
4792
+ if (database === "mysql") {
4793
+ return "TINYINT(1) NOT NULL DEFAULT 1";
4794
+ }
4795
+ return "INTEGER NOT NULL DEFAULT 1";
4796
+ }
4797
+ function webhookMigrationName(layers) {
4798
+ if (!layers.extras.webhooks) {
4799
+ return null;
4800
+ }
4801
+ return "0002_webhooks_schema";
4802
+ }
4803
+ function billingMigrationName(layers) {
4804
+ if (!layers.extras.billing) {
4805
+ return null;
4806
+ }
4807
+ return layers.extras.webhooks ? "0003_billing_schema" : "0002_billing_schema";
4808
+ }
4809
+ function renderFileMigration(name, upSql, downSql) {
4810
+ const upCalls = upSql.map((sql) => ` await db.unsafe(${JSON.stringify(sql)});`).join(`
4811
+ `);
4812
+ const downCalls = downSql.map((sql) => ` await db.unsafe(${JSON.stringify(sql)});`).join(`
4813
+ `);
4814
+ return `import type { Migration } from "@getstrata/core/database/migrations/types";
4815
+
4816
+ const migration: Migration = {
4817
+ name: ${JSON.stringify(name)},
4818
+ async up(db) {
4819
+ ${upCalls}
4820
+ },
4821
+ async down(db) {
4822
+ ${downCalls}
4823
+ },
4824
+ };
4825
+
4826
+ export default migration;
4827
+ `;
4828
+ }
4829
+ function renderWebhooksMigration(layers) {
4830
+ const name = webhookMigrationName(layers);
4831
+ if (!name) {
4832
+ return null;
4833
+ }
4834
+ const d = dialectFragments(layers.database);
4835
+ const payloadType = layers.database === "postgres" ? "JSONB NOT NULL" : `${d.text} NOT NULL`;
4836
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
4837
+ return renderFileMigration(name, [
4838
+ `CREATE TABLE IF NOT EXISTS webhooks (
4839
+ id ${d.id},
4840
+ url ${d.text} NOT NULL,
4841
+ secret ${d.text} NOT NULL,
4842
+ events ${d.defaultText} NOT NULL DEFAULT '*',
4843
+ active ${activeBoolColumn(layers.database)},
4844
+ created_at ${d.timestamp}
4845
+ )`,
4846
+ `CREATE TABLE IF NOT EXISTS webhook_deliveries (
4847
+ id ${d.id},
4848
+ webhook_id INTEGER NOT NULL,
4849
+ event ${d.keyText} NOT NULL,
4850
+ payload ${payloadType},
4851
+ response_status INTEGER,
4852
+ attempt INTEGER NOT NULL DEFAULT 1,
4853
+ error ${d.text},
4854
+ created_at ${d.timestamp}
4855
+ )`
4856
+ ], [
4857
+ `DROP TABLE IF EXISTS webhook_deliveries${cascade}`,
4858
+ `DROP TABLE IF EXISTS webhooks${cascade}`
4859
+ ]);
4860
+ }
4861
+ function renderBillingMigration(layers) {
4862
+ const name = billingMigrationName(layers);
4863
+ if (!name) {
4864
+ return null;
4865
+ }
4866
+ const d = dialectFragments(layers.database);
4867
+ const tenancyOn = usesTenantTable(layers.tenancy);
4868
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
4869
+ const up = [];
4870
+ if (tenancyOn) {
4871
+ up.push(`ALTER TABLE tenant ADD COLUMN stripe_customer_id ${d.keyText}`);
4872
+ }
4873
+ const tenantColumn = tenancyOn ? `
4874
+ tenant_id INTEGER NOT NULL,` : "";
4875
+ up.push(`CREATE TABLE IF NOT EXISTS subscription (
4876
+ id ${d.id},${tenantColumn}
4877
+ stripe_subscription_id ${d.keyText} UNIQUE,
4878
+ plan ${d.defaultText} NOT NULL DEFAULT 'free',
4879
+ status ${d.defaultText} NOT NULL DEFAULT 'active',
4880
+ current_period_end ${d.timestampNull},
4881
+ created_at ${d.timestamp},
4882
+ updated_at ${d.timestamp}
4883
+ )`);
4884
+ const eventTenant = tenancyOn ? `
4885
+ tenant_id INTEGER,` : "";
4886
+ up.push(`CREATE TABLE IF NOT EXISTS stripe_webhook_event (
4887
+ id ${d.keyText} PRIMARY KEY,
4888
+ event_type ${d.keyText} NOT NULL,${eventTenant}
4889
+ processed_at ${d.timestamp}
4890
+ )`);
4891
+ const down = [
4892
+ `DROP TABLE IF EXISTS stripe_webhook_event${cascade}`,
4893
+ `DROP TABLE IF EXISTS subscription${cascade}`
4894
+ ];
4895
+ return renderFileMigration(name, up, down);
4896
+ }
4897
+ function renderDispatchOutboundWebhookJob() {
4898
+ return `import { Job } from "@getstrata/core/queue";
4899
+ import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
4900
+ import { webhookSignatureHeader } from "@getstrata/core/runtime/appKeyPrefix";
4901
+ import { safeFetch } from "@getstrata/core/security/safeFetch";
4902
+ import { signWebhookBody } from "@getstrata/core/security/webhookSignature";
4903
+ import { getSql } from "../bootstrap/database.ts";
4904
+
4905
+ interface DispatchOutboundWebhookPayload {
4906
+ webhookId: number;
4907
+ url: string;
4908
+ secret: string;
4909
+ body: string;
4910
+ }
4911
+
4912
+ class DispatchOutboundWebhookJob extends Job<DispatchOutboundWebhookPayload> {
4913
+ static readonly jobName = "webhook.dispatch";
4914
+
4915
+ override async handle(payload: DispatchOutboundWebhookPayload): Promise<void> {
4916
+ const allowPrivate = !isProductionEnv() && process.env.WEBHOOK_ALLOW_PRIVATE === "true";
4917
+ let responseStatus: number | null = null;
4918
+ let error: string | null = null;
4919
+ try {
4920
+ const response = await safeFetch(
4921
+ payload.url,
4922
+ {
4923
+ method: "POST",
4924
+ headers: {
4925
+ "content-type": "application/json",
4926
+ [webhookSignatureHeader()]: signWebhookBody(payload.secret, payload.body),
4927
+ },
4928
+ body: payload.body,
4929
+ },
4930
+ { timeoutMs: 10_000, maxRedirects: 0, allowPrivate, allowHttp: allowPrivate },
4931
+ );
4932
+ responseStatus = response.status;
4933
+ if (!response.ok) {
4934
+ error = \`HTTP \${response.status}\`;
4935
+ }
4936
+ } catch (caught) {
4937
+ error = caught instanceof Error ? caught.message : "webhook dispatch failed";
4938
+ }
4939
+
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
+ );
4944
+ }
4945
+ }
4946
+
4947
+ export default DispatchOutboundWebhookJob;
4948
+ export type { DispatchOutboundWebhookPayload };
4949
+ `;
4950
+ }
4951
+ function renderNoteCreatedWebhookListener() {
4952
+ return `import { eventBus, modelEventName } from "@getstrata/core/events";
4953
+ import { resolveApplicationQueue } from "@getstrata/core/runtime/applicationRegistry";
4954
+ import { getSql } from "../bootstrap/database.ts";
4955
+ import DispatchOutboundWebhookJob from "../jobs/dispatchOutboundWebhookJob.ts";
4956
+
4957
+ function registerNoteCreatedWebhookListener(): void {
4958
+ eventBus.listen(modelEventName("notes", "created"), async (payload) => {
4959
+ const rows = await getSql().unsafe<{
4960
+ id: number;
4961
+ url: string;
4962
+ secret: string;
4963
+ active: number | boolean;
4964
+ }>("SELECT id, url, secret, active FROM webhooks");
4965
+ const queue = resolveApplicationQueue();
4966
+ const body = JSON.stringify({ event: "notes.created", data: payload });
4967
+ for (const hook of rows) {
4968
+ if (!hook.active) {
4969
+ continue;
4970
+ }
4971
+ await queue.dispatch(new DispatchOutboundWebhookJob(), {
4972
+ webhookId: hook.id,
4973
+ url: hook.url,
4974
+ secret: hook.secret,
4975
+ body,
4976
+ });
4977
+ }
4978
+ });
4979
+ }
4980
+
4981
+ export default registerNoteCreatedWebhookListener;
4982
+ `;
4983
+ }
4984
+ function renderBillingModule(layers) {
4985
+ const tenancyOn = usesTenantTable(layers.tenancy);
4986
+ const tenantImport = tenancyOn ? `import { currentTenant } from "@getstrata/core/tenant/tenantContext";
4987
+ ` : "";
4988
+ const subscriptionQuery = tenancyOn ? ` const tenant = currentTenant();
4989
+ if (!tenant) {
4990
+ return jsonResponse({ error: "Tenant context is required." }, { status: 500 });
4991
+ }
4992
+ 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",
4994
+ [tenant.id],
4995
+ );` : ` const rows = await getSql().unsafe<Record<string, unknown>>(
4996
+ "SELECT plan, status, stripe_subscription_id, current_period_end FROM subscription LIMIT 1",
4997
+ );`;
4998
+ return `import type { AppModule } from "@getstrata/bootstrap/contracts";
4999
+ import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";
5000
+ import { verifyStripeWebhookSignature } from "@getstrata/core/security/stripeWebhook";
5001
+ ${tenantImport}import { getSql } from "../../bootstrap/database.ts";
5002
+
5003
+ function billingEnabled(): boolean {
5004
+ return process.env.FEATURE_BILLING === "true";
5005
+ }
5006
+
5007
+ const billingModule: AppModule = {
5008
+ name: "billing",
5009
+ order: 40,
5010
+ routes({ kernel }) {
5011
+ return {
5012
+ "/billing/webhooks/stripe": {
5013
+ POST: kernel.wrap(
5014
+ "api",
5015
+ withErrorHandling(async (request) => {
5016
+ if (!billingEnabled()) {
5017
+ return new Response("Not found", { status: 404 });
5018
+ }
5019
+ const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
5020
+ if (!secret) {
5021
+ return jsonResponse({ error: "STRIPE_WEBHOOK_SECRET is not set." }, { status: 503 });
5022
+ }
5023
+ const rawBody = await request.text();
5024
+ verifyStripeWebhookSignature(rawBody, request.headers.get("stripe-signature"), secret);
5025
+ let event: { id?: string; type?: string; data?: { object?: Record<string, unknown> } };
5026
+ try {
5027
+ event = JSON.parse(rawBody) as typeof event;
5028
+ } catch {
5029
+ return jsonResponse({ error: "Invalid JSON." }, { status: 400 });
5030
+ }
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;
5035
+ return jsonResponse({ received: true, id: event.id ?? null, type: event.type ?? null });
5036
+ }),
5037
+ ),
5038
+ },
5039
+ "/api/v1/billing/subscription": {
5040
+ GET: kernel.wrapApi(
5041
+ withErrorHandling(async () => {
5042
+ if (!billingEnabled()) {
5043
+ return new Response("Not found", { status: 404 });
5044
+ }
5045
+ ${subscriptionQuery}
5046
+ const row = rows[0];
5047
+ if (!row) {
5048
+ return jsonResponse({ plan: "free", status: "none" });
5049
+ }
5050
+ return jsonResponse({
5051
+ plan: row.plan ?? "free",
5052
+ status: row.status ?? "none",
5053
+ stripe_subscription_id: row.stripe_subscription_id ?? null,
5054
+ current_period_end: row.current_period_end ?? null,
5055
+ });
5056
+ }),
5057
+ ),
5058
+ },
5059
+ };
5060
+ },
5061
+ };
5062
+
5063
+ export default billingModule;
5064
+ `;
5065
+ }
5066
+
4608
5067
  // src/renderScim.ts
4609
5068
  function renderScimModule(layers) {
4610
5069
  if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
@@ -5026,9 +5485,23 @@ function writeGeneratedFiles(options) {
5026
5485
  } else {
5027
5486
  removeIfExists(join2(src, "models/ApiToken.ts"));
5028
5487
  }
5488
+ writeText(join2(src, "models/register.ts"), renderModelsRegister(layers));
5029
5489
  writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
5030
5490
  writeText(join2(src, "db/migrationRuntime.ts"), renderMigrationRuntimeTs(layers));
5031
5491
  writeText(join2(src, "db/migrations/0001_starter_schema.ts"), renderStarterSchemaMigration(layers));
5492
+ removeIfExists(join2(src, "db/migrations/0002_webhooks_schema.ts"));
5493
+ removeIfExists(join2(src, "db/migrations/0002_billing_schema.ts"));
5494
+ removeIfExists(join2(src, "db/migrations/0003_billing_schema.ts"));
5495
+ const webhooksMigration = renderWebhooksMigration(layers);
5496
+ const webhooksName = webhookMigrationName(layers);
5497
+ if (webhooksMigration && webhooksName) {
5498
+ writeText(join2(src, `db/migrations/${webhooksName}.ts`), webhooksMigration);
5499
+ }
5500
+ const billingMigration = renderBillingMigration(layers);
5501
+ const billingName = billingMigrationName(layers);
5502
+ if (billingMigration && billingName) {
5503
+ writeText(join2(src, `db/migrations/${billingName}.ts`), billingMigration);
5504
+ }
5032
5505
  writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
5033
5506
  writeText(join2(src, "db/seed.ts"), renderSeedTs());
5034
5507
  writeText(join2(src, "db/status.ts"), renderStatusTs(layers));
@@ -5052,6 +5525,18 @@ function writeGeneratedFiles(options) {
5052
5525
  } else {
5053
5526
  removeIfExists(join2(src, "modules/scim/index.ts"));
5054
5527
  }
5528
+ if (layers.extras.billing) {
5529
+ writeText(join2(src, "modules/billing/index.ts"), renderBillingModule(layers));
5530
+ } else {
5531
+ removeIfExists(join2(src, "modules/billing/index.ts"));
5532
+ }
5533
+ if (layers.extras.webhooks) {
5534
+ writeText(join2(src, "jobs/dispatchOutboundWebhookJob.ts"), renderDispatchOutboundWebhookJob());
5535
+ writeText(join2(src, "listeners/noteCreatedWebhookListener.ts"), renderNoteCreatedWebhookListener());
5536
+ } else {
5537
+ removeIfExists(join2(src, "jobs/dispatchOutboundWebhookJob.ts"));
5538
+ removeIfExists(join2(src, "listeners/noteCreatedWebhookListener.ts"));
5539
+ }
5055
5540
  if (layers.extras.mfa && htmlAuthKit(layers.auth)) {
5056
5541
  writeText(join2(src, "bootstrap/pendingMfa.ts"), renderPendingMfaTs());
5057
5542
  } else {
@@ -5072,7 +5557,7 @@ function writeGeneratedFiles(options) {
5072
5557
  writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
5073
5558
  writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
5074
5559
  if (htmlAuthKit(layers.auth)) {
5075
- writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView());
5560
+ writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView(layers));
5076
5561
  writeText(join2(targetDir, "views/auth/register.eta"), renderRegisterView());
5077
5562
  writeText(join2(targetDir, "views/auth/forgot-password.eta"), renderForgotPasswordView());
5078
5563
  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.5",
16
+ "@getstrata/cli": "^1.1.5",
17
+ "@getstrata/core": "^1.1.5",
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.5",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",