diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e10b0..b9c44be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Changed +- **Authentication and connection lifecycle extracted into `ConnectionSession`** + (#276 Phase 2). OAuth PKCE login/refresh, Basic-auth probing, IdP config + resolution, identity, sign-in/out, and the cross-tab dashboard auth handoff + now live in `src/application/connection-session.ts` — constructible without + `App`/`AppState`/DOM, with rendering inverted through an injected + `onAuthLost` shell callback (the session never renders or toasts). The app + exposes it as `app.conn`; `app.chCtx` remains the same single live ClickHouse + context object (now session-owned). The scalar auth fields + (`token`/`refreshToken`/`authMode`/`chAuth`/`basicUserClaim`/`idpId`) left + the `App` contract; view/bootstrap-consumed members stay as one-line + delegates slated for removal in the issue's Phase 5. Behavior, storage keys, + handoff message pinning, and error strings are byte-identical. - **Query execution extracted into an application service** (#276 Phases 0–1, the first step of the app.ts → services refactor). The shared request/stream/normalize core (`runReadInto`) and the multiquery-script diff --git a/src/application/connection-session.ts b/src/application/connection-session.ts new file mode 100644 index 0000000..6085b68 --- /dev/null +++ b/src/application/connection-session.ts @@ -0,0 +1,467 @@ +// #276 Phase 2's ConnectionSession — auth + config + ClickHouse connection +// lifecycle (OAuth PKCE login/refresh, Basic probing, IdP config resolution, +// cross-tab auth handoff), constructible without App/AppState/DOM; no imports +// from src/ui/** or src/editor/** (check:arch enforces). Rendering is the +// shell's job — auth loss surfaces through the injected `onAuthLost` callback, +// never a render/toast call. This is a byte-for-byte port of the auth/config/ +// connection lifecycle that used to live inline in src/ui/app.ts (see that +// file's history around the OAuth/basic-auth/handoff sections) — comments +// below are carried over, adapted only where the code itself moved (e.g. +// `app.token` becomes a closed-over local, `ss`/`loc`/`win` become the +// injected `deps.storage`/`deps.location`/`deps.win`, and a couple of +// `app.renderApp()`/`renderLoginApp()` calls are gone because rendering isn't +// this module's job any more — see each call site below for the specific +// note). `openDashboard` itself (opening the new tab) stays app-side; this +// module only owns the credential-grant half of the handoff. + +import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; +import { generatePKCE, randomState } from '../core/pkce.js'; +import type { PkceCrypto } from '../core/pkce.js'; +import { configBase } from '../core/dashboard.js'; +import { resolveTarget } from '../core/target.js'; +import { + snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, AUTH_REQUEST, AUTH_GRANT, +} from '../core/auth-handoff.js'; +import type { AuthSnapshot, StorageLike } from '../core/auth-handoff.js'; +import { buildAuthorizeUrl, refreshTokens, bearerFromTokens } from '../net/oauth.js'; +import { memoizeConfig, loadConfigDoc, resolveIdp } from '../net/oauth-config.js'; +import type { ConfigDoc, ResolvedIdpConfig, ChAuthKind } from '../net/oauth-config.js'; +import type { ChCtx as NetChCtx, queryJson } from '../net/ch-client.js'; + +// ── Injected dependency seam ───────────────────────────────────────────────── + +/** The sessionStorage-like surface this module needs — `core/auth-handoff.js`'s + * canonical `StorageLike` plus `removeItem` (the token/handoff bookkeeping + * below removes one-shot keys alongside `getItem`/`setItem`). */ +export interface SessionStorageLike extends StorageLike { + removeItem(key: string): void; +} + +/** The minimal Web Crypto surface PKCE generation needs — real Web Crypto + * (browser `crypto` or Node's `webcrypto`) or an injectable stub: exactly + * `core/pkce.js`'s own `PkceCrypto` seam, re-exported under this session's + * naming so deps read uniformly. A real `Crypto` object satisfies it without + * a cast; a test stub only implements two members. */ +export type SessionCrypto = PkceCrypto; + +/** Every side effect this session needs, injected as a narrow bag — mirrors + * `query-execution-service.ts`'s own `QueryExecutionDeps` seam. Production + * wires the real browser/env objects (`main.js`'s bootstrap resolves + * `handoffMs`/`handoffListenMs` defaults before constructing the session — + * this module never defaults them itself); tests inject plain stubs. */ +export interface ConnectionSessionDeps { + fetch: typeof fetch; + storage: SessionStorageLike; + /** `href` is WRITTEN (the OAuth redirect assigns it) as well as read. */ + location: { origin: string; pathname: string; search: string; href: string }; + crypto: SessionCrypto; + /** Cross-tab auth-handoff listeners/timeouts. */ + win: Pick; + /** Basic-auth sign-in probe. */ + queryJson: typeof queryJson; + /** The child's own wait for a grant once it asks (a same-origin reply is + * near-instant, so this is short) — resolved by the caller; the historical + * 4000ms env default stays app-side. */ + handoffMs: number; + /** How long the opener keeps listening for a request — far longer than + * `handoffMs` because it must survive the child's cold JS load before the + * child can even ask; a short opener window would drop a slow tab's + * request and force a needless re-login. The historical 30000ms env + * default stays app-side. */ + handoffListenMs: number; + /** Auth was lost (no token / expired-and-unrefreshable / CH rejected a + * valid login). The session never renders — it calls this and lets the + * shell decide how to show the login screen. */ + onAuthLost: (detail?: string) => void; +} + +// ── The ClickHouse auth context ────────────────────────────────────────────── + +/** The live ClickHouse auth context this session owns — `origin`/ + * `authConfirmed` are mutated IN PLACE by `signOut`/`connectBasic`/ + * `applyAuthSnapshot` here, and by `net/ch-client.js`'s `authedFetch` (its + * own one-shot latch) — this ONE object is never reconstructed, only ever + * mutated, so a caller holding a reference (or passing it straight into + * ch-client's functions) always observes the current auth state. Assignable + * to `net/ch-client.js`'s own `ChCtx` (whose `authConfirmed`/`authHeader` + * are optional there, since some callers build a throwaway ctx without + * them — this session always supplies both, matching the stricter + * `ui/app.types.ts` `ChCtx` shape the rest of the app already reads through, + * redeclared here rather than imported since `src/application/**` may not + * import `src/ui/**`). */ +export interface SessionChCtx { + fetch: typeof fetch; + origin: string; + authConfirmed: boolean; + getToken(): Promise; + refresh(): Promise; + authHeader(token: string): string; + onSignedOut(detail?: string): void; +} + +// ── The session ─────────────────────────────────────────────────────────── + +/** The auth + config + ClickHouse connection lifecycle, constructible without + * App/AppState/DOM. See `createConnectionSession` for construction/seeding + * and each method below for the ported behavior. */ +export interface ConnectionSession { + readonly basePath: string; + readonly hostHint: string; + readonly chCtx: SessionChCtx; + + // state accessors (test-support + shell display; do not log values) + token(): string | null; + refreshToken(): string | null; + authMode(): 'oauth' | 'basic'; + idpId(): string | null; + chAuth(): ChAuthKind; + basicUserClaim(): string; + isSignedIn(): boolean; + email(): string; + host(): string; + + // config + loadIdps(): Promise; + selectIdp(id: string): void; + resolveConfig(): Promise; + ensureConfig(): Promise; + + // lifecycle + setTokens(id: string, refresh?: string): void; + getToken(): Promise; + beginOAuth(idpId?: string, targetOrigin?: string): Promise; + connectBasic(input: { username: string; password: string; host?: string }): Promise; + signOut(): void; + ensureFreshToken(): Promise; + + // cross-tab handoff (message contract: core/auth-handoff) + grantHandoffTo(child: Window): void; + receiveAuthHandoff(env: { opener?: Window | null }): Promise; +} + +export function createConnectionSession(deps: ConnectionSessionDeps): ConnectionSession { + const { storage: ss, location: loc, crypto: cryptoObj, fetch: fetchFn, win, queryJson: queryJsonFn } = deps; + + // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — + // a ClickHouse username/password sent as Authorization: Basic, optionally + // against another host. A live basic session is restored from sessionStorage + // (ch_basic_*), mirroring how the OAuth token is restored below. + let token: string | null = ss.getItem('oauth_id_token'); + let refreshTok: string | null = ss.getItem('oauth_refresh_token'); + let authMode: 'oauth' | 'basic' = ss.getItem('ch_basic_auth') ? 'basic' : 'oauth'; + const basicCreds = (): string | null => ss.getItem('ch_basic_auth'); + const basicUser = (): string => ss.getItem('ch_basic_user') || ''; + const originHost = (o: string): string => { try { return new URL(o).host; } catch { return ''; } }; + + // config.json may list several IdPs. Fetch the doc once; resolve OIDC + // discovery per selected IdP. The chosen IdP id is persisted so it survives + // the OAuth redirect (like oauth_state) and drives token exchange/refresh. + // configBase strips a trailing `/dashboard` so config.json / OAuth discovery + // resolve from the SPA base (`/sql/config.json`) on the dashboard route too. + // The same base is the single source of truth for the workbench<->dashboard + // links (openDashboard, the dashboard's Back link) rather than hardcoding + // `/sql` in several shapes. + const basePath = configBase(loc.pathname); + const loadDoc = memoizeConfig(() => loadConfigDoc(fetchFn, basePath)); + const resolvedCache = new Map>(); + let idpId: string | null = ss.getItem('oauth_idp') || null; + function selectIdp(id: string): void { idpId = id; ss.setItem('oauth_idp', id); } + async function resolveConfig(): Promise { + const { idps } = await loadDoc(); + const chosen = idps.find((i) => i.id === idpId) || idps[0]; + idpId = chosen.id; + if (!resolvedCache.has(chosen.id)) resolvedCache.set(chosen.id, resolveIdp(fetchFn, chosen)); + return resolvedCache.get(chosen.id)!; + } + + // A `?host=` query param pre-fills the credential server address on the login + // screen (and disables SSO, which only targets the serving host). + const hostHint = new URLSearchParams(loc.search || '').get('host') || ''; + + // isSignedIn uses ZERO skew (bufferSeconds=0) — it's a point-in-time "is the + // token still technically valid right now" check (drives whether the + // workbench renders at all). getToken (below) uses the default 60s skew + // instead — it drives whether a QUERY is about to be sent with a token that + // will likely have expired by the time the request lands, so it refreshes + // proactively before that happens. Same isTokenExpired call, deliberately + // different bufferSeconds for deliberately different questions. + const isSignedIn = (): boolean => (authMode === 'basic' + ? !!basicCreds() + : !!token && !isTokenExpired(token, 0)); + + // The CH-facing identity for the current token — what currentUser() will be: + // for ch_auth=basic it's the Basic username (honouring basicUserClaim); for + // bearer it's the email the token-processor keys on. Shared by authHeader and + // the header display so the UI never shows a different claim than CH sees. + function chUsername(p: Record): string { + return String((chAuthVal === 'basic' && basicUserClaimVal && p[basicUserClaimVal]) + || p.email || p.preferred_username || p.sub || ''); + } + const email = (): string => (authMode === 'basic' + ? basicUser() + : chUsername(decodeJwtPayload(token))); + + function setTokens(id: string, refresh?: string): void { + token = id; + ss.setItem('oauth_id_token', id); + if (refresh) { + refreshTok = refresh; + ss.setItem('oauth_refresh_token', refresh); + } + // The PKCE verifier + CSRF state are one-shot — done with them once we hold + // tokens. (The refresh path also lands here; they're already gone → no-op.) + ss.removeItem('oauth_verifier'); + ss.removeItem('oauth_state'); + } + function clearTokens(): void { + token = null; + refreshTok = null; + idpId = null; + authMode = 'oauth'; + chCtx.origin = loc.origin; + chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again + ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', 'oauth_origin', + 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k)); + } + // `signOut` is exactly today's app.ts `clearTokens()` — no render. (app.ts's + // own `app.signOut` additionally re-renders the login screen; that's the + // shell's job now, done by whatever caller notices signOut() was called.) + const signOut = (): void => clearTokens(); + + // --- OAuth ------------------------------------------------------------- + async function beginOAuth(idpArg?: string, targetOrigin?: string): Promise { + if (idpArg) selectIdp(idpArg); + // A picked saved-connection can target another cluster: stash its origin so + // the rebuilt chCtx (after the redirect reload) POSTs the bearer there. + // Survives the redirect like oauth_state/oauth_idp; cleared for serving-host SSO. + if (targetOrigin) ss.setItem('oauth_origin', targetOrigin); + else ss.removeItem('oauth_origin'); + const cfg = await resolveConfig(); + const { verifier, challenge } = await generatePKCE(cryptoObj); + const state = randomState(cryptoObj); + ss.setItem('oauth_verifier', verifier); + ss.setItem('oauth_state', state); + loc.href = buildAuthorizeUrl(cfg, { + redirectUri: loc.origin + loc.pathname, + challenge, + state, + }); + } + + async function refresh(): Promise { + // Basic credentials don't expire and can't be refreshed; a surviving 401 + // means the password is wrong → authedFetch falls through to onSignedOut. + if (authMode === 'basic') return false; + const cfg = await resolveConfig(); + const tokens = await refreshTokens(fetchFn, cfg, refreshTok); + const bearer = bearerFromTokens(tokens, cfg.bearer); + if (!bearer) return false; + // `bearer` is only ever truthy when `tokens` (its own source) is non-null. + setTokens(bearer, tokens?.refresh_token); + return true; + } + + async function getToken(): Promise { + // In basic mode the stored credential is the "token" authedFetch carries. + if (authMode === 'basic') return basicCreds(); + if (!token) return null; + if (!isTokenExpired(token)) return token; + if (await refresh()) return token; + clearTokens(); + return null; + } + + // --- ClickHouse context -------------------------------------------------- + // How the token is presented to CH. 'bearer' (token_processor) or 'basic' + // (OSS + a verifier like ch-jwt-verify, where the JWT is the Basic password + // and the username is the token's email). Resolved from config by ensureConfig. + let chAuthVal: ChAuthKind = 'bearer'; + // Which claim becomes the Basic username (per-IdP, from config). Empty → the + // default chain. Lets one IdP map to a CH username distinct from another's. + let basicUserClaimVal = ''; + function authHeader(t: string): string { + // Basic mode: `t` is already base64(user:pass) — send it verbatim. + if (authMode === 'basic') return 'Basic ' + t; + if (chAuthVal !== 'basic') return 'Bearer ' + t; + const user = chUsername(decodeJwtPayload(t)); + return 'Basic ' + btoa(unescape(encodeURIComponent(user + ':' + t))); + } + const chCtx: SessionChCtx = { + fetch: fetchFn, + // Where queries POST: the serving origin for OAuth, or the (possibly + // cross-origin) target chosen at credential sign-in for basic mode. + origin: authMode === 'basic' + ? (ss.getItem('ch_basic_origin') || loc.origin) + : (ss.getItem('oauth_origin') || loc.origin), + // Flips true after the first 2xx; gates whether a later 401/403 is treated + // as a sign-in failure (only before auth is confirmed) or a query error. + authConfirmed: false, + getToken, + refresh, + authHeader, + // detail is set when CH rejects a *valid* login (authorization denial); the + // no-arg calls (no token / expired + refresh failed) fall back to expiry. + onSignedOut: (detail?: string) => { + clearTokens(); + deps.onAuthLost(detail || 'Your session expired — please sign in again.'); + }, + }; + + // Load config (once) and apply the CH auth mode before any query runs. + // Fail-soft: if config can't be loaded we keep the current mode (bearer) + // rather than blocking the query. + async function ensureConfig(): Promise { + // Basic mode needs no OAuth config — the auth scheme is fixed. + if (authMode === 'basic') return null; + try { + const cfg = await resolveConfig(); + chAuthVal = cfg.chAuth; + basicUserClaimVal = cfg.basicUserClaim || ''; + return cfg; + } catch { + return null; + } + } + + // --- credentials (HTTP Basic) sign-in ---------------------------------- + // Validate a ClickHouse username/password against `host` (blank → the serving + // host) with a probe query, then commit the session (NO render — the shell + // re-renders after connectBasic resolves; app.ts's own `connect` used to call + // `app.renderApp()` here, which is the shell's job now). The probe uses a + // throwaway ctx so a bad password surfaces CH's own reason here (rejected as + // a thrown Error) instead of auto-triggering onAuthLost. + async function connectBasic({ username, password, host }: { username: string; password: string; host?: string }): Promise { + const user = String(username || '').trim(); + const target = resolveTarget(host, loc.origin); + const creds = btoa(unescape(encodeURIComponent(user + ':' + (password || '')))); + const probeCtx: NetChCtx = { + fetch: fetchFn, + origin: target, + getToken: async () => creds, + authHeader: () => 'Basic ' + creds, + refresh: async () => false, + onSignedOut: (detail?: string) => { throw new Error(detail || 'Authentication failed'); }, + }; + await queryJsonFn(probeCtx, 'SELECT 1'); + // Probe passed → commit the session and switch the live ctx to the target. + authMode = 'basic'; + ss.setItem('ch_basic_auth', creds); + ss.setItem('ch_basic_user', user); + ss.setItem('ch_basic_origin', target); + chCtx.origin = target; + } + + // --- dashboard (#149 D1) ------------------------------------------------- + // ensureConfig + getToken, resolving (and refreshing) the auth token ONCE. + // The dashboard calls this before fanning tiles out, so the tiles never each + // race an expired-token refresh (a rotating refresh token used N-ways at once + // would invalidate itself), and a single sign-out is handled by the caller + // instead of N tiles each firing onSignedOut. Also used by bootstrap to + // refresh a handed-off-but-expired token before falling back to login. + async function ensureFreshToken(): Promise { + await ensureConfig(); + return !!(await getToken()); + } + + // One-time cross-tab auth handoff. The dashboard opens in a new same-origin + // tab whose sessionStorage starts empty; rather than force a second sign-in, + // this (opener) side grants its live credentials once when the child asks. + // Both sides pin the target origin AND the peer window; a timeout stops the + // opener listening if the child never asks. Message contract: core/auth-handoff. + // Two windows: the child waits `handoffMs` for a grant once it *asks* (a + // same-origin reply is near-instant, so this is short); the opener listens far + // longer (`handoffListenMs`) because it must survive the child's cold JS load + // before the child can ask — a short opener window would drop a slow tab's + // request and force a needless re-login. + function grantHandoffTo(child: Window): void { + const onMsg = (e: MessageEvent): void => { + if (!isAuthRequest(e, loc.origin, child)) return; + const creds = snapshotAuth(ss); + // Only grant when we actually hold credentials — never hand over an empty + // snapshot (which the child would have to reject anyway). + if (hasAuth(creds)) child.postMessage({ type: AUTH_GRANT, creds }, loc.origin); + win.removeEventListener('message', onMsg); + }; + win.addEventListener('message', onMsg); + win.setTimeout(() => win.removeEventListener('message', onMsg), deps.handoffListenMs); + } + + // Restore a handed-off credential snapshot into BOTH this tab's sessionStorage + // and the already-constructed in-memory auth fields — token/authMode/idp/origin + // were snapshotted from an empty ss at construction, so writing keys back alone + // wouldn't take effect until a reload. + function applyAuthSnapshot(creds: AuthSnapshot): void { + restoreAuth(ss, creds); + if (creds.ch_basic_auth) { + authMode = 'basic'; + chCtx.origin = creds.ch_basic_origin || loc.origin; + } else { + if (creds.oauth_id_token) setTokens(creds.oauth_id_token, creds.oauth_refresh_token); + if (creds.oauth_idp) idpId = creds.oauth_idp; + chCtx.origin = creds.oauth_origin || loc.origin; + } + } + // Child side: ask the opener for credentials once. Resolves true once a valid + // grant is applied; false when there's no opener or the request times out (a + // cold/bookmarked visit → the caller falls through to the normal login flow). + function receiveAuthHandoff(handoffEnv: { opener?: Window | null }): Promise { + return new Promise((resolve) => { + const opener = handoffEnv.opener; + if (!opener) { resolve(false); return; } + let done = false; + const finish = (ok: boolean): void => { + if (done) return; + done = true; + win.removeEventListener('message', onMsg); + resolve(ok); + }; + const onMsg = (e: MessageEvent): void => { + if (!isAuthGrant(e, loc.origin, opener)) return; + // `e.data` is a real handoff grant `{type, creds}` once isAuthGrant has + // confirmed `type` — `AuthMessageEvent`'s own contract type only pins + // `type` (the shared predicate's whole point); `creds` rides alongside. + const data = e.data as { type?: string; creds?: AuthSnapshot } | null; + // Ignore an empty grant (opener signed out / mid-sign-in) — keep waiting so + // the request times out into the normal login rather than falsely + // reporting success with no credentials applied. + if (!hasAuth(data?.creds)) return; + applyAuthSnapshot(data!.creds!); + finish(true); + }; + win.addEventListener('message', onMsg); + opener.postMessage({ type: AUTH_REQUEST }, loc.origin); + win.setTimeout(() => finish(false), deps.handoffMs); + }); + } + + return { + basePath, + hostHint, + chCtx, + token: () => token, + refreshToken: () => refreshTok, + authMode: () => authMode, + idpId: () => idpId, + chAuth: () => chAuthVal, + basicUserClaim: () => basicUserClaimVal, + isSignedIn, + email, + // The host queries actually go to. chCtx.origin already resolves to the basic + // target, the picked OAuth cluster (oauth_origin), or the serving origin — so a + // cross-origin OAuth connection shows the cluster, not localhost. (URL.host drops + // a default :443, so a 443 cluster shows a bare hostname; an 8443 one shows :8443.) + host: () => originHost(chCtx.origin) || 'clickhouse', + loadIdps: loadDoc, + selectIdp, + resolveConfig, + ensureConfig, + setTokens, + getToken, + beginOAuth, + connectBasic, + signOut, + ensureFreshToken, + grantHandoffTo, + receiveAuthHandoff, + }; +} diff --git a/src/core/jwt.ts b/src/core/jwt.ts index ba1167e..4fb3bd6 100644 --- a/src/core/jwt.ts +++ b/src/core/jwt.ts @@ -4,7 +4,8 @@ /** A decoded JWT payload — `exp` (seconds since epoch) is the one claim this * module reads itself; other claims (email/preferred_username/sub/…) pass - * through untyped for `app.js`'s `chUsername`. */ + * through untyped for the ConnectionSession's `chUsername` + * (`application/connection-session.ts`, #276 Phase 2). */ export interface JwtPayload { exp?: number; [key: string]: unknown; diff --git a/src/core/pkce.ts b/src/core/pkce.ts index d598513..f0a54e2 100644 --- a/src/core/pkce.ts +++ b/src/core/pkce.ts @@ -7,13 +7,13 @@ * `ArrayBuffer`-backed form (`new Uint8Array(n)`'s actual type) since * lib.dom's own `Crypto.getRandomValues` requires exactly that, not the * wider `ArrayBufferLike` (which also admits `SharedArrayBuffer`). */ -interface RandomBytesSource { +export interface RandomBytesSource { getRandomValues(array: Uint8Array): Uint8Array; } /** The minimal Web Crypto surface `generatePKCE` uses — real Web Crypto (the * global `crypto`, browser or Node's `webcrypto`) or an injectable stub. */ -interface PkceCrypto extends RandomBytesSource { +export interface PkceCrypto extends RandomBytesSource { subtle: { digest(algorithm: string, data: BufferSource): Promise }; } diff --git a/src/ui/app.ts b/src/ui/app.ts index 14079f1..eac7393 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -23,13 +23,11 @@ import type { } from '../core/param-pipeline.js'; import { hasOptionalBlocks } from '../core/optional-blocks.js'; import { saveJSON, saveStr } from '../core/storage.js'; -import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { sqlString, inferQueryName, shortVersion, supportsExplainPretty, userShortName, withStatementBreak, detectSqlFormat, isSchemaMutatingSql, prepareExportSql, formatBytes, formatRows } from '../core/format.js'; import { EXPLAIN_VIEWS, parseExplain, detectExplainView, buildExplainQuery } from '../core/explain.js'; import { buildSchemaGraph, expandLineage } from '../core/schema-graph.js'; import { buildCardGraph } from '../core/schema-cards.js'; import type { SchemaCardColumnRow } from '../core/schema-cards.js'; -import { resolveTarget } from '../core/target.js'; import { toTSV, formatFileMeta, exportFilename, scriptExportName } from '../core/export.js'; import { newResult, parseErrorPos, findExceptionFrame } from '../core/stream.js'; import { buildResultSource } from '../core/query-source.js'; @@ -45,14 +43,8 @@ import { import type { SpecValidatorEntry, QuerySpecValidationService } from '../core/spec-draft.js'; import type { SpecDiagnostic } from '../editor/spec-editor.types.js'; import { assembleReferenceData, buildCompletions } from '../core/completions.js'; -import { generatePKCE, randomState } from '../core/pkce.js'; -import { configBase } from '../core/dashboard.js'; import { isQuerylessPanel } from '../core/panel-cfg.js'; import { isKpiPanel, panelExecution } from '../core/panel-execution.js'; -import { snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, AUTH_REQUEST, AUTH_GRANT } from '../core/auth-handoff.js'; -import type { AuthSnapshot } from '../core/auth-handoff.js'; -import * as oauthCfg from '../net/oauth-config.js'; -import * as oauth from '../net/oauth.js'; import * as ch from '../net/ch-client.js'; import { createNoopPort } from '../editor/editor-port.js'; import type { EditorPort } from '../editor/editor-port.types.js'; @@ -93,11 +85,12 @@ import { openShortcuts } from './shortcuts.js'; import { startDrag } from './splitters.js'; import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from './splitters.js'; import { flashToast } from './toast.js'; -import type { App, ActionsRegistry, SchemaFocus, ChCtx as AppChCtx } from './app.types.js'; +import type { App, ActionsRegistry, SchemaFocus } from './app.types.js'; import type { CreateAppEnv } from '../env.types.js'; import type { SchemaGraphFocus, SchemaGraphNode, SchemaGraphEdge } from '../core/schema-graph.js'; import type { LineageFocus } from '../net/ch-client.js'; import { createQueryExecutionService } from '../application/query-execution-service.js'; +import { createConnectionSession } from '../application/connection-session.js'; /** Optional globals a plain browser page (or the CM6/Chart/dagre UMD bundles a * `