diff --git a/knip.json b/knip.json index 21f7b9ca0..b46121129 100644 --- a/knip.json +++ b/knip.json @@ -14,6 +14,7 @@ "@databricks/sdk-core", "@databricks/sdk-experimental", "@databricks/sdk-options", + "@databricks/sdk-scim", "@databricks/sdk-statementexecution", "@databricks/sdk-warehouses", "@mlflow/core", diff --git a/packages/appkit/package.json b/packages/appkit/package.json index f3d8a8c32..fb1ce78ec 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -75,6 +75,7 @@ "@databricks/sdk-core": "0.51.0", "@databricks/sdk-experimental": "0.17.0", "@databricks/sdk-options": "0.51.0", + "@databricks/sdk-scim": "0.51.0", "@databricks/sdk-statementexecution": "0.52.0", "@databricks/sdk-warehouses": "0.53.0", "@opentelemetry/api": "1.9.0", diff --git a/packages/appkit/src/context/service-context.ts b/packages/appkit/src/context/service-context.ts index 789457c93..ef9142159 100644 --- a/packages/appkit/src/context/service-context.ts +++ b/packages/appkit/src/context/service-context.ts @@ -44,6 +44,8 @@ export interface ServiceContextState { */ export class ServiceContext { private static instance: ServiceContextState | null = null; + /** Workspace host resolved at init (profile-aware); dev fallback for OBO. */ + private static resolvedHost: string | undefined; private static initPromise: Promise | null = null; /** @@ -111,11 +113,11 @@ export class ServiceContext { throw AuthenticationError.missingToken("user token"); } - // Local templates can configure only a profile, whose host the SDK resolved. + // Local templates can configure only a profile, whose host init resolved. const host = process.env.DATABRICKS_HOST || (process.env.NODE_ENV === "development" && ServiceContext.isInitialized() - ? ServiceContext.get().client.config?.host + ? ServiceContext.resolvedHost : undefined); if (!host) { throw ConfigurationError.missingEnvVar("DATABRICKS_HOST"); @@ -183,12 +185,14 @@ export class ServiceContext { const wsClient = client ?? createWorkspaceClient({ clientOptions: getClientOptions() }); - const [resolvedWorkspaceId, currentUser, resolvedResources] = + const [resolvedWorkspaceId, currentUser, resolvedResources, host] = await Promise.all([ ServiceContext.getWorkspaceId(wsClient), - wsClient.currentUser.me(), + wsClient.currentUser.me({}), WarehouseResource.resolve(wsClient, options?.warehouseId), + ServiceContext.resolveHost(wsClient), ]); + ServiceContext.resolvedHost = host; if (!currentUser.id) { throw ConfigurationError.resourceNotFound("Service user ID"); @@ -240,12 +244,27 @@ export class ServiceContext { return workspaceId; } + /** + * Resolve the host locally (env or profile, no network). Never fails startup: + * without a host, createCallerContext throws missingEnvVar instead. + */ + private static async resolveHost( + client: WorkspaceClient, + ): Promise { + try { + return await client.getHost(); + } catch { + return undefined; + } + } + /** * Reset the service context. Only for testing purposes. */ static reset(): void { ServiceContext.instance = null; ServiceContext.initPromise = null; + ServiceContext.resolvedHost = undefined; WarehouseResource.reset(); } } diff --git a/packages/appkit/src/context/tests/service-context.test.ts b/packages/appkit/src/context/tests/service-context.test.ts index 3251ea867..f2859edb4 100644 --- a/packages/appkit/src/context/tests/service-context.test.ts +++ b/packages/appkit/src/context/tests/service-context.test.ts @@ -12,34 +12,47 @@ import { ServiceContext } from "../service-context"; // ── Mock the workspace-client wrapper ────────────────────────────── -const { mockMe, mockApiRequest, MockWorkspaceClient, MockConfigError } = - vi.hoisted(() => { - const mockMe = vi.fn(); - const mockApiRequest = vi.fn(); - - const MockWorkspaceClient = vi.fn().mockImplementation(() => ({ - currentUser: { me: mockMe }, - // Tests script legacy-style results; adapt them to the raw `Response` - // `client.request` returns (org id → response header, else JSON body). - request: async (req: unknown) => { - const result = await mockApiRequest(req); - const orgId = result?.["x-databricks-org-id"]; - return orgId !== undefined - ? new Response(null, { headers: { "x-databricks-org-id": orgId } }) - : new Response(JSON.stringify(result ?? {})); - }, - })); - - class MockConfigError extends Error { - baseMessage: string; - constructor(message: string) { - super(message); - this.baseMessage = message; - } +const { + mockMe, + mockGetHost, + mockApiRequest, + MockWorkspaceClient, + MockConfigError, +} = vi.hoisted(() => { + const mockMe = vi.fn(); + const mockGetHost = vi.fn(); + const mockApiRequest = vi.fn(); + + const MockWorkspaceClient = vi.fn().mockImplementation(() => ({ + currentUser: { me: mockMe }, + getHost: mockGetHost, + // Tests script legacy-style results; adapt them to the raw `Response` + // `client.request` returns (org id → response header, else JSON body). + request: async (req: unknown) => { + const result = await mockApiRequest(req); + const orgId = result?.["x-databricks-org-id"]; + return orgId !== undefined + ? new Response(null, { headers: { "x-databricks-org-id": orgId } }) + : new Response(JSON.stringify(result ?? {})); + }, + })); + + class MockConfigError extends Error { + baseMessage: string; + constructor(message: string) { + super(message); + this.baseMessage = message; } + } - return { mockMe, mockApiRequest, MockWorkspaceClient, MockConfigError }; - }); + return { + mockMe, + mockGetHost, + mockApiRequest, + MockWorkspaceClient, + MockConfigError, + }; +}); vi.mock("../../workspace-client", async (importOriginal) => { const actual = @@ -325,26 +338,65 @@ describe("ServiceContext", () => { ); }); - test("uses the initialized profile host for local callers without DATABRICKS_HOST", () => { + // Regression: the dev fallback must come from the modular getHost() at + // init, not the legacy Config.host (only filled after a legacy API call). + async function initWithProfileHost() { + ServiceContext.reset(); + mockGetHost.mockResolvedValue("https://profile-host.example.com"); + await ServiceContext.initialize({ warehouseId: true }); + } + + test("uses the init-resolved profile host for local callers without DATABRICKS_HOST", async () => { delete process.env.DATABRICKS_HOST; process.env.NODE_ENV = "development"; - process.env.DATABRICKS_CONFIG_PROFILE = "selected-user"; - Object.defineProperty(ServiceContext.get().client, "config", { - value: { host: "https://profile-workspace.databricks.com" }, - }); + await initWithProfileHost(); const caller = ServiceContext.createCallerContext("user-token", "alice"); expect(caller.principal).toMatchObject({ type: "user", userId: "alice" }); expect(MockWorkspaceClient).toHaveBeenLastCalledWith( expect.objectContaining({ - host: "https://profile-workspace.databricks.com", + host: "https://profile-host.example.com", token: "user-token", authType: "pat", }), ); }); + test("DATABRICKS_HOST wins over the profile host", async () => { + process.env.NODE_ENV = "development"; + await initWithProfileHost(); + process.env.DATABRICKS_HOST = "https://env-host.example.com"; + + ServiceContext.createCallerContext("user-token", "alice"); + + expect(MockWorkspaceClient).toHaveBeenLastCalledWith( + expect.objectContaining({ host: "https://env-host.example.com" }), + ); + }); + + test("outside development the profile host is ignored and it still throws", async () => { + process.env.NODE_ENV = "production"; + await initWithProfileHost(); + delete process.env.DATABRICKS_HOST; + + expect(() => + ServiceContext.createCallerContext("user-token", "alice"), + ).toThrow(ConfigurationError); + }); + + test("a getHost() failure does not fail startup; callers get missingEnvVar", async () => { + ServiceContext.reset(); + process.env.NODE_ENV = "development"; + mockGetHost.mockRejectedValue(new Error("no host configured")); + await ServiceContext.initialize({ warehouseId: true }); + delete process.env.DATABRICKS_HOST; + + expect(() => + ServiceContext.createCallerContext("user-token", "alice"), + ).toThrow(ConfigurationError); + }); + test("should throw InitializationError when service context is not initialized", () => { ServiceContext.reset(); diff --git a/packages/shared/package.json b/packages/shared/package.json index 85d2a0672..867a9f2b4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -52,6 +52,7 @@ "@databricks/sdk-core": "0.51.0", "@databricks/sdk-experimental": "0.17.0", "@databricks/sdk-options": "0.51.0", + "@databricks/sdk-scim": "0.51.0", "@databricks/sdk-statementexecution": "0.52.0", "@databricks/sdk-warehouses": "0.53.0", "@standard-schema/spec": "1.1.0", diff --git a/packages/shared/src/workspace-client/client.ts b/packages/shared/src/workspace-client/client.ts index db0fab36e..29494bb3c 100644 --- a/packages/shared/src/workspace-client/client.ts +++ b/packages/shared/src/workspace-client/client.ts @@ -13,9 +13,11 @@ import { type WorkspaceClientOptions, } from "./legacy"; import { + buildScimClient, buildStatementExecutionClient, buildWarehousesClient, buildWorkspaceAuth, + type ScimClient, type StatementExecutionClient, type WarehousesClient, type WorkspaceAuth, @@ -29,6 +31,7 @@ export class AppKitWorkspaceClient implements WorkspaceClient { #warehouses?: WarehousesClient; #statementExecution?: StatementExecutionClient; #auth?: WorkspaceAuth; + #currentUser?: ScimClient; constructor(opts: WorkspaceClientOptions) { this.#opts = opts; @@ -66,8 +69,12 @@ export class AppKitWorkspaceClient implements WorkspaceClient { return this.#getLegacy().servingEndpoints; } - get currentUser() { - return this.#getLegacy().currentUser; + // Migrated to the modular SDK (SCIM) — built lazily, independent of the legacy client. + get currentUser(): ScimClient { + if (!this.#currentUser) { + this.#currentUser = buildScimClient(this.#opts); + } + return this.#currentUser; } // Modular auth + raw-request seam — built lazily, independent of the legacy client. diff --git a/packages/shared/src/workspace-client/modular.ts b/packages/shared/src/workspace-client/modular.ts index 1b1c78754..377836417 100644 --- a/packages/shared/src/workspace-client/modular.ts +++ b/packages/shared/src/workspace-client/modular.ts @@ -36,6 +36,7 @@ import { } from "@databricks/sdk-core/http"; import { resolve } from "@databricks/sdk-core/profiles"; import type { ClientOptions } from "@databricks/sdk-options/client"; +import { ScimClient } from "@databricks/sdk-scim/v1"; import { StatementExecutionClient } from "@databricks/sdk-statementexecution/v1"; import { WarehousesClient } from "@databricks/sdk-warehouses/v1"; @@ -312,7 +313,16 @@ export function buildStatementExecutionClient( return new StatementExecutionClient(mapToClientOptions(opts)); } +/** + * Build a modular SCIM client from wrapper options. Backs the facade's + * `currentUser` accessor: legacy `currentUser.me()` is `ScimClient.me({})`. + */ +export function buildScimClient(opts: WorkspaceClientOptions): ScimClient { + return new ScimClient(mapToClientOptions(opts)); +} + // ── Client type re-exports (for the facade accessor types) ─────────────── +export type { ScimClient } from "@databricks/sdk-scim/v1"; export type { StatementExecutionClient } from "@databricks/sdk-statementexecution/v1"; export type { WarehousesClient } from "@databricks/sdk-warehouses/v1"; diff --git a/packages/shared/src/workspace-client/types.ts b/packages/shared/src/workspace-client/types.ts index b627afb23..bce944dc1 100644 --- a/packages/shared/src/workspace-client/types.ts +++ b/packages/shared/src/workspace-client/types.ts @@ -18,6 +18,7 @@ import type { StatementExecutionClient, WarehousesClient, WorkspaceAuth, + ScimClient, } from "./modular"; // Legacy SDK type namespaces for un-migrated services, re-exported so AppKit @@ -59,8 +60,8 @@ export interface WorkspaceClient extends WorkspaceAuth { /** Serving Endpoints. */ readonly servingEndpoints: LegacyWorkspaceClient["servingEndpoints"]; - /** Current user. */ - readonly currentUser: LegacyWorkspaceClient["currentUser"]; + /** Current user (modular SDK SCIM client; `me({})` returns the caller). */ + readonly currentUser: ScimClient; /** * Legacy SDK `Config`. Prefer `getHost()` / `authenticate(headers)` (modular, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 929d9c01e..ba0ae702c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@databricks/sdk-options': specifier: 0.51.0 version: 0.51.0 + '@databricks/sdk-scim': + specifier: 0.51.0 + version: 0.51.0 '@databricks/sdk-statementexecution': specifier: 0.52.0 version: 0.52.0(patch_hash=9377a46b883b548d47116662bf73b159a4da51ec28b9ff1896a7e4a7841c240c) @@ -621,6 +624,9 @@ importers: '@databricks/sdk-options': specifier: 0.51.0 version: 0.51.0 + '@databricks/sdk-scim': + specifier: 0.51.0 + version: 0.51.0 '@databricks/sdk-statementexecution': specifier: 0.52.0 version: 0.52.0(patch_hash=9377a46b883b548d47116662bf73b159a4da51ec28b9ff1896a7e4a7841c240c) @@ -2012,6 +2018,10 @@ packages: resolution: {integrity: sha512-p5uBh64Y1onnvwlEwfRsmKmGRI23Y4Ly8fZiCgNUwVDe4Z9d0ctI0mo1v4gnDqlmBaBml3TZfqn5BJBROCXsEw==} engines: {node: '>=22.0.0'} + '@databricks/sdk-scim@0.51.0': + resolution: {integrity: sha512-WSTpuID4gRoTxVApyxujW+MsI5ANvmn8/DqPK6fXqxCkSINLEbtR+GTk/th8xnADtJekKNIWzeORFblXd3idcg==} + engines: {node: '>=22.0.0'} + '@databricks/sdk-statementexecution@0.52.0': resolution: {integrity: sha512-ymDGDW67PJ/0VujM89mUrE6mbbrabsKJP5LvPv8ZbgzPR0bm0DwXMcQzvS0XUt0UGITjV2gqbx5jQwP3ijtOmg==} engines: {node: '>=22.0.0'} @@ -14321,6 +14331,15 @@ snapshots: '@databricks/sdk-auth': 0.51.0 '@databricks/sdk-core': 0.51.0 + '@databricks/sdk-scim@0.51.0': + dependencies: + '@databricks/sdk-auth': 0.51.0 + '@databricks/sdk-core': 0.51.0 + '@databricks/sdk-options': 0.51.0 + '@js-temporal/polyfill': 0.5.1 + json-bigint: 1.0.0 + zod: 4.3.6 + '@databricks/sdk-statementexecution@0.52.0(patch_hash=9377a46b883b548d47116662bf73b159a4da51ec28b9ff1896a7e4a7841c240c)': dependencies: '@databricks/sdk-auth': 0.51.0