@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.
- package/LICENSE +93 -0
- package/README.md +279 -2
- package/dist/backend/context.d.ts +31 -0
- package/dist/backend/context.js +58 -0
- package/dist/backend/index.d.ts +9 -0
- package/dist/backend/index.js +26 -0
- package/dist/browser/client.d.ts +59 -0
- package/dist/browser/client.js +288 -0
- package/dist/browser/errors.d.ts +14 -0
- package/dist/browser/errors.js +32 -0
- package/dist/browser/http.d.ts +72 -0
- package/dist/browser/http.js +170 -0
- package/dist/browser/index.d.ts +11 -0
- package/dist/browser/index.js +28 -0
- package/dist/browser/theme-apply.d.ts +22 -0
- package/dist/browser/theme-apply.js +22 -0
- package/dist/contracts/access.d.ts +14 -0
- package/dist/contracts/access.js +41 -0
- package/dist/contracts/authorizer.d.ts +17 -0
- package/dist/contracts/authorizer.js +44 -0
- package/dist/contracts/canonical.d.ts +2 -0
- package/dist/contracts/canonical.js +21 -0
- package/dist/contracts/catalog.d.ts +37 -0
- package/dist/contracts/catalog.js +18 -0
- package/dist/contracts/discovery.d.ts +78 -0
- package/dist/contracts/discovery.js +48 -0
- package/dist/contracts/fields.d.ts +7 -0
- package/dist/contracts/fields.js +2 -0
- package/dist/contracts/manifest.d.ts +59 -0
- package/dist/contracts/manifest.js +19 -0
- package/dist/contracts/messages.d.ts +108 -0
- package/dist/contracts/messages.js +103 -0
- package/dist/contracts/paths.d.ts +20 -0
- package/dist/contracts/paths.js +49 -0
- package/dist/contracts/theme.d.ts +21 -0
- package/dist/contracts/theme.js +60 -0
- package/dist/contracts/transport.d.ts +14 -0
- package/dist/contracts/transport.js +10 -0
- package/dist/contracts/validate.d.ts +27 -0
- package/dist/contracts/validate.js +321 -0
- package/dist/fixtures/devHost.d.ts +57 -0
- package/dist/fixtures/devHost.js +91 -0
- package/dist/fixtures/fakeDocument.d.ts +12 -0
- package/dist/fixtures/fakeDocument.js +22 -0
- package/dist/fixtures/fakeWindows.d.ts +22 -0
- package/dist/fixtures/fakeWindows.js +46 -0
- package/dist/fixtures/index.d.ts +4 -0
- package/dist/fixtures/index.js +20 -0
- package/dist/fixtures/samples.d.ts +37 -0
- package/dist/fixtures/samples.js +220 -0
- package/dist/host/host.d.ts +47 -0
- package/dist/host/host.js +117 -0
- package/dist/host/index.d.ts +5 -0
- package/dist/host/index.js +22 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +28 -0
- package/package.json +97 -4
- 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
|
+
}
|