@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.d.ts +367 -0
- package/dist/index.js +602 -0
- package/dist/integration-execute-input-rules.d.ts +187 -0
- package/dist/integration-execute-input-rules.js +114 -0
- package/dist/supabase-compat.d.ts +323 -0
- package/dist/supabase-compat.js +308 -0
- package/dist/supabase-compat.typecheck.d.ts +23 -0
- package/dist/supabase-compat.typecheck.js +92 -0
- package/package.json +15 -0
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;
|