@aglyn/plugins-marketplace 1.14.0 → 1.15.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 (107) hide show
  1. package/README.md +1 -0
  2. package/package.json +15 -14
  3. package/src/lib/components/editor-connections-provider.component.d.ts +37 -0
  4. package/src/lib/components/editor-connections-provider.component.js +161 -0
  5. package/src/lib/components/editor-connections-provider.component.js.map +1 -0
  6. package/src/lib/components/listing-connection-credentials-card.component.d.ts +33 -0
  7. package/src/lib/components/listing-connection-credentials-card.component.js +318 -0
  8. package/src/lib/components/listing-connection-credentials-card.component.js.map +1 -0
  9. package/src/lib/components/listing-detail-page.component.js +10 -2
  10. package/src/lib/components/listing-detail-page.component.js.map +1 -1
  11. package/src/lib/components/site-plugin-connections-card.component.d.ts +38 -0
  12. package/src/lib/components/site-plugin-connections-card.component.js +576 -0
  13. package/src/lib/components/site-plugin-connections-card.component.js.map +1 -0
  14. package/src/lib/declarations.console-server.d.ts +17 -0
  15. package/src/lib/declarations.console-server.js +41 -0
  16. package/src/lib/declarations.console-server.js.map +1 -0
  17. package/src/lib/declarations.js +11 -2
  18. package/src/lib/declarations.js.map +1 -1
  19. package/src/lib/declarations.server.js +68 -1
  20. package/src/lib/declarations.server.js.map +1 -1
  21. package/src/lib/hooks/use-plugin-connections-api.d.ts +89 -0
  22. package/src/lib/hooks/use-plugin-connections-api.js +132 -0
  23. package/src/lib/hooks/use-plugin-connections-api.js.map +1 -0
  24. package/src/lib/model/editor-connections.d.ts +46 -0
  25. package/src/lib/model/editor-connections.js +70 -0
  26. package/src/lib/model/editor-connections.js.map +1 -0
  27. package/src/lib/model/listing-price-floor.js +7 -4
  28. package/src/lib/model/listing-price-floor.js.map +1 -1
  29. package/src/lib/model/marketplace-publisher-permissions.d.ts +3 -1
  30. package/src/lib/model/marketplace-publisher-permissions.js +14 -0
  31. package/src/lib/model/marketplace-publisher-permissions.js.map +1 -1
  32. package/src/lib/model/marketplace-site-permissions.d.ts +9 -0
  33. package/src/lib/model/marketplace-site-permissions.js +29 -0
  34. package/src/lib/model/marketplace-site-permissions.js.map +1 -1
  35. package/src/lib/model/plugin-connection-card.d.ts +88 -0
  36. package/src/lib/model/plugin-connection-card.js +187 -0
  37. package/src/lib/model/plugin-connection-card.js.map +1 -0
  38. package/src/lib/model/plugin-connections.d.ts +172 -0
  39. package/src/lib/model/plugin-connections.js +119 -0
  40. package/src/lib/model/plugin-connections.js.map +1 -0
  41. package/src/lib/plugin.js +21 -1
  42. package/src/lib/plugin.js.map +1 -1
  43. package/src/lib/server/admin-reviews.js +18 -12
  44. package/src/lib/server/admin-reviews.js.map +1 -1
  45. package/src/lib/server/artifact-inventory.js +3 -1
  46. package/src/lib/server/artifact-inventory.js.map +1 -1
  47. package/src/lib/server/checkout.js +4 -0
  48. package/src/lib/server/checkout.js.map +1 -1
  49. package/src/lib/server/connections/cleanup.d.ts +84 -0
  50. package/src/lib/server/connections/cleanup.js +116 -0
  51. package/src/lib/server/connections/cleanup.js.map +1 -0
  52. package/src/lib/server/connections/config.d.ts +42 -0
  53. package/src/lib/server/connections/config.js +48 -0
  54. package/src/lib/server/connections/config.js.map +1 -0
  55. package/src/lib/server/connections/connection-store.d.ts +173 -0
  56. package/src/lib/server/connections/connection-store.js +331 -0
  57. package/src/lib/server/connections/connection-store.js.map +1 -0
  58. package/src/lib/server/connections/credentials-store.d.ts +71 -0
  59. package/src/lib/server/connections/credentials-store.js +78 -0
  60. package/src/lib/server/connections/credentials-store.js.map +1 -0
  61. package/src/lib/server/connections/erasure.d.ts +25 -0
  62. package/src/lib/server/connections/erasure.js +72 -0
  63. package/src/lib/server/connections/erasure.js.map +1 -0
  64. package/src/lib/server/connections/installed-connection.d.ts +64 -0
  65. package/src/lib/server/connections/installed-connection.js +92 -0
  66. package/src/lib/server/connections/installed-connection.js.map +1 -0
  67. package/src/lib/server/connections/oauth-state.d.ts +129 -0
  68. package/src/lib/server/connections/oauth-state.js +252 -0
  69. package/src/lib/server/connections/oauth-state.js.map +1 -0
  70. package/src/lib/server/connections/provider-http.d.ts +99 -0
  71. package/src/lib/server/connections/provider-http.js +253 -0
  72. package/src/lib/server/connections/provider-http.js.map +1 -0
  73. package/src/lib/server/connections/record-announce.d.ts +20 -0
  74. package/src/lib/server/connections/record-announce.js +53 -0
  75. package/src/lib/server/connections/record-announce.js.map +1 -0
  76. package/src/lib/server/connections/record-mapping.d.ts +63 -0
  77. package/src/lib/server/connections/record-mapping.js +151 -0
  78. package/src/lib/server/connections/record-mapping.js.map +1 -0
  79. package/src/lib/server/connections/record-routes.d.ts +17 -0
  80. package/src/lib/server/connections/record-routes.js +96 -0
  81. package/src/lib/server/connections/record-routes.js.map +1 -0
  82. package/src/lib/server/connections/revoke.d.ts +29 -0
  83. package/src/lib/server/connections/revoke.js +52 -0
  84. package/src/lib/server/connections/revoke.js.map +1 -0
  85. package/src/lib/server/connections/routes.d.ts +77 -0
  86. package/src/lib/server/connections/routes.js +706 -0
  87. package/src/lib/server/connections/routes.js.map +1 -0
  88. package/src/lib/server/connections/site-records-platform.d.ts +24 -0
  89. package/src/lib/server/connections/site-records-platform.js +94 -0
  90. package/src/lib/server/connections/site-records-platform.js.map +1 -0
  91. package/src/lib/server/connections/site-records.d.ts +95 -0
  92. package/src/lib/server/connections/site-records.js +221 -0
  93. package/src/lib/server/connections/site-records.js.map +1 -0
  94. package/src/lib/server/connections/sync.d.ts +110 -0
  95. package/src/lib/server/connections/sync.js +414 -0
  96. package/src/lib/server/connections/sync.js.map +1 -0
  97. package/src/lib/server/install-plugin.js +56 -6
  98. package/src/lib/server/install-plugin.js.map +1 -1
  99. package/src/lib/server/publish-plugin.js +5 -2
  100. package/src/lib/server/publish-plugin.js.map +1 -1
  101. package/src/lib/server/revenue-source.js +6 -5
  102. package/src/lib/server/revenue-source.js.map +1 -1
  103. package/src/lib/server.js +60 -0
  104. package/src/lib/server.js.map +1 -1
  105. package/src/lib/testing/memory-firestore.d.ts +34 -0
  106. package/src/lib/testing/memory-firestore.js +250 -0
  107. package/src/lib/testing/memory-firestore.js.map +1 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
