@seatlayer/js 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,190 @@
1
1
  import { PickerSeat, RendererViewMode, SeatHoverDetails, PickerTransport, ChartDoc, AvailabilityRule, ChartTheme, ExpandedSeat } from '@seatlayer/core';
2
2
  export { ExpandedSeat, SeatHoverDetails } from '@seatlayer/core';
3
3
 
4
+ /**
5
+ * Buyer access context — the browser half of the Sales Channels contract
6
+ * (`docs/sales-channels-integration-guide-2026-08-01.md` §6, §9, §10).
7
+ *
8
+ * A promoter's own backend mints a short-lived, opaque buyer-access session
9
+ * (`bse_…`) that grants exactly one event's private channel scope. That bearer
10
+ * reaches the browser, and only the browser — this module is where it lives,
11
+ * and the rules it enforces are the ones the guide states outright:
12
+ *
13
+ * - the token is held in memory on a private field. It is never written to
14
+ * localStorage/sessionStorage/cookies, never appended to a URL, and never
15
+ * placed in a log, an Error message, telemetry, or JSON. `toJSON()` and
16
+ * `toString()` are overridden so an accidental `JSON.stringify(context)` or
17
+ * template interpolation cannot leak it;
18
+ * - refresh goes through the host's `buyerAccessTokenProvider`, which is
19
+ * called with a `reason` so the host can distinguish a first acquisition
20
+ * from an expiry from a 401;
21
+ * - **a configured context never falls back to anonymous Public sale.** When
22
+ * no bearer can be obtained the operation fails with a typed access error
23
+ * instead of going out unauthenticated. Sending the request without the
24
+ * bearer would silently widen the buyer's scope to Public — the exact
25
+ * failure the feature exists to prevent (guide §7).
26
+ *
27
+ * Deliberately free of any `@seatlayer/core` import: it deals in tokens, HTTP
28
+ * status codes and callbacks only, so it vendors into the app's widget copy
29
+ * with no engine coupling.
30
+ */
31
+ /** Why the SDK is asking the host for a token. Passed to the provider. */
32
+ type BuyerAccessRefreshReason =
33
+ /** First acquisition, before the chart is fetched. */
34
+ 'initial'
35
+ /** Proactive: the held token is inside the renewal skew. */
36
+ | 'expiring'
37
+ /** Reactive: the held token's own expiry has passed. */
38
+ | 'expired'
39
+ /** Reactive: the server answered 401 `buyer_access_expired`. */
40
+ | 'unauthorized'
41
+ /** A realtime reconnect needs a live bearer to mint a subscribe ticket. */
42
+ | 'reconnect'
43
+ /** The host called `refreshAccess()`. */
44
+ | 'manual';
45
+ /** What a `buyerAccessTokenProvider` resolves to — the response body of the
46
+ * host's own mint endpoint, unchanged. */
47
+ interface BuyerAccessToken {
48
+ /** The opaque `bse_…` buyer-access session bearer. */
49
+ token: string;
50
+ /** Epoch ms. Optional — absent means "trust the server", and the SDK then
51
+ * refreshes only reactively on a 401. */
52
+ expiresAt?: number;
53
+ }
54
+ type BuyerAccessTokenProvider = (context: {
55
+ reason: BuyerAccessRefreshReason;
56
+ }) => BuyerAccessToken | Promise<BuyerAccessToken>;
57
+ /**
58
+ * Why private inventory is not available. Never collapsed into a generic
59
+ * network failure (guide §10) and never carrying channel identity — the buyer
60
+ * is told the state, not which allocation they missed.
61
+ */
62
+ type BuyerAccessUnavailableReason =
63
+ /** The session was revoked (HTTP 401 after refresh, or WS close 4401). */
64
+ 'revoked'
65
+ /** The channel is paused — a legitimate, temporary organizer state. */
66
+ | 'paused'
67
+ /** 401 `buyer_access_invalid`: do not retry this bearer. */
68
+ | 'invalid'
69
+ /** 403 `buyer_access_origin_mismatch`. */
70
+ | 'origin_mismatch'
71
+ /** 403 `buyer_access_event_mismatch`. */
72
+ | 'event_mismatch'
73
+ /** 403 `buyer_access_mode_mismatch` (test bearer on a live event or v.v.). */
74
+ | 'mode_mismatch'
75
+ /** 403 `channel_access_denied`. */
76
+ | 'channel_denied'
77
+ /** 422 `invalid_channel_scope` — an integration configuration error. */
78
+ | 'invalid_scope'
79
+ /** The host's token provider threw or returned nothing usable. */
80
+ | 'provider_failed'
81
+ /** A one-shot `buyerAccessToken` lapsed and no provider was configured. */
82
+ | 'no_token';
83
+ /** The access session expired. Carries whether the refresh recovered it. */
84
+ interface BuyerAccessExpiredEvent {
85
+ reason: BuyerAccessRefreshReason;
86
+ /** The server's machine code when the expiry was observed over HTTP. */
87
+ code?: string;
88
+ /** True when the provider handed back a fresh token and work continues. */
89
+ refreshed: boolean;
90
+ }
91
+ /** Private inventory is unavailable, and refreshing will not fix it. */
92
+ interface BuyerAccessUnavailableEvent {
93
+ reason: BuyerAccessUnavailableReason;
94
+ /** The server's machine code, when there was one. */
95
+ code?: string;
96
+ /** The HTTP status, when the state came from an HTTP response. */
97
+ status?: number;
98
+ /** True only for states a later retry could clear (`paused`). */
99
+ retryable: boolean;
100
+ }
101
+ /** One or more selected-but-unheld units stopped being selectable. */
102
+ interface SelectedObjectUnavailableEvent {
103
+ /** Inventory labels (never channel identity). */
104
+ labels: string[];
105
+ reason:
106
+ /** An allocation change moved it out of this buyer's scope (guide §9). */
107
+ 'ineligible'
108
+ /** Someone else held or booked it. */
109
+ | 'taken'
110
+ /** 409 `allocation_exhausted` — this private allocation has none left. */
111
+ | 'exhausted';
112
+ code?: string;
113
+ }
114
+ interface BuyerAccessContextOptions {
115
+ provider?: BuyerAccessTokenProvider;
116
+ /** One-shot escape hatch for hosts that already own the token lifecycle. */
117
+ token?: string | BuyerAccessToken;
118
+ /** Renew this long before the stated expiry. Default 30s. */
119
+ skewMs?: number;
120
+ onExpired?: (event: BuyerAccessExpiredEvent) => void;
121
+ onUnavailable?: (event: BuyerAccessUnavailableEvent) => void;
122
+ }
123
+ /**
124
+ * Thrown instead of letting a scoped request go out unauthenticated. Carries no
125
+ * bearer and no channel identity, so it is safe to log or hand to an error
126
+ * reporter verbatim.
127
+ */
128
+ declare class BuyerAccessUnavailableError extends Error {
129
+ readonly reason: BuyerAccessUnavailableReason;
130
+ readonly code?: string;
131
+ readonly status?: number;
132
+ constructor(event: BuyerAccessUnavailableEvent);
133
+ }
134
+ declare class BuyerAccessContext {
135
+ #private;
136
+ constructor(options: BuyerAccessContextOptions);
137
+ /**
138
+ * True when this picker is access-scoped at all. A false here is the
139
+ * tokenless public picker, which must behave exactly as it always has.
140
+ *
141
+ * Answered from what the HOST asked for, never from live token state. It used
142
+ * to be `!!#provider || !!#token`, which quietly inverted this file's central
143
+ * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
144
+ * the first refusal turned a configured context into an "unconfigured" one,
145
+ * `authorization()` then returned null instead of throwing, and the very next
146
+ * call went out with no bearer — the anonymous Public sale fallback this
147
+ * module exists to prevent. A provider host never saw it, because `#provider`
148
+ * held `configured` true. Found against a live worker in the M9 pass.
149
+ */
150
+ get configured(): boolean;
151
+ /** Set once a state arrives that refreshing cannot clear. */
152
+ get unavailable(): BuyerAccessUnavailableEvent | null;
153
+ /** True while a usable bearer is held (ignores skew). */
154
+ get hasToken(): boolean;
155
+ /** Epoch ms the current token expires, or 0 when the host didn't say. */
156
+ get expiresAt(): number;
157
+ /**
158
+ * The `Authorization` header value for a scoped operation.
159
+ *
160
+ * Returns null only when this context is not configured at all (the ordinary
161
+ * anonymous public picker). A configured context either returns a bearer or
162
+ * throws `BuyerAccessUnavailableError` — it never returns null, because a
163
+ * null here would send the request as anonymous Public sale.
164
+ */
165
+ authorization(reason?: BuyerAccessRefreshReason): Promise<string | null>;
166
+ /**
167
+ * Handle a 401/403 from a scoped call. Returns true when the caller should
168
+ * retry the same request once with the refreshed bearer.
169
+ */
170
+ handleFailure(status: number, code: string | undefined): Promise<boolean>;
171
+ /** Host-driven re-acquisition (after the buyer signs in again, say). */
172
+ refresh(reason?: BuyerAccessRefreshReason): Promise<boolean>;
173
+ /** Drop the bearer. Called on destroy so nothing outlives the widget. */
174
+ clear(): void;
175
+ /** Redaction: the bearer must not survive a stringify or an interpolation. */
176
+ toJSON(): {
177
+ configured: boolean;
178
+ hasToken: boolean;
179
+ };
180
+ toString(): string;
181
+ }
182
+ /** Build a context from widget options, or null for the tokenless public path. */
183
+ declare function createBuyerAccessContext(options: {
184
+ buyerAccessTokenProvider?: BuyerAccessTokenProvider;
185
+ buyerAccessToken?: string | BuyerAccessToken;
186
+ }, hooks?: Pick<BuyerAccessContextOptions, 'onExpired' | 'onUnavailable'>): BuyerAccessContext | null;
187
+
4
188
  /**
5
189
  * Minimal client for the public embed surface of workers/api (the `/pub/*`
6
190
  * routes). Deliberately self-contained — it does NOT reuse src/lib/api.ts,
@@ -55,6 +239,19 @@ interface BestAvailableResult {
55
239
  items?: HoldResult['items'];
56
240
  zoneId?: string;
57
241
  }
242
+ interface PubApiOptions {
243
+ /**
244
+ * Buyer access session. When present, EVERY scoped operation on this client
245
+ * carries `Authorization: Bearer bse_…` — chart, objects, hold, replace-hold,
246
+ * best-available, resume, release, extend, resnapshot and the realtime
247
+ * subscribe ticket. The binding is immutable for the client's lifetime: there
248
+ * is no method that turns it off, so no operation can silently downgrade to
249
+ * anonymous Public sale (guide §6, §7).
250
+ */
251
+ access?: BuyerAccessContext;
252
+ /** A 409 named specific inventory the buyer can no longer have. */
253
+ onObjectUnavailable?: (event: SelectedObjectUnavailableEvent) => void;
254
+ }
58
255
 
