mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into ai-analytics
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
This commit is contained in:
commit
81ead1bd01
@ -544,3 +544,6 @@ A: `Project.createAndSwitch` should leave `backendContext.projectKeys` set to re
|
||||
|
||||
## Q: How should backend SMTP SSRF checks be rolled out?
|
||||
A: Keep the real outbound SMTP policy in `apps/backend/src/private/implementation/smtp-egress-policy.ts`, export it through `apps/backend/src/private/index.ts`, and provide a simple `implementation-fallback` function for self-hosters. It should allow only SMTP ports 25, 465, 587, 2465, 2587, and 2525, reject internal IP literals or DNS resolutions, and initially run report-only from `emails-low-level.tsx` via `captureError("smtp-egress-policy-report-only", ...)` before enforcing hard failures.
|
||||
|
||||
## Q: What project-level `sourceOfTruth` config is supported?
|
||||
A: Project config overrides only support the hosted `sourceOfTruth` shape. Legacy external source-of-truth overrides such as Postgres or Neon are removed by `migrateConfigOverride("project", ...)`, while raw schema validation should reject them.
|
||||
|
||||
@ -28,11 +28,11 @@ const MODEL_SELECTION_MATRIX: Record<
|
||||
},
|
||||
smart: {
|
||||
slow: {
|
||||
authenticated: { modelId: "moonshotai/kimi-k2.6:nitro" },
|
||||
authenticated: { modelId: "x-ai/grok-build-0.1" },
|
||||
unauthenticated: { modelId: "deepseek/deepseek-v4-flash" },
|
||||
},
|
||||
fast: {
|
||||
authenticated: { modelId: "moonshotai/kimi-k2.6:nitro" },
|
||||
authenticated: { modelId: "x-ai/grok-build-0.1" },
|
||||
unauthenticated: { modelId: "deepseek/deepseek-v4-flash:nitro" },
|
||||
},
|
||||
},
|
||||
|
||||
@ -772,22 +772,18 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe
|
||||
// Actual configs — advanced cases
|
||||
expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {
|
||||
sourceOfTruth: {
|
||||
type: 'postgres',
|
||||
connectionString: 'postgres://user:pass@host:port/db',
|
||||
type: 'hosted',
|
||||
},
|
||||
})).toEqual(Result.ok(null));
|
||||
expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {
|
||||
sourceOfTruth: {
|
||||
type: 'postgres',
|
||||
connectionString: 'postgres://user:pass@host:port/db',
|
||||
},
|
||||
})).toEqual(Result.error(deindent`
|
||||
[WARNING] sourceOfTruth is not matched by any of the provided schemas:
|
||||
[ERROR] sourceOfTruth is not matched by any of the provided schemas:
|
||||
Schema 0:
|
||||
sourceOfTruth.type must be one of the following values: hosted
|
||||
Schema 1:
|
||||
sourceOfTruth.connectionStrings must be defined
|
||||
Schema 2:
|
||||
sourceOfTruth.connectionString must be defined
|
||||
sourceOfTruth contains unknown properties: connectionString
|
||||
`));
|
||||
|
||||
// Dot-notation keys that dot into nothing — detected by simulating the rendering pipeline
|
||||
|
||||
@ -179,15 +179,12 @@ function getDependencyScripts(esmVersion: string, esmFallbackVersion: string, da
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function escapeScriptContent(code: string): string {
|
||||
return code
|
||||
.replace(/<\/script/gi, "<\\/script")
|
||||
.replace(/<!--/g, "<\\!--")
|
||||
.replace(/-->/g, "--\\>");
|
||||
function encodeSourceForJsonScript(code: string): string {
|
||||
return JSON.stringify(code).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashboardUrl: string, initialTheme: "light" | "dark", showControls: boolean, initialChatOpen: boolean): string {
|
||||
const sourceCode = escapeScriptContent(artifact.runtimeCodegen.uiRuntimeSourceCode);
|
||||
const encodedSource = encodeSourceForJsonScript(artifact.runtimeCodegen.uiRuntimeSourceCode);
|
||||
const darkClass = initialTheme === "dark" ? "dark" : "";
|
||||
const esmVersion = extractEsmVersion(artifact.runtimeCodegen.uiRuntimeSourceCode) ?? packageJson.version;
|
||||
const esmFallbackVersion = getEsmFallbackVersion(esmVersion);
|
||||
@ -386,11 +383,43 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Babel (for JSX transpilation) -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- Babel (for JSX transpilation). crossorigin=anonymous is required so that
|
||||
errors thrown from inside Babel (e.g. JSX SyntaxErrors from AI-generated
|
||||
code) are not sanitized to "Script error." with no message — unpkg sends
|
||||
the matching Access-Control-Allow-Origin header. -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Install a global error listener BEFORE any AI code runs so that Babel parse
|
||||
errors, uncaught runtime throws, and async rejections all reach the parent.
|
||||
Without this, a JSX SyntaxError in the AI-generated code would surface only
|
||||
as a console error and the user would see a blank iframe. -->
|
||||
<script>
|
||||
(function () {
|
||||
function postError(message, stack) {
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'dashboard-error-boundary',
|
||||
message: message || 'Unknown dashboard error',
|
||||
stack: stack || undefined,
|
||||
}, '*');
|
||||
} catch (_) { /* parent may be gone */ }
|
||||
}
|
||||
window.__postDashboardError = postError;
|
||||
window.addEventListener('error', function (event) {
|
||||
var err = event && event.error;
|
||||
postError((err && err.message) || (event && event.message) || 'Unknown runtime error', err && err.stack);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', function (event) {
|
||||
var reason = event && event.reason;
|
||||
postError((reason && (reason.message || String(reason))) || 'Unhandled promise rejection', reason && reason.stack);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
${getDependencyScripts(esmVersion, esmFallbackVersion, dashboardUrl)}
|
||||
|
||||
|
||||
<script type="application/json" id="ai-dashboard-source">${encodedSource}</script>
|
||||
|
||||
<script type="text/babel">
|
||||
// Navigation API for AI-generated code
|
||||
window.dashboardNavigate = function(path) {
|
||||
@ -433,7 +462,7 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
baseUrl: ${JSON.stringify(baseUrl)},
|
||||
projectId: ${JSON.stringify(artifact.projectId)},
|
||||
};
|
||||
|
||||
|
||||
async function waitForDeps() {
|
||||
if (!window.__depsReady) {
|
||||
await new Promise(resolve => {
|
||||
@ -456,12 +485,12 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
window.removeEventListener('message', handler);
|
||||
reject(new Error('Token request timeout'));
|
||||
}, 5000);
|
||||
|
||||
|
||||
const handler = (event) => {
|
||||
if (event.data?.type === 'stack-access-token-response' && event.data?.requestId === requestId) {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener('message', handler);
|
||||
|
||||
|
||||
if (event.data.accessToken) {
|
||||
resolve(event.data.accessToken);
|
||||
} else {
|
||||
@ -469,22 +498,22 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
window.parent.postMessage({
|
||||
window.parent.postMessage({
|
||||
type: 'stack-access-token-request',
|
||||
requestId
|
||||
requestId
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function initializeStackApp() {
|
||||
await waitForDeps();
|
||||
|
||||
|
||||
if (!window.StackAdminApp) {
|
||||
throw new Error("Stack SDK failed to load. The SDK should expose window.StackAdminApp.");
|
||||
}
|
||||
|
||||
|
||||
const stackServerApp = new window.StackAdminApp({
|
||||
projectId: STACK_CONFIG.projectId,
|
||||
baseUrl: STACK_CONFIG.baseUrl,
|
||||
@ -492,31 +521,14 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
return await requestAccessToken();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
window.stackServerApp = stackServerApp;
|
||||
|
||||
|
||||
return stackServerApp;
|
||||
}
|
||||
|
||||
// Forward uncaught runtime errors (async throws, unhandled rejections) that never
|
||||
// reach the React boundary. React ErrorBoundary alone misses these, so without this
|
||||
// the parent has no way to observe e.g. a fetch() that rejected inside useEffect.
|
||||
window.addEventListener('error', (event) => {
|
||||
const err = event?.error;
|
||||
window.parent.postMessage({
|
||||
type: 'dashboard-error-boundary',
|
||||
message: err?.message || event?.message || 'Unknown runtime error',
|
||||
stack: err?.stack,
|
||||
}, '*');
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event?.reason;
|
||||
window.parent.postMessage({
|
||||
type: 'dashboard-error-boundary',
|
||||
message: (reason && (reason.message || String(reason))) || 'Unhandled promise rejection',
|
||||
stack: reason?.stack,
|
||||
}, '*');
|
||||
});
|
||||
|
||||
// Uncaught runtime errors and unhandled rejections are forwarded by the
|
||||
// early global listener installed before Babel loads (see top of <head>).
|
||||
|
||||
// Error Boundary Component
|
||||
class ErrorBoundary extends React.Component {
|
||||
@ -537,7 +549,7 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
componentStack: errorInfo?.componentStack,
|
||||
}, '*');
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
@ -557,7 +569,7 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Boot the dashboard
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
@ -576,11 +588,51 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
|
||||
throw new Error("Recharts failed to load in sandbox.");
|
||||
}
|
||||
|
||||
// Execute AI-generated code with DashboardUI and Recharts in scope
|
||||
const Dashboard = (() => {
|
||||
${sourceCode}
|
||||
return Dashboard;
|
||||
})();
|
||||
// Execute AI-generated code with DashboardUI and Recharts in scope.
|
||||
// We compile here (rather than via <script type="text/babel">) so that
|
||||
// a JSX SyntaxError in the AI output surfaces as a normal throw — the
|
||||
// window 'error' listener picks it up and forwards it to the parent
|
||||
// composer instead of leaving the iframe blank.
|
||||
const aiSourceEl = document.getElementById('ai-dashboard-source');
|
||||
if (!aiSourceEl || !aiSourceEl.textContent) {
|
||||
throw new Error('Failed to parse aiSource from aiSourceEl: #ai-dashboard-source script tag is missing or empty');
|
||||
}
|
||||
let aiSource;
|
||||
try {
|
||||
aiSource = JSON.parse(aiSourceEl.textContent);
|
||||
} catch (parseErr) {
|
||||
const original = parseErr && parseErr.message ? parseErr.message : String(parseErr);
|
||||
const preview = aiSourceEl.textContent.slice(0, 500);
|
||||
const wrapped = new Error('Failed to parse aiSource from aiSourceEl: ' + original + ' | textContent preview: ' + preview);
|
||||
if (parseErr && parseErr.stack) wrapped.stack = parseErr.stack;
|
||||
throw wrapped;
|
||||
}
|
||||
if (typeof aiSource !== 'string') {
|
||||
throw new Error('Failed to parse aiSource from aiSourceEl: expected JSON-encoded string, got ' + typeof aiSource);
|
||||
}
|
||||
let compiledSource;
|
||||
try {
|
||||
compiledSource = window.Babel.transform(aiSource, { presets: ['react'], sourceType: 'script' }).code;
|
||||
} catch (err) {
|
||||
const message = err && err.message ? 'Dashboard code failed to compile: ' + err.message : 'Dashboard code failed to compile';
|
||||
const stack = err && err.stack ? err.stack : undefined;
|
||||
window.__postDashboardError && window.__postDashboardError(message, stack);
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<div className="p-6 text-red-500">
|
||||
<h2 className="text-xl font-bold mb-2">Dashboard failed to compile</h2>
|
||||
<pre className="text-sm bg-red-950/20 p-4 rounded overflow-auto whitespace-pre-wrap">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-new-func
|
||||
const Dashboard = new Function('React', 'ReactDOM', 'DashboardUI', 'Recharts', 'stackServerApp', compiledSource + '\\nreturn Dashboard;')(
|
||||
React, ReactDOM, DashboardUI, Recharts, window.stackServerApp,
|
||||
);
|
||||
|
||||
|
||||
if (typeof Dashboard !== 'function') {
|
||||
throw new Error('Dashboard component not found in generated code');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user