@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.
Files changed (63) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +176 -0
  3. package/dist/auth.d.ts +71 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +98 -0
  6. package/dist/browser/controller.d.ts +76 -0
  7. package/dist/browser/controller.d.ts.map +1 -0
  8. package/dist/browser/controller.js +215 -0
  9. package/dist/browser/frame.d.ts +65 -0
  10. package/dist/browser/frame.d.ts.map +1 -0
  11. package/dist/browser/frame.js +237 -0
  12. package/dist/browser/index.d.ts +52 -0
  13. package/dist/browser/index.d.ts.map +1 -0
  14. package/dist/browser/index.js +117 -0
  15. package/dist/browser/popup.d.ts +105 -0
  16. package/dist/browser/popup.d.ts.map +1 -0
  17. package/dist/browser/popup.js +177 -0
  18. package/dist/cli/index.d.ts +47 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +72 -0
  21. package/dist/cli/server.d.ts +11 -0
  22. package/dist/cli/server.d.ts.map +1 -0
  23. package/dist/cli/server.js +91 -0
  24. package/dist/client.d.ts +135 -0
  25. package/dist/client.d.ts.map +1 -0
  26. package/dist/client.js +210 -0
  27. package/dist/config.d.ts +82 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +109 -0
  30. package/dist/credentials.d.ts +76 -0
  31. package/dist/credentials.d.ts.map +1 -0
  32. package/dist/credentials.js +0 -0
  33. package/dist/errors.d.ts +165 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/errors.js +197 -0
  36. package/dist/index.d.ts +13 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +11 -0
  39. package/dist/intents.d.ts +89 -0
  40. package/dist/intents.d.ts.map +1 -0
  41. package/dist/intents.js +157 -0
  42. package/dist/oauth.d.ts +147 -0
  43. package/dist/oauth.d.ts.map +1 -0
  44. package/dist/oauth.js +348 -0
  45. package/dist/presenter.d.ts +36 -0
  46. package/dist/presenter.d.ts.map +1 -0
  47. package/dist/presenter.js +1 -0
  48. package/dist/rest.d.ts +58 -0
  49. package/dist/rest.d.ts.map +1 -0
  50. package/dist/rest.js +48 -0
  51. package/dist/server/index.d.ts +21 -0
  52. package/dist/server/index.d.ts.map +1 -0
  53. package/dist/server/index.js +27 -0
  54. package/dist/server-metadata.generated.d.ts +4 -0
  55. package/dist/server-metadata.generated.d.ts.map +1 -0
  56. package/dist/server-metadata.generated.js +49 -0
  57. package/dist/session-store.d.ts +83 -0
  58. package/dist/session-store.d.ts.map +1 -0
  59. package/dist/session-store.js +186 -0
  60. package/dist/storage.d.ts +61 -0
  61. package/dist/storage.d.ts.map +1 -0
  62. package/dist/storage.js +47 -0
  63. package/package.json +56 -0