59
256
  /**
60
257
  * SeatingChart — the embeddable buyer picker.
@@ -92,8 +289,32 @@ interface SeatingChartOptions {
92
289
  event: string;
93
290
  /** API origin. Defaults to https://api.seatlayer.io. */
94
291
  apiBase?: string;
95
- /** Reserved for future authenticated rendering — accepted + stored, not yet sent. */
292
+ /** Reserved for future authenticated rendering — accepted + stored, not yet sent.
293
+ * NOT the channel-access credential: a buyer access session is a different
294
+ * thing with different authority, and uses the two options below. */
96
295
  publicKey?: string;
296
+ /**
297
+ * Buyer access session provider — the recommended way to render private
298
+ * channel inventory (Sales Channels guide §6).
299
+ *
300
+ * Called with a `reason` whenever the SDK needs a bearer: first acquisition,
301
+ * a near/actual expiry, a 401 `buyer_access_expired`, a realtime reconnect,
302
+ * or `refreshAccess()`. It should POST to YOUR backend, which mints the
303
+ * session with your secret key and returns `{ token, expiresAt }`.
304
+ *
305
+ * The token lives in memory for the widget's lifetime and nowhere else: never
306
+ * in storage, never in a URL, never in a log or an error message. Refresh
307
+ * returns the same or a narrower scope — the SDK never widens to Public sale
308
+ * on its own, and a failed refresh stops the scoped operation rather than
309
+ * retrying it anonymously.
310
+ */
311
+ buyerAccessTokenProvider?: BuyerAccessTokenProvider;
312
+ /**
313
+ * One-shot escape hatch for hosts that already own the session lifecycle.
314
+ * Cannot be renewed — when it lapses the widget reports `onAccessExpired`
315
+ * and then `onAccessUnavailable`. Prefer `buyerAccessTokenProvider`.
316
+ */
317
+ buyerAccessToken?: string | BuyerAccessToken;
97
318
  /** Max seats selectable at once (default 10). */
98
319
  maxSelection?: number;
99
320
  /**
@@ -138,6 +359,25 @@ interface SeatingChartOptions {
138
359
  onHoldRestored?: (result: HoldResult) => void;
139
360
  onHoldExpired?: () => void;
140
361
  onGAClick?: (area: GAAreaAvailability) => void;
362
+ /**
363
+ * The buyer access session lapsed. `refreshed` says whether the provider
364
+ * already recovered it — false means private inventory is now unavailable and
365
+ * `onAccessUnavailable` follows. Distinct from `onError` on purpose: this is
366
+ * never a network failure (guide §10).
367
+ */
368
+ onAccessExpired?: (event: BuyerAccessExpiredEvent) => void;
369
+ /**
370
+ * Private inventory is unavailable and refreshing will not fix it — revoked,
371
+ * paused, wrong origin/event/mode, or the provider failed. Carries a reason,
372
+ * never a channel name, id, colour or count.
373
+ */
374
+ onAccessUnavailable?: (event: BuyerAccessUnavailableEvent) => void;
375
+ /**
376
+ * Selected-but-unheld units stopped being selectable — someone else took
377
+ * them, or an allocation change moved them out of this buyer's scope. The
378
+ * widget has already dropped them from the selection.
379
+ */
380
+ onSelectedObjectUnavailable?: (event: SelectedObjectUnavailableEvent) => void;
141
381
  onError?: (err: unknown) => void;
142
382
  /**
143
383
  * Multi-floor charts only: fires when the buyer taps a deck in the stacked
@@ -164,6 +404,10 @@ declare class SeatingChart {
164
404
  private tipEl;
165
405
  private tipPos;
166
406
  private onTipMove;
407
+ /** Null for the ordinary public chart — the tokenless path is untouched. */
408
+ private readonly access;
409
+ private readonly api;
410
+ private realtime;
167
411
  constructor(options: SeatingChartOptions);
168
412
  /** Fetch the chart, mount the renderer, seed statuses and go live. Idempotent. */
169
413
  render(): Promise<this>;
@@ -286,9 +530,180 @@ declare class SeatingChart {
286
530
  /** Release selected labels from the current hold while keeping the remainder. */
287
531
  releaseLabels(labels: string[]): Promise<boolean>;
288
532
  /** Tear everything down: close the socket, stop timers, drop the canvas. */
533
+ /**
534
+ * Realtime for an access-scoped chart.
535
+ *
536
+ * A tokenless chart never gets here: `access` is null, `PubApi.socketUrl()`
537
+ * returns the URL it always has, and PickerController keeps its own socket
538
+ * and its own legacy frames. Nothing about the public path changes.
539
+ */
540
+ private startRealtime;
541
+ /**
542
+ * Re-acquire the buyer access session — call after your app has re-authorized
543
+ * the buyer (a revoked session cannot be recovered any other way). Resolves
544
+ * true when a fresh bearer is held; the realtime feed restarts with it.
545
+ */
546
+ refreshAccess(): Promise<boolean>;
289
547
  destroy(): void;
290
548
  }
291
549
 
