@opengeni/connect 0.2.0-canary.0
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/LICENSE +190 -0
- package/README.md +112 -0
- package/dist/authorization.d.ts +16 -0
- package/dist/browser-navigation.d.ts +19 -0
- package/dist/device.d.ts +17 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +370 -0
- package/dist/index.js.map +1 -0
- package/dist/poll.d.ts +8 -0
- package/dist/recovery.d.ts +4 -0
- package/dist/types.d.ts +144 -0
- package/package.json +38 -0
- package/src/authorization.ts +51 -0
- package/src/browser-navigation.ts +52 -0
- package/src/device.ts +109 -0
- package/src/index.ts +210 -0
- package/src/poll.ts +78 -0
- package/src/recovery.ts +16 -0
- package/src/types.ts +134 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// src/device.ts
|
|
2
|
+
async function pollDeviceAuthorization(options) {
|
|
3
|
+
const now = options.now ?? Date.now;
|
|
4
|
+
if (!Number.isFinite(options.expiresAtMs) || !Number.isFinite(options.initialIntervalSeconds) || options.initialIntervalSeconds <= 0 || options.maxRetryDelaySeconds !== void 0 && (!Number.isFinite(options.maxRetryDelaySeconds) || options.maxRetryDelaySeconds <= 0))
|
|
5
|
+
throw new Error("Device authorization requires a finite expiry and polling interval");
|
|
6
|
+
const wait = options.wait ?? waitForDeviceDelay;
|
|
7
|
+
const initial = Math.max(1, options.initialIntervalSeconds);
|
|
8
|
+
const maximum = Math.max(initial, options.maxRetryDelaySeconds ?? 30);
|
|
9
|
+
let delay = initial;
|
|
10
|
+
while (!options.signal.aborted) {
|
|
11
|
+
const remaining = options.expiresAtMs - now();
|
|
12
|
+
if (remaining <= 0) return options.expired;
|
|
13
|
+
if (!await wait(Math.min(delay * 1e3, remaining), options.signal) || options.signal.aborted)
|
|
14
|
+
return null;
|
|
15
|
+
if (now() >= options.expiresAtMs) return options.expired;
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
const observed = await observeDevicePoll(
|
|
19
|
+
options.poll,
|
|
20
|
+
options.expiresAtMs - now(),
|
|
21
|
+
options.signal
|
|
22
|
+
);
|
|
23
|
+
if (observed.kind === "aborted") return null;
|
|
24
|
+
if (observed.kind === "expired") return options.expired;
|
|
25
|
+
result = observed.result;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (!options.retryable?.(error)) throw error;
|
|
28
|
+
delay = Math.min(maximum, Math.max(initial, delay * 2));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (options.signal.aborted) return null;
|
|
32
|
+
if (result.status !== "pending" && result.status !== "slow_down") return result;
|
|
33
|
+
if (result.intervalSeconds !== void 0 && (!Number.isFinite(result.intervalSeconds) || result.intervalSeconds <= 0))
|
|
34
|
+
throw new Error("Provider returned an invalid device polling interval");
|
|
35
|
+
delay = Math.max(
|
|
36
|
+
1,
|
|
37
|
+
result.intervalSeconds ?? (result.status === "slow_down" ? delay + 5 : delay)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
async function observeDevicePoll(poll, remainingMs, signal) {
|
|
43
|
+
if (signal.aborted) return { kind: "aborted" };
|
|
44
|
+
if (remainingMs <= 0) return { kind: "expired" };
|
|
45
|
+
let timer;
|
|
46
|
+
let abort;
|
|
47
|
+
try {
|
|
48
|
+
const stopped = new Promise((resolve) => {
|
|
49
|
+
abort = () => resolve({ kind: "aborted" });
|
|
50
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
51
|
+
timer = setTimeout(() => resolve({ kind: "expired" }), remainingMs);
|
|
52
|
+
});
|
|
53
|
+
return await Promise.race([
|
|
54
|
+
Promise.resolve().then(poll).then((result) => ({ kind: "result", result })),
|
|
55
|
+
stopped
|
|
56
|
+
]);
|
|
57
|
+
} finally {
|
|
58
|
+
if (timer) clearTimeout(timer);
|
|
59
|
+
if (abort) signal.removeEventListener("abort", abort);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function waitForDeviceDelay(delayMs, signal) {
|
|
63
|
+
if (signal.aborted) return false;
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const abort = () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
resolve(false);
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
signal.removeEventListener("abort", abort);
|
|
71
|
+
resolve(true);
|
|
72
|
+
}, delayMs);
|
|
73
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
74
|
+
if (signal.aborted) abort();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/recovery.ts
|
|
79
|
+
function findConnectRecoveryAccount(accounts, connectionId) {
|
|
80
|
+
if (!connectionId) return null;
|
|
81
|
+
const matches = accounts.filter(
|
|
82
|
+
(account) => account.id === connectionId || account.id === `social:${connectionId}`
|
|
83
|
+
);
|
|
84
|
+
if (matches.length > 1)
|
|
85
|
+
throw new Error("Connection recovery is ambiguous; choose the exact account.");
|
|
86
|
+
return matches[0] ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/poll.ts
|
|
90
|
+
async function pollConnectAttempt(transport, workspaceId, attemptId, options = {}) {
|
|
91
|
+
const timeoutMs = options.timeoutMs ?? 12e4;
|
|
92
|
+
if (!workspaceId || !attemptId || !Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 6e5 || !Number.isSafeInteger(options.minimumRevision ?? 1) || (options.minimumRevision ?? 1) < 1) {
|
|
93
|
+
throw new Error("Connect polling requires a scope and a bounded timeout");
|
|
94
|
+
}
|
|
95
|
+
const abort = new AbortController();
|
|
96
|
+
const cancel = () => abort.abort(options.signal?.reason);
|
|
97
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
98
|
+
if (options.signal?.aborted) cancel();
|
|
99
|
+
const timer = setTimeout(() => abort.abort(new Error("Connect polling timed out")), timeoutMs);
|
|
100
|
+
let revision = options.minimumRevision ?? 1;
|
|
101
|
+
try {
|
|
102
|
+
while (true) {
|
|
103
|
+
abort.signal.throwIfAborted();
|
|
104
|
+
const result = await abortable(
|
|
105
|
+
transport.get(workspaceId, attemptId, { signal: abort.signal }),
|
|
106
|
+
abort.signal
|
|
107
|
+
);
|
|
108
|
+
if (result.workspaceId !== workspaceId || result.id !== attemptId || !Number.isSafeInteger(result.revision) || result.revision < Math.max(1, revision)) {
|
|
109
|
+
throw new Error("Connect polling response scope or revision mismatch");
|
|
110
|
+
}
|
|
111
|
+
revision = result.revision;
|
|
112
|
+
if (["complete", "cancelled", "expired", "failed", "uncertain"].includes(result.state) || !["authorize", "wait"].includes(result.nextAction.type))
|
|
113
|
+
return result;
|
|
114
|
+
const requested = result.nextAction.type === "wait" ? result.nextAction.pollAfterMs : 1e3;
|
|
115
|
+
const delay = Number.isFinite(requested) ? Math.min(6e4, Math.max(250, requested)) : 1e3;
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
const stop = () => {
|
|
118
|
+
clearTimeout(wait);
|
|
119
|
+
reject(abort.signal.reason);
|
|
120
|
+
};
|
|
121
|
+
const wait = setTimeout(() => {
|
|
122
|
+
abort.signal.removeEventListener("abort", stop);
|
|
123
|
+
resolve();
|
|
124
|
+
}, delay);
|
|
125
|
+
abort.signal.addEventListener("abort", stop, { once: true });
|
|
126
|
+
if (abort.signal.aborted) stop();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function abortable(pending, signal) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const stop = () => reject(signal.reason);
|
|
137
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
138
|
+
if (signal.aborted) stop();
|
|
139
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/authorization.ts
|
|
144
|
+
function authorizeConnectAttempt(transport, attempt, navigation, options) {
|
|
145
|
+
options.signal?.throwIfAborted();
|
|
146
|
+
if (attempt.nextAction.type !== "authorize") {
|
|
147
|
+
throw new Error("Connect attempt does not require authorization");
|
|
148
|
+
}
|
|
149
|
+
const destination = new URL(attempt.nextAction.url);
|
|
150
|
+
if (destination.protocol !== "https:" || destination.username || destination.password) {
|
|
151
|
+
throw new Error("Connect authorization requires an HTTPS destination without credentials");
|
|
152
|
+
}
|
|
153
|
+
if (!attempt.id || !attempt.workspaceId || !Number.isSafeInteger(attempt.revision) || attempt.revision < 1)
|
|
154
|
+
throw new Error("Connect authorization requires a scope");
|
|
155
|
+
if (options.mode === "redirect") {
|
|
156
|
+
navigation.redirect(attempt.nextAction.url);
|
|
157
|
+
return Promise.resolve(null);
|
|
158
|
+
}
|
|
159
|
+
const popup = navigation.openPopup(attempt.nextAction.url);
|
|
160
|
+
if (!popup) throw new Error("Connect popup was blocked; retry with redirect mode");
|
|
161
|
+
return pollConnectAttempt(transport, attempt.workspaceId, attempt.id, {
|
|
162
|
+
...options,
|
|
163
|
+
minimumRevision: attempt.revision
|
|
164
|
+
}).finally(() => {
|
|
165
|
+
try {
|
|
166
|
+
popup.close();
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/browser-navigation.ts
|
|
173
|
+
function validateDestination(url) {
|
|
174
|
+
const parsed = new URL(url);
|
|
175
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password)
|
|
176
|
+
throw new Error("Connect authorization requires an HTTPS destination without credentials");
|
|
177
|
+
}
|
|
178
|
+
function createBrowserConnectNavigation(browser) {
|
|
179
|
+
return {
|
|
180
|
+
openPopup(url) {
|
|
181
|
+
validateDestination(url);
|
|
182
|
+
const popup = browser.open("about:blank", "_blank", "popup,width=520,height=720");
|
|
183
|
+
if (!popup) return null;
|
|
184
|
+
try {
|
|
185
|
+
popup.opener = null;
|
|
186
|
+
if (popup.opener !== null) throw new Error("Connect popup isolation failed");
|
|
187
|
+
popup.location.replace(url);
|
|
188
|
+
} catch {
|
|
189
|
+
try {
|
|
190
|
+
popup.close();
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
throw new Error("Connect popup could not navigate safely; retry with redirect mode");
|
|
194
|
+
}
|
|
195
|
+
return { close: () => popup.close() };
|
|
196
|
+
},
|
|
197
|
+
redirect(url) {
|
|
198
|
+
validateDestination(url);
|
|
199
|
+
browser.location.assign(url);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/index.ts
|
|
205
|
+
var ConnectController = class {
|
|
206
|
+
constructor(transport, workspaceId) {
|
|
207
|
+
this.transport = transport;
|
|
208
|
+
this.workspaceId = workspaceId;
|
|
209
|
+
if (!workspaceId) throw new Error("Connect requires an explicit workspace");
|
|
210
|
+
}
|
|
211
|
+
snapshot = Object.freeze({ attempt: null, busy: false, error: null });
|
|
212
|
+
listeners = /* @__PURE__ */ new Set();
|
|
213
|
+
generation = 0;
|
|
214
|
+
request = null;
|
|
215
|
+
disposed = false;
|
|
216
|
+
getSnapshot = () => this.snapshot;
|
|
217
|
+
subscribe = (listener) => {
|
|
218
|
+
this.assertActive();
|
|
219
|
+
this.listeners.add(listener);
|
|
220
|
+
return () => {
|
|
221
|
+
this.listeners.delete(listener);
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
begin(input) {
|
|
225
|
+
const url = new URL(input.returnUrl);
|
|
226
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) {
|
|
227
|
+
throw new Error("Connect return URL must be an HTTP(S) destination without credentials");
|
|
228
|
+
}
|
|
229
|
+
return this.run((signal) => this.transport.begin(this.workspaceId, input, { signal }), true);
|
|
230
|
+
}
|
|
231
|
+
recover(attemptId) {
|
|
232
|
+
if (!attemptId) throw new Error("Connect recovery requires an attempt ID");
|
|
233
|
+
return this.run(
|
|
234
|
+
(signal) => this.transport.get(this.workspaceId, attemptId, { signal }),
|
|
235
|
+
true,
|
|
236
|
+
attemptId
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
refresh() {
|
|
240
|
+
const attempt = this.requireAttempt();
|
|
241
|
+
if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
|
|
242
|
+
return this.run(
|
|
243
|
+
(signal) => this.transport.get(this.workspaceId, attempt.id, { signal }),
|
|
244
|
+
false,
|
|
245
|
+
attempt.id
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
advance(action, idempotencyKey) {
|
|
249
|
+
const attempt = this.requireAttempt();
|
|
250
|
+
if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
|
|
251
|
+
return this.run(
|
|
252
|
+
(signal) => this.transport.advance(
|
|
253
|
+
this.workspaceId,
|
|
254
|
+
attempt.id,
|
|
255
|
+
{
|
|
256
|
+
expectedRevision: attempt.revision,
|
|
257
|
+
idempotencyKey,
|
|
258
|
+
action
|
|
259
|
+
},
|
|
260
|
+
{ signal }
|
|
261
|
+
),
|
|
262
|
+
false,
|
|
263
|
+
attempt.id
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
/** Observe backend progress without replaying a setup mutation. Disposal or
|
|
267
|
+
* selecting another attempt aborts the read loop and fences its late result. */
|
|
268
|
+
waitForAction(options = {}) {
|
|
269
|
+
const attempt = this.requireAttempt();
|
|
270
|
+
if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
|
|
271
|
+
return this.run(
|
|
272
|
+
(signal) => pollConnectAttempt(this.transport, this.workspaceId, attempt.id, {
|
|
273
|
+
...options,
|
|
274
|
+
signal,
|
|
275
|
+
minimumRevision: attempt.revision
|
|
276
|
+
}),
|
|
277
|
+
false,
|
|
278
|
+
attempt.id
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
cancel(idempotencyKey) {
|
|
282
|
+
const attempt = this.requireAttempt();
|
|
283
|
+
if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
|
|
284
|
+
return this.run(
|
|
285
|
+
(signal) => this.transport.cancel(
|
|
286
|
+
this.workspaceId,
|
|
287
|
+
attempt.id,
|
|
288
|
+
{
|
|
289
|
+
expectedRevision: attempt.revision,
|
|
290
|
+
idempotencyKey
|
|
291
|
+
},
|
|
292
|
+
{ signal }
|
|
293
|
+
),
|
|
294
|
+
false,
|
|
295
|
+
attempt.id
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
dispose() {
|
|
299
|
+
if (this.disposed) return;
|
|
300
|
+
this.disposed = true;
|
|
301
|
+
this.generation++;
|
|
302
|
+
this.request?.abort();
|
|
303
|
+
this.request = null;
|
|
304
|
+
this.listeners.clear();
|
|
305
|
+
}
|
|
306
|
+
assertActive() {
|
|
307
|
+
if (this.disposed) throw new Error("Connect controller is disposed");
|
|
308
|
+
}
|
|
309
|
+
requireAttempt() {
|
|
310
|
+
this.assertActive();
|
|
311
|
+
if (!this.snapshot.attempt) throw new Error("No Connect attempt is selected");
|
|
312
|
+
return this.snapshot.attempt;
|
|
313
|
+
}
|
|
314
|
+
publish(snapshot) {
|
|
315
|
+
this.snapshot = Object.freeze(snapshot);
|
|
316
|
+
for (const listener of this.listeners) listener();
|
|
317
|
+
}
|
|
318
|
+
async run(operation, replace, expectedId) {
|
|
319
|
+
this.assertActive();
|
|
320
|
+
const generation = ++this.generation;
|
|
321
|
+
this.request?.abort();
|
|
322
|
+
const request = new AbortController();
|
|
323
|
+
this.request = request;
|
|
324
|
+
this.publish({ attempt: replace ? null : this.snapshot.attempt, busy: true, error: null });
|
|
325
|
+
try {
|
|
326
|
+
const result = await operation(request.signal);
|
|
327
|
+
if (generation !== this.generation || this.disposed)
|
|
328
|
+
throw new Error("Connect operation was superseded");
|
|
329
|
+
if (result.workspaceId !== this.workspaceId || expectedId && result.id !== expectedId) {
|
|
330
|
+
throw new Error("Connect response scope mismatch");
|
|
331
|
+
}
|
|
332
|
+
if (!Number.isSafeInteger(result.revision) || result.revision < 1 || this.snapshot.attempt?.id === result.id && result.revision < this.snapshot.attempt.revision) {
|
|
333
|
+
throw new Error("Connect response revision is stale");
|
|
334
|
+
}
|
|
335
|
+
const attempt = freezeTree(structuredClone(result));
|
|
336
|
+
this.publish({ attempt, busy: false, error: null });
|
|
337
|
+
return structuredClone(attempt);
|
|
338
|
+
} catch (cause) {
|
|
339
|
+
const error = cause instanceof Error ? cause : new Error("Connect operation failed");
|
|
340
|
+
if (generation === this.generation && !this.disposed) {
|
|
341
|
+
this.publish({
|
|
342
|
+
attempt: this.snapshot.attempt,
|
|
343
|
+
busy: false,
|
|
344
|
+
error: Object.freeze(
|
|
345
|
+
new Error("Connect operation failed; refresh its status before retrying")
|
|
346
|
+
)
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
} finally {
|
|
351
|
+
if (generation === this.generation) this.request = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
function freezeTree(value) {
|
|
356
|
+
if (value !== null && typeof value === "object") {
|
|
357
|
+
for (const child of Object.values(value)) freezeTree(child);
|
|
358
|
+
Object.freeze(value);
|
|
359
|
+
}
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
export {
|
|
363
|
+
ConnectController,
|
|
364
|
+
authorizeConnectAttempt,
|
|
365
|
+
createBrowserConnectNavigation,
|
|
366
|
+
findConnectRecoveryAccount,
|
|
367
|
+
pollConnectAttempt,
|
|
368
|
+
pollDeviceAuthorization
|
|
369
|
+
};
|
|
370
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/device.ts","../src/recovery.ts","../src/poll.ts","../src/authorization.ts","../src/browser-navigation.ts","../src/index.ts"],"sourcesContent":["/** Shared device-code behavior for model-account domains. These accounts keep\n * their existing APIs and ownership semantics; they are not generic credentials.\n * Keep opaque provider state on the host backend when reload recovery is needed. */\nexport async function pollDeviceAuthorization<\n T extends { status: string; intervalSeconds?: number },\n>(options: {\n poll: () => Promise<T>;\n expired: T;\n initialIntervalSeconds: number;\n expiresAtMs: number;\n signal: AbortSignal;\n retryable?: (error: unknown) => boolean;\n now?: () => number;\n wait?: (delayMs: number, signal: AbortSignal) => Promise<boolean>;\n maxRetryDelaySeconds?: number;\n}): Promise<T | null> {\n const now = options.now ?? Date.now;\n if (\n !Number.isFinite(options.expiresAtMs) ||\n !Number.isFinite(options.initialIntervalSeconds) ||\n options.initialIntervalSeconds <= 0 ||\n (options.maxRetryDelaySeconds !== undefined &&\n (!Number.isFinite(options.maxRetryDelaySeconds) || options.maxRetryDelaySeconds <= 0))\n )\n throw new Error(\"Device authorization requires a finite expiry and polling interval\");\n const wait = options.wait ?? waitForDeviceDelay;\n const initial = Math.max(1, options.initialIntervalSeconds);\n const maximum = Math.max(initial, options.maxRetryDelaySeconds ?? 30);\n let delay = initial;\n while (!options.signal.aborted) {\n const remaining = options.expiresAtMs - now();\n if (remaining <= 0) return options.expired;\n if (!(await wait(Math.min(delay * 1000, remaining), options.signal)) || options.signal.aborted)\n return null;\n if (now() >= options.expiresAtMs) return options.expired;\n let result: T;\n try {\n const observed = await observeDevicePoll(\n options.poll,\n options.expiresAtMs - now(),\n options.signal,\n );\n if (observed.kind === \"aborted\") return null;\n if (observed.kind === \"expired\") return options.expired;\n result = observed.result;\n } catch (error) {\n if (!options.retryable?.(error)) throw error;\n delay = Math.min(maximum, Math.max(initial, delay * 2));\n continue;\n }\n if (options.signal.aborted) return null;\n if (result.status !== \"pending\" && result.status !== \"slow_down\") return result;\n if (\n result.intervalSeconds !== undefined &&\n (!Number.isFinite(result.intervalSeconds) || result.intervalSeconds <= 0)\n )\n throw new Error(\"Provider returned an invalid device polling interval\");\n delay = Math.max(\n 1,\n result.intervalSeconds ?? (result.status === \"slow_down\" ? delay + 5 : delay),\n );\n }\n return null;\n}\n\n/** A transport may ignore cancellation. Bound observation without retrying an\n * in-flight request or treating the loss of observation as provider success. */\nasync function observeDevicePoll<T>(\n poll: () => Promise<T>,\n remainingMs: number,\n signal: AbortSignal,\n): Promise<{ kind: \"result\"; result: T } | { kind: \"aborted\" } | { kind: \"expired\" }> {\n if (signal.aborted) return { kind: \"aborted\" };\n if (remainingMs <= 0) return { kind: \"expired\" };\n let timer: ReturnType<typeof setTimeout> | undefined;\n let abort: (() => void) | undefined;\n try {\n const stopped = new Promise<{ kind: \"aborted\" } | { kind: \"expired\" }>((resolve) => {\n abort = () => resolve({ kind: \"aborted\" });\n signal.addEventListener(\"abort\", abort, { once: true });\n timer = setTimeout(() => resolve({ kind: \"expired\" }), remainingMs);\n });\n return await Promise.race([\n Promise.resolve()\n .then(poll)\n .then((result) => ({ kind: \"result\" as const, result })),\n stopped,\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n if (abort) signal.removeEventListener(\"abort\", abort);\n }\n}\n\nasync function waitForDeviceDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {\n if (signal.aborted) return false;\n return new Promise((resolve) => {\n const abort = () => {\n clearTimeout(timer);\n resolve(false);\n };\n const timer = setTimeout(() => {\n signal.removeEventListener(\"abort\", abort);\n resolve(true);\n }, delayMs);\n signal.addEventListener(\"abort\", abort, { once: true });\n if (signal.aborted) abort();\n });\n}\n","import type { ConnectAccount } from \"./types\";\n\n/** A provider/domain match is not account identity. Never silently substitute\n * another account when the original connection has disappeared. */\nexport function findConnectRecoveryAccount(\n accounts: readonly ConnectAccount[],\n connectionId: string | null | undefined,\n): ConnectAccount | null {\n if (!connectionId) return null;\n const matches = accounts.filter(\n (account) => account.id === connectionId || account.id === `social:${connectionId}`,\n );\n if (matches.length > 1)\n throw new Error(\"Connection recovery is ambiguous; choose the exact account.\");\n return matches[0] ?? null;\n}\n","import type { ConnectAttempt, ConnectTransport } from \"./types\";\n\n/** Read-only continuation. The backend remains authoritative for expiry and\n * completion; redirects and popup messages are never completion evidence. */\nexport async function pollConnectAttempt(\n transport: Pick<ConnectTransport, \"get\">,\n workspaceId: string,\n attemptId: string,\n options: { signal?: AbortSignal; timeoutMs?: number; minimumRevision?: number } = {},\n): Promise<ConnectAttempt> {\n const timeoutMs = options.timeoutMs ?? 120_000;\n if (\n !workspaceId ||\n !attemptId ||\n !Number.isFinite(timeoutMs) ||\n timeoutMs < 1 ||\n timeoutMs > 600_000 ||\n !Number.isSafeInteger(options.minimumRevision ?? 1) ||\n (options.minimumRevision ?? 1) < 1\n ) {\n throw new Error(\"Connect polling requires a scope and a bounded timeout\");\n }\n const abort = new AbortController();\n const cancel = () => abort.abort(options.signal?.reason);\n options.signal?.addEventListener(\"abort\", cancel, { once: true });\n if (options.signal?.aborted) cancel();\n const timer = setTimeout(() => abort.abort(new Error(\"Connect polling timed out\")), timeoutMs);\n let revision = options.minimumRevision ?? 1;\n try {\n while (true) {\n abort.signal.throwIfAborted();\n const result = await abortable(\n transport.get(workspaceId, attemptId, { signal: abort.signal }),\n abort.signal,\n );\n if (\n result.workspaceId !== workspaceId ||\n result.id !== attemptId ||\n !Number.isSafeInteger(result.revision) ||\n result.revision < Math.max(1, revision)\n ) {\n throw new Error(\"Connect polling response scope or revision mismatch\");\n }\n revision = result.revision;\n if (\n [\"complete\", \"cancelled\", \"expired\", \"failed\", \"uncertain\"].includes(result.state) ||\n ![\"authorize\", \"wait\"].includes(result.nextAction.type)\n )\n return result;\n const requested = result.nextAction.type === \"wait\" ? result.nextAction.pollAfterMs : 1000;\n const delay = Number.isFinite(requested) ? Math.min(60_000, Math.max(250, requested)) : 1000;\n await new Promise<void>((resolve, reject) => {\n const stop = () => {\n clearTimeout(wait);\n reject(abort.signal.reason);\n };\n const wait = setTimeout(() => {\n abort.signal.removeEventListener(\"abort\", stop);\n resolve();\n }, delay);\n abort.signal.addEventListener(\"abort\", stop, { once: true });\n if (abort.signal.aborted) stop();\n });\n }\n } finally {\n clearTimeout(timer);\n options.signal?.removeEventListener(\"abort\", cancel);\n }\n}\n\nfunction abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {\n return new Promise((resolve, reject) => {\n const stop = () => reject(signal.reason);\n signal.addEventListener(\"abort\", stop, { once: true });\n if (signal.aborted) stop();\n pending.then(resolve, reject).finally(() => signal.removeEventListener(\"abort\", stop));\n });\n}\n","import { pollConnectAttempt } from \"./poll\";\nimport type { ConnectAttempt, ConnectTransport } from \"./types\";\n\n/** Inject navigation so hosts own routing and this package needs no DOM globals. */\nexport type ConnectNavigation = {\n openPopup(url: string): { close(): void } | null;\n redirect(url: string): void;\n};\n\n/** Invoke directly from a user gesture for popup mode. Persist the opaque\n * attempt ID in host-owned state before redirect mode, then recover/poll on\n * return. Neither URL parameters nor popup messages prove completion. */\nexport function authorizeConnectAttempt(\n transport: Pick<ConnectTransport, \"get\">,\n attempt: ConnectAttempt,\n navigation: ConnectNavigation,\n options: { mode: \"popup\" | \"redirect\"; signal?: AbortSignal; timeoutMs?: number },\n): Promise<ConnectAttempt | null> {\n options.signal?.throwIfAborted();\n if (attempt.nextAction.type !== \"authorize\") {\n throw new Error(\"Connect attempt does not require authorization\");\n }\n const destination = new URL(attempt.nextAction.url);\n if (destination.protocol !== \"https:\" || destination.username || destination.password) {\n throw new Error(\"Connect authorization requires an HTTPS destination without credentials\");\n }\n if (\n !attempt.id ||\n !attempt.workspaceId ||\n !Number.isSafeInteger(attempt.revision) ||\n attempt.revision < 1\n )\n throw new Error(\"Connect authorization requires a scope\");\n if (options.mode === \"redirect\") {\n navigation.redirect(attempt.nextAction.url);\n return Promise.resolve(null);\n }\n const popup = navigation.openPopup(attempt.nextAction.url);\n if (!popup) throw new Error(\"Connect popup was blocked; retry with redirect mode\");\n return pollConnectAttempt(transport, attempt.workspaceId, attempt.id, {\n ...options,\n minimumRevision: attempt.revision,\n }).finally(() => {\n // Window cleanup must never replace the authoritative result or failure.\n try {\n popup.close();\n } catch {\n /* Host navigation may already have disposed it. */\n }\n });\n}\n","import type { ConnectNavigation } from \"./authorization\";\n\n/** Structural browser surface so importing Connect never reads DOM globals. */\nexport type ConnectBrowserWindow = {\n open(\n url: string,\n target: string,\n features: string,\n ): {\n opener: unknown;\n location: { replace(url: string): void };\n close(): void;\n } | null;\n location: { assign(url: string): void };\n};\n\nfunction validateDestination(url: string): void {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\" || parsed.username || parsed.password)\n throw new Error(\"Connect authorization requires an HTTPS destination without credentials\");\n}\n\n/** Pass window from the host's browser entry point. Opens a fresh blank window\n * synchronously and severs its opener BEFORE any provider content can load.\n * Never uses a reusable named target or relies on provider window messages.\n * The caller retains only the close capability for backend-polling cleanup. */\nexport function createBrowserConnectNavigation(browser: ConnectBrowserWindow): ConnectNavigation {\n return {\n openPopup(url) {\n validateDestination(url);\n const popup = browser.open(\"about:blank\", \"_blank\", \"popup,width=520,height=720\");\n if (!popup) return null;\n try {\n popup.opener = null;\n if (popup.opener !== null) throw new Error(\"Connect popup isolation failed\");\n popup.location.replace(url);\n } catch {\n try {\n popup.close();\n } catch {\n /* Preserve the isolated navigation failure. */\n }\n throw new Error(\"Connect popup could not navigate safely; retry with redirect mode\");\n }\n return { close: () => popup.close() };\n },\n redirect(url) {\n validateDestination(url);\n browser.location.assign(url);\n },\n };\n}\n","export * from \"./types\";\nexport { pollDeviceAuthorization } from \"./device\";\nexport { findConnectRecoveryAccount } from \"./recovery\";\nexport { pollConnectAttempt } from \"./poll\";\nexport { authorizeConnectAttempt, type ConnectNavigation } from \"./authorization\";\nexport { createBrowserConnectNavigation, type ConnectBrowserWindow } from \"./browser-navigation\";\nimport type { ConnectAdvance, ConnectAttempt, ConnectOwnership, ConnectTransport } from \"./types\";\nimport { pollConnectAttempt } from \"./poll\";\n\nexport type ConnectSnapshot = {\n attempt: ConnectAttempt | null;\n busy: boolean;\n error: Error | null;\n};\n\n/** A transport-injected, framework-neutral view of one durable setup attempt.\n * OAuth redirects are hints to navigate; completion is read from the backend.\n * Secret form values are never stored in a snapshot or browser persistence. */\nexport class ConnectController {\n private snapshot: ConnectSnapshot = Object.freeze({ attempt: null, busy: false, error: null });\n private readonly listeners = new Set<() => void>();\n private generation = 0;\n private request: AbortController | null = null;\n private disposed = false;\n\n constructor(\n readonly transport: ConnectTransport,\n readonly workspaceId: string,\n ) {\n if (!workspaceId) throw new Error(\"Connect requires an explicit workspace\");\n }\n\n getSnapshot = (): ConnectSnapshot => this.snapshot;\n subscribe = (listener: () => void): (() => void) => {\n this.assertActive();\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n };\n\n begin(input: {\n providerId: string;\n ownership: ConnectOwnership;\n returnUrl: string;\n idempotencyKey: string;\n reconnectAccountId?: string;\n installationTarget?: import(\"./types\").ConnectInstallationTarget;\n }): Promise<ConnectAttempt> {\n // Validation must not serialize or decorate the host's exact return string.\n const url = new URL(input.returnUrl);\n if (![\"https:\", \"http:\"].includes(url.protocol) || url.username || url.password) {\n throw new Error(\"Connect return URL must be an HTTP(S) destination without credentials\");\n }\n return this.run((signal) => this.transport.begin(this.workspaceId, input, { signal }), true);\n }\n\n recover(attemptId: string): Promise<ConnectAttempt> {\n if (!attemptId) throw new Error(\"Connect recovery requires an attempt ID\");\n return this.run(\n (signal) => this.transport.get(this.workspaceId, attemptId, { signal }),\n true,\n attemptId,\n );\n }\n\n refresh(): Promise<ConnectAttempt> {\n const attempt = this.requireAttempt();\n if (this.snapshot.busy) throw new Error(\"A Connect operation is already in progress\");\n return this.run(\n (signal) => this.transport.get(this.workspaceId, attempt.id, { signal }),\n false,\n attempt.id,\n );\n }\n\n advance(action: ConnectAdvance, idempotencyKey: string): Promise<ConnectAttempt> {\n const attempt = this.requireAttempt();\n if (this.snapshot.busy) throw new Error(\"A Connect operation is already in progress\");\n return this.run(\n (signal) =>\n this.transport.advance(\n this.workspaceId,\n attempt.id,\n {\n expectedRevision: attempt.revision,\n idempotencyKey,\n action,\n },\n { signal },\n ),\n false,\n attempt.id,\n );\n }\n\n /** Observe backend progress without replaying a setup mutation. Disposal or\n * selecting another attempt aborts the read loop and fences its late result. */\n waitForAction(options: { timeoutMs?: number } = {}): Promise<ConnectAttempt> {\n const attempt = this.requireAttempt();\n if (this.snapshot.busy) throw new Error(\"A Connect operation is already in progress\");\n return this.run(\n (signal) =>\n pollConnectAttempt(this.transport, this.workspaceId, attempt.id, {\n ...options,\n signal,\n minimumRevision: attempt.revision,\n }),\n false,\n attempt.id,\n );\n }\n\n cancel(idempotencyKey: string): Promise<ConnectAttempt> {\n const attempt = this.requireAttempt();\n if (this.snapshot.busy) throw new Error(\"A Connect operation is already in progress\");\n return this.run(\n (signal) =>\n this.transport.cancel(\n this.workspaceId,\n attempt.id,\n {\n expectedRevision: attempt.revision,\n idempotencyKey,\n },\n { signal },\n ),\n false,\n attempt.id,\n );\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.generation++;\n this.request?.abort();\n this.request = null;\n this.listeners.clear();\n }\n\n private assertActive(): void {\n if (this.disposed) throw new Error(\"Connect controller is disposed\");\n }\n private requireAttempt(): ConnectAttempt {\n this.assertActive();\n if (!this.snapshot.attempt) throw new Error(\"No Connect attempt is selected\");\n return this.snapshot.attempt;\n }\n private publish(snapshot: ConnectSnapshot): void {\n this.snapshot = Object.freeze(snapshot);\n for (const listener of this.listeners) listener();\n }\n private async run(\n operation: (signal: AbortSignal) => Promise<ConnectAttempt>,\n replace: boolean,\n expectedId?: string,\n ): Promise<ConnectAttempt> {\n this.assertActive();\n const generation = ++this.generation;\n this.request?.abort();\n const request = new AbortController();\n this.request = request;\n this.publish({ attempt: replace ? null : this.snapshot.attempt, busy: true, error: null });\n try {\n const result = await operation(request.signal);\n if (generation !== this.generation || this.disposed)\n throw new Error(\"Connect operation was superseded\");\n if (result.workspaceId !== this.workspaceId || (expectedId && result.id !== expectedId)) {\n throw new Error(\"Connect response scope mismatch\");\n }\n if (\n !Number.isSafeInteger(result.revision) ||\n result.revision < 1 ||\n (this.snapshot.attempt?.id === result.id &&\n result.revision < this.snapshot.attempt.revision)\n ) {\n throw new Error(\"Connect response revision is stale\");\n }\n const attempt = freezeTree(structuredClone(result));\n this.publish({ attempt, busy: false, error: null });\n return structuredClone(attempt);\n } catch (cause) {\n const error = cause instanceof Error ? cause : new Error(\"Connect operation failed\");\n if (generation === this.generation && !this.disposed) {\n // Transport errors can retain request bodies, headers, nested causes or\n // echoed credentials. Keep only a fixed, credential-free UI error in\n // the long-lived snapshot; direct callers still receive the rejection.\n this.publish({\n attempt: this.snapshot.attempt,\n busy: false,\n error: Object.freeze(\n new Error(\"Connect operation failed; refresh its status before retrying\"),\n ),\n });\n }\n throw error;\n } finally {\n if (generation === this.generation) this.request = null;\n }\n }\n}\n\nfunction freezeTree<T>(value: T): T {\n if (value !== null && typeof value === \"object\") {\n for (const child of Object.values(value)) freezeTree(child);\n Object.freeze(value);\n }\n return value;\n}\n"],"mappings":"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","names":[]}
|
package/dist/poll.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConnectAttempt, ConnectTransport } from "./types.js";
|
|
2
|
+
/** Read-only continuation. The backend remains authoritative for expiry and
|
|
3
|
+
* completion; redirects and popup messages are never completion evidence. */
|
|
4
|
+
export declare function pollConnectAttempt(transport: Pick<ConnectTransport, "get">, workspaceId: string, attemptId: string, options?: {
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
minimumRevision?: number;
|
|
8
|
+
}): Promise<ConnectAttempt>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ConnectAccount } from "./types.js";
|
|
2
|
+
/** A provider/domain match is not account identity. Never silently substitute
|
|
3
|
+
* another account when the original connection has disappeared. */
|
|
4
|
+
export declare function findConnectRecoveryAccount(accounts: readonly ConnectAccount[], connectionId: string | null | undefined): ConnectAccount | null;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type ConnectOwnership = "personal" | "workspace";
|
|
2
|
+
export type ConnectProvider = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
family: string;
|
|
6
|
+
readiness: "available" | "needs_configuration" | "operator_only" | "unsupported";
|
|
7
|
+
reason?: string;
|
|
8
|
+
ownership: ConnectOwnership[];
|
|
9
|
+
setup: Array<"none" | "oauth" | "credentials" | "device" | "installation" | "openapi" | "graphql">;
|
|
10
|
+
};
|
|
11
|
+
export type ConnectAccount = {
|
|
12
|
+
id: string;
|
|
13
|
+
providerId: string;
|
|
14
|
+
/** Observed credential generation; pass to disconnect to reject stale selections. */
|
|
15
|
+
version?: number;
|
|
16
|
+
label: string;
|
|
17
|
+
ownership: ConnectOwnership;
|
|
18
|
+
status: "connected" | "auth_needed" | "disabled";
|
|
19
|
+
};
|
|
20
|
+
export type ConnectResource = {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
};
|
|
25
|
+
export type ConnectInstallationTarget = {
|
|
26
|
+
instanceKey: string;
|
|
27
|
+
displayName: string;
|
|
28
|
+
expectedInstanceVersion?: number;
|
|
29
|
+
};
|
|
30
|
+
export type ConnectNextAction = {
|
|
31
|
+
type: "authorize";
|
|
32
|
+
url: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: "credentials";
|
|
35
|
+
fields: Array<{
|
|
36
|
+
name: string;
|
|
37
|
+
label: string;
|
|
38
|
+
required: boolean;
|
|
39
|
+
secret: boolean;
|
|
40
|
+
options?: Array<{
|
|
41
|
+
value: string;
|
|
42
|
+
label: string;
|
|
43
|
+
}>;
|
|
44
|
+
}>;
|
|
45
|
+
} | {
|
|
46
|
+
type: "wait";
|
|
47
|
+
pollAfterMs: number;
|
|
48
|
+
userCode?: string;
|
|
49
|
+
verificationUrl?: string;
|
|
50
|
+
} | {
|
|
51
|
+
type: "select_account";
|
|
52
|
+
accounts: ConnectAccount[];
|
|
53
|
+
} | {
|
|
54
|
+
type: "select_resources";
|
|
55
|
+
resources: ConnectResource[];
|
|
56
|
+
cursor?: string;
|
|
57
|
+
} | {
|
|
58
|
+
type: "preview";
|
|
59
|
+
previewId: string;
|
|
60
|
+
contentHash: string;
|
|
61
|
+
operations: ConnectResource[];
|
|
62
|
+
} | {
|
|
63
|
+
type: "none";
|
|
64
|
+
};
|
|
65
|
+
export type ConnectAttempt = {
|
|
66
|
+
id: string;
|
|
67
|
+
workspaceId: string;
|
|
68
|
+
providerId: string;
|
|
69
|
+
ownership: ConnectOwnership;
|
|
70
|
+
revision: number;
|
|
71
|
+
state: "ready" | "requires_user_action" | "credential_input" | "provider_wait" | "account_selection" | "resource_selection" | "preview" | "installing" | "connected_but_incomplete" | "complete" | "cancelled" | "expired" | "failed" | "uncertain";
|
|
72
|
+
credentialsCommitted: boolean;
|
|
73
|
+
integrationInstalled: boolean;
|
|
74
|
+
completionRequirement: "connection" | "integration" | "provider_setup";
|
|
75
|
+
nextAction: ConnectNextAction;
|
|
76
|
+
expiresAt: string;
|
|
77
|
+
account?: ConnectAccount;
|
|
78
|
+
installationTarget?: ConnectInstallationTarget;
|
|
79
|
+
source?: {
|
|
80
|
+
kind: "definition";
|
|
81
|
+
definitionId: string;
|
|
82
|
+
} | {
|
|
83
|
+
kind: "openapi" | "auto";
|
|
84
|
+
url: string;
|
|
85
|
+
baseUrl?: string;
|
|
86
|
+
} | {
|
|
87
|
+
kind: "graphql";
|
|
88
|
+
endpoint: string;
|
|
89
|
+
name?: string;
|
|
90
|
+
};
|
|
91
|
+
error?: {
|
|
92
|
+
code: string;
|
|
93
|
+
message: string;
|
|
94
|
+
retryable: boolean;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
export type ConnectAdvance = {
|
|
98
|
+
type: "credentials";
|
|
99
|
+
values: Record<string, string>;
|
|
100
|
+
} | {
|
|
101
|
+
type: "account";
|
|
102
|
+
accountId: string;
|
|
103
|
+
} | {
|
|
104
|
+
type: "resources";
|
|
105
|
+
resourceIds: string[];
|
|
106
|
+
} | {
|
|
107
|
+
type: "install";
|
|
108
|
+
previewId: string;
|
|
109
|
+
contentHash: string;
|
|
110
|
+
operationIds: string[];
|
|
111
|
+
} | {
|
|
112
|
+
type: "retry";
|
|
113
|
+
};
|
|
114
|
+
export type ConnectCallOptions = {
|
|
115
|
+
signal?: AbortSignal;
|
|
116
|
+
};
|
|
117
|
+
/** Host backend transport; authenticated workspace and actor admission stays
|
|
118
|
+
* server-side. A controller never turns browser-supplied IDs into authority. */
|
|
119
|
+
export interface ConnectTransport {
|
|
120
|
+
catalog(workspaceId: string, options?: ConnectCallOptions): Promise<ConnectProvider[]>;
|
|
121
|
+
accounts(workspaceId: string, options?: ConnectCallOptions): Promise<ConnectAccount[]>;
|
|
122
|
+
pending(workspaceId: string, options?: ConnectCallOptions): Promise<ConnectAttempt[]>;
|
|
123
|
+
begin(workspaceId: string, input: {
|
|
124
|
+
providerId: string;
|
|
125
|
+
ownership: ConnectOwnership;
|
|
126
|
+
returnUrl: string;
|
|
127
|
+
idempotencyKey: string;
|
|
128
|
+
reconnectAccountId?: string;
|
|
129
|
+
installationTarget?: ConnectInstallationTarget;
|
|
130
|
+
}, options?: ConnectCallOptions): Promise<ConnectAttempt>;
|
|
131
|
+
get(workspaceId: string, attemptId: string, options?: ConnectCallOptions): Promise<ConnectAttempt>;
|
|
132
|
+
advance(workspaceId: string, attemptId: string, input: {
|
|
133
|
+
expectedRevision: number;
|
|
134
|
+
idempotencyKey: string;
|
|
135
|
+
action: ConnectAdvance;
|
|
136
|
+
}, options?: ConnectCallOptions): Promise<ConnectAttempt>;
|
|
137
|
+
cancel(workspaceId: string, attemptId: string, input: {
|
|
138
|
+
expectedRevision: number;
|
|
139
|
+
idempotencyKey: string;
|
|
140
|
+
}, options?: ConnectCallOptions): Promise<ConnectAttempt>;
|
|
141
|
+
disconnect(workspaceId: string, accountId: string, options?: ConnectCallOptions & {
|
|
142
|
+
expectedVersion?: number;
|
|
143
|
+
}): Promise<void>;
|
|
144
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opengeni/connect",
|
|
3
|
+
"version": "0.2.0-canary.0",
|
|
4
|
+
"description": "Framework-neutral OpenGeni connection setup and recovery controller.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
|
|
9
|
+
"directory": "packages/connect"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"module": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"build": "bun ../../scripts/build-typescript-package.ts",
|
|
33
|
+
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|