diff --git a/.changeset/27774-adapter-base-url-origin.md b/.changeset/27774-adapter-base-url-origin.md new file mode 100644 index 00000000000..58d46dc61d9 --- /dev/null +++ b/.changeset/27774-adapter-base-url-origin.md @@ -0,0 +1,14 @@ +--- +'@object-ui/data-objectstack': patch +'@objectstack/skills': patch +--- + +fix(data-objectstack): `ObjectStackAdapter` reaches discovery from a `baseUrl` that ends in `/api/vN`, and the objectui skill teaches the server origin + +Clause-②: no +Metadata-protocol: none + +`new ObjectStackAdapter({ baseUrl: '/api/v1' })` — the form the published `objectui` skill taught — asked for `/api/v1/api/v1/discovery`, so `connect()` failed and every read and write that awaits it failed with it. The adapter's own search, data, export and meta reads already dropped a trailing `/api/vN`; discovery and the wrapped `ObjectStackClient` did not. + +- The adapter now reads `baseUrl` as the server origin once, where it and its client are built: a trailing slash and a trailing `/api/vN` are dropped, so `''`, `http://host:3000`, `/api/v1` and `http://host:3000/api/v1` all reach `/api/v1/discovery` and the same `/api/v1/...` routes, through the client and through the adapter's raw reads alike. The five per-call suffix checks are gone; nothing else about the URLs changes. +- `skills/objectui` (`guides/data-integration.md`, `guides/auth-permissions.md`) and the package README teach the origin form the console passes (`''` behind a same-origin proxy, or the server origin), and the data-integration eval refuses an answer that writes `baseUrl: '/api/v1'`. diff --git a/skills/objectui/evals/data-integration.json b/skills/objectui/evals/data-integration.json index 22035ef699b..d5264b52c01 100644 --- a/skills/objectui/evals/data-integration.json +++ b/skills/objectui/evals/data-integration.json @@ -22,7 +22,7 @@ { "id": 2, "prompt": "I want to build my ObjectUI frontend before the backend is deployed. I have a Todo object with title, completed, and dueDate fields. How do I get a working API locally?", - "expected_output": "Runs the real server locally with os dev (the Todo object in objectstack.config) and proxies /api to it from the Vite dev server; no MSW plugin.", + "expected_output": "Runs the server with os dev (Todo in objectstack.config), proxies /api from Vite, hands ObjectStackAdapter the origin ''; no MSW.", "files": [], "assertions": { "must_contain": [ @@ -32,7 +32,8 @@ ], "must_not_contain": [ "enableBrowser", - "setupWorker" + "setupWorker", + "baseUrl: '/api/v1" ] } }, diff --git a/skills/objectui/guides/auth-permissions.md b/skills/objectui/guides/auth-permissions.md index baeb980c3db..0dc2a9a29fd 100644 --- a/skills/objectui/guides/auth-permissions.md +++ b/skills/objectui/guides/auth-permissions.md @@ -133,7 +133,7 @@ const authClient = createAuthClient({ baseURL: '/api/v1/auth' }); const authenticatedFetch = createAuthenticatedFetch(); const dataSource = new ObjectStackAdapter({ - baseUrl: '/api/v1', + baseUrl: '', fetch: authenticatedFetch, // Bearer token injected on every request }); ``` diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md index a9f5f7d0b33..bfe45c58f36 100644 --- a/skills/objectui/guides/data-integration.md +++ b/skills/objectui/guides/data-integration.md @@ -130,7 +130,7 @@ import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; const dataSource = new ObjectStackAdapter({ - baseUrl: '/api/v1', + baseUrl: '', // the server ORIGIN: '' behind a same-origin proxy, or 'https://api.example.com' }); function App() { @@ -152,7 +152,7 @@ const authClient = createAuthClient({ baseURL: '/api/v1/auth' }); const authenticatedFetch = createAuthenticatedFetch(authClient); const dataSource = new ObjectStackAdapter({ - baseUrl: '/api/v1', + baseUrl: '', fetch: authenticatedFetch, // Injects Bearer token automatically }); ``` @@ -204,7 +204,7 @@ The built-in adapter for ObjectStack backends (`packages/data-objectstack`). ```typescript new ObjectStackAdapter({ - baseUrl: string; // API base URL + baseUrl: string; // Server origin ('' = same origin); the adapter adds /api/v1 token?: string; // Static API token autoReconnect?: boolean; // Auto-reconnect on disconnect (default: true) maxReconnectAttempts?: number; // Max retry count (default: 5) @@ -215,7 +215,7 @@ new ObjectStackAdapter({ ### Connection management ```typescript -const adapter = new ObjectStackAdapter({ baseUrl: '/api/v1' }); +const adapter = new ObjectStackAdapter({ baseUrl: '' }); // Listen for connection state changes adapter.onConnectionStateChange((event) => { @@ -459,7 +459,7 @@ export class RestApiAdapter implements DataSource { For real-time UI updates when data changes: ```typescript -const adapter = new ObjectStackAdapter({ baseUrl: '/api/v1' }); +const adapter = new ObjectStackAdapter({ baseUrl: '' }); // Subscribe to mutations const unsubscribe = adapter.onMutation?.((event) => { diff --git a/ui/packages/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index d27f83420e4..7dbde7626fb 100644 --- a/ui/packages/data-objectstack/README.md +++ b/ui/packages/data-objectstack/README.md @@ -901,7 +901,7 @@ is: ```ts import { ObjectStackAdapter, type MetaItemAccess } from '@object-ui/data-objectstack'; -const adapter = new ObjectStackAdapter({ baseUrl: '/api/v1' }); +const adapter = new ObjectStackAdapter({ baseUrl: '' }); const access: MetaItemAccess = await adapter.probeMetaItemAccess('dashboard', 'legal_board'); // { verdict: 'denied', message: "You do not have permission to open the 'legal_board' dashboard." } diff --git a/ui/packages/data-objectstack/src/connectBaseUrlApiSuffix-27774.test.ts b/ui/packages/data-objectstack/src/connectBaseUrlApiSuffix-27774.test.ts new file mode 100644 index 00000000000..a3c00425148 --- /dev/null +++ b/ui/packages/data-objectstack/src/connectBaseUrlApiSuffix-27774.test.ts @@ -0,0 +1,87 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectStackAdapter` reaches `/api/v1/discovery` from BOTH spellings of + * `baseUrl` (objectstack-ai/objectstack#27774): the server origin the console + * passes (`''` behind a same-origin proxy, or `http://host:3000`), and a base + * that already ends in `/api/vN`, which the published `objectui` skill taught. + * + * Discovery used to join `/api/v1/discovery` onto the base as given, so + * `baseUrl: '/api/v1'` asked for `/api/v1/api/v1/discovery`, `connect()` + * failed, and every read and write that awaits it failed with it. The + * adapter's own search, data, export and meta reads already dropped the + * suffix; the client it wraps did not, so a fix that stripped discovery alone + * would have left every client-routed read (`GET /api/v1/meta/:type`) doubled. + * The base is therefore normalised ONCE, where the adapter and its client are + * built, and these pins read the URL the injected fetch actually received. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index'; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +/** The adapter over a fake server that answers discovery and one metadata list. */ +function adapterAt(baseUrl: string) { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/api/v1/discovery')) return json({ success: true, data: { routes: {} } }); + if (url.endsWith('/api/v1/meta/position')) { + return json({ success: true, data: { type: 'position', items: [{ name: 'auditor' }] } }); + } + return new Response(JSON.stringify({ success: false, error: { code: 'NOT_FOUND' } }), { status: 404 }); + }); + const adapter = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl, autoReconnect: false }); + const urls = () => fetchImpl.mock.calls.map(([u]) => String(u)); + return { adapter, urls }; +} + +describe('ObjectStackAdapter baseUrl spellings reach discovery (objectstack-ai/objectstack#27774)', () => { + beforeEach(() => { + clearSharedDiscoveryCache(); + }); + + it.each([ + ['the server origin', 'http://fake-27774.test', 'http://fake-27774.test/api/v1/discovery'], + ['an empty base behind a same-origin proxy', '', '/api/v1/discovery'], + ['a relative base ending in /api/v1', '/api/v1', '/api/v1/discovery'], + ['an absolute base ending in /api/v1', 'http://fake-27774.test/api/v1', 'http://fake-27774.test/api/v1/discovery'], + ['a base ending in /api/v1 and a trailing slash', 'http://fake-27774.test/api/v1/', 'http://fake-27774.test/api/v1/discovery'], + ])('connects from %s', async (_label, baseUrl, discoveryUrl) => { + const { adapter, urls } = adapterAt(baseUrl); + + await expect(adapter.connect()).resolves.toBeUndefined(); + + expect(urls()).toEqual([discoveryUrl]); + expect(adapter.isConnected()).toBe(true); + }); + + it('routes a client read through the same base once, not /api/v1/api/v1', async () => { + // `listMetadataItems` goes through the wrapped `ObjectStackClient`, which + // joins the conventional `/api/v1/meta` route onto ITS base — the half a + // discovery-only strip would have left doubled. + const { adapter, urls } = adapterAt('http://fake-27774.test/api/v1'); + + await expect(adapter.listMetadataItems('position')).resolves.toEqual([{ name: 'auditor' }]); + + expect(urls()).toEqual([ + 'http://fake-27774.test/api/v1/discovery', + 'http://fake-27774.test/api/v1/meta/position', + ]); + }); + + it('keeps a path prefix that is not an API version', async () => { + const { adapter, urls } = adapterAt('http://fake-27774.test/tenant-a'); + + await adapter.connect(); + + expect(urls()).toEqual(['http://fake-27774.test/tenant-a/api/v1/discovery']); + }); +}); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index a258fe17a1e..25b604f77cb 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -936,6 +936,19 @@ const VIEW_LIST_KEY = 'list:view'; const discoveryCache = new Map>(); +/** + * The server ORIGIN a `baseUrl` names: no trailing slash, and no trailing + * `/api/vN`. The adapter, the `ObjectStackClient` it wraps and + * `ObjectStackClient`'s conventional routes all join absolute `/api/v1/...` + * paths onto this value, so a base already carrying the version suffix (the + * form the published `objectui` skill once taught) is accepted and read as its + * origin, instead of asking for `/api/v1/api/v1/discovery` + * (objectstack-ai/objectstack#27774). + */ +function serverOriginOf(baseUrl: string): string { + return (baseUrl || '').replace(/\/$/, '').replace(/\/api\/v\d+$/i, ''); +} + /** * Fetch the server `discovery` document once per (baseUrl) and reuse the * resulting Promise. Used by `ObjectStackAdapter.connect()` (and any caller @@ -3349,6 +3362,12 @@ export class ObjectStackAdapter implements DataSource { private metadataReadWarningListeners = new Set(); constructor(config: { + /** + * The server origin: `''` behind a same-origin proxy, or + * `https://api.example.com`. The adapter joins `/api/v1/...` onto it; a + * value already ending in `/api/vN` is read as its origin + * ({@link serverOriginOf}). + */ baseUrl: string; token?: string; fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -3372,12 +3391,16 @@ export class ObjectStackAdapter implements DataSource { const transport = config.fetch || globalThis.fetch.bind(globalThis); this.fetchImpl = (input, init) => this.observeServerAnswer(() => transport(input, init), init); + // objectstack-ai/objectstack#27774 — one origin for the client and for every + // raw read below; the client joins `/api/v1/...` onto its base as given. + const baseUrl = serverOriginOf(config.baseUrl); // Inject a quiet logger that demotes expected 404s ("HTTP request failed" // from probing optional collections like sys_presence/sys_activity) to // debug() so they don't pollute the browser console. Other log levels are // forwarded to the standard console. this.client = new ObjectStackClient({ ...config, + baseUrl, fetch: this.fetchImpl, logger: createQuietHttpLogger(), }); @@ -3390,7 +3413,7 @@ export class ObjectStackAdapter implements DataSource { this.autoReconnect = config.autoReconnect ?? true; this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3; this.reconnectDelay = config.reconnectDelay ?? 1000; - this.baseUrl = config.baseUrl; + this.baseUrl = baseUrl; this.token = config.token; } @@ -3429,10 +3452,8 @@ export class ObjectStackAdapter implements DataSource { // (or React StrictMode double-mounts) at the same baseUrl share a // single network round trip. We inject the result into the client's // private `discoveryInfo` field to avoid client.connect() re-fetching. - const baseUrl = this.baseUrl || ''; - const discoveryUrl = baseUrl - ? `${baseUrl.replace(/\/$/, '')}/api/v1/discovery` - : '/api/v1/discovery'; + const baseUrl = this.baseUrl; + const discoveryUrl = `${baseUrl}/api/v1/discovery`; const data = await getSharedDiscovery(baseUrl, async () => { const res = await this.fetchImpl(discoveryUrl, { @@ -3762,11 +3783,7 @@ export class ObjectStackAdapter implements DataSource { queryParams.set('objects', options.objects.join(',')); } - const baseUrl = (this.baseUrl || '').replace(/\/$/, ''); - // Avoid doubling /api/v1 when baseUrl already carries the version suffix. - const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl); - const searchPath = hasApiVersionSuffix ? '/search' : '/api/v1/search'; - const url = `${baseUrl}${searchPath}?${queryParams.toString()}`; + const url = `${this.baseUrl}/api/v1/search?${queryParams.toString()}`; const headers: Record = { 'Content-Type': 'application/json', @@ -5159,12 +5176,8 @@ export class ObjectStackAdapter implements DataSource { } } - const baseUrl = this.baseUrl.replace(/\/$/, ''); const qs = queryParams.toString(); - // Avoid doubling /api/v1 if baseUrl already includes it - const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl); - const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data'; - const url = `${baseUrl}${dataPath}/${resource}${qs ? `?${qs}` : ''}`; + const url = `${this.baseUrl}/api/v1/data/${resource}${qs ? `?${qs}` : ''}`; const headers: Record = { 'Content-Type': 'application/json', @@ -5291,11 +5304,7 @@ export class ObjectStackAdapter implements DataSource { queryParams: URLSearchParams, extraHeaders?: Record, ): Promise { - const baseUrl = this.baseUrl.replace(/\/$/, ''); - // Avoid doubling /api/v1 if baseUrl already includes the version suffix. - const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl); - const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data'; - const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`; + const url = `${this.baseUrl}/api/v1/data/${encodeURIComponent(resource)}/export?${queryParams.toString()}`; const headers: Record = { ...this.getAuthHeaders(), ...extraHeaders }; // `credentials: 'include'` carries the session cookie for the browser @@ -5460,12 +5469,7 @@ export class ObjectStackAdapter implements DataSource { * not expose the request cache mode. */ private async fetchObjectSchemaFresh(objectName: string): Promise { - const baseUrl = (this.baseUrl || '').replace(/\/$/, ''); - // Avoid doubling /api/v1 when baseUrl already carries the version suffix - // (mirrors rawFindWithPopulate). - const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl); - const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta'; - const url = `${baseUrl}${metaPath}/object/${encodeURIComponent(objectName)}`; + const url = `${this.baseUrl}/api/v1/meta/object/${encodeURIComponent(objectName)}`; const headers: Record = { 'Content-Type': 'application/json' }; // Bearer (server-to-server) callers configure `this.token`; cookie/console @@ -5526,12 +5530,7 @@ export class ObjectStackAdapter implements DataSource { async getObjects(): Promise> { try { await this.connect(); - const baseUrl = (this.baseUrl || '').replace(/\/$/, ''); - // Avoid doubling /api/v1 when baseUrl already carries the version suffix - // (mirrors fetchObjectSchemaFresh). - const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl); - const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta'; - const url = `${baseUrl}${metaPath}/object`; + const url = `${this.baseUrl}/api/v1/meta/object`; const headers: Record = { 'Content-Type': 'application/json' }; if (this.token) headers['Authorization'] = `Bearer ${this.token}`; @@ -6873,8 +6872,7 @@ export class ObjectStackAdapter implements DataSource { totals?: Array<{ dimensions: string[]; rows: Array> }>; }> { await this.connect(); - const base = (this.baseUrl || '').replace(/\/$/, ''); - const url = `${base}/api/v1/analytics/dataset/query`; + const url = `${this.baseUrl}/api/v1/analytics/dataset/query`; // ADR-0037 P3 — draft data preview. Preview mode is URL-keyed by design // (`?preview=draft` flips the whole document, incl. the Live Canvas // iframe), so the adapter reads it straight off the location rather than