@@ -0,0 +1,117 @@
1
+ import { AndcoClient } from "../client.js";
2
+ import { ANDCO_ERROR_CODES, Result } from "../errors.js";
3
+ import { WebStorage } from "../storage.js";
4
+ import { andcoPopupPresentationId, isAndcoNativeHost, openAndcoPopup, relayAndcoPopupCallback } from "./popup.js";
5
+ export { AndcoButtonController, } from "./controller.js";
6
+ export { AndcoButtonBridge } from "./frame.js";
7
+ export { andcoPopupPresentationId, isAndcoNativeHost, openAndcoPopup, openAndcoPopupWindow, relayAndcoPopupCallback, } from "./popup.js";
8
+ /**
9
+ * Presents an authorization in a browser.
10
+ *
11
+ * It detects the first-party Andco Host and routes over the native bridge instead of opening a
12
+ * window, so a Miniapp runs the same build in a browser tab and inside the Host without branching
13
+ * on the platform. That is why there is no mobile presenter and no mobile builder.
14
+ */
15
+ export function browserPresenter() {
16
+ return {
17
+ async present(options) {
18
+ if (typeof window === "undefined")
19
+ return Result.fail(ANDCO_ERROR_CODES.BROWSER_REQUIRED);
20
+ if (options.presentation === "redirect") {
21
+ window.location.assign(options.url.href);
22
+ // The document is being replaced. The result arrives on the next load, not here.
23
+ return Result.ok(null);
24
+ }
25
+ if (isAndcoNativeHost() && window.__ANDCO_NATIVE_POPUP_HANDLER__ !== true) {
26
+ return Result.fail(ANDCO_ERROR_CODES.PRESENTATION_UNSUPPORTED, {
27
+ message: "the native Host cannot present a popup without navigating the Miniapp",
28
+ });
29
+ }
30
+ return openAndcoPopup({
31
+ url: options.url,
32
+ presentationId: options.presentationId,
33
+ expectedOrigin: options.returnTo.origin,
34
+ signal: options.signal,
35
+ });
36
+ },
37
+ };
38
+ }
39
+ const TRANSACTION_KEY = "andco.transaction";
40
+ /**
41
+ * Creates an Andco Instance for a browser.
42
+ *
43
+ * Construction is synchronous and performs no I/O. The one effect it does perform is the popup
44
+ * relay, and only when five conditions hold: this document has an opener, its `window.name` carries
45
+ * a valid Presentation ID, the URL carries callback parameters, the state parses, and the opener
46
+ * origin is allowed. A normal page fails the first two and the call is inert.
47
+ *
48
+ * Exchanging an authorization code is *not* done here. That is a credential write, the code is
49
+ * single-use, and a construction a framework discards must not burn it — so it happens lazily, on
50
+ * the store's first read.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const andco = createAndcoInstanceForBrowser({
55
+ * clientId: "your-public-oauth-client-id",
56
+ * redirectTo: "https://app.example.com/auth/callback",
57
+ * initialScopes: ["openid", "email", "profile"],
58
+ * });
59
+ * ```
60
+ */
61
+ export function createAndcoInstanceForBrowser(options) {
62
+ const presenter = options.presenter ?? browserPresenter();
63
+ const storage = options.storage ?? (typeof window === "undefined" ? undefined : new WebStorage(window.sessionStorage));
64
+ // Annotated because `source` closes over `client`; the closure only runs on the first read.
65
+ const client = new AndcoClient({
66
+ ...options,
67
+ presenter,
68
+ storage,
69
+ source: () => consumeAuthorizationCode(client.config, client.oauth, options),
70
+ persistTransaction: (request) => storeAndcoTransaction(client.config.clientId, request, options.transactionStorage),
71
+ });
72
+ if (options.relayPopupCallback !== false && typeof window !== "undefined") {
73
+ relayAndcoPopupCallback(client.config.allowedPopupOrigins);
74
+ }
75
+ return client;
76
+ }
77
+ /**
78
+ * Exchanges an authorization code left in the URL by a redirect presentation.
79
+ *
80
+ * Skipped when this document is an Andco popup: there the code belongs to the relay, which delivers
81
+ * it to the opener that holds the PKCE verifier.
82
+ */
83
+ async function consumeAuthorizationCode(config, oauth, options) {
84
+ if (options.detectSessionInUrl === false)
85
+ return Result.ok(null);
86
+ if (typeof window === "undefined")
87
+ return Result.ok(null);
88
+ if (andcoPopupPresentationId())
89
+ return Result.ok(null);
90
+ const url = new URL(window.location.href);
91
+ if (!url.searchParams.has("code") && !url.searchParams.has("error"))
92
+ return Result.ok(null);
93
+ const storage = options.transactionStorage ?? window.sessionStorage;
94
+ const stored = storage.getItem(`${TRANSACTION_KEY}.${config.clientId}`);
95
+ if (!stored)
96
+ return Result.ok(null);
97
+ storage.removeItem(`${TRANSACTION_KEY}.${config.clientId}`);
98
+ let request;
99
+ try {
100
+ const parsed = JSON.parse(stored);
101
+ request = { ...parsed, authorizationUrl: new URL(parsed.authorizationUrl) };
102
+ }
103
+ catch {
104
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK);
105
+ }
106
+ const exchanged = await oauth.exchangeCallback({ callbackUrl: url, request });
107
+ for (const parameter of ["code", "state", "error", "error_description"])
108
+ url.searchParams.delete(parameter);
109
+ const replace = options.replaceUrl ?? ((next) => window.history.replaceState(window.history.state, "", next));
110
+ replace(url);
111
+ return exchanged.error ? Result.fail(exchanged.error) : Result.ok(exchanged.data);
112
+ }
113
+ /** Persists a transaction across a full-page redirect. Used by the redirect presentation. */
114
+ export function storeAndcoTransaction(clientId, request, storage) {
115
+ const target = storage ?? window.sessionStorage;
116
+ target.setItem(`${TRANSACTION_KEY}.${clientId}`, JSON.stringify({ ...request, authorizationUrl: request.authorizationUrl.href }));
117
+ }
@@ -0,0 +1,105 @@
1
+ import { AndcoError, Result } from "../errors.js";
2
+ /** The message a popup relays to its opener. Carries a lifecycle outcome, never financial data. */
3
+ export type AndcoPopupMessage = {
4
+ protocol: "andco";
5
+ version: 1;
6
+ presentationId: string;
7
+ callbackUrl: string;
8
+ };
9
+ /** What consuming the current document as a popup callback did. */
10
+ export type AndcoRelayOutcome = {
11
+ status: "not_callback";
12
+ } | {
13
+ status: "not_popup";
14
+ } | {
15
+ status: "relayed";
16
+ presentationId: string;
17
+ } | {
18
+ status: "rejected";
19
+ error: AndcoError;
20
+ };
21
+ declare global {
22
+ interface Window {
23
+ /** Capability marker injected by the first-party Andco mobile Host. */
24
+ __ANDCO_NATIVE_HOST__?: boolean;
25
+ /** Capability marker for Host-presented popups that preserve the mounted document. */
26
+ __ANDCO_NATIVE_POPUP_HANDLER__?: boolean;
27
+ ReactNativeWebView?: {
28
+ postMessage(message: string): void;
29
+ };
30
+ }
31
+ }
32
+ /** Whether the document is running inside the first-party Andco Host. */
33
+ export declare function isAndcoNativeHost(): boolean;
34
+ /**
35
+ * Whether this document is a popup Andco opened.
36
+ *
37
+ * One predicate, two consumers. The relay uses it to decide the callback is its to deliver, and the
38
+ * session store uses it to decide the authorization code is *not* its to exchange. Without a single
39
+ * predicate the two would race, and an authorization code is single-use: whichever ran first would
40
+ * leave the other broken.
41
+ */
42
+ export declare function andcoPopupPresentationId(): string | null;
43
+ /**
44
+ * Hands this document's callback to the window that opened it, then closes.
45
+ *
46
+ * The popup cannot complete the exchange itself: the PKCE verifier was generated by the opener and
47
+ * never left it. So this is a courier, not a consumer — it carries the callback URL back and dies.
48
+ *
49
+ * Synchronous throughout. There is no network call and nothing to await, which is why running it at
50
+ * construction is safe even inside a render a framework may discard: closing a document is terminal,
51
+ * so there is no retained construction left for a discarded one to make inconsistent.
52
+ */
53
+ export declare function relayAndcoPopupCallback(allowedOrigins: ReadonlySet<string>, options?: {
54
+ /**
55
+ * The URL to relay, when the application already knows this document is a callback.
56
+ *
57
+ * A host-managed flow runs the authorization on the Project's own server and lands on a page
58
+ * whose result parameters the Project chose. There is no authorization code to recognise, so
59
+ * the caller asserts what to deliver instead of the relay guessing.
60
+ */
61
+ callbackUrl?: string | URL;
62
+ }): AndcoRelayOutcome;
63
+ /** A popup opened during user activation, navigated once its destination is known. */
64
+ export type AndcoPopupWindow = {
65
+ /**
66
+ * Whether the browser refused to open the window.
67
+ *
68
+ * Exposed so a caller can abandon before doing server work. A blocked popup that still triggered
69
+ * an authorization would leave a transaction nobody can complete.
70
+ */
71
+ readonly blocked: boolean;
72
+ /** Sends the popup to its destination. Safe to call once. */
73
+ navigate(url: URL): void;
74
+ /** Settles with the relayed callback, `null` on dismissal, or an error. */
75
+ readonly result: Promise<Result<URL | null>>;
76
+ close(): void;
77
+ };
78
+ /**
79
+ * Opens a popup now and navigates it later.
80
+ *
81
+ * A browser only grants `window.open` during a user activation, so a flow whose destination needs a
82
+ * server round trip has to open first and navigate second. That is the host-managed shape: the
83
+ * Project's own backend builds the authorization, and the popup is already waiting when it answers.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const popup = openAndcoPopupWindow({ presentationId, expectedOrigin: location.origin });
88
+ * popup.navigate(await authorizationUrlFromMyServer());
89
+ * const { data } = await popup.result;
90
+ * ```
91
+ */
92
+ export declare function openAndcoPopupWindow(options: {
93
+ presentationId: string;
94
+ expectedOrigin: string;
95
+ url?: URL;
96
+ signal?: AbortSignal;
97
+ }): AndcoPopupWindow;
98
+ /** Opens a popup straight to its destination and waits for the relayed callback. */
99
+ export declare function openAndcoPopup(options: {
100
+ url: URL;
101
+ presentationId: string;
102
+ expectedOrigin: string;
103
+ signal?: AbortSignal;
104
+ }): Promise<Result<URL | null>>;
105
+ //# sourceMappingURL=popup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popup.d.ts","sourceRoot":"","sources":["../../src/browser/popup.ts"],"names":[],"mappings":"AAOA,OAAO,EAAqB,UAAU,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAMrE,mGAAmG;AACnG,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,CAAC,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GACzB;IAAE,MAAM,EAAE,cAAc,CAAA;CAAE,GAC1B;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAC7C;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAC;AAE9C,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,uEAAuE;QACvE,qBAAqB,CAAC,EAAE,OAAO,CAAC;QAChC,sFAAsF;QACtF,8BAA8B,CAAC,EAAE,OAAO,CAAC;QACzC,kBAAkB,CAAC,EAAE;YAAE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,CAAC;KAC7D;CACF;AAED,yEAAyE;AACzE,wBAAgB,iBAAiB,IAAI,OAAO,CAM3C;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,GAAG,IAAI,CAMxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,EACnC,OAAO,GAAE;IACP;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;CACvB,GACL,iBAAiB,CAkCnB;AAsBD,sFAAsF;AACtF,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,IAAI,CAAC;IACzB,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC;IAC7C,KAAK,IAAI,IAAI,CAAC;CACf,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAC5C,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,gBAAgB,CA+DnB;AAED,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,OAAO,EAAE;IACtC,GAAG,EAAE,GAAG,CAAC;IACT,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAE9B"}
@@ -0,0 +1,177 @@
1
+ import { ANDCO_POPUP_NAME_PREFIX, ANDCO_PROTOCOL_VERSION, ANDCO_SDK_PROTOCOL, CALLBACK_STATE_FROM, } from "@andco/protocol";
2
+ import { ANDCO_ERROR_CODES, AndcoError, Result } from "../errors.js";
3
+ const PRESENTATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
4
+ const POPUP_TIMEOUT_MS = 10 * 60 * 1_000;
5
+ const CLOSE_POLL_MS = 400;
6
+ /** Whether the document is running inside the first-party Andco Host. */
7
+ export function isAndcoNativeHost() {
8
+ return (typeof window !== "undefined" &&
9
+ window.__ANDCO_NATIVE_HOST__ === true &&
10
+ typeof window.ReactNativeWebView?.postMessage === "function");
11
+ }
12
+ /**
13
+ * Whether this document is a popup Andco opened.
14
+ *
15
+ * One predicate, two consumers. The relay uses it to decide the callback is its to deliver, and the
16
+ * session store uses it to decide the authorization code is *not* its to exchange. Without a single
17
+ * predicate the two would race, and an authorization code is single-use: whichever ran first would
18
+ * leave the other broken.
19
+ */
20
+ export function andcoPopupPresentationId() {
21
+ if (typeof window === "undefined")
22
+ return null;
23
+ if (!window.opener || window.opener === window)
24
+ return null;
25
+ if (!window.name.startsWith(ANDCO_POPUP_NAME_PREFIX))
26
+ return null;
27
+ const presentationId = window.name.slice(ANDCO_POPUP_NAME_PREFIX.length);
28
+ return PRESENTATION_ID_PATTERN.test(presentationId) ? presentationId : null;
29
+ }
30
+ /**
31
+ * Hands this document's callback to the window that opened it, then closes.
32
+ *
33
+ * The popup cannot complete the exchange itself: the PKCE verifier was generated by the opener and
34
+ * never left it. So this is a courier, not a consumer — it carries the callback URL back and dies.
35
+ *
36
+ * Synchronous throughout. There is no network call and nothing to await, which is why running it at
37
+ * construction is safe even inside a render a framework may discard: closing a document is terminal,
38
+ * so there is no retained construction left for a discarded one to make inconsistent.
39
+ */
40
+ export function relayAndcoPopupCallback(allowedOrigins, options = {}) {
41
+ if (typeof window === "undefined")
42
+ return { status: "not_callback" };
43
+ const url = options.callbackUrl ? new URL(options.callbackUrl, window.location.href) : new URL(window.location.href);
44
+ const isCallback = Boolean(options.callbackUrl) || url.searchParams.has("code") || url.searchParams.has("error");
45
+ if (!isCallback)
46
+ return { status: "not_callback" };
47
+ const presentationId = andcoPopupPresentationId();
48
+ if (!presentationId)
49
+ return { status: "not_popup" };
50
+ // Where to deliver comes from the state the request was created with, never from the opener's
51
+ // own `location`: reading that on a cross-origin opener throws, and a hosted popup is always
52
+ // opened by the Andco surface rather than by the page that will receive the result.
53
+ const encoded = url.searchParams.get("state");
54
+ const openerOrigin = (encoded ? CALLBACK_STATE_FROM(encoded)?.openerOrigin : null) ?? window.location.origin;
55
+ if (openerOrigin !== window.location.origin && !allowedOrigins.has(openerOrigin)) {
56
+ return { status: "rejected", error: new AndcoError(ANDCO_ERROR_CODES.UNTRUSTED_ORIGIN) };
57
+ }
58
+ // Who opened this window decides what it is told. The Andco surface speaks the Protocol
59
+ // Contract and gets a protocol result; an opener on this document's own origin is the SDK's own
60
+ // popup, which holds the transaction and wants the whole callback URL. Sending the SDK's shape
61
+ // to the hosted surface is what made a completed authorization look like a dismissal.
62
+ const message = openerOrigin === window.location.origin ? sdkMessage(presentationId, url) : protocolResult(presentationId, url);
63
+ // Strip before leaving. If closing is refused, the code must not remain visible in the address
64
+ // bar or in the window name.
65
+ for (const parameter of ["code", "state", "error", "error_description"])
66
+ url.searchParams.delete(parameter);
67
+ window.history.replaceState(window.history.state, "", url);
68
+ window.name = "";
69
+ window.opener?.postMessage(message, openerOrigin);
70
+ window.close();
71
+ return { status: "relayed", presentationId };
72
+ }
73
+ function sdkMessage(presentationId, url) {
74
+ return { protocol: "andco", version: 1, presentationId, callbackUrl: url.href };
75
+ }
76
+ function protocolResult(presentationId, url) {
77
+ const envelope = { protocol: ANDCO_SDK_PROTOCOL, version: ANDCO_PROTOCOL_VERSION, presentationId };
78
+ const code = url.searchParams.get("code");
79
+ if (code)
80
+ return { ...envelope, type: "oauth.authorization_code", authorizationCode: code };
81
+ const error = url.searchParams.get("error") ?? "invalid_request";
82
+ // `access_denied` is the only OAuth error that carries a decision, and it is the one the
83
+ // Authorization Server returns when the user declines.
84
+ if (error === "access_denied")
85
+ return { ...envelope, type: "oauth.dismiss" };
86
+ const description = url.searchParams.get("error_description");
87
+ return {
88
+ ...envelope,
89
+ type: "oauth.error",
90
+ error: { code: error.slice(0, 256), description: description ? description.slice(0, 2_048) : undefined },
91
+ };
92
+ }
93
+ /**
94
+ * Opens a popup now and navigates it later.
95
+ *
96
+ * A browser only grants `window.open` during a user activation, so a flow whose destination needs a
97
+ * server round trip has to open first and navigate second. That is the host-managed shape: the
98
+ * Project's own backend builds the authorization, and the popup is already waiting when it answers.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const popup = openAndcoPopupWindow({ presentationId, expectedOrigin: location.origin });
103
+ * popup.navigate(await authorizationUrlFromMyServer());
104
+ * const { data } = await popup.result;
105
+ * ```
106
+ */
107
+ export function openAndcoPopupWindow(options) {
108
+ const popup = window.open(options.url?.href ?? "about:blank", `${ANDCO_POPUP_NAME_PREFIX}${options.presentationId}`, windowFeatures({ width: 620, height: 720 }));
109
+ if (!popup) {
110
+ return {
111
+ blocked: true,
112
+ navigate: () => { },
113
+ result: Promise.resolve(Result.fail(ANDCO_ERROR_CODES.POPUP_BLOCKED)),
114
+ close: () => { },
115
+ };
116
+ }
117
+ const result = new Promise((resolve) => {
118
+ let settled = false;
119
+ const settle = (result) => {
120
+ if (settled)
121
+ return;
122
+ settled = true;
123
+ window.removeEventListener("message", receive);
124
+ window.clearInterval(closeMonitor);
125
+ window.clearTimeout(timeout);
126
+ resolve(result);
127
+ };
128
+ const receive = (event) => {
129
+ // Three checks: exact origin, the window this presentation opened, and the correlation id.
130
+ if (event.origin !== options.expectedOrigin || event.source !== popup)
131
+ return;
132
+ const message = event.data;
133
+ if (!message || message.protocol !== "andco" || message.presentationId !== options.presentationId)
134
+ return;
135
+ if (typeof message.callbackUrl !== "string")
136
+ return;
137
+ // The relay closes itself, but a browser may refuse; closing from the opener guarantees the
138
+ // window does not outlive the flow it belonged to.
139
+ popup.close();
140
+ try {
141
+ settle(Result.ok(new URL(message.callbackUrl)));
142
+ }
143
+ catch {
144
+ settle(Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK));
145
+ }
146
+ };
147
+ window.addEventListener("message", receive);
148
+ // A closed window with no message is a dismissal, never a failure.
149
+ const closeMonitor = window.setInterval(() => {
150
+ if (popup.closed)
151
+ settle(Result.ok(null));
152
+ }, CLOSE_POLL_MS);
153
+ const timeout = window.setTimeout(() => {
154
+ popup.close();
155
+ settle(Result.fail(ANDCO_ERROR_CODES.POPUP_TIMEOUT));
156
+ }, POPUP_TIMEOUT_MS);
157
+ options.signal?.addEventListener("abort", () => {
158
+ popup.close();
159
+ settle(Result.ok(null));
160
+ });
161
+ });
162
+ return {
163
+ blocked: false,
164
+ navigate: (url) => popup.location.assign(url.href),
165
+ result,
166
+ close: () => popup.close(),
167
+ };
168
+ }
169
+ /** Opens a popup straight to its destination and waits for the relayed callback. */
170
+ export function openAndcoPopup(options) {
171
+ return openAndcoPopupWindow(options).result;
172
+ }
173
+ function windowFeatures(size) {
174
+ const left = Math.max(0, Math.round((window.screen.width - size.width) / 2));
175
+ const top = Math.max(0, Math.round((window.screen.height - size.height) / 2));
176
+ return `popup=1,width=${size.width},height=${size.height},left=${left},top=${top}`;
177
+ }
@@ -0,0 +1,47 @@
1
+ import { AndcoClient, type AndcoClientOptions } from "../client.js";
2
+ import { Result } from "../errors.js";
3
+ import type { AndcoPresenter, AndcoPresentOptions } from "../presenter.js";
4
+ export type AndcoCliPresenterOptions = {
5
+ /** Opens the authorization URL. Defaults to printing it, so nothing is assumed about the host. */
6
+ open?: (url: URL) => void | Promise<void>;
7
+ /** How long to wait for the user to finish. Defaults to ten minutes. */
8
+ timeoutMs?: number;
9
+ /** Receives the URL when no browser can be opened, so the user can paste it. */
10
+ print?: (message: string) => void;
11
+ };
12
+ /**
13
+ * Presents an authorization from a terminal, using a one-shot loopback receiver.
14
+ *
15
+ * Every command-line integrator needs this exact thing, and until now every one wrote it: bind a
16
+ * loopback port, refuse anything that is not the registered path, check the state, answer once, and
17
+ * shut down. The Andco CLI's own version is ninety lines. Getting any of it wrong — accepting a
18
+ * callback on the wrong path, skipping the state check, leaving the port open — is a security bug
19
+ * in someone's developer tooling, which is exactly the kind of thing an SDK should own.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const andco = createAndcoInstanceForCLI({
24
+ * clientId,
25
+ * storage: keychain,
26
+ * redirectTo: "http://127.0.0.1:0/callback",
27
+ * });
28
+ * const { data: session } = await andco.auth.signIn();
29
+ * ```
30
+ */
31
+ export declare class AndcoCliPresenter implements AndcoPresenter {
32
+ private readonly options;
33
+ constructor(options?: AndcoCliPresenterOptions);
34
+ present(presentation: AndcoPresentOptions): Promise<Result<URL | null>>;
35
+ }
36
+ export type AndcoCliOptions = Omit<AndcoClientOptions, "presenter"> & {
37
+ presenter?: AndcoPresenter;
38
+ };
39
+ /**
40
+ * Creates an Andco Instance for a terminal.
41
+ *
42
+ * Storage is required and may be asynchronous, which is the whole point: a keychain cannot answer
43
+ * synchronously, and the previous contract left no legal way to supply one. Restoring a saved
44
+ * credential is `andco.session.set(...)`, not a write into a private storage key.
45
+ */
46
+ export declare function createAndcoInstanceForCLI(options: AndcoCliOptions): AndcoClient;
47
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEpE,OAAO,EAAqB,MAAM,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAG3E,MAAM,MAAM,wBAAwB,GAAG;IACrC,kGAAkG;IAClG,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,iBAAkB,YAAW,cAAc;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,GAAE,wBAA6B;IAE7D,OAAO,CAAC,YAAY,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;CAiC9E;AAED,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,kBAAkB,EAAE,WAAW,CAAC,GAAG;IACpE,SAAS,CAAC,EAAE,cAAc,CAAC;CAC5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,eAAe,GAAG,WAAW,CAE/E"}
@@ -0,0 +1,72 @@
1
+ import { AndcoClient } from "../client.js";
2
+ import { isLoopback } from "../config.js";
3
+ import { ANDCO_ERROR_CODES, Result } from "../errors.js";
4
+ import { LoopbackServer } from "./server.js";
5
+ /**
6
+ * Presents an authorization from a terminal, using a one-shot loopback receiver.
7
+ *
8
+ * Every command-line integrator needs this exact thing, and until now every one wrote it: bind a
9
+ * loopback port, refuse anything that is not the registered path, check the state, answer once, and
10
+ * shut down. The Andco CLI's own version is ninety lines. Getting any of it wrong — accepting a
11
+ * callback on the wrong path, skipping the state check, leaving the port open — is a security bug
12
+ * in someone's developer tooling, which is exactly the kind of thing an SDK should own.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const andco = createAndcoInstanceForCLI({
17
+ * clientId,
18
+ * storage: keychain,
19
+ * redirectTo: "http://127.0.0.1:0/callback",
20
+ * });
21
+ * const { data: session } = await andco.auth.signIn();
22
+ * ```
23
+ */
24
+ export class AndcoCliPresenter {
25
+ options;
26
+ constructor(options = {}) {
27
+ this.options = options;
28
+ }
29
+ async present(presentation) {
30
+ const configured = presentation.returnTo;
31
+ if (configured.protocol !== "http:" || !isLoopback(configured)) {
32
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
33
+ message: "a terminal callback must be http on a loopback host",
34
+ });
35
+ }
36
+ let receiver;
37
+ try {
38
+ receiver = await LoopbackServer.start(configured);
39
+ }
40
+ catch (cause) {
41
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
42
+ message: "could not bind the loopback port",
43
+ cause,
44
+ });
45
+ }
46
+ try {
47
+ // The bound port may differ from the configured one when it asked for any free port, and the
48
+ // authorization request must carry the exact URI the server will redirect to.
49
+ const url = new URL(presentation.url.href);
50
+ url.searchParams.set("redirect_uri", receiver.redirectUri.href);
51
+ if (this.options.open)
52
+ await this.options.open(url);
53
+ else
54
+ this.options.print?.(`Abre esta URL para continuar:\n\n${url.href}\n`);
55
+ const callback = await receiver.wait(this.options.timeoutMs ?? 10 * 60 * 1_000);
56
+ return callback ? Result.ok(callback) : Result.fail(ANDCO_ERROR_CODES.POPUP_TIMEOUT);
57
+ }
58
+ finally {
59
+ await receiver.close();
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * Creates an Andco Instance for a terminal.
65
+ *
66
+ * Storage is required and may be asynchronous, which is the whole point: a keychain cannot answer
67
+ * synchronously, and the previous contract left no legal way to supply one. Restoring a saved
68
+ * credential is `andco.session.set(...)`, not a write into a private storage key.
69
+ */
70
+ export function createAndcoInstanceForCLI(options) {
71
+ return new AndcoClient({ ...options, presenter: options.presenter ?? new AndcoCliPresenter() });
72
+ }
@@ -0,0 +1,11 @@
1
+ export declare class LoopbackServer {
2
+ #private;
3
+ private constructor();
4
+ static start(configured: URL): Promise<LoopbackServer>;
5
+ get redirectUri(): URL;
6
+ wait(timeoutMs: number): Promise<URL | null>;
7
+ close(): Promise<void>;
8
+ private listen;
9
+ private static hostFor;
10
+ }
11
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/cli/server.ts"],"names":[],"mappings":"AAEA,qBAAa,cAAc;;IAKzB,OAAO;WAKM,KAAK,CAAC,UAAU,EAAE,GAAG,GAAG,OAAO,CAAC,cAAc,CAAC;IAc5D,IAAI,WAAW,IAAI,GAAG,CAErB;IAED,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC;IAS5C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAiDtB,OAAO,CAAC,MAAM;IAcd,OAAO,CAAC,MAAM,CAAC,OAAO;CAGvB"}
@@ -0,0 +1,91 @@
1
+ import { createServer } from "node:http";
2
+ export class LoopbackServer {
3
+ #server;
4
+ #resolveCallback;
5
+ #redirectUri;
6
+ constructor(configured) {
7
+ this.#redirectUri = configured;
8
+ this.#server = this.#buildServer();
9
+ }
10
+ static async start(configured) {
11
+ const receiver = new LoopbackServer(configured);
12
+ await receiver.listen();
13
+ const address = receiver.#server.address();
14
+ if (!address || typeof address === "string") {
15
+ await receiver.close();
16
+ throw new Error("the loopback receiver reported no address");
17
+ }
18
+ receiver.#redirectUri = new URL(configured.href);
19
+ receiver.#redirectUri.port = String(address.port);
20
+ return receiver;
21
+ }
22
+ get redirectUri() {
23
+ return this.#redirectUri;
24
+ }
25
+ wait(timeoutMs) {
26
+ return new Promise((resolve) => {
27
+ this.#resolveCallback = resolve;
28
+ // Node returns a Timeout with `unref`; the DOM lib types this as a number.
29
+ const timer = setTimeout(() => resolve(null), timeoutMs);
30
+ timer.unref?.();
31
+ });
32
+ }
33
+ close() {
34
+ return new Promise((resolve) => {
35
+ this.#server.closeAllConnections?.();
36
+ this.#server.close(() => resolve());
37
+ });
38
+ }
39
+ #buildServer() {
40
+ const server = createServer((request, response) => {
41
+ // A local browser must not be able to script this origin or keep it around.
42
+ response.setHeader("Cache-Control", "no-store");
43
+ response.setHeader("Connection", "close");
44
+ response.setHeader("Content-Security-Policy", "default-src 'none'; base-uri 'none'; frame-ancestors 'none'");
45
+ response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
46
+ response.setHeader("Referrer-Policy", "no-referrer");
47
+ response.setHeader("X-Content-Type-Options", "nosniff");
48
+ if (request.method !== "GET" || !request.url) {
49
+ response.writeHead(405).end("Method Not Allowed");
50
+ return;
51
+ }
52
+ const url = new URL(request.url, this.#redirectUri.origin);
53
+ if (url.pathname !== this.#redirectUri.pathname) {
54
+ response.writeHead(404).end("Not Found");
55
+ return;
56
+ }
57
+ if (!url.searchParams.has("code") && !url.searchParams.has("error")) {
58
+ response.writeHead(400).end("Missing OAuth response");
59
+ return;
60
+ }
61
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(`
62
+ <!doctype html>
63
+ <html lang="es">
64
+ <head><meta charset="utf-8"><title>Andco</title></head>
65
+ <body>
66
+ <main>
67
+ <h1>Autorización recibida</h1>
68
+ <p>Puedes cerrar esta ventana y volver a la terminal.</p>
69
+ </main>
70
+ </body>
71
+ </html>
72
+ `);
73
+ this.#resolveCallback?.(url);
74
+ });
75
+ server.requestTimeout = 10_000;
76
+ server.headersTimeout = 10_000;
77
+ return server;
78
+ }
79
+ listen() {
80
+ return new Promise((resolve, reject) => {
81
+ this.#server.once("error", reject);
82
+ this.#server.listen(this.#redirectUri.port ? Number(this.#redirectUri.port) : 0, LoopbackServer.hostFor(this.#redirectUri.hostname), () => {
83
+ this.#server.removeListener("error", reject);
84
+ resolve();
85
+ });
86
+ });
87
+ }
88
+ static hostFor(hostname) {
89
+ return hostname === "localhost" ? "127.0.0.1" : hostname.replace(/^\[|\]$/g, "");
90
+ }
91
+ }