Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/27774-adapter-base-url-origin.md
Original file line number Diff line number Diff line change
@@ -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'`.
5 changes: 3 additions & 2 deletions skills/objectui/evals/data-integration.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -32,7 +32,8 @@
],
"must_not_contain": [
"enableBrowser",
"setupWorker"
"setupWorker",
"baseUrl: '/api/v1"
]
}
},
Expand Down
2 changes: 1 addition & 1 deletion skills/objectui/guides/auth-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```
Expand Down
10 changes: 5 additions & 5 deletions skills/objectui/guides/data-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
});
```
Expand Down Expand Up @@ -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)
Expand All @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/data-objectstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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." }
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
66 changes: 32 additions & 34 deletions ui/packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,19 @@ const VIEW_LIST_KEY = 'list:view';

const discoveryCache = new Map<string, Promise<unknown>>();

/**
* 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
Expand Down Expand Up @@ -3349,6 +3362,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
private metadataReadWarningListeners = new Set<MetadataReadWarningListener>();

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<Response>;
Expand All @@ -3372,12 +3391,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
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(),
});
Expand All @@ -3390,7 +3413,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
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;
}

Expand Down Expand Up @@ -3429,10 +3452,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
// (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, {
Expand Down Expand Up @@ -3762,11 +3783,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
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<string, string> = {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -5159,12 +5176,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
}
}

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<string, string> = {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -5291,11 +5304,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
queryParams: URLSearchParams,
extraHeaders?: Record<string, string>,
): Promise<Blob> {
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<string, string> = { ...this.getAuthHeaders(), ...extraHeaders };
// `credentials: 'include'` carries the session cookie for the browser
Expand Down Expand Up @@ -5460,12 +5469,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
* not expose the request cache mode.
*/
private async fetchObjectSchemaFresh(objectName: string): Promise<unknown> {
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<string, string> = { 'Content-Type': 'application/json' };
// Bearer (server-to-server) callers configure `this.token`; cookie/console
Expand Down Expand Up @@ -5526,12 +5530,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
async getObjects(): Promise<Array<{ name: string; label?: string }>> {
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<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
Expand Down Expand Up @@ -6873,8 +6872,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
}> {
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
Expand Down
Loading