@floegence/redevplugin-ui 0.2.1 → 0.3.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/dist/surface.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PluginBridgeError } from "./errors.js";
1
+ import { PluginBridgeError, pluginBridgeErrorCodes } from "./errors.js";
2
2
  import { pluginUIProtocolVersion } from "./contracts.gen.js";
3
3
  import { opaqueSurfaceAllowedTags, opaqueSurfaceGlobalAttributes, opaqueSurfaceRenderLimits, opaqueSurfaceSafeInputTypes, opaqueSurfaceTagAttributes, } from "./opaque-surface-policy.gen.js";
4
4
  import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readHostEnvelope, } from "./http.js";
@@ -9,9 +9,32 @@ const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridgeV2";
9
9
  const maxPendingPluginBridgeRequests = 256;
10
10
  const maxPluginBridgeMessageBytes = 256 * 1024;
11
11
  const maxRetainedPluginStreamHandles = 128;
12
+ const streamCredentialInvalidatingErrorCodes = new Set([
13
+ "PLUGIN_BRIDGE_DISPOSED",
14
+ "PLUGIN_BRIDGE_HANDSHAKE_FAILED",
15
+ "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED",
16
+ "PLUGIN_BRIDGE_TIMEOUT",
17
+ "PLUGIN_CONTRACT_MISMATCH",
18
+ "PLUGIN_GATEWAY_TOKEN_CHANNEL_MISMATCH",
19
+ "PLUGIN_GATEWAY_TOKEN_INVALID",
20
+ "PLUGIN_GATEWAY_TOKEN_REPLAYED",
21
+ "PLUGIN_GRANT_INVALID",
22
+ "PLUGIN_LEASE_INVALID",
23
+ "PLUGIN_LEASE_REPLAYED",
24
+ "PLUGIN_STATE_VERSION_MISMATCH",
25
+ "PLUGIN_STREAM_CANCELLED",
26
+ "PLUGIN_STREAM_TICKET_INVALID",
27
+ "PLUGIN_TOKEN_EXPIRED",
28
+ "PLUGIN_TOKEN_REPLAY",
29
+ ]);
12
30
  const maxOpaqueSurfaceLazyAssets = 128;
13
31
  const maxOpaqueSurfaceLazyBytes = 32 * 1024 * 1024;
14
32
  const maxConcurrentAssetReads = 4;
33
+ const pluginBridgeErrorCodeSet = new Set(pluginBridgeErrorCodes);
34
+ const hostCapabilityIDPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._-]*$");
35
+ const canonicalSemverPattern = new RegExp("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
36
+ const lowercaseSHA256Pattern = new RegExp("^[0-9a-f]{64}$");
37
+ const businessErrorCodePattern = new RegExp("^[A-Z][A-Z0-9_]*$");
15
38
  export class PluginBridgeClient {
16
39
  surfaceHandle;
17
40
  timeoutMs;
@@ -80,6 +103,19 @@ export class PluginBridgeClient {
80
103
  stream_handle: streamHandle,
81
104
  });
82
105
  }
