mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
Merge branch 'dev' into ask-mcp-endpoint-on-skill
This commit is contained in:
commit
4ad3c135fd
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/backend",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -96,7 +96,7 @@ describe("local emulator config", () => {
|
||||
await writeConfigToFile(absoluteFilePath, { auth: { allowLocalhost: true } });
|
||||
|
||||
await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toBe(
|
||||
`import type { HexclaveConfig } from "@hexclave/js";\n\nexport const config: HexclaveConfig = {\n "auth": {\n "allowLocalhost": true\n }\n};\n`
|
||||
`import type { HexclaveConfig } from "@hexclave/js/config";\n\nexport const config: HexclaveConfig = {\n "auth": {\n "allowLocalhost": true\n }\n};\n`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit a815ddcd1354d4bf27042626d1035709c80abdc6
|
||||
Subproject commit 9f7eb33f1a4bdb38d2aef4e64a42efe423a925ec
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -34,7 +34,7 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { ActionDialog, Dialog, DialogContent, DialogTitle, Label, Popover, PopoverContent, PopoverTrigger, Typography, useToast } from "@/components/ui";
|
||||
import { ActionDialog, Dialog, DialogContent, DialogTitle, Label, Popover, PopoverContent, PopoverTrigger, SimpleTooltip, Typography, useToast } from "@/components/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import Image from "next/image";
|
||||
@ -178,6 +178,8 @@ const PROVIDERS: ProviderMeta[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const REMOTE_DEVELOPMENT_ENVIRONMENT_EMAIL_PROVIDER_TOOLTIP = "These email server options are not supported when running dashboard locally.";
|
||||
|
||||
function TestSendingDialog(props: { trigger: React.ReactNode }) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const project = hexclaveAdminApp.useProject();
|
||||
@ -960,18 +962,21 @@ export function DomainSettings() {
|
||||
const isSelected = serverType === p.value;
|
||||
const isSaved = savedServerType === p.value;
|
||||
const isDraft = isSelected && !isSaved;
|
||||
const isDisabledInDevelopmentEnvironment = project.isDevelopmentEnvironment && p.value !== "shared";
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
disabled={isDisabledInDevelopmentEnvironment}
|
||||
onClick={() => handleSelectProvider(p.value)}
|
||||
className={cn(
|
||||
"relative text-left rounded-xl border p-4 transition-all",
|
||||
"relative h-full w-full text-left rounded-xl border p-4 transition-all disabled:pointer-events-none",
|
||||
isSaved && "border-green-500/40 bg-green-500/[0.04]",
|
||||
isDraft && "border-amber-500/50 bg-amber-500/[0.04] ring-1 ring-amber-500/20 border-dashed",
|
||||
!isSaved && !isDraft && "border-border/60 hover:border-foreground/20 hover:bg-foreground/[0.02]",
|
||||
isSelected && !isDraft && !isSaved && "border-foreground/40 bg-foreground/[0.03] shadow-sm ring-1 ring-foreground/10",
|
||||
isDisabledInDevelopmentEnvironment && "cursor-not-allowed opacity-50 hover:border-border/60 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
@ -994,6 +999,11 @@ export function DomainSettings() {
|
||||
<div className="text-xs text-muted-foreground mt-1 leading-relaxed">{p.tagline}</div>
|
||||
</button>
|
||||
);
|
||||
return isDisabledInDevelopmentEnvironment ? (
|
||||
<SimpleTooltip key={p.value} tooltip={REMOTE_DEVELOPMENT_ENVIRONMENT_EMAIL_PROVIDER_TOOLTIP} inline className="block h-full">
|
||||
{button}
|
||||
</SimpleTooltip>
|
||||
) : button;
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@ -53,7 +53,6 @@ import { fromNow } from "@hexclave/shared/dist/utils/dates";
|
||||
import { captureError, HexclaveAssertionError, throwErr } from '@hexclave/shared/dist/utils/errors';
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { deindent } from "@hexclave/shared/dist/utils/strings";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@ -115,7 +114,6 @@ function UserHeader({ user }: UserHeaderProps) {
|
||||
const [restrictionDialogOpen, setRestrictionDialogOpen] = useState(false);
|
||||
const [impersonateSnippet, setImpersonateSnippet] = useState<string | null>(null);
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 gap-4 items-center">
|
||||
@ -136,12 +134,6 @@ function UserHeader({ user }: UserHeaderProps) {
|
||||
<p>Last active {fromNow(user.lastActiveAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`${urlString`/projects/${hexclaveAdminApp.projectId}/conversations`}?userId=${encodeURIComponent(user.id)}`)}
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
<DesignMenu
|
||||
variant="actions"
|
||||
trigger="icon"
|
||||
|
||||
@ -50,7 +50,7 @@ export const config: HexclaveConfig = {
|
||||
`;
|
||||
const result = buildUpdatedConfigFileContent(current, { "teams.allowClientTeamCreation": true });
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { HexclaveConfig } from "@hexclave/next";
|
||||
"import type { HexclaveConfig } from "@hexclave/next/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"teams": {
|
||||
@ -68,7 +68,7 @@ export const config: HexclaveConfig = {};
|
||||
`;
|
||||
const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true });
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { HexclaveConfig } from "@hexclave/react";
|
||||
"import type { HexclaveConfig } from "@hexclave/react/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"auth": {
|
||||
@ -104,7 +104,7 @@ export const config: StackConfig = {};
|
||||
const current = `export const config = {};\n`;
|
||||
const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true });
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { HexclaveConfig } from "@hexclave/js";
|
||||
"import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"auth": {
|
||||
@ -124,7 +124,7 @@ export const config: HexclaveConfig = {};
|
||||
"payments.items.todos.customerType": "user",
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { HexclaveConfig } from "@hexclave/js";
|
||||
"import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"payments": {
|
||||
@ -150,7 +150,7 @@ export const config: HexclaveConfig = {
|
||||
"payments.items.todos.displayName": "New",
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { HexclaveConfig } from "@hexclave/js";
|
||||
"import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"payments": {
|
||||
@ -226,7 +226,7 @@ export const config: HexclaveConfig = { teams: { allowClientTeamCreation: false
|
||||
{
|
||||
"body": {
|
||||
"branch": "main",
|
||||
"content": "import type { HexclaveConfig } from "@hexclave/js";
|
||||
"content": "import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"teams": {
|
||||
@ -266,7 +266,7 @@ export const config: HexclaveConfig = { teams: { allowClientTeamCreation: false
|
||||
{
|
||||
"body": {
|
||||
"branch": "main",
|
||||
"content": "import type { HexclaveConfig } from "@hexclave/js";
|
||||
"content": "import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"auth": {
|
||||
@ -288,7 +288,7 @@ export const config: HexclaveConfig = { teams: { allowClientTeamCreation: false
|
||||
});
|
||||
|
||||
it("skips the commit when the new rendered file is identical to the old one", async () => {
|
||||
const same = `import type { HexclaveConfig } from "@hexclave/js";
|
||||
const same = `import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"teams": {
|
||||
|
||||
@ -28,10 +28,13 @@ import {
|
||||
*/
|
||||
function detectImportPackage(currentFileContent: string): string | undefined {
|
||||
// Match `from "@hexclave/<name>"` or `from "@stackframe/<name>"` — single
|
||||
// or double quotes. Hexclave preferred when both appear.
|
||||
const hexclave = currentFileContent.match(/from\s+["']@hexclave\/([a-z0-9-]+)["']/i);
|
||||
// or double quotes, with an optional `/config` subpath suffix (the lightweight
|
||||
// entrypoint newer config files import from). We return the bare package name;
|
||||
// the renderer re-appends `/config` for Hexclave packages. Hexclave preferred
|
||||
// when both appear.
|
||||
const hexclave = currentFileContent.match(/from\s+["']@hexclave\/([a-z0-9-]+)(?:\/config)?["']/i);
|
||||
if (hexclave) return `@hexclave/${hexclave[1]}`;
|
||||
const stackframe = currentFileContent.match(/from\s+["']@stackframe\/([a-z0-9-]+)["']/i);
|
||||
const stackframe = currentFileContent.match(/from\s+["']@stackframe\/([a-z0-9-]+)(?:\/config)?["']/i);
|
||||
return stackframe ? `@stackframe/${stackframe[1]}` : undefined;
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
// Root temp config files next to this test file (inside apps/dashboard) rather
|
||||
// than at process.cwd() (the repo root under vitest's workspace runner). This
|
||||
// lets jiti resolve workspace packages like `@hexclave/next/config` the same
|
||||
// way a real user project would — walking up to apps/dashboard/node_modules.
|
||||
const TEST_FILE_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
function createTempDir(): string {
|
||||
tempDir ??= mkdtempSync(join(TEST_FILE_DIR, ".stack-rde-config-test-"));
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function writeTempConfig(content: string): string {
|
||||
tempDir ??= mkdtempSync(join(process.cwd(), ".stack-rde-config-test-"));
|
||||
const configPath = join(tempDir, "stack.config.ts");
|
||||
const configPath = join(createTempDir(), "stack.config.ts");
|
||||
writeFileSync(configPath, content, "utf-8");
|
||||
return configPath;
|
||||
}
|
||||
@ -24,7 +35,7 @@ afterEach(() => {
|
||||
describe("remote development environment config file", () => {
|
||||
it("loads config exports wrapped in defineStackConfig", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
import { defineStackConfig } from "@hexclave/next/config";
|
||||
|
||||
export const config = defineStackConfig({
|
||||
auth: {
|
||||
@ -49,7 +60,7 @@ describe("remote development environment config file", () => {
|
||||
|
||||
it("loads config exports wrapped in defineHexclaveConfig", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
import { defineHexclaveConfig } from "@hexclave/shared/config";
|
||||
import { defineHexclaveConfig } from "@hexclave/next/config";
|
||||
|
||||
export const config = defineHexclaveConfig({
|
||||
auth: {
|
||||
@ -155,6 +166,24 @@ describe("remote development environment config file", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws a helpful error when the config file imports a module that fails to load", async () => {
|
||||
// Simulate a heavy framework package (e.g. @stackframe/stack) that throws on import
|
||||
const dir = createTempDir();
|
||||
const heavyPackagePath = join(dir, "heavy-package.ts");
|
||||
writeFileSync(heavyPackagePath, `throw new Error("Cannot load this in a Node.js context");`, "utf-8");
|
||||
const configPath = join(dir, "stack.config.ts");
|
||||
writeFileSync(configPath, `
|
||||
import "${heavyPackagePath}";
|
||||
export const config = {};
|
||||
`, "utf-8");
|
||||
|
||||
const { readConfigFile } = await import("./config-file");
|
||||
|
||||
await expect(readConfigFile(configPath)).rejects.toThrow(
|
||||
`Failed to load config file ${configPath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead`
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects modules without a valid config export", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
export const config = () => ({ auth: { allowSignUp: true } });
|
||||
@ -173,6 +202,10 @@ describe("remote development environment config file", () => {
|
||||
},
|
||||
};
|
||||
`);
|
||||
// Pin the SDK package the rendered import line points at, so the snapshot
|
||||
// doesn't depend on which @hexclave/* package the surrounding workspace
|
||||
// (apps/dashboard) happens to depend on.
|
||||
writeFileSync(join(createTempDir(), "package.json"), JSON.stringify({ dependencies: { "@hexclave/js": "*" } }), "utf-8");
|
||||
const { readConfigFile, writeConfigObject } = await import("./config-file");
|
||||
const current = await readConfigFile(configPath);
|
||||
|
||||
@ -182,7 +215,7 @@ describe("remote development environment config file", () => {
|
||||
});
|
||||
|
||||
expect(readFileSync(configPath, "utf-8")).toMatchInlineSnapshot(`
|
||||
"import type { HexclaveConfig } from "@hexclave/js";
|
||||
"import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"auth": {
|
||||
|
||||
@ -3,6 +3,7 @@ import "server-only";
|
||||
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
|
||||
import { Config, isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { createHash } from "crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { createJiti } from "jiti";
|
||||
@ -62,7 +63,18 @@ export async function readConfigFile(configFilePath: string): Promise<{ config:
|
||||
};
|
||||
}
|
||||
|
||||
const configModule = await jiti.import<unknown>(configFilePath);
|
||||
let configModule: unknown;
|
||||
try {
|
||||
configModule = await jiti.import<unknown>(configFilePath);
|
||||
} catch (error) {
|
||||
// Capture the raw jiti/framework error for diagnostics, but don't attach it as `cause` on the thrown error:
|
||||
// the dashboard's error formatter (errorToNiceString -> nicify) renders `Error.cause` recursively, which would
|
||||
// leak the underlying framework stack/internals back into the user-facing message we're deliberately replacing.
|
||||
captureError("remote-development-environment/readConfigFile", error);
|
||||
throw new Error(
|
||||
`Failed to load config file ${configFilePath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead, which doesn't load the framework runtime:\n\n import { defineHexclaveConfig } from "@hexclave/next/config";\n`,
|
||||
);
|
||||
}
|
||||
if (!isConfigModule(configModule)) {
|
||||
throw new Error(`Invalid config in ${configFilePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dev-launchpad",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/e2e-tests",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -115,7 +115,7 @@ describe("local emulator config restrictions", () => {
|
||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
||||
expect(fileContent).toMatchInlineSnapshot(`
|
||||
deindent\`
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
"teams": {
|
||||
|
||||
@ -461,7 +461,7 @@ describe("Stack CLI", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Config written to");
|
||||
const content = fs.readFileSync(configTsPath, "utf-8");
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
||||
expect(content).toContain("export const config: HexclaveConfig");
|
||||
});
|
||||
|
||||
@ -556,7 +556,7 @@ describe("Stack CLI", () => {
|
||||
expect(stdout).toContain("Config file written to");
|
||||
|
||||
const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8");
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
||||
expect(content).toContain("export const config: HexclaveConfig");
|
||||
expect(JSON.parse(extractConfigObjectString(content))).toMatchObject({
|
||||
apps: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/hosted-components",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}09",
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { StackClientApp, StackProvider, StackTheme } from '@hexclave/react';
|
||||
import { HexclaveClientApp, HexclaveProvider, HexclaveTheme } from '@hexclave/react';
|
||||
import { publishableClientKeyNotNecessarySentinel } from '@hexclave/shared/dist/utils/oauth';
|
||||
import { runAsynchronously } from '@hexclave/shared/dist/utils/promises';
|
||||
import { validateRedirectUrl } from '@hexclave/shared/dist/utils/redirect-urls';
|
||||
import { isRelative } from '@hexclave/shared/dist/utils/urls';
|
||||
import { throwErr } from '@hexclave/shared/dist/utils/errors';
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
@ -49,6 +53,27 @@ function getApiBaseUrlFromEnv(): string | undefined {
|
||||
return import.meta.env.VITE_HEXCLAVE_API_URL ?? import.meta.env.VITE_STACK_API_URL ?? undefined;
|
||||
}
|
||||
|
||||
function isTrustedNavigationTarget(to: string): boolean {
|
||||
return isRelative(to) || validateRedirectUrl(to, { trustedDomains: [window.location.origin] });
|
||||
}
|
||||
|
||||
function useHostedComponentsNavigate() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMemo(() => (to: string) => {
|
||||
runAsynchronously(async () => {
|
||||
if (to.startsWith("#")) {
|
||||
await navigate({ hash: to.slice(1) });
|
||||
} else {
|
||||
if (!isTrustedNavigationTarget(to)) {
|
||||
throw new Error("Refusing to navigate to an untrusted URL");
|
||||
}
|
||||
await navigate({ href: to });
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
}
|
||||
|
||||
function FullPageError({ title, message }: { title: string, message: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
@ -142,7 +167,7 @@ function RootComponent() {
|
||||
|
||||
const hexclaveApp = useMemo(() => {
|
||||
if (!projectId || !isValidProjectId) return null;
|
||||
return new StackClientApp({
|
||||
return new HexclaveClientApp({
|
||||
projectId,
|
||||
publishableClientKey: publishableClientKeyNotNecessarySentinel,
|
||||
tokenStore: "cookie",
|
||||
@ -155,7 +180,7 @@ function RootComponent() {
|
||||
afterSignUp: "/",
|
||||
afterSignOut: "/handler/sign-in",
|
||||
},
|
||||
redirectMethod: { useNavigate: useNavigate as any }
|
||||
redirectMethod: { useNavigate: useHostedComponentsNavigate },
|
||||
});
|
||||
}, [isValidProjectId, projectId]);
|
||||
|
||||
@ -171,13 +196,15 @@ function RootComponent() {
|
||||
return <FullPageError title="Something went wrong" message={`Invalid project ID: ${projectId}. Project IDs must be UUIDs.`} />;
|
||||
}
|
||||
|
||||
const app = hexclaveApp ?? throwErr("RootComponent expected a HexclaveClientApp after project ID validation.");
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<StackProvider app={hexclaveApp!}>
|
||||
<StackTheme>
|
||||
<HexclaveProvider app={app}>
|
||||
<HexclaveTheme>
|
||||
<Outlet />
|
||||
</StackTheme>
|
||||
</StackProvider>
|
||||
</HexclaveTheme>
|
||||
</HexclaveProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/internal-tool",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/pre-dev.mjs && next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/mcp",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/mock-oauth-server",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/skills",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -104,6 +104,23 @@ export const hexclaveClientApp = new HexclaveClientApp({
|
||||
|
||||
`maskAllInputs` defaults to `true`, so form fields are masked unless you explicitly disable it.
|
||||
|
||||
### Disabling Analytics Capture in the SDK
|
||||
|
||||
SDK-managed analytics capture is enabled by default. If you don't want the SDK to collect any analytics, pass `analytics: { enabled: false }` when creating your client app:
|
||||
|
||||
```ts
|
||||
import { HexclaveClientApp } from "@hexclave/next";
|
||||
|
||||
export const hexclaveClientApp = new HexclaveClientApp({
|
||||
projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID!,
|
||||
publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY!,
|
||||
tokenStore: "nextjs-cookie",
|
||||
analytics: { enabled: false },
|
||||
});
|
||||
```
|
||||
|
||||
This stops the SDK from sending `$page-view` and `$click` events. It also resolves the `ANALYTICS_NOT_ENABLED` warning the SDK logs to the browser console when it tries to send events to a project that hasn't enabled the Analytics app — with capture disabled, the SDK never makes those requests. If you'd rather keep analytics, enable the Analytics app in your dashboard (**Apps -> Analytics**) instead.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Tables for quick incident triage**: the Tables UI is the fastest way to inspect recent `events` rows without writing SQL.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -9,7 +9,7 @@ sidebarTitle: "hexclave.config.ts"
|
||||
The file exports a static `config` object:
|
||||
|
||||
```ts title="hexclave.config.ts"
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = {
|
||||
auth: {
|
||||
@ -28,6 +28,22 @@ export const config: HexclaveConfig = {
|
||||
};
|
||||
```
|
||||
|
||||
<Note>
|
||||
Always import config helpers from the package's lightweight `/config` entrypoint (e.g. `@hexclave/js/config`, `@hexclave/next/config`) rather than the package root. The `/config` entrypoint contains no framework runtime code, so tooling such as the local dashboard can load your config file in a plain Node context. Importing `defineHexclaveConfig` (or the `HexclaveConfig` type) from the package root instead would pull in the entire SDK and fail to load.
|
||||
</Note>
|
||||
|
||||
To get type-checking and editor autocomplete for your config object, wrap it with `defineHexclaveConfig`:
|
||||
|
||||
```ts title="hexclave.config.ts"
|
||||
import { defineHexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config = defineHexclaveConfig({
|
||||
auth: {
|
||||
allowSignUp: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you are running Hexclave with a [local dashboard](/guides/going-further/local-vs-cloud-dashboard), you already have a `hexclave.config.ts` file, and any changes you make on the dashboard will automatically be synced to the config file.
|
||||
|
||||
If you are running Hexclave on a [cloud project](/guides/going-further/local-vs-cloud-dashboard) instead, you may need to use the [CLI's `pull` and `push`](/guides/going-further/cli#config-commands) commands to sync your config file with the cloud. In production, you would usually do this in your GitHub Actions or CI/CD pipeline.
|
||||
|
||||
@ -25,7 +25,7 @@ Use a development environment when you want to:
|
||||
The usual setup looks like this:
|
||||
|
||||
```ts title="hexclave.config.ts"
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
```
|
||||
|
||||
@ -184,6 +184,10 @@ The frameworks and languages with explicit SDK support are:
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
The SDK auto-captures page-view and click analytics. To turn this off (and silence the `ANALYTICS_NOT_ENABLED` console warning that appears until you enable the Analytics app in your dashboard), pass `analytics: { enabled: false }`.
|
||||
</Note>
|
||||
|
||||
In a backend where you can keep a secret key safe, you can use the `HexclaveServerApp`, which provides access to more sensitive APIs compared to `HexclaveClientApp`:
|
||||
|
||||
```ts src/hexclave/server.ts
|
||||
@ -227,12 +231,14 @@ The frameworks and languages with explicit SDK support are:
|
||||
First, create a `hexclave.config.ts` configuration file in the root directory of the workspace (or anywhere else):
|
||||
|
||||
```ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "<the-sdk-from-above>";
|
||||
import type { HexclaveConfig } from "<the-sdk-from-above>/config";
|
||||
|
||||
// default: show-onboarding, which shows the onboarding flow for this project when Hexclave starts
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
```
|
||||
|
||||
The `/config` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with `defineHexclaveConfig` imported from the same `<the-sdk-from-above>/config` path (never from `<the-sdk-from-above>` directly, which would pull in the whole SDK and fail to load).
|
||||
|
||||
To run your application with Hexclave, you can then start the dev environment and set environment variables expected by your application. Hexclave's CLI has a `dev` command does both of these, so let's install it as a dev dependency and wrap your existing `dev` script in your package.json:
|
||||
|
||||
```sh
|
||||
@ -778,11 +784,13 @@ This setup is for Python backends that do not use the JavaScript SDK. The backen
|
||||
If this project already has a `hexclave.config.ts` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new `hexclave.config.ts` file in your workspace:
|
||||
|
||||
```ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
```
|
||||
|
||||
The `/config` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with `defineHexclaveConfig` imported from the same `@hexclave/js/config` path (never from `@hexclave/js` directly, which would pull in the whole SDK and fail to load).
|
||||
|
||||
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
||||
|
||||
```json package.json
|
||||
@ -927,11 +935,13 @@ Use this option when your backend is not JavaScript/TypeScript or Python, or whe
|
||||
If this project already has a `hexclave.config.ts` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new `hexclave.config.ts` file in your workspace:
|
||||
|
||||
```ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
```
|
||||
|
||||
The `/config` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with `defineHexclaveConfig` imported from the same `@hexclave/js/config` path (never from `@hexclave/js` directly, which would pull in the whole SDK and fail to load).
|
||||
|
||||
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
||||
|
||||
```json package.json
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs-mintlify",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "mint dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}04 --no-open",
|
||||
|
||||
@ -86,6 +86,10 @@ If you're building a client-only app and don't have a `SECRET_SERVER_KEY`, you c
|
||||
Redirect URL configuration.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="analytics" type="object">
|
||||
Analytics capture configuration. SDK-managed capture is enabled by default; pass `{ enabled: false }` to disable it entirely (which also avoids the `ANALYTICS_NOT_ENABLED` console warning on projects that haven't enabled the Analytics app), or `{ replays: { enabled: true } }` to record session replays.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="noAutomaticPrefetch" type="boolean">
|
||||
Disable automatic prefetching.
|
||||
</ParamField>
|
||||
@ -106,6 +110,7 @@ If you're building a client-only app and don't have a `SECRET_SERVER_KEY`, you c
|
||||
projectId?: string;
|
||||
publishableClientKey?: string;
|
||||
urls?: object;
|
||||
analytics?: object;
|
||||
noAutomaticPrefetch?: boolean;
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-cjs-test",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/convex-example",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-demo-app",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs-examples",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/e-commerce-demo",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/js-example",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"description": "",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/lovable-react-18-example",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-middleware-demo",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-supabase",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-tanstack-start-demo",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "TanStack Start demo app for Hexclave",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/cli",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "The CLI for Hexclave. https://hexclave.com",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@ -278,7 +278,10 @@ export function registerConfigCommand(program: Command) {
|
||||
const config = parseConfigOverride(configModule.config);
|
||||
if (config == null) {
|
||||
const examplePkg = detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js";
|
||||
throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { StackConfig } from "${examplePkg}"; export const config: StackConfig = { ... };`);
|
||||
// The lightweight `/config` entrypoint only exists on Hexclave-branded packages;
|
||||
// legacy `@stackframe/*` releases predate it, so import from their root.
|
||||
const exampleImport = examplePkg.startsWith("@hexclave/") ? `${examplePkg}/config` : examplePkg;
|
||||
throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${exampleImport}"; export const config: HexclaveConfig = { ... };`);
|
||||
}
|
||||
|
||||
const source = buildConfigPushSource(opts.configFile, {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard-ui-components",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/js",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -16,6 +16,15 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": {
|
||||
"default": "./dist/esm/config.js"
|
||||
},
|
||||
"require": {
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"./convex.config": {
|
||||
"types": "./dist/integrations/convex/component/convex.config.d.ts",
|
||||
"import": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/next",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -16,6 +16,15 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": {
|
||||
"default": "./dist/esm/config.js"
|
||||
},
|
||||
"require": {
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"./convex.config": {
|
||||
"types": "./dist/integrations/convex/component/convex.config.d.ts",
|
||||
"import": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/react",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -16,6 +16,15 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": {
|
||||
"default": "./dist/esm/config.js"
|
||||
},
|
||||
"require": {
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"./convex.config": {
|
||||
"types": "./dist/integrations/convex/component/convex.config.d.ts",
|
||||
"import": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sc",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"exports": {
|
||||
"./force-react-server": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/shared",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsdown",
|
||||
|
||||
@ -442,11 +442,13 @@ function getRestBackendSetupPrompt(kind: "python" | "rest-api") {
|
||||
If this project already has a \`hexclave.config.ts\` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new \`hexclave.config.ts\` file in your workspace:
|
||||
|
||||
\`\`\`ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
import type { HexclaveConfig } from "@hexclave/js/config";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
\`\`\`
|
||||
|
||||
The \`/config\` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\` imported from the same \`@hexclave/js/config\` path (never from \`@hexclave/js\` directly, which would pull in the whole SDK and fail to load).
|
||||
|
||||
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
||||
|
||||
\`\`\`json package.json
|
||||
@ -662,6 +664,10 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
|
||||
},
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
<Note>
|
||||
The SDK auto-captures page-view and click analytics. To turn this off (and silence the \`ANALYTICS_NOT_ENABLED\` console warning that appears until you enable the Analytics app in your dashboard), pass \`analytics: { enabled: false }\`.
|
||||
</Note>
|
||||
` : ""}
|
||||
|
||||
${isMaybeBackend ? deindent`
|
||||
@ -719,12 +725,14 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
|
||||
First, create a \`hexclave.config.ts\` configuration file in the root directory of the workspace (or anywhere else):
|
||||
|
||||
\`\`\`ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "${packageName}";
|
||||
import type { HexclaveConfig } from "${packageName}/config";
|
||||
|
||||
// default: show-onboarding, which shows the onboarding flow for this project when Hexclave starts
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
\`\`\`
|
||||
|
||||
The \`/config\` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\` imported from the same \`${packageName}/config\` path (never from \`${packageName}\` directly, which would pull in the whole SDK and fail to load).
|
||||
|
||||
To run your application with Hexclave, you can then start the dev environment and set environment variables expected by your application. Hexclave's CLI has a \`dev\` command does both of these, so let's install it as a dev dependency and wrap your existing \`dev\` script in your package.json:
|
||||
|
||||
\`\`\`sh
|
||||
|
||||
@ -13,6 +13,7 @@ export { parseHexclaveConfigFileContent, renderConfigFileContent };
|
||||
const CONFIG_IMPORT_PACKAGES = [
|
||||
"@hexclave/next",
|
||||
"@hexclave/react",
|
||||
"@hexclave/tanstack-start",
|
||||
"@hexclave/js",
|
||||
"@hexclave/template",
|
||||
"@stackframe/stack",
|
||||
@ -120,18 +121,26 @@ import.meta.vitest?.test("renderConfigFileContent rejects invalid config exports
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent uses custom import package", ({ expect }) => {
|
||||
const content = renderConfigFileContent({}, "@hexclave/next");
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/next";');
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/next/config";');
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({ expect }) => {
|
||||
const content = renderConfigFileContent({});
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
|
||||
// The lightweight `/config` subpath only exists on Hexclave-branded packages;
|
||||
// already-published @stackframe/* releases predate it.
|
||||
const content = renderConfigFileContent({}, "@stackframe/next");
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("detectConfigImportPackage picks first matching package by priority", ({ expect }) => {
|
||||
expect(detectConfigImportPackage(["@hexclave/next", "@hexclave/js"])).toBe("@hexclave/next");
|
||||
expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react");
|
||||
expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js");
|
||||
expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start");
|
||||
// Hexclave names take priority over legacy stackframe names when both appear.
|
||||
expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next");
|
||||
// Legacy fallback still works for projects pinned to the last @stackframe/* release.
|
||||
|
||||
@ -28,7 +28,13 @@ export function renderConfigFileContent(config: unknown, importPackage?: string)
|
||||
throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
|
||||
}
|
||||
const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
|
||||
const importLine = `import type { HexclaveConfig } from "${pkg}";`;
|
||||
// Import the `HexclaveConfig` type from the package's lightweight `/config`
|
||||
// entrypoint, which is free of framework runtime code and therefore safe for
|
||||
// tooling (e.g. the local dashboard) to load in a plain Node context. Only the
|
||||
// Hexclave-branded packages expose this subpath; legacy `@stackframe/*`
|
||||
// releases predate it, so fall back to their package root.
|
||||
const importSpecifier = pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
|
||||
const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
|
||||
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
|
||||
}
|
||||
|
||||
|
||||
147
packages/shared/src/sessions.test.ts
Normal file
147
packages/shared/src/sessions.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InternalSession } from "./sessions";
|
||||
|
||||
/**
|
||||
* Builds a decodable (unsigned) access-token JWT with a valid payload. `refreshTokenId` controls the
|
||||
* `refresh_token_id` claim (the session identifier); `iatOffsetSeconds` lets two tokens for the same session
|
||||
* differ as strings while sharing a `refresh_token_id`.
|
||||
*/
|
||||
function createAccessTokenString(refreshTokenId: string, options?: { iatOffsetSeconds?: number, sub?: string }): string {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const nowSeconds = Math.floor(Date.now() / 1000) + (options?.iatOffsetSeconds ?? 0);
|
||||
return [
|
||||
encode({ alg: "none", typ: "JWT" }),
|
||||
encode({
|
||||
sub: options?.sub ?? "user-id",
|
||||
exp: nowSeconds + 60,
|
||||
iat: nowSeconds,
|
||||
iss: "https://api.example.test",
|
||||
aud: "project-id",
|
||||
project_id: "project-id",
|
||||
branch_id: "main",
|
||||
refresh_token_id: refreshTokenId,
|
||||
role: "authenticated",
|
||||
name: null,
|
||||
email: null,
|
||||
email_verified: false,
|
||||
selected_team_id: null,
|
||||
signed_up_at: nowSeconds,
|
||||
is_anonymous: false,
|
||||
is_restricted: false,
|
||||
restricted_reason: null,
|
||||
requires_totp_mfa: false,
|
||||
}),
|
||||
"",
|
||||
].join(".");
|
||||
}
|
||||
|
||||
function createAccessOnlySession(accessToken: string): InternalSession {
|
||||
return new InternalSession({
|
||||
refreshAccessTokenCallback: async () => null,
|
||||
refreshToken: null,
|
||||
accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
const currentToken = (session: InternalSession) => session.getAccessTokenIfNotExpiredYet(20_000, null)?.token;
|
||||
|
||||
describe("InternalSession.calculateSessionKey", () => {
|
||||
it("keys by the refresh token when one is present (ignoring any access token)", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc" })).toBe("refresh-rt-abc");
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc", accessToken: createAccessTokenString("rtid-1") }))
|
||||
.toBe("refresh-rt-abc");
|
||||
});
|
||||
|
||||
it("returns not-logged-in when neither token is present", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null })).toBe("not-logged-in");
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: null })).toBe("not-logged-in");
|
||||
});
|
||||
|
||||
it("keys an access-only session by its refresh_token_id", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") }))
|
||||
.toBe("access-session-rtid-1");
|
||||
});
|
||||
|
||||
it("is stable across re-minted access tokens for the same session (the regression this fixes)", () => {
|
||||
const first = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
||||
const second = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
||||
expect(second).not.toBe(first);
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: second }))
|
||||
.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: first }));
|
||||
});
|
||||
|
||||
it("distinguishes access-only sessions with different refresh_token_ids", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") }))
|
||||
.not.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-2") }));
|
||||
});
|
||||
|
||||
it("falls back to the raw token when the access token can't be decoded", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: "not-a-jwt" })).toBe("access-not-a-jwt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InternalSession#updateAccessToken", () => {
|
||||
it("installs a fresh token for the same access-only session in place", () => {
|
||||
const initial = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
||||
const refreshed = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
||||
const session = createAccessOnlySession(initial);
|
||||
|
||||
session.updateAccessToken({ accessToken: refreshed, refreshToken: null });
|
||||
expect(currentToken(session)).toBe(refreshed);
|
||||
// identity is unchanged — same session key, same object
|
||||
expect(session.sessionKey).toBe("access-session-rtid-1");
|
||||
});
|
||||
|
||||
it("rejects a token pair belonging to a different access-only session", () => {
|
||||
const initial = createAccessTokenString("rtid-1");
|
||||
const foreign = createAccessTokenString("rtid-2", { sub: "other-user" });
|
||||
const session = createAccessOnlySession(initial);
|
||||
|
||||
session.updateAccessToken({ accessToken: foreign, refreshToken: null });
|
||||
expect(currentToken(session)).toBe(initial);
|
||||
});
|
||||
|
||||
it("is a no-op for an unchanged, null, or undecodable token", () => {
|
||||
const initial = createAccessTokenString("rtid-1");
|
||||
const session = createAccessOnlySession(initial);
|
||||
|
||||
session.updateAccessToken({ accessToken: initial, refreshToken: null });
|
||||
session.updateAccessToken({ accessToken: null, refreshToken: null });
|
||||
session.updateAccessToken({ accessToken: "not-a-jwt", refreshToken: null });
|
||||
expect(currentToken(session)).toBe(initial);
|
||||
});
|
||||
|
||||
it("never revives an invalidated session", () => {
|
||||
const session = createAccessOnlySession(createAccessTokenString("rtid-1"));
|
||||
session.markInvalid();
|
||||
|
||||
session.updateAccessToken({ accessToken: createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }), refreshToken: null });
|
||||
expect(session.isKnownToBeInvalid()).toBe(true);
|
||||
expect(currentToken(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates a refresh-token-backed session's access token in place when the refresh token matches", () => {
|
||||
const session = new InternalSession({
|
||||
refreshAccessTokenCallback: async () => null,
|
||||
refreshToken: "rt-abc",
|
||||
accessToken: createAccessTokenString("rtid-1"),
|
||||
});
|
||||
const refreshed = createAccessTokenString("rtid-2", { iatOffsetSeconds: 1 });
|
||||
|
||||
session.updateAccessToken({ accessToken: refreshed, refreshToken: "rt-abc" });
|
||||
expect(currentToken(session)).toBe(refreshed);
|
||||
expect(session.sessionKey).toBe("refresh-rt-abc");
|
||||
});
|
||||
|
||||
it("rejects a token pair carrying a different refresh token for a refresh-backed session", () => {
|
||||
const initial = createAccessTokenString("rtid-1");
|
||||
const session = new InternalSession({
|
||||
refreshAccessTokenCallback: async () => null,
|
||||
refreshToken: "rt-abc",
|
||||
accessToken: initial,
|
||||
});
|
||||
|
||||
session.updateAccessToken({ accessToken: createAccessTokenString("rtid-2"), refreshToken: "rt-other" });
|
||||
expect(currentToken(session)).toBe(initial);
|
||||
});
|
||||
});
|
||||
@ -124,6 +124,14 @@ export class InternalSession {
|
||||
if (ofTokens.refreshToken) {
|
||||
return `refresh-${ofTokens.refreshToken}`;
|
||||
} else if (ofTokens.accessToken) {
|
||||
// Access-only sessions (no refresh token) are keyed by the underlying session's `refresh_token_id`, not the
|
||||
// access token string: access tokens get re-minted frequently, and keying by the raw token would spawn a new
|
||||
// session (and cold-invalidate every session-scoped cache) on each refresh. Falls back to the raw token if
|
||||
// the JWT can't be decoded.
|
||||
const refreshTokenId = decodeAccessTokenIfValid(ofTokens.accessToken)?.refresh_token_id;
|
||||
if (refreshTokenId) {
|
||||
return `access-session-${refreshTokenId}`;
|
||||
}
|
||||
return `access-${ofTokens.accessToken}`;
|
||||
} else {
|
||||
return "not-logged-in";
|
||||
@ -210,6 +218,24 @@ export class InternalSession {
|
||||
return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a freshly obtained token pair's access token into this session in place, keeping the session object
|
||||
* (and therefore every session-scoped cache) stable instead of constructing a new InternalSession. No-op if the
|
||||
* session is invalid, the access token can't be decoded, it's unchanged, or the pair doesn't map to this session
|
||||
* (so a foreign token can never be written into this object's cache); never clears an existing token.
|
||||
*/
|
||||
updateAccessToken(tokens: { accessToken: string | null, refreshToken: string | null }) {
|
||||
if (this._knownToBeInvalid.get()) return;
|
||||
if (!tokens.accessToken) return;
|
||||
const newAccessToken = AccessToken.createIfValid(tokens.accessToken);
|
||||
if (!newAccessToken) return;
|
||||
// Self-enforce the "a session never changes which session it belongs to" invariant: only install a token pair
|
||||
// that maps to this same session key (validated against the incoming pair, not this session's existing tokens).
|
||||
if (InternalSession.calculateSessionKey(tokens) !== this.sessionKey) return;
|
||||
if (this._accessToken.get()?.token === newAccessToken.token) return;
|
||||
this._accessToken.set(newAccessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually mark the access token as expired, even if the date on its payload may still be valid.
|
||||
*
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/tanstack-start",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -16,6 +16,15 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": {
|
||||
"default": "./dist/esm/config.js"
|
||||
},
|
||||
"require": {
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"./tanstack-start-server-context": {
|
||||
"types": "./dist/tanstack-start-server-context.combined.d.ts",
|
||||
"import": {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
"//": "NEXT_LINE_PLATFORM template",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -28,6 +28,15 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": {
|
||||
"default": "./dist/esm/config.js"
|
||||
},
|
||||
"require": {
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"//": "IF_PLATFORM tanstack-start",
|
||||
"./tanstack-start-server-context": {
|
||||
"types": "./dist/tanstack-start-server-context.combined.d.ts",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/template",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -17,6 +17,15 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": {
|
||||
"default": "./dist/esm/config.js"
|
||||
},
|
||||
"require": {
|
||||
"default": "./dist/config.js"
|
||||
}
|
||||
},
|
||||
"./tanstack-start-server-context": {
|
||||
"types": "./dist/tanstack-start-server-context.combined.d.ts",
|
||||
"import": {
|
||||
|
||||
13
packages/template/src/config.ts
Normal file
13
packages/template/src/config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// Lightweight, side-effect-free entrypoint for authoring `hexclave.config.ts`
|
||||
// files. Importing from here (e.g. `@hexclave/next/config`) gives you the
|
||||
// `defineHexclaveConfig` helper and config types WITHOUT pulling in the
|
||||
// framework runtime (React, server-only, Next.js internals). That matters
|
||||
// because tooling such as the local dashboard evaluates your config file in a
|
||||
// plain Node context — importing `defineHexclaveConfig` from the package root
|
||||
// would drag in the whole SDK and fail to load.
|
||||
//
|
||||
// Hexclave aliases and legacy Stack* names — @deprecated JSDoc lives on the
|
||||
// original declarations in @hexclave/shared/config so it survives dts bundling
|
||||
// (per-specifier JSDoc on re-exports does not).
|
||||
export type { HexclaveConfig, StackConfig } from "@hexclave/shared/config";
|
||||
export { defineHexclaveConfig, defineStackConfig, showOnboardingHexclaveConfigValue } from "@hexclave/shared/config";
|
||||
@ -1547,10 +1547,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper());
|
||||
tokenStore.set(tokens);
|
||||
|
||||
// Pre-fetch the current user for the new session so the cache is already
|
||||
// populated when useUser() re-renders, avoiding a stale-cache render cycle.
|
||||
const newSession = this._getSessionFromTokenStore(tokenStore);
|
||||
this._currentUserCache.getOrWait([newSession], "write-only").catch(() => {});
|
||||
// If these tokens resolve to a session we already have (eg. the RDE dashboard re-installing a freshly minted
|
||||
// access token for the same access-only session), push the new token into it in place; constructing a new
|
||||
// session here would cold-invalidate every session-scoped cache and suspend the UI on each refresh.
|
||||
const session = this._getSessionFromTokenStore(tokenStore);
|
||||
session.updateAccessToken(tokens);
|
||||
|
||||
// Pre-fetch the current user so the cache is warm when useUser() re-renders (write-only, so it never suspends).
|
||||
runAsynchronously(this._currentUserCache.getOrWait([session], "write-only"));
|
||||
}
|
||||
|
||||
protected _getTokenStoreInitForFreshTokens(tokens: { accessToken: string | null, refreshToken: string }): TokenStoreInit | undefined {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/ui",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/swift-sdk",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"description": "Hexclave Swift SDK",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sdk-spec",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"description": "Hexclave SDK specification files",
|
||||
"scripts": {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user