@andco/sdk 0.0.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/LICENSE +202 -0
- package/README.md +176 -0
- package/dist/auth.d.ts +71 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +98 -0
- package/dist/browser/controller.d.ts +76 -0
- package/dist/browser/controller.d.ts.map +1 -0
- package/dist/browser/controller.js +215 -0
- package/dist/browser/frame.d.ts +65 -0
- package/dist/browser/frame.d.ts.map +1 -0
- package/dist/browser/frame.js +237 -0
- package/dist/browser/index.d.ts +52 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +117 -0
- package/dist/browser/popup.d.ts +105 -0
- package/dist/browser/popup.d.ts.map +1 -0
- package/dist/browser/popup.js +177 -0
- package/dist/cli/index.d.ts +47 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +72 -0
- package/dist/cli/server.d.ts +11 -0
- package/dist/cli/server.d.ts.map +1 -0
- package/dist/cli/server.js +91 -0
- package/dist/client.d.ts +135 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +210 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +109 -0
- package/dist/credentials.d.ts +76 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +0 -0
- package/dist/errors.d.ts +165 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +197 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/intents.d.ts +89 -0
- package/dist/intents.d.ts.map +1 -0
- package/dist/intents.js +157 -0
- package/dist/oauth.d.ts +147 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +348 -0
- package/dist/presenter.d.ts +36 -0
- package/dist/presenter.d.ts.map +1 -0
- package/dist/presenter.js +1 -0
- package/dist/rest.d.ts +58 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +48 -0
- package/dist/server/index.d.ts +21 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +27 -0
- package/dist/server-metadata.generated.d.ts +4 -0
- package/dist/server-metadata.generated.d.ts.map +1 -0
- package/dist/server-metadata.generated.js +49 -0
- package/dist/session-store.d.ts +83 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +186 -0
- package/dist/storage.d.ts +61 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +47 -0
- package/package.json +56 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ANDCO_ERROR_CODES as ANDCO_PROTOCOL_ERROR_CODES, ANDCO_RELEASE, callbackStateCreate } from "@andco/protocol";
|
|
2
|
+
import { ANDCO_ERROR_CODES, AndcoError } from "../errors.js";
|
|
3
|
+
import { AndcoButtonBridge } from "./frame.js";
|
|
4
|
+
/**
|
|
5
|
+
* Coordinates one hosted Andco iframe without owning how a framework renders it.
|
|
6
|
+
*
|
|
7
|
+
* The iframe is Andco-deployed and cross-origin, which is what the Hosted Intent Button buys: the
|
|
8
|
+
* visible control's DOM and code are not the Project's, and the popup handle never enters Project
|
|
9
|
+
* JavaScript. It is not an authorization boundary and does not protect Project tokens.
|
|
10
|
+
*
|
|
11
|
+
* Render `snapshot.iframeURL` in your platform's own iframe element, then hand that element to
|
|
12
|
+
* `connect()` once it mounts.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const controller = new AndcoButtonController({ client: andco, onComplete: reload });
|
|
17
|
+
* const stop = controller.subscribe((snapshot) => setState(snapshot));
|
|
18
|
+
* const disconnect = controller.connect(iframeElement);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export class AndcoButtonController {
|
|
22
|
+
#options;
|
|
23
|
+
#listeners = new Set();
|
|
24
|
+
#bridge;
|
|
25
|
+
#iframe;
|
|
26
|
+
#snapshot;
|
|
27
|
+
#pending;
|
|
28
|
+
#presentationId;
|
|
29
|
+
#generation = 0;
|
|
30
|
+
/** One automatic reconnect per `connect()` call, so a single slow network blip self-heals without
|
|
31
|
+
* bothering the app — but a persistently broken endpoint still surfaces as a visible `"error"`
|
|
32
|
+
* rather than retrying forever and hiding a real misconfiguration. */
|
|
33
|
+
#timedOutOnce = false;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.#options = options;
|
|
36
|
+
this.#snapshot = { status: "idle", iframeURL: this.#iframeURL(), ready: false, error: null };
|
|
37
|
+
}
|
|
38
|
+
get snapshot() {
|
|
39
|
+
return this.#snapshot;
|
|
40
|
+
}
|
|
41
|
+
/** Observes lifecycle changes. The return value is the unsubscribe. */
|
|
42
|
+
subscribe(listener) {
|
|
43
|
+
this.#listeners.add(listener);
|
|
44
|
+
listener(this.#snapshot);
|
|
45
|
+
return () => {
|
|
46
|
+
this.#listeners.delete(listener);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Attaches a mounted iframe. The return value disconnects it. */
|
|
50
|
+
connect(iframe) {
|
|
51
|
+
this.disconnect();
|
|
52
|
+
this.#iframe = iframe;
|
|
53
|
+
console.debug("[AndcoButtonController] connecting: %o", { iframeURL: this.#snapshot.iframeURL });
|
|
54
|
+
this.#publish({ status: "connecting", ready: false, error: null });
|
|
55
|
+
try {
|
|
56
|
+
this.#bridge = new AndcoButtonBridge(iframe, {
|
|
57
|
+
endpointButton: this.#iframeURL(),
|
|
58
|
+
onReady: (event) => {
|
|
59
|
+
this.#publish({ status: "ready", ready: true });
|
|
60
|
+
this.#options.onReady?.(event);
|
|
61
|
+
},
|
|
62
|
+
onActivate: (event) => {
|
|
63
|
+
this.#presentationId = event.presentationId;
|
|
64
|
+
this.#options.onActivate?.(event);
|
|
65
|
+
void this.#authorize();
|
|
66
|
+
},
|
|
67
|
+
onComplete: (event) => void this.#settle(event),
|
|
68
|
+
onDismiss: () => {
|
|
69
|
+
this.#publish({ status: "dismissed" });
|
|
70
|
+
this.#options.onDismiss?.();
|
|
71
|
+
},
|
|
72
|
+
onAuthorizationError: (event) => this.#fail(new AndcoError(event.error.code, {
|
|
73
|
+
message: event.error.description,
|
|
74
|
+
})),
|
|
75
|
+
onError: (event) => {
|
|
76
|
+
// A single automatic reconnect on a timed-out handshake, and only that one code: it is the
|
|
77
|
+
// one transient failure the bridge itself cannot retry past (it deliberately settles
|
|
78
|
+
// permanently once it gives up, see `AndcoButtonBridge`). Everything else — a rejected
|
|
79
|
+
// iframe, an untrusted origin — is not a timing fluke and retrying it would just repeat it.
|
|
80
|
+
const timedOut = event.error.code === ANDCO_PROTOCOL_ERROR_CODES.HANDSHAKE_TIMEOUT;
|
|
81
|
+
if (timedOut && !this.#timedOutOnce && this.#iframe) {
|
|
82
|
+
this.#timedOutOnce = true;
|
|
83
|
+
console.debug("[AndcoButtonController] handshake timed out", { action: "reconnect", retry: 1 });
|
|
84
|
+
this.connect(this.#iframe);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.#fail(new AndcoError(event.error.code), event);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
this.#fail(AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_IFRAME));
|
|
93
|
+
}
|
|
94
|
+
return () => this.disconnect();
|
|
95
|
+
}
|
|
96
|
+
disconnect() {
|
|
97
|
+
this.#bridge?.destroy();
|
|
98
|
+
this.#bridge = undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Returns the surface to its ready state so it can be activated again.
|
|
102
|
+
*
|
|
103
|
+
* A bridge that ended in `"error"` — a timed-out or failed handshake — has already settled
|
|
104
|
+
* permanently and silently ignores every message after that, including a late `"ready"`. Resetting
|
|
105
|
+
* the status without rebuilding it would show a working button over a bridge that can never
|
|
106
|
+
* recover, so this reconnects against the last-connected iframe instead.
|
|
107
|
+
*/
|
|
108
|
+
restart() {
|
|
109
|
+
this.#generation += 1;
|
|
110
|
+
this.#pending = undefined;
|
|
111
|
+
if (this.#snapshot.status === "error" && this.#iframe) {
|
|
112
|
+
// A deliberate retry (the app calling this after showing the error) earns its own one-shot
|
|
113
|
+
// auto-reconnect on a future timeout, same as the very first connect did.
|
|
114
|
+
this.#timedOutOnce = false;
|
|
115
|
+
console.debug("[AndcoButtonController] restarting after an error, reconnecting: %o", {
|
|
116
|
+
code: this.#snapshot.error?.code,
|
|
117
|
+
});
|
|
118
|
+
this.connect(this.#iframe);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.#publish({ status: this.#bridge ? "ready" : "idle", error: null });
|
|
122
|
+
}
|
|
123
|
+
destroy() {
|
|
124
|
+
this.disconnect();
|
|
125
|
+
this.#iframe = undefined;
|
|
126
|
+
this.#listeners.clear();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Prepares an authorization once the user has activated the hosted control.
|
|
130
|
+
*
|
|
131
|
+
* Deferred on purpose: the iframe holds the user activation and opens the popup synchronously,
|
|
132
|
+
* then asks for the URL. Preparing it earlier would waste a transaction on every render and,
|
|
133
|
+
* for an Intent, create one before anyone asked for it.
|
|
134
|
+
*/
|
|
135
|
+
async #authorize() {
|
|
136
|
+
const generation = ++this.#generation;
|
|
137
|
+
this.#publish({ status: "authorizing", error: null });
|
|
138
|
+
// The hosted surface opens the popup, so the callback has to know to deliver its result there.
|
|
139
|
+
// Encoding that in the state is the protocol's own mechanism and the only one available: the
|
|
140
|
+
// callback document cannot read a cross-origin opener's location.
|
|
141
|
+
const prepared = await this.#options.client.oauth.createAuthorizationRequest({
|
|
142
|
+
...(this.#options.authorization ?? {}),
|
|
143
|
+
state: callbackStateCreate(this.#options.client.config.endpoints.widget.origin, this.#presentationId),
|
|
144
|
+
});
|
|
145
|
+
if (generation !== this.#generation)
|
|
146
|
+
return;
|
|
147
|
+
if (prepared.error)
|
|
148
|
+
return this.#fail(prepared.error);
|
|
149
|
+
this.#pending = prepared.data;
|
|
150
|
+
this.#bridge?.resolveAuthorization(this.#presentationId, prepared.data.authorizationUrl.href);
|
|
151
|
+
}
|
|
152
|
+
async #settle(event) {
|
|
153
|
+
const generation = this.#generation;
|
|
154
|
+
const request = this.#pending;
|
|
155
|
+
this.#pending = undefined;
|
|
156
|
+
// A public PKCE completion carries a one-time code the opener must exchange; a host-managed or
|
|
157
|
+
// Intent completion carries only a lifecycle outcome, and the authenticated read is authoritative.
|
|
158
|
+
if (event.type === "oauth.authorization_code" && request) {
|
|
159
|
+
const callbackUrl = new URL(request.redirectTo);
|
|
160
|
+
callbackUrl.searchParams.set("code", event.authorizationCode);
|
|
161
|
+
callbackUrl.searchParams.set("state", request.state);
|
|
162
|
+
const exchanged = await this.#options.client.oauth.exchangeCallback({ callbackUrl, request });
|
|
163
|
+
if (generation !== this.#generation)
|
|
164
|
+
return;
|
|
165
|
+
if (exchanged.error)
|
|
166
|
+
return this.#fail(exchanged.error);
|
|
167
|
+
const written = await this.#options.client.session.set(exchanged.data);
|
|
168
|
+
if (written.error)
|
|
169
|
+
return this.#fail(written.error);
|
|
170
|
+
}
|
|
171
|
+
if (generation !== this.#generation)
|
|
172
|
+
return;
|
|
173
|
+
this.#publish({ status: "complete", error: null });
|
|
174
|
+
this.#options.onComplete?.();
|
|
175
|
+
}
|
|
176
|
+
#fail(error, event) {
|
|
177
|
+
// An app that never wired `onError` must not lose the failure entirely — a button that just sits
|
|
178
|
+
// there disabled, with nothing in the console, is what made the last one of these hard to find.
|
|
179
|
+
console.error("[AndcoButtonController] %s: %o", error.code, { message: error.message, details: error.details });
|
|
180
|
+
this.#publish({ status: "error", error });
|
|
181
|
+
this.#options.onError?.(error, event);
|
|
182
|
+
}
|
|
183
|
+
#iframeURL() {
|
|
184
|
+
const config = this.#options.client.config;
|
|
185
|
+
const url = config.widgetURL(this.#options.release ?? ANDCO_RELEASE);
|
|
186
|
+
url.searchParams.set("themeMode", this.#options.themeMode ?? config.themeMode);
|
|
187
|
+
url.searchParams.set("locale", this.#options.locale ?? config.locale);
|
|
188
|
+
url.searchParams.set("theme", config.theme);
|
|
189
|
+
url.searchParams.set("flow", this.#options.flow ?? "authorization");
|
|
190
|
+
url.searchParams.set("presentation", this.#options.presentation ?? "popup");
|
|
191
|
+
// The hosted surface renders its own authorization affordances, so it has to reach the same
|
|
192
|
+
// Authorization Server as the instance. Omitting this let it fall back to the production
|
|
193
|
+
// origin, which is invisible in development until nothing works.
|
|
194
|
+
url.searchParams.set("endpointAuth", config.endpoints.auth.href);
|
|
195
|
+
// The hosted contract needs the client it represents and the callback it will return to: it
|
|
196
|
+
// derives its trusted parent origin from the callback during local development, and refuses to
|
|
197
|
+
// enable itself without both. Both come from the instance, never from a component prop — the
|
|
198
|
+
// rule is that a *component* does not accept endpoints, not that the hosted contract can go
|
|
199
|
+
// without them.
|
|
200
|
+
url.searchParams.set("clientId", config.clientId);
|
|
201
|
+
if (config.redirectTo)
|
|
202
|
+
url.searchParams.set("redirectTo", config.redirectTo.href);
|
|
203
|
+
// The hosted contract spells booleans out; "1" is rejected by its parameter schema.
|
|
204
|
+
if (this.#options.disabled !== undefined)
|
|
205
|
+
url.searchParams.set("disabled", String(this.#options.disabled));
|
|
206
|
+
if (this.#options.busy !== undefined)
|
|
207
|
+
url.searchParams.set("busy", String(this.#options.busy));
|
|
208
|
+
return url.href;
|
|
209
|
+
}
|
|
210
|
+
#publish(patch) {
|
|
211
|
+
this.#snapshot = { ...this.#snapshot, ...patch, iframeURL: this.#iframeURL() };
|
|
212
|
+
for (const listener of [...this.#listeners])
|
|
213
|
+
listener(this.#snapshot);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type AndCoClickEvent, type AndCoErrorEvent, type AndCoIntentCompleteEvent, type AndCoIntentDismissEvent, type AndCoOAuthAuthorizationCodeEvent, type AndCoOAuthCompleteEvent, type AndCoOAuthDismissEvent, type AndCoOAuthErrorEvent, type AndCoPopupResult, type AndCoReadyEvent, type AndCoSetPreferencesMessage } from "@andco/protocol";
|
|
2
|
+
/** Callbacks and endpoint required by a secure-button bridge. */
|
|
3
|
+
export type AndcoFrameOptions = {
|
|
4
|
+
endpointButton: string;
|
|
5
|
+
onReady?: (event: AndCoReadyEvent) => void;
|
|
6
|
+
onError?: (event: AndCoErrorEvent) => void;
|
|
7
|
+
onComplete?: (event: AndCoOAuthAuthorizationCodeEvent | AndCoOAuthCompleteEvent | AndCoIntentCompleteEvent) => void;
|
|
8
|
+
onDismiss?: (event: AndCoOAuthDismissEvent | AndCoIntentDismissEvent) => void;
|
|
9
|
+
onAuthorizationError?: (event: AndCoOAuthErrorEvent) => void;
|
|
10
|
+
onActivate?: (event: AndCoClickEvent) => void;
|
|
11
|
+
};
|
|
12
|
+
/** Manages the protocol connection to a secure-button iframe.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const bridge = new AndcoButtonBridge(iframe, { endpointButton });
|
|
17
|
+
* // When unmounting the iframe:
|
|
18
|
+
* bridge.destroy();
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare class AndcoButtonBridge {
|
|
22
|
+
private readonly iframe;
|
|
23
|
+
private readonly options;
|
|
24
|
+
private readonly url;
|
|
25
|
+
private settled;
|
|
26
|
+
private transport;
|
|
27
|
+
private port;
|
|
28
|
+
private handshakeTimer;
|
|
29
|
+
private handshakeTimeout;
|
|
30
|
+
private handshakeAttempts;
|
|
31
|
+
private handshakeStartedAt;
|
|
32
|
+
private destroyed;
|
|
33
|
+
constructor(iframe: HTMLIFrameElement, options: AndcoFrameOptions);
|
|
34
|
+
private stopHandshake;
|
|
35
|
+
private closePort;
|
|
36
|
+
private receive;
|
|
37
|
+
private readonly receiveWindow;
|
|
38
|
+
private readonly initialize;
|
|
39
|
+
private readonly startHandshake;
|
|
40
|
+
/** Sends a validated protocol message to the hosted iframe. */
|
|
41
|
+
private post;
|
|
42
|
+
/**
|
|
43
|
+
* Resolves a deferred authorization request from the hosted button.
|
|
44
|
+
* Omit the URL to close the pending popup after resolution fails.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* bridge.resolveAuthorization(presentationId, "https://app.example.com/oauth/start");
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
resolveAuthorization(presentationId: string, authorizationUrl?: string | URL): void;
|
|
52
|
+
/**
|
|
53
|
+
* Forwards a validated same-origin host callback to the iframe that owns the popup.
|
|
54
|
+
*
|
|
55
|
+
* This method is intended for SDK adapters that relay a callback through an
|
|
56
|
+
* established button bridge; application code normally uses `onComplete` or
|
|
57
|
+
* `onDismiss` instead.
|
|
58
|
+
*/
|
|
59
|
+
forwardAuthorizationResult(message: AndCoPopupResult): void;
|
|
60
|
+
/** Updates locale or color scheme without recreating the iframe. */
|
|
61
|
+
setPreferences(preferences: Pick<AndCoSetPreferencesMessage, "locale" | "themeMode">): void;
|
|
62
|
+
/** Stops the handshake and releases listeners and the message channel. */
|
|
63
|
+
destroy(): void;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=frame.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frame.d.ts","sourceRoot":"","sources":["../../src/browser/frame.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,eAAe,EAEpB,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,gCAAgC,EACrC,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,0BAA0B,EAEhC,MAAM,iBAAiB,CAAC;AAGzB,iEAAiE;AACjE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3C,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,gCAAgC,GAAG,uBAAuB,GAAG,wBAAwB,KAAK,IAAI,CAAC;IACpH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,GAAG,uBAAuB,KAAK,IAAI,CAAC;IAC9E,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC7D,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC/C,CAAC;AAEF;;;;;;;;GAQG;AACH,qBAAa,iBAAiB;IAY1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAZ1B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAM;IAC1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAA4C;IAC7D,OAAO,CAAC,IAAI,CAA0B;IACtC,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,SAAS,CAAS;gBAGP,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,iBAAiB;IAkB7C,OAAO,CAAC,aAAa;IAWrB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,OAAO;IAkDf,OAAO,CAAC,QAAQ,CAAC,aAAa,CAM5B;IAEF,OAAO,CAAC,QAAQ,CAAC,UAAU,CA8BzB;IAEF,OAAO,CAAC,QAAQ,CAAC,cAAc,CA0B7B;IAEF,+DAA+D;IAC/D,OAAO,CAAC,IAAI;IASZ;;;;;;;;OAQG;IACI,oBAAoB,CAAC,cAAc,EAAE,MAAM,EAAE,gBAAgB,CAAC,EAAE,MAAM,GAAG,GAAG;IAWnF;;;;;;OAMG;IACI,0BAA0B,CAAC,OAAO,EAAE,gBAAgB;IAI3D,oEAAoE;IAC7D,cAAc,CAAC,WAAW,EAAE,IAAI,CAAC,0BAA0B,EAAE,QAAQ,GAAG,WAAW,CAAC;IAU3F,0EAA0E;IACnE,OAAO;CASf"}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { ANDCO_ERROR_CODES as ANDCO_PROTOCOL_ERROR_CODES, ANDCO_PROTOCOL_VERSION, ANDCO_SDK_PROTOCOL, isAndCoBridgeMessage, } from "@andco/protocol";
|
|
2
|
+
import { ANDCO_ERROR_CODES, AndcoError } from "../errors.js";
|
|
3
|
+
/** Manages the protocol connection to a secure-button iframe.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const bridge = new AndcoButtonBridge(iframe, { endpointButton });
|
|
8
|
+
* // When unmounting the iframe:
|
|
9
|
+
* bridge.destroy();
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export class AndcoButtonBridge {
|
|
13
|
+
iframe;
|
|
14
|
+
options;
|
|
15
|
+
url;
|
|
16
|
+
settled = false;
|
|
17
|
+
transport = "pending";
|
|
18
|
+
port;
|
|
19
|
+
handshakeTimer;
|
|
20
|
+
handshakeTimeout;
|
|
21
|
+
handshakeAttempts = 0;
|
|
22
|
+
handshakeStartedAt = 0;
|
|
23
|
+
destroyed = false;
|
|
24
|
+
constructor(iframe, options) {
|
|
25
|
+
this.iframe = iframe;
|
|
26
|
+
this.options = options;
|
|
27
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
28
|
+
throw new AndcoError(ANDCO_ERROR_CODES.BROWSER_UNAVAILABLE);
|
|
29
|
+
}
|
|
30
|
+
else if (!(iframe instanceof HTMLIFrameElement)) {
|
|
31
|
+
throw new AndcoError(ANDCO_ERROR_CODES.INVALID_IFRAME, { details: { iframe } });
|
|
32
|
+
}
|
|
33
|
+
this.url = new URL(options.endpointButton);
|
|
34
|
+
if (this.url.origin === window.location.origin) {
|
|
35
|
+
throw new AndcoError(ANDCO_ERROR_CODES.SAME_ORIGIN_ENDPOINT, { details: { endpointButton: this.url.href } });
|
|
36
|
+
}
|
|
37
|
+
console.debug("[AndcoButtonBridge] created: %o", { endpointButton: this.url.href });
|
|
38
|
+
iframe.addEventListener("load", this.startHandshake);
|
|
39
|
+
this.startHandshake();
|
|
40
|
+
}
|
|
41
|
+
stopHandshake() {
|
|
42
|
+
if (this.handshakeTimer !== undefined) {
|
|
43
|
+
window.clearInterval(this.handshakeTimer);
|
|
44
|
+
}
|
|
45
|
+
if (this.handshakeTimeout !== undefined) {
|
|
46
|
+
window.clearTimeout(this.handshakeTimeout);
|
|
47
|
+
}
|
|
48
|
+
this.handshakeTimer = undefined;
|
|
49
|
+
this.handshakeTimeout = undefined;
|
|
50
|
+
}
|
|
51
|
+
closePort() {
|
|
52
|
+
this.port?.close();
|
|
53
|
+
this.port = undefined;
|
|
54
|
+
}
|
|
55
|
+
receive(message) {
|
|
56
|
+
if (!isAndCoBridgeMessage(message)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
switch (message["type"]) {
|
|
60
|
+
case "ready": {
|
|
61
|
+
if (this.settled)
|
|
62
|
+
return;
|
|
63
|
+
this.settled = true;
|
|
64
|
+
this.stopHandshake();
|
|
65
|
+
console.debug("[AndcoButtonBridge] handshake settled: %o", {
|
|
66
|
+
attempts: this.handshakeAttempts,
|
|
67
|
+
transport: this.transport,
|
|
68
|
+
elapsedMs: Date.now() - this.handshakeStartedAt,
|
|
69
|
+
});
|
|
70
|
+
this.options.onReady?.(message);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
case "error": {
|
|
74
|
+
if (!this.settled) {
|
|
75
|
+
this.settled = true;
|
|
76
|
+
this.stopHandshake();
|
|
77
|
+
}
|
|
78
|
+
console.warn("[AndcoButtonBridge] hosted surface reported an error: %o", message.error);
|
|
79
|
+
this.options.onError?.(message);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
case "oauth.authorization_code":
|
|
83
|
+
case "oauth.complete":
|
|
84
|
+
case "intent.complete": {
|
|
85
|
+
if (!this.settled)
|
|
86
|
+
return;
|
|
87
|
+
this.options.onComplete?.(message);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
case "oauth.dismiss":
|
|
91
|
+
case "intent.dismiss": {
|
|
92
|
+
if (this.settled)
|
|
93
|
+
this.options.onDismiss?.(message);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case "oauth.error": {
|
|
97
|
+
// An Authorization Server error is not a decision, so it never becomes a dismissal.
|
|
98
|
+
if (this.settled)
|
|
99
|
+
this.options.onAuthorizationError?.(message);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case "click": {
|
|
103
|
+
if (this.settled)
|
|
104
|
+
this.options.onActivate?.(message);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
receiveWindow = (event) => {
|
|
110
|
+
if (this.transport === "port" || event.origin !== this.url.origin || event.source !== this.iframe.contentWindow) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.transport = "window";
|
|
114
|
+
this.receive(event.data);
|
|
115
|
+
};
|
|
116
|
+
initialize = () => {
|
|
117
|
+
const target = this.iframe.contentWindow;
|
|
118
|
+
if (!target)
|
|
119
|
+
return;
|
|
120
|
+
this.handshakeAttempts += 1;
|
|
121
|
+
this.closePort();
|
|
122
|
+
const channel = new MessageChannel();
|
|
123
|
+
this.port = channel.port1;
|
|
124
|
+
this.port.onmessage = (event) => {
|
|
125
|
+
if (this.transport === "window")
|
|
126
|
+
return;
|
|
127
|
+
this.transport = "port";
|
|
128
|
+
window.removeEventListener("message", this.receiveWindow);
|
|
129
|
+
this.receive(event.data);
|
|
130
|
+
};
|
|
131
|
+
const message = {
|
|
132
|
+
protocol: ANDCO_SDK_PROTOCOL,
|
|
133
|
+
version: ANDCO_PROTOCOL_VERSION,
|
|
134
|
+
type: "init",
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
target.postMessage(message, this.url.origin, [channel.port2]);
|
|
138
|
+
}
|
|
139
|
+
catch (cause) {
|
|
140
|
+
// The iframe can still be on `about:blank` (inheriting this window's origin) the instant this
|
|
141
|
+
// runs, since `startHandshake` calls `initialize` once before the retry interval exists. That
|
|
142
|
+
// makes the very first attempt throw a targetOrigin mismatch; the 250ms retry loop recovers
|
|
143
|
+
// once the iframe has actually navigated, so a transient failure here is not fatal — but it is
|
|
144
|
+
// worth a log line, since a run that never recovers looks identical to this from the outside.
|
|
145
|
+
console.debug("[AndcoButtonBridge] init attempt %d could not post yet, retrying: %o", this.handshakeAttempts, {
|
|
146
|
+
cause,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
startHandshake = () => {
|
|
151
|
+
this.stopHandshake();
|
|
152
|
+
this.settled = false;
|
|
153
|
+
this.transport = "pending";
|
|
154
|
+
this.handshakeAttempts = 0;
|
|
155
|
+
this.handshakeStartedAt = Date.now();
|
|
156
|
+
window.addEventListener("message", this.receiveWindow);
|
|
157
|
+
this.initialize();
|
|
158
|
+
this.handshakeTimer = window.setInterval(this.initialize, 250);
|
|
159
|
+
this.handshakeTimeout = window.setTimeout(() => {
|
|
160
|
+
this.settled = true;
|
|
161
|
+
this.stopHandshake();
|
|
162
|
+
this.closePort();
|
|
163
|
+
console.error("[AndcoButtonBridge] handshake timed out: %o", {
|
|
164
|
+
attempts: this.handshakeAttempts,
|
|
165
|
+
endpointButton: this.url.href,
|
|
166
|
+
});
|
|
167
|
+
const message = {
|
|
168
|
+
protocol: ANDCO_SDK_PROTOCOL,
|
|
169
|
+
version: ANDCO_PROTOCOL_VERSION,
|
|
170
|
+
type: "error",
|
|
171
|
+
// The wire protocol owns its own code vocabulary; SDK-raised codes are a separate set.
|
|
172
|
+
error: { code: ANDCO_PROTOCOL_ERROR_CODES.HANDSHAKE_TIMEOUT },
|
|
173
|
+
};
|
|
174
|
+
this.options.onError?.(message);
|
|
175
|
+
}, 10_000);
|
|
176
|
+
};
|
|
177
|
+
/** Sends a validated protocol message to the hosted iframe. */
|
|
178
|
+
post(message) {
|
|
179
|
+
if (this.destroyed || !this.settled)
|
|
180
|
+
return;
|
|
181
|
+
if (this.transport === "port" && this.port) {
|
|
182
|
+
this.port.postMessage(message);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.iframe.contentWindow?.postMessage(message, this.url.origin);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Resolves a deferred authorization request from the hosted button.
|
|
189
|
+
* Omit the URL to close the pending popup after resolution fails.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```ts
|
|
193
|
+
* bridge.resolveAuthorization(presentationId, "https://app.example.com/oauth/start");
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
resolveAuthorization(presentationId, authorizationUrl) {
|
|
197
|
+
const message = {
|
|
198
|
+
protocol: ANDCO_SDK_PROTOCOL,
|
|
199
|
+
version: ANDCO_PROTOCOL_VERSION,
|
|
200
|
+
type: "authorization-response",
|
|
201
|
+
presentationId,
|
|
202
|
+
authorizationUrl: authorizationUrl?.toString(),
|
|
203
|
+
};
|
|
204
|
+
this.post(message);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Forwards a validated same-origin host callback to the iframe that owns the popup.
|
|
208
|
+
*
|
|
209
|
+
* This method is intended for SDK adapters that relay a callback through an
|
|
210
|
+
* established button bridge; application code normally uses `onComplete` or
|
|
211
|
+
* `onDismiss` instead.
|
|
212
|
+
*/
|
|
213
|
+
forwardAuthorizationResult(message) {
|
|
214
|
+
this.post(message);
|
|
215
|
+
}
|
|
216
|
+
/** Updates locale or color scheme without recreating the iframe. */
|
|
217
|
+
setPreferences(preferences) {
|
|
218
|
+
const message = {
|
|
219
|
+
protocol: ANDCO_SDK_PROTOCOL,
|
|
220
|
+
version: ANDCO_PROTOCOL_VERSION,
|
|
221
|
+
type: "set-preferences",
|
|
222
|
+
...preferences,
|
|
223
|
+
};
|
|
224
|
+
this.post(message);
|
|
225
|
+
}
|
|
226
|
+
/** Stops the handshake and releases listeners and the message channel. */
|
|
227
|
+
destroy() {
|
|
228
|
+
if (this.destroyed)
|
|
229
|
+
return;
|
|
230
|
+
console.debug("[AndcoButtonBridge] destroyed: %o", { settled: this.settled, attempts: this.handshakeAttempts });
|
|
231
|
+
this.destroyed = true;
|
|
232
|
+
window.removeEventListener("message", this.receiveWindow);
|
|
233
|
+
this.iframe.removeEventListener("load", this.startHandshake);
|
|
234
|
+
this.stopHandshake();
|
|
235
|
+
this.closePort();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AndcoClient, type AndcoClientOptions } from "../client.js";
|
|
2
|
+
import type { AndcoAuthorizationRequest } from "../oauth.js";
|
|
3
|
+
import type { AndcoPresenter } from "../presenter.js";
|
|
4
|
+
export { AndcoButtonController, type AndcoButtonControllerOptions, type AndcoButtonFlow, type AndcoButtonSnapshot, type AndcoButtonStatus, } from "./controller.js";
|
|
5
|
+
export { AndcoButtonBridge, type AndcoFrameOptions } from "./frame.js";
|
|
6
|
+
export { type AndcoPopupMessage, type AndcoPopupWindow, type AndcoRelayOutcome, andcoPopupPresentationId, isAndcoNativeHost, openAndcoPopup, openAndcoPopupWindow, relayAndcoPopupCallback, } from "./popup.js";
|
|
7
|
+
/**
|
|
8
|
+
* Presents an authorization in a browser.
|
|
9
|
+
*
|
|
10
|
+
* It detects the first-party Andco Host and routes over the native bridge instead of opening a
|
|
11
|
+
* window, so a Miniapp runs the same build in a browser tab and inside the Host without branching
|
|
12
|
+
* on the platform. That is why there is no mobile presenter and no mobile builder.
|
|
13
|
+
*/
|
|
14
|
+
export declare function browserPresenter(): AndcoPresenter;
|
|
15
|
+
export type AndcoBrowserOptions = Omit<AndcoClientOptions, "presenter" | "source" | "clientSecret"> & {
|
|
16
|
+
/** Rejected by type: a confidential credential must never reach a browser. */
|
|
17
|
+
clientSecret?: never;
|
|
18
|
+
presenter?: AndcoPresenter;
|
|
19
|
+
/** Whether an authorization code in the URL is exchanged during the load. Defaults to `true`. */
|
|
20
|
+
detectSessionInUrl?: boolean;
|
|
21
|
+
/** Whether this document relays a callback to its opener when it is an Andco popup. Default `true`. */
|
|
22
|
+
relayPopupCallback?: boolean;
|
|
23
|
+
/** Replaces the URL after consumption. Defaults to the History API. */
|
|
24
|
+
replaceUrl?: (url: URL) => void;
|
|
25
|
+
/** Recovers the transaction started before the redirect. Supplied by the SDK's own storage. */
|
|
26
|
+
transactionStorage?: Storage;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Creates an Andco Instance for a browser.
|
|
30
|
+
*
|
|
31
|
+
* Construction is synchronous and performs no I/O. The one effect it does perform is the popup
|
|
32
|
+
* relay, and only when five conditions hold: this document has an opener, its `window.name` carries
|
|
33
|
+
* a valid Presentation ID, the URL carries callback parameters, the state parses, and the opener
|
|
34
|
+
* origin is allowed. A normal page fails the first two and the call is inert.
|
|
35
|
+
*
|
|
36
|
+
* Exchanging an authorization code is *not* done here. That is a credential write, the code is
|
|
37
|
+
* single-use, and a construction a framework discards must not burn it — so it happens lazily, on
|
|
38
|
+
* the store's first read.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* const andco = createAndcoInstanceForBrowser({
|
|
43
|
+
* clientId: "your-public-oauth-client-id",
|
|
44
|
+
* redirectTo: "https://app.example.com/auth/callback",
|
|
45
|
+
* initialScopes: ["openid", "email", "profile"],
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare function createAndcoInstanceForBrowser(options: AndcoBrowserOptions): AndcoClient;
|
|
50
|
+
/** Persists a transaction across a full-page redirect. Used by the redirect presentation. */
|
|
51
|
+
export declare function storeAndcoTransaction(clientId: string, request: AndcoAuthorizationRequest, storage?: Storage): void;
|
|
52
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/browser/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIpE,OAAO,KAAK,EAAE,yBAAyB,EAAc,MAAM,aAAa,CAAC;AACzE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAItD,OAAO,EACL,qBAAqB,EACrB,KAAK,4BAA4B,EACjC,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,iBAAiB,EAAE,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,wBAAgB,gBAAgB,IAAI,cAAc,CAyBjD;AAED,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,kBAAkB,EAAE,WAAW,GAAG,QAAQ,GAAG,cAAc,CAAC,GAAG;IACpG,8EAA8E;IAC9E,YAAY,CAAC,EAAE,KAAK,CAAC;IACrB,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,iGAAiG;IACjG,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,uGAAuG;IACvG,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,uEAAuE;IACvE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC;IAChC,+FAA+F;IAC/F,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAIF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,mBAAmB,GAAG,WAAW,CAmBvF;AA0CD,6FAA6F;AAC7F,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,yBAAyB,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAMnH"}
|