@elytracms/studio 0.0.53 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/components/app-views/default-modules.d.ts +16 -2
  2. package/dist/components/app-views/default-modules.js +22 -5
  3. package/dist/components/app-views/default-modules.js.map +1 -1
  4. package/dist/components/sign-in-view.d.ts +41 -0
  5. package/dist/components/sign-in-view.js +75 -0
  6. package/dist/components/sign-in-view.js.map +1 -0
  7. package/dist/lib/ai/convex-session-headers.d.ts +68 -0
  8. package/dist/lib/ai/convex-session-headers.js +79 -0
  9. package/dist/lib/ai/convex-session-headers.js.map +1 -0
  10. package/dist/lib/ai/convex-session.d.ts +82 -0
  11. package/dist/lib/ai/convex-session.js +92 -0
  12. package/dist/lib/ai/convex-session.js.map +1 -0
  13. package/dist/lib/ai/index.d.ts +12 -1
  14. package/dist/lib/ai/index.js +12 -1
  15. package/dist/lib/ai/index.js.map +1 -1
  16. package/dist/lib/ai/node-handler.d.ts +29 -0
  17. package/dist/lib/ai/node-handler.js +48 -0
  18. package/dist/lib/ai/node-handler.js.map +1 -0
  19. package/dist/lib/ai/vite.d.ts +32 -0
  20. package/dist/lib/ai/vite.js +97 -0
  21. package/dist/lib/ai/vite.js.map +1 -0
  22. package/dist/lib/persistence/backend-mode.d.ts +53 -1
  23. package/dist/lib/persistence/backend-mode.js +141 -10
  24. package/dist/lib/persistence/backend-mode.js.map +1 -1
  25. package/dist/lib/persistence/index.d.ts +2 -2
  26. package/dist/lib/persistence/index.js +1 -1
  27. package/dist/lib/persistence/index.js.map +1 -1
  28. package/dist/lib/webhooks/config.js +6 -5
  29. package/dist/lib/webhooks/config.js.map +1 -1
  30. package/dist/lib/workspace/index.d.ts +24 -2
  31. package/dist/lib/workspace/index.js +24 -17
  32. package/dist/lib/workspace/index.js.map +1 -1
  33. package/dist/router/root.d.ts +50 -0
  34. package/dist/router/root.js +69 -0
  35. package/dist/router/root.js.map +1 -0
  36. package/package.json +17 -5
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `createConvexSessionHeaders` — the CLIENT half of the engine-default AI session
3
+ * gate (EC-371). It supplies the `headers` thunk `createRouteProvider` re-evaluates
4
+ * per request, attaching the signed-in user's Convex Auth JWT as
5
+ * `authorization: Bearer <token>` so the route's {@link createConvexJwtSessionResolver}
6
+ * can verify it against the deployment's JWKS.
7
+ *
8
+ * Two storage conventions, owned HERE and nowhere else:
9
+ *
10
+ * 1. **The engine's own session store (primary).** The studio's `ConvexAuthAdapter`
11
+ * (`lib/auth`) persists the JWT in `sessionStorage` under
12
+ * `elytra.convex-auth.jwt` / `elytra.convex-auth.jwt::<deployment-url>`
13
+ * (per-backend sessions, EC-179). This covers every engine studio sign-in,
14
+ * including the dev-login path. The prefix is pinned against `tokenStorageKey()`
15
+ * by a drift test in `convex-session-headers.test.ts` — if `lib/auth` ever
16
+ * renames it, that test fails loudly.
17
+ * 2. **The `@convex-dev/auth` react-client store (fallback).** Hosts that mount the
18
+ * official `ConvexAuthProvider` get the JWT persisted in `localStorage` under a
19
+ * key starting with `__convexAuthJWT` — an INTERNAL convention of that package,
20
+ * not a public API. If a future upgrade renames it, the token is simply not
21
+ * found and the assistant fails CLOSED (401) — never open.
22
+ *
23
+ * A playground/local studio has no Convex session, so the thunk returns `undefined`
24
+ * (no auth header); the route then rejects unless the loud dev-only opt-in on
25
+ * `createAiDevPlugin` is active. Browser-only reads; SSR yields no header.
26
+ *
27
+ * Note on multiple per-backend sessions: the scan returns the first token found;
28
+ * a token minted by a DIFFERENT deployment fails the route's issuer/JWKS check and
29
+ * yields 401 (fail-closed), never a cross-deployment pass.
30
+ */
31
+ /**
32
+ * The engine `ConvexAuthAdapter` sessionStorage key prefix (`lib/auth`,
33
+ * `tokenStorageKey()`), duplicated here so `lib/ai` carries no runtime edge into
34
+ * the auth adapter (which pulls the Convex client). Drift-pinned by test.
35
+ */
36
+ export const ELYTRA_CONVEX_JWT_STORAGE_PREFIX = 'elytra.convex-auth.jwt';
37
+ /** The `@convex-dev/auth`-internal localStorage key prefix carrying the fresh JWT. */
38
+ export const CONVEX_AUTH_JWT_STORAGE_PREFIX = '__convexAuthJWT';
39
+ /** First non-empty value under a key starting with `prefix`; `null` when absent. */
40
+ export function readTokenByPrefix(storage, prefix) {
41
+ if (!storage)
42
+ return null;
43
+ for (let i = 0; i < storage.length; i += 1) {
44
+ const key = storage.key(i);
45
+ if (!key?.startsWith(prefix))
46
+ continue;
47
+ const token = storage.getItem(key);
48
+ if (token)
49
+ return token;
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Read the current Convex Auth JWT: the engine's sessionStorage convention first,
55
+ * then the `@convex-dev/auth` localStorage convention. Pure over the injected
56
+ * storages; `null` when signed out (or outside a browser).
57
+ */
58
+ export function readConvexAuthToken(storages) {
59
+ return (readTokenByPrefix(storages.sessionStorage, ELYTRA_CONVEX_JWT_STORAGE_PREFIX) ??
60
+ readTokenByPrefix(storages.localStorage, CONVEX_AUTH_JWT_STORAGE_PREFIX));
61
+ }
62
+ function browserStorages() {
63
+ if (typeof window === 'undefined')
64
+ return {};
65
+ return { sessionStorage: window.sessionStorage, localStorage: window.localStorage };
66
+ }
67
+ /**
68
+ * Build the `headers` thunk for `createRouteProvider({ headers })`. Re-evaluated on
69
+ * every request, so a refreshed token is picked up automatically; `undefined` (no
70
+ * header) when no session token exists.
71
+ */
72
+ export function createConvexSessionHeaders(options = {}) {
73
+ const storages = options.storages ?? browserStorages;
74
+ return () => {
75
+ const token = readConvexAuthToken(storages());
76
+ return token ? { authorization: `Bearer ${token}` } : undefined;
77
+ };
78
+ }
79
+ //# sourceMappingURL=convex-session-headers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"convex-session-headers.js","sourceRoot":"","sources":["../../../src/lib/ai/convex-session-headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,gCAAgC,GAAG,wBAAwB,CAAA;AAExE,sFAAsF;AACtF,MAAM,CAAC,MAAM,8BAA8B,GAAG,iBAAiB,CAAA;AAS/D,oFAAoF;AACpF,MAAM,UAAU,iBAAiB,CAC/B,OAA4C,EAC5C,MAAc;IAEd,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAA;IACzB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;QAC1B,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,MAAM,CAAC;YAAE,SAAQ;QACtC,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAClC,IAAI,KAAK;YAAE,OAAO,KAAK,CAAA;IACzB,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AASD;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,QAA+B;IACjE,OAAO,CACL,iBAAiB,CAAC,QAAQ,CAAC,cAAc,EAAE,gCAAgC,CAAC;QAC5E,iBAAiB,CAAC,QAAQ,CAAC,YAAY,EAAE,8BAA8B,CAAC,CACzE,CAAA;AACH,CAAC;AAOD,SAAS,eAAe;IACtB,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,EAAE,CAAA;IAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,CAAC,cAAc,EAAE,YAAY,EAAE,MAAM,CAAC,YAAY,EAAE,CAAA;AACrF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CACxC,UAAuC,EAAE;IAEzC,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,eAAe,CAAA;IACpD,OAAO,GAAG,EAAE;QACV,MAAM,KAAK,GAAG,mBAAmB,CAAC,QAAQ,EAAE,CAAC,CAAA;QAC7C,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;IACjE,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * `createConvexSessionHeaders` — the CLIENT half of the engine-default AI session\n * gate (EC-371). It supplies the `headers` thunk `createRouteProvider` re-evaluates\n * per request, attaching the signed-in user's Convex Auth JWT as\n * `authorization: Bearer <token>` so the route's {@link createConvexJwtSessionResolver}\n * can verify it against the deployment's JWKS.\n *\n * Two storage conventions, owned HERE and nowhere else:\n *\n * 1. **The engine's own session store (primary).** The studio's `ConvexAuthAdapter`\n * (`lib/auth`) persists the JWT in `sessionStorage` under\n * `elytra.convex-auth.jwt` / `elytra.convex-auth.jwt::<deployment-url>`\n * (per-backend sessions, EC-179). This covers every engine studio sign-in,\n * including the dev-login path. The prefix is pinned against `tokenStorageKey()`\n * by a drift test in `convex-session-headers.test.ts` — if `lib/auth` ever\n * renames it, that test fails loudly.\n * 2. **The `@convex-dev/auth` react-client store (fallback).** Hosts that mount the\n * official `ConvexAuthProvider` get the JWT persisted in `localStorage` under a\n * key starting with `__convexAuthJWT` — an INTERNAL convention of that package,\n * not a public API. If a future upgrade renames it, the token is simply not\n * found and the assistant fails CLOSED (401) — never open.\n *\n * A playground/local studio has no Convex session, so the thunk returns `undefined`\n * (no auth header); the route then rejects unless the loud dev-only opt-in on\n * `createAiDevPlugin` is active. Browser-only reads; SSR yields no header.\n *\n * Note on multiple per-backend sessions: the scan returns the first token found;\n * a token minted by a DIFFERENT deployment fails the route's issuer/JWKS check and\n * yields 401 (fail-closed), never a cross-deployment pass.\n */\n\n/**\n * The engine `ConvexAuthAdapter` sessionStorage key prefix (`lib/auth`,\n * `tokenStorageKey()`), duplicated here so `lib/ai` carries no runtime edge into\n * the auth adapter (which pulls the Convex client). Drift-pinned by test.\n */\nexport const ELYTRA_CONVEX_JWT_STORAGE_PREFIX = 'elytra.convex-auth.jwt'\n\n/** The `@convex-dev/auth`-internal localStorage key prefix carrying the fresh JWT. */\nexport const CONVEX_AUTH_JWT_STORAGE_PREFIX = '__convexAuthJWT'\n\n/** The read-only subset of DOM `Storage` the token scan needs (test seam). */\nexport interface TokenStorageLike {\n readonly length: number\n key(index: number): string | null\n getItem(key: string): string | null\n}\n\n/** First non-empty value under a key starting with `prefix`; `null` when absent. */\nexport function readTokenByPrefix(\n storage: TokenStorageLike | null | undefined,\n prefix: string,\n): string | null {\n if (!storage) return null\n for (let i = 0; i < storage.length; i += 1) {\n const key = storage.key(i)\n if (!key?.startsWith(prefix)) continue\n const token = storage.getItem(key)\n if (token) return token\n }\n return null\n}\n\nexport interface ConvexSessionStorages {\n /** The engine adapter's store (default: `window.sessionStorage`). */\n sessionStorage?: TokenStorageLike | null\n /** The @convex-dev/auth react-client store (default: `window.localStorage`). */\n localStorage?: TokenStorageLike | null\n}\n\n/**\n * Read the current Convex Auth JWT: the engine's sessionStorage convention first,\n * then the `@convex-dev/auth` localStorage convention. Pure over the injected\n * storages; `null` when signed out (or outside a browser).\n */\nexport function readConvexAuthToken(storages: ConvexSessionStorages): string | null {\n return (\n readTokenByPrefix(storages.sessionStorage, ELYTRA_CONVEX_JWT_STORAGE_PREFIX) ??\n readTokenByPrefix(storages.localStorage, CONVEX_AUTH_JWT_STORAGE_PREFIX)\n )\n}\n\nexport interface ConvexSessionHeadersOptions {\n /** Storage accessor override (default: the browser's session/local storages). */\n storages?: () => ConvexSessionStorages\n}\n\nfunction browserStorages(): ConvexSessionStorages {\n if (typeof window === 'undefined') return {}\n return { sessionStorage: window.sessionStorage, localStorage: window.localStorage }\n}\n\n/**\n * Build the `headers` thunk for `createRouteProvider({ headers })`. Re-evaluated on\n * every request, so a refreshed token is picked up automatically; `undefined` (no\n * header) when no session token exists.\n */\nexport function createConvexSessionHeaders(\n options: ConvexSessionHeadersOptions = {},\n): () => HeadersInit | undefined {\n const storages = options.storages ?? browserStorages\n return () => {\n const token = readConvexAuthToken(storages())\n return token ? { authorization: `Bearer ${token}` } : undefined\n }\n}\n"]}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `createConvexJwtSessionResolver` — the engine-default `getSession` for
3
+ * {@link createAiRouteHandler} (EC-371; closes EC-369 finding #1, where the template
4
+ * shipped an auth-shaped no-op that accepted any caller with a non-empty marker
5
+ * header).
6
+ *
7
+ * REAL verification, fail-closed: the studio client attaches the signed-in user's
8
+ * Convex Auth JWT (`authorization: Bearer …`, see `createConvexSessionHeaders`), and
9
+ * this resolver verifies signature / issuer / audience / expiry against the Convex
10
+ * deployment's public JWKS (`<site-url>/.well-known/jwks.json`). Only a signed-in
11
+ * studio user of THIS deployment can spend the route's provider key. No token,
12
+ * an invalid token, or missing site-URL env → `null` → the route answers 401.
13
+ *
14
+ * The deployment's site URL (JWT issuer + JWKS host) resolves from, in order:
15
+ * the explicit `siteUrl` option, `VITE_CONVEX_SITE_URL`, or `VITE_CONVEX_URL`
16
+ * (`….convex.cloud` mapped to `….convex.site`). In a Vite-dev mount these are
17
+ * surfaced to `process.env` by {@link createAiDevPlugin}'s `loadEnv` bridge.
18
+ *
19
+ * Dev-only anonymous escape hatch (playground DX): a playground/local studio has no
20
+ * Convex session at all, so the assistant panel would 401 there. Anonymous requests
21
+ * (NO credential presented) may pass ONLY when the loud
22
+ * {@link INSECURE_ANONYMOUS_SESSION_ENV_VAR} handshake is active — set exclusively by
23
+ * `createAiDevPlugin`'s explicit `insecureAllowAnonymousSessions` opt-in under
24
+ * `vite dev` (the plugin is `apply: 'serve'`, so no production build ever carries it)
25
+ * AND additionally gated on `NODE_ENV !== 'production'`. A PRESENTED token must
26
+ * always verify for real — the bypass never masks an invalid credential.
27
+ *
28
+ * `jose` is loaded via dynamic `import()` so this module can sit in the barrel
29
+ * without putting the JWT library on the studio client's critical path (the same
30
+ * discipline `anthropic-provider` applies to the AI SDK; pinned in `bundle.test.ts`).
31
+ */
32
+ import type { JWTVerifyGetKey } from 'jose';
33
+ import type { AiSession } from './types.js';
34
+ /** A `getSession` implementation for {@link AiRouteHandlerOptions.getSession}. */
35
+ export type AiSessionResolver = (request: Request) => Promise<AiSession | null>;
36
+ /**
37
+ * The dev-bypass handshake env var. Set ONLY by `createAiDevPlugin` (vite dev) when
38
+ * the host explicitly opts in via `insecureAllowAnonymousSessions`. Never set this
39
+ * on a deployment: the name says exactly what it does.
40
+ */
41
+ export declare const INSECURE_ANONYMOUS_SESSION_ENV_VAR = "ELYTRA_AI_INSECURE_ALLOW_ANONYMOUS";
42
+ /** The exact value the handshake requires (anything else is ignored, fail-closed). */
43
+ export declare const INSECURE_ANONYMOUS_SESSION_ENV_VALUE = "dev-only";
44
+ /** The synthetic principal id an insecure dev-anonymous session carries. */
45
+ export declare const INSECURE_ANONYMOUS_USER_ID = "insecure-anonymous-dev";
46
+ /** An env bag (defaults to `process.env`, read per-request). */
47
+ export type EnvSource = Record<string, string | undefined>;
48
+ export interface ConvexJwtSessionResolverOptions {
49
+ /**
50
+ * The Convex deployment's SITE url — the JWT issuer and JWKS host (e.g.
51
+ * `https://your-deployment-123.convex.site`). Default: resolved from env via
52
+ * {@link resolveConvexSiteUrl}. Missing everywhere ⇒ fail closed (nobody passes).
53
+ */
54
+ siteUrl?: string;
55
+ /** Expected JWT audience (default `convex` — what Convex Auth mints). */
56
+ audience?: string;
57
+ /** Env source override (default `process.env`, read per-request). */
58
+ env?: EnvSource;
59
+ /**
60
+ * Key source override — primarily a test seam (a `createLocalJWKSet` over a
61
+ * locally generated pair). Default: the deployment's remote JWKS.
62
+ */
63
+ getKey?: JWTVerifyGetKey;
64
+ }
65
+ /**
66
+ * Resolve the Convex SITE url (JWT issuer + JWKS host) from env:
67
+ * `VITE_CONVEX_SITE_URL`, else derived from `VITE_CONVEX_URL`
68
+ * (`….convex.cloud` → `….convex.site`), else `null` (fail closed).
69
+ */
70
+ export declare function resolveConvexSiteUrl(env: EnvSource): string | null;
71
+ /**
72
+ * True when the dev-only anonymous handshake is active: the loud env var carries its
73
+ * exact value AND the process is not a production one. Both conditions are required —
74
+ * setting the var on a production deployment does nothing.
75
+ */
76
+ export declare function insecureAnonymousSessionActive(env: EnvSource): boolean;
77
+ /**
78
+ * Build the engine-default `getSession`: verify the caller's Convex Auth Bearer JWT
79
+ * against the deployment's public JWKS; every failure resolves `null` (→ 401), never
80
+ * throws. See the module doc for the full contract.
81
+ */
82
+ export declare function createConvexJwtSessionResolver(options?: ConvexJwtSessionResolverOptions): AiSessionResolver;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The dev-bypass handshake env var. Set ONLY by `createAiDevPlugin` (vite dev) when
3
+ * the host explicitly opts in via `insecureAllowAnonymousSessions`. Never set this
4
+ * on a deployment: the name says exactly what it does.
5
+ */
6
+ export const INSECURE_ANONYMOUS_SESSION_ENV_VAR = 'ELYTRA_AI_INSECURE_ALLOW_ANONYMOUS';
7
+ /** The exact value the handshake requires (anything else is ignored, fail-closed). */
8
+ export const INSECURE_ANONYMOUS_SESSION_ENV_VALUE = 'dev-only';
9
+ /** The synthetic principal id an insecure dev-anonymous session carries. */
10
+ export const INSECURE_ANONYMOUS_USER_ID = 'insecure-anonymous-dev';
11
+ /**
12
+ * Resolve the Convex SITE url (JWT issuer + JWKS host) from env:
13
+ * `VITE_CONVEX_SITE_URL`, else derived from `VITE_CONVEX_URL`
14
+ * (`….convex.cloud` → `….convex.site`), else `null` (fail closed).
15
+ */
16
+ export function resolveConvexSiteUrl(env) {
17
+ const site = env.VITE_CONVEX_SITE_URL?.trim();
18
+ if (site)
19
+ return site.replace(/\/$/, '');
20
+ const cloud = env.VITE_CONVEX_URL?.trim();
21
+ if (cloud)
22
+ return cloud.replace(/\/$/, '').replace('.convex.cloud', '.convex.site');
23
+ return null;
24
+ }
25
+ /**
26
+ * True when the dev-only anonymous handshake is active: the loud env var carries its
27
+ * exact value AND the process is not a production one. Both conditions are required —
28
+ * setting the var on a production deployment does nothing.
29
+ */
30
+ export function insecureAnonymousSessionActive(env) {
31
+ return (env[INSECURE_ANONYMOUS_SESSION_ENV_VAR] === INSECURE_ANONYMOUS_SESSION_ENV_VALUE &&
32
+ env.NODE_ENV !== 'production');
33
+ }
34
+ function processEnv() {
35
+ return typeof process !== 'undefined' ? process.env : {};
36
+ }
37
+ let josePromise = null;
38
+ function loadJose() {
39
+ return (josePromise ??= import('jose'));
40
+ }
41
+ let warnedInsecureAnonymous = false;
42
+ function insecureAnonymousSession() {
43
+ if (!warnedInsecureAnonymous) {
44
+ warnedInsecureAnonymous = true;
45
+ console.warn(`[elytra-ai] INSECURE anonymous AI session bypass is ACTIVE (${INSECURE_ANONYMOUS_SESSION_ENV_VAR}). ` +
46
+ 'This is a dev-only playground convenience — if you ever see this on a deployment, remove the env var immediately.');
47
+ }
48
+ return { userId: INSECURE_ANONYMOUS_USER_ID };
49
+ }
50
+ /**
51
+ * Build the engine-default `getSession`: verify the caller's Convex Auth Bearer JWT
52
+ * against the deployment's public JWKS; every failure resolves `null` (→ 401), never
53
+ * throws. See the module doc for the full contract.
54
+ */
55
+ export function createConvexJwtSessionResolver(options = {}) {
56
+ const audience = options.audience ?? 'convex';
57
+ // Remote JWKS getters are cached per site URL (jose caches the fetched keys inside).
58
+ const remoteKeys = new Map();
59
+ return async (request) => {
60
+ const env = options.env ?? processEnv();
61
+ const authorization = request.headers.get('authorization');
62
+ const token = authorization?.startsWith('Bearer ')
63
+ ? authorization.slice('Bearer '.length)
64
+ : null;
65
+ if (!token) {
66
+ // No credential presented. The ONLY way this passes is the loud, dev-only
67
+ // anonymous opt-in (set by `createAiDevPlugin` under `vite dev`).
68
+ return insecureAnonymousSessionActive(env) ? insecureAnonymousSession() : null;
69
+ }
70
+ // A presented token must ALWAYS verify for real — the dev bypass never masks
71
+ // an invalid credential.
72
+ const siteUrl = options.siteUrl?.replace(/\/$/, '') ?? resolveConvexSiteUrl(env);
73
+ if (!siteUrl)
74
+ return null; // fail closed: gate env missing → nobody passes
75
+ try {
76
+ const jose = await loadJose();
77
+ let getKey = options.getKey ?? remoteKeys.get(siteUrl);
78
+ if (!getKey) {
79
+ getKey = jose.createRemoteJWKSet(new URL('/.well-known/jwks.json', siteUrl));
80
+ remoteKeys.set(siteUrl, getKey);
81
+ }
82
+ const { payload } = await jose.jwtVerify(token, getKey, { issuer: siteUrl, audience });
83
+ return typeof payload.sub === 'string' && payload.sub.length > 0
84
+ ? { userId: payload.sub }
85
+ : null;
86
+ }
87
+ catch {
88
+ return null; // expired / bad signature / wrong issuer, audience, or deployment → 401
89
+ }
90
+ };
91
+ }
92
+ //# sourceMappingURL=convex-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"convex-session.js","sourceRoot":"","sources":["../../../src/lib/ai/convex-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `createConvexJwtSessionResolver` — the engine-default `getSession` for\n * {@link createAiRouteHandler} (EC-371; closes EC-369 finding #1, where the template\n * shipped an auth-shaped no-op that accepted any caller with a non-empty marker\n * header).\n *\n * REAL verification, fail-closed: the studio client attaches the signed-in user's\n * Convex Auth JWT (`authorization: Bearer …`, see `createConvexSessionHeaders`), and\n * this resolver verifies signature / issuer / audience / expiry against the Convex\n * deployment's public JWKS (`<site-url>/.well-known/jwks.json`). Only a signed-in\n * studio user of THIS deployment can spend the route's provider key. No token,\n * an invalid token, or missing site-URL env → `null` → the route answers 401.\n *\n * The deployment's site URL (JWT issuer + JWKS host) resolves from, in order:\n * the explicit `siteUrl` option, `VITE_CONVEX_SITE_URL`, or `VITE_CONVEX_URL`\n * (`….convex.cloud` mapped to `….convex.site`). In a Vite-dev mount these are\n * surfaced to `process.env` by {@link createAiDevPlugin}'s `loadEnv` bridge.\n *\n * Dev-only anonymous escape hatch (playground DX): a playground/local studio has no\n * Convex session at all, so the assistant panel would 401 there. Anonymous requests\n * (NO credential presented) may pass ONLY when the loud\n * {@link INSECURE_ANONYMOUS_SESSION_ENV_VAR} handshake is active — set exclusively by\n * `createAiDevPlugin`'s explicit `insecureAllowAnonymousSessions` opt-in under\n * `vite dev` (the plugin is `apply: 'serve'`, so no production build ever carries it)\n * AND additionally gated on `NODE_ENV !== 'production'`. A PRESENTED token must\n * always verify for real — the bypass never masks an invalid credential.\n *\n * `jose` is loaded via dynamic `import()` so this module can sit in the barrel\n * without putting the JWT library on the studio client's critical path (the same\n * discipline `anthropic-provider` applies to the AI SDK; pinned in `bundle.test.ts`).\n */\nimport type { JWTVerifyGetKey } from 'jose'\nimport type { AiSession } from './types'\n\n/** A `getSession` implementation for {@link AiRouteHandlerOptions.getSession}. */\nexport type AiSessionResolver = (request: Request) => Promise<AiSession | null>\n\n/**\n * The dev-bypass handshake env var. Set ONLY by `createAiDevPlugin` (vite dev) when\n * the host explicitly opts in via `insecureAllowAnonymousSessions`. Never set this\n * on a deployment: the name says exactly what it does.\n */\nexport const INSECURE_ANONYMOUS_SESSION_ENV_VAR = 'ELYTRA_AI_INSECURE_ALLOW_ANONYMOUS'\n\n/** The exact value the handshake requires (anything else is ignored, fail-closed). */\nexport const INSECURE_ANONYMOUS_SESSION_ENV_VALUE = 'dev-only'\n\n/** The synthetic principal id an insecure dev-anonymous session carries. */\nexport const INSECURE_ANONYMOUS_USER_ID = 'insecure-anonymous-dev'\n\n/** An env bag (defaults to `process.env`, read per-request). */\nexport type EnvSource = Record<string, string | undefined>\n\nexport interface ConvexJwtSessionResolverOptions {\n /**\n * The Convex deployment's SITE url — the JWT issuer and JWKS host (e.g.\n * `https://your-deployment-123.convex.site`). Default: resolved from env via\n * {@link resolveConvexSiteUrl}. Missing everywhere ⇒ fail closed (nobody passes).\n */\n siteUrl?: string\n /** Expected JWT audience (default `convex` — what Convex Auth mints). */\n audience?: string\n /** Env source override (default `process.env`, read per-request). */\n env?: EnvSource\n /**\n * Key source override — primarily a test seam (a `createLocalJWKSet` over a\n * locally generated pair). Default: the deployment's remote JWKS.\n */\n getKey?: JWTVerifyGetKey\n}\n\n/**\n * Resolve the Convex SITE url (JWT issuer + JWKS host) from env:\n * `VITE_CONVEX_SITE_URL`, else derived from `VITE_CONVEX_URL`\n * (`….convex.cloud` → `….convex.site`), else `null` (fail closed).\n */\nexport function resolveConvexSiteUrl(env: EnvSource): string | null {\n const site = env.VITE_CONVEX_SITE_URL?.trim()\n if (site) return site.replace(/\\/$/, '')\n const cloud = env.VITE_CONVEX_URL?.trim()\n if (cloud) return cloud.replace(/\\/$/, '').replace('.convex.cloud', '.convex.site')\n return null\n}\n\n/**\n * True when the dev-only anonymous handshake is active: the loud env var carries its\n * exact value AND the process is not a production one. Both conditions are required —\n * setting the var on a production deployment does nothing.\n */\nexport function insecureAnonymousSessionActive(env: EnvSource): boolean {\n return (\n env[INSECURE_ANONYMOUS_SESSION_ENV_VAR] === INSECURE_ANONYMOUS_SESSION_ENV_VALUE &&\n env.NODE_ENV !== 'production'\n )\n}\n\nfunction processEnv(): EnvSource {\n return typeof process !== 'undefined' ? process.env : {}\n}\n\n// jose stays off the studio client's critical path: loaded on first verification only.\ntype JoseModule = typeof import('jose')\nlet josePromise: Promise<JoseModule> | null = null\nfunction loadJose(): Promise<JoseModule> {\n return (josePromise ??= import('jose'))\n}\n\nlet warnedInsecureAnonymous = false\nfunction insecureAnonymousSession(): AiSession {\n if (!warnedInsecureAnonymous) {\n warnedInsecureAnonymous = true\n console.warn(\n `[elytra-ai] INSECURE anonymous AI session bypass is ACTIVE (${INSECURE_ANONYMOUS_SESSION_ENV_VAR}). ` +\n 'This is a dev-only playground convenience — if you ever see this on a deployment, remove the env var immediately.',\n )\n }\n return { userId: INSECURE_ANONYMOUS_USER_ID }\n}\n\n/**\n * Build the engine-default `getSession`: verify the caller's Convex Auth Bearer JWT\n * against the deployment's public JWKS; every failure resolves `null` (→ 401), never\n * throws. See the module doc for the full contract.\n */\nexport function createConvexJwtSessionResolver(\n options: ConvexJwtSessionResolverOptions = {},\n): AiSessionResolver {\n const audience = options.audience ?? 'convex'\n // Remote JWKS getters are cached per site URL (jose caches the fetched keys inside).\n const remoteKeys = new Map<string, JWTVerifyGetKey>()\n\n return async (request: Request): Promise<AiSession | null> => {\n const env = options.env ?? processEnv()\n const authorization = request.headers.get('authorization')\n const token = authorization?.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : null\n\n if (!token) {\n // No credential presented. The ONLY way this passes is the loud, dev-only\n // anonymous opt-in (set by `createAiDevPlugin` under `vite dev`).\n return insecureAnonymousSessionActive(env) ? insecureAnonymousSession() : null\n }\n\n // A presented token must ALWAYS verify for real — the dev bypass never masks\n // an invalid credential.\n const siteUrl = options.siteUrl?.replace(/\\/$/, '') ?? resolveConvexSiteUrl(env)\n if (!siteUrl) return null // fail closed: gate env missing → nobody passes\n try {\n const jose = await loadJose()\n let getKey = options.getKey ?? remoteKeys.get(siteUrl)\n if (!getKey) {\n getKey = jose.createRemoteJWKSet(new URL('/.well-known/jwks.json', siteUrl))\n remoteKeys.set(siteUrl, getKey)\n }\n const { payload } = await jose.jwtVerify(token, getKey, { issuer: siteUrl, audience })\n return typeof payload.sub === 'string' && payload.sub.length > 0\n ? { userId: payload.sub }\n : null\n } catch {\n return null // expired / bad signature / wrong issuer, audience, or deployment → 401\n }\n }\n}\n"]}
@@ -8,7 +8,15 @@
8
8
  * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —
9
9
  * only `anthropic-provider` touches them, via dynamic `import()`. So importing this
10
10
  * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block
11
- * (which only references `StudioAiConfig` as a type) reaches none of it.
11
+ * (which only references `StudioAiConfig` as a type) reaches none of it. The same
12
+ * discipline keeps `jose` (the session resolver's JWT library) behind a dynamic
13
+ * `import()` in `convex-session`.
14
+ *
15
+ * EC-371 adds the engine-default session gate + mounting kit: the Convex JWT
16
+ * session resolver (server), the matching client header provider, and the Node
17
+ * `(req, res)` bridge. The Vite DEV mount lives in its own entry —
18
+ * `@elytracms/studio/lib/ai/vite` (`createAiDevPlugin`) — because it imports `vite`,
19
+ * a dev-only tool that must stay out of this barrel's dependency edge.
12
20
  */
13
21
  export declare const PACKAGE = "@elytracms/studio/lib/ai";
14
22
  export * from './types.js';
@@ -21,6 +29,9 @@ export * from './anthropic-provider.js';
21
29
  export * from './fake-provider.js';
22
30
  export * from './route.js';
23
31
  export * from './route-provider.js';
32
+ export * from './convex-session.js';
33
+ export * from './convex-session-headers.js';
34
+ export * from './node-handler.js';
24
35
  export * from './tools.js';
25
36
  export * from './create-flow.js';
26
37
  export * from './chat-state.js';
@@ -8,7 +8,15 @@
8
8
  * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —
9
9
  * only `anthropic-provider` touches them, via dynamic `import()`. So importing this
10
10
  * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block
11
- * (which only references `StudioAiConfig` as a type) reaches none of it.
11
+ * (which only references `StudioAiConfig` as a type) reaches none of it. The same
12
+ * discipline keeps `jose` (the session resolver's JWT library) behind a dynamic
13
+ * `import()` in `convex-session`.
14
+ *
15
+ * EC-371 adds the engine-default session gate + mounting kit: the Convex JWT
16
+ * session resolver (server), the matching client header provider, and the Node
17
+ * `(req, res)` bridge. The Vite DEV mount lives in its own entry —
18
+ * `@elytracms/studio/lib/ai/vite` (`createAiDevPlugin`) — because it imports `vite`,
19
+ * a dev-only tool that must stay out of this barrel's dependency edge.
12
20
  */
13
21
  export const PACKAGE = '@elytracms/studio/lib/ai';
14
22
  export * from './types.js';
@@ -21,6 +29,9 @@ export * from './anthropic-provider.js';
21
29
  export * from './fake-provider.js';
22
30
  export * from './route.js';
23
31
  export * from './route-provider.js';
32
+ export * from './convex-session.js';
33
+ export * from './convex-session-headers.js';
34
+ export * from './node-handler.js';
24
35
  export * from './tools.js';
25
36
  export * from './create-flow.js';
26
37
  export * from './chat-state.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/ai/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,0BAA0B,CAAA;AAEjD,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,SAAS,CAAA;AACvB,cAAc,kBAAkB,CAAA;AAChC,cAAc,SAAS,CAAA;AACvB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,uBAAuB,CAAA;AACrC,cAAc,sBAAsB,CAAA","sourcesContent":["/**\n * `@elytracms/studio/lib/ai` — the headless AI platform seam (EC-331, phase 1).\n *\n * Public surface: the config gate, the provider adapters (Anthropic + Fake), the\n * knowledge mechanism, the target-shape derivation, the `applyContentWithShape`\n * engine, the server-route factory, and the provenance seam. No chat UI here.\n *\n * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —\n * only `anthropic-provider` touches them, via dynamic `import()`. So importing this\n * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block\n * (which only references `StudioAiConfig` as a type) reaches none of it.\n */\nexport const PACKAGE = '@elytracms/studio/lib/ai'\n\nexport * from './types'\nexport * from './config'\nexport * from './provenance'\nexport * from './knowledge'\nexport * from './shape'\nexport * from './apply-content'\nexport * from './anthropic-provider'\nexport * from './fake-provider'\nexport * from './route'\nexport * from './route-provider'\nexport * from './tools'\nexport * from './create-flow'\nexport * from './chat-state'\nexport * from './rich-text-translate'\nexport * from './translate-document'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/ai/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,0BAA0B,CAAA;AAEjD,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,SAAS,CAAA;AACvB,cAAc,kBAAkB,CAAA;AAChC,cAAc,kBAAkB,CAAA;AAChC,cAAc,0BAA0B,CAAA;AACxC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,SAAS,CAAA;AACvB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,uBAAuB,CAAA;AACrC,cAAc,sBAAsB,CAAA","sourcesContent":["/**\n * `@elytracms/studio/lib/ai` — the headless AI platform seam (EC-331, phase 1).\n *\n * Public surface: the config gate, the provider adapters (Anthropic + Fake), the\n * knowledge mechanism, the target-shape derivation, the `applyContentWithShape`\n * engine, the server-route factory, and the provenance seam. No chat UI here.\n *\n * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —\n * only `anthropic-provider` touches them, via dynamic `import()`. So importing this\n * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block\n * (which only references `StudioAiConfig` as a type) reaches none of it. The same\n * discipline keeps `jose` (the session resolver's JWT library) behind a dynamic\n * `import()` in `convex-session`.\n *\n * EC-371 adds the engine-default session gate + mounting kit: the Convex JWT\n * session resolver (server), the matching client header provider, and the Node\n * `(req, res)` bridge. The Vite DEV mount lives in its own entry —\n * `@elytracms/studio/lib/ai/vite` (`createAiDevPlugin`) — because it imports `vite`,\n * a dev-only tool that must stay out of this barrel's dependency edge.\n */\nexport const PACKAGE = '@elytracms/studio/lib/ai'\n\nexport * from './types'\nexport * from './config'\nexport * from './provenance'\nexport * from './knowledge'\nexport * from './shape'\nexport * from './apply-content'\nexport * from './anthropic-provider'\nexport * from './fake-provider'\nexport * from './route'\nexport * from './route-provider'\nexport * from './convex-session'\nexport * from './convex-session-headers'\nexport * from './node-handler'\nexport * from './tools'\nexport * from './create-flow'\nexport * from './chat-state'\nexport * from './rich-text-translate'\nexport * from './translate-document'\n"]}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `toNodeHandler` — bridge the framework-agnostic Web `Request → Response`
3
+ * {@link AiRouteHandler} to Node's `(req, res)` signature (EC-371; closes EC-369
4
+ * finding #2, where the identical ~25-line bridge was hand-copied four times across
5
+ * consumers). This is the PROD half of the mounting kit: a Vercel Node function (or
6
+ * any Node server) mounts the route as
7
+ *
8
+ * ```ts
9
+ * // api/ai.ts (Vercel filesystem routing beats the SPA catch-all rewrite)
10
+ * import { toNodeHandler } from '@elytracms/studio/lib/ai'
11
+ * import { aiRouteHandler } from '../src/server/ai-handler.js'
12
+ * export default toNodeHandler(aiRouteHandler)
13
+ * ```
14
+ *
15
+ * The DEV half (`createAiDevPlugin`, Vite middleware + `ssrLoadModule`) reuses this
16
+ * same bridge — one implementation, both mounts. No platform dependency: the classic
17
+ * `(IncomingMessage, ServerResponse)` signature works on every @vercel/node version
18
+ * and on plain `node:http`. Note it buffers the response body (including the chat
19
+ * stream) — the proven Konditorei behavior; native streaming is EC-334's scope.
20
+ */
21
+ import type { IncomingMessage, ServerResponse } from 'node:http';
22
+ import type { AiRouteHandler } from './types.js';
23
+ /** A Node-mountable request handler (Vercel Node function / `node:http` compatible). */
24
+ export type NodeAiRouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
25
+ /**
26
+ * Wrap an {@link AiRouteHandler} as a Node `(req, res)` handler: buffer the body,
27
+ * bridge headers both directions, copy status + payload back.
28
+ */
29
+ export declare function toNodeHandler(handler: AiRouteHandler): NodeAiRouteHandler;
@@ -0,0 +1,48 @@
1
+ const encoder = new TextEncoder();
2
+ function concatBytes(chunks) {
3
+ let total = 0;
4
+ for (const chunk of chunks)
5
+ total += chunk.byteLength;
6
+ const out = new Uint8Array(total);
7
+ let offset = 0;
8
+ for (const chunk of chunks) {
9
+ out.set(chunk, offset);
10
+ offset += chunk.byteLength;
11
+ }
12
+ return out;
13
+ }
14
+ /** Reconstruct a Web `Request` from a Node request (headers, method, buffered body). */
15
+ async function toWebRequest(req) {
16
+ const chunks = [];
17
+ for await (const chunk of req) {
18
+ chunks.push(typeof chunk === 'string' ? encoder.encode(chunk) : chunk);
19
+ }
20
+ const headers = new Headers();
21
+ for (const [key, value] of Object.entries(req.headers)) {
22
+ if (typeof value === 'string')
23
+ headers.set(key, value);
24
+ else if (Array.isArray(value))
25
+ headers.set(key, value.join(', '));
26
+ }
27
+ const forwardedProto = req.headers['x-forwarded-proto'];
28
+ const proto = (typeof forwardedProto === 'string' && forwardedProto.split(',')[0]?.trim()) || 'http';
29
+ return new Request(`${proto}://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`, {
30
+ method: req.method,
31
+ headers,
32
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : concatBytes(chunks),
33
+ });
34
+ }
35
+ /**
36
+ * Wrap an {@link AiRouteHandler} as a Node `(req, res)` handler: buffer the body,
37
+ * bridge headers both directions, copy status + payload back.
38
+ */
39
+ export function toNodeHandler(handler) {
40
+ return async (req, res) => {
41
+ const request = await toWebRequest(req);
42
+ const response = await handler(request);
43
+ res.statusCode = response.status;
44
+ response.headers.forEach((value, key) => res.setHeader(key, value));
45
+ res.end(new Uint8Array(await response.arrayBuffer()));
46
+ };
47
+ }
48
+ //# sourceMappingURL=node-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-handler.js","sourceRoot":"","sources":["../../../src/lib/ai/node-handler.ts"],"names":[],"mappings":"AA0BA,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAA;AAEjC,SAAS,WAAW,CAAC,MAAoB;IACvC,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,KAAK,MAAM,KAAK,IAAI,MAAM;QAAE,KAAK,IAAI,KAAK,CAAC,UAAU,CAAA;IACrD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAA;IACjC,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;QACtB,MAAM,IAAI,KAAK,CAAC,UAAU,CAAA;IAC5B,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,wFAAwF;AACxF,KAAK,UAAU,YAAY,CAAC,GAAoB;IAC9C,MAAM,MAAM,GAAiB,EAAE,CAAA;IAC/B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,EAAE,CAAC;QAC9B,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAE,KAAoB,CAAC,CAAA;IACxF,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA;IAC7B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;QACvD,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;aACjD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;IACnE,CAAC;IACD,MAAM,cAAc,GAAG,GAAG,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACvD,MAAM,KAAK,GACT,CAAC,OAAO,cAAc,KAAK,QAAQ,IAAI,cAAc,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,IAAI,MAAM,CAAA;IACxF,OAAO,IAAI,OAAO,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,WAAW,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,EAAE;QACnF,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,OAAO;QACP,IAAI,EAAE,GAAG,CAAC,MAAM,KAAK,KAAK,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC;KACtF,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,OAAuB;IACnD,OAAO,KAAK,EAAE,GAAoB,EAAE,GAAmB,EAAiB,EAAE;QACxE,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,CAAA;QACvC,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;QACvC,GAAG,CAAC,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAA;QAChC,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAA;QACnE,GAAG,CAAC,GAAG,CAAC,IAAI,UAAU,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAA;IACvD,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * `toNodeHandler` — bridge the framework-agnostic Web `Request → Response`\n * {@link AiRouteHandler} to Node's `(req, res)` signature (EC-371; closes EC-369\n * finding #2, where the identical ~25-line bridge was hand-copied four times across\n * consumers). This is the PROD half of the mounting kit: a Vercel Node function (or\n * any Node server) mounts the route as\n *\n * ```ts\n * // api/ai.ts (Vercel filesystem routing beats the SPA catch-all rewrite)\n * import { toNodeHandler } from '@elytracms/studio/lib/ai'\n * import { aiRouteHandler } from '../src/server/ai-handler.js'\n * export default toNodeHandler(aiRouteHandler)\n * ```\n *\n * The DEV half (`createAiDevPlugin`, Vite middleware + `ssrLoadModule`) reuses this\n * same bridge — one implementation, both mounts. No platform dependency: the classic\n * `(IncomingMessage, ServerResponse)` signature works on every @vercel/node version\n * and on plain `node:http`. Note it buffers the response body (including the chat\n * stream) — the proven Konditorei behavior; native streaming is EC-334's scope.\n */\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { AiRouteHandler } from './types'\n\n/** A Node-mountable request handler (Vercel Node function / `node:http` compatible). */\nexport type NodeAiRouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>\n\nconst encoder = new TextEncoder()\n\nfunction concatBytes(chunks: Uint8Array[]): Uint8Array<ArrayBuffer> {\n let total = 0\n for (const chunk of chunks) total += chunk.byteLength\n const out = new Uint8Array(total)\n let offset = 0\n for (const chunk of chunks) {\n out.set(chunk, offset)\n offset += chunk.byteLength\n }\n return out\n}\n\n/** Reconstruct a Web `Request` from a Node request (headers, method, buffered body). */\nasync function toWebRequest(req: IncomingMessage): Promise<Request> {\n const chunks: Uint8Array[] = []\n for await (const chunk of req) {\n chunks.push(typeof chunk === 'string' ? encoder.encode(chunk) : (chunk as Uint8Array))\n }\n const headers = new Headers()\n for (const [key, value] of Object.entries(req.headers)) {\n if (typeof value === 'string') headers.set(key, value)\n else if (Array.isArray(value)) headers.set(key, value.join(', '))\n }\n const forwardedProto = req.headers['x-forwarded-proto']\n const proto =\n (typeof forwardedProto === 'string' && forwardedProto.split(',')[0]?.trim()) || 'http'\n return new Request(`${proto}://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`, {\n method: req.method,\n headers,\n body: req.method === 'GET' || req.method === 'HEAD' ? undefined : concatBytes(chunks),\n })\n}\n\n/**\n * Wrap an {@link AiRouteHandler} as a Node `(req, res)` handler: buffer the body,\n * bridge headers both directions, copy status + payload back.\n */\nexport function toNodeHandler(handler: AiRouteHandler): NodeAiRouteHandler {\n return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n const request = await toWebRequest(req)\n const response = await handler(request)\n res.statusCode = response.status\n response.headers.forEach((value, key) => res.setHeader(key, value))\n res.end(new Uint8Array(await response.arrayBuffer()))\n }\n}\n"]}
@@ -0,0 +1,32 @@
1
+ import type { Plugin } from 'vite';
2
+ /** Env keys the plugin surfaces from `.env*` files into `process.env` for the handler. */
3
+ export declare const DEFAULT_AI_DEV_ENV_KEYS: readonly ["ANTHROPIC_API_KEY", "VITE_CONVEX_SITE_URL", "VITE_CONVEX_URL"];
4
+ export interface AiDevPluginOptions {
5
+ /** The handler module (Vite root-relative), default `/src/server/ai-handler.ts`. */
6
+ handlerModulePath?: string;
7
+ /** The mounted route path (default {@link DEFAULT_AI_ENDPOINT}, `/api/ai`). */
8
+ route?: string;
9
+ /** The handler module's export name (default `aiRouteHandler`). */
10
+ exportName?: string;
11
+ /** Extra env keys to surface into `process.env` (merged over the defaults). */
12
+ envKeys?: readonly string[];
13
+ /**
14
+ * INSECURE, dev-only: let requests presenting NO credential pass the session gate.
15
+ * A playground studio has no Convex session, so without this the assistant panel
16
+ * answers 401 there. `'playground-mode'` scopes the bypass to dev servers booted
17
+ * with `VITE_ELYTRA_MODE=playground` (the recommended setting); `true` applies it
18
+ * to every dev boot. Defaults OFF. It cannot reach production: the plugin is
19
+ * `apply: 'serve'`, the handshake env var is set only in `configureServer`, and
20
+ * the resolver additionally refuses it under `NODE_ENV=production`. A PRESENTED
21
+ * token must still verify for real — the bypass never masks a bad credential.
22
+ */
23
+ insecureAllowAnonymousSessions?: boolean | 'playground-mode';
24
+ }
25
+ /** Resolve the anonymous-bypass opt-in against the loaded env (pure; unit-pinned). */
26
+ export declare function resolveInsecureAnonymousOptIn(optIn: boolean | 'playground-mode' | undefined, env: Record<string, string | undefined>): boolean;
27
+ /**
28
+ * Mount the AI route in the Vite dev server. Dev-only by construction
29
+ * (`apply: 'serve'`); a production deploy mounts the same handler module via
30
+ * `toNodeHandler` in its serverless entry instead.
31
+ */
32
+ export declare function createAiDevPlugin(options?: AiDevPluginOptions): Plugin;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `@elytracms/studio/lib/ai/vite` — `createAiDevPlugin`, the DEV half of the AI
3
+ * route mounting kit (EC-371; closes EC-369 finding #2). A studio app is a client
4
+ * SPA with no server build, so in dev the host route lives as a Vite dev-server
5
+ * middleware: this plugin loads the framework-agnostic `Request → Response` handler
6
+ * module through Vite's SSR pipeline (so its `@elytracms/*` imports resolve like the
7
+ * rest of the app) and bridges Node req/res via the shared {@link toNodeHandler}.
8
+ *
9
+ * It also owns the env bridge the resolver needs (the piece the template had
10
+ * drifted on): `loadEnv` surfaces `ANTHROPIC_API_KEY` / `VITE_CONVEX_SITE_URL` /
11
+ * `VITE_CONVEX_URL` from `.env`/`.env.local` into `process.env` for the SSR-loaded
12
+ * handler — Vite only exposes `VITE_`-prefixed vars via `import.meta.env`, and a
13
+ * `VITE_` prefix on the provider key would leak it into the client bundle.
14
+ *
15
+ * SEPARATE ENTRY on purpose: this module statically imports `vite`, which is a
16
+ * dev-only tool. It is NOT re-exported from the `lib/ai` barrel (pinned in
17
+ * `bundle.test.ts`), and the plugin is `apply: 'serve'` — no production build or
18
+ * serverless mount ever includes it. Production mounts use `toNodeHandler` from
19
+ * `@elytracms/studio/lib/ai` directly.
20
+ */
21
+ import { loadEnv } from 'vite';
22
+ import { INSECURE_ANONYMOUS_SESSION_ENV_VALUE, INSECURE_ANONYMOUS_SESSION_ENV_VAR, } from './convex-session.js';
23
+ import { toNodeHandler } from './node-handler.js';
24
+ import { DEFAULT_AI_ENDPOINT } from './route-provider.js';
25
+ /** Env keys the plugin surfaces from `.env*` files into `process.env` for the handler. */
26
+ export const DEFAULT_AI_DEV_ENV_KEYS = [
27
+ 'ANTHROPIC_API_KEY',
28
+ 'VITE_CONVEX_SITE_URL',
29
+ 'VITE_CONVEX_URL',
30
+ ];
31
+ /** Resolve the anonymous-bypass opt-in against the loaded env (pure; unit-pinned). */
32
+ export function resolveInsecureAnonymousOptIn(optIn, env) {
33
+ if (optIn === true)
34
+ return true;
35
+ if (optIn === 'playground-mode')
36
+ return env.VITE_ELYTRA_MODE === 'playground';
37
+ return false;
38
+ }
39
+ function jsonError(code, status) {
40
+ return new Response(JSON.stringify({ error: code }), {
41
+ status,
42
+ headers: { 'content-type': 'application/json' },
43
+ });
44
+ }
45
+ /**
46
+ * Mount the AI route in the Vite dev server. Dev-only by construction
47
+ * (`apply: 'serve'`); a production deploy mounts the same handler module via
48
+ * `toNodeHandler` in its serverless entry instead.
49
+ */
50
+ export function createAiDevPlugin(options = {}) {
51
+ const handlerModulePath = options.handlerModulePath ?? '/src/server/ai-handler.ts';
52
+ const route = options.route ?? DEFAULT_AI_ENDPOINT;
53
+ const exportName = options.exportName ?? 'aiRouteHandler';
54
+ let env = {};
55
+ return {
56
+ name: 'elytra-ai-route',
57
+ apply: 'serve',
58
+ configResolved(config) {
59
+ env = loadEnv(config.mode, config.root, '');
60
+ for (const key of [...DEFAULT_AI_DEV_ENV_KEYS, ...(options.envKeys ?? [])]) {
61
+ if (env[key] && !process.env[key])
62
+ process.env[key] = env[key];
63
+ }
64
+ },
65
+ configureServer(server) {
66
+ if (resolveInsecureAnonymousOptIn(options.insecureAllowAnonymousSessions, env)) {
67
+ process.env[INSECURE_ANONYMOUS_SESSION_ENV_VAR] = INSECURE_ANONYMOUS_SESSION_ENV_VALUE;
68
+ server.config.logger.warn(`[elytra-ai] insecureAllowAnonymousSessions is ON for this dev server: ${route} ` +
69
+ 'accepts requests without a session token (requests presenting a token are still verified).');
70
+ }
71
+ // Per request: SSR-load the handler (picks up its @elytracms/* imports through
72
+ // Vite) and run it; a load/handler failure is a structured 500, never a crash.
73
+ const bridge = toNodeHandler(async (request) => {
74
+ try {
75
+ const mod = (await server.ssrLoadModule(handlerModulePath));
76
+ const handler = mod[exportName];
77
+ if (typeof handler !== 'function')
78
+ return jsonError('route-handler-missing', 500);
79
+ return await handler(request);
80
+ }
81
+ catch {
82
+ return jsonError('route-error', 500);
83
+ }
84
+ });
85
+ server.middlewares.use((req, res, next) => {
86
+ if (!req.url || !req.url.startsWith(route))
87
+ return next();
88
+ void bridge(req, res).catch(() => {
89
+ if (!res.headersSent)
90
+ res.statusCode = 500;
91
+ res.end();
92
+ });
93
+ });
94
+ },
95
+ };
96
+ }
97
+ //# sourceMappingURL=vite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.js","sourceRoot":"","sources":["../../../src/lib/ai/vite.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `@elytracms/studio/lib/ai/vite` — `createAiDevPlugin`, the DEV half of the AI\n * route mounting kit (EC-371; closes EC-369 finding #2). A studio app is a client\n * SPA with no server build, so in dev the host route lives as a Vite dev-server\n * middleware: this plugin loads the framework-agnostic `Request → Response` handler\n * module through Vite's SSR pipeline (so its `@elytracms/*` imports resolve like the\n * rest of the app) and bridges Node req/res via the shared {@link toNodeHandler}.\n *\n * It also owns the env bridge the resolver needs (the piece the template had\n * drifted on): `loadEnv` surfaces `ANTHROPIC_API_KEY` / `VITE_CONVEX_SITE_URL` /\n * `VITE_CONVEX_URL` from `.env`/`.env.local` into `process.env` for the SSR-loaded\n * handler — Vite only exposes `VITE_`-prefixed vars via `import.meta.env`, and a\n * `VITE_` prefix on the provider key would leak it into the client bundle.\n *\n * SEPARATE ENTRY on purpose: this module statically imports `vite`, which is a\n * dev-only tool. It is NOT re-exported from the `lib/ai` barrel (pinned in\n * `bundle.test.ts`), and the plugin is `apply: 'serve'` — no production build or\n * serverless mount ever includes it. Production mounts use `toNodeHandler` from\n * `@elytracms/studio/lib/ai` directly.\n */\nimport { loadEnv } from 'vite'\nimport type { Plugin, ViteDevServer } from 'vite'\nimport {\n INSECURE_ANONYMOUS_SESSION_ENV_VALUE,\n INSECURE_ANONYMOUS_SESSION_ENV_VAR,\n} from './convex-session'\nimport { toNodeHandler } from './node-handler'\nimport { DEFAULT_AI_ENDPOINT } from './route-provider'\nimport type { AiRouteHandler } from './types'\n\n/** Env keys the plugin surfaces from `.env*` files into `process.env` for the handler. */\nexport const DEFAULT_AI_DEV_ENV_KEYS = [\n 'ANTHROPIC_API_KEY',\n 'VITE_CONVEX_SITE_URL',\n 'VITE_CONVEX_URL',\n] as const\n\nexport interface AiDevPluginOptions {\n /** The handler module (Vite root-relative), default `/src/server/ai-handler.ts`. */\n handlerModulePath?: string\n /** The mounted route path (default {@link DEFAULT_AI_ENDPOINT}, `/api/ai`). */\n route?: string\n /** The handler module's export name (default `aiRouteHandler`). */\n exportName?: string\n /** Extra env keys to surface into `process.env` (merged over the defaults). */\n envKeys?: readonly string[]\n /**\n * INSECURE, dev-only: let requests presenting NO credential pass the session gate.\n * A playground studio has no Convex session, so without this the assistant panel\n * answers 401 there. `'playground-mode'` scopes the bypass to dev servers booted\n * with `VITE_ELYTRA_MODE=playground` (the recommended setting); `true` applies it\n * to every dev boot. Defaults OFF. It cannot reach production: the plugin is\n * `apply: 'serve'`, the handshake env var is set only in `configureServer`, and\n * the resolver additionally refuses it under `NODE_ENV=production`. A PRESENTED\n * token must still verify for real — the bypass never masks a bad credential.\n */\n insecureAllowAnonymousSessions?: boolean | 'playground-mode'\n}\n\n/** Resolve the anonymous-bypass opt-in against the loaded env (pure; unit-pinned). */\nexport function resolveInsecureAnonymousOptIn(\n optIn: boolean | 'playground-mode' | undefined,\n env: Record<string, string | undefined>,\n): boolean {\n if (optIn === true) return true\n if (optIn === 'playground-mode') return env.VITE_ELYTRA_MODE === 'playground'\n return false\n}\n\nfunction jsonError(code: string, status: number): Response {\n return new Response(JSON.stringify({ error: code }), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\n/**\n * Mount the AI route in the Vite dev server. Dev-only by construction\n * (`apply: 'serve'`); a production deploy mounts the same handler module via\n * `toNodeHandler` in its serverless entry instead.\n */\nexport function createAiDevPlugin(options: AiDevPluginOptions = {}): Plugin {\n const handlerModulePath = options.handlerModulePath ?? '/src/server/ai-handler.ts'\n const route = options.route ?? DEFAULT_AI_ENDPOINT\n const exportName = options.exportName ?? 'aiRouteHandler'\n let env: Record<string, string | undefined> = {}\n\n return {\n name: 'elytra-ai-route',\n apply: 'serve',\n configResolved(config) {\n env = loadEnv(config.mode, config.root, '')\n for (const key of [...DEFAULT_AI_DEV_ENV_KEYS, ...(options.envKeys ?? [])]) {\n if (env[key] && !process.env[key]) process.env[key] = env[key]\n }\n },\n configureServer(server: ViteDevServer) {\n if (resolveInsecureAnonymousOptIn(options.insecureAllowAnonymousSessions, env)) {\n process.env[INSECURE_ANONYMOUS_SESSION_ENV_VAR] = INSECURE_ANONYMOUS_SESSION_ENV_VALUE\n server.config.logger.warn(\n `[elytra-ai] insecureAllowAnonymousSessions is ON for this dev server: ${route} ` +\n 'accepts requests without a session token (requests presenting a token are still verified).',\n )\n }\n\n // Per request: SSR-load the handler (picks up its @elytracms/* imports through\n // Vite) and run it; a load/handler failure is a structured 500, never a crash.\n const bridge = toNodeHandler(async (request: Request): Promise<Response> => {\n try {\n const mod = (await server.ssrLoadModule(handlerModulePath)) as Record<string, unknown>\n const handler = mod[exportName]\n if (typeof handler !== 'function') return jsonError('route-handler-missing', 500)\n return await (handler as AiRouteHandler)(request)\n } catch {\n return jsonError('route-error', 500)\n }\n })\n\n server.middlewares.use((req, res, next) => {\n if (!req.url || !req.url.startsWith(route)) return next()\n void bridge(req, res).catch(() => {\n if (!res.headersSent) res.statusCode = 500\n res.end()\n })\n })\n },\n }\n}\n"]}