550
+ /**
551
+ * BuyerRealtimeClient — the client half of `docs/realtime-protocol-2026-08-01.md`.
552
+ *
553
+ * Why this exists as a separate socket rather than inside PickerController:
554
+ * a private scope authenticates with a one-use **subscribe ticket** carried in
555
+ * `Sec-WebSocket-Protocol`, and a browser can only set that at construction —
556
+ * `new WebSocket(url, protocols)`. The controller's socket is built from a URL
557
+ * alone (`PickerTransport.socketUrl`), and a bearer must never travel in a URL
558
+ * because URLs are routinely logged. So an access-scoped picker asks the
559
+ * transport for an empty `socketUrl()` (the controller then skips its own
560
+ * connection entirely) and this client owns the wire instead.
561
+ *
562
+ * A tokenless public picker never reaches this file. It keeps the controller's
563
+ * original socket, offers no subprotocol, and therefore receives byte-for-byte
564
+ * the frames it received before this module existed (protocol doc §1).
565
+ *
566
+ * What it implements:
567
+ * - protocol negotiation: offer `seatlayer.v1`, believe the 101 echo, and fall
568
+ * back to legacy frame handling when the server (or a proxy) does not echo;
569
+ * - the ticket exchange, one mint per connection attempt, ticket in the
570
+ * subprotocol list and never in the URL;
571
+ * - compact `{default, exceptions}` snapshot reconstruction;
572
+ * - `sv.<n>` resume, handling BOTH outcomes (a `resumed` delta or a full
573
+ * snapshot) on every reconnect;
574
+ * - close code 4401 as a typed access-revoked state, never a reconnect loop;
575
+ * - liveness by ping/pong only. Silence is normal and carries no information
576
+ * (protocol doc §5) — a quiet socket is never treated as a dead one.
577
+ */
578
+
579
+ /** The projected status of one unit, as the server words it on the wire. */
580
+ type WireStatus = string;
581
+ /** A scope's projection: one default plus the units that differ from it. */
582
+ interface Projection {
583
+ default: WireStatus;
584
+ exceptions: Record<string, WireStatus>;
585
+ }
586
+ interface StatusChange {
587
+ label: string;
588
+ status: WireStatus;
589
+ }
590
+ /** Where reconstructed inventory goes. Implemented over PickerController. */
591
+ interface RealtimeSink {
592
+ /** Apply a batch of label→status changes. Implementations must paint this as
593
+ * ONE pass, not one pass per label (motion system §4 rule 2). */
594
+ applyStatuses(changes: StatusChange[]): void;
595
+ /** Re-pull authoritative state over the scoped HTTP route. Used when a frame
596
+ * cannot be diffed against what we hold (first snapshot, or the scope's
597
+ * default itself changed, which redefines every unit we were never told
598
+ * about). */
599
+ resync(): void | Promise<void>;
600
+ /** Section availability changed (channel-agnostic; identical for every scope). */
601
+ onSections?(hidden: string[], closed: string[]): void;
602
+ /** Scope-projected presence counters. */
603
+ onPresence?(counts: {
604
+ shoppingSessions: number;
605
+ activeHolds: number;
606
+ }): void;
607
+ }
608
+ interface SubscribeTicket {
609
+ ticket?: string;
610
+ /** Exactly what to hand `new WebSocket(url, protocols)`, per protocol doc §3. */
611
+ protocols?: string[];
612
+ }
613
+ interface BuyerRealtimeOptions {
614
+ /** The subscribe URL. Must never carry a credential — asserted below. */
615
+ url: string;
616
+ sink: RealtimeSink;
617
+ /** Mint a one-use ticket for THIS connection attempt. Returns null for the
618
+ * anonymous public case (no ticket needed). Throwing stops the client. */
619
+ mintTicket?: () => Promise<SubscribeTicket | null>;
620
+ /** Typed access states. 4401 arrives here as `revoked`. */
621
+ onAccessUnavailable?: (event: BuyerAccessUnavailableEvent) => void;
622
+ /** Test seam. Defaults to the global WebSocket. */
623
+ socketFactory?: (url: string, protocols: string[]) => WebSocket;
624
+ /** Test seam for the keepalive/backoff timers. */
625
+ now?: () => number;
626
+ }
627
+ declare class BuyerRealtimeClient {
628
+ private readonly opts;
629
+ private ws;
630
+ private stopped;
631
+ private attempt;
632
+ private reconnectTimer;
633
+ private pingTimer;
634
+ private pongTimer;
635
+ private resumeTimer;
636
+ /** Our model of this scope's projection. Null until the first snapshot. */
637
+ private projection;
638
+ /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
639
+ private version;
640
+ /** True once the 101 echoed `seatlayer.v1`. */
641
+ private v1;
642
+ /** Set when we offered v1 and the handshake came back without it — a proxy
643
+ * most likely stripped the header, so the next attempt selects the v1 frame
644
+ * format with the `?pv=1` marker instead (protocol doc §1). The marker
645
+ * selects a format and can never carry a credential or widen a scope. */
646
+ private useQueryMarker;
647
+ private hidden;
648
+ private closedSections;
649
+ constructor(options: BuyerRealtimeOptions);
650
+ /** Negotiated protocol, for tests and diagnostics. */
651
+ get protocol(): 'v1' | 'legacy' | null;
652
+ get snapshotVersion(): number | null;
653
+ start(): void;
654
+ /** Stop for good (destroy, or a revocation). Safe to call twice. */
655
+ stop(): void;
656
+ /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
657
+ restart(): void;
658
+ private connect;
659
+ private handleFrame;
660
+ /** The server answered our resume; cancel the fallback resync. */
661
+ private answered;
662
+ private reportIfAccessError;
663
+ /**
664
+ * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
665
+ * for minutes is the normal, correct state for a narrowly-scoped buyer on a
666
+ * busy event (protocol doc §5), so quiet time never triggers a reconnect.
667
+ */
668
+ private startKeepalive;
669
+ private scheduleReconnect;
670
+ private clearPongTimer;
671
+ private clearTimers;
672
+ }
673
+ /**
674
+ * The slice of PickerController this module drives. Structural on purpose — it
675
+ * keeps this file free of an `@seatlayer/core` import, and every member here is
676
+ * public controller API, so nothing in the engine mirror has to change.
677
+ */
678
+ interface PickerControllerLike {
679
+ idForLabel(label: string): string | undefined;
680
+ tableSelection(seatIdOrLabel: string): {
681
+ physicalSeatIds: string[];
682
+ } | null;
683
+ setStatus(ids: string[], status: 'free' | 'held' | 'booked' | 'not_for_sale'): void;
684
+ getStatus(id: string): string | undefined;
685
+ flashSeat(id: string, color?: string): void;
686
+ currentHold(): {
687
+ labels: string[];
688
+ } | null;
689
+ getSelection(): Array<{
690
+ id: string;
691
+ label: string;
692
+ }>;
693
+ deselect(ids: string[]): void;
694
+ refresh(): Promise<void>;
695
+ }
696
+ interface ControllerSinkOptions {
697
+ /** Pulse seats other buyers take, as the controller's own socket does. */
698
+ flashOnLiveChange?: boolean;
699
+ /** Selected-but-unheld units that stopped being selectable. */
700
+ onSelectedObjectUnavailable?: (labels: string[], reason: 'ineligible' | 'taken') => void;
701
+ /** Section availability changed and the chart itself needs rebuilding. */
702
+ onSections?: (hidden: string[], closed: string[]) => void;
703
+ onStatusChange?: () => void;
704
+ }
705
+ declare function createControllerSink(controller: PickerControllerLike, options?: ControllerSinkOptions): RealtimeSink;
706
+
292
707
  /**
293
708
  * A secure, framework-neutral host for the SeatLayer chart Designer.
294
709
  *
@@ -653,8 +1068,37 @@ interface SeatPickerOptions {
653
1068
  * SeatLayer dashboard's own transport) or a fully local mock (demos).
654
1069
  */
655
1070
  transport?: PickerTransport;
656
- /** Reserved for future authenticated rendering. */
1071
+ /** Reserved for future authenticated rendering. NOT the channel-access
1072
+ * credential — a buyer access session is a different thing with different
1073
+ * authority, and uses the two options below. */
657
1074
  publicKey?: string;
1075
+ /**
1076
+ * Buyer access session provider — the recommended way to show private channel
1077
+ * inventory (Sales Channels guide §6).
1078
+ *
1079
+ * Called with a `reason` whenever the widget needs a bearer: first
1080
+ * acquisition, a near/actual expiry, a 401 `buyer_access_expired`, a realtime
1081
+ * reconnect, or `refreshAccess()`. It should POST to YOUR backend, which
1082
+ * mints the session with your secret key and returns `{ token, expiresAt }`.
1083
+ *
1084
+ * The token lives in memory for the widget's lifetime and nowhere else: never
1085
+ * in storage, never in a URL, never in a log or an error message. Refresh
1086
+ * returns the same or a narrower scope; the widget never widens to Public
1087
+ * sale on its own, and a failed refresh stops the scoped operation rather
1088
+ * than retrying it anonymously. Any held seats stay held — a hold is
1089
+ * relinquished by its own opaque capability, not by channel access, so
1090
+ * losing access never strands inventory (guide §9).
1091
+ *
1092
+ * Ignored when a custom `transport` is supplied: that host owns its own
1093
+ * credentials.
1094
+ */
1095
+ buyerAccessTokenProvider?: BuyerAccessTokenProvider;
1096
+ /**
1097
+ * One-shot escape hatch for hosts that already own the session lifecycle.
1098
+ * Cannot be renewed — when it lapses the widget reports `onAccessExpired`
1099
+ * and then `onAccessUnavailable`. Prefer `buyerAccessTokenProvider`.
1100
+ */
1101
+ buyerAccessToken?: string | BuyerAccessToken;
658
1102
  /** Max seats selectable at once (default 10). */