106
+ cancelOperation(operationID, reason) {
107
+ this.#assertActive();
108
+ if (!validOpaqueHandle(operationID, "operation") || (reason !== undefined && (typeof reason !== "string" || reason.length > 256))) {
109
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin operation cancellation is invalid");
110
+ }
111
+ const id = this.#requestID("operation");
112
+ return this.#request(id, removeUndefined({
113
+ type: "redevplugin.bridge.operation.cancel",
114
+ id,
115
+ operation_id: operationID,
116
+ reason,
117
+ }));
118
+ }
83
119
  render(tree) {
84
120
  this.#assertActive();
85
121
  const id = this.#requestID("render");
@@ -176,16 +212,20 @@ export class PluginBridgeClient {
176
212
  if (this.#disposed || !messageWithinLimit(event.data))
177
213
  return;
178
214
  const data = event.data;
179
- if (isBridgeResponse(data)) {
215
+ if (isBridgeResponseCandidate(data)) {
180
216
  const pending = this.#pending.get(data.id);
181
217
  if (!pending)
182
218
  return;
183
219
  this.#pending.delete(data.id);
184
220
  clearTimeout(pending.timer);
221
+ if (!isBridgeResponse(data)) {
222
+ pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin bridge response ${data.id} is invalid`));
223
+ return;
224
+ }
185
225
  if (data.ok)
186
226
  pending.resolve(data.data);
187
227
  else
188
- pending.reject(new PluginBridgeError(data.error_code, data.error));
228
+ pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details));
189
229
  return;
190
230
  }
191
231
  if (isLifecycleMessage(data)) {
@@ -465,7 +505,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
465
505
  let currentDocument;
466
506
  let workerReady = false;
467
507
  const pendingWorkerRequests = new Set();
468
- const requestSequence = { rpc: 0, stream: 0, render: 0 };
508
+ const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0 };
469
509
  let renderWindowStartedAt = 0;
470
510
  let renderCount = 0;
471
511
  const pendingAssets = new Map();
@@ -735,7 +775,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
735
775
  const validCall = (value) => exactKeys(value, ["type", "request"]) && value.type === "redevplugin.bridge.call" && isRecord(value.request) && Object.keys(value.request).every((key) => ["id", "method", "params"].includes(key)) && typeof value.request.id === "string" && value.request.id.length <= 128 && typeof value.request.method === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value.request.method) && (value.request.params === undefined || isRecord(value.request.params));
736
776
  const requestID = (value, expectedKind) => {
737
777
  if (typeof value !== "string") return undefined;
738
- const match = /^(rpc|stream|render)_([1-9][0-9]{0,15})$/.exec(value);
778
+ const match = /^(rpc|stream|render|operation)_([1-9][0-9]{0,15})$/.exec(value);
739
779
  if (!match || match[1] !== expectedKind) return undefined;
740
780
  const sequence = Number(match[2]);
741
781
  return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
@@ -783,6 +823,13 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
783
823
  sendParent(message);
784
824
  return;
785
825
  }
826
+ if (isRecord(message) && Object.keys(message).every((key) => ["type", "id", "operation_id", "reason"].includes(key)) &&
827
+ message.type === "redevplugin.bridge.operation.cancel" && typeof message.id === "string" &&
828
+ validOpaqueHandle(message.operation_id, "operation") && (message.reason === undefined || (typeof message.reason === "string" && message.reason.length <= 256))) {
829
+ if (!acceptWorkerRequest(message.id, "operation")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
830
+ sendParent(message);
831
+ return;
832
+ }
786
833
  if (exactKeys(message, ["type", "id", "tree"]) && message.type === "redevplugin.ui.render" && typeof message.id === "string") {
787
834
  if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
788
835
  if (!renderRateAllowed()) {
@@ -1131,6 +1178,10 @@ export class PluginSurfaceHost {
1131
1178
  await this.#handleStreamRead(data.id, data.stream_handle);
1132
1179
  return;
1133
1180
  }
1181
+ if (isOperationCancelMessage(data)) {
1182
+ await this.#handleOperationCancel(data);
1183
+ return;
1184
+ }
1134
1185
  if (isAssetReadMessage(data)) {
1135
1186
  await this.#handleAssetRead(data);
1136
1187
  }
@@ -1158,7 +1209,7 @@ export class PluginSurfaceHost {
1158
1209
  await this.#handleConfirmationRequired(request, bridgeError, controller.signal);
1159
1210
  return;
1160
1211
  }
1161
- this.#postError(request.id, bridgeError.errorCode, bridgeError.message);
1212
+ this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
1162
1213
  }
1163
1214
  finally {
1164
1215
  this.#pendingRequestControllers.delete(request.id);
@@ -1166,7 +1217,7 @@ export class PluginSurfaceHost {
1166
1217
  }
1167
1218
  async #handleConfirmationRequired(request, originalError, signal) {
1168
1219
  if (!this.#confirm) {
1169
- this.#postError(request.id, originalError.errorCode, originalError.message);
1220
+ this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details);
1170
1221
  return;
1171
1222
  }
1172
1223
  try {
@@ -1184,6 +1235,9 @@ export class PluginSurfaceHost {
1184
1235
  if (signal.aborted || this.#disposed)
1185
1236
  return;
1186
1237
  if (!confirmationDecisionAccepted(decision)) {
1238
+ await this.#rejectConfirmation(confirmation.confirmation_id, signal);
1239
+ if (signal.aborted || this.#disposed)
1240
+ return;
1187
1241
  this.#postError(request.id, "PLUGIN_CONFIRMATION_REJECTED", "Plugin method confirmation was rejected");
1188
1242
  return;
1189
1243
  }
@@ -1195,13 +1249,12 @@ export class PluginSurfaceHost {
1195
1249
  if (signal.aborted || this.#disposed)
1196
1250
  return;
1197
1251
  const bridgeError = toBridgeError(error, "PLUGIN_PERMISSION_DENIED");
1198
- this.#postError(request.id, bridgeError.errorCode, bridgeError.message);
1252
+ this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
1199
1253
  }
1200
1254
  }
1201
1255
  async #handleStreamRead(id, streamHandle) {
1202
1256
  const credential = this.#streamCredentials.get(streamHandle);
1203
- this.#streamCredentials.delete(streamHandle);
1204
- if (!credential) {
1257
+ if (!credential || credential.reading) {
1205
1258
  this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid or already consumed");
1206
1259
  return;
1207
1260
  }
@@ -1209,16 +1262,39 @@ export class PluginSurfaceHost {
1209
1262
  this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is expired");
1210
1263
  return;
1211
1264
  }
1265
+ credential.reading = true;
1212
1266
  const controller = this.#registerPendingRequest(id);
1213
1267
  try {
1214
- const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`, { stream_id: credential.streamID, stream_ticket: credential.streamTicket }, controller.signal);
1215
- if (!isStreamReadResult(result, credential.streamID)) {
1268
+ const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`, () => ({ stream_id: credential.streamID, stream_ticket: credential.streamTicket }), controller.signal);
1269
+ if (!isStreamReadResult(result, credential.streamID, credential.lastSequence)) {
1216
1270
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream endpoint returned an invalid response");
1217
1271
  }
1272
+ const lastSequence = result.events.length > 0 ? result.events[result.events.length - 1].sequence : credential.lastSequence;
1273
+ if (result.done) {
1274
+ this.#streamCredentials.delete(streamHandle);
1275
+ }
1276
+ else {
1277
+ const expiresAtMs = Date.parse(result.next_stream_expires_at);
1278
+ credential.streamTicket = result.next_stream_ticket;
1279
+ credential.expiresAtMs = expiresAtMs;
1280
+ credential.lastSequence = lastSequence;
1281
+ credential.reading = false;
1282
+ }
1218
1283
  if (!controller.signal.aborted && !this.#disposed)
1219
- this.#postResponse(id, result.events);
1284
+ this.#postResponse(id, {
1285
+ events: result.events.map(publicPluginStreamEvent),
1286
+ done: result.done,
1287
+ ...(result.done ? { terminal_status: result.terminal_status } : {}),
1288
+ retry_after_ms: result.events.length === 0 && !result.done ? 25 : 0,
1289
+ });
1220
1290
  }
1221
1291
  catch (error) {
1292
+ if (streamReadFailureInvalidatesCredential(error)) {
1293
+ this.#streamCredentials.delete(streamHandle);
1294
+ }
1295
+ else {
1296
+ credential.reading = false;
1297
+ }
1222
1298
  if (controller.signal.aborted || this.#disposed)
1223
1299
  return;
1224
1300
  const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
@@ -1228,6 +1304,24 @@ export class PluginSurfaceHost {
1228
1304
  this.#pendingRequestControllers.delete(id);
1229
1305
  }
1230
1306
  }
1307
+ async #handleOperationCancel(message) {
1308
+ const controller = this.#registerPendingRequest(message.id);
1309
+ try {
1310
+ await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/operations/cancel`, { operation_id: message.operation_id, bridge_channel_id: this.bridgeChannelId, reason: message.reason }, controller.signal);
1311
+ this.#releaseOperationStreams(message.operation_id);
1312
+ if (!controller.signal.aborted && !this.#disposed)
1313
+ this.#postResponse(message.id, undefined);
1314
+ }
1315
+ catch (error) {
1316
+ if (controller.signal.aborted || this.#disposed)
1317
+ return;
1318
+ const bridgeError = toBridgeError(error, "PLUGIN_OPERATION_BLOCKED");
1319
+ this.#postError(message.id, bridgeError.errorCode, bridgeError.message);
1320
+ }
1321
+ finally {
1322
+ this.#pendingRequestControllers.delete(message.id);
1323
+ }
1324
+ }
1231
1325
  async #handleAssetRead(message) {
1232
1326
  if (this.#activeAssetReads >= maxConcurrentAssetReads) {
1233
1327
  throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin asset reads exceed the concurrency limit");
@@ -1238,11 +1332,11 @@ export class PluginSurfaceHost {
1238
1332
  }
1239
1333
  this.#activeAssetReads += 1;
1240
1334
  try {
1241
- const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/assets/read`, {
1335
+ const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/assets/read`, () => ({
1242
1336
  asset_session: this.#assetSession,
1243
1337
  asset_session_id: this.#assetSessionID,
1244
1338
  binding_id: asset.binding_id,
1245
- });
1339
+ }));
1246
1340
  if (!isAssetReadResult(result) || result.path !== asset.path || result.sha256 !== asset.sha256 || result.content_type !== asset.content_type) {
1247
1341
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin asset endpoint returned mismatched content");
1248
1342
  }
@@ -1278,7 +1372,7 @@ export class PluginSurfaceHost {
1278
1372
  request_hash: result.request_hash,
1279
1373
  };
1280
1374
  if (result.stream_id || result.stream_ticket || result.stream_ticket_id || result.stream_expires_at) {
1281
- if (!result.stream_id || !result.stream_ticket || !result.stream_ticket_id || !result.stream_expires_at) {
1375
+ if (!result.operation_id || !result.stream_id || !result.stream_ticket || !result.stream_ticket_id || !result.stream_expires_at) {
1282
1376
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin RPC returned incomplete stream credentials");
1283
1377
  }
1284
1378
  const expiresAtMs = Date.parse(result.stream_expires_at);
@@ -1290,7 +1384,14 @@ export class PluginSurfaceHost {
1290
1384
  throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin surface retained too many unread stream handles");
1291
1385
  }
1292
1386
  const handle = randomOpaqueHandle("stream");
1293
- this.#streamCredentials.set(handle, { streamID: result.stream_id, streamTicket: result.stream_ticket, expiresAtMs });
1387
+ this.#streamCredentials.set(handle, {
1388
+ streamID: result.stream_id,
1389
+ operationID: result.operation_id,
1390
+ streamTicket: result.stream_ticket,
1391
+ expiresAtMs,
1392
+ lastSequence: 0,
1393
+ reading: false,
1394
+ });
1294
1395
  publicResult.stream_handle = handle;
1295
1396
  }
1296
1397
  return removeUndefined(publicResult);
@@ -1301,11 +1402,28 @@ export class PluginSurfaceHost {
1301
1402
  this.#streamCredentials.delete(handle);
1302
1403
  }
1303
1404
  }
1405
+ #releaseOperationStreams(operationID) {
1406
+ for (const [handle, credential] of this.#streamCredentials) {
1407
+ if (credential.operationID === operationID)
1408
+ this.#streamCredentials.delete(handle);
1409
+ }
1410
+ }
1304
1411
  #callRPC(request, confirmationID, signal) {
1305
- return this.#postJSON("/_redevplugin/api/plugins/rpc", this.#rpcBody(request, confirmationID), signal);
1412
+ return this.#postJSON("/_redevplugin/api/plugins/rpc", () => this.#rpcBody(request, confirmationID), signal);
1306
1413
  }
