@q9labsai/chalk-client 5.1.0 → 5.2.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 (39) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/chat-files/types.d.ts +3 -0
  3. package/dist/chunk-6LNRDKJF.js +162 -0
  4. package/dist/{chunk-3ZN7IXOZ.js → chunk-GA3JDPFI.js} +252 -2
  5. package/dist/{chunk-RJRSLXVT.js → chunk-RVUN6LW4.js} +764 -265
  6. package/dist/{chunk-K7PNW3KI.js → chunk-TVNI2XUZ.js} +172 -18
  7. package/dist/client.d.ts +205 -0
  8. package/dist/connection/dependencies.d.ts +3 -0
  9. package/dist/connection/diagnostics.d.ts +3 -2
  10. package/dist/connection/index.d.ts +1 -0
  11. package/dist/connection/lifecycle.d.ts +1 -0
  12. package/dist/connection/types.d.ts +2 -0
  13. package/dist/effect.js +62 -4
  14. package/dist/generated/http-api.d.ts +201 -0
  15. package/dist/generated/schemas.d.ts +373 -0
  16. package/dist/generated/sync.d.ts +74 -3
  17. package/dist/generated/whiteboard-v1.d.ts +71 -9
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +20 -14
  20. package/dist/public-invites/code.d.ts +1 -0
  21. package/dist/public-invites/index.d.ts +1 -0
  22. package/dist/public-invites/index.js +6 -4
  23. package/dist/public-invites/words.d.ts +2 -0
  24. package/dist/space-client/reload-rejoin.d.ts +1 -1
  25. package/dist/space-client/types.d.ts +10 -1
  26. package/dist/sync/connect-diagnostics.d.ts +10 -0
  27. package/dist/sync/index.js +1 -1
  28. package/dist/sync/types.d.ts +1 -0
  29. package/dist/sync/v1-client.d.ts +2 -0
  30. package/dist/sync/v1-types.d.ts +8 -0
  31. package/dist/whiteboard/index.d.ts +2 -5
  32. package/dist/whiteboard/types.d.ts +26 -3
  33. package/dist/whiteboard/v1-client.d.ts +3 -1
  34. package/dist/whiteboard/v1-codec.d.ts +5 -1
  35. package/dist/whiteboard/v1-multipart.d.ts +1 -1
  36. package/dist/whiteboard/view-bounds.d.ts +5 -0
  37. package/dist/whiteboard/view-versions.d.ts +9 -0
  38. package/package.json +1 -1
  39. package/dist/chunk-KHHULLBZ.js +0 -142
@@ -1,12 +1,12 @@
1
- import {
2
- AccessGrantError,
3
- requireParsedAccessGrant
4
- } from "./chunk-3ZN7IXOZ.js";
5
1
  import {
6
2
  CloudflareSFUClient,
7
3
  createCloudflareSFUHTTPTransport,
8
4
  describeMediaCaptureError
9
5
  } from "./chunk-DWKTGBHR.js";
6
+ import {
7
+ AccessGrantError,
8
+ requireParsedAccessGrant
9
+ } from "./chunk-GA3JDPFI.js";
10
10
  import {
11
11
  ChatAttachmentMimeTypeSchema,
12
12
  ChatReactionSetFrameSchema,
@@ -17,8 +17,9 @@ import {
17
17
  createBrowserSyncLifecycle,
18
18
  createBrowserWebSocketFactory,
19
19
  createV1SyncClient,
20
+ formatSyncConnectDiagnostic,
20
21
  scheduleSyncDialDeadline
21
- } from "./chunk-K7PNW3KI.js";
22
+ } from "./chunk-TVNI2XUZ.js";
22
23
 
23
24
  // src/access/manager.ts
24
25
  import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
@@ -33,24 +34,24 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
33
34
  Effect.gen(function* () {
34
35
  const state = yield* SynchronizedRef.make({ access: null });
35
36
  const automaticMediaReplacementListeners = /* @__PURE__ */ new Set();
36
- const fetch = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs })).pipe(
37
+ const fetch2 = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs })).pipe(
37
38
  Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
38
39
  Effect.map((result) => result.access)
39
40
  );
40
- const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure2) => failure2.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch(reason, false, true) : Effect.fail(failure2)));
41
+ const initialize = (reason = "join") => fetch2(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure2) => failure2.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch2(reason, false, true) : Effect.fail(failure2)));
41
42
  const millisecondsUntilRefresh = Effect.gen(function* () {
42
43
  const access = yield* SynchronizedRef.get(state);
43
44
  if (!access.access) return null;
44
45
  return millisecondsUntil(access.access, yield* Clock.currentTimeMillis, refreshWindowMs);
45
46
  });