659
1103
  maxSelection?: number;
660
1104
  /** BCP 47 language for the widget UI. Built-in: en, es, de, fr. */
@@ -767,11 +1211,37 @@ interface SeatPickerOptions {
767
1211
  onHoldRestored?: (hold: HoldResult, seats: PickerSeat[], handoff: CheckoutHandoff) => void;
768
1212
  /** Modal only: the buyer closed the picker (ESC / scrim / ✕). */
769
1213
  onClose?: () => void;
1214
+ /**
1215
+ * The buyer access session lapsed. `refreshed` says whether the provider
1216
+ * already recovered it — false means private inventory is now unavailable and
1217
+ * `onAccessUnavailable` follows. Never collapsed into `onError`: an expiry is
1218
+ * a recoverable, buyer-explainable state, not a network failure (guide §10).
1219
+ */
1220
+ onAccessExpired?: (event: BuyerAccessExpiredEvent) => void;
1221
+ /**
1222
+ * Private inventory is unavailable and refreshing will not fix it — revoked,
1223
+ * paused, wrong origin/event/mode, or the provider failed. Carries a reason,
1224
+ * never a channel name, id, colour or count. The widget shows its own
1225
+ * explanatory panel; return nothing to keep it, or handle the state yourself.
1226
+ */
1227
+ onAccessUnavailable?: (event: BuyerAccessUnavailableEvent) => void;
1228
+ /**
1229
+ * Selected-but-unheld units stopped being selectable — someone else took
1230
+ * them, or an allocation change moved them out of this buyer's scope. The
1231
+ * widget has already dropped them from the tray.
1232
+ */
1233
+ onSelectedObjectUnavailable?: (event: SelectedObjectUnavailableEvent) => void;
770
1234
  onError?: (err: unknown) => void;
771
1235
  }
772
1236
  declare class SeatPicker {
773
1237
  private readonly opts;
774
1238
  private readonly api;
1239
+ /** Our own public client, or null when the host injected a transport. */
1240
+ private readonly pubApi;
1241
+ /** Null for the ordinary public picker — the tokenless path is untouched. */
1242
+ private readonly access;
1243
+ private realtime;
1244
+ private accessEl;
775
1245
  private readonly apiBase;
776
1246
  private readonly controller;
777
1247
  private readonly maxTickets;
@@ -1277,6 +1747,32 @@ declare class SeatPicker {
1277
1747
  zoneId?: string;
1278
1748
  }): Promise<HoldResult | null>;
1279
1749
  release(): Promise<void>;
1750
+ /**
1751
+ * Realtime for an access-scoped picker.
1752
+ *
1753
+ * A tokenless picker never gets here: `access` is null, `PubApi.socketUrl()`
1754
+ * returns the URL it always has, and PickerController keeps its own socket
1755
+ * and its own legacy frames. Nothing about the public path changes.
1756
+ */
1757
+ private startRealtime;
1758
+ /**
1759
+ * Re-acquire the buyer access session — call after your app has re-authorized
1760
+ * the buyer. A revoked session cannot recover any other way. Resolves true
1761
+ * when a fresh bearer is held; the map and the realtime feed resume with it.
1762
+ */
1763
+ refreshAccess(): Promise<boolean>;
1764
+ /**
1765
+ * The buyer-facing access state. Plain language, no internal vocabulary, and
1766
+ * never a channel name, id or count — the buyer is told what happened and
1767
+ * what to do, not which allocation they missed (guide §7, §10).
1768
+ *
1769
+ * Held seats are deliberately left alone: a hold is relinquished by its own
1770
+ * opaque capability, not by channel access, so losing access never strands
1771
+ * inventory and never silently drops a buyer's cart (guide §9).
1772
+ */
1773
+ private showAccessPanel;
1774
+ private dismissAccessPanel;
1775
+ private accessCopy;
1280
1776
  destroy(): void;
1281
1777
  }
1282
1778
 