1307
1414
  #prepareConfirmation(request, signal) {
1308
- return this.#postJSON("/_redevplugin/api/plugins/confirm", this.#rpcBody(request), signal);
1415
+ return this.#postJSON("/_redevplugin/api/plugins/confirm", () => this.#rpcBody(request), signal);
1416
+ }
1417
+ async #rejectConfirmation(confirmationID, signal) {
1418
+ const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/confirmations/reject`, () => ({
1419
+ plugin_instance_id: this.bootstrap.pluginInstanceId,
1420
+ bridge_channel_id: this.bridgeChannelId,
1421
+ plugin_gateway_token: this.#gatewayToken,
1422
+ confirmation_id: confirmationID,
1423
+ }), signal);
1424
+ if (!hasExactKeys(result, ["rejected"]) || result.rejected !== true) {
1425
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin confirmation rejection endpoint returned an invalid response");
1426
+ }
1309
1427
  }
1310
1428
  #rpcBody(request, confirmationID) {
1311
1429
  const body = {
@@ -1356,9 +1474,10 @@ export class PluginSurfaceHost {
1356
1474
  else if (this.#leaseRenewalPromise)
1357
1475
  await this.#leaseRenewalPromise;
1358
1476
  }
1477
+ const requestBody = typeof body === "function" ? body() : body;
1359
1478
  this.#activeTransportRequests += 1;
1360
1479
  try {
1361
- return await this.#fetchJSON(path, body, signal);
1480
+ return await this.#fetchJSON(path, requestBody, signal);
1362
1481
  }
1363
1482
  finally {
1364
1483
  this.#activeTransportRequests -= 1;
@@ -1507,8 +1626,16 @@ export class PluginSurfaceHost {
1507
1626
  #postResponse(id, data) {
1508
1627
  this.#postToRenderer({ type: "redevplugin.bridge.response", id, ok: true, data });
1509
1628
  }
1510
- #postError(id, errorCode, error) {
1511
- this.#postToRenderer({ type: "redevplugin.bridge.response", id, ok: false, error_code: errorCode, error });
1629
+ #postError(id, errorCode, error, details) {
1630
+ const errorDetails = details === undefined ? undefined : normalizePluginJSONObject(details);
1631
+ this.#postToRenderer(removeUndefined({
1632
+ type: "redevplugin.bridge.response",
1633
+ id,
1634
+ ok: false,
1635
+ error_code: errorCode,
1636
+ error,
1637
+ error_details: errorDetails,
1638
+ }));
1512
1639
  }
1513
1640
  #postToRenderer(message) {
1514
1641
  if (!this.#port) {
@@ -1743,6 +1870,13 @@ function isStreamReadMessage(value) {
1743
1870
  validBridgeRequestID(value.id, "stream") &&
1744
1871
  validOpaqueHandle(value.stream_handle, "stream");
1745
1872
  }
1873
+ function isOperationCancelMessage(value) {
1874
+ return hasAllowedKeys(value, ["type", "id", "operation_id", "reason"]) &&
1875
+ value.type === "redevplugin.bridge.operation.cancel" &&
1876
+ validBridgeRequestID(value.id, "operation") &&
1877
+ validOpaqueHandle(value.operation_id, "operation") &&
1878
+ (value.reason === undefined || (typeof value.reason === "string" && value.reason.length <= 256));
1879
+ }
1746
1880
  function isBridgeCancelMessage(value) {
1747
1881
  return hasExactKeys(value, ["type", "id"]) &&
1748
1882
  value.type === "redevplugin.bridge.cancel" &&
@@ -1756,13 +1890,54 @@ function isAssetReadMessage(value) {
1756
1890
  validPackagePath(value.path) &&
1757
1891
  validSHA256(value.sha256);
1758
1892
  }
1893
+ function isBridgeResponseCandidate(value) {
1894
+ return isRecord(value) && value.type === "redevplugin.bridge.response" && typeof value.id === "string";
1895
+ }
1759
1896
  function isBridgeResponse(value) {
1760
- if (!isRecord(value) || value.type !== "redevplugin.bridge.response" || typeof value.id !== "string")
1897
+ if (!isBridgeResponseCandidate(value) || !validBridgeRequestID(value.id))
1761
1898
  return false;
1762
1899
  if (value.ok === true)
1763
1900
  return Object.keys(value).every((key) => ["type", "id", "ok", "data"].includes(key));
1764
- return value.ok === false && typeof value.error_code === "string" && typeof value.error === "string" &&
1765
- Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error"].includes(key));
1901
+ if (value.ok !== false || typeof value.error_code !== "string" || !pluginBridgeErrorCodeSet.has(value.error_code) ||
1902
+ typeof value.error !== "string" || value.error.length > 4096 ||
1903
+ !Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details"].includes(key))) {
1904
+ return false;
1905
+ }
1906
+ if (value.error_code === "PLUGIN_CAPABILITY_ERROR")
1907
+ return isCapabilityBusinessErrorDetails(value.error_details);
1908
+ if (value.error_details === undefined)
1909
+ return true;
1910
+ try {
1911
+ return Object.keys(normalizePluginJSONObject(value.error_details)).length <= 8;
1912
+ }
1913
+ catch {
1914
+ return false;
1915
+ }
1916
+ }
1917
+ function isCapabilityBusinessErrorDetails(value) {
1918
+ if (!hasAllowedKeys(value, [
1919
+ "capability_id",
1920
+ "capability_version",
1921
+ "detail_schema_sha256",
1922
+ "business_error_code",
1923
+ "business_error_details",
1924
+ ]))
1925
+ return false;
1926
+ if (typeof value.capability_id !== "string" || !hostCapabilityIDPattern.test(value.capability_id) ||
1927
+ typeof value.capability_version !== "string" || !canonicalSemverPattern.test(value.capability_version) ||
1928
+ typeof value.detail_schema_sha256 !== "string" || !lowercaseSHA256Pattern.test(value.detail_schema_sha256) ||
1929
+ typeof value.business_error_code !== "string" || !businessErrorCodePattern.test(value.business_error_code)) {
1930
+ return false;
1931
+ }
1932
+ if (value.business_error_details === undefined)
1933
+ return true;
1934
+ try {
1935
+ normalizePluginJSONObject(value.business_error_details);
1936
+ return true;
1937
+ }
1938
+ catch {
1939
+ return false;
1940
+ }
1766
1941
  }
1767
1942
  function isLifecycleMessage(value) {
1768
1943
  return hasExactKeys(value, ["type", "event"]) &&
@@ -1801,10 +1976,25 @@ function isGatewayTokenResult(value) {
1801
1976
  typeof value.asset_session_id === "string" && value.asset_session_id.length > 0 &&
1802
1977
  typeof value.issued_at === "string" && typeof value.expires_at === "string";
1803
1978
  }
1804
- function isStreamReadResult(value, expectedStreamID) {
1805
- if (!hasExactKeys(value, ["events"]) || !Array.isArray(value.events))
1979
+ function isStreamReadResult(value, expectedStreamID, previousSequence) {
1980
+ if (!isRecord(value) || typeof value.done !== "boolean" || !Array.isArray(value.events))
1981
+ return false;
1982
+ const expectedKeys = value.done
1983
+ ? ["done", "events", "terminal_status"]
1984
+ : ["done", "events", "next_stream_expires_at", "next_stream_ticket", "next_stream_ticket_id"];
1985
+ if (!hasExactKeys(value, expectedKeys))
1806
1986
  return false;
1807
- let previousSequence = 0;
1987
+ if (value.done) {
1988
+ if (!validPluginStreamTerminalStatus(value.terminal_status))
1989
+ return false;
1990
+ }
1991
+ else {
1992
+ const expiresAt = Date.parse(String(value.next_stream_expires_at));
1993
+ if (typeof value.next_stream_ticket !== "string" || value.next_stream_ticket.length === 0 ||
1994
+ typeof value.next_stream_ticket_id !== "string" || value.next_stream_ticket_id.length === 0 ||
1995
+ !Number.isFinite(expiresAt) || expiresAt <= Date.now())
1996
+ return false;
1997
+ }
1808
1998
  let terminal = false;
1809
1999
  for (const event of value.events) {
1810
2000
  if (!isStreamEvent(event) || event.stream_id !== expectedStreamID || event.sequence <= previousSequence || terminal)
@@ -1823,6 +2013,13 @@ function isStreamEvent(value) {
1823
2013
  (value.error == null || typeof value.error === "string") &&
1824
2014
  typeof value.at === "string";
1825
2015
  }
2016
+ function publicPluginStreamEvent(event) {
2017
+ return removeUndefined({ sequence: event.sequence, kind: event.kind, data: event.data, error: event.error, at: event.at });
2018
+ }
2019
+ function validPluginStreamTerminalStatus(value) {
2020
+ return value === "closed" || value === "canceled" || value === "failed" ||
2021
+ value === "orphaned_after_disable" || value === "orphaned_after_uninstall";
2022
+ }
1826
2023
  function validRPCParams(value) {
1827
2024
  if (value === undefined)
1828
2025
  return true;
@@ -1837,7 +2034,7 @@ function validRPCParams(value) {
1837
2034
  const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
1838
2035
  const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
1839
2036
  const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
1840
- const bridgeRequestIDPattern = /^(rpc|stream|render)_([1-9][0-9]{0,15})$/;
2037
+ const bridgeRequestIDPattern = /^(rpc|stream|render|operation)_([1-9][0-9]{0,15})$/;
1841
2038
  function validBridgeRequestID(value, expectedKind) {
1842
2039
  if (typeof value !== "string")
1843
2040
  return false;
@@ -2003,6 +2200,8 @@ function normalizePluginJSONValue(value, depth = 0, state = { nodes: 0, seen: ne
2003
2200
  for (const key of Reflect.ownKeys(value)) {
2004
2201
  if (typeof key !== "string")
2005
2202
  throw new TypeError("JSON object keys must be strings");
2203
+ if (prototypeSensitivePropertyNames.has(key))
2204
+ throw new TypeError("JSON object keys must not alter object prototypes");
2006
2205
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
2007
2206
  if (!descriptor?.enumerable || !("value" in descriptor))
2008
2207
  throw new TypeError("JSON object fields must be enumerable data properties");
@@ -2014,6 +2213,7 @@ function normalizePluginJSONValue(value, depth = 0, state = { nodes: 0, seen: ne
2014
2213
  state.seen.delete(value);
2015
2214
  }
2016
2215
  }
2216
+ const prototypeSensitivePropertyNames = new Set(["__proto__", "constructor", "prototype"]);
2017
2217
  function normalizeTimeout(timeoutMs) {
2018
2218
  if (timeoutMs == null)
2019
2219
  return 30_000;
@@ -2071,3 +2271,8 @@ function toBridgeError(error, fallbackCode) {
2071
2271
  return new PluginBridgeError(fallbackCode, error.message);
2072
2272
  return new PluginBridgeError(fallbackCode, String(error));
2073
2273
  }
2274
+ function streamReadFailureInvalidatesCredential(error) {
2275
+ if (!(error instanceof PluginBridgeError))
2276
+ return true;
2277
+ return streamCredentialInvalidatingErrorCodes.has(error.errorCode);
2278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/redevplugin-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {