@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.
@@ -0,0 +1,51 @@
1
+ import { pollConnectAttempt } from "./poll";
2
+ import type { ConnectAttempt, ConnectTransport } from "./types";
3
+
4
+ /** Inject navigation so hosts own routing and this package needs no DOM globals. */
5
+ export type ConnectNavigation = {
6
+ openPopup(url: string): { close(): void } | null;
7
+ redirect(url: string): void;
8
+ };
9
+
10
+ /** Invoke directly from a user gesture for popup mode. Persist the opaque
11
+ * attempt ID in host-owned state before redirect mode, then recover/poll on
12
+ * return. Neither URL parameters nor popup messages prove completion. */
13
+ export function authorizeConnectAttempt(
14
+ transport: Pick<ConnectTransport, "get">,
15
+ attempt: ConnectAttempt,
16
+ navigation: ConnectNavigation,
17
+ options: { mode: "popup" | "redirect"; signal?: AbortSignal; timeoutMs?: number },
18
+ ): Promise<ConnectAttempt | null> {
19
+ options.signal?.throwIfAborted();
20
+ if (attempt.nextAction.type !== "authorize") {
21
+ throw new Error("Connect attempt does not require authorization");
22
+ }
23
+ const destination = new URL(attempt.nextAction.url);
24
+ if (destination.protocol !== "https:" || destination.username || destination.password) {
25
+ throw new Error("Connect authorization requires an HTTPS destination without credentials");
26
+ }
27
+ if (
28
+ !attempt.id ||
29
+ !attempt.workspaceId ||
30
+ !Number.isSafeInteger(attempt.revision) ||
31
+ attempt.revision < 1
32
+ )
33
+ throw new Error("Connect authorization requires a scope");
34
+ if (options.mode === "redirect") {
35
+ navigation.redirect(attempt.nextAction.url);
36
+ return Promise.resolve(null);
37
+ }
38
+ const popup = navigation.openPopup(attempt.nextAction.url);
39
+ if (!popup) throw new Error("Connect popup was blocked; retry with redirect mode");
40
+ return pollConnectAttempt(transport, attempt.workspaceId, attempt.id, {
41
+ ...options,
42
+ minimumRevision: attempt.revision,
43
+ }).finally(() => {
44
+ // Window cleanup must never replace the authoritative result or failure.
45
+ try {
46
+ popup.close();
47
+ } catch {
48
+ /* Host navigation may already have disposed it. */
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,52 @@
1
+ import type { ConnectNavigation } from "./authorization";
2
+
3
+ /** Structural browser surface so importing Connect never reads DOM globals. */
4
+ export type ConnectBrowserWindow = {
5
+ open(
6
+ url: string,
7
+ target: string,
8
+ features: string,
9
+ ): {
10
+ opener: unknown;
11
+ location: { replace(url: string): void };
12
+ close(): void;
13
+ } | null;
14
+ location: { assign(url: string): void };
15
+ };
16
+
17
+ function validateDestination(url: string): void {
18
+ const parsed = new URL(url);
19
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password)
20
+ throw new Error("Connect authorization requires an HTTPS destination without credentials");
21
+ }
22
+
23
+ /** Pass window from the host's browser entry point. Opens a fresh blank window
24
+ * synchronously and severs its opener BEFORE any provider content can load.
25
+ * Never uses a reusable named target or relies on provider window messages.
26
+ * The caller retains only the close capability for backend-polling cleanup. */
27
+ export function createBrowserConnectNavigation(browser: ConnectBrowserWindow): ConnectNavigation {
28
+ return {
29
+ openPopup(url) {
30
+ validateDestination(url);
31
+ const popup = browser.open("about:blank", "_blank", "popup,width=520,height=720");
32
+ if (!popup) return null;
33
+ try {
34
+ popup.opener = null;
35
+ if (popup.opener !== null) throw new Error("Connect popup isolation failed");
36
+ popup.location.replace(url);
37
+ } catch {
38
+ try {
39
+ popup.close();
40
+ } catch {
41
+ /* Preserve the isolated navigation failure. */
42
+ }
43
+ throw new Error("Connect popup could not navigate safely; retry with redirect mode");
44
+ }
45
+ return { close: () => popup.close() };
46
+ },
47
+ redirect(url) {
48
+ validateDestination(url);
49
+ browser.location.assign(url);
50
+ },
51
+ };
52
+ }
package/src/device.ts ADDED
@@ -0,0 +1,109 @@
1
+ /** Shared device-code behavior for model-account domains. These accounts keep
2
+ * their existing APIs and ownership semantics; they are not generic credentials.
3
+ * Keep opaque provider state on the host backend when reload recovery is needed. */
4
+ export async function pollDeviceAuthorization<
5
+ T extends { status: string; intervalSeconds?: number },
6
+ >(options: {
7
+ poll: () => Promise<T>;
8
+ expired: T;
9
+ initialIntervalSeconds: number;
10
+ expiresAtMs: number;
11
+ signal: AbortSignal;
12
+ retryable?: (error: unknown) => boolean;
13
+ now?: () => number;
14
+ wait?: (delayMs: number, signal: AbortSignal) => Promise<boolean>;
15
+ maxRetryDelaySeconds?: number;
16
+ }): Promise<T | null> {
17
+ const now = options.now ?? Date.now;
18
+ if (
19
+ !Number.isFinite(options.expiresAtMs) ||
20
+ !Number.isFinite(options.initialIntervalSeconds) ||
21
+ options.initialIntervalSeconds <= 0 ||
22
+ (options.maxRetryDelaySeconds !== undefined &&
23
+ (!Number.isFinite(options.maxRetryDelaySeconds) || options.maxRetryDelaySeconds <= 0))
24
+ )
25
+ throw new Error("Device authorization requires a finite expiry and polling interval");
26
+ const wait = options.wait ?? waitForDeviceDelay;
27
+ const initial = Math.max(1, options.initialIntervalSeconds);
28
+ const maximum = Math.max(initial, options.maxRetryDelaySeconds ?? 30);
29
+ let delay = initial;
30
+ while (!options.signal.aborted) {
31
+ const remaining = options.expiresAtMs - now();
32
+ if (remaining <= 0) return options.expired;
33
+ if (!(await wait(Math.min(delay * 1000, remaining), options.signal)) || options.signal.aborted)
34
+ return null;
35
+ if (now() >= options.expiresAtMs) return options.expired;
36
+ let result: T;
37
+ try {
38
+ const observed = await observeDevicePoll(
39
+ options.poll,
40
+ options.expiresAtMs - now(),
41
+ options.signal,
42
+ );
43
+ if (observed.kind === "aborted") return null;
44
+ if (observed.kind === "expired") return options.expired;
45
+ result = observed.result;
46
+ } catch (error) {
47
+ if (!options.retryable?.(error)) throw error;
48
+ delay = Math.min(maximum, Math.max(initial, delay * 2));
49
+ continue;
50
+ }
51
+ if (options.signal.aborted) return null;
52
+ if (result.status !== "pending" && result.status !== "slow_down") return result;
53
+ if (
54
+ result.intervalSeconds !== undefined &&
55
+ (!Number.isFinite(result.intervalSeconds) || result.intervalSeconds <= 0)
56
+ )
57
+ throw new Error("Provider returned an invalid device polling interval");
58
+ delay = Math.max(
59
+ 1,
60
+ result.intervalSeconds ?? (result.status === "slow_down" ? delay + 5 : delay),
61
+ );
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /** A transport may ignore cancellation. Bound observation without retrying an
67
+ * in-flight request or treating the loss of observation as provider success. */
68
+ async function observeDevicePoll<T>(
69
+ poll: () => Promise<T>,
70
+ remainingMs: number,
71
+ signal: AbortSignal,
72
+ ): Promise<{ kind: "result"; result: T } | { kind: "aborted" } | { kind: "expired" }> {
73
+ if (signal.aborted) return { kind: "aborted" };
74
+ if (remainingMs <= 0) return { kind: "expired" };
75
+ let timer: ReturnType<typeof setTimeout> | undefined;
76
+ let abort: (() => void) | undefined;
77
+ try {
78
+ const stopped = new Promise<{ kind: "aborted" } | { kind: "expired" }>((resolve) => {
79
+ abort = () => resolve({ kind: "aborted" });
80
+ signal.addEventListener("abort", abort, { once: true });
81
+ timer = setTimeout(() => resolve({ kind: "expired" }), remainingMs);
82
+ });
83
+ return await Promise.race([
84
+ Promise.resolve()
85
+ .then(poll)
86
+ .then((result) => ({ kind: "result" as const, result })),
87
+ stopped,
88
+ ]);
89
+ } finally {
90
+ if (timer) clearTimeout(timer);
91
+ if (abort) signal.removeEventListener("abort", abort);
92
+ }
93
+ }
94
+
95
+ async function waitForDeviceDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
96
+ if (signal.aborted) return false;
97
+ return new Promise((resolve) => {
98
+ const abort = () => {
99
+ clearTimeout(timer);
100
+ resolve(false);
101
+ };
102
+ const timer = setTimeout(() => {
103
+ signal.removeEventListener("abort", abort);
104
+ resolve(true);
105
+ }, delayMs);
106
+ signal.addEventListener("abort", abort, { once: true });
107
+ if (signal.aborted) abort();
108
+ });
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,210 @@
1
+ export * from "./types";
2
+ export { pollDeviceAuthorization } from "./device";
3
+ export { findConnectRecoveryAccount } from "./recovery";
4
+ export { pollConnectAttempt } from "./poll";
5
+ export { authorizeConnectAttempt, type ConnectNavigation } from "./authorization";
6
+ export { createBrowserConnectNavigation, type ConnectBrowserWindow } from "./browser-navigation";
7
+ import type { ConnectAdvance, ConnectAttempt, ConnectOwnership, ConnectTransport } from "./types";
8
+ import { pollConnectAttempt } from "./poll";
9
+
10
+ export type ConnectSnapshot = {
11
+ attempt: ConnectAttempt | null;
12
+ busy: boolean;
13
+ error: Error | null;
14
+ };
15
+
16
+ /** A transport-injected, framework-neutral view of one durable setup attempt.
17
+ * OAuth redirects are hints to navigate; completion is read from the backend.
18
+ * Secret form values are never stored in a snapshot or browser persistence. */
19
+ export class ConnectController {
20
+ private snapshot: ConnectSnapshot = Object.freeze({ attempt: null, busy: false, error: null });
21
+ private readonly listeners = new Set<() => void>();
22
+ private generation = 0;
23
+ private request: AbortController | null = null;
24
+ private disposed = false;
25
+
26
+ constructor(
27
+ readonly transport: ConnectTransport,
28
+ readonly workspaceId: string,
29
+ ) {
30
+ if (!workspaceId) throw new Error("Connect requires an explicit workspace");
31
+ }
32
+
33
+ getSnapshot = (): ConnectSnapshot => this.snapshot;
34
+ subscribe = (listener: () => void): (() => void) => {
35
+ this.assertActive();
36
+ this.listeners.add(listener);
37
+ return () => {
38
+ this.listeners.delete(listener);
39
+ };
40
+ };
41
+
42
+ begin(input: {
43
+ providerId: string;
44
+ ownership: ConnectOwnership;
45
+ returnUrl: string;
46
+ idempotencyKey: string;
47
+ reconnectAccountId?: string;
48
+ installationTarget?: import("./types").ConnectInstallationTarget;
49
+ }): Promise<ConnectAttempt> {
50
+ // Validation must not serialize or decorate the host's exact return string.
51
+ const url = new URL(input.returnUrl);
52
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) {
53
+ throw new Error("Connect return URL must be an HTTP(S) destination without credentials");
54
+ }
55
+ return this.run((signal) => this.transport.begin(this.workspaceId, input, { signal }), true);
56
+ }
57
+
58
+ recover(attemptId: string): Promise<ConnectAttempt> {
59
+ if (!attemptId) throw new Error("Connect recovery requires an attempt ID");
60
+ return this.run(
61
+ (signal) => this.transport.get(this.workspaceId, attemptId, { signal }),
62
+ true,
63
+ attemptId,
64
+ );
65
+ }
66
+
67
+ refresh(): Promise<ConnectAttempt> {
68
+ const attempt = this.requireAttempt();
69
+ if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
70
+ return this.run(
71
+ (signal) => this.transport.get(this.workspaceId, attempt.id, { signal }),
72
+ false,
73
+ attempt.id,
74
+ );
75
+ }
76
+
77
+ advance(action: ConnectAdvance, idempotencyKey: string): Promise<ConnectAttempt> {
78
+ const attempt = this.requireAttempt();
79
+ if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
80
+ return this.run(
81
+ (signal) =>
82
+ this.transport.advance(
83
+ this.workspaceId,
84
+ attempt.id,
85
+ {
86
+ expectedRevision: attempt.revision,
87
+ idempotencyKey,
88
+ action,
89
+ },
90
+ { signal },
91
+ ),
92
+ false,
93
+ attempt.id,
94
+ );
95
+ }
96
+
97
+ /** Observe backend progress without replaying a setup mutation. Disposal or
98
+ * selecting another attempt aborts the read loop and fences its late result. */
99
+ waitForAction(options: { timeoutMs?: number } = {}): Promise<ConnectAttempt> {
100
+ const attempt = this.requireAttempt();
101
+ if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
102
+ return this.run(
103
+ (signal) =>
104
+ pollConnectAttempt(this.transport, this.workspaceId, attempt.id, {
105
+ ...options,
106
+ signal,
107
+ minimumRevision: attempt.revision,
108
+ }),
109
+ false,
110
+ attempt.id,
111
+ );
112
+ }
113
+
114
+ cancel(idempotencyKey: string): Promise<ConnectAttempt> {
115
+ const attempt = this.requireAttempt();
116
+ if (this.snapshot.busy) throw new Error("A Connect operation is already in progress");
117
+ return this.run(
118
+ (signal) =>
119
+ this.transport.cancel(
120
+ this.workspaceId,
121
+ attempt.id,
122
+ {
123
+ expectedRevision: attempt.revision,
124
+ idempotencyKey,
125
+ },
126
+ { signal },
127
+ ),
128
+ false,
129
+ attempt.id,
130
+ );
131
+ }
132
+
133
+ dispose(): void {
134
+ if (this.disposed) return;
135
+ this.disposed = true;
136
+ this.generation++;
137
+ this.request?.abort();
138
+ this.request = null;
139
+ this.listeners.clear();
140
+ }
141
+
142
+ private assertActive(): void {
143
+ if (this.disposed) throw new Error("Connect controller is disposed");
144
+ }
145
+ private requireAttempt(): ConnectAttempt {
146
+ this.assertActive();
147
+ if (!this.snapshot.attempt) throw new Error("No Connect attempt is selected");
148
+ return this.snapshot.attempt;
149
+ }
150
+ private publish(snapshot: ConnectSnapshot): void {
151
+ this.snapshot = Object.freeze(snapshot);
152
+ for (const listener of this.listeners) listener();
153
+ }
154
+ private async run(
155
+ operation: (signal: AbortSignal) => Promise<ConnectAttempt>,
156
+ replace: boolean,
157
+ expectedId?: string,
158
+ ): Promise<ConnectAttempt> {
159
+ this.assertActive();
160
+ const generation = ++this.generation;
161
+ this.request?.abort();
162
+ const request = new AbortController();
163
+ this.request = request;
164
+ this.publish({ attempt: replace ? null : this.snapshot.attempt, busy: true, error: null });
165
+ try {
166
+ const result = await operation(request.signal);
167
+ if (generation !== this.generation || this.disposed)
168
+ throw new Error("Connect operation was superseded");
169
+ if (result.workspaceId !== this.workspaceId || (expectedId && result.id !== expectedId)) {
170
+ throw new Error("Connect response scope mismatch");
171
+ }
172
+ if (
173
+ !Number.isSafeInteger(result.revision) ||
174
+ result.revision < 1 ||
175
+ (this.snapshot.attempt?.id === result.id &&
176
+ result.revision < this.snapshot.attempt.revision)
177
+ ) {
178
+ throw new Error("Connect response revision is stale");
179
+ }
180
+ const attempt = freezeTree(structuredClone(result));
181
+ this.publish({ attempt, busy: false, error: null });
182
+ return structuredClone(attempt);
183
+ } catch (cause) {
184
+ const error = cause instanceof Error ? cause : new Error("Connect operation failed");
185
+ if (generation === this.generation && !this.disposed) {
186
+ // Transport errors can retain request bodies, headers, nested causes or
187
+ // echoed credentials. Keep only a fixed, credential-free UI error in
188
+ // the long-lived snapshot; direct callers still receive the rejection.
189
+ this.publish({
190
+ attempt: this.snapshot.attempt,
191
+ busy: false,
192
+ error: Object.freeze(
193
+ new Error("Connect operation failed; refresh its status before retrying"),
194
+ ),
195
+ });
196
+ }
197
+ throw error;
198
+ } finally {
199
+ if (generation === this.generation) this.request = null;
200
+ }
201
+ }
202
+ }
203
+
204
+ function freezeTree<T>(value: T): T {
205
+ if (value !== null && typeof value === "object") {
206
+ for (const child of Object.values(value)) freezeTree(child);
207
+ Object.freeze(value);
208
+ }
209
+ return value;
210
+ }
package/src/poll.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { ConnectAttempt, ConnectTransport } from "./types";
2
+
3
+ /** Read-only continuation. The backend remains authoritative for expiry and
4
+ * completion; redirects and popup messages are never completion evidence. */
5
+ export async function pollConnectAttempt(
6
+ transport: Pick<ConnectTransport, "get">,
7
+ workspaceId: string,
8
+ attemptId: string,
9
+ options: { signal?: AbortSignal; timeoutMs?: number; minimumRevision?: number } = {},
10
+ ): Promise<ConnectAttempt> {
11
+ const timeoutMs = options.timeoutMs ?? 120_000;
12
+ if (
13
+ !workspaceId ||
14
+ !attemptId ||
15
+ !Number.isFinite(timeoutMs) ||
16
+ timeoutMs < 1 ||
17
+ timeoutMs > 600_000 ||
18
+ !Number.isSafeInteger(options.minimumRevision ?? 1) ||
19
+ (options.minimumRevision ?? 1) < 1
20
+ ) {
21
+ throw new Error("Connect polling requires a scope and a bounded timeout");
22
+ }
23
+ const abort = new AbortController();
24
+ const cancel = () => abort.abort(options.signal?.reason);
25
+ options.signal?.addEventListener("abort", cancel, { once: true });
26
+ if (options.signal?.aborted) cancel();
27
+ const timer = setTimeout(() => abort.abort(new Error("Connect polling timed out")), timeoutMs);
28
+ let revision = options.minimumRevision ?? 1;
29
+ try {
30
+ while (true) {
31
+ abort.signal.throwIfAborted();
32
+ const result = await abortable(
33
+ transport.get(workspaceId, attemptId, { signal: abort.signal }),
34
+ abort.signal,
35
+ );
36
+ if (
37
+ result.workspaceId !== workspaceId ||
38
+ result.id !== attemptId ||
39
+ !Number.isSafeInteger(result.revision) ||
40
+ result.revision < Math.max(1, revision)
41
+ ) {
42
+ throw new Error("Connect polling response scope or revision mismatch");
43
+ }
44
+ revision = result.revision;
45
+ if (
46
+ ["complete", "cancelled", "expired", "failed", "uncertain"].includes(result.state) ||
47
+ !["authorize", "wait"].includes(result.nextAction.type)
48
+ )
49
+ return result;
50
+ const requested = result.nextAction.type === "wait" ? result.nextAction.pollAfterMs : 1000;
51
+ const delay = Number.isFinite(requested) ? Math.min(60_000, Math.max(250, requested)) : 1000;
52
+ await new Promise<void>((resolve, reject) => {
53
+ const stop = () => {
54
+ clearTimeout(wait);
55
+ reject(abort.signal.reason);
56
+ };
57
+ const wait = setTimeout(() => {
58
+ abort.signal.removeEventListener("abort", stop);
59
+ resolve();
60
+ }, delay);
61
+ abort.signal.addEventListener("abort", stop, { once: true });
62
+ if (abort.signal.aborted) stop();
63
+ });
64
+ }
65
+ } finally {
66
+ clearTimeout(timer);
67
+ options.signal?.removeEventListener("abort", cancel);
68
+ }
69
+ }
70
+
71
+ function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
72
+ return new Promise((resolve, reject) => {
73
+ const stop = () => reject(signal.reason);
74
+ signal.addEventListener("abort", stop, { once: true });
75
+ if (signal.aborted) stop();
76
+ pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
77
+ });
78
+ }
@@ -0,0 +1,16 @@
1
+ import type { ConnectAccount } from "./types";
2
+
3
+ /** A provider/domain match is not account identity. Never silently substitute
4
+ * another account when the original connection has disappeared. */
5
+ export function findConnectRecoveryAccount(
6
+ accounts: readonly ConnectAccount[],
7
+ connectionId: string | null | undefined,
8
+ ): ConnectAccount | null {
9
+ if (!connectionId) return null;
10
+ const matches = accounts.filter(
11
+ (account) => account.id === connectionId || account.id === `social:${connectionId}`,
12
+ );
13
+ if (matches.length > 1)
14
+ throw new Error("Connection recovery is ambiguous; choose the exact account.");
15
+ return matches[0] ?? null;
16
+ }
package/src/types.ts ADDED
@@ -0,0 +1,134 @@
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<
10
+ "none" | "oauth" | "credentials" | "device" | "installation" | "openapi" | "graphql"
11
+ >;
12
+ };
13
+ export type ConnectAccount = {
14
+ id: string;
15
+ providerId: string;
16
+ /** Observed credential generation; pass to disconnect to reject stale selections. */
17
+ version?: number;
18
+ label: string;
19
+ ownership: ConnectOwnership;
20
+ status: "connected" | "auth_needed" | "disabled";
21
+ };
22
+ export type ConnectResource = { id: string; label: string; kind: string };
23
+ export type ConnectInstallationTarget = {
24
+ instanceKey: string;
25
+ displayName: string;
26
+ expectedInstanceVersion?: number;
27
+ };
28
+ export type ConnectNextAction =
29
+ | { type: "authorize"; url: string }
30
+ | {
31
+ type: "credentials";
32
+ fields: Array<{
33
+ name: string;
34
+ label: string;
35
+ required: boolean;
36
+ secret: boolean;
37
+ options?: Array<{ value: string; label: string }>;
38
+ }>;
39
+ }
40
+ | { type: "wait"; pollAfterMs: number; userCode?: string; verificationUrl?: string }
41
+ | { type: "select_account"; accounts: ConnectAccount[] }
42
+ | { type: "select_resources"; resources: ConnectResource[]; cursor?: string }
43
+ | { type: "preview"; previewId: string; contentHash: string; operations: ConnectResource[] }
44
+ | { type: "none" };
45
+ export type ConnectAttempt = {
46
+ id: string;
47
+ workspaceId: string;
48
+ providerId: string;
49
+ ownership: ConnectOwnership;
50
+ revision: number;
51
+ state:
52
+ | "ready"
53
+ | "requires_user_action"
54
+ | "credential_input"
55
+ | "provider_wait"
56
+ | "account_selection"
57
+ | "resource_selection"
58
+ | "preview"
59
+ | "installing"
60
+ | "connected_but_incomplete"
61
+ | "complete"
62
+ | "cancelled"
63
+ | "expired"
64
+ | "failed"
65
+ | "uncertain";
66
+ credentialsCommitted: boolean;
67
+ integrationInstalled: boolean;
68
+ completionRequirement: "connection" | "integration" | "provider_setup";
69
+ nextAction: ConnectNextAction;
70
+ expiresAt: string;
71
+ account?: ConnectAccount;
72
+ installationTarget?: ConnectInstallationTarget;
73
+ source?:
74
+ | { kind: "definition"; definitionId: string }
75
+ | { kind: "openapi" | "auto"; url: string; baseUrl?: string }
76
+ | { kind: "graphql"; endpoint: string; name?: string };
77
+ error?: { code: string; message: string; retryable: boolean };
78
+ };
79
+ export type ConnectAdvance =
80
+ | { type: "credentials"; values: Record<string, string> }
81
+ | { type: "account"; accountId: string }
82
+ | { type: "resources"; resourceIds: string[] }
83
+ | { type: "install"; previewId: string; contentHash: string; operationIds: string[] }
84
+ | { type: "retry" };
85
+ export type ConnectCallOptions = { signal?: AbortSignal };
86
+
87
+ /** Host backend transport; authenticated workspace and actor admission stays
88
+ * server-side. A controller never turns browser-supplied IDs into authority. */
89
+ export interface ConnectTransport {
90
+ catalog(workspaceId: string, options?: ConnectCallOptions): Promise<ConnectProvider[]>;
91
+ accounts(workspaceId: string, options?: ConnectCallOptions): Promise<ConnectAccount[]>;
92
+ pending(workspaceId: string, options?: ConnectCallOptions): Promise<ConnectAttempt[]>;
93
+ begin(
94
+ workspaceId: string,
95
+ input: {
96
+ providerId: string;
97
+ ownership: ConnectOwnership;
98
+ returnUrl: string;
99
+ idempotencyKey: string;
100
+ reconnectAccountId?: string;
101
+ installationTarget?: ConnectInstallationTarget;
102
+ },
103
+ options?: ConnectCallOptions,
104
+ ): Promise<ConnectAttempt>;
105
+ get(
106
+ workspaceId: string,
107
+ attemptId: string,
108
+ options?: ConnectCallOptions,
109
+ ): Promise<ConnectAttempt>;
110
+ advance(
111
+ workspaceId: string,
112
+ attemptId: string,
113
+ input: {
114
+ expectedRevision: number;
115
+ idempotencyKey: string;
116
+ action: ConnectAdvance;
117
+ },
118
+ options?: ConnectCallOptions,
119
+ ): Promise<ConnectAttempt>;
120
+ cancel(
121
+ workspaceId: string,
122
+ attemptId: string,
123
+ input: {
124
+ expectedRevision: number;
125
+ idempotencyKey: string;
126
+ },
127
+ options?: ConnectCallOptions,
128
+ ): Promise<ConnectAttempt>;
129
+ disconnect(
130
+ workspaceId: string,
131
+ accountId: string,
132
+ options?: ConnectCallOptions & { expectedVersion?: number },
133
+ ): Promise<void>;
134
+ }