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