46
- const ensureFresh = (reason = "scheduled_refresh") => fetch(reason, false, false);
47
+ const ensureFresh = (reason = "scheduled_refresh") => fetch2(reason, false, false);
47
48
  return {
48
49
  current: SynchronizedRef.get(state).pipe(Effect.map((value) => value.access)),
49
50
  currentUnsafe: () => SynchronizedRef.getUnsafe(state).access,
50
51
  initialize,
51
52
  ensureFresh,
52
- refresh: (reason, replaceMediaConnection) => fetch(reason, replaceMediaConnection, true),
53
- refreshAfterRejection: () => fetch("access_retry", true, true),
53
+ refresh: (reason, replaceMediaConnection) => fetch2(reason, replaceMediaConnection, true),
54
+ refreshAfterRejection: () => fetch2("access_retry", true, true),
54
55
  getSyncToken: (reason = "sync_recovery") => ensureFresh(reason).pipe(Effect.map((access) => access.sync.token)),
55
56
  getMediaToken: () => ensureFresh("scheduled_refresh").pipe(Effect.map((access) => access.media.token)),
56
57
  subscribeAutomaticMediaReplacement: (listener) => Effect.sync(() => {
@@ -184,16 +185,19 @@ var CHALK_CHAT_ATTACHMENT_MIME_TYPES = [
184
185
  ];
185
186
 
186
187
  // src/chat-files/types.ts
188
+ var CHAT_FILE_REACHABILITY_MESSAGE = "Could not reach the chat attachment service (network or browser access failure).";
187
189
  var ChalkChatFileError = class extends Error {
188
190
  operation;
189
191
  code;
190
192
  recoverable;
193
+ stage;
191
194
  constructor(failure2, options) {
192
195
  super(failure2.message, options);
193
196
  this.name = "ChalkChatFileError";
194
197
  this.operation = failure2.operation;
195
198
  this.code = failure2.code;
196
199
  this.recoverable = failure2.recoverable;
200
+ this.stage = failure2.stage ?? "request";
197
201
  }
198
202
  };
199
203
 
@@ -240,15 +244,19 @@ function createChatFileRequest(options) {
240
244
  const fetchImplementation = options.fetch ?? globalThis.fetch;
241
245
  const baseUrl = options.baseUrl.replace(/\/+$/u, "");
242
246
  return async function request(operation, path, init, accepts) {
243
- let response;
247
+ const headers = new Headers(init.headers);
244
248
  try {
245
- const headers = new Headers(init.headers);
246
249
  headers.set("authorization", `Bearer ${await options.token()}`);
250
+ } catch (cause) {
251
+ throw new ChalkChatFileError({ operation, code: "unavailable", stage: "token", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE }, { cause });
252
+ }
253
+ let response;
254
+ try {
247
255
  response = await fetchImplementation(`${baseUrl}${path}`, { ...init, headers });
248
256
  } catch (cause) {
249
- throw new ChalkChatFileError({ operation, code: "unavailable", recoverable: true, message: "Chat attachment storage is unavailable." }, { cause });
257
+ throw new ChalkChatFileError({ operation, code: "unavailable", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE }, { cause });
250
258
  }
251
- if (!response.ok) throw responseError(response, operation);
259
+ if (!response.ok) throw await responseError(response, operation);
252
260
  let value;
253
261
  try {
254
262
  value = await response.json();
@@ -259,9 +267,14 @@ function createChatFileRequest(options) {
259
267
  return value;
260
268
  };
261
269
  }
262
- function responseError(response, operation) {
270
+ async function responseError(response, operation) {
263
271
  const code = responseErrorCode(response.status);
264
- return chatFileError(operation, code, response.status === 429 || response.status >= 500, "Chat attachment request failed.");
272
+ let message = "Chat attachment request failed.";
273
+ if (response.status === 503) {
274
+ const value = await response.json().catch(() => null);
275
+ if (isRecord(value) && isRecord(value.error) && value.error.code === "chat.storage_unavailable") message = "Chat attachment storage is unavailable.";
276
+ }
277
+ return chatFileError(operation, code, response.status === 429 || response.status >= 500, message);
265
278
  }
266
279
  function responseErrorCode(status) {
267
280
  if (status === 401) return "access.invalid";
@@ -306,71 +319,6 @@ function isAllowedMimeType(value) {
306
319
  return typeof value === "string" && CHALK_CHAT_ATTACHMENT_MIME_TYPES.includes(value);
307
320
  }
308
321
 
309
- // src/whiteboard/file-download-transport.ts
310
- function createChalkWhiteboardV1FileDownloadTransport(options) {
311
- const fetchImplementation = options.fetch ?? globalThis.fetch;
312
- let baseUrlEnd = options.baseUrl.length;
313
- while (baseUrlEnd > 0 && options.baseUrl[baseUrlEnd - 1] === "/") baseUrlEnd -= 1;
314
- const baseUrl = options.baseUrl.slice(0, baseUrlEnd);
315
- return {
316
- getDownloadUrl: async (fileId) => {
317
- const response = await authorizedFetch(fetchImplementation, options.token, `${baseUrl}/v1/whiteboard/files/${encodeURIComponent(fileId)}/download`, { method: "GET" });
318
- if (!response.ok) throw await responseError2(response, "get_file_download");
319
- const value = await response.json();
320
- if (!isDownloadResponse2(value)) {
321
- throw whiteboardError("get_file_download", "file_transfer_failed", false, "Whiteboard download service returned an invalid response.");
322
- }
323
- return value;
324
- }
325
- };
326
- }
327
- async function authorizedFetch(fetchImplementation, token, url, init) {
328
- try {
329
- const authorization = await token();
330
- const headers = new Headers(init.headers);
331
- headers.set("authorization", `Bearer ${authorization}`);
332
- return await fetchImplementation(url, { ...init, headers });
333
- } catch (cause) {
334
- throw new ChalkWhiteboardV1Error(
335
- {
336
- operation: "get_file_download",
337
- code: "storage_unavailable",
338
- recoverable: true,
339
- message: "Whiteboard file storage is unavailable."
340
- },
341
- { cause }
342
- );
343
- }
344
- }
345
- async function responseError2(response, operation) {
346
- const code = response.status === 401 || response.status === 403 ? "permission_denied" : response.status === 400 ? "invalid_payload" : response.status >= 500 || response.status === 429 ? "storage_unavailable" : "file_transfer_failed";
347
- return whiteboardError(operation, code, response.status >= 500 || response.status === 429, "Whiteboard file request failed.");
348
- }
349
- function whiteboardError(operation, code, recoverable, message) {
350
- return new ChalkWhiteboardV1Error({ operation, code, recoverable, message });
351
- }
352
- function isDownloadResponse2(value) {
353
- return isExactObject(value, ["downloadUrl", "expiresAt"]) && isHttpUrl2(value.downloadUrl) && isDate2(value.expiresAt);
354
- }
355
- function isExactObject(value, keys) {
356
- return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
357
- }
358
- function isHttpUrl2(value) {
359
- if (typeof value !== "string") return false;
360
- try {
361
- const url = new URL(value);
362
- return url.protocol === "http:" || url.protocol === "https:";
363
- } catch {
364
- return false;
365
- }
366
- }
367
- function isDate2(value) {
368
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
369
- }
370
-
371
- // src/whiteboard/v1-codec.ts
372
- import { Schema as Schema2 } from "effect";
373
-
374
322
  // src/generated/whiteboard-v1.ts
375
323
  import { Schema } from "effect";
376
324
  var WhiteboardV1ProtocolMetadata = {
@@ -389,7 +337,12 @@ var WhiteboardV1ProtocolMetadata = {
389
337
  view: {
390
338
  name: "view_v1",
391
339
  exactFields: ["name"],
392
- extendedExactFields: ["name", "origins"]
340
+ extendedExactFields: ["name", "origins"],
341
+ ownerExactFields: ["name", "origins", "owner"]
342
+ },
343
+ drawPolicy: {
344
+ name: "draw_policy_v1",
345
+ exactFields: ["name"]
393
346
  }
394
347
  },
395
348
  identity: {
@@ -398,6 +351,7 @@ var WhiteboardV1ProtocolMetadata = {
398
351
  generationFenced: true
399
352
  },
400
353
  capabilities: ["drawWhiteboard", "manageWhiteboard"],
354
+ drawPolicies: ["everyone", "hosts", "picked"],
401
355
  phases: [
402
356
  {
403
357
  id: "awaiting_hello",
@@ -406,7 +360,7 @@ var WhiteboardV1ProtocolMetadata = {
406
360
  },
407
361
  {
408
362
  id: "live",
409
- accepts: ["submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "view", "ping"],
363
+ accepts: ["submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_draw_policy", "set_presentation", "cursor", "view", "ping"],
410
364
  heartbeatIntervalMs: 2e4,
411
365
  missedDeadlinesBeforeClose: 2
412
366
  }
@@ -436,6 +390,7 @@ var WhiteboardV1ProtocolMetadata = {
436
390
  socketQueueMaxAgeMs: 5e3,
437
391
  cursorTtlMs: 1e4,
438
392
  cursorRatePerSecond: 60,
393
+ viewPeopleMaxItems: 500,
439
394
  pendingOperationMaxItems: 128,
440
395
  multipartUpdateMaxParts: 128,
441
396
  multipartUpdateMaxItems: 1e4,
@@ -443,7 +398,8 @@ var WhiteboardV1ProtocolMetadata = {
443
398
  multipartUpdateTimeoutMs: 1e4,
444
399
  sceneElementMaxItems: 1e4,
445
400
  sceneJsonMaxBytes: 67108864,
446
- sceneObjectMaxBytes: 268435456
401
+ sceneObjectMaxBytes: 268435456,
402
+ drawAccessMaxGrants: 1024
447
403
  },
448
404
  cursor: {
449
405
  sceneId: "uuid",
@@ -479,6 +435,9 @@ var WhiteboardV1ProtocolMetadata = {
479
435
  setDrawPermission: {
480
436
  exactFields: ["type", "operation_id", "participant_id", "can_draw"]
481
437
  },
438
+ setDrawPolicy: {
439
+ exactFields: ["type", "operation_id", "policy"]
440
+ },
482
441
  setPresentation: {
483
442
  exactFields: ["type", "operation_id", "presenting"]
484
443
  },
@@ -490,11 +449,14 @@ var WhiteboardV1ProtocolMetadata = {
490
449
  maxCoordinate: 1e6,
491
450
  maxExtent: 2e6,
492
451
  epochField: "origin_epoch",
452
+ inputFields: ["input_id", "move"],
453
+ ownerFields: ["owner_id", "owner_sequence"],
493
454
  originFields: ["origin_participant_id", "origin_sequence"],
494
455
  clientExactFields: ["type", "x", "y", "width", "height", "bring"],
495
456
  serverExactFields: ["type", "participant_id", "display_name", "x", "y", "width", "height", "bring", "occurred_at"]
496
457
  },
497
458
  welcome: {
459
+ ownerField: "view_owner_id",
498
460
  exactFields: ["type", "protocol", "participant_id", "participant_generation", "capabilities", "participant_capabilities", "scene_id", "revision", "can_draw"],
499
461
  extendedExactFields: ["type", "protocol", "participant_id", "participant_generation", "capabilities", "participant_capabilities", "scene_id", "revision", "can_draw", "presenting"]
500
462
  },
@@ -513,6 +475,10 @@ var WhiteboardV1ProtocolMetadata = {
513
475
  permissionUpdated: {
514
476
  exactFields: ["type", "participant_id", "can_draw"]
515
477
  },
478
+ drawAccess: {
479
+ exactFields: ["type", "policy", "can_draw", "capabilities", "grants"],
480
+ grantExactFields: ["participant_id", "can_draw"]
481
+ },
516
482
  presentationUpdated: {
517
483
  exactFields: ["type", "scene_id", "revision", "presenting"]
518
484
  },
@@ -523,9 +489,9 @@ var WhiteboardV1ProtocolMetadata = {
523
489
  exactFields: ["type", "correlation_id", "operation", "code", "recoverable", "message"]
524
490
  }
525
491
  },
526
- clientFrames: ["hello", "submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "view", "ping"],
527
- serverFrames: ["welcome", "snapshot_page", "update", "update_part", "commit", "cursor", "view", "permission_updated", "presentation_updated", "reset_required", "operation_error", "pong"],
528
- receiptOperations: ["submit_update", "clear", "set_draw_permission", "set_presentation"],
492
+ clientFrames: ["hello", "submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_draw_policy", "set_presentation", "cursor", "view", "ping"],
493
+ serverFrames: ["welcome", "snapshot_page", "update", "update_part", "commit", "cursor", "view", "permission_updated", "draw_access", "presentation_updated", "reset_required", "operation_error", "view_ack", "pong"],
494
+ receiptOperations: ["submit_update", "clear", "set_draw_permission", "set_draw_policy", "set_presentation"],
529
495
  receiptOutcomes: ["committed", "duplicate"],
530
496
  resetReasons: ["scene_changed", "cursor_expired", "gap"],
531
497
  errorCodes: ["unavailable", "permission_denied", "invalid_payload", "stale_scene", "cursor_reset_required", "rate_limited", "overloaded", "storage_unavailable"],
@@ -640,18 +606,33 @@ function isClientFrame(value) {
640
606
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.clear.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isUuid(value.scene_id);
641
607
  case "set_draw_permission":
642
608
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setDrawPermission.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
609
+ case "set_draw_policy":
610
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setDrawPolicy.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isDrawPolicy(value.policy);
643
611
  case "set_presentation":
644
612
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setPresentation.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && typeof value.presenting === "boolean";
645
613
  case "cursor":
646
614
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.cursor.clientExactFields) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
647
615
  case "view":
648
- return isViewFields(value, WhiteboardV1ProtocolMetadata.frames.view.clientExactFields) && isViewRect(value) && typeof value.bring === "boolean" && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
616
+ return isViewInput(value, WhiteboardV1ProtocolMetadata.frames.view.clientExactFields) && isViewRect(value) && typeof value.bring === "boolean" && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
649
617
  case "ping":
650
618
  return hasExactKeys(value, ["type"]);
651
619
  default:
652
620
  return false;
653
621
  }
654
622
  }
623
+ function isViewInputId(input_id) {
624
+ return typeof input_id === "string" && /^[0-9a-fA-F-]{36}:[1-9][0-9]{0,15}$/.test(input_id) && isUuid(input_id.split(":")[0]) && Number.isSafeInteger(Number(input_id.split(":")[1]));
625
+ }
626
+ function isViewInput(value, fields) {
627
+ if (!("input_id" in value || "move" in value)) return isViewFields(value, fields);
628
+ const { input_id, move, ...rectangle } = value;
629
+ return isViewInputId(input_id) && typeof move === "boolean" && isViewFields(rectangle, fields);
630
+ }
631
+ function isViewOrder(value, fields) {
632
+ if (!("owner_id" in value || "owner_sequence" in value)) return isViewFields(value, fields);
633
+ const { owner_id, owner_sequence, ...rectangle } = value;
634
+ return isUuid(owner_id) && Number.isSafeInteger(owner_sequence) && Number(owner_sequence) > 0 && isViewFields(rectangle, fields);
635
+ }
655
636
  function isViewFields(value, fields) {
656
637
  if (hasExactRecordKeys(value, fields)) return true;
657
638
  const originFields = [...fields, ...WhiteboardV1ProtocolMetadata.frames.view.originFields];
@@ -668,10 +649,24 @@ function isExtension(value, extension) {
668
649
  return hasExactKeys(value, extension.exactFields) && value.name === extension.name;
669
650
  }
670
651
  function isExtensions(value) {
671
- const { presentation, view } = WhiteboardV1ProtocolMetadata.extensions;
672
- return Array.isArray(value) && (value.length === 1 || value.length === 2) && isExtension(value[0], presentation) && (value.length === 1 || isExtension(value[1], view) || hasExactKeys(value[1], view.extendedExactFields) && value[1].name === view.name && value[1].origins === true);
652
+ const { presentation, view, drawPolicy } = WhiteboardV1ProtocolMetadata.extensions;
653
+ if (!Array.isArray(value)) return false;
654
+ const drawPolicyLast = value.length > 0 && isExtension(value[value.length - 1], drawPolicy);
655
+ const base = drawPolicyLast ? value.slice(0, -1) : value;
656
+ if (base.length === 0) return drawPolicyLast;
657
+ return (base.length === 1 || base.length === 2) && isExtension(base[0], presentation) && (base.length === 1 || isExtension(base[1], view) || hasExactKeys(base[1], view.ownerExactFields) && base[1].name === view.name && base[1].origins === true && base[1].owner === true || hasExactKeys(base[1], view.extendedExactFields) && base[1].name === view.name && base[1].origins === true);
658
+ }
659
+ function isDrawPolicy(value) {
660
+ return typeof value === "string" && WhiteboardV1ProtocolMetadata.drawPolicies.some((policy) => policy === value);
661
+ }
662
+ function isDrawGrant(value) {
663
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.drawAccess.grantExactFields) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
673
664
  }
674
665
  function isWelcome(value) {
666
+ if ("view_owner_id" in value) {
667
+ const { view_owner_id, ...welcome } = value;
668
+ return isUuid(view_owner_id) && isWelcome(welcome);
669
+ }
675
670
  const exact = hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.extendedExactFields) && typeof value.presenting === "boolean";
676
671
  return exact && value.protocol === "whiteboard-v1" && isUuid(value.participant_id) && Number.isSafeInteger(value.participant_generation) && Number(value.participant_generation) > 0 && isCapabilities(value.capabilities) && isCapabilities(value.participant_capabilities) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.can_draw === "boolean";
677
672
  }
@@ -691,15 +686,19 @@ function isServerFrame(value) {
691
686
  case "cursor":
692
687
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.cursor.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isFiniteNumber(value.x) && isFiniteNumber(value.y) && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
693
688
  case "view":
694
- return isViewFields(value, WhiteboardV1ProtocolMetadata.frames.view.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isViewRect(value) && typeof value.bring === "boolean" && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
689
+ return isViewOrder(value, WhiteboardV1ProtocolMetadata.frames.view.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isViewRect(value) && typeof value.bring === "boolean" && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
695
690
  case "permission_updated":
696
691
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.permissionUpdated.exactFields) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
692
+ case "draw_access":
693
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.drawAccess.exactFields) && isDrawPolicy(value.policy) && typeof value.can_draw === "boolean" && isCapabilities(value.capabilities) && Array.isArray(value.grants) && value.grants.length <= WhiteboardV1ProtocolLimits.drawAccessMaxGrants && value.grants.every(isDrawGrant);
697
694
  case "presentation_updated":
698
695
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.presentationUpdated.exactFields) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.presenting === "boolean";
699
696
  case "reset_required":
700
697
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.resetRequired.exactFields) && isUuid(value.scene_id) && typeof value.reason === "string" && WhiteboardV1ProtocolMetadata.resetReasons.some((reason) => reason === value.reason);
701
698
  case "operation_error":
702
- return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.operationError.exactFields) && isBoundedIdentifier(value.correlation_id, WhiteboardV1ProtocolLimits.requestIdMinBytes, WhiteboardV1ProtocolLimits.requestIdMaxBytes) && ["submit_update", "request_snapshot", "clear", "set_draw_permission", "set_presentation"].includes(String(value.operation)) && typeof value.code === "string" && WhiteboardV1ProtocolMetadata.errorCodes.some((code) => code === value.code) && typeof value.recoverable === "boolean" && typeof value.message === "string" && utf8Bytes(value.message) <= WhiteboardV1ProtocolLimits.errorMessageMaxBytes;
699
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.operationError.exactFields) && isBoundedIdentifier(value.correlation_id, WhiteboardV1ProtocolLimits.requestIdMinBytes, WhiteboardV1ProtocolLimits.requestIdMaxBytes) && ["submit_update", "request_snapshot", "clear", "set_draw_permission", "set_draw_policy", "set_presentation"].includes(String(value.operation)) && typeof value.code === "string" && WhiteboardV1ProtocolMetadata.errorCodes.some((code) => code === value.code) && typeof value.recoverable === "boolean" && typeof value.message === "string" && utf8Bytes(value.message) <= WhiteboardV1ProtocolLimits.errorMessageMaxBytes;
700
+ case "view_ack":
701
+ return hasExactKeys(value, ["type", "input_id"]) && isViewInputId(value.input_id);
703
702
  case "pong":
704
703
  return hasExactKeys(value, ["type"]);
705
704
  default:
@@ -717,11 +716,118 @@ function encodeWhiteboardV1Frame(frame) {
717
716
  return JSON.stringify(frame);
718
717
  }
719
718
 
719
+ // src/whiteboard/view-bounds.ts
720
+ var ChalkWhiteboardViewBounds = {
721
+ maxCoordinate: WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate,
722
+ maxExtent: WhiteboardV1ProtocolMetadata.frames.view.maxExtent
723
+ };
724
+
725
+ // src/whiteboard/file-download-transport.ts
726
+ function createChalkWhiteboardV1FileDownloadTransport(options) {
727
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
728
+ let baseUrlEnd = options.baseUrl.length;
729
+ while (baseUrlEnd > 0 && options.baseUrl[baseUrlEnd - 1] === "/") baseUrlEnd -= 1;
730
+ const baseUrl = options.baseUrl.slice(0, baseUrlEnd);
731
+ return {
732
+ getDownloadUrl: async (fileId) => {
733
+ const response = await authorizedFetch(fetchImplementation, options.token, `${baseUrl}/v1/whiteboard/files/${encodeURIComponent(fileId)}/download`, { method: "GET" });
734
+ if (!response.ok) throw await responseError2(response, "get_file_download");
735
+ const value = await response.json();
736
+ if (!isDownloadResponse2(value)) {
737
+ throw whiteboardError("get_file_download", "file_transfer_failed", false, "Whiteboard download service returned an invalid response.");
738
+ }
739
+ return value;
740
+ }
741
+ };
742
+ }
743
+ async function authorizedFetch(fetchImplementation, token, url, init) {
744
+ try {
745
+ const authorization = await token();
746
+ const headers = new Headers(init.headers);
747
+ headers.set("authorization", `Bearer ${authorization}`);
748
+ return await fetchImplementation(url, { ...init, headers });
749
+ } catch (cause) {
750
+ throw new ChalkWhiteboardV1Error(
751
+ {
752
+ operation: "get_file_download",
753
+ code: "storage_unavailable",
754
+ recoverable: true,
755
+ message: "Whiteboard file storage is unavailable."
756
+ },
757
+ { cause }
758
+ );
759
+ }
760
+ }
761
+ async function responseError2(response, operation) {
762
+ const code = response.status === 401 || response.status === 403 ? "permission_denied" : response.status === 400 ? "invalid_payload" : response.status >= 500 || response.status === 429 ? "storage_unavailable" : "file_transfer_failed";
763
+ return whiteboardError(operation, code, response.status >= 500 || response.status === 429, "Whiteboard file request failed.");
764
+ }
765
+ function whiteboardError(operation, code, recoverable, message) {
766
+ return new ChalkWhiteboardV1Error({ operation, code, recoverable, message });
767
+ }
768
+ function isDownloadResponse2(value) {
769
+ return isExactObject(value, ["downloadUrl", "expiresAt"]) && isHttpUrl2(value.downloadUrl) && isDate2(value.expiresAt);
770
+ }
771
+ function isExactObject(value, keys) {
772
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
773
+ }
774
+ function isHttpUrl2(value) {
775
+ if (typeof value !== "string") return false;
776
+ try {
777
+ const url = new URL(value);
778
+ return url.protocol === "http:" || url.protocol === "https:";
779
+ } catch {
780
+ return false;
781
+ }
782
+ }
783
+ function isDate2(value) {
784
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
785
+ }
786
+
787
+ // src/whiteboard/view-versions.ts
788
+ var ChalkWhiteboardViewVersions = class {
789
+ #versions = /* @__PURE__ */ new Map();
790
+ isNew(view, participantId) {
791
+ const key = this.#key(view, participantId);
792
+ const sequence = view.ownerSequence ?? view.originSequence;
793
+ return key === void 0 || sequence === void 0 || sequence > (this.#versions.get(key) ?? 0);
794
+ }
795
+ remember(view, participantId) {
796
+ const key = this.#key(view, participantId);
797
+ const sequence = view.ownerSequence ?? view.originSequence;
798
+ if (key === void 0 || sequence === void 0 || !this.isNew(view, participantId)) return;
799
+ this.#versions.delete(key);
800
+ this.#versions.set(key, sequence);
801
+ if (this.#versions.size > WhiteboardV1ProtocolLimits.viewPeopleMaxItems) {
802
+ const oldest = this.#versions.keys().next().value;
803
+ if (oldest !== void 0) this.#versions.delete(oldest);
804
+ }
805
+ }
806
+ forgetParticipant(participantId) {
807
+ for (const key of this.#versions.keys()) {
808
+ if (key.startsWith(`${participantId}:`) || key.endsWith(`:${participantId}`)) this.#versions.delete(key);
809
+ }
810
+ }
811
+ clear() {
812
+ this.#versions.clear();
813
+ }
814
+ #key(view, participantId) {
815
+ if (view.ownerId && participantId) return `${view.ownerId}:${participantId}`;
816
+ return view.originParticipantId ? `${view.originParticipantId}:${view.originEpoch ?? ""}` : void 0;
817
+ }
818
+ };
819
+
720
820
  // src/whiteboard/v1-codec.ts
821
+ import { Schema as Schema2 } from "effect";
721
822
  function encodeWhiteboardV1ClientFrame(frame) {
722
823
  return encodeWhiteboardV1Frame(Schema2.decodeUnknownSync(WhiteboardV1ClientFrameSchema)(frame));
723
824
  }
724
825
  function decodeWhiteboardV1ServerFrame(wire) {
826
+ const frame = decodeWhiteboardV1WireFrame(wire);
827
+ if (frame.type === "view_ack" || frame.type === "draw_access") throw new Error("Negotiated whiteboard control frame");
828
+ return frame;
829
+ }
830
+ function decodeWhiteboardV1WireFrame(wire) {
725
831
  return Schema2.decodeUnknownSync(WhiteboardV1ServerFrameSchema)(JSON.parse(wire));
726
832
  }
727
833
 
@@ -916,6 +1022,11 @@ var MAX_TIMER_DRIFT_MS = 250;
916
1022
  var MAX_SLOW_CONNECTION_BACKOFF_MS = 4e3;
917
1023
  var DEFAULT_RECONNECT_DELAY_MS = 250;
918
1024
  var MAX_RECONNECT_DELAY_MS = 5e3;
1025
+ var MAX_REJECTED_HELLO_RETRIES = 4;
1026
+ var CLOSE_STEPS = {
1027
+ 1008: { connecting: "retry-hello", authenticating: "retry-hello" },
1028
+ 1009: { authenticating: "step-down" }
1029
+ };
919
1030
  var encoder2 = new TextEncoder();
920
1031
  var ChalkWhiteboardV1Client = class {
921
1032
  files;
@@ -925,7 +1036,14 @@ var ChalkWhiteboardV1Client = class {
925
1036
  #latestViews = /* @__PURE__ */ new Map();
926
1037
  #summaryListeners = /* @__PURE__ */ new Set();
927
1038
  #operations = /* @__PURE__ */ new Map();
1039
+ /** Strictly increasing, so a restore replays operations made in the same millisecond in the order they were made. */
1040
+ #lastCreatedAt = 0;
928
1041
  #reservedOperationIds = /* @__PURE__ */ new Set();
1042
+ /**
1043
+ * Policy changes and per-person choices in the order the host made them. Only the first one is sent, and only once it
1044
+ * is saved, so the order comes from creation and never from which save finishes first.
1045
+ */
1046
+ #accessChanges = [];
929
1047
  #awaitingOperationIds = /* @__PURE__ */ new Set();
930
1048
  #pendingStoreWrites = /* @__PURE__ */ new Set();
931
1049
  #operationRetryTimers = /* @__PURE__ */ new Map();
@@ -942,13 +1060,17 @@ var ChalkWhiteboardV1Client = class {
942
1060
  #revision;
943
1061
  #capabilities = [];
944
1062
  #canDraw = false;
1063
+ #drawPolicy = null;
1064
+ #drawPermissions = [];
945
1065
  #presentationSupported = false;
946
1066
  #viewsSupported = false;
947
1067
  #presenting = false;
948
- #hello = "versioned-view";
1068
+ #hello = "owner-view";
949
1069
  #viewSequence = 0;
1070
+ #viewOwnerId = null;
1071
+ #viewVersions = new ChalkWhiteboardViewVersions();
950
1072
  #viewEpoch = crypto.randomUUID();
951
- #pendingBring = null;
1073
+ #pendingLeadingView = null;
952
1074
  #summaryStatus = "unsubscribed";
953
1075
  #summaryError = null;
954
1076
  #latestSnapshot = null;
@@ -958,6 +1080,7 @@ var ChalkWhiteboardV1Client = class {
958
1080
  #waitingForOperations = false;
959
1081
  #reconnectTimer;
960
1082
  #reconnectAttempt = 0;
1083
+ #rejectedHelloRetries = 0;
961
1084
  #heartbeatTimer;
962
1085
  #heartbeats = [];
963
1086
  #ignoredHeartbeats = 0;
@@ -995,7 +1118,8 @@ var ChalkWhiteboardV1Client = class {
995
1118
  if (this.#started && this.#startPromise) return this.#startPromise;
996
1119
  this.#started = true;
997
1120
  globalThis.document?.addEventListener("visibilitychange", this.#visibilityChanged);
998
- this.#hello = "versioned-view";
1121
+ this.#hello = "owner-view";
1122
+ this.#rejectedHelloRetries = 0;
999
1123
  this.#viewsSupported = false;
1000
1124
  this.#phase = "connecting";
1001
1125
  this.#publishSummary("loading", null);
@@ -1021,7 +1145,7 @@ var ChalkWhiteboardV1Client = class {
1021
1145
  #stopNow() {
1022
1146
  this.#started = false;
1023
1147
  this.#viewsSupported = false;
1024
- this.#pendingBring = null;
1148
+ this.#pendingLeadingView = null;
1025
1149
  this.#latestViews.clear();
1026
1150
  this.#removeVisibilityListener();
1027
1151
  this.#startupGeneration += 1;
@@ -1045,6 +1169,8 @@ var ChalkWhiteboardV1Client = class {
1045
1169
  this.#participantId = null;
1046
1170
  this.#capabilities = [];
1047
1171
  this.#canDraw = false;
1172
+ this.#drawPolicy = null;
1173
+ this.#drawPermissions = [];
1048
1174
  this.#presenting = false;
1049
1175
  this.#publishSummary("unsubscribed", null);
1050
1176
  }
@@ -1070,7 +1196,7 @@ var ChalkWhiteboardV1Client = class {
1070
1196
  }
1071
1197
  submitUpdate(input) {
1072
1198
  if (!this.#started || !this.#sceneId) this.#assertLive("submit_update");
1073
- if (!this.#canDraw || !this.#capabilities.includes("drawWhiteboard")) {
1199
+ if (!this.#canDraw) {
1074
1200
  throw error(failure("submit_update", "permission_denied", false, "Whiteboard draw permission is required."));
1075
1201
  }
1076
1202
  const operationId = this.#nextId();
@@ -1095,35 +1221,52 @@ var ChalkWhiteboardV1Client = class {
1095
1221
  return this.#started && this.#viewsSupported && this.#viewExtensionEnabled;
1096
1222
  }
1097
1223
  get #viewExtensionEnabled() {
1098
- return this.#hello === "view" || this.#hello === "versioned-view";
1224
+ return viewsNegotiated(this.#hello);
1099
1225
  }
1100
1226
  get #presenceReady() {
1101
1227
  return this.#phase === "live" && this.#summaryStatus === "ready" && this.#snapshots.size === 0;
1102
1228
  }
1229
+ forgetParticipant(participantId) {
1230
+ this.#latestViews.delete(participantId);
1231
+ this.#viewVersions.forgetParticipant(participantId);
1232
+ }
1103
1233
  sendView(view) {
1104
1234
  if (!this.#started || !this.#viewExtensionEnabled) return false;
1105
1235
  const frame = this.#viewFrame(view);
1106
1236
  encodeWhiteboardV1ClientFrame(frame);
1107
- if (!this.#presenceReady) {
1108
- if (view.bring) this.#pendingBring = { sceneId: this.#sceneId, view: { ...view } };
1109
- return true;
1110
- }
1111
- if (view.bring) this.#pendingBring = null;
1237
+ if (view.bring || view.lead !== false) this.#pendingLeadingView = { sceneId: this.#sceneId, view: { ...view }, frame };
1238
+ if (!this.#presenceReady) return true;
1112
1239
  this.#send(frame);
1240
+ if (!this.#viewOwnerId && (view.bring || view.lead !== false)) this.#pendingLeadingView = null;
1113
1241
  return true;
1114
1242
  }
1115
1243
  #viewFrame(view) {
1116
1244
  const rectangle = { type: "view", x: view.x, y: view.y, width: view.width, height: view.height, bring: view.bring };
1117
- if (view.bring || this.#hello !== "versioned-view") return rectangle;
1245
+ if (this.#viewOwnerId) {
1246
+ const input = { input_id: `${this.#viewEpoch}:${++this.#viewSequence}`, move: view.lead !== false || view.bring };
1247
+ return view.originParticipantId ? { ...rectangle, ...input, origin_participant_id: view.originParticipantId, origin_sequence: view.originSequence } : { ...rectangle, ...input };
1248
+ }
1249
+ if (view.bring || !viewOriginsNegotiated(this.#hello)) return rectangle;
1118
1250
  if (view.originParticipantId !== void 0) return { ...rectangle, origin_participant_id: view.originParticipantId, origin_sequence: view.originSequence, ...view.originEpoch !== void 0 ? { origin_epoch: view.originEpoch } : {} };
1119
1251
  if (!this.#participantId) return rectangle;
1120
1252
  return { ...rectangle, origin_participant_id: this.#participantId, origin_sequence: ++this.#viewSequence, origin_epoch: this.#viewEpoch };
1121
1253
  }
1122
1254
  #flushPendingBring() {
1123
1255
  if (!this.#presenceReady) return;
1124
- const pending = this.#pendingBring;
1125
- this.#pendingBring = null;
1126
- if (pending && (pending.sceneId === null || pending.sceneId === this.#sceneId)) this.sendView(pending.view);
1256
+ const pending = this.#pendingLeadingView;
1257
+ if (!pending) return;
1258
+ if (pending.sceneId !== null && pending.sceneId !== this.#sceneId) {
1259
+ this.#pendingLeadingView = null;
1260
+ return;
1261
+ }
1262
+ if (!this.#viewOwnerId) {
1263
+ this.#pendingLeadingView = null;
1264
+ this.sendView(pending.view);
1265
+ return;
1266
+ }
1267
+ const frame = pending.frame.input_id ? pending.frame : this.#viewFrame(pending.view);
1268
+ this.#pendingLeadingView = { ...pending, frame };
1269
+ this.#send(frame);
1127
1270
  }
1128
1271
  requestSnapshot() {
1129
1272
  this.#assertLive("request_snapshot");
@@ -1151,12 +1294,22 @@ var ChalkWhiteboardV1Client = class {
1151
1294
  };
1152
1295
  await this.#queueOperation(frame);
1153
1296
  }
1297
+ async setDrawPolicy(policy) {
1298
+ this.#assertLive("set_draw_policy");
1299
+ if (this.#drawPolicy === null) {
1300
+ throw error(policyUnavailable());
1301
+ }
1302
+ if (!this.#capabilities.includes("manageWhiteboard")) {
1303
+ throw error(failure("set_draw_policy", "permission_denied", false, "Whiteboard management permission is required."));
1304
+ }
1305
+ await this.#queueOperation({ type: "set_draw_policy", operation_id: this.#nextId(), policy });
1306
+ }
1154
1307
  async setPresentation(presenting) {
1155
1308
  this.#assertLive("set_presentation");
1156
1309
  if (!this.#presentationSupported) {
1157
1310
  throw error(failure("set_presentation", "unavailable", true, "Whiteboard presentation is unavailable on this Sync server."));
1158
1311
  }
1159
- if (!this.#canDraw || !this.#capabilities.includes("drawWhiteboard")) {
1312
+ if (!this.#canDraw) {
1160
1313
  throw error(failure("set_presentation", "permission_denied", false, "Whiteboard draw permission is required."));
1161
1314
  }
1162
1315
  const frame = {
@@ -1186,8 +1339,10 @@ var ChalkWhiteboardV1Client = class {
1186
1339
  void this.#store.remove(pending.operationId).catch(() => void 0);
1187
1340
  continue;
1188
1341
  }
1189
- if (this.#operations.size >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) return;
1190
- this.#operations.set(pending.operationId, { pending, retries: 0 });
1342
+ if (this.#operationCount() >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) return;
1343
+ this.#lastCreatedAt = Math.max(this.#lastCreatedAt, pending.createdAt);
1344
+ if (!isAccessChange(pending.frame)) this.#operations.set(pending.operationId, { pending, retries: 0 });
1345
+ else if (!this.#accessChange(pending.operationId)) this.#restoreAccessChange(pending);
1191
1346
  }
1192
1347
  }
1193
1348
  #validRestoredOperation(pending) {
@@ -1250,7 +1405,7 @@ var ChalkWhiteboardV1Client = class {
1250
1405
  if (typeof data !== "string" || encoder2.encode(data).byteLength > WhiteboardV1ProtocolLimits.encodedOutboundFrameBytes) {
1251
1406
  throw new Error("invalid whiteboard frame size");
1252
1407
  }
1253
- const frame = decodeWhiteboardV1ServerFrame(data);
1408
+ const frame = decodeWhiteboardV1WireFrame(data);
1254
1409
  if (frame.type !== "pong") {
1255
1410
  this.#lastProgressAt = this.#elapsedNow();
1256
1411
  this.#resetProbeState();
@@ -1290,9 +1445,14 @@ var ChalkWhiteboardV1Client = class {
1290
1445
  occurredAt: frame.occurred_at
1291
1446
  });
1292
1447
  return;
1448
+ case "view_ack":
1449
+ this.#requireLive();
1450
+ if (this.#pendingLeadingView?.frame.input_id === frame.input_id) this.#pendingLeadingView = null;
1451
+ return;
1293
1452
  case "view": {
1294
1453
  this.#requireLive();
1295
- const event = {
1454
+ if (this.#viewOwnerId && frame.owner_id !== this.#viewOwnerId) return;
1455
+ const rectangle = {
1296
1456
  type: "view",
1297
1457
  participantId: frame.participant_id,
1298
1458
  displayName: frame.display_name,
@@ -1303,6 +1463,9 @@ var ChalkWhiteboardV1Client = class {
1303
1463
  bring: frame.bring,
1304
1464
  occurredAt: frame.occurred_at
1305
1465
  };
1466
+ const event = frame.owner_id ? { ...rectangle, ownerId: frame.owner_id, ownerSequence: frame.owner_sequence } : rectangle;
1467
+ if (!this.#viewVersions.isNew(event, event.participantId)) return;
1468
+ this.#viewVersions.remember(event, event.participantId);
1306
1469
  this.#emit(frame.origin_participant_id ? { ...event, originParticipantId: frame.origin_participant_id, originSequence: frame.origin_sequence, ...frame.origin_epoch ? { originEpoch: frame.origin_epoch } : {} } : event);
1307
1470
  return;
1308
1471
  }
@@ -1311,6 +1474,14 @@ var ChalkWhiteboardV1Client = class {
1311
1474
  if (frame.participant_id === this.#participantId) this.#canDraw = frame.can_draw;
1312
1475
  this.#publishSummary("ready", null);
1313
1476
  return;
1477
+ case "draw_access":
1478
+ this.#requireLive();
1479
+ this.#canDraw = frame.can_draw;
1480
+ this.#capabilities = [...frame.capabilities];
1481
+ this.#drawPolicy = frame.policy;
1482
+ this.#drawPermissions = frame.grants.map((grant) => ({ participantId: grant.participant_id, canDraw: grant.can_draw }));
1483
+ this.#publishSummary(this.#summaryStatus, this.#summaryError);
1484
+ return;
1314
1485
  case "presentation_updated":
1315
1486
  this.#requireLive();
1316
1487
  if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
@@ -1342,7 +1513,6 @@ var ChalkWhiteboardV1Client = class {
1342
1513
  }
1343
1514
  #update(frame) {
1344
1515
  this.#requireLive();
1345
- if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1346
1516
  this.#sceneId = frame.scene_id;
1347
1517
  this.#revision = frame.revision;
1348
1518
  this.#publishSummary("ready", null);
@@ -1377,27 +1547,50 @@ var ChalkWhiteboardV1Client = class {
1377
1547
  #welcome(frame) {
1378
1548
  if (this.#phase !== "authenticating") throw new Error("unexpected whiteboard welcome");
1379
1549
  this.#phase = "live";
1380
- this.#viewsSupported = this.#hello === "view" || this.#hello === "versioned-view";
1550
+ this.#viewsSupported = this.#viewExtensionEnabled;
1551
+ this.#adoptViewOwner("view_owner_id" in frame ? frame.view_owner_id ?? null : null);
1381
1552
  this.#reconnectAttempt = 0;
1553
+ this.#rejectedHelloRetries = 0;
1382
1554
  this.#participantId = frame.participant_id;
1383
1555
  if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1384
1556
  this.#sceneId = frame.scene_id;
1385
1557
  this.#revision = frame.revision;
1386
1558
  this.#capabilities = [...frame.capabilities];
1387
1559
  this.#canDraw = frame.can_draw;
1560
+ this.#drawPolicy = null;
1561
+ this.#drawPermissions = [];
1388
1562
  this.#presentationSupported = "presenting" in frame;
1389
1563
  this.#presenting = "presenting" in frame ? frame.presenting : false;
1390
- this.#publishSummary("loading", null);
1564
+ const refused = !drawPolicyNegotiated(this.#hello) && this.#dropPolicyChanges();
1565
+ this.#publishSummary("loading", refused ? policyUnavailable() : null);
1391
1566
  this.#clearHeartbeat();
1392
- const pendingOperations = [...this.#operations.values()].sort((left, right) => compareChalkWhiteboardV1PendingOperations(left.pending, right.pending));
1567
+ this.#resumePendingOperations();
1568
+ this.#startHeartbeat(HEARTBEAT_INTERVAL_MS + Math.max(0, this.#nextSlowConnectionReplacementAt - this.#elapsedNow()));
1569
+ }
1570
+ #resumePendingOperations() {
1571
+ this.#clearOperationRetryTimers();
1572
+ for (const change of this.#accessChanges) if (change.state === "sent") change.state = "ready";
1393
1573
  this.#awaitingOperationIds.clear();
1394
1574
  this.#waitingForOperations = true;
1395
- for (const entry of pendingOperations) this.#awaitingOperationIds.add(entry.pending.operationId);
1396
- for (const entry of pendingOperations) {
1397
- this.#sendOperation(entry.pending.frame);
1398
- }
1575
+ this.#sendInCreationOrder([...this.#operations.values()]);
1399
1576
  this.#requestSnapshotAfterOperations();
1400
- this.#startHeartbeat(HEARTBEAT_INTERVAL_MS + Math.max(0, this.#nextSlowConnectionReplacementAt - this.#elapsedNow()));
1577
+ }
1578
+ /** A Sync without draw_policy_v1 closes the socket on a policy change, so a change saved for a newer Sync is dropped, not replayed. */
1579
+ #dropPolicyChanges() {
1580
+ const dropped = this.#accessChanges.filter((change) => change.entry.pending.frame.type === "set_draw_policy");
1581
+ for (const { entry } of dropped) {
1582
+ const { operationId } = entry.pending;
1583
+ this.#removeAccessChange(operationId);
1584
+ this.#clearOperationRetryTimer(operationId);
1585
+ entry.deferred && rejectDeferred(entry.deferred, error(policyUnavailable()));
1586
+ this.#discardStoredOperation(operationId);
1587
+ }
1588
+ return dropped.length > 0;
1589
+ }
1590
+ #adoptViewOwner(ownerId) {
1591
+ this.#viewOwnerId = ownerId;
1592
+ this.#latestViews.clear();
1593
+ this.#viewVersions.clear();
1401
1594
  }
1402
1595
  #requestSnapshotAfterOperations() {
1403
1596
  if (this.#phase !== "live" || !this.#waitingForOperations || this.#awaitingOperationIds.size > 0) return;
@@ -1467,45 +1660,78 @@ var ChalkWhiteboardV1Client = class {
1467
1660
  const pending = {
1468
1661
  operationId: frame.operation_id,
1469
1662
  frame,
1470
- createdAt: this.#now(),
1663
+ createdAt: this.#lastCreatedAt = Math.max(this.#now(), this.#lastCreatedAt + 1),
1471
1664
  bytes: whiteboardV1PendingOperationBytes(frame)
1472
1665
  };
1473
- const queuedBytes = [...this.#operations.values()].reduce((total, entry) => total + entry.pending.bytes, 0);
1666
+ const queuedBytes = this.#pendingEntries().reduce((total, entry) => total + entry.pending.bytes, 0);
1474
1667
  if (queuedBytes + pending.bytes > WhiteboardV1ProtocolLimits.multipartUpdateMaxBytes) {
1475
1668
  throw error(failure("submit_update", "unavailable", true, "Whiteboard operation byte capacity is full."));
1476
1669
  }
1670
+ const { promise, deferred } = commitDeferred();
1671
+ if (isAccessChange(frame)) return this.#queueAccessChange({ pending, deferred, retries: 0 }, deferred, promise);
1477
1672
  this.#reservedOperationIds.add(frame.operation_id);
1673
+ const saveFailure = await this.#persist(pending);
1674
+ this.#reservedOperationIds.delete(frame.operation_id);
1675
+ if (saveFailure) throw saveFailure;
1676
+ this.#operations.set(frame.operation_id, { pending, deferred, retries: 0 });
1677
+ if (this.#phase === "live" && this.#snapshots.size === 0) {
1678
+ if (this.#waitingForOperations) this.#awaitingOperationIds.add(frame.operation_id);
1679
+ this.#sendOperation(frame);
1680
+ }
1681
+ return promise;
1682
+ }
1683
+ /**
1684
+ * The change joins the list before its save starts, so a later change can't overtake it while it saves. The caller
1685
+ * gets its one promise at once: a refusal or a rejected connection during the save settles it without waiting for
1686
+ * the store, and a failed save settles it otherwise.
1687
+ */
1688
+ #queueAccessChange(entry, deferred, promise) {
1689
+ const change = { entry, state: "saving" };
1690
+ this.#accessChanges.push(change);
1691
+ void this.#saveAccessChange(change, deferred);
1692
+ return promise;
1693
+ }
1694
+ async #saveAccessChange(change, deferred) {
1695
+ const { pending } = change.entry;
1696
+ const saveFailure = await this.#persist(pending);
1697
+ if (saveFailure) {
1698
+ this.#removeAccessChange(pending.operationId);
1699
+ this.#sendNextAccessChange();
1700
+ rejectDeferred(deferred, saveFailure);
1701
+ return;
1702
+ }
1703
+ if (!this.#accessChanges.includes(change)) {
1704
+ this.#discardStoredOperation(pending.operationId);
1705
+ return;
1706
+ }
1707
+ change.state = "ready";
1708
+ this.#sendNextAccessChange();
1709
+ }
1710
+ /** Returns the failure instead of throwing it, so an access change can hand it to the promise its caller holds. */
1711
+ async #persist(pending) {
1478
1712
  let storeWrite;
1479
1713
  try {
1480
1714
  storeWrite = this.#store.put(pending);
1481
- } catch (cause) {
1482
- throw error(failure(operationName(frame), "unavailable", true, "Unable to persist the whiteboard operation."), cause);
1483
- }
1484
- this.#pendingStoreWrites.add(storeWrite);
1485
- try {
1715
+ this.#pendingStoreWrites.add(storeWrite);
1486
1716
  await storeWrite;
1717
+ return void 0;
1487
1718
  } catch (cause) {
1488
- throw error(failure(operationName(frame), "unavailable", true, "Unable to persist the whiteboard operation."), cause);
1719
+ this.#discardStoredOperation(pending.operationId);
1720
+ return error(failure(operationName(pending.frame), "unavailable", true, "Unable to persist the whiteboard operation."), cause);
1489
1721
  } finally {
1490
- this.#pendingStoreWrites.delete(storeWrite);
1491
- this.#reservedOperationIds.delete(frame.operation_id);
1492
- }
1493
- let deferred;
1494
- const promise = new Promise((resolve, reject) => {
1495
- deferred = { resolve, reject, settled: false };
1496
- });
1497
- this.#operations.set(frame.operation_id, { pending, deferred, retries: 0 });
1498
- if (this.#phase === "live" && this.#snapshots.size === 0) {
1499
- if (this.#waitingForOperations) this.#awaitingOperationIds.add(frame.operation_id);
1500
- this.#sendOperation(frame);
1722
+ if (storeWrite) this.#pendingStoreWrites.delete(storeWrite);
1501
1723
  }
1502
- return promise;
1724
+ }
1725
+ #discardStoredOperation(operationId) {
1726
+ const cleanup = this.#store.remove(operationId).catch(() => void 0);
1727
+ this.#pendingStoreWrites.add(cleanup);
1728
+ void cleanup.finally(() => this.#pendingStoreWrites.delete(cleanup));
1503
1729
  }
1504
1730
  #commit(frame) {
1505
1731
  this.#requireLive();
1506
- const entry = this.#operations.get(frame.operation_id);
1732
+ const entry = this.#sentEntry(frame.operation_id);
1507
1733
  if (!entry) return;
1508
- this.#operations.delete(frame.operation_id);
1734
+ this.#forgetOperation(frame.operation_id);
1509
1735
  this.#clearOperationRetryTimer(frame.operation_id);
1510
1736
  if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1511
1737
  this.#sceneId = frame.scene_id;
@@ -1518,27 +1744,35 @@ var ChalkWhiteboardV1Client = class {
1518
1744
  revision: frame.revision
1519
1745
  });
1520
1746
  void this.#store.remove(frame.operation_id).catch(() => void 0);
1747
+ this.#sendNextAccessChange();
1521
1748
  this.#requestSnapshotAfterOperations();
1522
1749
  }
1523
1750
  #operationError(frame) {
1524
1751
  this.#requireLive();
1525
- const entry = this.#operations.get(frame.correlation_id);
1752
+ const entry = this.#sentEntry(frame.correlation_id);
1526
1753
  if (entry) {
1527
- if (frame.recoverable && retryableOperationCode(frame.code)) {
1528
- this.#scheduleOperationRetry(frame.correlation_id, entry);
1529
- return;
1530
- }
1531
- this.#operations.delete(frame.correlation_id);
1532
- this.#clearOperationRetryTimer(frame.correlation_id);
1533
- this.#awaitingOperationIds.delete(frame.correlation_id);
1534
- entry.deferred && rejectDeferred(entry.deferred, error(failure(operationName(entry.pending.frame), mapErrorCode(frame.code), frame.recoverable, frame.message)));
1535
- this.#publishSummary("ready", failure(operationName(entry.pending.frame), mapErrorCode(frame.code), frame.recoverable, frame.message));
1536
- void this.#store.remove(frame.correlation_id).catch(() => void 0);
1537
- this.#requestSnapshotAfterOperations();
1754
+ this.#failOperation(entry, frame);
1538
1755
  return;
1539
1756
  }
1540
1757
  const snapshot = this.#snapshots.get(frame.correlation_id);
1541
- if (!snapshot) return;
1758
+ if (snapshot) this.#failSnapshot(snapshot, frame);
1759
+ }
1760
+ #failOperation(entry, frame) {
1761
+ if (frame.recoverable && retryableOperationCode(frame.code)) {
1762
+ this.#scheduleOperationRetry(frame.correlation_id, entry);
1763
+ return;
1764
+ }
1765
+ this.#forgetOperation(frame.correlation_id);
1766
+ this.#clearOperationRetryTimer(frame.correlation_id);
1767
+ this.#awaitingOperationIds.delete(frame.correlation_id);
1768
+ const operationFailure = failure(operationName(entry.pending.frame), mapErrorCode(frame.code), frame.recoverable, frame.message);
1769
+ entry.deferred && rejectDeferred(entry.deferred, error(operationFailure));
1770
+ this.#publishSummary("ready", operationFailure);
1771
+ void this.#store.remove(frame.correlation_id).catch(() => void 0);
1772
+ this.#sendNextAccessChange();
1773
+ this.#requestSnapshotAfterOperations();
1774
+ }
1775
+ #failSnapshot(snapshot, frame) {
1542
1776
  this.#snapshots.delete(frame.correlation_id);
1543
1777
  const snapshotError = error(failure("request_snapshot", mapErrorCode(frame.code), frame.recoverable, frame.message));
1544
1778
  this.#publishSummary(frame.recoverable ? "recovering" : "failed", failure("request_snapshot", mapErrorCode(frame.code), frame.recoverable, frame.message));
@@ -1548,20 +1782,69 @@ var ChalkWhiteboardV1Client = class {
1548
1782
  this.#initialSnapshot = null;
1549
1783
  }
1550
1784
  if (frame.recoverable) this.#flushQueuedOperations();
1785
+ else this.#sendNextAccessChange();
1551
1786
  }
1552
1787
  #flushQueuedOperations() {
1553
1788
  if (this.#phase !== "live" || this.#snapshots.size > 0) return;
1554
- for (const entry of this.#operations.values()) {
1555
- if (!this.#awaitingOperationIds.has(entry.pending.operationId)) this.#sendOperation(entry.pending.frame);
1789
+ this.#sendInCreationOrder([...this.#operations.values()].filter((entry) => !this.#awaitingOperationIds.has(entry.pending.operationId)));
1790
+ }
1791
+ #sendNextAccessChange() {
1792
+ this.#sendInCreationOrder([]);
1793
+ }
1794
+ /** Sends the operations, and the first access change when it may go, in the order they were made. */
1795
+ #sendInCreationOrder(operations) {
1796
+ const head = this.#sendableAccessChange();
1797
+ if (head) head.state = "sent";
1798
+ const entries = [...operations, ...head ? [head.entry] : []].sort((left, right) => compareChalkWhiteboardV1PendingOperations(left.pending, right.pending));
1799
+ for (const entry of entries) {
1800
+ if (this.#waitingForOperations) this.#awaitingOperationIds.add(entry.pending.operationId);
1801
+ this.#sendOperation(entry.pending.frame);
1556
1802
  }
1557
1803
  }
1804
+ /**
1805
+ * Access changes go out one at a time in the host's order, so a retried older change can't land after a newer one.
1806
+ * Sync also stores a per-person choice according to the policy it lands under, so their order matters too.
1807
+ */
1808
+ #sendableAccessChange() {
1809
+ if (this.#phase !== "live" || this.#snapshots.size > 0) return void 0;
1810
+ const head = this.#accessChanges[0];
1811
+ if (head?.state !== "ready" || this.#accessChanges.some((change) => change.state === "sent")) return void 0;
1812
+ return head;
1813
+ }
1814
+ /** A restart keeps the changes it already holds, so a restored one goes where its creation puts it. */
1815
+ #restoreAccessChange(pending) {
1816
+ const later = this.#accessChanges.findIndex((change) => compareChalkWhiteboardV1PendingOperations(change.entry.pending, pending) > 0);
1817
+ this.#accessChanges.splice(later < 0 ? this.#accessChanges.length : later, 0, { entry: { pending, retries: 0 }, state: "ready" });
1818
+ }
1819
+ #accessChange(operationId) {
1820
+ return this.#accessChanges.find((change) => change.entry.pending.operationId === operationId);
1821
+ }
1822
+ #removeAccessChange(operationId) {
1823
+ const index = this.#accessChanges.findIndex((change) => change.entry.pending.operationId === operationId);
1824
+ if (index >= 0) this.#accessChanges.splice(index, 1);
1825
+ }
1826
+ /** The operation a receipt or an error can be about: any queued operation, or the access change that was sent. */
1827
+ #sentEntry(operationId) {
1828
+ const change = this.#accessChange(operationId);
1829
+ return this.#operations.get(operationId) ?? (change?.state === "sent" ? change.entry : void 0);
1830
+ }
1831
+ #forgetOperation(operationId) {
1832
+ this.#operations.delete(operationId);
1833
+ this.#removeAccessChange(operationId);
1834
+ }
1835
+ #pendingEntries() {
1836
+ return [...this.#operations.values(), ...this.#accessChanges.map((change) => change.entry)];
1837
+ }
1838
+ #operationCount() {
1839
+ return this.#operations.size + this.#accessChanges.length;
1840
+ }
1558
1841
  #scheduleOperationRetry(operationId, entry) {
1559
1842
  if (this.#operationRetryTimers.has(operationId)) return;
1560
1843
  entry.retries += 1;
1561
1844
  const timer = this.#clock().setTimeout(
1562
1845
  () => {
1563
1846
  this.#operationRetryTimers.delete(operationId);
1564
- if (this.#phase === "live" && this.#operations.get(operationId) === entry) this.#sendOperation(entry.pending.frame);
1847
+ if (this.#phase === "live" && this.#sentEntry(operationId) === entry) this.#sendOperation(entry.pending.frame);
1565
1848
  },
1566
1849
  this.#options.retryDelayMs ?? Math.min(100 * 2 ** Math.min(entry.retries - 1, 4), 2e3)
1567
1850
  );
@@ -1569,30 +1852,39 @@ var ChalkWhiteboardV1Client = class {
1569
1852
  }
1570
1853
  #disconnected(socket, closeCode) {
1571
1854
  if (socket !== this.#socket) return;
1572
- const rejectedPresentationHello = this.#rejectedPresentationHello(closeCode);
1573
- const wasLive = this.#phase === "live";
1855
+ const phase = this.#phase;
1574
1856
  this.#resetDisconnectedState();
1575
1857
  if (!this.#started || !this.#transportAvailable) return;
1576
- if (rejectedPresentationHello) {
1577
- this.#fallbackHello();
1578
- this.#scheduleReconnect();
1579
- return;
1580
- }
1581
- if (terminalCloseCode(closeCode)) {
1582
- this.#failRejectedConnection(closeCode);
1583
- return;
1584
- }
1585
- if (wasLive) this.#hello = "versioned-view";
1586
- this.#scheduleReconnect();
1858
+ const step = CLOSE_STEPS[closeCode]?.[phase] ?? (terminalCloseCode(closeCode) ? "fail" : "reconnect");
1859
+ if (this.#takeCloseStep(step, phase)) this.#scheduleReconnect();
1860
+ else this.#failRejectedConnection(closeCode);
1861
+ }
1862
+ /** Returns false when the step has nothing left to try, so the connection fails. */
1863
+ #takeCloseStep(step, phase) {
1864
+ if (step === "fail") return false;
1865
+ if (step === "step-down") return this.#stepDownHello();
1866
+ if (step === "retry-hello") return this.#retryHello();
1867
+ if (phase === "live") this.#hello = "owner-view";
1868
+ return true;
1587
1869
  }
1588
- #rejectedPresentationHello(closeCode) {
1589
- return this.#phase === "authenticating" && this.#hello !== "legacy" && terminalCloseCode(closeCode);
1870
+ #stepDownHello() {
1871
+ if (this.#hello === "legacy") return false;
1872
+ this.#fallbackHello();
1873
+ return true;
1874
+ }
1875
+ #retryHello() {
1876
+ if (this.#rejectedHelloRetries >= MAX_REJECTED_HELLO_RETRIES) return false;
1877
+ this.#rejectedHelloRetries += 1;
1878
+ return true;
1590
1879
  }
1591
1880
  #fallbackHello() {
1592
- this.#hello = this.#hello === "versioned-view" ? "view" : this.#hello === "view" ? "presentation" : "legacy";
1593
- if (this.#hello !== "view") this.#pendingBring = null;
1881
+ this.#hello = HELLO_LEVELS[Math.min(HELLO_LEVELS.indexOf(this.#hello) + 1, HELLO_LEVELS.length - 1)] ?? "legacy";
1882
+ if (!viewsNegotiated(this.#hello)) this.#pendingLeadingView = null;
1594
1883
  }
1595
1884
  #resetDisconnectedState() {
1885
+ this.#viewOwnerId = null;
1886
+ this.#latestViews.clear();
1887
+ this.#viewVersions.clear();
1596
1888
  this.#socket = null;
1597
1889
  this.#clearDial();
1598
1890
  this.#clearHeartbeat();
@@ -1639,13 +1931,13 @@ var ChalkWhiteboardV1Client = class {
1639
1931
  if (this.#phase !== "live") throw new Error("whiteboard frame arrived before welcome");
1640
1932
  }
1641
1933
  #assertOperationCapacity() {
1642
- if (this.#operations.size + this.#reservedOperationIds.size >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) {
1934
+ if (this.#operationCount() + this.#reservedOperationIds.size >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) {
1643
1935
  throw error(failure("submit_update", "unavailable", true, "Whiteboard operation capacity is full."));
1644
1936
  }
1645
1937
  }
1646
1938
  #nextId() {
1647
1939
  const id = this.#options.ids?.next() ?? crypto.randomUUID();
1648
- if (this.#operations.has(id) || this.#reservedOperationIds.has(id) || this.#snapshots.has(id)) {
1940
+ if (this.#operations.has(id) || this.#accessChange(id) || this.#reservedOperationIds.has(id) || this.#snapshots.has(id)) {
1649
1941
  throw error(failure("submit_update", "invalid_payload", false, "Whiteboard operation ID is already pending."));
1650
1942
  }
1651
1943
  return id;
@@ -1661,17 +1953,22 @@ var ChalkWhiteboardV1Client = class {
1661
1953
  if (event.type === "view") {
1662
1954
  this.#latestViews.delete(event.participantId);
1663
1955
  this.#latestViews.set(event.participantId, event);
1664
- if (this.#latestViews.size > 256) {
1956
+ if (this.#latestViews.size > WhiteboardV1ProtocolLimits.viewPeopleMaxItems) {
1665
1957
  const oldest = this.#latestViews.keys().next().value;
1666
1958
  if (oldest !== void 0) this.#latestViews.delete(oldest);
1667
1959
  }
1668
1960
  }
1669
1961
  for (const listener of this.#listeners) listener(event);
1962
+ if (event.type === "snapshot") {
1963
+ for (const view of this.#latestViews.values()) {
1964
+ for (const listener of this.#listeners) listener({ ...view, bring: false });
1965
+ }
1966
+ }
1670
1967
  }
1671
1968
  #publishSummary(status, summaryError) {
1672
1969
  this.#summaryStatus = status;
1673
1970
  this.#summaryError = summaryError;
1674
- if (status === "failed") this.#pendingBring = null;
1971
+ if (status === "failed") this.#pendingLeadingView = null;
1675
1972
  if (status === "ready") this.#flushPendingBring();
1676
1973
  const summary = this.#summary();
1677
1974
  try {
@@ -1688,6 +1985,8 @@ var ChalkWhiteboardV1Client = class {
1688
1985
  capabilities: [...this.#capabilities],
1689
1986
  canDraw: this.#canDraw,
1690
1987
  canClear: this.#capabilities.includes("manageWhiteboard"),
1988
+ drawPolicy: this.#drawPolicy,
1989
+ drawPermissions: this.#drawPermissions,
1691
1990
  presenting: this.#presenting,
1692
1991
  error: this.#summaryError
1693
1992
  };
@@ -1786,7 +2085,7 @@ var ChalkWhiteboardV1Client = class {
1786
2085
  this.#snapshots.clear();
1787
2086
  }
1788
2087
  #rejectOperationCallers(value) {
1789
- for (const entry of this.#operations.values()) entry.deferred && rejectDeferred(entry.deferred, error({ ...value, operation: operationName(entry.pending.frame) }));
2088
+ for (const entry of this.#pendingEntries()) entry.deferred && rejectDeferred(entry.deferred, error({ ...value, operation: operationName(entry.pending.frame) }));
1790
2089
  }
1791
2090
  #clearOperationRetryTimers() {
1792
2091
  for (const timer of this.#operationRetryTimers.values()) this.#clock().clearTimeout(timer);
@@ -1893,11 +2192,27 @@ function operationName(frame) {
1893
2192
  function retryableOperationCode(code) {
1894
2193
  return code === "unavailable" || code === "overloaded" || code === "rate_limited" || code === "storage_unavailable";
1895
2194
  }
2195
+ var HELLO_LEVELS = ["owner-view", "draw-policy", "versioned-view", "view", "presentation", "legacy"];
2196
+ function viewsNegotiated(level) {
2197
+ return level === "owner-view" || level === "draw-policy" || level === "versioned-view" || level === "view";
2198
+ }
2199
+ function viewOriginsNegotiated(level) {
2200
+ return level === "owner-view" || level === "draw-policy" || level === "versioned-view";
2201
+ }
2202
+ function drawPolicyNegotiated(level) {
2203
+ return level === "owner-view" || level === "draw-policy";
2204
+ }
1896
2205
  function helloFrame(level, token, cursor) {
1897
2206
  const hello = { type: "hello", protocol: "whiteboard-v1", token, cursor };
1898
2207
  if (level === "legacy") return hello;
1899
2208
  if (level === "presentation") return { ...hello, extensions: [{ name: "presentation_v1" }] };
1900
- return { ...hello, extensions: [{ name: "presentation_v1" }, level === "versioned-view" ? { name: "view_v1", origins: true } : { name: "view_v1" }] };
2209
+ if (level === "view") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1" }] };
2210
+ if (level === "versioned-view") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true }] };
2211
+ if (level === "draw-policy") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true }, { name: "draw_policy_v1" }] };
2212
+ return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true, owner: true }, { name: "draw_policy_v1" }] };
2213
+ }
2214
+ function policyUnavailable() {
2215
+ return failure("set_draw_policy", "unavailable", true, "The call-wide drawing policy is unavailable on this Sync server.");
1901
2216
  }
1902
2217
  function terminalCloseCode(closeCode) {
1903
2218
  return closeCode === 1008 || closeCode === 1009;
@@ -1911,6 +2226,16 @@ function failure(operation, code, recoverable, message) {
1911
2226
  function error(value, cause) {
1912
2227
  return new ChalkWhiteboardV1Error(value, cause === void 0 ? void 0 : { cause });
1913
2228
  }
2229
+ function commitDeferred() {
2230
+ let deferred;
2231
+ const promise = new Promise((resolve, reject) => {
2232
+ deferred = { resolve, reject, settled: false };
2233
+ });
2234
+ return { promise, deferred };
2235
+ }
2236
+ function isAccessChange(frame) {
2237
+ return frame.type === "set_draw_policy" || frame.type === "set_draw_permission";
2238
+ }
1914
2239
  function resolveDeferred(deferred, value) {
1915
2240
  if (deferred.settled) return;
1916
2241
  deferred.settled = true;
@@ -1939,23 +2264,19 @@ function createChalkWhiteboardV1Client(options) {
1939
2264
  });
1940
2265
  }
1941
2266
 
1942
- // src/whiteboard/index.ts
1943
- var ChalkWhiteboardViewBounds = {
1944
- maxCoordinate: WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate,
1945
- maxExtent: WhiteboardV1ProtocolMetadata.frames.view.maxExtent
1946
- };
1947
-
1948
2267
  // src/connection/types.ts
1949
2268
  var ConnectionError = class extends Error {
1950
2269
  code;
1951
2270
  action;
1952
2271
  recoverable;
2272
+ sync;
1953
2273
  constructor(failure2, options) {
1954
2274
  super(failure2.message, options);
1955
2275
  this.name = "ConnectionError";
1956
2276
  this.code = failure2.code;
1957
2277
  this.action = failure2.action;
1958
2278
  this.recoverable = failure2.recoverable;
2279
+ this.sync = failure2.sync;
1959
2280
  }
1960
2281
  };
1961
2282
 
@@ -2018,6 +2339,21 @@ function createDefaultConnectionDependencies(options) {
2018
2339
  clearTimeout: (handle) => globalThis.clearTimeout(handle)
2019
2340
  },
2020
2341
  mediaDevices: createBrowserMediaDevices(),
2342
+ cancelPendingAdmission: async (access, signal) => {
2343
+ const { tenantId, spaceId, episodeId, participantId } = access.subject;
2344
+ const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
2345
+ let baseEnd = options.apiBaseURL.length;
2346
+ while (baseEnd > 0 && options.apiBaseURL[baseEnd - 1] === "/") baseEnd--;
2347
+ const baseURL = options.apiBaseURL.slice(0, baseEnd);
2348
+ const response = await fetch(`${baseURL}/v1/tenants/${path[0]}/spaces/${path[1]}/episodes/${path[2]}/participants/${path[3]}/cancel-join`, {
2349
+ method: "POST",
2350
+ headers: { Authorization: `Bearer ${access.media.token}` },
2351
+ signal
2352
+ });
2353
+ if (!response.ok) throw new Error(`Admission cancellation failed with HTTP ${response.status}`);
2354
+ const body = await response.json();
2355
+ return typeof body === "object" && body !== null && "cancelled" in body && body.cancelled === true;
2356
+ },
2021
2357
  createMediaClient: (input) => createMediaClient(options.apiBaseURL, input),
2022
2358
  createSyncClient: (input) => createSyncClient(options.syncURL, input),
2023
2359
  createChatFileTransport: (input) => createChatFileTransport(options.apiBaseURL, input),
@@ -2110,7 +2446,8 @@ function createSyncClient(syncURL, input) {
2110
2446
  token: input.token,
2111
2447
  mediaPlane: input.media,
2112
2448
  collaboration: { name: "collaboration_v2", chatCursor: { afterSequence: null, retainedFloorSequence: null } },
2113
- persistenceScope: `${input.access.subject.tenantId}:${input.access.subject.episodeId}:${input.access.subject.participantId}`
2449
+ persistenceScope: `${input.access.subject.tenantId}:${input.access.subject.episodeId}:${input.access.subject.participantId}`,
2450
+ entryConfirmation: true
2114
2451
  });
2115
2452
  }
2116
2453
  function createWhiteboardClient(apiBaseURL, url, input) {
@@ -2172,7 +2509,7 @@ var ConnectionDiagnostics = class {
2172
2509
  this.#onEvent = options.onEvent;
2173
2510
  }
2174
2511
  record(input) {
2175
- const event = Object.freeze({ timestamp: this.#now(), ...input });
2512
+ const event = Object.freeze({ timestamp: this.#now(), ...input, ...input.sync ? { sync: Object.freeze({ ...input.sync }) } : {} });
2176
2513
  this.#events.push(event);
2177
2514
  if (this.#events.length > this.#limit) this.#events.splice(0, this.#events.length - this.#limit);
2178
2515
  try {
@@ -2284,6 +2621,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2284
2621
  initialMediaIntent: null,
2285
2622
  intent: { microphone: options.initialMicrophoneEnabled ?? true, camera: options.initialCameraEnabled ?? true },
2286
2623
  episodeEndConfirmed: false,
2624
+ episodeEndReason: void 0,
2287
2625
  removedByHost: false,
2288
2626
  activeScope: null,
2289
2627
  syncBindingCleanup: null,
@@ -2293,15 +2631,16 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2293
2631
  refreshInFlight: false,
2294
2632
  recoveryQueued: null,
2295
2633
  joinCancelled: false,
2634
+ admissionCleanupConfirmed: true,
2296
2635
  closed: false,
2297
2636
  epoch: 0
2298
2637
  };
2299
2638
  const mediaAttempt = { expiresAt: null };
2300
- const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => lifecycleFailure(code, true, message, cause) });
2639
+ const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure(code, true, message, cause) });
2301
2640
  const withinForegroundBudget = (effect, milliseconds, failure2) => Effect2.raceFirst(
2302
2641
  effect,
2303
2642
  Effect2.callback((resume) => {
2304
- const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(failure2)));
2643
+ const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(typeof failure2 === "function" ? failure2() : failure2)));
2305
2644
  return Effect2.sync(cancel);
2306
2645
  })
2307
2646
  );
@@ -2422,13 +2761,23 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2422
2761
  model.syncNoticeUnresponsive = false;
2423
2762
  let latestSnapshot = null;
2424
2763
  let observedDialTimeouts = 0;
2764
+ let observedConnectFailure;
2425
2765
  let snapshotQueued = false;
2426
- const unsubscribe = sync.subscribe((snapshot) => {
2766
+ const recordSyncDiagnostics = (snapshot) => {
2767
+ if (sync !== model.sync) return;
2768
+ const failure2 = snapshot.connection.connectFailure;
2769
+ if (failure2 && failure2 !== observedConnectFailure) {
2770
+ observedConnectFailure = failure2;
2771
+ diagnostics.record({ event: "sync_connect_failed", state: model.state, epoch: model.epoch, code: "sync_hello_timeout", sync: failure2 });
2772
+ }
2427
2773
  const count = snapshot.connection.dialTimeoutCount ?? 0;
2428
- if (sync === model.sync && count > observedDialTimeouts) {
2774
+ if (count > observedDialTimeouts) {
2429
2775
  observedDialTimeouts = count;
2430
2776
  diagnostics.record({ event: "sync_dial_timeout", state: model.state, epoch: model.epoch, attempt: count });
2431
2777
  }
2778
+ };
2779
+ const unsubscribe = sync.subscribe((snapshot) => {
2780
+ recordSyncDiagnostics(snapshot);
2432
2781
  if (sync === model.sync && snapshot.connection.phase === "live") {
2433
2782
  const notice = snapshot.connection.noticeUnresponsive === true;
2434
2783
  if (notice !== model.syncNoticeUnresponsive) {
@@ -2490,7 +2839,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2490
2839
  const unsubscribe = sync.subscribe(observe);
2491
2840
  observe(sync.getSnapshot());
2492
2841
  return Effect2.sync(unsubscribe);
2493
- }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_hello_timeout", true, "Sync did not become live before the startup deadline")));
2842
+ }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, () => syncStartupFailure(sync.getSnapshot())));
2494
2843
  const trace = (step, effect) => {
2495
2844
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2496
2845
  return effect.pipe(
@@ -2498,12 +2847,17 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2498
2847
  Effect2.tapError(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed" })))
2499
2848
  );
2500
2849
  };
2850
+ const confirmSyncEntry = (sync) => {
2851
+ const confirmEntered = sync.confirmEntered?.bind(sync);
2852
+ return confirmEntered ? trace("confirm_entered", foreign7(confirmEntered, "sync_start_failed", "Entry could not be confirmed")) : Effect2.void;
2853
+ };
2501
2854
  const performJoin = (reason = "join", retried = false) => Effect2.gen(function* () {
2502
2855
  if (model.state === "live") return;
2503
2856
  if (model.state === "leaving" || model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Cannot join an inactive Connection"));
2504
2857
  model.epoch += 1;
2505
2858
  model.failure = null;
2506
2859
  model.episodeEndConfirmed = false;
2860
+ model.episodeEndReason = void 0;
2507
2861
  model.removedByHost = false;
2508
2862
  yield* transition("joining");
2509
2863
  const span = diagnostics.startSpan({ step: "join", state: model.state, epoch: model.epoch });
@@ -2517,7 +2871,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2517
2871
  stream = captured.stream;
2518
2872
  captureWarning = captured.failure;
2519
2873
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2520
- const grant = yield* trace("access_initialize", access.initialize(reason));
2874
+ const grant = yield* trace("access_initialize", reason === "access_retry" ? access.refreshAfterRejection() : access.initialize(reason));
2875
+ model.admissionCleanupConfirmed = false;
2521
2876
  const media = yield* trace(
2522
2877
  "create_media_client",
2523
2878
  Effect2.try({
@@ -2550,10 +2905,18 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2550
2905
  "start_media",
2551
2906
  model.startInitialMedia ? model.startInitialMedia(media, stream).pipe(Effect2.mapError((cause) => lifecycleFailure("media_start_failed", true, "The media layer could not start", cause))) : foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
2552
2907
  ),
2553
- trace("start_sync", foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))))
2908
+ trace(
2909
+ "start_sync",
2910
+ foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(
2911
+ Effect2.mapError((failure2) => sync.getSnapshot().connection.connectFailure ? syncStartupFailure(sync.getSnapshot()) : failure2),
2912
+ Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))
2913
+ )
2914
+ )
2554
2915
  ],
2555
2916
  { concurrency: "unbounded", discard: true }
2556
2917
  );
2918
+ if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2919
+ yield* confirmSyncEntry(sync);
2557
2920
  }).pipe(
2558
2921
  Effect2.matchEffect({
2559
2922
  onFailure: (cause) => Effect2.gen(function* () {
@@ -2567,8 +2930,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2567
2930
  span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
2568
2931
  const retry = !retried && failure2.code === "invalid_access";
2569
2932
  if (!retry) yield* recordFailure(failure2);
2933
+ model.admissionCleanupConfirmed = yield* cancelPendingAdmission();
2570
2934
  yield* stopPorts();
2571
- if (retry) return yield* performJoin("access_retry", true);
2935
+ if (retry) return yield* performJoin(model.admissionCleanupConfirmed ? "join" : "access_retry", true);
2572
2936
  yield* transition("failed");
2573
2937
  return yield* Effect2.fail(failure2);
2574
2938
  }),
@@ -2585,7 +2949,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2585
2949
  );
2586
2950
  });
2587
2951
  const performLeave = () => Effect2.gen(function* performLeaveEffect() {
2588
- if (model.state === "idle" || model.state === "left") return;
2952
+ if (model.state === "idle") return;
2953
+ if (model.state === "left") {
2954
+ if (model.failure) return yield* Effect2.fail(lifecycleFailure("leave_unconfirmed", false, model.failure.message));
2955
+ return;
2956
+ }
2589
2957
  yield* transition("leaving");
2590
2958
  const confirmed = yield* leaveIsConfirmed();
2591
2959
  yield* stopPorts();
@@ -2594,11 +2962,27 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2594
2962
  yield* transition("left");
2595
2963
  if (!confirmed) return yield* Effect2.fail(lifecycleFailure("leave_unconfirmed", false, "The Episode left locally without a durable Leave acknowledgement"));
2596
2964
  });
2965
+ const cancelPendingAdmission = () => Effect2.gen(function* () {
2966
+ const grant = access.currentUnsafe();
2967
+ if (!grant) return model.admissionCleanupConfirmed;
2968
+ const cancel = platform.cancelPendingAdmission;
2969
+ if (!cancel) return false;
2970
+ const result = yield* Effect2.exit(
2971
+ Effect2.tryPromise({
2972
+ try: (signal) => cancel(grant, signal),
2973
+ catch: (cause) => lifecycleFailure("internal_error", true, "Admission cancellation failed", cause)
2974
+ }).pipe(Effect2.timeout(3e3))
2975
+ );
2976
+ return result._tag === "Success" && result.value;
2977
+ });
2597
2978
  const leaveIsConfirmed = () => Effect2.gen(function* leaveIsConfirmedEffect() {
2598
2979
  const sync = model.sync;
2599
- if (!sync || model.episodeEndConfirmed || access.currentUnsafe() === null) return true;
2600
- const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2601
- return (yield* Effect2.exit(leave))._tag === "Success";
2980
+ if (model.episodeEndConfirmed) return true;
2981
+ if (sync && (sync.getSnapshot().connection.phase === "live" || sync.getSnapshot().control !== null)) {
2982
+ const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2983
+ if ((yield* Effect2.exit(leave))._tag === "Success") return true;
2984
+ }
2985
+ return yield* cancelPendingAdmission();
2602
2986
  });
2603
2987
  const publishFreshAccess = () => enqueue(
2604
2988
  Effect2.gen(function* () {
@@ -2812,7 +3196,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2812
3196
  if (snapshot.connection.phase === "idle" || snapshot.connection.phase === "stopped") return Effect2.succeed(true);
2813
3197
  if (snapshot.connection.phase !== "terminal") return Effect2.succeed(false);
2814
3198
  const failure2 = syncTerminalFailure(snapshot);
2815
- return failure2.recoverable ? Effect2.succeed(true) : Effect2.fail(failure2);
3199
+ return syncAutoRecoverable(snapshot, failure2) ? Effect2.succeed(true) : Effect2.fail(failure2);
2816
3200
  };
2817
3201
  const recoverSync = (attempt) => Effect2.gen(function* () {
2818
3202
  const sync = model.sync;
@@ -2856,6 +3240,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2856
3240
  else if (attempt.client === sync) attempt.started = true;
2857
3241
  })
2858
3242
  ).pipe(
3243
+ Effect2.andThen(waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4))),
3244
+ Effect2.andThen(Effect2.suspend(() => sync === model.sync && model.epoch === epoch && active(model) ? confirmSyncEntry(sync) : Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery was superseded")))),
2859
3245
  Effect2.tapError(
2860
3246
  () => enqueue(
2861
3247
  Effect2.gen(function* () {
@@ -2868,7 +3254,6 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2868
3254
  )
2869
3255
  )
2870
3256
  );
2871
- yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
2872
3257
  });
2873
3258
  const handleSyncSnapshot = (sync, snapshot) => Effect2.gen(function* handleSyncSnapshotEffect() {
2874
3259
  if (!isCurrentSyncSnapshot(sync, snapshot)) return;
@@ -2882,14 +3267,17 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2882
3267
  const ended = episodeEnded(snapshot);
2883
3268
  const removed = snapshot.connection.terminalReason === "participant_removed";
2884
3269
  if (ended || removed || snapshot.connection.terminalReason === "participant_inactive") {
2885
- if (ended) model.episodeEndConfirmed = true;
3270
+ if (ended) {
3271
+ model.episodeEndConfirmed = true;
3272
+ model.episodeEndReason = snapshot.control?.endReason;
3273
+ }
2886
3274
  if (removed) model.removedByHost = true;
2887
3275
  yield* finishTerminalSnapshot();
2888
3276
  return true;
2889
3277
  }
2890
3278
  if (snapshot.connection.phase !== "terminal") return false;
2891
3279
  const failure2 = syncTerminalFailure(snapshot);
2892
- if (failure2.recoverable) return false;
3280
+ if (syncAutoRecoverable(snapshot, failure2)) return false;
2893
3281
  yield* failForSnapshot(failure2);
2894
3282
  return true;
2895
3283
  });
@@ -2904,7 +3292,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2904
3292
  });
2905
3293
  const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
2906
3294
  const recordFailure = (failure2) => Effect2.sync(() => {
2907
- diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code });
3295
+ diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {} });
2908
3296
  });
2909
3297
  const failForSnapshot = (failure2) => Effect2.gen(function* failForSnapshotEffect() {
2910
3298
  model.failure = toFailure(failure2);
@@ -3071,6 +3459,7 @@ function snapshotFor(model, access) {
3071
3459
  connection: Object.freeze({ syncHealth: model.syncSnapshot?.connection.health, sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
3072
3460
  failure: model.failure ? Object.freeze({ ...model.failure }) : null,
3073
3461
  episodeEnded: model.episodeEndConfirmed,
3462
+ episodeEndReason: model.episodeEndReason,
3074
3463
  removedByHost: model.removedByHost
3075
3464
  });
3076
3465
  }
@@ -3096,7 +3485,7 @@ function lifecycleFailure(code, recoverable, message, cause) {
3096
3485
  return new ConnectionLifecycleFailure({ code, recoverable, message, ...cause === void 0 ? {} : { cause } });
3097
3486
  }
3098
3487
  function toFailure(value) {
3099
- return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message });
3488
+ return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message, ...value.sync ? { sync: value.sync } : {} });
3100
3489
  }
3101
3490
  function accessFailure(value) {
3102
3491
  return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? "Access was rejected" : "Could not reach the Space. Check your network and try again.", value.cause);
@@ -3190,9 +3579,18 @@ function syncTerminalFailure(snapshot) {
3190
3579
  const reason = snapshot.connection.terminalReason;
3191
3580
  if (reason === "state_digest_recovery_failed") return lifecycleFailure("state_digest_recovery_failed", false, "The Episode state could not be recovered from an authoritative snapshot");
3192
3581
  if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
3193
- if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
3582
+ if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation" || reason === "access_rejected") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
3583
+ if (reason === "entry_timeout") return lifecycleFailure("sync_start_failed", true, "Call entry was not confirmed in time");
3194
3584
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
3195
3585
  }
3586
+ function syncStartupFailure(snapshot) {
3587
+ const sync = snapshot.connection.connectFailure ?? snapshot.connection.connectAttempt;
3588
+ const message = sync?.opened === false ? "Sync could not reach the call service" : "Sync could not connect";
3589
+ return new ConnectionLifecycleFailure({ code: "sync_hello_timeout", recoverable: true, message: `${message}${sync ? ` ${formatSyncConnectDiagnostic(sync)}` : " before the startup deadline"}`, ...sync ? { sync: Object.freeze({ ...sync }) } : {} });
3590
+ }
3591
+ function syncAutoRecoverable(snapshot, failure2) {
3592
+ return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
3593
+ }
3196
3594
 
3197
3595
  // src/space-client/errors.ts
3198
3596
  import { Data as Data3 } from "effect";
@@ -3233,6 +3631,7 @@ var INVALID_PAYLOAD_CODE_BY_ACTION = /* @__PURE__ */ new Map([
3233
3631
  ]);
3234
3632
  var MEDIA_TIMEOUT_FAILURE = { code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." };
3235
3633
  var SYNC_FAILURES = /* @__PURE__ */ new Map([
3634
+ ["sync_hello_timeout", { failure: { code: "connection.sync_hello_timeout", recoverable: true, message: "Sync could not reach the call service. Try again." } }],
3236
3635
  ["not_live", { failure: { code: "connection.invalid_state", recoverable: true, message: "Wait for the call to connect, then try again." } }],
3237
3636
  ["signaling_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3238
3637
  ["negotiation_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
@@ -3247,11 +3646,14 @@ function normalizeMediaError(cause) {
3247
3646
  }
3248
3647
  function normalizeClientError(cause, fallback = "client.internal_error", scope) {
3249
3648
  if (cause instanceof SpaceClientError) return cause;
3649
+ const chatFileError2 = normalizeChatFileError(cause);
3650
+ if (chatFileError2) return chatFileError2;
3250
3651
  if (cause instanceof ConnectionLifecycleFailure || isConnectionError(cause)) {
3251
3652
  return new SpaceClientError({
3252
3653
  code: connectionErrorCode(cause.code, cause instanceof ConnectionLifecycleFailure ? null : cause.action),
3253
3654
  recoverable: cause.recoverable,
3254
- message: cause.message
3655
+ message: cause.message,
3656
+ ...cause.sync ? { sync: cause.sync } : {}
3255
3657
  });
3256
3658
  }
3257
3659
  if (cause instanceof V1SyncError) {
@@ -3264,8 +3666,15 @@ function normalizeClientError(cause, fallback = "client.internal_error", scope)
3264
3666
  const message = cause instanceof Error ? cause.message : "The client operation failed";
3265
3667
  return new SpaceClientError({ code: fallback, recoverable: false, message });
3266
3668
  }
3669
+ function normalizeChatFileError(cause) {
3670
+ if (!(cause instanceof ChalkChatFileError) || cause.code !== "unavailable") return null;
3671
+ if (cause.stage === "token" && isAccessInvalid(cause.cause)) {
3672
+ return new SpaceClientError({ code: "access.invalid", recoverable: false, message: cause.cause instanceof Error ? cause.cause.message : "Access was rejected" });
3673
+ }
3674
+ return new SpaceClientError({ code: cause.stage === "token" ? "access.unavailable" : "collaboration.unavailable", recoverable: cause.recoverable, message: cause.message });
3675
+ }
3267
3676
  function failureFromError(error2) {
3268
- return Object.freeze({ code: error2.code, recoverable: error2.recoverable, message: error2.message });
3677
+ return Object.freeze({ code: error2.code, recoverable: error2.recoverable, message: error2.message, ...error2.sync ? { sync: error2.sync } : {} });
3269
3678
  }
3270
3679
  function isAccessInvalid(cause) {
3271
3680
  if (typeof cause !== "object" || cause === null || !("code" in cause)) return false;
@@ -3299,7 +3708,7 @@ var EMPTY = empty({
3299
3708
  reactions: empty({ active: empty([]) }),
3300
3709
  recording: empty({ current: null }),
3301
3710
  offline: empty({ pending: empty([]), decisionNeeded: false, policy: "send" }),
3302
- whiteboard: empty({ open: false, engine: empty({ status: "unsubscribed", sceneId: null, revision: null, presenting: false, error: null }) })
3711
+ whiteboard: empty({ open: false, engine: empty({ status: "unsubscribed", sceneId: null, revision: null, presenting: false, error: null }), access: empty({ canDraw: false, policy: null, permissions: empty([]) }) })
3303
3712
  });
3304
3713
  var SpaceStore = class _SpaceStore {
3305
3714
  #listeners = /* @__PURE__ */ new Set();
@@ -3329,6 +3738,7 @@ var SpaceStore = class _SpaceStore {
3329
3738
  episode: snapshot.episode,
3330
3739
  lastError: sameError(current.lastError, error2) ? current.lastError : error2,
3331
3740
  episodeEnded: snapshot.episodeEnded ?? false,
3741
+ episodeEndReason: snapshot.episodeEndReason,
3332
3742
  removedByHost: snapshot.removedByHost ?? false
3333
3743
  };
3334
3744
  if (sameConnection(current, next)) return;
@@ -3380,14 +3790,14 @@ var SpaceStoreService = class extends Context4.Service()("@chalk/client/SpaceSto
3380
3790
  var makeSpaceStoreLayer = Layer4.effect(SpaceStoreService, SpaceStore.make());
3381
3791
  var makeFakeSpaceStoreLayer = (store = new SpaceStore()) => Layer4.succeed(SpaceStoreService, store);
3382
3792
  function connectionError(failure2) {
3383
- return failure2 ? empty({ code: connectionErrorCode(failure2.code, failure2.action), recoverable: failure2.recoverable, message: failure2.message }) : null;
3793
+ return failure2 ? empty({ code: connectionErrorCode(failure2.code, failure2.action), recoverable: failure2.recoverable, message: failure2.message, ...failure2.sync ? { sync: failure2.sync } : {} }) : null;
3384
3794
  }
3385
3795
  function connectionStatus(snapshot) {
3386
3796
  const interrupted = INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.sync) || INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.media);
3387
3797
  return snapshot.state === "live" && interrupted ? "reconnecting" : snapshot.state;
3388
3798
  }
3389
3799
  function sameConnection(left, right) {
3390
- return left.status === right.status && left.syncHealth === right.syncHealth && sameEpisode(left.episode, right.episode) && left.lastError === right.lastError && left.episodeEnded === right.episodeEnded && left.removedByHost === right.removedByHost;
3800
+ return left.status === right.status && left.syncHealth === right.syncHealth && sameEpisode(left.episode, right.episode) && left.lastError === right.lastError && left.episodeEnded === right.episodeEnded && left.episodeEndReason === right.episodeEndReason && left.removedByHost === right.removedByHost;
3391
3801
  }
3392
3802
  function sameEpisode(left, right) {
3393
3803
  if (left === right) return true;
@@ -3520,13 +3930,13 @@ var ChatControllerRuntime = class {
3520
3930
  #unsubscribeConnection = null;
3521
3931
  #unsubscribeEvents = null;
3522
3932
  #unsubscribeSnapshot = null;
3523
- constructor(connection, store, transport, apiBaseUrl, fetch, fork, offline) {
3933
+ constructor(connection, store, transport, apiBaseUrl, fetch2, fork, offline) {
3524
3934
  this.#offline = offline;
3525
3935
  this.#apiBaseUrl = apiBaseUrl.replace(/\/+$/u, "");
3526
3936
  this.#connection = connection;
3527
3937
  this.#store = store;
3528
3938
  this.#transport = transport;
3529
- this.#fetch = fetch;
3939
+ this.#fetch = fetch2;
3530
3940
  this.#fork = fork;
3531
3941
  this.#unsubscribeConnection = connection.subscribePorts((ports) => this.#bind(ports));
3532
3942
  }
@@ -3604,7 +4014,7 @@ var ChatControllerRuntime = class {
3604
4014
  });
3605
4015
  };
3606
4016
  upload = (file) => {
3607
- const fetch = this.#fetch;
4017
+ const fetch2 = this.#fetch;
3608
4018
  return Effect4.suspend(() => {
3609
4019
  if (!this.#transport) {
3610
4020
  return Effect4.fail(new SpaceClientError({ code: "collaboration.unavailable", recoverable: false, message: "Chat file upload is unavailable" }));
@@ -3619,7 +4029,10 @@ var ChatControllerRuntime = class {
3619
4029
  Effect4.flatMap(
3620
4030
  (upload) => Effect4.void.pipe(
3621
4031
  Effect4.andThen(
3622
- foreign(() => fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes })).pipe(
4032
+ Effect4.tryPromise({
4033
+ try: () => fetch2(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes }),
4034
+ catch: () => new SpaceClientError({ code: "collaboration.unavailable", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE })
4035
+ }).pipe(
3623
4036
  Effect4.flatMap((response) => {
3624
4037
  if (!response.ok) {
3625
4038
  return Effect4.fail(new SpaceClientError({ code: "command.rejected", recoverable: response.status >= 500, message: `Attachment upload failed with HTTP ${response.status}` }));
@@ -3942,6 +4355,7 @@ function chatError(message) {
3942
4355
  // src/space-client/media-controller.ts
3943
4356
  import { Clock as Clock3, Context as Context6, Deferred as Deferred2, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
3944
4357
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4358
+ var MEDIA_READY_TIMEOUT_MS = 1e4;
3945
4359
  function sourceWorker(enabled) {
3946
4360
  return { desired: { enabled, revision: 0, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, sentRevision: 0, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null };
3947
4361
  }
@@ -3986,8 +4400,9 @@ var MediaControllerRuntime = class {
3986
4400
  #tracks = /* @__PURE__ */ new Map();
3987
4401
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
3988
4402
  #syncLive = false;
3989
- #pendingSources = /* @__PURE__ */ new Set();
4403
+ #pendingSources = /* @__PURE__ */ new Map();
3990
4404
  #pendingCaptures = /* @__PURE__ */ new Set();
4405
+ #readinessChecks = /* @__PURE__ */ new Set();
3991
4406
  #requestGenerations = /* @__PURE__ */ new Map();
3992
4407
  #camera = { status: "idle" };
3993
4408
  #requests = Object.freeze([]);
@@ -4015,8 +4430,8 @@ var MediaControllerRuntime = class {
4015
4430
  if (intent.microphone !== void 0) this.#recordIntent("microphone", intent.microphone);
4016
4431
  if (intent.camera !== void 0) this.#recordIntent("camera", intent.camera);
4017
4432
  }
4018
- setMicrophoneEnabled = (enabled) => this.#mediaAction(enabled, this.#set("microphone", enabled));
4019
- setCameraEnabled = (enabled) => this.#mediaAction(enabled, this.#set("camera", enabled));
4433
+ setMicrophoneEnabled = (enabled) => this.#set("microphone", enabled);
4434
+ setCameraEnabled = (enabled) => this.#set("camera", enabled);
4020
4435
  captureCameraPreview = () => this.#cameraSource("preview").pipe(Effect5.mapError(normalizeMediaError));
4021
4436
  setScreenShareEnabled = (enabled, options) => enabled ? this.#mediaAction(true, this.#startScreen(options)) : this.#stopScreen();
4022
4437
  selectMicrophone = (deviceId) => Effect5.sync(() => {
@@ -4110,6 +4525,7 @@ var MediaControllerRuntime = class {
4110
4525
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
4111
4526
  }
4112
4527
  dispose() {
4528
+ for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
4113
4529
  this.#cancelCaptures();
4114
4530
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
4115
4531
  this.#unsubscribeConnection?.();
@@ -4134,8 +4550,37 @@ var MediaControllerRuntime = class {
4134
4550
  #mediaAction(enabling, operation) {
4135
4551
  return Effect5.suspend(() => !enabling || this.#store.getSnapshot().connection.status === "live" ? operation : Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
4136
4552
  }
4553
+ #waitForLive(source, revision, ports) {
4554
+ return Effect5.suspend(() => {
4555
+ let cleanup = () => {
4556
+ };
4557
+ return Effect5.callback((resume) => {
4558
+ const check = (failure2) => {
4559
+ if (failure2) return resume(Effect5.fail(failure2));
4560
+ if (this.#sources[source].desired.revision !== revision) return resume(Effect5.void);
4561
+ if (ports && (this.#ports?.sync !== ports.sync || this.#ports.media !== ports.media)) return resume(Effect5.fail(normalizeMediaError(cancelledCaptureError())));
4562
+ const state = this.#connection.getSnapshot().state;
4563
+ if (state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live" && this.#store.getSnapshot().connection.status === "live") return resume(Effect5.void);
4564
+ if (state !== "joining" && state !== "reconnecting" && state !== "live") resume(Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
4565
+ };
4566
+ this.#readinessChecks.add(check);
4567
+ const unsubscribe = this.#connection.subscribe(() => check());
4568
+ const unsubscribeStore = this.#store.subscribe(() => check());
4569
+ const unsubscribePorts = this.#connection.subscribePorts(() => check());
4570
+ const timer = this.#connection.scheduleUnsafe(() => check(new SpaceClientError({ code: "media.timeout", recoverable: true, message: "The call did not connect in time. Try again." })), MEDIA_READY_TIMEOUT_MS);
4571
+ cleanup = () => {
4572
+ this.#readinessChecks.delete(check);
4573
+ unsubscribe();
4574
+ unsubscribeStore();
4575
+ unsubscribePorts();
4576
+ this.#connection.cancelScheduleUnsafe(timer);
4577
+ };
4578
+ check();
4579
+ }).pipe(Effect5.ensuring(Effect5.sync(() => cleanup())));
4580
+ });
4581
+ }
4137
4582
  #set(source, enabled) {
4138
- return this.#act(source, enabled);
4583
+ return Effect5.suspend(() => this.#store.getSnapshot().connection.status === "joining" ? this.#act(source, enabled) : this.#mediaAction(enabled, this.#act(source, enabled)));
4139
4584
  }
4140
4585
  #startScreen(options) {
4141
4586
  return this.#act("screen", true, options);
@@ -4170,19 +4615,41 @@ var MediaControllerRuntime = class {
4170
4615
  void state.pause.catch(() => void 0);
4171
4616
  }
4172
4617
  }
4618
+ for (const check of this.#readinessChecks) check();
4173
4619
  return desired;
4174
4620
  }
4175
4621
  #act(source, enabled, options) {
4176
4622
  return Effect5.suspend(() => {
4177
4623
  const desired = this.#recordIntent(source, enabled, options);
4178
- return this.#serialize(source, this.#work(source)).pipe(
4179
- Effect5.andThen(
4180
- Effect5.suspend(() => {
4181
- if (enabled && this.#store.getSnapshot().connection.status !== "live") return this.#mediaAction(true, Effect5.void);
4182
- const result = this.#sources[source].result;
4183
- return result.revision === desired.revision && result.status === "failed" ? Effect5.fail(result.cause) : Effect5.void;
4624
+ const ready = enabled && source !== "screen" && this.#store.getSnapshot().connection.status !== "live" ? this.#waitForLive(source, desired.revision).pipe(
4625
+ Effect5.tapError(
4626
+ (cause) => Effect5.sync(() => {
4627
+ if (this.#sources[source].desired.revision === desired.revision) {
4628
+ const cancelled = this.#recordIntent(source, false);
4629
+ this.#sources[source].result = { revision: cancelled.revision, status: "failed", cause, cleanup: false };
4630
+ }
4184
4631
  })
4185
4632
  )
4633
+ ) : Effect5.void;
4634
+ return this.#pending(
4635
+ source,
4636
+ ready.pipe(
4637
+ Effect5.andThen(
4638
+ Effect5.suspend(() => {
4639
+ if (this.#sources[source].desired.revision !== desired.revision) return Effect5.void;
4640
+ return this.#serialize(source, this.#work(source)).pipe(
4641
+ Effect5.andThen(
4642
+ Effect5.suspend(() => {
4643
+ const result = this.#sources[source].result;
4644
+ if (result.revision === desired.revision && result.status === "failed") return Effect5.fail(result.cause);
4645
+ if (enabled && (source === "screen" || this.#sources[source].desired.enabled) && this.#store.getSnapshot().connection.status !== "live") return this.#mediaAction(true, Effect5.void);
4646
+ return Effect5.void;
4647
+ })
4648
+ )
4649
+ );
4650
+ })
4651
+ )
4652
+ )
4186
4653
  );
4187
4654
  }).pipe(Effect5.mapError(normalizeMediaError));
4188
4655
  }
@@ -4257,18 +4724,24 @@ var MediaControllerRuntime = class {
4257
4724
  return source === "camera" && !intent.previousEnabled || !this.#hasTrack(source) ? this.#captureForPublish(
4258
4725
  source,
4259
4726
  ports.media,
4260
- (track) => Effect5.try({
4261
- try: () => {
4262
- captured = track;
4263
- this.#assertActivePorts(ports, action);
4264
- this.#assertCurrentIntent(source, intent.revision);
4265
- this.#setTrack(source, track);
4266
- ports.media.prepareLocalTrack(source, track);
4267
- prepared = true;
4268
- this.#publish();
4269
- },
4270
- catch: (cause) => cause
4271
- })
4727
+ (track) => Effect5.sync(() => {
4728
+ captured = track;
4729
+ }).pipe(
4730
+ Effect5.andThen(this.#waitForLive(source, intent.revision, ports)),
4731
+ Effect5.andThen(
4732
+ Effect5.try({
4733
+ try: () => {
4734
+ this.#assertActivePorts(ports, action);
4735
+ this.#assertCurrentIntent(source, intent.revision);
4736
+ this.#setTrack(source, track);
4737
+ ports.media.prepareLocalTrack(source, track);
4738
+ prepared = true;
4739
+ this.#publish();
4740
+ },
4741
+ catch: (cause) => cause
4742
+ })
4743
+ )
4744
+ )
4272
4745
  ) : Effect5.void;
4273
4746
  });
4274
4747
  return retire.pipe(Effect5.andThen(acquire), Effect5.andThen(this.#send(ports, source, intent, true)));
@@ -4384,7 +4857,7 @@ var MediaControllerRuntime = class {
4384
4857
  return this.#serialize("microphone", this.#serialize("camera", publish));
4385
4858
  }
4386
4859
  #send(ports, source, intent, enabled) {
4387
- return this.#mediaAction(
4860
+ const command = this.#mediaAction(
4388
4861
  enabled,
4389
4862
  foreign2(async () => {
4390
4863
  this.#assertCurrentIntent(source, intent.revision);
@@ -4392,9 +4865,12 @@ var MediaControllerRuntime = class {
4392
4865
  const state = this.#sources[source];
4393
4866
  state.sentRevision = intent.revision;
4394
4867
  await (source === "microphone" ? ports.sync.setMicrophoneEnabled(enabled) : source === "camera" ? ports.sync.setCameraEnabled(enabled) : ports.sync.setScreenShareEnabled(enabled));
4395
- this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
4396
- })
4868
+ }).pipe(
4869
+ Effect5.andThen(enabled && source !== "screen" ? this.#waitForLive(source, intent.revision, ports) : Effect5.void),
4870
+ Effect5.tap(() => Effect5.try({ try: () => this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled"), catch: (cause) => cause }))
4871
+ )
4397
4872
  );
4873
+ return enabled && source !== "screen" ? this.#waitForLive(source, intent.revision, ports).pipe(Effect5.andThen(command)) : command;
4398
4874
  }
4399
4875
  #reconcileIntents() {
4400
4876
  const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
@@ -4419,18 +4895,21 @@ var MediaControllerRuntime = class {
4419
4895
  return pause;
4420
4896
  }
4421
4897
  #serialize(source, effect) {
4422
- return this.#gates.get(source).withPermit(
4423
- Effect5.sync(() => {
4424
- this.#pendingSources.add(source);
4425
- this.#publish();
4426
- }).pipe(
4427
- Effect5.andThen(effect),
4428
- Effect5.ensuring(
4429
- Effect5.sync(() => {
4430
- this.#pendingSources.delete(source);
4431
- this.#publish();
4432
- })
4433
- )
4898
+ return this.#gates.get(source).withPermit(this.#pending(source, effect));
4899
+ }
4900
+ #pending(source, effect) {
4901
+ return Effect5.sync(() => {
4902
+ this.#pendingSources.set(source, (this.#pendingSources.get(source) ?? 0) + 1);
4903
+ this.#publish();
4904
+ }).pipe(
4905
+ Effect5.andThen(effect),
4906
+ Effect5.ensuring(
4907
+ Effect5.sync(() => {
4908
+ const remaining = this.#pendingSources.get(source) - 1;
4909
+ if (remaining === 0) this.#pendingSources.delete(source);
4910
+ else this.#pendingSources.set(source, remaining);
4911
+ this.#publish();
4912
+ })
4434
4913
  )
4435
4914
  );
4436
4915
  }
@@ -4873,9 +5352,9 @@ var MediaControllerRuntime = class {
4873
5352
  const snapshot = this.#ports?.media.getSnapshot();
4874
5353
  const state = this.#connection.getSnapshot().state;
4875
5354
  const local = Object.freeze({
4876
- microphone: localMedia("microphone", this.#localTracks(), snapshot, this.#sources.microphone.localEnabled, state, this.#pendingSources.has("microphone")),
4877
- camera: localMedia("camera", this.#localTracks(), snapshot, this.#sources.camera.localEnabled, state, this.#pendingSources.has("camera")),
4878
- screen: localMedia("screen", this.#localTracks(), snapshot, false, state, this.#pendingSources.has("screen"))
5355
+ microphone: localMedia("microphone", this.#localTracks(), snapshot, this.#sources.microphone.localEnabled, state, this.#pendingSources.has("microphone") && this.#sources.microphone.desired.enabled),
5356
+ camera: localMedia("camera", this.#localTracks(), snapshot, this.#sources.camera.localEnabled, state, this.#pendingSources.has("camera") && this.#sources.camera.desired.enabled),
5357
+ screen: localMedia("screen", this.#localTracks(), snapshot, false, state, this.#pendingSources.has("screen") && this.#sources.screen.desired.enabled)
4879
5358
  });
4880
5359
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
4881
5360
  const media = Object.freeze({
@@ -4929,8 +5408,8 @@ function initialTrackEntries(microphone, camera) {
4929
5408
  function localMedia(source, tracks, snapshot, intended, connectionState, pending) {
4930
5409
  const track = tracks.get(source) ?? null;
4931
5410
  const publication = snapshot?.localTracks.find((candidate) => candidate.source === source);
4932
- if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
4933
5411
  if (pending) return Object.freeze({ source, state: "requesting", track });
5412
+ if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
4934
5413
  if (publication?.enabled) return Object.freeze({ source, state: "enabled", track });
4935
5414
  if (publication && connectionState === "live") return Object.freeze({ source, state: "disabled", track });
4936
5415
  return Object.freeze({ source, state: localMediaState(source, track, intended, connectionState), track });
@@ -5364,6 +5843,7 @@ var ParticipantsControllerRuntime = class {
5364
5843
  publish();
5365
5844
  }
5366
5845
  #publish(snapshot, collaborationCapabilities, media) {
5846
+ if (snapshot && !controlFor(snapshot)) return;
5367
5847
  const audioParticipants = new Set(media?.remoteTracks.filter((track) => track.source === "screen_audio" && track.track.readyState === "live").map((track) => track.participantId));
5368
5848
  if (snapshot?.participantId && media?.localTracks.some((track) => track.source === "screen_audio" && track.enabled && track.track.readyState === "live")) audioParticipants.add(snapshot.participantId);
5369
5849
  const projected = participantsFor(snapshot, collaborationCapabilities);
@@ -5698,7 +6178,7 @@ function foreign5(operation) {
5698
6178
 
5699
6179
  // src/space-client/whiteboard-controller.ts
5700
6180
  import { Context as Context11, Effect as Effect10, Layer as Layer11 } from "effect";
5701
- var EMPTY_SUMMARY = Object.freeze({ status: "unsubscribed", sceneId: null, revision: null, capabilities: Object.freeze([]), canDraw: false, canClear: false, presenting: false, error: null });
6181
+ var EMPTY_SUMMARY = Object.freeze({ status: "unsubscribed", sceneId: null, revision: null, capabilities: Object.freeze([]), canDraw: false, canClear: false, drawPolicy: null, drawPermissions: Object.freeze([]), presenting: false, error: null });
5702
6182
  var WhiteboardControllerService = class extends Context11.Service()("@chalk/client/WhiteboardController") {
5703
6183
  };
5704
6184
  var makeWhiteboardController = (connection, store, create) => Effect10.gen(function* () {
@@ -5721,13 +6201,22 @@ var WhiteboardControllerRuntime = class {
5721
6201
  #connected = false;
5722
6202
  #summary = EMPTY_SUMMARY;
5723
6203
  #unsubscribe = null;
6204
+ #unsubscribeRoster = null;
5724
6205
  constructor(connection, store, transport) {
5725
6206
  this.#store = store;
5726
6207
  this.#transport = transport;
6208
+ let roster = new Set(store.getSnapshot().participants.roster.map((person) => person.participantId));
6209
+ this.#unsubscribeRoster = store.subscribe(() => {
6210
+ const next = new Set(store.getSnapshot().participants.roster.map((person) => person.participantId));
6211
+ for (const id of roster) if (!next.has(id)) transport?.forgetParticipant?.(id);
6212
+ roster = next;
6213
+ });
5727
6214
  this.#unsubscribe = connection.subscribePorts((ports) => this.#bind(ports));
5728
6215
  }
5729
6216
  transport = () => this.#transport;
5730
6217
  dispose() {
6218
+ this.#unsubscribeRoster?.();
6219
+ this.#unsubscribeRoster = null;
5731
6220
  this.#unsubscribe?.();
5732
6221
  this.#unsubscribe = null;
5733
6222
  this.#bind(null);
@@ -5741,6 +6230,8 @@ var WhiteboardControllerRuntime = class {
5741
6230
  capabilities: Object.freeze([...summary.capabilities]),
5742
6231
  canDraw: summary.canDraw,
5743
6232
  canClear: summary.canClear,
6233
+ drawPolicy: summary.drawPolicy,
6234
+ drawPermissions: Object.freeze(summary.drawPermissions.map((permission) => Object.freeze({ ...permission }))),
5744
6235
  presenting: summary.presenting,
5745
6236
  error: summary.error ? Object.freeze({ ...summary.error }) : null
5746
6237
  });
@@ -5758,14 +6249,21 @@ var WhiteboardControllerRuntime = class {
5758
6249
  this.#publish();
5759
6250
  }
5760
6251
  #publish() {
5761
- const slice = Object.freeze({ open: this.#summary.status !== "unsubscribed", engine: Object.freeze({ status: this.#summary.status, sceneId: this.#summary.sceneId, revision: this.#summary.revision, presenting: this.#summary.presenting, error: this.#summary.error }) });
6252
+ const slice = Object.freeze({
6253
+ open: this.#summary.status !== "unsubscribed",
6254
+ engine: Object.freeze({ status: this.#summary.status, sceneId: this.#summary.sceneId, revision: this.#summary.revision, presenting: this.#summary.presenting, error: this.#summary.error }),
6255
+ access: Object.freeze({ canDraw: this.#summary.canDraw, policy: this.#summary.drawPolicy, permissions: this.#summary.drawPermissions })
6256
+ });
5762
6257
  const current = this.#store.getSnapshot().whiteboard;
5763
6258
  if (sameWhiteboard(current, slice)) return;
5764
6259
  this.#store.updateWhiteboard(slice);
5765
6260
  }
5766
6261
  };
5767
6262
  function sameWhiteboard(left, right) {
5768
- return left.open === right.open && left.engine.status === right.engine.status && left.engine.sceneId === right.engine.sceneId && left.engine.revision === right.engine.revision && left.engine.presenting === right.engine.presenting && sameError2(left.engine.error, right.engine.error);
6263
+ return left.open === right.open && left.engine.status === right.engine.status && left.engine.sceneId === right.engine.sceneId && left.engine.revision === right.engine.revision && left.engine.presenting === right.engine.presenting && sameError2(left.engine.error, right.engine.error) && sameAccess(left.access, right.access);
6264
+ }
6265
+ function sameAccess(left, right) {
6266
+ return left.canDraw === right.canDraw && left.policy === right.policy && left.permissions.length === right.permissions.length && left.permissions.every((permission, index) => permission.participantId === right.permissions[index]?.participantId && permission.canDraw === right.permissions[index]?.canDraw);
5769
6267
  }
5770
6268
  function sameError2(left, right) {
5771
6269
  if (left === right) return true;
@@ -5857,7 +6355,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
5857
6355
  diagnostics: {
5858
6356
  onEvent: (event) => {
5859
6357
  platform.onConnectionDiagnostic?.(event);
5860
- options.logger?.debug?.("SpaceClient connection event", { event: event.event, state: event.state, epoch: event.epoch, ...event.code ? { code: event.code } : {} });
6358
+ options.logger?.debug?.("SpaceClient connection event", { event: event.event, state: event.state, epoch: event.epoch, ...event.code ? { code: event.code } : {}, ...event.sync ? { sync: event.sync } : {} });
5861
6359
  }
5862
6360
  }
5863
6361
  });
@@ -5913,7 +6411,7 @@ var SpaceClientCore = class {
5913
6411
  this.#store.updateConnection(snapshot);
5914
6412
  const newlyEnded = snapshot.episodeEnded && !this.#episodeEnded;
5915
6413
  this.#episodeEnded = snapshot.episodeEnded ?? false;
5916
- if (newlyEnded) this.#emit("episodeEnded", { episode });
6414
+ if (newlyEnded) this.#emit("episodeEnded", { episode, reason: snapshot.episodeEndReason });
5917
6415
  });
5918
6416
  }
5919
6417
  getSnapshot = () => this.#store.getSnapshot();
@@ -6118,13 +6616,14 @@ export {
6118
6616
  CHALK_CHAT_ATTACHMENT_MIME_TYPES,
6119
6617
  ChalkChatFileError,
6120
6618
  createChalkChatFileHttpTransport,
6619
+ ChalkWhiteboardViewBounds,
6121
6620
  createChalkWhiteboardV1FileDownloadTransport,
6621
+ ChalkWhiteboardViewVersions,
6122
6622
  encodeWhiteboardV1ClientFrame,
6123
6623
  decodeWhiteboardV1ServerFrame,
6124
6624
  InMemoryChalkWhiteboardV1PendingOperationStore,
6125
6625
  ChalkWhiteboardV1Client,
6126
6626
  createChalkWhiteboardV1Client,
6127
- ChalkWhiteboardViewBounds,
6128
6627
  makeProductionConnectionPlatformLayer,
6129
6628
  ConnectionLifecycleService,
6130
6629
  makeConnectionLifecycleLayer,