@isomorph.ai/app-sdk 1.2.1

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.
package/dist/index.js ADDED
@@ -0,0 +1,602 @@
1
+ import { validateIntegrationExecuteInput } from './integration-execute-input-rules.js';
2
+ export class IsomorphError extends Error {
3
+ category;
4
+ requestId;
5
+ details;
6
+ status;
7
+ constructor(category, message, requestId, details,
8
+ /** The HTTP status the gateway answered, when the error came from a response. */
9
+ status) {
10
+ super(message);
11
+ this.category = category;
12
+ this.requestId = requestId;
13
+ this.details = details;
14
+ this.status = status;
15
+ this.name = 'IsomorphError';
16
+ }
17
+ }
18
+ /** The AI reason code carried by an error, when it is a governed-AI refusal. */
19
+ export function aiErrorCode(error) {
20
+ const code = error instanceof IsomorphError ? error.details?.code : undefined;
21
+ return typeof code === 'string' && AI_ERROR_CODES.has(code) ? code : undefined;
22
+ }
23
+ const AI_ERROR_CODES = new Set(['AI_NOT_ENABLED', 'AI_POLICY_DENIED', 'AI_QUOTA_EXCEEDED', 'AI_PROVIDER_ERROR', 'AI_UNAVAILABLE', 'REQUEST_TOO_LARGE', 'VALIDATION_FAILED']);
24
+ // Header names are case-insensitive, but a plain object is not: `Headers`
25
+ // JOINS `X-Harbour-Identity-Context` and `x-harbour-identity-context` into one
26
+ // value "a, b". The kit starter's retained checks attach the identity context
27
+ // themselves through createClient({ fetch }) — the documented pattern — and
28
+ // the runner attaches it too from ISOMORPH_IDENTITY_CONTEXT, so every check
29
+ // sent a joined token the gateway refused (401) and identity.current()
30
+ // answered null (fourier app-f3955673, 2026-09-11). Later layers win by
31
+ // case-insensitive name; the first spelling is kept.
32
+ function mergeHeaders(...layers) {
33
+ const merged = {};
34
+ const spelling = new Map();
35
+ for (const layer of layers) {
36
+ if (!layer)
37
+ continue;
38
+ for (const [key, value] of Object.entries(layer)) {
39
+ const canonical = key.toLowerCase();
40
+ const name = spelling.get(canonical) ?? key;
41
+ spelling.set(canonical, name);
42
+ if (value === undefined)
43
+ delete merged[name];
44
+ else
45
+ merged[name] = value;
46
+ }
47
+ }
48
+ return merged;
49
+ }
50
+ // A signed-in request carries the identity context header. In a browser the
51
+ // platform edge attaches it; in Node nobody does, so a check that imported the
52
+ // application's own client reached the gateway and was refused with "signed-in
53
+ // identity is required". The runner exports the header name and the signed
54
+ // token, so read them here — the same shape as the base URL above, and for the
55
+ // same reason: the brief tells the agent to drive the converted module under
56
+ // the runner and forbids patching fetch, so the SDK must make that true.
57
+ //
58
+ // Request-supplied headers still win the merge. That does NOT yet give a check
59
+ // a way to use the second identity (ISOMORPH_IDENTITY_CONTEXT_SECOND_USER):
60
+ // ClientOptions has no headers field, so a check needing it must build its own
61
+ // client with createClient({ fetch }). The ownership and cross-user coverage
62
+ // rules require that identity, so either the brief should sanction that option
63
+ // or this transport should take a per-request identity.
64
+ function runnerIdentityHeaders(identity) {
65
+ if (typeof location !== 'undefined')
66
+ return {};
67
+ const environment = globalThis.process?.env;
68
+ const name = environment?.ISOMORPH_IDENTITY_CONTEXT_HEADER;
69
+ const token = identity === 'second-user' ? environment?.ISOMORPH_IDENTITY_CONTEXT_SECOND_USER : environment?.ISOMORPH_IDENTITY_CONTEXT;
70
+ const workloadToken = environment?.ISOMORPH_WORKLOAD_TOKEN;
71
+ // Lower case on purpose: header names are case-insensitive on the wire but
72
+ // a plain header object is not. A retained check that attaches the same
73
+ // header itself (`createClient({ fetch })` spreading init.headers and adding
74
+ // 'x-harbour-identity-context') must replace this entry, not sit beside it —
75
+ // Headers joins two spellings into "a, b", which the gateway refuses.
76
+ if (workloadToken)
77
+ return { authorization: `Bearer ${workloadToken}` };
78
+ return name && token ? { [name.toLowerCase()]: token } : {};
79
+ }
80
+ // The default realtime socket. In a browser the platform edge attaches the
81
+ // identity to the upgrade; in Node nobody does, so the runner's identity
82
+ // (the same ISOMORPH_IDENTITY_CONTEXT every HTTP request carries) goes on the
83
+ // upgrade request too. Node's WebSocket accepts request headers as its
84
+ // second argument; a browser's does not, and gets none. Before this a check
85
+ // that subscribed through isomorph.realtime under the runner opened the
86
+ // socket anonymously and waited for SUBSCRIBED until the deadline (fourier
87
+ // app-f3955673, previews 000028 and 000030, 2026-09-11) — unless it read a
88
+ // token name of its own and built the socket itself, which is the kind of
89
+ // wrapper the kit gate now refuses.
90
+ function defaultSocketFactory(url, identity) {
91
+ const headers = runnerIdentityHeaders(identity);
92
+ if (Object.keys(headers).length === 0)
93
+ return new WebSocket(url);
94
+ const Socket = WebSocket;
95
+ return new Socket(url, { headers });
96
+ }
97
+ const IDENTITY_PATH = '/_harbour/identity/current';
98
+ // The platform edge attaches the signed identity context to every request
99
+ // from a fresh context cookie, but when that cookie is absent or stale it can
100
+ // only heal it on a GET/HEAD: healing is a redirect through
101
+ // /__harbour/context/refresh (the browser lands back on the same URL with
102
+ // `?__renewed=1`), and a POST cannot be redirected without losing its body,
103
+ // so the edge forwards it as-is and the gateway answers 401 "signed Isomorph
104
+ // identity is required". A page that mounts by firing identity.current()
105
+ // (GET) and its first data reads (POST) concurrently hits this on every cold
106
+ // load until the GET has minted the cookie, again whenever an open tab
107
+ // outlives the context TTL (fourier app-f3955673, production, 2026-09-12),
108
+ // and again on the first POST after an SSO login: the starter's addNote
109
+ // awaited data.from('notes').insert(), the insert was refused, the promise
110
+ // rejected and the note never existed (preview + production, 2026-09-16).
111
+ //
112
+ // The rule, uniform for every /_harbour/* call (data, files, actions,
113
+ // integrations, ai, telemetry) from a browser: on a 401, renew the identity
114
+ // ONCE through the GET the edge heals (identity.current()), then resend the
115
+ // original request ONCE, then surface the error. Never twice, never on 403
116
+ // or 5xx. The renewal has to be one that STARTED AFTER the refused request
117
+ // left: an identity GET already in flight when the 401 arrives may have
118
+ // passed the edge before the context rotated, and a resend on its word is
119
+ // refused again — so such a GET is awaited and a fresh one made. Resending is
120
+ // safe for a mutation because a 401 is refused before the gateway touches the
121
+ // database: the handler never ran, so the retry is the first and only write.
122
+ // The body is a JSON string, FormData or Blob — all replayable as-is.
123
+ //
124
+ // The identity GET itself is never renewed (it IS the renewal), and under Node
125
+ // the runner's identity is a static token no GET can renew, so a refusal there
126
+ // surfaces at once.
127
+ function renewableOnUnauthorized(path) {
128
+ if (typeof location === 'undefined')
129
+ return false;
130
+ return path.startsWith('/_harbour/') && path !== IDENTITY_PATH;
131
+ }
132
+ class Transport {
133
+ onUnauthorized;
134
+ baseUrl;
135
+ fetch;
136
+ identity;
137
+ renewal;
138
+ constructor(options, onUnauthorized) {
139
+ this.onUnauthorized = onUnauthorized;
140
+ this.baseUrl = (options.baseUrl ?? applicationBaseUrl()).replace(/\/$/, '');
141
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
142
+ this.identity = options.identity;
143
+ }
144
+ json(path, init = {}) {
145
+ return this.request(path, init, renewableOnUnauthorized(path));
146
+ }
147
+ async request(path, init, renewOnUnauthorized) {
148
+ const sentAt = this.renewal?.renewals() ?? 0;
149
+ // (headers with an undefined value are removed so a multipart body can
150
+ // clear the JSON default and let the browser set its own boundary)
151
+ const response = await this.fetch(`${this.baseUrl}${path}`, {
152
+ credentials: 'same-origin',
153
+ ...init,
154
+ headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers),
155
+ });
156
+ const body = await response.json().catch(() => undefined);
157
+ if (!response.ok) {
158
+ if (response.status === 401) {
159
+ if (renewOnUnauthorized && this.renewal) {
160
+ // One renewal, one resend, whatever the renewal answered: a resend
161
+ // after a signed-out answer costs one request and yields the
162
+ // definitive refusal below, with no second renewal.
163
+ await this.renewal.renew(sentAt).catch(() => undefined);
164
+ return this.request(path, init, false);
165
+ }
166
+ // Not before the resend: a page listening to identity.onChange would
167
+ // otherwise see the user flicker to null while the edge was healing.
168
+ this.onUnauthorized?.();
169
+ }
170
+ throw new IsomorphError(body?.error?.category ?? (response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'FORBIDDEN' : 'INTERNAL'), body?.error?.message ?? `Isomorph request failed with ${response.status}`, body?.requestId ?? response.headers.get('x-request-id') ?? undefined, body?.error?.details, response.status);
171
+ }
172
+ return (body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body);
173
+ }
174
+ }
175
+ function applicationBaseUrl() {
176
+ if (typeof location === 'undefined') {
177
+ // No browser: this module is running under the retained-check runner in
178
+ // Node, which exports ISOMORPH_GATEWAY_URL. Returning '' here sent fetch a
179
+ // host-less path and Node refused it with ERR_INVALID_URL, so every check
180
+ // that imported the application's own client failed — and the agent
181
+ // improvised a different shim each run (a path rewrite, a second client, a
182
+ // fake `location`), none of which is the application's production code.
183
+ // The brief tells the agent to drive the converted client module against
184
+ // ISOMORPH_GATEWAY_URL; this is what makes that literally true.
185
+ const runtime = globalThis.process;
186
+ return (runtime?.env?.ISOMORPH_GATEWAY_URL ?? '').replace(/\/$/, '');
187
+ }
188
+ const match = /^\/p\/([a-z0-9-]+\.[a-z0-9-]+)(?:\/|$)/.exec(location.pathname);
189
+ return match ? `/p/${match[1]}` : '';
190
+ }
191
+ // Result is what awaiting the builder resolves to: T[] for a list query, T
192
+ // after .single(), T | null after .maybeSingle(). The gateway applies the
193
+ // cardinality; the type follows it so a reader of these declarations sees the
194
+ // real contract (a wrapper written against `IsomorphResult<T[]>` for a
195
+ // .single() query was wrong twice on one app).
196
+ export class QueryBuilder {
197
+ transport;
198
+ table;
199
+ query;
200
+ constructor(transport, table, query) {
201
+ this.transport = transport;
202
+ this.table = table;
203
+ this.query = query ?? { operation: 'select', columns: '*', filters: [], orders: [] };
204
+ }
205
+ next(patch) {
206
+ return new QueryBuilder(this.transport, this.table, { ...this.query, ...patch });
207
+ }
208
+ select(columns = '*', options) {
209
+ // supabase-js: `.insert(v).select('*')` (and update/upsert/delete) means
210
+ // "return these columns of the written rows", not "turn this into a
211
+ // read". Resetting the operation here silently converted a converted
212
+ // app's writes into selects (trellix, 2026-09-05).
213
+ if (this.query.operation !== 'select') {
214
+ return this.next({ columns, count: options?.count, head: options?.head });
215
+ }
216
+ return this.next({ operation: 'select', columns, count: options?.count, head: options?.head });
217
+ }
218
+ insert(values) { return this.next({ operation: 'insert', values }); }
219
+ update(values) { return this.next({ operation: 'update', values }); }
220
+ upsert(values, options) {
221
+ // supabase-js allows upsert(row) with no options, meaning "conflict on the
222
+ // primary key". An unguarded options.onConflict dereference here made every
223
+ // optionless save in a converted app throw before any network call.
224
+ const declared = options?.onConflict;
225
+ const onConflict = declared === undefined ? [] :
226
+ Array.isArray(declared) ? declared :
227
+ declared.split(',').map((item) => item.trim());
228
+ return this.next({ operation: 'upsert', values, onConflict });
229
+ }
230
+ delete() { return this.next({ operation: 'delete' }); }
231
+ eq(column, value) { return this.filter(column, 'eq', value); }
232
+ neq(column, value) { return this.filter(column, 'neq', value); }
233
+ gt(column, value) { return this.filter(column, 'gt', value); }
234
+ gte(column, value) { return this.filter(column, 'gte', value); }
235
+ lt(column, value) { return this.filter(column, 'lt', value); }
236
+ lte(column, value) { return this.filter(column, 'lte', value); }
237
+ is(column, value) { return this.filter(column, 'is', value); }
238
+ in(column, values) { return this.filter(column, 'in', values); }
239
+ ilike(column, value) { return this.filter(column, 'ilike', value); }
240
+ or(expression) { return this.filter('', 'or', expression); }
241
+ not(column, operator, value) {
242
+ return this.filter(column, 'not', value, operator);
243
+ }
244
+ filter(column, operator, value, secondaryOperator) {
245
+ return this.next({ filters: [...this.query.filters, { column, operator, value, secondaryOperator }] });
246
+ }
247
+ order(column, options = {}) {
248
+ return this.next({ orders: [...this.query.orders, { column, ascending: options.ascending ?? true, nullsFirst: options.nullsFirst }] });
249
+ }
250
+ limit(value) { return this.next({ limit: value }); }
251
+ range(from, to) { return this.next({ range: { from, to } }); }
252
+ single() { return this.next({ cardinality: 'single' }); }
253
+ maybeSingle() { return this.next({ cardinality: 'maybeSingle' }); }
254
+ async execute() {
255
+ return this.transport.json(`/_harbour/data/${encodeURIComponent(this.table)}/query`, {
256
+ method: 'POST', body: JSON.stringify(this.query),
257
+ });
258
+ }
259
+ then(onfulfilled, onrejected) {
260
+ return this.execute().then(onfulfilled, onrejected);
261
+ }
262
+ }
263
+ class RealtimeChannelBuilder {
264
+ channel;
265
+ connect;
266
+ handlers = [];
267
+ constructor(channel, connect) {
268
+ this.channel = channel;
269
+ this.connect = connect;
270
+ }
271
+ on(event, filter, handler) {
272
+ this.handlers.push({ event, filter, handler });
273
+ return this;
274
+ }
275
+ subscribe(callback) {
276
+ try {
277
+ return this.connect(this.channel, [...this.handlers], callback);
278
+ }
279
+ catch (error) {
280
+ callback?.('CHANNEL_ERROR');
281
+ throw error;
282
+ }
283
+ }
284
+ }
285
+ export function createClient(options = {}) {
286
+ const authListeners = new Set();
287
+ let currentUser;
288
+ let refreshTimer;
289
+ let refreshInFlight;
290
+ let refreshesStarted = 0;
291
+ const setTimer = options.setTimeout ?? globalThis.setTimeout.bind(globalThis);
292
+ const clearTimer = options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
293
+ const identityRefreshMs = options.identityRefreshMs ?? 60_000;
294
+ const notifyIdentity = (user, force = false) => {
295
+ const changed = currentUser === undefined || currentUser?.id !== user?.id || currentUser?.email !== user?.email;
296
+ currentUser = user;
297
+ if (changed || force)
298
+ for (const listener of authListeners)
299
+ listener(user);
300
+ };
301
+ const transport = new Transport(options, () => notifyIdentity(null));
302
+ const socketFactory = options.websocket ?? ((url) => defaultSocketFactory(url, options.identity));
303
+ const refreshIdentity = () => {
304
+ if (refreshInFlight)
305
+ return refreshInFlight;
306
+ refreshesStarted += 1;
307
+ refreshInFlight = transport.json(IDENTITY_PATH)
308
+ .then((user) => {
309
+ notifyIdentity(user, true);
310
+ return user;
311
+ })
312
+ .catch((error) => {
313
+ if (error instanceof IsomorphError && error.category === 'AUTH_REQUIRED') {
314
+ notifyIdentity(null);
315
+ return null;
316
+ }
317
+ throw error;
318
+ })
319
+ .finally(() => { refreshInFlight = undefined; });
320
+ return refreshInFlight;
321
+ };
322
+ // A refused /_harbour/* request renews the identity through the same GET
323
+ // the edge heals. Every request refused while that GET is in flight shares
324
+ // it; a GET that was already in flight when the request LEFT does not
325
+ // count (it may have passed the edge before the context rotated) — it is
326
+ // awaited and a fresh one started, which the next refusals then share.
327
+ transport.renewal = {
328
+ renewals: () => refreshesStarted,
329
+ renew: async (sentAt) => {
330
+ if (refreshInFlight && refreshesStarted <= sentAt)
331
+ await refreshInFlight.catch(() => undefined);
332
+ await refreshIdentity();
333
+ },
334
+ };
335
+ const scheduleIdentityRefresh = () => {
336
+ if (refreshTimer !== undefined || authListeners.size === 0 || identityRefreshMs <= 0)
337
+ return;
338
+ refreshTimer = setTimer(() => {
339
+ refreshTimer = undefined;
340
+ void refreshIdentity().catch(() => undefined).finally(scheduleIdentityRefresh);
341
+ }, identityRefreshMs);
342
+ const timer = refreshTimer;
343
+ timer.unref?.();
344
+ };
345
+ const upload = async (path, body, checksum) => {
346
+ const instruction = await transport.json('/_harbour/files/uploads', {
347
+ method: 'POST',
348
+ body: JSON.stringify({ path, contentType: body.type || 'application/octet-stream', size: body.size, checksum }),
349
+ });
350
+ if (instruction.mode === 'single') {
351
+ const result = await transport.fetch(instruction.uploadUrl, { method: 'PUT', headers: instruction.headers, body });
352
+ if (!result.ok)
353
+ throw new IsomorphError('UNAVAILABLE', `Object upload failed with ${result.status}`);
354
+ return transport.json('/_harbour/files/uploads/complete', {
355
+ method: 'POST', body: JSON.stringify({ uploadId: instruction.uploadId, checksum }),
356
+ });
357
+ }
358
+ const completed = [];
359
+ for (const part of instruction.parts) {
360
+ const start = (part.partNumber - 1) * instruction.partSize;
361
+ const result = await transport.fetch(part.uploadUrl, {
362
+ method: 'PUT', headers: part.headers, body: body.slice(start, Math.min(start + instruction.partSize, body.size)),
363
+ });
364
+ if (!result.ok)
365
+ throw new IsomorphError('UNAVAILABLE', `Object part ${part.partNumber} failed with ${result.status}`);
366
+ const etag = result.headers.get('etag');
367
+ if (!etag)
368
+ throw new IsomorphError('UNAVAILABLE', `Object part ${part.partNumber} returned no ETag`);
369
+ completed.push({ partNumber: part.partNumber, etag });
370
+ }
371
+ return transport.json('/_harbour/files/uploads/complete', {
372
+ method: 'POST', body: JSON.stringify({ uploadId: instruction.uploadId, parts: completed, checksum }),
373
+ });
374
+ };
375
+ const connectRealtime = (channel, handlers, status) => {
376
+ const configured = `${transport.baseUrl}/_harbour/realtime`;
377
+ const locationOrigin = typeof location === 'undefined' ? 'http://localhost' : location.origin;
378
+ const url = new URL(configured, locationOrigin);
379
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
380
+ const presence = {};
381
+ let socket;
382
+ let stopped = false;
383
+ let open = false;
384
+ let connectedOnce = false;
385
+ let reconnectAttempt = 0;
386
+ let reconnectTimer;
387
+ let trackedState;
388
+ const subscribeMessage = JSON.stringify({ type: 'subscribe', channel, handlers: handlers.map(({ event, filter }) => ({ event, filter })) });
389
+ let openSocket;
390
+ const scheduleReconnect = () => {
391
+ if (stopped)
392
+ return;
393
+ status?.('RECONNECTING');
394
+ const delay = Math.min((options.realtimeReconnectMs ?? 500) * 2 ** reconnectAttempt, 30_000);
395
+ reconnectAttempt += 1;
396
+ reconnectTimer = setTimer(openSocket, delay);
397
+ const timer = reconnectTimer;
398
+ timer.unref?.();
399
+ };
400
+ openSocket = () => {
401
+ try {
402
+ socket = socketFactory(url.toString());
403
+ }
404
+ catch (error) {
405
+ status?.('CHANNEL_ERROR');
406
+ if (!connectedOnce)
407
+ throw error;
408
+ scheduleReconnect();
409
+ return;
410
+ }
411
+ socket.addEventListener('open', () => {
412
+ open = true;
413
+ socket.send(subscribeMessage);
414
+ if (trackedState)
415
+ socket.send(JSON.stringify({ type: 'presence.track', channel, state: trackedState }));
416
+ });
417
+ socket.addEventListener('message', (event) => {
418
+ let message;
419
+ try {
420
+ message = JSON.parse(String(event.data));
421
+ }
422
+ catch {
423
+ return;
424
+ }
425
+ if (message.type === 'subscribed') {
426
+ reconnectAttempt = 0;
427
+ if (connectedOnce)
428
+ status?.('REFETCH_REQUIRED');
429
+ else
430
+ status?.('SUBSCRIBED');
431
+ connectedOnce = true;
432
+ return;
433
+ }
434
+ if (message.type === 'presence' && message.state)
435
+ Object.assign(presence, message.state);
436
+ if (typeof message.index === 'number')
437
+ handlers[message.index]?.handler(message.payload);
438
+ });
439
+ socket.addEventListener('error', () => status?.('CHANNEL_ERROR'));
440
+ socket.addEventListener('close', () => {
441
+ open = false;
442
+ if (stopped) {
443
+ status?.('CLOSED');
444
+ return;
445
+ }
446
+ scheduleReconnect();
447
+ });
448
+ };
449
+ openSocket();
450
+ return {
451
+ unsubscribe: () => {
452
+ stopped = true;
453
+ if (reconnectTimer !== undefined)
454
+ clearTimer(reconnectTimer);
455
+ socket.close(1000, 'client unsubscribe');
456
+ },
457
+ track: (state) => {
458
+ trackedState = state;
459
+ if (open)
460
+ socket.send(JSON.stringify({ type: 'presence.track', channel, state }));
461
+ },
462
+ presenceState: () => ({ ...presence }),
463
+ };
464
+ };
465
+ return {
466
+ identity: {
467
+ current: refreshIdentity,
468
+ signIn: (returnTo = typeof location === 'undefined' ? '/' : location.href) => {
469
+ // Authentication is owned by Isomorph governance, not the shared data
470
+ // gateway. Use the edge control path so an anonymous browser can enter
471
+ // SSO before any signed application context exists.
472
+ const url = `/__harbour/login?returnTo=${encodeURIComponent(returnTo)}`;
473
+ if (typeof location !== 'undefined')
474
+ location.assign(url);
475
+ return url;
476
+ },
477
+ signOut: (returnTo = typeof location === 'undefined' ? '/' : location.href) => {
478
+ const url = `/__harbour/logout?returnTo=${encodeURIComponent(returnTo)}`;
479
+ notifyIdentity(null, true);
480
+ if (typeof location !== 'undefined')
481
+ location.assign(url);
482
+ return url;
483
+ },
484
+ onChange: (listener) => {
485
+ authListeners.add(listener);
486
+ void refreshIdentity().catch(() => undefined);
487
+ scheduleIdentityRefresh();
488
+ return () => {
489
+ authListeners.delete(listener);
490
+ if (authListeners.size === 0 && refreshTimer !== undefined) {
491
+ clearTimer(refreshTimer);
492
+ refreshTimer = undefined;
493
+ }
494
+ };
495
+ },
496
+ },
497
+ data: {
498
+ from: (table) => new QueryBuilder(transport, table),
499
+ rpc: (name, args = {}) => transport.json(`/_harbour/data/rpc/${encodeURIComponent(name)}`, { method: 'POST', body: JSON.stringify(args) }),
500
+ },
501
+ files: {
502
+ createUpload: (input) => transport.json('/_harbour/files/uploads', { method: 'POST', body: JSON.stringify(input) }),
503
+ upload,
504
+ list: (prefix = '') => transport.json(`/_harbour/files?prefix=${encodeURIComponent(prefix)}`),
505
+ remove: (paths) => transport.json('/_harbour/files/remove', { method: 'POST', body: JSON.stringify({ paths }) }),
506
+ getPublicUrl: (path) => `${transport.baseUrl}/_harbour/files/public/${encodeURIComponent(path)}`,
507
+ createSignedUrl: (path, expiresIn = 3600) => transport.json('/_harbour/files/signed-url', {
508
+ method: 'POST', body: JSON.stringify({ path, expiresIn }),
509
+ }),
510
+ createSignedUrls: (paths, expiresIn = 3600) => transport.json('/_harbour/files/signed-urls', {
511
+ method: 'POST', body: JSON.stringify({ paths, expiresIn }),
512
+ }),
513
+ },
514
+ realtime: {
515
+ channel: (name) => new RealtimeChannelBuilder(name, connectRealtime),
516
+ },
517
+ actions: {
518
+ invoke: (name, input) => {
519
+ // supabase-js accepts FormData/Blob bodies and sends them as
520
+ // multipart; JSON.stringify(FormData) silently becomes "{}", which
521
+ // turned a verbatim-ported multipart action into a guaranteed 500.
522
+ // Pass binary bodies through and let the browser set the boundary.
523
+ if (typeof FormData !== 'undefined' && input instanceof FormData) {
524
+ return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
525
+ method: 'POST', body: input, headers: { 'content-type': undefined },
526
+ });
527
+ }
528
+ if (typeof Blob !== 'undefined' && input instanceof Blob) {
529
+ return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
530
+ method: 'POST', body: input, headers: { 'content-type': input.type || 'application/octet-stream' },
531
+ });
532
+ }
533
+ return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
534
+ method: 'POST', body: JSON.stringify(input),
535
+ });
536
+ },
537
+ },
538
+ telemetry: {
539
+ track: (name, properties = {}) => transport.json('/_harbour/telemetry', {
540
+ method: 'POST', body: JSON.stringify({ name, properties }),
541
+ }),
542
+ page: (path = typeof location === 'undefined' ? '/' : location.pathname) => transport.json('/_harbour/telemetry', {
543
+ method: 'POST', body: JSON.stringify({ name: 'page_view', properties: { path } }),
544
+ }),
545
+ },
546
+ integrations: {
547
+ execute: (connection, request) => {
548
+ // The contract's per-operation rules, refused here before any request
549
+ // is made, with the platform's own sentence and the field named — so
550
+ // `isomorph check` (which runs retained journeys through this client)
551
+ // and the deployed app fail the same way at build time, not at runtime.
552
+ const checked = validateIntegrationExecuteInput(request.operation, request.input);
553
+ if (!checked.ok) {
554
+ return Promise.reject(new IsomorphError('VALIDATION_FAILED', checked.problem.message, undefined, { code: 'INPUT_INVALID', field: checked.problem.field }));
555
+ }
556
+ if (request.operation === 'warehouse.view.read' && Array.isArray(request.input?.columns) && request.input.columns.includes('*') && request.input.columns.length !== 1) {
557
+ return Promise.reject(new IsomorphError('VALIDATION_FAILED', 'columns may use "*" only as the sole entry', undefined, { code: 'INPUT_INVALID', field: 'columns' }));
558
+ }
559
+ // Exactly the contract's fields go on the wire: `mode` is one of them
560
+ // for a Slack post only (which approved mode it runs under); an
561
+ // `identity` or any other stray field is not sent.
562
+ const { operation, resource, input, idempotencyKey, mode } = request;
563
+ return transport.json('/_harbour/integrations/execute', {
564
+ method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation === 'slack.message.post' && mode !== undefined ? { mode } : {}) }),
565
+ });
566
+ },
567
+ connect: (connection) => transport.json('/_harbour/integrations/connect', {
568
+ method: 'POST', body: JSON.stringify({ connection }),
569
+ }),
570
+ disconnect: (connection) => transport.json('/_harbour/integrations/connect', {
571
+ method: 'DELETE', body: JSON.stringify({ connection }),
572
+ }),
573
+ },
574
+ ai: {
575
+ chat: (request) => transport.json('/_harbour/ai/chat', {
576
+ method: 'POST', body: JSON.stringify(request),
577
+ }),
578
+ embed: (request) => transport.json('/_harbour/ai/embed', {
579
+ method: 'POST', body: JSON.stringify(request),
580
+ }),
581
+ },
582
+ };
583
+ }
584
+ // The contract's integration input rules (vendored verbatim from
585
+ // @fourier-labs/harbour-contracts gen-ts/integration-execute-input-rules.ts;
586
+ // test/integration-input-rules.test.mjs pins the copy's digest), so an app or
587
+ // a check can read a bound instead of restating it.
588
+ export { IntegrationExecuteInputRules, integrationExecuteInputMaximum, validateIntegrationExecuteInput, } from './integration-execute-input-rules.js';
589
+ // supabase-js compatibility layer (additive; see src/supabase-compat.ts).
590
+ export { createSupabaseCompatClient, decodeSupabaseCompatResponse, SupabaseCompatQueryBuilder, } from './supabase-compat.js';
591
+ // The old names, kept ONLY for the deployment pipeline's internal
592
+ // transformation agent, whose brief and playbooks
593
+ // (harbour-deployment-control-plane internal/application/transformation)
594
+ // still write `@harbour/app-sdk` code against `HarbourError`, `HarbourUser`
595
+ // and the other five; the pipeline bakes this same source under that
596
+ // package name for that lane, and these aliases keep its output compiling.
597
+ // They are not part of the kit: an app built with the Isomorph development
598
+ // kit that imports one is refused by `isomorph check` and the deployment
599
+ // gate (the kit gate's SDK-surface check knows only the Isomorph names), and
600
+ // nothing a kit builder or their tool reads mentions them.
601
+ /** @deprecated Internal transformation lane only; a kit app uses IsomorphError. */
602
+ export const HarbourError = IsomorphError;