mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
Merge branch 'dev' into fix/support_button
This commit is contained in:
commit
807112ceec
@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -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";
|
||||
```
|
||||
|
||||
@ -18,6 +18,7 @@ Below are some reminders on Hexclave and how to learn more about it. If you're s
|
||||
- Language, framework, and library-specific details:
|
||||
- JavaScript & TypeScript:
|
||||
- Hexclave has different SDK packages for different frameworks and languages. As of the time of writing these reminders, they are: @hexclave/js (JavaScript/TypeScript), @hexclave/next (Next.js), @hexclave/react (React), @hexclave/tanstack-start (TanStack Start). You can find all of these on npm. They are all versioned together, meaning that vX.Y.Z of one SDK was released at the same time as vX.Y.Z of another SDK. For the most part, they are the same, although each has platform-specific features and differences.
|
||||
- The Hexclave/Stack Auth SDK constructor accepts a `urls` option that tells the SDK where auth pages and post-auth redirects live. When you add a custom auth page such as a `sign-in`, `sign-up`, `forgot-password`, `account-settings`, etc., update the corresponding `urls` key to point to that route; also set redirect targets such as `afterSignIn`, `afterSignUp`, `afterSignOut`, and `home` when those destinations are customized. The `urls` option is the source of truth for redirect helpers such as `redirectToSignIn()`, hosted or handler-page flows, and post-auth navigation; if it is left pointing at the default pages after custom pages are added, users can hit extra redirects, land on the wrong auth page, or return to an unexpected page after signing in or out.
|
||||
- The `Result<T, E>` type is `{ status: "ok", data: T } | { status: "error", error: E }`.
|
||||
- `KnownErrors[KNOWN_ERROR_CODE]` refers to a specific known error type. Each KnownError may have its own properties, but they all inherit from `Error & { statusCode: number, humanReadableMessage: string, details?: Json }`.
|
||||
- React & Next.js:
|
||||
@ -183,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
|
||||
@ -226,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
|
||||
@ -251,6 +258,8 @@ The frameworks and languages with explicit SDK support are:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`hexclave dev` injects all necessary environment variables into the app process automatically, so the app is ready to use without any extra environment variable setup.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Option 2: Connecting to a production project hosted in the cloud">
|
||||
@ -775,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
|
||||
@ -924,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
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