stack/apps/e2e/tests/backend/endpoints/api/migration-tests.test.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
14 KiB
TypeScript

import { describe } from "vitest";
import { it } from "../../../helpers";
import { niceBackendFetch } from "../../backend-helpers";
/*
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
*/
describe("SmartRouteHandler", () => {
describe("v1", () => {
it("should return a 404 error", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/migration-tests/smart-route-handler");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": deindent\`
404 — this page does not exist in Stack Auth's API.
Please see the API documentation at https://docs.stack-auth.com, or visit the Stack Auth dashboard at https://app.stack-auth.com.
URL: http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/migration-tests/smart-route-handler
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
});
});
describe("v2beta1", () => {
it("should return 200 without queryParam", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta1/migration-tests/smart-route-handler");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": deindent\`
Welcome to the migration test route for SmartRouteHandler! This route only exists for demonstration purposes and has no practical functionality.
The query parameter you passed in is: n/a
Here's what it does:
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
expect(response.headers.get("x-middleware-rewrite")).toBe(`/api/migrations/v2beta2/migration-tests/smart-route-handler`);
});
it("should return 200 with queryParam", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta1/migration-tests/smart-route-handler?queryParam=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": deindent\`
Welcome to the migration test route for SmartRouteHandler! This route only exists for demonstration purposes and has no practical functionality.
The query parameter you passed in is: 123
Here's what it does:
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should fail with queryParamNew", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta1/migration-tests/smart-route-handler?queryParamNew=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on GET /api/v2beta1/migration-tests/smart-route-handler:
- query contains unknown properties: queryParamNew
\`,
},
"error": deindent\`
Request validation failed on GET /api/v2beta1/migration-tests/smart-route-handler:
- query contains unknown properties: queryParamNew
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
});
describe("v2beta2", () => {
it("should return 200 with queryParam", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta2/migration-tests/smart-route-handler?queryParam=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": deindent\`
Welcome to the migration test route for SmartRouteHandler! This route only exists for demonstration purposes and has no practical functionality.
The query parameter you passed in is: 123
Here's what it does:
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should fail without queryParam", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta2/migration-tests/smart-route-handler");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on GET /api/v2beta2/migration-tests/smart-route-handler:
- query.queryParam must be defined
\`,
},
"error": deindent\`
Request validation failed on GET /api/v2beta2/migration-tests/smart-route-handler:
- query.queryParam must be defined
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("should fail with queryParamNew", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta2/migration-tests/smart-route-handler?queryParam=123&queryParamNew=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on GET /api/v2beta2/migration-tests/smart-route-handler:
- query contains unknown properties: queryParamNew
\`,
},
"error": deindent\`
Request validation failed on GET /api/v2beta2/migration-tests/smart-route-handler:
- query contains unknown properties: queryParamNew
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
});
describe("v2beta3", () => {
it("should return 200 with queryParamNew", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta3/migration-tests/smart-route-handler?queryParamNew=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": deindent\`
Welcome to the migration test route for SmartRouteHandler! This route only exists for demonstration purposes and has no practical functionality.
The query parameter you passed in is: 123
Here's what it does:
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should fail without queryParamNew", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta3/migration-tests/smart-route-handler");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on GET /api/v2beta3/migration-tests/smart-route-handler:
- query.queryParamNew must be defined
\`,
},
"error": deindent\`
Request validation failed on GET /api/v2beta3/migration-tests/smart-route-handler:
- query.queryParamNew must be defined
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("should fail with queryParam", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta3/migration-tests/smart-route-handler?queryParam=123&queryParamNew=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on GET /api/v2beta3/migration-tests/smart-route-handler:
- query contains unknown properties: queryParam
\`,
},
"error": deindent\`
Request validation failed on GET /api/v2beta3/migration-tests/smart-route-handler:
- query contains unknown properties: queryParam
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
});
describe("v2beta4", () => {
it("should return 200 with queryParamNew", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta4/migration-tests/smart-route-handler?queryParamNew=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": deindent\`
Welcome to the migration test route for SmartRouteHandler! This route only exists for demonstration purposes and has no practical functionality.
The query parameter you passed in is: 123
Here's what it does:
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 200 without queryParamNew", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta4/migration-tests/smart-route-handler");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": deindent\`
Welcome to the migration test route for SmartRouteHandler! This route only exists for demonstration purposes and has no practical functionality.
Looks like you didn't pass in the query parameter. That's fine, read on below to see what this route does.
Here's what it does:
- v1: This route does not yet exist; it shows a 404 error.
- v2beta1: Takes an optional query parameter 'queryParam' and displays it. If not given, it defaults to the string "n/a".
- v2beta2: The query parameter is now required.
- v2beta3: The query parameter is now called 'queryParamNew'.
- v2beta4: The query parameter is now optional again (this is not actually a breaking change, so in a real scenario we wouldn't need a new version).
\`,
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should fail with queryParam", async ({ expect }) => {
const response = await niceBackendFetch("/api/v2beta4/migration-tests/smart-route-handler?queryParam=123");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on GET /api/v2beta4/migration-tests/smart-route-handler:
- query contains unknown properties: queryParam
\`,
},
"error": deindent\`
Request validation failed on GET /api/v2beta4/migration-tests/smart-route-handler:
- query contains unknown properties: queryParam
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
});
});