17
+ import { createHash, createHmac, randomBytes } from "node:crypto";
18
+ import { consumeOnce } from "@aglyn/tenant-data-admin/server/consume-once";
19
+ import { tokenSigningSecret } from "@aglyn/tenant-data-admin/server/media-signing";
20
+ import { safeEqual } from "@aglyn/tenant-data-admin/server/safe-equal";
21
+ import { PLUGIN_CONNECTIONS_API_ROUTES } from "../../model/plugin-connections.js";
22
+ import { connectionsCollection } from "./connection-store.js";
23
+ /**
24
+ * THE OAUTH `state` FOR A DECLARED CONNECTION (AGL-4193): signed,
25
+ * short-lived, single-use, and bound to one member, one site, one listing and
26
+ * one of its connections. Copied in shape from sales channels' connect state
27
+ * (`libs/plugins/sales-channels/.../oauth-state.ts`); one plugin never
28
+ * imports another.
29
+ *
30
+ * ## Signed
31
+ *
32
+ * `pc1.<payload>.<signature>`: the payload names the site, its workspace, the
33
+ * member, the listing, the connection, a nonce, the expiry and the console
34
+ * page to come back to; the signature is an HMAC under the shared,
35
+ * fail-closed `TOKEN_SIGNING_SECRET` with its own context, so no other signed
36
+ * token here verifies as one. The provider hands the state back verbatim, so
37
+ * a state that verifies says whose connect it was — which is how the
38
+ * callback, a redirect with no bearer token, knows its site, and the release
39
+ * gate knows its workspace.
40
+ *
41
+ * ## Single-use
42
+ *
43
+ * Starting a connect also writes one pending record per member, site, listing
44
+ * and connection on `hosts/{hostId}/pluginConnections/oauth-…`, holding the
45
+ * SHA-256 of the nonce, the expiry and the redirect address the code is
46
+ * issued for. The callback consumes it in a transaction, so a replayed state
47
+ * finds nothing, and a later start replaces it, retiring every earlier state.
48
+ *
49
+ * ## Coming back
50
+ *
51
+ * The page to return to is checked against the console's own origin when the
52
+ * connect starts, signed into the state, and checked again on the way back;
53
+ * the redirect carries only its path, so it can only ever land on the console
54
+ * that served the callback.
55
+ */ /** How long a member has to finish the provider's consent screen. */ export const CONNECT_STATE_TTL_MS = 15 * 60 * 1000;
56
+ const STATE_VERSION = 'pc1';
57
+ const STATE_CONTEXT = 'plugin-connections-oauth-state:v1';
58
+ const STATE_MAX_CHARS = 2048;
59
+ const RETURN_TO_MAX_CHARS = 512;
60
+ /** The callback's path. Registered as a redirect URI at providers: never change it. */ export const CONNECT_CALLBACK_PATH = `/api/${PLUGIN_CONNECTIONS_API_ROUTES.connectCallback}`;
61
+ const hmac = (value)=>createHmac('sha256', tokenSigningSecret()).update(`${STATE_CONTEXT}:${value}`).digest('base64url');
62
+ /** Mints a state. Throws when `TOKEN_SIGNING_SECRET` is not configured. */ export function mintConnectState(input) {
63
+ var _input_nonce;
64
+ const claims = {
65
+ hostId: input.hostId,
66
+ orgId: input.orgId,
67
+ uid: input.uid,
68
+ listingId: input.listingId,
69
+ connectionId: input.connectionId,
70
+ nonce: (_input_nonce = input.nonce) != null ? _input_nonce : randomBytes(32).toString('base64url'),
71
+ exp: input.nowMs + CONNECT_STATE_TTL_MS,
72
+ returnTo: input.returnTo
73
+ };
74
+ const wire = {
75
+ h: claims.hostId,
76
+ o: claims.orgId,
77
+ u: claims.uid,
78
+ l: claims.listingId,
79
+ c: claims.connectionId,
80
+ n: claims.nonce,
81
+ e: claims.exp,
82
+ r: claims.returnTo
83
+ };
84
+ const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
85
+ return {
86
+ state: `${STATE_VERSION}.${payload}.${hmac(payload)}`,
87
+ claims
88
+ };
89
+ }
90
+ /** Verifies a state's signature and expiry. Never throws. */ export function readConnectState(state, nowMs) {
91
+ const invalid = {
92
+ ok: false,
93
+ refusal: 'state-invalid'
94
+ };
95
+ if (typeof state !== 'string' || !state || state.length > STATE_MAX_CHARS) return invalid;
96
+ const parts = state.split('.');
97
+ if (parts.length !== 3 || parts[0] !== STATE_VERSION) return invalid;
98
+ const [, payload, signature] = parts;
99
+ let expected;
100
+ try {
101
+ expected = hmac(payload);
102
+ } catch (unused) {
103
+ return invalid;
104
+ }
105
+ if (!safeEqual(signature, expected)) return invalid;
106
+ let wire;
107
+ try {
108
+ wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
109
+ } catch (unused) {
110
+ return invalid;
111
+ }
112
+ const text = (value)=>typeof value === 'string' ? value : '';
113
+ const claims = {
114
+ hostId: text(wire == null ? void 0 : wire.h),
115
+ orgId: text(wire == null ? void 0 : wire.o),
116
+ uid: text(wire == null ? void 0 : wire.u),
117
+ listingId: text(wire == null ? void 0 : wire.l),
118
+ connectionId: text(wire == null ? void 0 : wire.c),
119
+ nonce: text(wire == null ? void 0 : wire.n),
120
+ exp: Number(wire == null ? void 0 : wire.e),
121
+ returnTo: text(wire == null ? void 0 : wire.r)
122
+ };
123
+ if (!claims.hostId || !claims.orgId || !claims.uid || !claims.listingId || !claims.connectionId || !claims.nonce || !Number.isFinite(claims.exp)) {
124
+ return invalid;
125
+ }
126
+ if (claims.exp <= nowMs) return {
127
+ ok: false,
128
+ refusal: 'state-expired',
129
+ claims
130
+ };
131
+ return {
132
+ ok: true,
133
+ claims
134
+ };
135
+ }
136
+ /** The pending record's id: the member, listing and connection hashed, so a uid never names a document. */ export function connectStateDocId(uid, listingId, connectionId) {
137
+ return `oauth-${createHash('sha256').update(`${listingId}\n${connectionId}\n${uid}`).digest('hex').slice(0, 40)}`;
138
+ }
139
+ function connectStateRef(firestore, claims) {
140
+ return connectionsCollection(firestore, claims.hostId).doc(connectStateDocId(claims.uid, claims.listingId, claims.connectionId));
141
+ }
142
+ const digest = (nonce)=>createHash('sha256').update(nonce).digest('base64url');
143
+ /** Writes the pending record for a freshly minted state, replacing any earlier one. */ export async function recordConnectState(firestore, input) {
144
+ const { claims } = input;
145
+ await connectStateRef(firestore, claims).set({
146
+ kind: 'oauth-state',
147
+ uid: claims.uid,
148
+ listingId: claims.listingId,
149
+ connectionId: claims.connectionId,
150
+ nonceDigest: digest(claims.nonce),
151
+ redirectUri: input.redirectUri,
152
+ expiresAtMs: claims.exp,
153
+ createdAtMs: input.nowMs
154
+ });
155
+ }
156
+ /** Consumes the pending record a verified state names — once. */ export async function consumeConnectState(firestore, input) {
157
+ const { claims } = input;
158
+ const result = await consumeOnce(firestore, connectStateRef(firestore, claims), (data)=>{
159
+ var _data_nonceDigest, _data_redirectUri;
160
+ if (data['kind'] !== 'oauth-state' || data['uid'] !== claims.uid || data['listingId'] !== claims.listingId || data['connectionId'] !== claims.connectionId) {
161
+ return {
162
+ accept: false,
163
+ reason: 'state-superseded'
164
+ };
165
+ }
166
+ if (!safeEqual(String((_data_nonceDigest = data['nonceDigest']) != null ? _data_nonceDigest : ''), digest(claims.nonce))) {
167
+ return {
168
+ accept: false,
169
+ reason: 'state-superseded'
170
+ };
171
+ }
172
+ if (Number(data['expiresAtMs']) <= input.nowMs) {
173
+ return {
174
+ accept: false,
175
+ reason: 'state-expired',
176
+ remove: true
177
+ };
178
+ }
179
+ return {
180
+ accept: true,
181
+ value: String((_data_redirectUri = data['redirectUri']) != null ? _data_redirectUri : ''),
182
+ remove: true
183
+ };
184
+ });
185
+ if (result.ok && result.value) return {
186
+ ok: true,
187
+ redirectUri: result.value
188
+ };
189
+ if (result.reason === 'state-superseded') return {
190
+ ok: false,
191
+ refusal: 'state-superseded'
192
+ };
193
+ if (result.reason === 'state-expired') return {
194
+ ok: false,
195
+ refusal: 'state-expired'
196
+ };
197
+ return {
198
+ ok: false,
199
+ refusal: 'state-replayed'
200
+ };
201
+ }
202
+ const stripTrailingSlash = (value)=>value.replace(/\/+$/, '');
203
+ /** The canonical console origin, or `null` when it is set to something that is not http(s). */ export function canonicalConsoleOrigin() {
204
+ const raw = stripTrailingSlash(platformConsoleOrigin());
205
+ try {
206
+ const url = new URL(raw);
207
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null;
208
+ } catch (unused) {
209
+ return null;
210
+ }
211
+ }
212
+ /**
213
+ * The console origin a connect started by `requestUrl` comes back to: the
214
+ * canonical one — the address registered on each publisher's OAuth app —
215
+ * except in development, where a request from `http://localhost` or
216
+ * `127.0.0.1` comes back to itself. Nothing else a request claims about its
217
+ * origin is believed.
218
+ */ export function consoleOriginFor(requestUrl) {
219
+ if (process.env['NODE_ENV'] !== 'production') {
220
+ try {
221
+ const url = new URL(requestUrl);
222
+ if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) return url.origin;
223
+ } catch (unused) {
224
+ // Not a URL: the canonical origin below.
225
+ }
226
+ }
227
+ return canonicalConsoleOrigin();
228
+ }
229
+ /** Where the provider sends the member back to, or `null` when no origin is usable. */ export function connectRedirectUri(requestUrl) {
230
+ const origin = consoleOriginFor(requestUrl);
231
+ return origin ? `${origin}${CONNECT_CALLBACK_PATH}` : null;
232
+ }
233
+ /**
234
+ * The page to return to as a console path with its query, or `null` when
235
+ * `returnTo` is not on `origin`. Accepts an absolute URL on the origin or a
236
+ * single-slash path; anything else — another host, a protocol-relative
237
+ * `//host`, a `javascript:` URL, the callback itself — is refused.
238
+ */ export function consolePath(returnTo, origin) {
239
+ if (typeof returnTo !== 'string' || !returnTo || returnTo.length > RETURN_TO_MAX_CHARS) return null;
240
+ if (returnTo.startsWith('/') && (returnTo.startsWith('//') || returnTo.startsWith('/\\'))) return null;
241
+ let url;
242
+ try {
243
+ url = new URL(returnTo, origin);
244
+ } catch (unused) {
245
+ return null;
246
+ }
247
+ if (url.origin !== origin) return null;
248
+ if (url.pathname.startsWith(CONNECT_CALLBACK_PATH)) return null;
249
+ return `${url.pathname}${url.search}`;
250
+ }
251
+
252
+ //# sourceMappingURL=oauth-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/marketplace/src/lib/server/connections/oauth-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { platformConsoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { createHash, createHmac, randomBytes } from 'node:crypto'\nimport { consumeOnce } from '@aglyn/tenant-data-admin/server/consume-once'\nimport { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport { PLUGIN_CONNECTIONS_API_ROUTES } from '../../model/plugin-connections'\nimport { connectionsCollection } from './connection-store'\n\n/**\n * THE OAUTH `state` FOR A DECLARED CONNECTION (AGL-4193): signed,\n * short-lived, single-use, and bound to one member, one site, one listing and\n * one of its connections. Copied in shape from sales channels' connect state\n * (`libs/plugins/sales-channels/.../oauth-state.ts`); one plugin never\n * imports another.\n *\n * ## Signed\n *\n * `pc1.<payload>.<signature>`: the payload names the site, its workspace, the\n * member, the listing, the connection, a nonce, the expiry and the console\n * page to come back to; the signature is an HMAC under the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` with its own context, so no other signed\n * token here verifies as one. The provider hands the state back verbatim, so\n * a state that verifies says whose connect it was — which is how the\n * callback, a redirect with no bearer token, knows its site, and the release\n * gate knows its workspace.\n *\n * ## Single-use\n *\n * Starting a connect also writes one pending record per member, site, listing\n * and connection on `hosts/{hostId}/pluginConnections/oauth-…`, holding the\n * SHA-256 of the nonce, the expiry and the redirect address the code is\n * issued for. The callback consumes it in a transaction, so a replayed state\n * finds nothing, and a later start replaces it, retiring every earlier state.\n *\n * ## Coming back\n *\n * The page to return to is checked against the console's own origin when the\n * connect starts, signed into the state, and checked again on the way back;\n * the redirect carries only its path, so it can only ever land on the console\n * that served the callback.\n */\n\n/** How long a member has to finish the provider's consent screen. */\nexport const CONNECT_STATE_TTL_MS = 15 * 60 * 1000\n\nconst STATE_VERSION = 'pc1'\nconst STATE_CONTEXT = 'plugin-connections-oauth-state:v1'\nconst STATE_MAX_CHARS = 2048\nconst RETURN_TO_MAX_CHARS = 512\n\n/** The callback's path. Registered as a redirect URI at providers: never change it. */\nexport const CONNECT_CALLBACK_PATH = `/api/${PLUGIN_CONNECTIONS_API_ROUTES.connectCallback}`\n\nexport interface ConnectStateClaims {\n hostId: string\n orgId: string\n uid: string\n listingId: string\n connectionId: string\n nonce: string\n /** Expiry, epoch ms. */\n exp: number\n /** The console path (with its query) to return to. */\n returnTo: string\n}\n\ninterface WireClaims {\n h: string\n o: string\n u: string\n l: string\n c: string\n n: string\n e: number\n r: string\n}\n\nconst hmac = (value: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${STATE_CONTEXT}:${value}`).digest('base64url')\n\n/** Mints a state. Throws when `TOKEN_SIGNING_SECRET` is not configured. */\nexport function mintConnectState(\n input: Omit<ConnectStateClaims, 'nonce' | 'exp'> & { nowMs: number; nonce?: string },\n): { state: string; claims: ConnectStateClaims } {\n const claims: ConnectStateClaims = {\n hostId: input.hostId,\n orgId: input.orgId,\n uid: input.uid,\n listingId: input.listingId,\n connectionId: input.connectionId,\n nonce: input.nonce ?? randomBytes(32).toString('base64url'),\n exp: input.nowMs + CONNECT_STATE_TTL_MS,\n returnTo: input.returnTo,\n }\n const wire: WireClaims = {\n h: claims.hostId,\n o: claims.orgId,\n u: claims.uid,\n l: claims.listingId,\n c: claims.connectionId,\n n: claims.nonce,\n e: claims.exp,\n r: claims.returnTo,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return { state: `${STATE_VERSION}.${payload}.${hmac(payload)}`, claims }\n}\n\nexport type ConnectStateRead =\n | { ok: true; claims: ConnectStateClaims }\n /** Authentic and past its expiry: the claims still say whose it was. */\n | { ok: false; refusal: 'state-expired'; claims: ConnectStateClaims }\n /** Tampered, truncated, another version, or no secret to check it with. */\n | { ok: false; refusal: 'state-invalid' }\n\n/** Verifies a state's signature and expiry. Never throws. */\nexport function readConnectState(state: unknown, nowMs: number): ConnectStateRead {\n const invalid = { ok: false, refusal: 'state-invalid' } as const\n if (typeof state !== 'string' || !state || state.length > STATE_MAX_CHARS) return invalid\n const parts = state.split('.')\n if (parts.length !== 3 || parts[0] !== STATE_VERSION) return invalid\n const [, payload, signature] = parts\n let expected: string\n try {\n expected = hmac(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signature, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const claims: ConnectStateClaims = {\n hostId: text(wire?.h),\n orgId: text(wire?.o),\n uid: text(wire?.u),\n listingId: text(wire?.l),\n connectionId: text(wire?.c),\n nonce: text(wire?.n),\n exp: Number(wire?.e),\n returnTo: text(wire?.r),\n }\n if (\n !claims.hostId ||\n !claims.orgId ||\n !claims.uid ||\n !claims.listingId ||\n !claims.connectionId ||\n !claims.nonce ||\n !Number.isFinite(claims.exp)\n ) {\n return invalid\n }\n if (claims.exp <= nowMs) return { ok: false, refusal: 'state-expired', claims }\n return { ok: true, claims }\n}\n\n/** The pending record's id: the member, listing and connection hashed, so a uid never names a document. */\nexport function connectStateDocId(uid: string, listingId: string, connectionId: string): string {\n return `oauth-${createHash('sha256').update(`${listingId}\\n${connectionId}\\n${uid}`).digest('hex').slice(0, 40)}`\n}\n\nfunction connectStateRef(firestore: FirebaseFirestore.Firestore, claims: Pick<ConnectStateClaims, 'hostId' | 'uid' | 'listingId' | 'connectionId'>) {\n return connectionsCollection(firestore, claims.hostId).doc(connectStateDocId(claims.uid, claims.listingId, claims.connectionId))\n}\n\nconst digest = (nonce: string) => createHash('sha256').update(nonce).digest('base64url')\n\n/** Writes the pending record for a freshly minted state, replacing any earlier one. */\nexport async function recordConnectState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: ConnectStateClaims; redirectUri: string; nowMs: number },\n): Promise<void> {\n const { claims } = input\n await connectStateRef(firestore, claims).set({\n kind: 'oauth-state',\n uid: claims.uid,\n listingId: claims.listingId,\n connectionId: claims.connectionId,\n nonceDigest: digest(claims.nonce),\n redirectUri: input.redirectUri,\n expiresAtMs: claims.exp,\n createdAtMs: input.nowMs,\n })\n}\n\nexport type ConnectStateConsumed =\n | { ok: true; redirectUri: string }\n | { ok: false; refusal: 'state-replayed' | 'state-superseded' | 'state-expired' }\n\n/** Consumes the pending record a verified state names — once. */\nexport async function consumeConnectState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: ConnectStateClaims; nowMs: number },\n): Promise<ConnectStateConsumed> {\n const { claims } = input\n const result = await consumeOnce<string>(firestore, connectStateRef(firestore, claims), (data) => {\n if (\n data['kind'] !== 'oauth-state' ||\n data['uid'] !== claims.uid ||\n data['listingId'] !== claims.listingId ||\n data['connectionId'] !== claims.connectionId\n ) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (!safeEqual(String(data['nonceDigest'] ?? ''), digest(claims.nonce))) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (Number(data['expiresAtMs']) <= input.nowMs) {\n return { accept: false, reason: 'state-expired', remove: true }\n }\n return { accept: true, value: String(data['redirectUri'] ?? ''), remove: true }\n })\n if (result.ok && result.value) return { ok: true, redirectUri: result.value }\n if (result.reason === 'state-superseded') return { ok: false, refusal: 'state-superseded' }\n if (result.reason === 'state-expired') return { ok: false, refusal: 'state-expired' }\n return { ok: false, refusal: 'state-replayed' }\n}\n\nconst stripTrailingSlash = (value: string) => value.replace(/\\/+$/, '')\n\n/** The canonical console origin, or `null` when it is set to something that is not http(s). */\nexport function canonicalConsoleOrigin(): string | null {\n const raw = stripTrailingSlash(platformConsoleOrigin())\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null\n } catch {\n return null\n }\n}\n\n/**\n * The console origin a connect started by `requestUrl` comes back to: the\n * canonical one — the address registered on each publisher's OAuth app —\n * except in development, where a request from `http://localhost` or\n * `127.0.0.1` comes back to itself. Nothing else a request claims about its\n * origin is believed.\n */\nexport function consoleOriginFor(requestUrl: string): string | null {\n if (process.env['NODE_ENV'] !== 'production') {\n try {\n const url = new URL(requestUrl)\n if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) return url.origin\n } catch {\n // Not a URL: the canonical origin below.\n }\n }\n return canonicalConsoleOrigin()\n}\n\n/** Where the provider sends the member back to, or `null` when no origin is usable. */\nexport function connectRedirectUri(requestUrl: string): string | null {\n const origin = consoleOriginFor(requestUrl)\n return origin ? `${origin}${CONNECT_CALLBACK_PATH}` : null\n}\n\n/**\n * The page to return to as a console path with its query, or `null` when\n * `returnTo` is not on `origin`. Accepts an absolute URL on the origin or a\n * single-slash path; anything else — another host, a protocol-relative\n * `//host`, a `javascript:` URL, the callback itself — is refused.\n */\nexport function consolePath(returnTo: unknown, origin: string): string | null {\n if (typeof returnTo !== 'string' || !returnTo || returnTo.length > RETURN_TO_MAX_CHARS) return null\n if (returnTo.startsWith('/') && (returnTo.startsWith('//') || returnTo.startsWith('/\\\\'))) return null\n let url: URL\n try {\n url = new URL(returnTo, origin)\n } catch {\n return null\n }\n if (url.origin !== origin) return null\n if (url.pathname.startsWith(CONNECT_CALLBACK_PATH)) return null\n return `${url.pathname}${url.search}`\n}\n"],"names":["platformConsoleOrigin","createHash","createHmac","randomBytes","consumeOnce","tokenSigningSecret","safeEqual","PLUGIN_CONNECTIONS_API_ROUTES","connectionsCollection","CONNECT_STATE_TTL_MS","STATE_VERSION","STATE_CONTEXT","STATE_MAX_CHARS","RETURN_TO_MAX_CHARS","CONNECT_CALLBACK_PATH","connectCallback","hmac","value","update","digest","mintConnectState","input","claims","hostId","orgId","uid","listingId","connectionId","nonce","toString","exp","nowMs","returnTo","wire","h","o","u","l","c","n","e","r","payload","Buffer","from","JSON","stringify","state","readConnectState","invalid","ok","refusal","length","parts","split","signature","expected","parse","text","Number","isFinite","connectStateDocId","slice","connectStateRef","firestore","doc","recordConnectState","set","kind","nonceDigest","redirectUri","expiresAtMs","createdAtMs","consumeConnectState","result","data","accept","reason","String","remove","stripTrailingSlash","replace","canonicalConsoleOrigin","raw","url","URL","protocol","origin","consoleOriginFor","requestUrl","process","env","hostname","connectRedirectUri","consolePath","startsWith","pathname","search"],"mappings":"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"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginConnectionDeclaration, type PluginConnectionRequest, type PluginConnectionResource } from '@aglyn/aglyn/app-utils/plugin-connections';
18
+ import type { PluginConnectionChoice } from '../../model/plugin-connections';
19
+ /**
20
+ * HOW THE PLATFORM TALKS TO A DECLARED CONNECTION'S PROVIDER (AGL-4193).
21
+ *
22
+ * Every outbound request a connection causes goes through here: the code
23
+ * exchange and the refresh at the declared `tokenUrl`, the revoke at the
24
+ * declared `revocationUrl`, and the templated API requests, which
25
+ * `preparePluginConnectionRequest` refuses to send anywhere but the declared
26
+ * `apiHosts`. One runtime object holds the fetch, the clock and the sleep, so
27
+ * a spec swaps them in one place and no request leaves the process.
28
+ *
29
+ * Redirects are NOT followed: a provider answering a token or API request
30
+ * with a redirect would otherwise carry the grant to a host nobody declared.
31
+ * Answers are read up to a ceiling, so a hostile or broken endpoint cannot
32
+ * make the console buffer an unbounded body.
33
+ */
34
+ export interface PluginConnectionRuntime {
35
+ fetch: typeof fetch;
36
+ now: () => number;
37
+ sleep: (ms: number) => Promise<void>;
38
+ }
39
+ export declare const pluginConnectionRuntime: PluginConnectionRuntime;
40
+ /** Why a provider call failed: the grant is gone (`auth`), try later (`transient`), or the answer is unusable (`refused`). */
41
+ export type PluginConnectionErrorCode = 'auth' | 'transient' | 'refused';
42
+ export declare class PluginConnectionProviderError extends Error {
43
+ readonly code: PluginConnectionErrorCode;
44
+ readonly status: number;
45
+ constructor(code: PluginConnectionErrorCode, message: string, status?: number);
46
+ }
47
+ /** The publisher's OAuth app, opened from its sealed record. */
48
+ export interface PluginConnectionClient {
49
+ clientId: string;
50
+ clientSecret: string;
51
+ }
52
+ /** A token endpoint's answer, normalized. */
53
+ export interface PluginConnectionTokenSet {
54
+ accessToken: string;
55
+ /** `null` when the provider issued none (or kept the one it issued before). */
56
+ refreshToken: string | null;
57
+ /** Epoch ms; an hour from now when the provider did not say. */
58
+ accessExpiresAtMs: number;
59
+ /** The scopes granted, when the provider said; else the ones asked for. */
60
+ scopes: string[];
61
+ }
62
+ /** Bytes of an answer read at most. */
63
+ export declare const PLUGIN_CONNECTION_MAX_RESPONSE_BYTES: number;
64
+ /** Exchanges an authorization code at the declared token endpoint. */
65
+ export declare function exchangePluginConnectionCode(connection: PluginConnectionDeclaration, client: PluginConnectionClient, input: {
66
+ code: string;
67
+ redirectUri: string;
68
+ }): Promise<PluginConnectionTokenSet>;
69
+ /** Refreshes a grant at the declared token endpoint. */
70
+ export declare function refreshPluginConnectionToken(connection: PluginConnectionDeclaration, client: PluginConnectionClient, refreshToken: string): Promise<PluginConnectionTokenSet>;
71
+ /** Revokes a grant at the declared revocation endpoint, when there is one. Best effort; throws on failure. */
72
+ export declare function revokePluginConnectionToken(connection: PluginConnectionDeclaration, client: PluginConnectionClient | null, token: string): Promise<boolean>;
73
+ /**
74
+ * One templated API request with the site's access token, filled from its
75
+ * choices; answers the parsed JSON. A 401 or 403 is `auth` (the caller may
76
+ * refresh once and retry), anything else not 2xx is `refused`.
77
+ */
78
+ export declare function pluginConnectionApiRequest(connection: PluginConnectionDeclaration, request: PluginConnectionRequest, input: {
79
+ accessToken: string;
80
+ choices: Readonly<Record<string, string>>;
81
+ cursor?: string | null;
82
+ }): Promise<unknown>;
83
+ /**
84
+ * Every item of a paginated request, up to `maxItems` and the declared page
85
+ * ceiling. Exported for the sync, which reads its requests the same way.
86
+ */
87
+ export declare function pluginConnectionApiItems(connection: PluginConnectionDeclaration, request: PluginConnectionRequest, input: {
88
+ accessToken: string;
89
+ choices: Readonly<Record<string, string>>;
90
+ maxItems: number;
91
+ }): Promise<{
92
+ items: unknown[];
93
+ pages: unknown[];
94
+ }>;
95
+ /** The resource's items as the picker shows them: an id and a label, each a plain bounded string. */
96
+ export declare function listPluginConnectionResource(connection: PluginConnectionDeclaration, resource: PluginConnectionResource, input: {
97
+ accessToken: string;
98
+ choices: Readonly<Record<string, string>>;
99
+ }): Promise<PluginConnectionChoice[]>;
@@ -0,0 +1,253 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { preparePluginConnectionRequest, readPluginConnectionPath } from "@aglyn/aglyn/app-utils/plugin-connections";
18
+ export const pluginConnectionRuntime = {
19
+ fetch: (input, init)=>globalThis.fetch(input, init),
20
+ now: ()=>Date.now(),
21
+ sleep: (ms)=>new Promise((resolve)=>setTimeout(resolve, ms))
22
+ };
23
+ export class PluginConnectionProviderError extends Error {
24
+ constructor(code, message, status = 0){
25
+ super(message), this.code = code, this.status = status;
26
+ this.name = 'PluginConnectionProviderError';
27
+ }
28
+ }
29
+ /** Bytes of an answer read at most. */ export const PLUGIN_CONNECTION_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
30
+ const ATTEMPT_TIMEOUT_MS = 30000;
31
+ const RETRIES = 2;
32
+ const BASE_BACKOFF_MS = 500;
33
+ const DEFAULT_ACCESS_LIFETIME_MS = 60 * 60 * 1000;
34
+ /** Reads an answer's body, refusing one past the ceiling. */ async function readBody(response) {
35
+ const declared = Number(response.headers.get('content-length'));
36
+ if (Number.isFinite(declared) && declared > PLUGIN_CONNECTION_MAX_RESPONSE_BYTES) {
37
+ throw new PluginConnectionProviderError('refused', 'The provider’s answer is too large.', response.status);
38
+ }
39
+ const text = await response.text();
40
+ if (text.length > PLUGIN_CONNECTION_MAX_RESPONSE_BYTES) {
41
+ throw new PluginConnectionProviderError('refused', 'The provider’s answer is too large.', response.status);
42
+ }
43
+ return text;
44
+ }
45
+ const parseJson = (text)=>{
46
+ try {
47
+ return JSON.parse(text);
48
+ } catch (unused) {
49
+ return null;
50
+ }
51
+ };
52
+ /** The provider's own sentence for a refusal, cut short; never a token. */ function providerSentence(body, status) {
53
+ if (body && typeof body === 'object') {
54
+ const record = body;
55
+ if (typeof record['error_description'] === 'string') return record['error_description'].slice(0, 300);
56
+ const error = record['error'];
57
+ if (typeof error === 'string') return error.slice(0, 300);
58
+ if (error && typeof error === 'object' && typeof error['message'] === 'string') {
59
+ return String(error['message']).slice(0, 300);
60
+ }
61
+ }
62
+ return `HTTP ${status}`;
63
+ }
64
+ const retryable = (status)=>status === 429 || status >= 500;
65
+ /** One request: no redirects, a timeout per attempt, a throttle or server error retried. */ async function send(url, init) {
66
+ var _ref;
67
+ let lastError = null;
68
+ for(let attempt = 0; attempt <= RETRIES; attempt += 1){
69
+ let response = null;
70
+ try {
71
+ response = await pluginConnectionRuntime.fetch(url, _extends({}, init, {
72
+ redirect: 'manual',
73
+ signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS)
74
+ }));
75
+ } catch (error) {
76
+ lastError = error;
77
+ }
78
+ if (response && !retryable(response.status)) {
79
+ if (response.status >= 300 && response.status < 400) {
80
+ throw new PluginConnectionProviderError('refused', 'The provider answered with a redirect, which is not followed.', response.status);
81
+ }
82
+ return {
83
+ status: response.status,
84
+ body: parseJson(await readBody(response))
85
+ };
86
+ }
87
+ if (attempt === RETRIES) {
88
+ if (response) {
89
+ const body = parseJson(await readBody(response).catch(()=>''));
90
+ throw new PluginConnectionProviderError('transient', providerSentence(body, response.status), response.status);
91
+ }
92
+ break;
93
+ }
94
+ await pluginConnectionRuntime.sleep(BASE_BACKOFF_MS * 2 ** attempt);
95
+ }
96
+ throw new PluginConnectionProviderError('transient', `The provider could not be reached: ${String((_ref = lastError == null ? void 0 : lastError.message) != null ? _ref : lastError).slice(0, 200)}`);
97
+ }
98
+ /** The token endpoint's answer, or the refusal: `invalid_grant` and a 401 mean the grant is gone. */ function readTokenAnswer(answer, connection) {
99
+ const body = answer.body && typeof answer.body === 'object' ? answer.body : {};
100
+ if (answer.status < 200 || answer.status >= 300 || typeof body['access_token'] !== 'string' || !body['access_token']) {
101
+ const sentence = providerSentence(body, answer.status);
102
+ const grantGone = body['error'] === 'invalid_grant' || answer.status === 401 || body['error'] === 'unauthorized_client';
103
+ throw new PluginConnectionProviderError(grantGone ? 'auth' : 'refused', sentence, answer.status);
104
+ }
105
+ const expiresIn = Number(body['expires_in']);
106
+ const scope = typeof body['scope'] === 'string' ? body['scope'] : '';
107
+ return {
108
+ accessToken: body['access_token'],
109
+ refreshToken: typeof body['refresh_token'] === 'string' && body['refresh_token'] ? body['refresh_token'] : null,
110
+ accessExpiresAtMs: pluginConnectionRuntime.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : DEFAULT_ACCESS_LIFETIME_MS),
111
+ scopes: scope ? scope.split(/[\s,]+/).filter(Boolean) : [
112
+ ...connection.scopes
113
+ ]
114
+ };
115
+ }
116
+ /** Posts a form to the declared token endpoint, authenticating the publisher's app as declared. */ async function postToken(connection, client, fields) {
117
+ const form = new URLSearchParams(fields);
118
+ const headers = {
119
+ 'Content-Type': 'application/x-www-form-urlencoded',
120
+ Accept: 'application/json'
121
+ };
122
+ if (connection.tokenAuthMethod === 'basic') {
123
+ const pair = `${encodeURIComponent(client.clientId)}:${encodeURIComponent(client.clientSecret)}`;
124
+ headers['Authorization'] = `Basic ${Buffer.from(pair, 'utf8').toString('base64')}`;
125
+ } else {
126
+ form.set('client_id', client.clientId);
127
+ form.set('client_secret', client.clientSecret);
128
+ }
129
+ return send(connection.tokenUrl, {
130
+ method: 'POST',
131
+ headers,
132
+ body: form.toString()
133
+ });
134
+ }
135
+ /** Exchanges an authorization code at the declared token endpoint. */ export async function exchangePluginConnectionCode(connection, client, input) {
136
+ const answer = await postToken(connection, client, {
137
+ grant_type: 'authorization_code',
138
+ code: input.code,
139
+ redirect_uri: input.redirectUri
140
+ });
141
+ return readTokenAnswer(answer, connection);
142
+ }
143
+ /** Refreshes a grant at the declared token endpoint. */ export async function refreshPluginConnectionToken(connection, client, refreshToken) {
144
+ const answer = await postToken(connection, client, {
145
+ grant_type: 'refresh_token',
146
+ refresh_token: refreshToken
147
+ });
148
+ return readTokenAnswer(answer, connection);
149
+ }
150
+ /** Revokes a grant at the declared revocation endpoint, when there is one. Best effort; throws on failure. */ export async function revokePluginConnectionToken(connection, client, token) {
151
+ if (!connection.revocationUrl) return false;
152
+ const form = new URLSearchParams({
153
+ token
154
+ });
155
+ if (client && connection.tokenAuthMethod !== 'basic') {
156
+ form.set('client_id', client.clientId);
157
+ form.set('client_secret', client.clientSecret);
158
+ }
159
+ const headers = {
160
+ 'Content-Type': 'application/x-www-form-urlencoded'
161
+ };
162
+ if (client && connection.tokenAuthMethod === 'basic') {
163
+ const pair = `${encodeURIComponent(client.clientId)}:${encodeURIComponent(client.clientSecret)}`;
164
+ headers['Authorization'] = `Basic ${Buffer.from(pair, 'utf8').toString('base64')}`;
165
+ }
166
+ const answer = await send(connection.revocationUrl, {
167
+ method: 'POST',
168
+ headers,
169
+ body: form.toString()
170
+ });
171
+ if (answer.status >= 200 && answer.status < 300) return true;
172
+ throw new PluginConnectionProviderError('refused', providerSentence(answer.body, answer.status), answer.status);
173
+ }
174
+ /**
175
+ * One templated API request with the site's access token, filled from its
176
+ * choices; answers the parsed JSON. A 401 or 403 is `auth` (the caller may
177
+ * refresh once and retry), anything else not 2xx is `refused`.
178
+ */ export async function pluginConnectionApiRequest(connection, request, input) {
179
+ var _input_cursor;
180
+ const prepared = preparePluginConnectionRequest(request, {
181
+ apiHosts: connection.apiHosts,
182
+ choices: input.choices,
183
+ cursor: (_input_cursor = input.cursor) != null ? _input_cursor : null
184
+ });
185
+ if (prepared.ok === false) throw new PluginConnectionProviderError('refused', prepared.error);
186
+ const headers = {
187
+ Authorization: `Bearer ${input.accessToken}`,
188
+ Accept: 'application/json'
189
+ };
190
+ if (prepared.request.body !== undefined) headers['Content-Type'] = 'application/json';
191
+ const answer = await send(prepared.request.url, _extends({
192
+ method: prepared.request.method,
193
+ headers
194
+ }, prepared.request.body !== undefined ? {
195
+ body: prepared.request.body
196
+ } : {}));
197
+ if (answer.status === 401 || answer.status === 403) {
198
+ throw new PluginConnectionProviderError('auth', providerSentence(answer.body, answer.status), answer.status);
199
+ }
200
+ if (answer.status < 200 || answer.status >= 300) {
201
+ throw new PluginConnectionProviderError('refused', providerSentence(answer.body, answer.status), answer.status);
202
+ }
203
+ return answer.body;
204
+ }
205
+ /**
206
+ * Every item of a paginated request, up to `maxItems` and the declared page
207
+ * ceiling. Exported for the sync, which reads its requests the same way.
208
+ */ export async function pluginConnectionApiItems(connection, request, input) {
209
+ var _ref;
210
+ var _request_pagination;
211
+ const items = [];
212
+ const pages = [];
213
+ let cursor = null;
214
+ const maxPages = (_ref = (_request_pagination = request.pagination) == null ? void 0 : _request_pagination.maxPages) != null ? _ref : 1;
215
+ for(let page = 0; page < maxPages; page += 1){
216
+ const body = await pluginConnectionApiRequest(connection, request, _extends({}, input, {
217
+ cursor
218
+ }));
219
+ pages.push(body);
220
+ const list = request.itemsPath ? readPluginConnectionPath(body, request.itemsPath) : undefined;
221
+ if (Array.isArray(list)) items.push(...list.slice(0, Math.max(0, input.maxItems - items.length)));
222
+ if (items.length >= input.maxItems || !request.pagination) break;
223
+ const next = readPluginConnectionPath(body, request.pagination.cursorPath);
224
+ if (typeof next !== 'string' || !next || next.length > 2048 || next === cursor) break;
225
+ cursor = next;
226
+ }
227
+ return {
228
+ items,
229
+ pages
230
+ };
231
+ }
232
+ /** The resource's items as the picker shows them: an id and a label, each a plain bounded string. */ export async function listPluginConnectionResource(connection, resource, input) {
233
+ const { items } = await pluginConnectionApiItems(connection, resource.request, _extends({}, input, {
234
+ maxItems: resource.maxItems
235
+ }));
236
+ const out = [];
237
+ const seen = new Set();
238
+ for (const item of items){
239
+ const id = readPluginConnectionPath(item, resource.idPath);
240
+ const label = readPluginConnectionPath(item, resource.labelPath);
241
+ if (typeof id !== 'string' && typeof id !== 'number') continue;
242
+ const idText = String(id).slice(0, 256);
243
+ if (!idText || seen.has(idText)) continue;
244
+ seen.add(idText);
245
+ out.push({
246
+ id: idText,
247
+ label: typeof label === 'string' && label.trim() ? label.trim().slice(0, 200) : idText
248
+ });
249
+ }
250
+ return out;
251
+ }
252
+
253
+ //# sourceMappingURL=provider-http.js.map