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