stack/apps/e2e/tests/helpers.ts
BilalG1 f7e389809e
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 1 — wire compatibility layer (invisible) (#1475)
## Summary

**Stacked on #1468** (`docs/hexclave-rename-plan` — the plan doc). Diff
vs that base = the actual PR 1 code.

This is **PR 1 of the Hexclave rebrand: the invisible compatibility
layer**. Everything is additive. Old SDKs, old wire identifiers, and old
env var names keep working unchanged. The backend dual-accepts and
dual-emits; new SDK code emits `x-hexclave-*` headers and the
`hexclave_` Bearer prefix; cookies dual-write; env vars dual-read across
every category. **No user-visible rebranding lands here** — that's PR 2.

See [`RENAME-TO-HEXCLAVE.md`](./RENAME-TO-HEXCLAVE.md) → *"PR 1
implementation guide"* for the full per-work-area spec, file pointers,
and chosen approach.

## What's implemented (all 14 PR-1 work-areas)

- **SDK export aliases** — `Hexclave*` aliases for the user-facing
`Stack*` exports added in `packages/template`; codegen propagates them
to `@stackframe/{js,stack,react,tanstack-start}`. React-only aliases
correctly excluded from `@stackframe/js`. (`e60550a2`)
- **JWT issuer dual-accept** — `decodeAccessToken` accepts both
`api.stack-auth.com` and `api.hexclave.com` issuers. Signing unchanged.
(`fc781def`)
- **Request-header dual-accept** — backend + dashboard proxies normalize
`x-hexclave-*` → `x-stack-*` at the existing empty proxy hook (so
`smart-request.tsx` and every route schema keep working unchanged); CORS
allowlists extended via a derive-once helper. (`2a056eac`)
- **MCP `ask_hexclave`** — registered alongside `ask_stack_auth` via a
shared helper; `ask_stack_auth` behavior byte-identical. (`30ffd604`)
- **Dev-tool** — DOM ids + header emit switched.
`window.HexclaveDevTool` exposed alongside `window.StackDevTool`.
(`32131ea7`)
- **The big consolidated commit** (`7fed864a`):
- **Env vars** — central `getEnvVariable` prefix-transform (HEXCLAVE
first, STACK fallback); dashboard + template client env files dual-read;
`turbo.json` globalEnv; `NEXT_PUBLIC_STACK_PORT_PREFIX` renamed outright
across ~82 files including docker.
- **Cookies** — dual-write/dual-read auth (`stack-access`/`-refresh-*`
and custom-domain variants), OAuth-state
(`stack-oauth-{inner,outer}-*`), and low-risk cookies (`stack-is-https`,
`stack-last-seen-changelog-version`). Bypass sites patched (backend
OAuth callback, dashboard remote-dev auth route, impersonation snippets,
snapshot serializer).
- **Bearer prefix** — SDK token parser accepts both `stackauth_` and
`hexclave_`; emits `hexclave_`. Discovery correction: this is purely
SDK-internal — the backend never parses it.
- **Response headers** — backend dual-emits
`x-hexclave-{request-id,actual-status,known-error}`; SDKs dual-read (new
first, stack fallback).
- **SDK request-header emit switch** —
`client/server/admin-interface.ts` + dashboard `api-headers.ts` +
`internal-project-headers.ts` + `feedback-form.tsx` switched to
`x-hexclave-*`. Plus `stack_response_mode` query param.
- **Storage keys** — dev-tool / cli-auth / oauth-button / docs keys
renamed (straight); `stack:session-replay:v1` dual-read so in-progress
recordings survive SDK upgrades; `stack_mfa_attempt_code` dual-read.
- **Query params** — cross-domain params dual-emit/dual-accept via
shared helpers; backend `oauth/authorize` accepts
`hexclave_response_mode` and `stack_response_mode`; `stack-init-id`
renamed.
- **`Symbol.for`** — app-internals symbol gets a parallel
`Symbol.for("Hexclave--app-internals")` getter on each attach site (no
read-site churn — old symbol still attached). 3 file-private symbols
renamed outright.
- **Config discovery** — prefer `hexclave.config.ts`, fall back to
`stack.config.ts` at every discovery site (CLI / dashboard / backend /
local-emulator); `init` writes the new filename; CLI credentials path
migrates.
- **Internal renames** — `StackAssertionError`,
`StackClient/Server/AdminInterface` renamed outright (no alias, per the
"internal-only → rename" rule). ~264 files touched.
- **Review-pass fixes** (`21217fbe`) — three real bugs found by parallel
review agents and fixed:
- `snapshot-serializer.ts` was interpolating the whole
`keyedCookieNamePrefixes` array (`${arr}`) — adding a second prefix
would have corrupted **every** OAuth-cookie snapshot, not just new ones.
- **Docker port-prefix producer/consumer mismatch** —
`entrypoint.sh`/`run-emulator.sh`/cloud-init `user-data` were still
producing `NEXT_PUBLIC_STACK_PORT_PREFIX` while the dashboard sentinel +
consumers had been renamed; silent self-host regression (custom port
prefix would be ignored).
- **Missing `hexclave-oauth-inner-*` dual-write** in the OAuth authorize
route — callback's fallback masked it but the dual-write was specified
by the plan.
- Plus: `mcp.test.ts` tool-list assertions updated to include
`ask_hexclave`; two dashboard header-emit sites switched to
`x-hexclave-*` for consistency.
- **E2E snapshot serializer follow-up** (`4b16cc5d`) —
`x-hexclave-request-id` added to the hidden-headers list (mirroring
`x-stack-request-id` treatment), and 2 sample inline snapshots
regenerated in `projects.test.ts` to include the new dual-emitted
headers.

## Verification

- **`pnpm typecheck`** — clean (the fresh-worktree `@/.source` / Prisma
codegen gap in `stack-docs` is pre-existing and unrelated).
- **`pnpm lint`** — 29/29 packages green.
- **`pnpm exec turbo run build --filter=./packages/*`** — 13/13 packages
build (including `@stackframe/stack-cli` once the dashboard standalone
is present).
- **Live E2E** against a running backend on `cl/hexclave-pr1`:
- `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/mcp.test.ts` — **6/6
pass** (verifies the new `ask_hexclave` tool — the hand-written inline
snapshot matched actual MCP server output).
- `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts` —
**11/11 pass** (verifies wire dual-accept + dual-emit end-to-end; the
snapshot serializer fix was found and applied during this check).

A four-agent parallel **review pass** also audited the full diff for
logic/runtime bugs across the work-areas (wire headers + JWT, cookies +
bearer + symbols, env vars, query params + config + MCP + aliases). All
in-slice review verdicts were ✓ except the three bugs listed above,
which are now fixed.

## Known follow-ups (out of scope for this PR)

- **E2E snapshots across the rest of the suite** — backend now
dual-emits `x-hexclave-{known-error,actual-status}` alongside
`x-stack-*`, which legitimately appears in inline snapshots throughout
`apps/e2e`. Two were regenerated here as a sample; the rest should regen
with `vitest -u` in CI.
- **Docker shell env vars beyond `PORT_PREFIX`** — `entrypoint.sh` still
reads `STACK_*` env vars directly (the JS-side `getEnvVariable`
transform doesn't help the shell). JS consumers dual-read so it works in
practice; full shell-level dual-read is a deeper self-host follow-up.
- **`@stackframe/stack-cli` build ordering** — pre-existing; needs
`build:rde-standalone` first. Not affected by this PR.

## Test plan

- [ ] CI runs full e2e suite (with `vitest -u` to absorb dual-emit
snapshot deltas, then committed back)
- [ ] Spot-check: an old SDK build (emitting only `x-stack-*`) still
authenticates against the new backend
- [ ] Spot-check: a new SDK (emitting `x-hexclave-*` / `Bearer
hexclave_*`) still authenticates against an old backend during deploy
ordering
- [ ] Manual: `npx @stackframe/stack-cli@latest init` (new onboarding
entrypoint) generates `hexclave.config.ts`
- [ ] Manual: existing `stack.config.ts`-only project still resolves (no
migration required)

---------

Co-authored-by: bilal <bilal@stack-auth.com>
2026-05-23 17:24:55 -07:00

336 lines
13 KiB
TypeScript

import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { HexclaveAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { filterUndefined, omit } from "@stackframe/stack-shared/dist/utils/objects";
import { ignoreUnhandledRejection, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { Nicifiable } from "@stackframe/stack-shared/dist/utils/strings";
import { AsyncLocalStorage } from "node:async_hooks";
// eslint-disable-next-line no-restricted-imports
import { afterEach, beforeEach, onTestFailed, test as vitestTest } from "vitest";
export const test: typeof vitestTest = vitestTest.extend({});
export const it: typeof vitestTest = test;
export const afterTestFinishesCallbacks: (() => Promise<void>)[] = [];
export function logIfTestFails(...args: any[]) {
onTestFailed(() => {
console.error(...args);
});
}
export function runAsynchronouslyBeforeTestFinishes(callback: () => Promise<void>) {
const promise = callback();
ignoreUnhandledRejection(promise);
afterTestFinishesCallbacks.push(async () => await promise);
}
export function beforeTestFinishes(callback: () => Promise<void>) {
afterTestFinishesCallbacks.push(callback);
}
export class Context<R, T> {
// we want to retain order in which the values were set instead of the order in which the beforeEach callback was called, so we keep a Map and a Set together here
private _values = new Map<string, T>();
private _yetToReduce = new Set<string>();
private _deleteOnFinish = new Set<string>();
private _reduced: R | undefined;
private _withStorage: AsyncLocalStorage<T[]> = new AsyncLocalStorage();
private _isInTest = false;
constructor(private readonly _getInitialValue: () => R, private readonly _reducer: (acc: R, value: T) => R) {
beforeEach(async () => {
if (this._isInTest) {
throw new HexclaveAssertionError("beforeEach was called twice without a single afterEach! Are you running tests concurrently? This is not supported by withContext.");
}
if (this._withStorage.getStore()) {
throw new HexclaveAssertionError("Did you wrap an entire test into Context.with(...)?");
}
this._reduced = this._getInitialValue();
this._isInTest = true;
if (this._yetToReduce.size > 0) {
throw new HexclaveAssertionError("Something went wrong; _yetToReduce should be empty here.");
}
});
afterEach(async () => {
if (this._withStorage.getStore()) {
throw new HexclaveAssertionError("Test finished before _withStorage was cleaned up! This should not happen.");
}
this._isInTest = false;
this._reduced = undefined;
for (const key of this._deleteOnFinish) {
this._yetToReduce.delete(key);
}
if (this._yetToReduce.size > 0) {
throw new HexclaveAssertionError("Something went wrong; _yetToReduce should be empty here.");
}
});
}
async with<X>(value: T, callback: () => Promise<X>) {
const oldWithStorage = this._withStorage.getStore() ?? [];
return await this._withStorage.run([...oldWithStorage, value], async () => {
return await callback();
});
}
set(value: T) {
const randomId = generateSecureRandomString();
this._values.set(randomId, value);
const before = () => {
if (this._yetToReduce.has(randomId)) {
throw new HexclaveAssertionError("Value setter was called twice without a single afterEach! Are you running tests concurrently? This is not supported by withContext.");
}
this._yetToReduce.add(randomId);
};
if (this._isInTest) {
before();
this._deleteOnFinish.add(randomId);
} else {
beforeEach(async () => {
before();
});
afterEach(() => {
this._yetToReduce.delete(randomId);
});
}
}
get value(): R {
this._reduce();
const _withStore = this._withStorage.getStore() ?? [];
return _withStore.reduce((acc, val) => this._reducer(acc, val), this._reduced as R);
}
private _reduce() {
if (!this._isInTest) {
throw new HexclaveAssertionError("You can only call this function on Context inside a test.");
}
const yetToReduceOrdered = [...this._values.entries()].filter(([key]) => this._yetToReduce.has(key)).map(([, value]) => value);
for (const value of yetToReduceOrdered) {
this._reduced = this._reducer(this._reduced as R, value);
}
this._yetToReduce = new Set();
}
}
export function updateCookie(cookieString: string, cookieName: string, cookieValue: string) {
const cookies = cookieString.split(";").map((cookie) => cookie.trim()).filter((cookie) => cookie.length > 0);
const newCookie = `${cookieName}=${cookieValue}`;
const cookieIndex = cookies.findIndex((cookie) => cookie.startsWith(`${cookieName}=`));
if (cookieIndex === -1) {
return `${cookieString}; ${newCookie}`;
}
cookies[cookieIndex] = newCookie;
return cookies.join("; ");
}
export function updateCookiesFromResponse(cookieString: string, update: NiceResponse) {
const setCookies = update.headers.getSetCookie();
for (const setCookie of setCookies) {
const [cookieName, cookieValue] = setCookie.split(";")[0].split("=");
cookieString = updateCookie(cookieString, cookieName, cookieValue);
}
return cookieString;
}
export class NiceResponse implements Nicifiable {
constructor(
public readonly status: number,
public readonly headers: Headers,
public readonly body: any,
public readonly fromRequestInit?: NiceRequestInit,
) { }
getNicifiableKeys(): string[] {
// reorder the keys for nicer printing
return [
"status",
...this.body instanceof ArrayBuffer && this.body.byteLength === 0 ? [] : ["body"],
"headers",
];
}
async follow(options?: NiceRequestInit) {
if (![301, 302, 303, 307, 308].includes(this.status)) {
throw new HexclaveAssertionError(`Cannot follow non-redirect response: ${this.status}`);
}
const location = this.headers.get("Location");
if (!location) {
throw new HexclaveAssertionError(`Redirect response has no Location header: ${this.status}`);
}
const followRes = await niceFetch(location, {
...[301, 302, 303].includes(this.status) ? { method: "GET" } : {
body: this.fromRequestInit?.body,
method: this.fromRequestInit?.method,
headers: this.fromRequestInit?.headers,
},
...options,
});
return followRes;
}
};
export type NiceRequestInit = RequestInit & {
query?: Record<string, string>,
};
export async function niceFetch(url: string | URL, options?: NiceRequestInit): Promise<NiceResponse> {
if (options?.query) {
url = new URL(url);
for (const [key, value] of Object.entries(options.query)) {
url.searchParams.append(key, value);
}
}
const fetchRes = await fetch(url, {
...options,
headers: {
"x-stack-disable-artificial-development-delay": "yes",
"x-stack-development-disable-extended-logging": "yes",
...filterUndefined(options?.headers ?? {}),
},
});
let body;
if (fetchRes.headers.get("content-type")?.includes("application/json")) {
body = await fetchRes.json();
} else if (fetchRes.headers.get("content-type")?.startsWith("text/")) {
body = await fetchRes.text();
} else {
body = await fetchRes.arrayBuffer();
}
return new NiceResponse(fetchRes.status, fetchRes.headers, body, options);
}
export const localRedirectUrl = "http://stack-test.localhost/some-callback-url";
export const localRedirectUrlRegex = /http:\/\/stack-test\.localhost\/some-callback-url([?#][A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=]*)?/g;
export const generatedEmailSuffix = "@stack-generated.example.com";
export const generatedEmailRegex = /[a-zA-Z0-9_.+\-]+@stack-generated\.example\.com/;
export class Mailbox {
public readonly fetchMessages: (options?: { noBody?: boolean }) => Promise<MailboxMessage[]>;
public readonly waitForMessagesWithSubject: (subject: string, options?: { noBody?: boolean }) => Promise<MailboxMessage[]>;
public readonly waitForMessagesWithSubjectCount: (subject: string, minCount: number, options?: { noBody?: boolean }) => Promise<MailboxMessage[]>;
constructor(
disclaimer: "USE_CREATE_MAILBOX_FUNCTION_INSTEAD",
public readonly emailAddress: string,
) {
const mailboxName = emailAddress.split("@")[0];
const fullMessageCache = new Map<string, any>();
this.fetchMessages = async ({ noBody } = {}) => {
const res = await niceFetch(new URL(`/api/v1/mailbox/${encodeURIComponent(mailboxName)}`, STACK_INBUCKET_API_URL));
return await Promise.all((res.body as any[]).map(async (message) => {
let fullMessage: any;
if (fullMessageCache.has(message.id)) {
fullMessage = fullMessageCache.get(message.id);
} else {
const fullMessageRes = await niceFetch(new URL(`/api/v1/mailbox/${encodeURIComponent(mailboxName)}/${message.id}`, STACK_INBUCKET_API_URL));
fullMessage = fullMessageRes.body;
fullMessageCache.set(message.id, fullMessage);
}
const messagePart = noBody ? omit(fullMessage, ["body", "attachments"]) : fullMessage;
return new MailboxMessage(messagePart);
}));
};
this.waitForMessagesWithSubject = async (subject: string, options?: { noBody?: boolean }) => {
return await this.waitForMessagesWithSubjectCount(subject, 1, options);
};
this.waitForMessagesWithSubjectCount = async (subject: string, minCount: number, options?: { noBody?: boolean }) => {
const timeoutMs = Number(process.env.STACK_MAILBOX_WAIT_TIMEOUT_MS ?? 60000);
const intervalMs = 500;
const deadline = Date.now() + timeoutMs;
let messages: MailboxMessage[] = [];
while (true) {
messages = await this.fetchMessages(options);
const withSubject = messages.filter(m => m.subject.includes(subject));
if (withSubject.length >= minCount) {
return withSubject;
}
if (Date.now() >= deadline) break;
await wait(intervalMs);
}
throw new HexclaveAssertionError(`Expected at least ${minCount} messages with subject containing "${subject}", but found ${messages.filter(m => m.subject.includes(subject)).length}`, { messages });
};
}
}
export class MailboxMessage {
declare public readonly subject: string;
declare public readonly from: string;
declare public readonly to: string;
declare public readonly date: string;
declare public readonly id: string;
declare public readonly size: number;
declare public readonly seen: boolean;
declare public readonly "posix-millis": number;
declare public readonly header?: any;
declare public readonly body?: { text: string, html: string };
declare public readonly attachments?: any[];
constructor(json: any) {
Object.assign(this, json);
}
getSnapshotSerializerOptions() {
return ({
stripFields: [],
hideFields: [
"posix-millis",
"header",
"date",
"mailbox",
"id",
"size",
"seen",
],
});
};
}
function expandStackPortPrefix(value?: string | null) {
if (!value) return value ?? undefined;
const prefix = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81");
return prefix ? value.replace(/\$\{NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81\}/g, prefix) : value;
}
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("STACK_") || key.startsWith("NEXT_PUBLIC_STACK_")) {
const replaced = expandStackPortPrefix(value ?? undefined);
if (replaced !== undefined) {
// eslint-disable-next-line no-restricted-syntax
process.env[key] = replaced;
}
}
}
export const STACK_DASHBOARD_BASE_URL = getEnvVariable("STACK_DASHBOARD_BASE_URL");
export const STACK_BACKEND_BASE_URL = getEnvVariable("STACK_BACKEND_BASE_URL");
export const STACK_MCP_BASE_URL = getEnvVariable("STACK_MCP_BASE_URL");
/**
* The `baseUrl` to pass to SDK constructors (`StackClientApp`, `StackServerApp`,
* `StackAdminApp`) in JS-SDK e2e tests.
*
* Normally this is `STACK_BACKEND_BASE_URL` (single, explicit URL).
*
* In the e2e-fallback-tests workflow (`STACK_TEST_SDK_FALLBACK=true`) we leave
* this `undefined` so the SDK resolves the base URL from
* `NEXT_PUBLIC_STACK_API_URL` *and* appends its hardcoded fallback URL list,
* which is what the workflow exercises by running the backend only on the
* fallback port. Always thread this through to SDK constructors instead of
* hardcoding `STACK_BACKEND_BASE_URL`.
*/
export const SDK_BASE_URL: string | undefined = process.env.STACK_TEST_SDK_FALLBACK
? undefined
: STACK_BACKEND_BASE_URL;
export const STACK_INTERNAL_PROJECT_ID = getEnvVariable("STACK_INTERNAL_PROJECT_ID");
export const STACK_INTERNAL_PROJECT_CLIENT_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_CLIENT_KEY");
export const STACK_INTERNAL_PROJECT_SERVER_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_SERVER_KEY");
export const STACK_INTERNAL_PROJECT_ADMIN_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_ADMIN_KEY");
export const STACK_INBUCKET_API_URL = getEnvVariable("STACK_INBUCKET_API_URL");
export const STACK_SVIX_SERVER_URL = getEnvVariable("STACK_SVIX_SERVER_URL");