@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 +530 -45
- package/dist/templates/package.json +3 -3
- package/package.json +1 -1
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
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
return
|
|
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
|
-
|
|
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
|
|
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.
|
|
2862
|
-
"@getstrata/cli": "^1.1.
|
|
2863
|
-
"@getstrata/core": "^1.1.
|
|
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.
|
|
16
|
-
"@getstrata/cli": "^1.1.
|
|
17
|
-
"@getstrata/core": "^1.1.
|
|
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": {
|