@fntio/infra-client-plugin-sdk 0.0.0-stage → 0.2.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.
Files changed (58) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +279 -2
  3. package/dist/backend/context.d.ts +31 -0
  4. package/dist/backend/context.js +58 -0
  5. package/dist/backend/index.d.ts +9 -0
  6. package/dist/backend/index.js +26 -0
  7. package/dist/browser/client.d.ts +59 -0
  8. package/dist/browser/client.js +288 -0
  9. package/dist/browser/errors.d.ts +14 -0
  10. package/dist/browser/errors.js +32 -0
  11. package/dist/browser/http.d.ts +72 -0
  12. package/dist/browser/http.js +170 -0
  13. package/dist/browser/index.d.ts +11 -0
  14. package/dist/browser/index.js +28 -0
  15. package/dist/browser/theme-apply.d.ts +22 -0
  16. package/dist/browser/theme-apply.js +22 -0
  17. package/dist/contracts/access.d.ts +14 -0
  18. package/dist/contracts/access.js +41 -0
  19. package/dist/contracts/authorizer.d.ts +17 -0
  20. package/dist/contracts/authorizer.js +44 -0
  21. package/dist/contracts/canonical.d.ts +2 -0
  22. package/dist/contracts/canonical.js +21 -0
  23. package/dist/contracts/catalog.d.ts +37 -0
  24. package/dist/contracts/catalog.js +18 -0
  25. package/dist/contracts/discovery.d.ts +78 -0
  26. package/dist/contracts/discovery.js +48 -0
  27. package/dist/contracts/fields.d.ts +7 -0
  28. package/dist/contracts/fields.js +2 -0
  29. package/dist/contracts/manifest.d.ts +59 -0
  30. package/dist/contracts/manifest.js +19 -0
  31. package/dist/contracts/messages.d.ts +108 -0
  32. package/dist/contracts/messages.js +103 -0
  33. package/dist/contracts/paths.d.ts +20 -0
  34. package/dist/contracts/paths.js +49 -0
  35. package/dist/contracts/theme.d.ts +21 -0
  36. package/dist/contracts/theme.js +60 -0
  37. package/dist/contracts/transport.d.ts +14 -0
  38. package/dist/contracts/transport.js +10 -0
  39. package/dist/contracts/validate.d.ts +27 -0
  40. package/dist/contracts/validate.js +321 -0
  41. package/dist/fixtures/devHost.d.ts +57 -0
  42. package/dist/fixtures/devHost.js +91 -0
  43. package/dist/fixtures/fakeDocument.d.ts +12 -0
  44. package/dist/fixtures/fakeDocument.js +22 -0
  45. package/dist/fixtures/fakeWindows.d.ts +22 -0
  46. package/dist/fixtures/fakeWindows.js +46 -0
  47. package/dist/fixtures/index.d.ts +4 -0
  48. package/dist/fixtures/index.js +20 -0
  49. package/dist/fixtures/samples.d.ts +37 -0
  50. package/dist/fixtures/samples.js +220 -0
  51. package/dist/host/host.d.ts +47 -0
  52. package/dist/host/host.js +117 -0
  53. package/dist/host/index.d.ts +5 -0
  54. package/dist/host/index.js +22 -0
  55. package/dist/index.d.ts +10 -0
  56. package/dist/index.js +28 -0
  57. package/package.json +97 -4
  58. package/skills/build-infra-client-plugin/SKILL.md +61 -0
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_READY_INTERVAL_MS = exports.PLUGIN_REFRESH_LEAD_MS = exports.PLUGIN_REFRESH_TIMEOUT_MS = exports.PLUGIN_SDK_VERSION = void 0;
4
+ exports.connectToInfraClient = connectToInfraClient;
5
+ const messages_1 = require("../contracts/messages");
6
+ const transport_1 = require("../contracts/transport");
7
+ const errors_1 = require("./errors");
8
+ const http_1 = require("./http");
9
+ const theme_apply_1 = require("./theme-apply");
10
+ exports.PLUGIN_SDK_VERSION = "0.2.0";
11
+ exports.PLUGIN_REFRESH_TIMEOUT_MS = 15_000;
12
+ exports.PLUGIN_REFRESH_LEAD_MS = 30_000;
13
+ exports.PLUGIN_READY_INTERVAL_MS = 1_000;
14
+ function resolveEnvironment(options) {
15
+ const g = globalThis;
16
+ if (!options.hostWindow && g.window && g.window.parent === g.window) {
17
+ throw new errors_1.PluginSdkError("not_framed", "this page is not inside the portal's plugin frame");
18
+ }
19
+ const hostWindow = options.hostWindow ?? g.window?.parent;
20
+ const listenOn = options.listenOn ?? g.window;
21
+ const hostOrigin = options.hostOrigin ?? g.window?.location.origin;
22
+ const fetchImpl = options.fetch ?? (typeof g.fetch === "function" ? (input, init) => g.fetch(input, init) : undefined);
23
+ const missing = [
24
+ !hostWindow && "hostWindow", !listenOn && "listenOn", !hostOrigin && "hostOrigin", !fetchImpl && "fetch",
25
+ ].filter((name) => typeof name === "string");
26
+ if (!hostWindow || !listenOn || !hostOrigin || !fetchImpl) {
27
+ throw new errors_1.PluginSdkError("invalid_options", `outside a browser window, pass ${missing.join(", ")}`);
28
+ }
29
+ const doc = g.document;
30
+ const applyTheme = options.applyTheme === false ? null : options.applyTheme ?? (doc ? (theme) => (0, theme_apply_1.applyPluginTheme)(theme, doc) : null);
31
+ return { hostWindow, hostOrigin, listenOn, fetch: fetchImpl, applyTheme };
32
+ }
33
+ /** Only messages from the host window at the expected origin count; everything else is invisible. */
34
+ function fromHost(event, env) {
35
+ if (event.source !== env.hostWindow || event.origin !== env.hostOrigin)
36
+ return null;
37
+ return (0, messages_1.inspectPluginMessage)(event.data);
38
+ }
39
+ /** Announce to the host and resolve once `init` arrives. Rejects with `handshake_timeout` (call
40
+ * again to retry), `incompatible_host`, `not_framed` or `invalid_options`. */
41
+ function connectToInfraClient(options = {}) {
42
+ let env;
43
+ try {
44
+ env = resolveEnvironment(options);
45
+ }
46
+ catch (error) {
47
+ return Promise.reject(error);
48
+ }
49
+ const instanceId = options.instanceId ?? (0, transport_1.newPluginProtocolId)("frame");
50
+ const timeoutMs = options.handshakeTimeoutMs ?? messages_1.PLUGIN_HANDSHAKE_TIMEOUT_MS;
51
+ return new Promise((resolve, reject) => {
52
+ let ticker;
53
+ let timer;
54
+ const cleanup = () => {
55
+ env.listenOn.removeEventListener("message", listener);
56
+ clearInterval(ticker);
57
+ clearTimeout(timer);
58
+ };
59
+ const fail = (error) => {
60
+ cleanup();
61
+ reject(error);
62
+ };
63
+ const announce = () => {
64
+ try {
65
+ env.hostWindow.postMessage((0, messages_1.createPluginMessage)("ready", instanceId, { sdkVersion: exports.PLUGIN_SDK_VERSION, protocolVersion: messages_1.PLUGIN_PROTOCOL_VERSION }), env.hostOrigin);
66
+ }
67
+ catch (error) {
68
+ fail(new errors_1.PluginSdkError("invalid_options", `cannot post to the host: ${error instanceof Error ? error.message : String(error)}`));
69
+ }
70
+ };
71
+ const listener = (event) => {
72
+ const seen = fromHost(event, env);
73
+ if (!seen)
74
+ return;
75
+ if (seen.kind === "incompatible") {
76
+ fail(new errors_1.PluginSdkError("incompatible_host", `the host speaks protocol version ${String(seen.version)}`));
77
+ return;
78
+ }
79
+ if (seen.kind !== "message" || seen.message.type !== "init" || seen.message.instanceId !== instanceId)
80
+ return;
81
+ cleanup();
82
+ const client = new PluginClient(env, instanceId, seen.message.payload, options);
83
+ if (env.applyTheme) {
84
+ env.applyTheme(client.theme());
85
+ client.on("themeChanged", env.applyTheme);
86
+ }
87
+ resolve(client);
88
+ };
89
+ env.listenOn.addEventListener("message", listener);
90
+ ticker = setInterval(announce, exports.PLUGIN_READY_INTERVAL_MS);
91
+ if (timeoutMs > 0) {
92
+ timer = setTimeout(() => fail(new errors_1.PluginSdkError("handshake_timeout", `no init from the host within ${timeoutMs} ms`)), timeoutMs);
93
+ }
94
+ announce(); // last: a throwing first attempt must find the ticker and timer to clear
95
+ });
96
+ }
97
+ class PluginClient {
98
+ env;
99
+ instanceId;
100
+ user;
101
+ plugin;
102
+ entry;
103
+ session;
104
+ api;
105
+ current;
106
+ access;
107
+ currentTheme;
108
+ /** The expiry we already asked the host to renew past, so a host that answers with the same
109
+ * session is not asked again on every request for the rest of the lead window. */
110
+ askedFor = null;
111
+ ended = false;
112
+ pending = null;
113
+ listeners = new Map();
114
+ refreshTimeoutMs;
115
+ refreshLeadMs;
116
+ listener;
117
+ constructor(env, instanceId, init, options) {
118
+ this.env = env;
119
+ this.instanceId = instanceId;
120
+ this.user = init.user;
121
+ this.plugin = init.plugin;
122
+ this.entry = init.entry;
123
+ this.current = init.session;
124
+ this.access = { accounts: init.accounts, entryAvailable: true };
125
+ this.currentTheme = init.theme;
126
+ this.refreshTimeoutMs = options.refreshTimeoutMs ?? exports.PLUGIN_REFRESH_TIMEOUT_MS;
127
+ this.refreshLeadMs = options.refreshLeadMs ?? exports.PLUGIN_REFRESH_LEAD_MS;
128
+ this.session = {
129
+ current: () => this.current,
130
+ token: () => this.tokenForRequest(),
131
+ refresh: (reason) => this.refresh(reason),
132
+ };
133
+ this.api = (0, http_1.createPluginApi)({
134
+ apiBaseUrl: init.plugin.apiBaseUrl,
135
+ tokens: this.session,
136
+ accounts: () => this.access.accounts,
137
+ fetch: env.fetch,
138
+ assertActive: () => this.assertActive(),
139
+ onForbidden: options.accessRefreshOnForbidden === false ? undefined : () => this.requestAccessRefresh("forbidden"),
140
+ });
141
+ this.listener = (event) => {
142
+ const seen = fromHost(event, env);
143
+ if (seen?.kind === "message" && seen.message.instanceId === instanceId && (0, messages_1.isHostToPluginMessage)(seen.message))
144
+ this.handle(seen.message);
145
+ };
146
+ env.listenOn.addEventListener("message", this.listener);
147
+ }
148
+ state() {
149
+ return this.ended ? "ended" : "connected";
150
+ }
151
+ accounts() {
152
+ return this.access.accounts;
153
+ }
154
+ permissionsFor(accountNumber) {
155
+ return this.access.accounts.find((a) => a.accountNumber === accountNumber)?.permissions ?? [];
156
+ }
157
+ hasPermission(permission, accountNumber) {
158
+ return this.permissionsFor(accountNumber).includes(permission);
159
+ }
160
+ entryAvailable() {
161
+ return this.access.entryAvailable;
162
+ }
163
+ theme() {
164
+ return this.currentTheme;
165
+ }
166
+ requestAccessRefresh(reason = "manual") {
167
+ if (this.ended)
168
+ return;
169
+ this.post("requestAccessRefresh", { reason });
170
+ }
171
+ on(event, listener) {
172
+ let set = this.listeners.get(event);
173
+ if (!set)
174
+ this.listeners.set(event, (set = new Set()));
175
+ set.add(listener);
176
+ return () => set.delete(listener);
177
+ }
178
+ dispose() {
179
+ if (this.ended)
180
+ return;
181
+ this.teardown();
182
+ this.pending?.reject(new errors_1.PluginSdkError("disposed", "the plugin context was disposed"));
183
+ this.listeners.clear();
184
+ }
185
+ assertActive() {
186
+ if (this.ended)
187
+ throw new errors_1.PluginSdkError("session_ended", "the host ended this session");
188
+ }
189
+ post(type, payload, requestId) {
190
+ this.env.hostWindow.postMessage((0, messages_1.createPluginMessage)(type, this.instanceId, payload, requestId), this.env.hostOrigin);
191
+ }
192
+ /** Expired: wait for the host. Expiring: renew in the background, the current token still
193
+ * carries the request. Fresh: send. */
194
+ async tokenForRequest() {
195
+ this.assertActive();
196
+ const remaining = this.current.expiresAt - Date.now();
197
+ if (remaining <= 0) {
198
+ await this.refresh("expiring");
199
+ }
200
+ else if (remaining <= this.refreshLeadMs && this.askedFor !== this.current.expiresAt) {
201
+ this.askedFor = this.current.expiresAt;
202
+ void this.refresh("expiring").catch(() => undefined);
203
+ }
204
+ return this.current.token;
205
+ }
206
+ refresh(reason) {
207
+ if (this.ended)
208
+ return Promise.reject(new errors_1.PluginSdkError("session_ended", "the host ended this session"));
209
+ if (this.pending)
210
+ return this.pending.promise;
211
+ let settleResolve;
212
+ let settleReject;
213
+ const promise = new Promise((resolve, reject) => {
214
+ settleResolve = resolve;
215
+ settleReject = reject;
216
+ });
217
+ let timer;
218
+ const finish = () => {
219
+ clearTimeout(timer);
220
+ this.pending = null;
221
+ };
222
+ const pending = {
223
+ promise,
224
+ resolve: (session) => {
225
+ finish();
226
+ settleResolve(session);
227
+ },
228
+ reject: (error) => {
229
+ finish();
230
+ settleReject(error);
231
+ },
232
+ };
233
+ if (this.refreshTimeoutMs > 0) {
234
+ timer = setTimeout(() => pending.reject(new errors_1.PluginSdkError("refresh_timeout", `no session from the host within ${this.refreshTimeoutMs} ms`)), this.refreshTimeoutMs);
235
+ }
236
+ this.pending = pending;
237
+ this.post("requestSessionRefresh", { reason }, (0, transport_1.newPluginProtocolId)("req"));
238
+ return promise;
239
+ }
240
+ handle(message) {
241
+ switch (message.type) {
242
+ case "sessionChanged":
243
+ // The host owns session truth: whatever it sends after our request is the answer.
244
+ this.current = message.payload;
245
+ this.pending?.resolve(message.payload);
246
+ this.emit("sessionChanged", message.payload);
247
+ return;
248
+ case "sessionEnded":
249
+ this.teardown();
250
+ this.pending?.reject(new errors_1.PluginSdkError("session_ended", `the host ended this session (${message.payload.reason})`));
251
+ this.emit("sessionEnded", message.payload);
252
+ this.listeners.clear();
253
+ return;
254
+ case "accessChanged":
255
+ this.access = message.payload;
256
+ this.emit("accessChanged", message.payload);
257
+ return;
258
+ case "themeChanged":
259
+ this.currentTheme = message.payload;
260
+ this.emit("themeChanged", message.payload);
261
+ return;
262
+ default:
263
+ return; // a repeated `init` for this instance carries nothing new
264
+ }
265
+ }
266
+ /** After this, no host message reaches the client again: an old frame's session cannot come back. */
267
+ teardown() {
268
+ this.ended = true;
269
+ this.env.listenOn.removeEventListener("message", this.listener);
270
+ }
271
+ emit(event, payload) {
272
+ const set = this.listeners.get(event);
273
+ if (!set)
274
+ return;
275
+ for (const listener of [...set]) {
276
+ try {
277
+ listener(payload);
278
+ }
279
+ catch (error) {
280
+ const report = globalThis.reportError;
281
+ if (typeof report === "function")
282
+ report(error);
283
+ else
284
+ setTimeout(() => { throw error; }, 0);
285
+ }
286
+ }
287
+ }
288
+ }
@@ -0,0 +1,14 @@
1
+ export type PluginSdkErrorCode = "not_framed" | "invalid_options" | "handshake_timeout" | "incompatible_host" | "refresh_timeout" | "session_ended" | "disposed" | "invalid_url" | "timeout" | "http";
2
+ export declare class PluginSdkError extends Error {
3
+ readonly code: PluginSdkErrorCode;
4
+ constructor(code: PluginSdkErrorCode, message: string);
5
+ }
6
+ /** A non-2xx response from a JSON helper. `body` is the parsed JSON, the text, or undefined. */
7
+ export declare class PluginApiError extends PluginSdkError {
8
+ readonly status: number;
9
+ readonly method: string;
10
+ readonly url: string;
11
+ readonly body: unknown;
12
+ constructor(status: number, method: string, url: string, body: unknown);
13
+ }
14
+ export declare function isPluginSdkError(value: unknown, code?: PluginSdkErrorCode): value is PluginSdkError;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PluginApiError = exports.PluginSdkError = void 0;
4
+ exports.isPluginSdkError = isPluginSdkError;
5
+ class PluginSdkError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "PluginSdkError";
11
+ }
12
+ }
13
+ exports.PluginSdkError = PluginSdkError;
14
+ /** A non-2xx response from a JSON helper. `body` is the parsed JSON, the text, or undefined. */
15
+ class PluginApiError extends PluginSdkError {
16
+ status;
17
+ method;
18
+ url;
19
+ body;
20
+ constructor(status, method, url, body) {
21
+ super("http", `${method} ${url} failed with ${status}`);
22
+ this.status = status;
23
+ this.method = method;
24
+ this.url = url;
25
+ this.body = body;
26
+ this.name = "PluginApiError";
27
+ }
28
+ }
29
+ exports.PluginApiError = PluginApiError;
30
+ function isPluginSdkError(value, code) {
31
+ return value instanceof PluginSdkError && (code === undefined || value.code === code);
32
+ }
@@ -0,0 +1,72 @@
1
+ import { PluginAccountContext, PluginSession } from "../contracts/messages";
2
+ export type PluginQuery = Record<string, string | number | boolean | undefined>;
3
+ export interface PluginRequestOptions {
4
+ method?: string;
5
+ headers?: Record<string, string>;
6
+ body?: BodyInit | null;
7
+ query?: PluginQuery;
8
+ signal?: AbortSignal;
9
+ /** Aborts the request, body read included (`PluginSdkError` code `timeout`). Never retried. */
10
+ timeoutMs?: number;
11
+ }
12
+ export type PluginJsonOptions = Omit<PluginRequestOptions, "method" | "body">;
13
+ export interface PluginApi {
14
+ /** `fetch` from the iframe with the current bearer token attached, one retry after an
15
+ * authentication-layer 401. Returns the raw `Response`; no status check. */
16
+ request(path: string, options?: PluginRequestOptions): Promise<Response>;
17
+ get<T = unknown>(path: string, options?: PluginJsonOptions): Promise<T>;
18
+ post<T = unknown>(path: string, body?: unknown, options?: PluginJsonOptions): Promise<T>;
19
+ put<T = unknown>(path: string, body?: unknown, options?: PluginJsonOptions): Promise<T>;
20
+ patch<T = unknown>(path: string, body?: unknown, options?: PluginJsonOptions): Promise<T>;
21
+ delete<T = unknown>(path: string, options?: PluginJsonOptions): Promise<T>;
22
+ /** The URL a plugin API path resolves to, for callers bringing their own HTTP client. */
23
+ url(path: string, query?: PluginQuery): string;
24
+ }
25
+ export interface PluginTokenProvider {
26
+ current(): PluginSession;
27
+ /** The current bearer token. Renewal through the host runs in the background while the token is
28
+ * still valid and is awaited only once it has expired. */
29
+ token(): Promise<string>;
30
+ /** Ask the host to renew. Concurrent callers share one round trip. */
31
+ refresh(reason: "unauthorized" | "expiring"): Promise<PluginSession>;
32
+ }
33
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
34
+ export interface PluginApiDeps {
35
+ readonly apiBaseUrl: string;
36
+ readonly tokens: PluginTokenProvider;
37
+ readonly accounts: () => readonly PluginAccountContext[];
38
+ readonly fetch: FetchLike;
39
+ readonly assertActive: () => void;
40
+ readonly onForbidden?: () => void;
41
+ }
42
+ /** Relative paths under the plugin's own API root only, so a well-behaved plugin cannot send the bearer
43
+ * token to another origin or the core API by mistake (a `..` segment would climb out). A guardrail, not
44
+ * a boundary: the page is same-origin with the portal (README "Trust model"). */
45
+ export declare function resolvePluginApiUrl(apiBaseUrl: string, path: string, query?: PluginQuery): string;
46
+ export declare function isReplayableBody(body: unknown): boolean;
47
+ /** The account a plugin API URL acts on: every route mounts under `<apiBaseUrl>/accounts/<accountNumber>`.
48
+ * `null` for a URL outside that shape, which names no account. */
49
+ export declare function pluginRequestAccount(apiBaseUrl: string, url: string): string | null;
50
+ export interface UnauthorizedRefreshInput {
51
+ readonly body: unknown;
52
+ /** `pluginRequestAccount` of the request. */
53
+ readonly account: string | null;
54
+ /** The caller's accounts as they stand when the rule is asked, not as they stood at send. */
55
+ readonly permitted: readonly Pick<PluginAccountContext, "accountNumber">[];
56
+ }
57
+ export interface UnauthorizedRetryInput extends UnauthorizedRefreshInput {
58
+ readonly rejectedToken: string;
59
+ readonly retryToken: string;
60
+ }
61
+ /** Is a 401 worth asking the host to renew for? Only when the request could be sent again at all:
62
+ * the body must be re-readable, and the account the request acts on must still be one the
63
+ * caller may use (access can be revoked while the request or the renewal is in flight). A request
64
+ * that names no account is not held to the list. */
65
+ export declare function shouldRefreshAfterUnauthorized(input: UnauthorizedRefreshInput): boolean;
66
+ /** The one rule for re-sending a request the authorizer rejected with 401 (`context.api` and the
67
+ * Angular interceptor both use it): the renewal must have produced a different token, and the
68
+ * conditions that allowed the refresh must still hold after it. An authorizer 401 is decided
69
+ * before the plugin's handler runs, so that replay repeats nothing; a plugin whose own handler
70
+ * answers 401 breaks that assumption, which is why handlers must use domain 4xx codes instead. */
71
+ export declare function canRetryAfterUnauthorized(input: UnauthorizedRetryInput): boolean;
72
+ export declare function createPluginApi(deps: PluginApiDeps): PluginApi;
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolvePluginApiUrl = resolvePluginApiUrl;
4
+ exports.isReplayableBody = isReplayableBody;
5
+ exports.pluginRequestAccount = pluginRequestAccount;
6
+ exports.shouldRefreshAfterUnauthorized = shouldRefreshAfterUnauthorized;
7
+ exports.canRetryAfterUnauthorized = canRetryAfterUnauthorized;
8
+ exports.createPluginApi = createPluginApi;
9
+ const errors_1 = require("./errors");
10
+ /** Relative paths under the plugin's own API root only, so a well-behaved plugin cannot send the bearer
11
+ * token to another origin or the core API by mistake (a `..` segment would climb out). A guardrail, not
12
+ * a boundary: the page is same-origin with the portal (README "Trust model"). */
13
+ function resolvePluginApiUrl(apiBaseUrl, path, query) {
14
+ const escapes = path.split(/[/?#]/).includes("..");
15
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//") || escapes) {
16
+ throw new errors_1.PluginSdkError("invalid_url", `plugin API paths are relative to ${apiBaseUrl}; got ${path}`);
17
+ }
18
+ const relative = path.replace(/^\/+/, "");
19
+ const base = apiBaseUrl.replace(/\/+$/, "");
20
+ let url = relative ? `${base}/${relative}` : base;
21
+ if (query) {
22
+ const params = new URLSearchParams();
23
+ for (const [key, value] of Object.entries(query))
24
+ if (value !== undefined)
25
+ params.set(key, String(value));
26
+ const encoded = params.toString();
27
+ if (encoded)
28
+ url += (url.includes("?") ? "&" : "?") + encoded;
29
+ }
30
+ return url;
31
+ }
32
+ function isReplayableBody(body) {
33
+ return body == null || typeof body === "string" || typeof ReadableStream === "undefined" || !(body instanceof ReadableStream);
34
+ }
35
+ /** The account a plugin API URL acts on: every route mounts under `<apiBaseUrl>/accounts/<accountNumber>`.
36
+ * `null` for a URL outside that shape, which names no account. */
37
+ function pluginRequestAccount(apiBaseUrl, url) {
38
+ // Compared by path: one side may be written absolute and the other relative, and a mismatch in form
39
+ // must not read as "names no account", which would skip the revoked-account check.
40
+ const path = (value) => {
41
+ try {
42
+ return new URL(value, "http://plugin.invalid").pathname;
43
+ }
44
+ catch {
45
+ return value;
46
+ }
47
+ };
48
+ const base = path(apiBaseUrl).replace(/\/+$/, "");
49
+ const target = path(url);
50
+ if (!target.startsWith(`${base}/`))
51
+ return null;
52
+ return /^\/accounts\/(\d{12})(?:\/|$)/.exec(target.slice(base.length))?.[1] ?? null;
53
+ }
54
+ /** Is a 401 worth asking the host to renew for? Only when the request could be sent again at all:
55
+ * the body must be re-readable, and the account the request acts on must still be one the
56
+ * caller may use (access can be revoked while the request or the renewal is in flight). A request
57
+ * that names no account is not held to the list. */
58
+ function shouldRefreshAfterUnauthorized(input) {
59
+ return isReplayableBody(input.body) && (input.account === null || input.permitted.some((a) => a.accountNumber === input.account));
60
+ }
61
+ /** The one rule for re-sending a request the authorizer rejected with 401 (`context.api` and the
62
+ * Angular interceptor both use it): the renewal must have produced a different token, and the
63
+ * conditions that allowed the refresh must still hold after it. An authorizer 401 is decided
64
+ * before the plugin's handler runs, so that replay repeats nothing; a plugin whose own handler
65
+ * answers 401 breaks that assumption, which is why handlers must use domain 4xx codes instead. */
66
+ function canRetryAfterUnauthorized(input) {
67
+ return shouldRefreshAfterUnauthorized(input) && input.rejectedToken !== input.retryToken;
68
+ }
69
+ const timedOut = () => new errors_1.PluginSdkError("timeout", "the request timed out");
70
+ /** `AbortSignal.timeout`/`any` are recent (Safari 17.4, Firefox 124); fall back to a controller so a
71
+ * plugin on an older browser gets the timeout rather than a TypeError. */
72
+ function requestSignal(user, timeoutMs) {
73
+ if (!timeoutMs)
74
+ return user;
75
+ if (typeof AbortSignal.timeout === "function" && (!user || typeof AbortSignal.any === "function")) {
76
+ const timeout = AbortSignal.timeout(timeoutMs);
77
+ return user ? AbortSignal.any([user, timeout]) : timeout;
78
+ }
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(timedOut()), timeoutMs);
81
+ const forward = () => {
82
+ clearTimeout(timer);
83
+ controller.abort(user?.reason);
84
+ };
85
+ if (user?.aborted)
86
+ forward();
87
+ else
88
+ user?.addEventListener("abort", forward, { once: true });
89
+ return controller.signal;
90
+ }
91
+ /** `AbortSignal.timeout` rejects with a DOMException named TimeoutError; callers get the SDK code. */
92
+ function mapTimeout(error) {
93
+ return typeof error === "object" && error !== null && error.name === "TimeoutError" ? timedOut() : error;
94
+ }
95
+ async function parseBody(response) {
96
+ if (response.status === 204 || response.status === 205)
97
+ return undefined;
98
+ const text = await response.text();
99
+ if (!text)
100
+ return undefined;
101
+ if (/json/i.test(response.headers.get("content-type") ?? "")) {
102
+ try {
103
+ return JSON.parse(text);
104
+ }
105
+ catch {
106
+ return text;
107
+ }
108
+ }
109
+ return text;
110
+ }
111
+ function createPluginApi(deps) {
112
+ const url = (path, query) => resolvePluginApiUrl(deps.apiBaseUrl, path, query);
113
+ const perform = async (target, method, options) => {
114
+ deps.assertActive();
115
+ const replayable = () => ({ body: options.body, account: pluginRequestAccount(deps.apiBaseUrl, target), permitted: deps.accounts() });
116
+ const signal = requestSignal(options.signal, options.timeoutMs);
117
+ const send = (token) => deps.fetch(target, {
118
+ method,
119
+ headers: { ...options.headers, Authorization: `Bearer ${token}` },
120
+ body: options.body ?? undefined,
121
+ signal,
122
+ });
123
+ try {
124
+ const token = await deps.tokens.token();
125
+ let response = await send(token);
126
+ if (response.status === 401 && shouldRefreshAfterUnauthorized(replayable())) {
127
+ deps.assertActive();
128
+ const renewed = await deps.tokens.refresh("unauthorized");
129
+ if (canRetryAfterUnauthorized({ ...replayable(), rejectedToken: token, retryToken: renewed.token }))
130
+ response = await send(renewed.token);
131
+ }
132
+ if (response.status === 403)
133
+ deps.onForbidden?.();
134
+ return response;
135
+ }
136
+ catch (error) {
137
+ throw mapTimeout(error);
138
+ }
139
+ };
140
+ const request = (path, options = {}) => perform(url(path, options.query), (options.method ?? "GET").toUpperCase(), options);
141
+ const json = async (method, path, body, options = {}) => {
142
+ const target = url(path, options.query);
143
+ const headers = { Accept: "application/json", ...options.headers };
144
+ let payload;
145
+ if (body !== undefined) {
146
+ headers["Content-Type"] = "application/json";
147
+ payload = JSON.stringify(body);
148
+ }
149
+ const response = await perform(target, method, { ...options, headers, body: payload });
150
+ let parsed;
151
+ try {
152
+ parsed = await parseBody(response);
153
+ }
154
+ catch (error) {
155
+ throw mapTimeout(error);
156
+ }
157
+ if (!response.ok)
158
+ throw new errors_1.PluginApiError(response.status, method, target, parsed);
159
+ return parsed;
160
+ };
161
+ return {
162
+ request,
163
+ url,
164
+ get: (path, options) => json("GET", path, undefined, options),
165
+ post: (path, body, options) => json("POST", path, body, options),
166
+ put: (path, body, options) => json("PUT", path, body, options),
167
+ patch: (path, body, options) => json("PATCH", path, body, options),
168
+ delete: (path, options) => json("DELETE", path, undefined, options),
169
+ };
170
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./client";
2
+ export * from "./http";
3
+ export * from "./errors";
4
+ export * from "./theme-apply";
5
+ export * from "../contracts/messages";
6
+ export * from "../contracts/theme";
7
+ export * from "../contracts/transport";
8
+ export * from "../contracts/discovery";
9
+ export { pluginApiBaseUrl } from "../contracts/paths";
10
+ export type { PluginPermission, PluginRouteMethod } from "../contracts/manifest";
11
+ export type { PluginPrincipalType } from "../contracts/authorizer";
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.pluginApiBaseUrl = void 0;
18
+ // Entry point for plugin frontends. Must stay free of Node, CDK and AWS server imports.
19
+ __exportStar(require("./client"), exports);
20
+ __exportStar(require("./http"), exports);
21
+ __exportStar(require("./errors"), exports);
22
+ __exportStar(require("./theme-apply"), exports);
23
+ __exportStar(require("../contracts/messages"), exports);
24
+ __exportStar(require("../contracts/theme"), exports);
25
+ __exportStar(require("../contracts/transport"), exports);
26
+ __exportStar(require("../contracts/discovery"), exports);
27
+ var paths_1 = require("../contracts/paths");
28
+ Object.defineProperty(exports, "pluginApiBaseUrl", { enumerable: true, get: function () { return paths_1.pluginApiBaseUrl; } });
@@ -0,0 +1,22 @@
1
+ import { PluginTheme } from "../contracts/theme";
2
+ export declare const PLUGIN_THEME_STYLE_ID = "infra-client-plugin-theme";
3
+ export interface PluginThemeNode {
4
+ textContent: string | null;
5
+ }
6
+ export interface PluginThemeStyleElement extends PluginThemeNode {
7
+ id: string;
8
+ }
9
+ /** The slice of `Document` theme application touches; a real `Document` satisfies it. */
10
+ export interface PluginThemeDocument {
11
+ readonly documentElement: {
12
+ setAttribute(name: string, value: string): void;
13
+ };
14
+ readonly head: {
15
+ appendChild(node: PluginThemeNode): unknown;
16
+ };
17
+ getElementById(id: string): PluginThemeNode | null;
18
+ createElement(tagName: "style"): PluginThemeStyleElement;
19
+ }
20
+ /** Writes the theme into one `<style>` element (replaced on every call) and stamps
21
+ * `data-theme="light|dark"` on the root element for appearance-specific plugin CSS. */
22
+ export declare function applyPluginTheme(theme: PluginTheme, doc: PluginThemeDocument): void;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_THEME_STYLE_ID = void 0;
4
+ exports.applyPluginTheme = applyPluginTheme;
5
+ const theme_1 = require("../contracts/theme");
6
+ exports.PLUGIN_THEME_STYLE_ID = "infra-client-plugin-theme";
7
+ /** Writes the theme into one `<style>` element (replaced on every call) and stamps
8
+ * `data-theme="light|dark"` on the root element for appearance-specific plugin CSS. */
9
+ function applyPluginTheme(theme, doc) {
10
+ const css = (0, theme_1.pluginThemeCss)(theme);
11
+ const existing = doc.getElementById(exports.PLUGIN_THEME_STYLE_ID);
12
+ if (existing) {
13
+ existing.textContent = css;
14
+ }
15
+ else {
16
+ const element = doc.createElement("style");
17
+ element.id = exports.PLUGIN_THEME_STYLE_ID;
18
+ element.textContent = css;
19
+ doc.head.appendChild(element);
20
+ }
21
+ doc.documentElement.setAttribute("data-theme", theme.appearance);
22
+ }