From 9fcd36575c3984a49282e874777eb5e30d1306a1 Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:21:46 +0530 Subject: [PATCH 1/2] feat: add Performance Groups server resource --- src/index.ts | 17 ++ src/resources/performance-groups.ts | 282 ++++++++++++++++++++++++++++ test/client.test.ts | 58 ++++++ 3 files changed, 357 insertions(+) create mode 100644 src/resources/performance-groups.ts diff --git a/src/index.ts b/src/index.ts index 6f7e8bd..bd358d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { Charts } from './resources/charts.js'; import { Channels } from './resources/channels.js'; import { Events } from './resources/events.js'; import { Inventory } from './resources/inventory.js'; +import { PerformanceGroups } from './resources/performance-groups.js'; import { Sessions } from './resources/sessions.js'; import { Templates } from './resources/templates.js'; import { Webhooks } from './resources/webhooks.js'; @@ -20,6 +21,7 @@ export class SeatLayer { readonly channels: Channels; readonly events: Events; readonly inventory: Inventory; + readonly performanceGroups: PerformanceGroups; readonly sessions: Sessions; readonly templates: Templates; readonly webhooks: Webhooks; @@ -39,6 +41,7 @@ export class SeatLayer { this.channels = new Channels(this.#http); this.events = new Events(this.#http); this.inventory = new Inventory(this.#http); + this.performanceGroups = new PerformanceGroups(this.#http); this.sessions = new Sessions(this.#http); this.templates = new Templates(this.#http); this.webhooks = new Webhooks(this.#http); @@ -72,6 +75,20 @@ export { type ApiErrorBody, } from './errors.js'; export type { ClientOptions, RequestOptions } from './http.js'; +export type { + CreatePerformanceGroupBuyerAccessSessionParams, + CreatePerformanceGroupParams, + PerformanceGroup, + PerformanceGroupBooking, + PerformanceGroupBuyerAccessReveal, + PerformanceGroupBuyerAccessSession, + PerformanceGroupDetail, + PerformanceGroupHold, + PerformanceGroupLifecycleOperation, + PerformanceGroupLifecycleResult, + PerformanceGroupListOptions, + PerformanceGroupPerformance, +} from './resources/performance-groups.js'; export type { AccessLink, AccessLinkReveal, diff --git a/src/resources/performance-groups.ts b/src/resources/performance-groups.ts new file mode 100644 index 0000000..9e5b935 --- /dev/null +++ b/src/resources/performance-groups.ts @@ -0,0 +1,282 @@ +import type { HttpClient } from '../http.js'; +import type { HoldLineItem, KeyMode } from '../types.js'; + +/** A fixed run of two to eight compatible assigned-seat performances. */ +export interface PerformanceGroup { + key: string; + kind: 'performance_run'; + name: string; + externalRef: string | null; + state: 'draft' | 'active' | 'closing' | 'closed' | 'archived'; + revision: number; + workspaceId: string; + mode: KeyMode; + environment: string | null; + chartId: string; + sourceChartKey: string; + inventoryModelVersion: number; + currency: string; + venue: string | null; + timezone: string | null; + performanceCount: number; + firstStartsAt: number; + lastStartsAt: number; + createdAt: number; + updatedAt: number; + activatedAt: number | null; + closedAt: number | null; +} + +export interface PerformanceGroupPerformance { + position: number; + eventKey: string; + name: string; + startsAt: number; + endsAt: number | null; + venue: string | null; + timezone: string | null; + externalRef: string | null; + salesState: string; + status: string; +} + +export interface PerformanceGroupDetail extends PerformanceGroup { + performances: PerformanceGroupPerformance[]; +} + +export interface PerformanceGroupLifecycleOperation { + operationId: string; + kind: 'ACTIVATION' | 'CLOSE'; + phase: + | 'activation_locking' | 'activation_registry' | 'activation_aborting' | 'active' | 'activation_failed' + | 'close_registry' | 'close_draining' | 'close_finalize' | 'close_unlocking' | 'close_failed' | 'closed'; + terminal: boolean; + totalLocks: number; + ackedLocks: number; + remainingOperations: number | null; + code: string | null; +} + +export interface PerformanceGroupLifecycleResult { + performanceGroup: PerformanceGroupDetail; + lifecycleOperation: PerformanceGroupLifecycleOperation; + /** Present while SeatLayer is still coordinating the lifecycle transition. */ + message?: string; +} + +export interface PerformanceGroupBuyerAccessSession { + sessionId: string; + allowedOrigin: string; + expiresAt: number; + includePublic: boolean; + maxQuantity: number | null; + buyerRef: string | null; + partnerRef: string | null; + accessSource: 'promoter' | 'partner'; + state: 'active' | 'revoked'; + createdAt: number; + revokedAt: number | null; +} + +/** One-time reveal. Store the token securely and pass it only to the browser picker. */ +export interface PerformanceGroupBuyerAccessReveal extends PerformanceGroupBuyerAccessSession { + token: string; + performanceGroupKey: string; +} + +export interface PerformanceGroupHold { + operationId: string; + state: 'preparing' | 'commit_pending' | 'committed' | 'expire_pending' | 'expired' | 'booked' | 'abort_pending' | 'aborted'; + currency: string; + groupRevision: number; + allocations: Array<{ + eventKey: string; + name: string; + startsAt: number; + endsAt: number | null; + configuredValue: number; + items: HoldLineItem[]; + }>; + [key: string]: unknown; +} + +export interface PerformanceGroupBooking { + bookActionId: string; + operationId: string; + groupId: string; + bookingRef: string; + state: 'book_pending' | 'booked' | 'book_failed'; + createdAt: number; + bookedAt: number | null; + totalPerformances: number; + bookedPerformances: number; + nextRetryAt: number | null; + attempts: number; + lastError: string | null; +} + +export interface PerformanceGroupListOptions { + workspaceId?: string; + externalRef?: string; + state?: PerformanceGroup['state']; + limit?: number; + cursor?: string; +} + +export interface CreatePerformanceGroupParams { + name: string; + eventKeys: string[]; + externalRef?: string | null; +} + +export interface CreatePerformanceGroupBuyerAccessSessionParams { + allowedOrigin: string; + includePublic: boolean; + channelIdsByEvent?: Record; + expiresInSeconds?: number; + maxQuantity?: number | null; + buyerRef?: string; + partnerRef?: string; +} + +/** + * Fixed-run lifecycle, trusted hold inspection, and host-authorized booking. + * + * The browser uses `@seatlayer/js`'s PerformanceGroupPicker with the one-time + * bearer minted here. Keep this secret-key resource on the host server. + */ +export class PerformanceGroups { + #http: HttpClient; + + constructor(http: HttpClient) { + this.#http = http; + } + + listPerformanceGroups(options: PerformanceGroupListOptions = {}): Promise<{ + performanceGroups: PerformanceGroup[]; + nextCursor: string | null; + }> { + return this.#http.get('/v1/performance-groups', { + query: { + workspaceId: options.workspaceId, + externalRef: options.externalRef, + state: options.state, + limit: options.limit, + cursor: options.cursor, + }, + }); + } + + /** Create a draft run. Repeating the same idempotency key safely replays it. */ + createPerformanceGroup( + params: CreatePerformanceGroupParams, + options: { idempotencyKey?: string } = {}, + ): Promise<{ performanceGroup: PerformanceGroupDetail }> { + return this.#http.postWithHeaderReplay('/v1/performance-groups', { + body: params, + idempotencyKey: options.idempotencyKey, + }); + } + + retrievePerformanceGroup(performanceGroupKey: string): Promise<{ performanceGroup: PerformanceGroupDetail }> { + return this.#http.get(`/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}`); + } + + /** Delete only a draft group. Active and closed runs retain their audit identity. */ + deletePerformanceGroup(performanceGroupKey: string): Promise { + return this.#http.delete(`/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}`); + } + + /** + * Start activation. If `lifecycleOperation.terminal` is false, poll with + * retrievePerformanceGroupLifecycle using its operationId. + */ + activatePerformanceGroup(performanceGroupKey: string, expectedRevision: number): Promise { + return this.#http.post(`/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/activate`, { + body: { expectedRevision }, + }); + } + + /** + * Stop new group sales and complete closure after active group holds drain. + * If it is still pending, poll the lifecycle operation returned in the body. + */ + closePerformanceGroup(performanceGroupKey: string, expectedRevision: number): Promise { + return this.#http.post(`/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/close`, { + body: { expectedRevision }, + }); + } + + retrievePerformanceGroupLifecycle( + performanceGroupKey: string, + operationId: string, + ): Promise { + return this.#http.get( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/lifecycle/${encodeURIComponent(operationId)}`, + ); + } + + /** + * Mint a one-time browser bearer for PerformanceGroupPicker. This operation + * is deliberately single-attempt: a network retry could reveal two secrets. + */ + createPerformanceGroupBuyerAccessSession( + performanceGroupKey: string, + params: CreatePerformanceGroupBuyerAccessSessionParams, + ): Promise { + return this.#http.post( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/buyer-access-sessions`, + { body: params }, + ); + } + + listPerformanceGroupBuyerAccessSessions(performanceGroupKey: string, options: { limit?: number } = {}): Promise<{ + sessions: PerformanceGroupBuyerAccessSession[]; + }> { + return this.#http.get( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/buyer-access-sessions`, + { query: { limit: options.limit } }, + ); + } + + revokePerformanceGroupBuyerAccessSession(performanceGroupKey: string, sessionId: string): Promise<{ + ok: true; + sessionId: string; + }> { + return this.#http.delete( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/buyer-access-sessions/${encodeURIComponent(sessionId)}`, + ); + } + + retrievePerformanceGroupHold(performanceGroupKey: string, operationId: string): Promise<{ + hold: PerformanceGroupHold; + }> { + return this.#http.get( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/holds/${encodeURIComponent(operationId)}`, + ); + } + + /** + * Confirm payment on a committed group hold. Keep bookActionId and bookingRef + * stable across a retry, then poll retrievePerformanceGroupBooking while the + * returned booking is in `book_pending` state. + */ + bookPerformanceGroupHold( + performanceGroupKey: string, + operationId: string, + params: { bookActionId: string; bookingRef: string }, + ): Promise<{ booking: PerformanceGroupBooking }> { + return this.#http.post( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/holds/${encodeURIComponent(operationId)}/book`, + { body: params }, + ); + } + + retrievePerformanceGroupBooking(performanceGroupKey: string, actionId: string): Promise<{ + booking: PerformanceGroupBooking; + }> { + return this.#http.get( + `/v1/performance-groups/${encodeURIComponent(performanceGroupKey)}/bookings/${encodeURIComponent(actionId)}`, + ); + } +} diff --git a/test/client.test.ts b/test/client.test.ts index e33d918..492b0d7 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -157,6 +157,64 @@ describe('requests', () => { await sdk.charts.list({ workspaceId: 'ws_1' }); expect(call(0).url).toBe('https://api.seatlayer.io/v1/charts?workspaceId=ws_1'); }); + + it('maps the full Performance Groups lifecycle to its secret-key server routes', async () => { + const { sdk, call } = client([ + { status: 200, body: { performanceGroups: [], nextCursor: null } }, + { status: 201, body: { performanceGroup: {} } }, + { status: 200, body: { performanceGroup: {} } }, + { status: 204 }, + { status: 202, body: { performanceGroup: {}, lifecycleOperation: { operationId: 'pga_1', terminal: false } } }, + { status: 200, body: { performanceGroup: {}, lifecycleOperation: { operationId: 'pgc_1', terminal: true } } }, + { status: 200, body: { performanceGroup: {}, lifecycleOperation: { operationId: 'pga_1', terminal: false } } }, + { status: 201, body: { sessionId: 'pgbs_1', token: 'bsg_secret' } }, + { status: 200, body: { sessions: [] } }, + { status: 200, body: { ok: true, sessionId: 'pgbs_1' } }, + { status: 200, body: { hold: {} } }, + { status: 202, body: { booking: { state: 'book_pending' } } }, + { status: 200, body: { booking: { state: 'booked' } } }, + ]); + const groupKey = 'pg_a/b'; + + await sdk.performanceGroups.listPerformanceGroups({ workspaceId: 'ws_1', state: 'draft' }); + await sdk.performanceGroups.createPerformanceGroup( + { name: 'Weekend run', eventKeys: ['ev_1', 'ev_2'] }, + { idempotencyKey: 'weekend-run-1' }, + ); + await sdk.performanceGroups.retrievePerformanceGroup(groupKey); + await expect(sdk.performanceGroups.deletePerformanceGroup(groupKey)).resolves.toBeUndefined(); + await sdk.performanceGroups.activatePerformanceGroup(groupKey, 1); + await sdk.performanceGroups.closePerformanceGroup(groupKey, 2); + await sdk.performanceGroups.retrievePerformanceGroupLifecycle(groupKey, 'pga_1'); + await sdk.performanceGroups.createPerformanceGroupBuyerAccessSession(groupKey, { + allowedOrigin: 'https://tickets.example.test', includePublic: true, + }); + await sdk.performanceGroups.listPerformanceGroupBuyerAccessSessions(groupKey, { limit: 25 }); + await sdk.performanceGroups.revokePerformanceGroupBuyerAccessSession(groupKey, 'pgbs_1'); + await sdk.performanceGroups.retrievePerformanceGroupHold(groupKey, 'pgh_1'); + await sdk.performanceGroups.bookPerformanceGroupHold(groupKey, 'pgh_1', { + bookActionId: 'book_1', bookingRef: 'order_1', + }); + await sdk.performanceGroups.retrievePerformanceGroupBooking(groupKey, 'book_1'); + + const base = 'https://api.seatlayer.io/v1/performance-groups/pg_a%2Fb'; + expect(call(0).url).toBe('https://api.seatlayer.io/v1/performance-groups?workspaceId=ws_1&state=draft'); + expect(call(1).url).toBe('https://api.seatlayer.io/v1/performance-groups'); + expect(call(1).headers['Idempotency-Key']).toBe('weekend-run-1'); + expect(call(2).url).toBe(base); + expect(call(3).method).toBe('DELETE'); + expect(call(4).url).toBe(`${base}/activate`); + expect(call(5).url).toBe(`${base}/close`); + expect(call(6).url).toBe(`${base}/lifecycle/pga_1`); + expect(call(7).url).toBe(`${base}/buyer-access-sessions`); + expect(call(7).headers['Idempotency-Key']).toBeUndefined(); + expect(call(8).url).toBe(`${base}/buyer-access-sessions?limit=25`); + expect(call(9).url).toBe(`${base}/buyer-access-sessions/pgbs_1`); + expect(call(10).url).toBe(`${base}/holds/pgh_1`); + expect(call(11).url).toBe(`${base}/holds/pgh_1/book`); + expect(call(11).headers['Idempotency-Key']).toBeUndefined(); + expect(call(12).url).toBe(`${base}/bookings/book_1`); + }); }); describe('errors', () => { From 1cc58480d800a054d54d1e7391f6cc1f4f8669cc Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:27:52 +0530 Subject: [PATCH 2/2] release: prepare 0.5.0 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 520f7f4..075e837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## 0.5.0 — 2026-08-21 + +- Added `performanceGroups`, the trusted server resource for fixed two-to-eight + performance runs. It creates and activates groups, mints one-time browser + access, retrieves authoritative group holds, and confirms bookings with + stable action and order references. Browser-only group routes remain outside + this secret-key SDK. + - Added template instantiation and ticket-release management (`Templates.instantiateTemplate`, `Events.listTicketReleases`, `Events.updateTicketReleases`, and `Events.closeTicketRelease`). Template instantiation uses exact header replay; diff --git a/package.json b/package.json index b90f4ce..f71e3ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@seatlayer/server", - "version": "0.4.0", + "version": "0.5.0", "description": "Official Node.js server SDK for SeatLayer inventory, holds, booking references, allocations, and reports. Secret-key only.", "license": "MIT", "homepage": "https://seatlayer.io/",