@@ -1319,6 +1815,214 @@ interface AttachPickerFrameOptions {
1319
1815
  */
1320
1816
  declare function attachPickerFrame(iframe: HTMLIFrameElement, opts?: AttachPickerFrameOptions): () => void;
1321
1817
 
1818
+ /**
1819
+ * Sales-channel planning — the pure, DOM-free half of Channels mode.
1820
+ *
1821
+ * Everything here is deterministic and testable without a canvas: the marker
1822
+ * palette, the mixed-source selection summary, the LOCAL staged preview of an
1823
+ * assignment, and the bucket rows the Review sheet renders.
1824
+ *
1825
+ * The local preview deliberately produces the SAME `AssignmentBuckets` shape the
1826
+ * server returns from `POST /channels/assignments`. There is no dry-run endpoint,
1827
+ * so the review sheet is drawn from this local computation and then REDRAWN from
1828
+ * the authoritative server response after Apply. One renderer, two sources —
1829
+ * which is why the shapes must match exactly.
1830
+ *
1831
+ * Spec: sales-channels-product-ux-spec §8.4–8.5.
1832
+ */
1833
+ /** Public sale is a built-in pseudo-channel; the server uses '' as its id. */
1834
+ declare const PUBLIC_CHANNEL_ID = "";
1835
+ declare const PUBLIC_CHANNEL_NAME = "Public sale";
1836
+ type ChannelState = 'active' | 'paused' | 'archived';
1837
+ /** Physical inventory status, as the manage surface speaks it. */
1838
+ type ChannelSeatStatus = 'free' | 'held' | 'booked' | 'blocked';
1839
+ interface ChannelCounts {
1840
+ allocated: number;
1841
+ free: number;
1842
+ held: number;
1843
+ booked: number;
1844
+ blocked: number;
1845
+ units: number;
1846
+ }
1847
+ /** Buyer-access intents the server stores per channel. */
1848
+ type ChannelAccessIntent = 'none' | 'internal' | 'server' | 'hosted_link';
1849
+ /**
1850
+ * Buyer-access summary on a channel row. Shipped by the access hardening branch
1851
+ * (merged to app main). Still optional in this type: a worker that predates the
1852
+ * merge simply omits it and the rail reads "—" rather than inventing a state.
1853
+ */
1854
+ interface ChannelAccessSummary {
1855
+ intent?: ChannelAccessIntent | string;
1856
+ hasActiveGrants?: boolean;
1857
+ lastMintAt?: number | null;
1858
+ /** Free-text detail (partner host, who paused it) when the server offers one. */
1859
+ detail?: string | null;
1860
+ }
1861
+ interface ChannelRecord {
1862
+ id: string;
1863
+ name: string;
1864
+ color: string | null;
1865
+ marker: string | null;
1866
+ externalRef: string | null;
1867
+ state: ChannelState;
1868
+ archiveDestination: string | null;
1869
+ createdAt: number;
1870
+ updatedAt: number;
1871
+ archivedAt: number | null;
1872
+ counts: ChannelCounts;
1873
+ access?: ChannelAccessSummary | null;
1874
+ }
1875
+ interface PublicSaleChannel {
1876
+ id: typeof PUBLIC_CHANNEL_ID;
1877
+ name: string;
1878
+ state: 'active';
1879
+ counts: ChannelCounts;
1880
+ access?: ChannelAccessSummary | null;
1881
+ }
1882
+ interface ChannelListResult {
1883
+ assignmentVersion: number;
1884
+ publicSale: PublicSaleChannel;
1885
+ channels: ChannelRecord[];
1886
+ }
1887
+ interface AssignmentBucketCount {
1888
+ count: number;
1889
+ }
1890
+ interface AssignmentSkippedBucket extends AssignmentBucketCount {
1891
+ labels: string[];
1892
+ truncated: boolean;
1893
+ }
1894
+ interface AssignmentBuckets {
1895
+ changedFromPublic: AssignmentBucketCount;
1896
+ movedFromOtherChannel: AssignmentBucketCount & {
1897
+ channels: Array<{
1898
+ channelId: string;
1899
+ name: string | null;
1900
+ count: number;
1901
+ }>;
1902
+ };
1903
+ alreadyInTarget: AssignmentBucketCount;
1904
+ skippedHeld: AssignmentSkippedBucket;
1905
+ skippedBooked: AssignmentSkippedBucket;
1906
+ /** Requested labels that are not inventory in this event. */
1907
+ notFound: AssignmentSkippedBucket;
1908
+ }
1909
+ interface AssignmentResult {
1910
+ ok: true;
1911
+ targetChannelId: string;
1912
+ assignmentVersion: number;
1913
+ requested: number;
1914
+ applied: number;
1915
+ buckets: AssignmentBuckets;
1916
+ }
1917
+ interface ArchiveBlockedDetails {
1918
+ activeHolds?: number;
1919
+ heldUnits?: number;
1920
+ latestHoldExpiresAt?: number | null;
1921
+ retryAfterMs?: number;
1922
+ }
1923
+ /**
1924
+ * Suggest a marker for a new channel: the first letter of its name when that
1925
+ * letter is still free, otherwise the next unused letter. Deterministic so the
1926
+ * Create dialog's preview matches what actually gets stored.
1927
+ */
1928
+ declare function suggestMarker(name: string, taken: Iterable<string>): {
1929
+ letter: string;
1930
+ color: string;
1931
+ };
1932
+ /** The letter + color a channel actually renders with (server value wins). */
1933
+ declare function markerOf(channel: {
1934
+ id: string;
1935
+ name: string;
1936
+ marker?: string | null;
1937
+ color?: string | null;
1938
+ }, index?: number): {
1939
+ letter: string;
1940
+ color: string;
1941
+ };
1942
+ /** One line of the rail's mixed-source selection summary (§8.4). */
1943
+ interface SelectionSourceRow {
1944
+ channelId: string;
1945
+ name: string;
1946
+ count: number;
1947
+ }
1948
+ /**
1949
+ * Group the current selection by the channel each unit is allocated to today.
1950
+ * Public sale is listed first; the rest follow in list order so the rail's
1951
+ * ordering never jitters as the selection changes.
1952
+ */
1953
+ declare function selectionSources(labels: string[], allocation: Map<string, string>, list: ChannelListResult | null): SelectionSourceRow[];
1954
+ /**
1955
+ * The LOCAL staged preview of "move these labels to this channel".
1956
+ *
1957
+ * Mirrors the DO's rules exactly (eventChannels.applyAssignment):
1958
+ * - a unit already in the target is `alreadyInTarget`, whatever its status;
1959
+ * - otherwise held and booked units are skipped and never rewritten;
1960
+ * - otherwise a public unit is `changedFromPublic`, a private one is
1961
+ * `movedFromOtherChannel` (itemised per source);
1962
+ * - a label that is not inventory in this event is `notFound`.
1963
+ *
1964
+ * Every requested label lands in exactly one bucket — the property the Review
1965
+ * sheet's "every selected seat is in exactly one line" promise depends on.
1966
+ */
1967
+ declare function planAssignment(input: {
1968
+ labels: string[];
1969
+ targetChannelId: string;
1970
+ allocation: Map<string, string>;
1971
+ statusOf: (label: string) => ChannelSeatStatus | undefined;
1972
+ nameOf: (channelId: string) => string | null;
1973
+ }): AssignmentBuckets;
1974
+ /** Units this plan will actually mutate — what the Apply button counts. */
1975
+ declare function mutationCount(buckets: AssignmentBuckets): number;
1976
+ /** True when moving inventory out of another PRIVATE channel — §8.5 requires an
1977
+ * explicit confirmation line for exactly this case. */
1978
+ declare function needsMoveConfirmation(buckets: AssignmentBuckets): boolean;
1979
+ interface BucketRow {
1980
+ kind: 'add' | 'move' | 'same' | 'skip';
1981
+ icon: string;
1982
+ count: number;
1983
+ text: string;
1984
+ why?: string;
1985
+ /** Sampled seat labels for a skipped bucket, when the server sent any. */
1986
+ peek?: string;
1987
+ }
1988
+ /**
1989
+ * Render-ready rows for the Review sheet. The comp shows five lines; the server
1990
+ * carries a sixth bucket (`notFound`) which is emitted only when it is non-zero,
1991
+ * so a normal review still reads exactly like the approved design.
1992
+ *
1993
+ * Empty buckets are dropped — a zero line is noise, not honesty.
1994
+ */
1995
+ declare function bucketRows(buckets: AssignmentBuckets, targetName: string): BucketRow[];
1996
+ /**
1997
+ * "try again in ~N minutes" for the archive-blocked-by-holds 409 (§8.8).
1998
+ * Rounds up so the organizer never comes back one tick early.
1999
+ */
2000
+ declare function retryAfterCopy(details: ArchiveBlockedDetails | null | undefined): string;
2001
+ /** The access line under a channel row. Falls back to "—" before the hardening
2002
+ * branch lands the `access` field, never to a guess. */
2003
+ declare function accessLine(access: ChannelAccessSummary | null | undefined): string;
2004
+ /** Plain-language label for the access-intent control. */
2005
+ declare function accessIntentLabel(intent: ChannelAccessIntent): string;
2006
+ /**
2007
+ * The chart-update refusal `channel_assignment_would_drop` (409) deliberately
2008
+ * mirrors the Apply skipped buckets, so ONE review component renders both.
2009
+ * This adapts it into the same `BucketRow[]` the Review sheet already draws.
2010
+ */
2011
+ interface AssignmentDropDetails {
2012
+ droppedUnits?: number;
2013
+ channels?: Array<{
2014
+ channelId: string;
2015
+ name: string | null;
2016
+ count: number;
2017
+ labels?: string[];
2018
+ truncated?: boolean;
2019
+ }>;
2020
+ acknowledgeWith?: string;
2021
+ }
2022
+ declare function dropReviewRows(details: AssignmentDropDetails | null | undefined): BucketRow[];
2023
+ /** Plain-language state badge text (§9: no internal vocabulary on user surfaces). */
2024
+ declare function stateBadge(state: ChannelState | 'builtin'): string;
2025
+
1322
2026
  /**
1323
2027
  * Organizer manage-surface client for workers/api (the `/v1/events/:key/*`
1324
2028
  * inventory routes + the public realtime channel). Companion to api.ts (the
@@ -1340,6 +2044,59 @@ declare function attachPickerFrame(iframe: HTMLIFrameElement, opts?: AttachPicke
1340
2044
  * route is still session-only server-side).
1341
2045
  */
1342
2046
 
2047
+ /** One page of the organizer-only label → channel projection. */
2048
+ interface ChannelAllocationPage {
2049
+ assignmentVersion: number;
2050
+ allocations: Array<{
2051
+ label: string;
2052
+ channelId: string;
2053
+ }>;
2054
+ nextAfterLabel: string | null;
2055
+ }
2056
+ interface ChannelAuditEntry {
2057
+ id: number;
2058
+ at: number;
2059
+ actor: string | null;
2060
+ action: string;
2061
+ channelId: string | null;
2062
+ assignmentVersion: number;
2063
+ before: unknown;
2064
+ after: unknown;
2065
+ reason: string | null;
2066
+ }
2067
+ interface ChannelAuditPage {
2068
+ entries: ChannelAuditEntry[];
2069
+ nextBefore: number | null;
2070
+ }
2071
+ /**
2072
+ * Buyer projection for a preview audience — the same scoped server view the
2073
+ * buyer SDK receives. When an audience cannot be previewed (a paused or
2074
+ * archived channel), the server answers `{available:false, unavailable:[…]}`
2075
+ * and the UI shows the real paused/unavailable landing state instead of
2076
+ * rendering those seats as eligible.
2077
+ *
2078
+ * Fields stay optional: a worker that predates the hardening merge 404s here,
2079
+ * and Channels mode says the preview needs a newer server rather than faking a
2080
+ * projection client-side.
2081
+ */
2082
+ interface ChannelPreviewProjection {
2083
+ available?: boolean;
2084
+ unavailable?: Array<{
2085
+ channelId: string;
2086
+ state: 'paused' | 'archived' | string;
2087
+ }>;
2088
+ channelIds?: string[];
2089
+ includePublic?: boolean;
2090
+ /** Labels this audience may buy. Everything else renders as ONE neutral
2091
+ * unavailable state so preview never leaks which channel holds a seat. */
2092
+ eligible?: string[];
2093
+ counts?: {
2094
+ eligible?: number;
2095
+ free?: number;
2096
+ held?: number;
2097
+ booked?: number;
2098
+ };
2099
+ }
1343
2100
  declare class ManageApiError extends Error {
1344
2101
  status: number;
1345
2102
  code?: string;
@@ -1348,10 +2105,17 @@ declare class ManageApiError extends Error {
1348
2105
  label: string;
1349
2106
  reason?: string;
1350
2107
  }[];
2108
+ /**
2109
+ * Structured refusal detail. The channel routes use it for the two 409s a UI
2110
+ * must render rather than merely report: `channel_archive_blocked_by_holds`
2111
+ * carries {activeHolds, heldUnits, latestHoldExpiresAt, retryAfterMs}, and
2112
+ * `channel_assignment_conflict` carries the current assignmentVersion.
2113
+ */
2114
+ details?: Record<string, unknown>;
1351
2115
  constructor(status: number, message: string, code?: string, conflicts?: {
1352
2116
  label: string;
1353
2117
  reason?: string;
1354
- }[]);
2118
+ }[], details?: Record<string, unknown>);
1355
2119
  }
1356
2120
  interface ReportByStatus {
1357
2121
  free: number;
@@ -1536,6 +2300,76 @@ declare class ManageApi {
1536
2300
  hidden: string[];
1537
2301
  rules: Record<string, AvailabilityRule>;
1538
2302
  }>;
2303
+ /** Allocation list with exact per-channel counts. `includeArchived` adds the
2304
+ * read-only archived rows behind the rail's "Show archived" control. */
2305
+ channels(key: string, opts?: {
2306
+ includeArchived?: boolean;
2307
+ }): Promise<ChannelListResult>;
2308
+ /** One page of the label → channel map that paints the allocation overlay.
2309
+ * Paged by label; follow `nextAfterLabel` until it is null. */
2310
+ channelAllocation(key: string, opts?: {
2311
+ afterLabel?: string;
2312
+ limit?: number;
2313
+ }): Promise<ChannelAllocationPage>;
2314
+ channelAudit(key: string, opts?: {
2315
+ limit?: number;
2316
+ before?: number;
2317
+ }): Promise<ChannelAuditPage>;
2318
+ createChannel(key: string, input: {
2319
+ name: string;
2320
+ color?: string | null;
2321
+ marker?: string | null;
2322
+ externalRef?: string | null;
2323
+ }): Promise<{
2324
+ ok: true;
2325
+ channel: ChannelRecord;
2326
+ }>;
2327
+ renameChannel(key: string, channelId: string, name: string): Promise<{
2328
+ ok: true;
2329
+ channel: ChannelRecord;
2330
+ }>;
2331
+ setChannelPaused(key: string, channelId: string, paused: boolean): Promise<{
2332
+ ok: true;
2333
+ channel: ChannelRecord;
2334
+ }>;
2335
+ /** Archive with a mandatory destination for the remaining allocation.
2336
+ * Throws ManageApiError 409 `channel_archive_blocked_by_holds` while any hold
2337
+ * is live; `err.details` carries the exact counts + retry window. */
2338
+ archiveChannel(key: string, channelId: string, destination: string | null): Promise<{
2339
+ ok: true;
2340
+ channel: ChannelRecord;
2341
+ assignmentVersion: number;
2342
+ moved: number;
2343
+ }>;
2344
+ /**
2345
+ * Versioned Apply. A stale `assignmentVersion` mutates NOTHING and throws
2346
+ * ManageApiError 409 `channel_assignment_conflict` — the caller keeps its
2347
+ * selection and offers "Refresh and review". There is no dry-run: the review
2348
+ * sheet previews locally, this call returns the authoritative buckets.
2349
+ */
2350
+ applyChannelAssignment(key: string, input: {
2351
+ targetChannelId: string | null;
2352
+ labels: string[];
2353
+ assignmentVersion: number;
2354
+ }): Promise<AssignmentResult>;
2355
+ /**
2356
+ * Read-only buyer projection for an audience (§8.6) — the SAME scoped server
2357
+ * view the buyer SDK receives, never a local approximation.
2358
+ *
2359
+ * Ships on the access-hardening branch. Older workers 404/405 here; callers
2360
+ * MUST feature-detect and quietly say the preview needs a newer server rather
2361
+ * than faking a projection client-side.
2362
+ */
2363
+ channelPreview(key: string, channelIds: string[], opts?: {
2364
+ includePublic?: boolean;
2365
+ }): Promise<ChannelPreviewProjection>;
2366
+ /** Declare how buyers are meant to reach this channel. Drives the rail's
2367
+ * access line and turns "No buyer access configured" from information into a
2368
+ * warning when the organizer says the channel is for buyer self-service. */
2369
+ setChannelAccessIntent(key: string, channelId: string, accessIntent: ChannelAccessIntent): Promise<{
2370
+ ok: true;
2371
+ channel: ChannelRecord;
2372
+ }>;
1539
2373
  report(key: string): Promise<ReportResult>;
1540
2374
  controlRoom(key: string, windowMinutes?: number): Promise<ControlRoomSnapshot>;
1541
2375
  log(key: string, opts?: {
@@ -1569,7 +2403,14 @@ declare class ManageApi {
1569
2403
  * {@link ManageApi}. Box office + Sections + full Reports UI are M2/M3.
1570
2404
  */
1571
2405
 
1572
- type SeatManagerMode = 'view' | 'inspect' | 'block' | 'sections';
2406
+ type SeatManagerMode = 'view' | 'inspect' | 'block' | 'sections' | 'channels';
2407
+ /**
2408
+ * Capabilities the cockpit's token was minted with. Channels mode is gated on
2409
+ * these and fails CLOSED: no `event:channels:view` ⇒ no Channels pill at all;
2410
+ * view without `event:channels:manage` ⇒ read-only inspection with every
2411
+ * mutation control absent, not merely disabled.
2412
+ */
2413
+ type SeatManagerCapability = 'event:view' | 'event:block' | 'event:cancel' | 'event:reports' | 'event:channels:view' | 'event:channels:manage';
1573
2414
  /** DO seat status — 'blocked' has no engine analogue (→ 'not_for_sale'). */
1574
2415
  type DoStatus = 'free' | 'held' | 'booked' | 'blocked';
1575
2416
  /** Live KPI snapshot pushed to `onTallies` on every state change. */
@@ -1625,6 +2466,14 @@ interface SeatManagerOptions {
1625
2466
  tokenExpiresAt?: number;
1626
2467
  /** Initial mode. Default 'view'. */
1627
2468
  mode?: SeatManagerMode;
2469
+ /**
2470
+ * The capability set this token was minted with. Supply it whenever you mint
2471
+ * an `mse_…` grant — it is the only way the widget can know a delegated token
2472
+ * carries `event:channels:manage`, and without it Channels mode stays
2473
+ * read-only (fail-closed). A tenant secret (`sk_…`) is org authority and is
2474
+ * never narrowed server-side, so it is treated as fully capable.
2475
+ */
2476
+ capabilities?: SeatManagerCapability[] | string[];
1628
2477
  /** ISO-4217 fallback currency for revenue (chart/event currency wins). */
1629
2478
  currency?: string;
1630
2479
  /** Chart theme override for the chrome (rails/bar). Chart colors come from the doc. */
@@ -1719,6 +2568,8 @@ declare class SeatManager {
1719
2568
  private blockedSection;
1720
2569
  private blockedResultLimit;
1721
2570
  private unblockAllConfirmTimer;
2571
+ private channels;
2572
+ private channelCaps;
1722
2573
  private readonly onFullscreenChange;
1723
2574
  private readonly onKeyDown;
1724
2575
  private readonly onRailClick;
@@ -1726,6 +2577,21 @@ declare class SeatManager {
1726
2577
  /** Build the DOM, load the chart, subscribe to realtime, mount the board. */
1727
2578
  render(): Promise<this>;
1728
2579
  setMode(mode: SeatManagerMode): void;
2580
+ /**
2581
+ * Decide what this token may do with sales channels.
2582
+ *
2583
+ * Declared capabilities win — a host that mints an `mse_…` grant knows exactly
2584
+ * what it asked for. Otherwise a tenant secret (`sk_…`) is org authority the
2585
+ * worker never narrows, so it is fully capable; and a delegated token with no
2586
+ * declaration is probed for read access and then treated as READ-ONLY, because
2587
+ * "we could not tell" must never render mutation controls.
2588
+ */
2589
+ private resolveChannelCapabilities;
2590
+ /** The adapter between the cockpit's internals and Channels mode. */
2591
+ private buildChannelsHost;
2592
+ /** Approximate on-screen seat size, for the channel overlay's marks. Derived
2593
+ * from the live camera so the overlay tracks zoom without a renderer hook. */
2594
+ private seatPixelSize;
1729
2595
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
1730
2596
  setHeatOverlay(enabled: boolean): void;
1731
2597
  /** Toggle opt-in camera following for new buyer hold/book events. */
@@ -1769,6 +2635,16 @@ declare class SeatManager {
1769
2635
  zoomToFit(): void;
1770
2636
  destroy(): void;
1771
2637
  private buildRenderer;
2638
+ /** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
2639
+ * section. The two differ only in WHICH statuses they may act on. */
2640
+ private isBulkSelectMode;
2641
+ /**
2642
+ * Block never touches held or booked inventory, so it cannot select it.
2643
+ * Channels must be able to select it — the Review sheet's honesty depends on
2644
+ * counting the held and sold units inside a marquee and saying they will not
2645
+ * move, rather than silently omitting them from the selection.
2646
+ */
2647
+ private selectableStatuses;
1772
2648
  private updateRendererInteraction;
1773
2649
  private handleSeatSelect;
1774
2650
  private repaintAll;
@@ -1870,4 +2746,294 @@ declare class SeatManager {
1870
2746
  private fail;
1871
2747
  }
1872
2748
 
1873
- export { ApiError, type AttachPickerFrameOptions, type BestAvailableResult, type CheckoutHandoff, type CheckoutLineItem, type ControlRoomActivityEntry, type ControlRoomSectionMetric, type ControlRoomSnapshot, EmbeddedDesigner, type EmbeddedDesignerEventType, type EmbeddedDesignerMessage, type EmbeddedDesignerOptions, type GAAreaAvailability, type HoldConflict, type HoldLineItem, type HoldResult, type LogEntry, type LogPage, ManageApi, ManageApiError, type ReportByStatus, type ReportCategoryMeta, type ReportCategoryRow, type ReportResult, type ResumedHoldResult, SeatManager, type SeatManagerActionResult, type SeatManagerActivity, type SeatManagerMode, type SeatManagerOptions, type SeatManagerTallies, SeatPicker, type SeatPickerOptions, type SeatPickerTheme, SeatingChart, type SeatingChartOptions, type SelectedSeat, attachPickerFrame };
2749
+ /**
2750
+ * Channels mode — the sales-channel management surface inside the SeatManager
2751
+ * cockpit. It ships in the SDK, so every embedding platform (and our own Control
2752
+ * Room) gets the same organizer experience.
2753
+ *
2754
+ * Design contract: `SeatmapUX/05 Event Manager.dc.html` (all 8 desktop states +
2755
+ * the mobile artboard). Behaviour: sales-channels-product-ux-spec §8 (states),
2756
+ * §9 (language), §13 (a11y + compact detents). Motion: motion-system §2 tokens,
2757
+ * mirrored here as `--slm-mo-*` so an embed is self-contained, and §3 cockpit
2758
+ * choreography.
2759
+ *
2760
+ * Boundaries this module keeps:
2761
+ *
2762
+ * - **Capability gating is fail-closed.** Without `event:channels:view` the
2763
+ * cockpit never renders the pill (SeatManager's job). Without
2764
+ * `event:channels:manage` every mutation control is ABSENT — not disabled —
2765
+ * so a read-only operator is never shown authority they do not have.
2766
+ * - **Nothing here mutates physical inventory.** Assignment moves an
2767
+ * allocation; held and booked units are never rewritten.
2768
+ * - **Staging is local, truth is the server.** There is no dry-run endpoint,
2769
+ * so the Review sheet previews the buckets client-side and then re-renders
2770
+ * the AUTHORITATIVE bucket counts from the Apply response. A stale
2771
+ * `assignmentVersion` mutates nothing: the selection survives and the only
2772
+ * action offered is Refresh and review.
2773
+ * - **Forward-compatible reads.** The `access` field and the buyer-preview
2774
+ * projection land on the access-hardening branch. Both are feature-detected;
2775
+ * absent, the access line reads "—" and the Preview segment says it needs a
2776
+ * newer server. Neither ever blocks allocation work.
2777
+ */
2778
+
2779
+ /** The seat facts the overlay needs. Chart space, not screen space. */
2780
+ interface ChannelsSeatView {
2781
+ id: string;
2782
+ label: string;
2783
+ x: number;
2784
+ y: number;
2785
+ }
2786
+ /** The `ManageApi` subset Channels mode uses — structural so tests can pass a
2787
+ * hand-rolled double without constructing a real client. */
2788
+ interface ChannelsClient {
2789
+ channels(key: string, opts?: {
2790
+ includeArchived?: boolean;
2791
+ }): Promise<ChannelListResult>;
2792
+ channelAllocation(key: string, opts?: {
2793
+ afterLabel?: string;
2794
+ limit?: number;
2795
+ }): Promise<ChannelAllocationPage>;
2796
+ createChannel(key: string, input: {
2797
+ name: string;
2798
+ color?: string | null;
2799
+ marker?: string | null;
2800
+ externalRef?: string | null;
2801
+ }): Promise<{
2802
+ ok: true;
2803
+ channel: ChannelRecord;
2804
+ }>;
2805
+ renameChannel(key: string, channelId: string, name: string): Promise<{
2806
+ ok: true;
2807
+ channel: ChannelRecord;
2808
+ }>;
2809
+ setChannelPaused(key: string, channelId: string, paused: boolean): Promise<{
2810
+ ok: true;
2811
+ channel: ChannelRecord;
2812
+ }>;
2813
+ archiveChannel(key: string, channelId: string, destination: string | null): Promise<{
2814
+ ok: true;
2815
+ channel: ChannelRecord;
2816
+ assignmentVersion: number;
2817
+ moved: number;
2818
+ }>;
2819
+ applyChannelAssignment(key: string, input: {
2820
+ targetChannelId: string | null;
2821
+ labels: string[];
2822
+ assignmentVersion: number;
2823
+ }): Promise<AssignmentResult>;
2824
+ channelPreview(key: string, channelIds: string[], opts?: {
2825
+ includePublic?: boolean;
2826
+ }): Promise<ChannelPreviewProjection>;
2827
+ setChannelAccessIntent(key: string, channelId: string, accessIntent: ChannelAccessIntent): Promise<{
2828
+ ok: true;
2829
+ channel: ChannelRecord;
2830
+ }>;
2831
+ }
2832
+ interface ChannelsCapabilities {
2833
+ view: boolean;
2834
+ manage: boolean;
2835
+ }
2836
+ /** Everything Channels mode needs from the cockpit around it. */
2837
+ interface ChannelsModeHost {
2838
+ eventKey: string;
2839
+ api: ChannelsClient;
2840
+ /** The rail scroll container the mode paints into. */
2841
+ rail: HTMLElement;
2842
+ /** An absolutely-positioned layer over the map (overlay canvas, flags, bars). */
2843
+ mapLayer: HTMLElement;
2844
+ /** The widget root — dialogs mount here so they inherit the widget's tokens. */
2845
+ root: HTMLElement;
2846
+ seats(): ChannelsSeatView[];
2847
+ statusOf(label: string): ChannelSeatStatus | undefined;
2848
+ selectionLabels(): string[];
2849
+ selectByLabels(labels: string[]): void;
2850
+ clearSelection(): void;
2851
+ selectSection(sectionId: string): void;
2852
+ sections(): Array<{
2853
+ id: string;
2854
+ label: string;
2855
+ }>;
2856
+ categories(): Array<{
2857
+ key: string;
2858
+ label: string;
2859
+ color?: string;
2860
+ }>;
2861
+ labelsInCategory(key: string): string[];
2862
+ sectionOfLabel(label: string): {
2863
+ id: string;
2864
+ label: string;
2865
+ } | null;
2866
+ /** Chart-space → container pixels. Null when there is no live renderer. */
2867
+ worldToScreen(point: {
2868
+ x: number;
2869
+ y: number;
2870
+ }): {
2871
+ x: number;
2872
+ y: number;
2873
+ } | null;
2874
+ /** Approximate on-screen seat size in CSS pixels, for the overlay marks. */
2875
+ seatPixelSize(): number;
2876
+ isCompact(): boolean;
2877
+ /** Make the canvas non-interactive behind a full-detent sheet (§13). */
2878
+ setMapInert(inert: boolean): void;
2879
+ toast(message: string, kind: 'ok' | 'err'): void;
2880
+ onError(err: unknown): void;
2881
+ /** Fired whenever the staged mutation count changes, for host telemetry. */
2882
+ onStagedChange?(staged: number): void;
2883
+ }
2884
+ /**
2885
+ * Render bucket rows to markup. Deliberately generic over `BucketRow`, because
2886
+ * three different refusals share this exact presentation: the local staged
2887
+ * preview, the authoritative Apply response, and the chart-update
2888
+ * `channel_assignment_would_drop` review (via `dropReviewRows`). One component,
2889
+ * one visual language for "here is every affected unit, in exactly one line".
2890
+ */
2891
+ declare function bucketRowsHtml(rows: BucketRow[]): string;
2892
+ declare class ChannelsMode {
2893
+ private readonly host;
2894
+ private caps;
2895
+ private active;
2896
+ private list;
2897
+ private allocation;
2898
+ private assignmentVersion;
2899
+ private loadError;
2900
+ private loading;
2901
+ private view;
2902
+ private showArchived;
2903
+ private detailChannelId;
2904
+ private targetChannelId;
2905
+ private conflict;
2906
+ private dialog;
2907
+ private detent;
2908
+ private seatListLimit;
2909
+ private previewAudience;
2910
+ private previewIncludePublic;
2911
+ private previewProjection;
2912
+ private previewSupported;
2913
+ private pollTimer;
2914
+ private layer;
2915
+ private canvas;
2916
+ /** undefined = not resolved yet, null = this environment has no 2d canvas. */
2917
+ private ctx;
2918
+ private bannerEl;
2919
+ private stagedEl;
2920
+ private liveEl;
2921
+ private scrimEl;
2922
+ private lastFocus;
2923
+ private stagedDoneTimer;
2924
+ private lastSelectionCount;
2925
+ private lastCounts;
2926
+ constructor(host: ChannelsModeHost, capabilities: ChannelsCapabilities);
2927
+ /** Called when the cockpit switches into Channels mode. */
2928
+ enter(): void;
2929
+ /** Called when the cockpit leaves Channels mode. Everything this mode painted
2930
+ * over the map goes with it — no other tool ever inherits a channel overlay. */
2931
+ leave(): void;
2932
+ destroy(): void;
2933
+ /** Capabilities can change when a token rotates. Re-render, fail-closed. */
2934
+ setCapabilities(capabilities: ChannelsCapabilities): void;
2935
+ isActive(): boolean;
2936
+ /**
2937
+ * Whether the map should accept bulk selection right now. Preview is a
2938
+ * read-only simulation of somebody else's view, and a view-only token has no
2939
+ * assignment to stage — in both cases the canvas must not offer selection at
2940
+ * all rather than collect a selection nothing can act on.
2941
+ */
2942
+ canSelect(): boolean;
2943
+ /**
2944
+ * Organizer realtime integration point. M5 ships a per-scope socket for
2945
+ * buyers; the organizer channel-count stream is a later milestone. When it
2946
+ * arrives, call this from the cockpit's WS handler instead of waiting for the
2947
+ * poll — everything downstream already reacts to a fresh list.
2948
+ */
2949
+ applyRealtimeHint(): void;
2950
+ /** The cockpit's selection changed (marquee / click / section / category). */
2951
+ handleSelectionChange(): void;
2952
+ /** Camera moved or the container resized — the overlay is screen-space. */
2953
+ handleViewChange(): void;
2954
+ handleLayoutChange(): void;
2955
+ private refresh;
2956
+ /** Walk every allocation page. Bounded by the event's seat count, and the
2957
+ * server caps each page, so an arena is a handful of round trips. */
2958
+ private loadAllocation;
2959
+ private channelById;
2960
+ private nameOf;
2961
+ private markerFor;
2962
+ /** Channels an organizer may assign INTO: public sale plus every live channel. */
2963
+ private assignableChannels;
2964
+ private currentPlan;
2965
+ private ensureLayer;
2966
+ private announce;
2967
+ /**
2968
+ * Repaint the allocation (or preview) overlay in ONE canvas pass.
2969
+ *
2970
+ * Channel identity on the map is a fill in the administrative color PLUS the
2971
+ * letter flags below — never color alone. Physical status keeps its own cue:
2972
+ * only FREE units take a channel fill, so sold/held/blocked seats still read
2973
+ * exactly as they do in every other tool.
2974
+ */
2975
+ private paintOverlay;
2976
+ /** Letter flags at each channel's centroid — the non-color identity cue. */
2977
+ private paintFlags;
2978
+ private setStaged;
2979
+ private paintStagedBar;
2980
+ private setBanner;
2981
+ private setView;
2982
+ /** Set by the cockpit so a view switch can re-arm canvas selection. */
2983
+ onInteractionChange?: () => void;
2984
+ private loadPreview;
2985
+ paintRail(): void;
2986
+ private viewSegmentHtml;
2987
+ private countsHtml;
2988
+ private channelRowHtml;
2989
+ private listRailHtml;
2990
+ private selectionRailHtml;
2991
+ private detailRailHtml;
2992
+ private previewRailHtml;
2993
+ private paintSelection;
2994
+ private wireRail;
2995
+ private railAction;
2996
+ private pickSection;
2997
+ private pickCategory;
2998
+ /** A tiny modal chooser reusing the dialog primitive (focus trap + Escape). */
2999
+ private promptChoice;
3000
+ private openDialog;
3001
+ private renderDialog;
3002
+ /**
3003
+ * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
3004
+ * Tab trapped, Escape closes WITHOUT mutating, focus restored on close (§13).
3005
+ */
3006
+ private renderScrim;
3007
+ private closeDialog;
3008
+ private renderCreateDialog;
3009
+ private createChannel;
3010
+ private showDialogError;
3011
+ private renderReviewDialog;
3012
+ /**
3013
+ * Apply. On success the review sheet re-renders with the AUTHORITATIVE server
3014
+ * buckets and the staged bar morphs to a ✓ for 1.2s. On a stale version the
3015
+ * server mutated nothing: keep the selection, shake the bar once, and offer
3016
+ * exactly one action — Refresh and review.
3017
+ */
3018
+ private apply;
3019
+ private showApplied;
3020
+ private shakeStaged;
3021
+ private renderRenameDialog;
3022
+ private setAccessIntent;
3023
+ private togglePause;
3024
+ private renderArchiveDialog;
3025
+ private archive;
3026
+ /**
3027
+ * The synchronized inventory list (§13): the keyboard and screen-reader
3028
+ * equivalent of canvas click / marquee / brush, grouped by section with
3029
+ * per-section select actions.
3030
+ */
3031
+ private renderSeatListDialog;
3032
+ private applySheetClasses;
3033
+ private cycleDetent;
3034
+ /** Back/Close from the full detent returns to the previous one and keeps the
3035
+ * selection — losing a hard-won selection to a Back press is unforgivable. */
3036
+ handleBack(): boolean;
3037
+ }
3038
+
3039
+ export { ApiError, type ArchiveBlockedDetails, type AssignmentBuckets, type AssignmentDropDetails, type AssignmentResult, type AttachPickerFrameOptions, type BestAvailableResult, type BucketRow, BuyerAccessContext, type BuyerAccessExpiredEvent, type BuyerAccessRefreshReason, type BuyerAccessToken, type BuyerAccessTokenProvider, BuyerAccessUnavailableError, type BuyerAccessUnavailableEvent, type BuyerAccessUnavailableReason, BuyerRealtimeClient, type BuyerRealtimeOptions, type ChannelAccessIntent, type ChannelAccessSummary, type ChannelAllocationPage, type ChannelAuditEntry, type ChannelAuditPage, type ChannelCounts, type ChannelListResult, type ChannelPreviewProjection, type ChannelRecord, type ChannelSeatStatus, type ChannelState, type ChannelsCapabilities, type ChannelsClient, ChannelsMode, type ChannelsModeHost, type ChannelsSeatView, type CheckoutHandoff, type CheckoutLineItem, type ControlRoomActivityEntry, type ControlRoomSectionMetric, type ControlRoomSnapshot, EmbeddedDesigner, type EmbeddedDesignerEventType, type EmbeddedDesignerMessage, type EmbeddedDesignerOptions, type GAAreaAvailability, type HoldConflict, type HoldLineItem, type HoldResult, type LogEntry, type LogPage, ManageApi, ManageApiError, PUBLIC_CHANNEL_ID, PUBLIC_CHANNEL_NAME, type Projection, type PubApiOptions, type RealtimeSink, type ReportByStatus, type ReportCategoryMeta, type ReportCategoryRow, type ReportResult, type ResumedHoldResult, SeatManager, type SeatManagerActionResult, type SeatManagerActivity, type SeatManagerCapability, type SeatManagerMode, type SeatManagerOptions, type SeatManagerTallies, SeatPicker, type SeatPickerOptions, type SeatPickerTheme, SeatingChart, type SeatingChartOptions, type SelectedObjectUnavailableEvent, type SelectedSeat, type SelectionSourceRow, type StatusChange, type SubscribeTicket, accessIntentLabel, accessLine, attachPickerFrame, bucketRows, bucketRowsHtml, createBuyerAccessContext, createControllerSink, dropReviewRows, markerOf, mutationCount, needsMoveConfirmation, planAssignment, retryAfterCopy, selectionSources, stateBadge, suggestMarker };