@pickleball/server-sdk 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @pickleball/server-sdk
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0d74c20: Camera grant (ADR-0002) cho điện thoại của user đối tác: namespace mới
8
+ `liveSessions` trên `createPickleballLiveClient` — `createCameraSession`
9
+ (`POST /api/v1/live-sessions {cameraGrant:true}`, idempotent theo matchRef),
10
+ `cameraGrant` (cấp lại grant, thu hồi grant cũ), `end`, `get`, `getRecordings`
11
+ đi đường REST v1 với `x-api-key`, không cần `appId` (option này thành tuỳ chọn).
12
+ Lỗi REST v1 `{error, code, ...extra}` thành `PickleballPartnerApiError`
13
+ (`partnerCode`, `details`). Contract `CameraGrant` (superset của
14
+ `SdkSessionGrant`). Các thao tác `/api/v2/sdk/*` và subpath `./proxy` được đánh
15
+ dấu deprecated, giữ nguyên hành vi.
16
+
17
+ ## 0.2.0
18
+
19
+ ### Minor Changes
20
+
21
+ - Device registry endpoints for the partner proxy: `devices.register` and
22
+ `devices.heartbeat` on `createPickleballLiveClient`, plus the proxy handlers
23
+ `handleDeviceRegister` / `handleDeviceHeartbeat` (routes `/devices/register`
24
+ and `/devices/heartbeat`). The heartbeat response carries a one-time control
25
+ session grant so an operator dashboard can remote-start a livestream on a
26
+ device that is open but idle.
27
+ - New authorization operations `device_register` and `device_heartbeat`.
28
+ **Production authz gateways must allow these two operation names**; the
29
+ staging stub already allows everything.
30
+ - Version aligned with `@pickleball/expo-sdk@0.2.0` (both packages share a
31
+ stable dist-tag promotion).
32
+
3
33
  ## 0.1.1
4
34
 
5
35
  ### Patch Changes
package/README.md CHANGED
@@ -56,6 +56,28 @@ Never expose this package's API key to a native/web bundle or log it. Put the
56
56
  client in a server route, Worker, serverless function, or other trusted runtime.
57
57
  The SDK never logs the API key, participant token, or webhook secret.
58
58
 
59
+ ## Camera grant — điện thoại của user đối tác (ADR-0002)
60
+
61
+ Backend đối tác tạo phiên theo `matchRef` và nhận **camera grant**; app đối tác
62
+ (`@pickleball/expo-sdk` chế độ grant) dùng `cameraGrant.telemetry.token` làm
63
+ Bearer để nói thẳng với Picklive — không proxy, không cần `appId`:
64
+
65
+ ```ts
66
+ const client = createPickleballLiveClient({ baseUrl, apiKey });
67
+ const { id, watchUrl, cameraGrant } = await client.liveSessions.createCameraSession({
68
+ matchRef: match.id,
69
+ title: match.title,
70
+ quality: 720,
71
+ // courtRef: "san-3", // tuỳ chọn: đậu vào sân giải để dùng match-start/match-end
72
+ });
73
+ // Trả `cameraGrant` NGUYÊN TRẠNG cho app qua auth riêng của bạn. Không log grant.
74
+ // App bị xoá/cài lại hoặc token hết hạn: client.liveSessions.cameraGrant(id) cấp lại
75
+ // (grant cũ bị thu hồi — máy cũ dừng, không tranh sóng với máy mới).
76
+ ```
77
+
78
+ Lỗi REST v1 ném `PickleballPartnerApiError` với `partnerCode` (VD
79
+ `COURT_HAS_DEVICE`, `COURT_BUSY`, `CAPACITY`, `SESSION_EXPIRED`) và `details`.
80
+
59
81
  ## Webhook verification
60
82
 
61
83
  Verify the exact raw request bytes before parsing. Delivery is at-least-once:
@@ -19,6 +19,15 @@ var PickleballApiError = class extends PickleballLiveError {
19
19
  this.name = "PickleballApiError";
20
20
  }
21
21
  };
22
+ var PickleballPartnerApiError = class extends PickleballLiveError {
23
+ constructor(message, partnerCode, status, details = {}) {
24
+ super(message, "PARTNER_API_ERROR");
25
+ this.partnerCode = partnerCode;
26
+ this.status = status;
27
+ this.details = details;
28
+ this.name = "PickleballPartnerApiError";
29
+ }
30
+ };
22
31
  var PickleballHttpError = class extends PickleballLiveError {
23
32
  constructor(status) {
24
33
  super(`Request failed with HTTP ${status}`, "HTTP_ERROR");
@@ -247,6 +256,49 @@ function validateGrant(value) {
247
256
  config: validateRemoteConfig(data.config)
248
257
  };
249
258
  }
259
+ function validateCameraGrant(value) {
260
+ const data = record(value);
261
+ const base = validateGrant(data);
262
+ if (!/^rtmps?:\/\//i.test(base.serverUrl)) invalid();
263
+ if (data.status !== "scheduled" && data.status !== "live") invalid();
264
+ return {
265
+ ...base,
266
+ status: data.status,
267
+ matchRef: nullableString(data.matchRef),
268
+ courtRef: nullableString(data.courtRef),
269
+ watchUrl: nullableString(data.watchUrl),
270
+ convexUrl: nullableString(data.convexUrl),
271
+ issuedAt: finite(data.issuedAt),
272
+ expiresAt: finite(data.expiresAt)
273
+ };
274
+ }
275
+ function validateCameraSessionCreated(value) {
276
+ const data = record(value);
277
+ if (data.status !== "scheduled" && data.status !== "live" && data.status !== "ended") {
278
+ invalid();
279
+ }
280
+ if (data.visibility !== "public" && data.visibility !== "private") invalid();
281
+ if (typeof data.reused !== "boolean") invalid();
282
+ return {
283
+ id: nonblank(data.id),
284
+ status: data.status,
285
+ visibility: data.visibility,
286
+ quality: finite(data.quality),
287
+ deliveryMode: nonblank(data.deliveryMode),
288
+ matchRef: nullableString(data.matchRef),
289
+ courtRef: nullableString(data.courtRef),
290
+ playbackUrl: nullableString(data.playbackUrl),
291
+ whepUrl: nullableString(data.whepUrl),
292
+ watchUrl: nullableString(data.watchUrl),
293
+ reused: data.reused,
294
+ cameraGrant: validateCameraGrant(data.cameraGrant)
295
+ };
296
+ }
297
+ function validateLegacyEnd(value) {
298
+ const data = record(value);
299
+ if (data.ok !== true || data.status !== "ended") invalid();
300
+ return { status: "ended" };
301
+ }
250
302
  function validateRefresh(value) {
251
303
  const data = record(value);
252
304
  return {
@@ -284,6 +336,7 @@ function validateSession(value) {
284
336
  matchRef: nullableString(data.matchRef),
285
337
  playbackUrl: nullableString(data.playbackUrl),
286
338
  watchUrl: nullableString(data.watchUrl),
339
+ whepUrl: nullableString(data.whepUrl),
287
340
  scheduledAt: nullableNumber(data.scheduledAt),
288
341
  startedAt: nullableNumber(data.startedAt),
289
342
  endedAt: nullableNumber(data.endedAt),
@@ -314,6 +367,29 @@ function validateRecordings(value, sessionId) {
314
367
  if (!Array.isArray(value)) invalid();
315
368
  return value.map((item) => validateRecording(item, sessionId));
316
369
  }
370
+ function validateDeviceRegister(value) {
371
+ const data = record(value);
372
+ const heartbeatIntervalMs = finite(data.heartbeatIntervalMs);
373
+ if (heartbeatIntervalMs <= 0) invalid();
374
+ return {
375
+ deviceId: nonblank(data.deviceId),
376
+ heartbeatIntervalMs
377
+ };
378
+ }
379
+ function validateDeviceHeartbeat(value) {
380
+ const data = record(value);
381
+ if (data.controlSession === null || data.controlSession === void 0) {
382
+ return { controlSession: null };
383
+ }
384
+ const grant = record(data.controlSession);
385
+ return {
386
+ controlSession: {
387
+ id: nonblank(grant.id),
388
+ token: nonblank(grant.token),
389
+ convexUrl: nonblank(grant.convexUrl)
390
+ }
391
+ };
392
+ }
317
393
 
318
394
  // src/contracts.ts
319
395
  var SDK_TELEMETRY_EVENT_NAMES = [
@@ -459,7 +535,23 @@ async function readBoundedResponseText(response) {
459
535
  }
460
536
  return new TextDecoder().decode(bytes);
461
537
  }
462
- async function decodeResponse(response, apiKey, validate) {
538
+ function parsePartnerError(payload, status, apiKey) {
539
+ if (!isRecord(payload) || !("error" in payload)) {
540
+ return new PickleballInvalidResponseError();
541
+ }
542
+ if (isRecord(payload.error)) {
543
+ return parseSdkError(payload, status, apiKey);
544
+ }
545
+ if (typeof payload.error !== "string") return new PickleballInvalidResponseError();
546
+ const { error, code, ...details } = payload;
547
+ const safeMessage = apiKey === "" ? error : error.split(apiKey).join("[REDACTED]");
548
+ const partnerCode = typeof code === "string" && code.trim() !== "" ? code : `HTTP_${status}`;
549
+ return new PickleballPartnerApiError(safeMessage, partnerCode, status, details);
550
+ }
551
+ async function decodeResponse(response, apiKey, validate, options = {
552
+ errorStyle: "sdk",
553
+ envelope: "data"
554
+ }) {
463
555
  if (response.status >= 300 && response.status < 400) {
464
556
  await response.body?.cancel().catch(() => void 0);
465
557
  throw new PickleballHttpError(response.status);
@@ -471,7 +563,10 @@ async function decodeResponse(response, apiKey, validate) {
471
563
  } catch {
472
564
  throw new PickleballInvalidResponseError();
473
565
  }
474
- if (!response.ok) throw parseSdkError(payload, response.status, apiKey);
566
+ if (!response.ok) {
567
+ throw options.errorStyle === "v1" ? parsePartnerError(payload, response.status, apiKey) : parseSdkError(payload, response.status, apiKey);
568
+ }
569
+ if (options.envelope === "raw") return validate(payload);
475
570
  if (!isRecord(payload) || !("data" in payload)) {
476
571
  throw new PickleballInvalidResponseError();
477
572
  }
@@ -479,7 +574,15 @@ async function decodeResponse(response, apiKey, validate) {
479
574
  }
480
575
  function createPickleballLiveClient(options) {
481
576
  const baseUrl = validatedBaseUrl(options.baseUrl);
482
- const appId = requiredNonblank(options.appId, "appId");
577
+ const configuredAppId = options.appId === void 0 ? null : requiredNonblank(options.appId, "appId");
578
+ const requireAppId = () => {
579
+ if (configuredAppId === null) {
580
+ throw new PickleballConfigurationError(
581
+ "appId is required for /api/v2/sdk operations (apps/sessions/devices)"
582
+ );
583
+ }
584
+ return configuredAppId;
585
+ };
483
586
  const apiKey = requiredNonblank(options.apiKey, "apiKey");
484
587
  const fetchImplementation = options.fetch ?? globalThis.fetch;
485
588
  if (typeof fetchImplementation !== "function") {
@@ -492,7 +595,9 @@ function createPickleballLiveClient(options) {
492
595
  path,
493
596
  body,
494
597
  retryPolicy,
495
- validate
598
+ validate,
599
+ errorStyle = "sdk",
600
+ envelope = "data"
496
601
  }) {
497
602
  const retryLimit = retryPolicy === "transient" ? maxRetries : 0;
498
603
  for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
@@ -513,14 +618,14 @@ function createPickleballLiveClient(options) {
513
618
  } : {
514
619
  accept: "application/json",
515
620
  "x-api-key": apiKey,
516
- "x-pickleball-app-id": appId
621
+ ...configuredAppId === null ? {} : { "x-pickleball-app-id": configuredAppId }
517
622
  },
518
623
  ...body === void 0 ? {} : { body: JSON.stringify(body) },
519
624
  signal: controller.signal,
520
625
  redirect: "error"
521
626
  });
522
627
  try {
523
- return await decodeResponse(response, apiKey, validate);
628
+ return await decodeResponse(response, apiKey, validate, { errorStyle, envelope });
524
629
  } catch (error) {
525
630
  if (isTransientStatus(response.status) && attempt < retryLimit && isRetryableResponseError(error)) {
526
631
  clearTimeout(timer);
@@ -532,7 +637,7 @@ function createPickleballLiveClient(options) {
532
637
  throw error;
533
638
  }
534
639
  } catch (error) {
535
- if (error instanceof PickleballApiError || error instanceof PickleballHttpError || error instanceof PickleballInvalidResponseError) {
640
+ if (error instanceof PickleballApiError || error instanceof PickleballPartnerApiError || error instanceof PickleballHttpError || error instanceof PickleballInvalidResponseError) {
536
641
  throw error;
537
642
  }
538
643
  if (attempt < retryLimit) {
@@ -551,54 +656,115 @@ function createPickleballLiveClient(options) {
551
656
  const post = (path, body, retryPolicy, validate) => request({ method: "POST", path, body, retryPolicy, validate });
552
657
  const get = (path, validate) => request({ method: "GET", path, retryPolicy: "transient", validate });
553
658
  const sessionPath = (sessionId) => `/api/v2/sdk/sessions/${encodeURIComponent(sessionId)}`;
659
+ const v1SessionPath = (sessionId) => `/api/v1/live-sessions/${encodeURIComponent(sessionId)}`;
660
+ const postV1 = (path, body, retryPolicy, validate, envelope = "data") => request({ method: "POST", path, body, retryPolicy, validate, errorStyle: "v1", envelope });
661
+ const getV1 = (path, validate) => request({ method: "GET", path, retryPolicy: "transient", validate, errorStyle: "v1" });
554
662
  return {
555
663
  apps: {
556
- bootstrap: (input) => post(
664
+ bootstrap: async (input) => post(
557
665
  "/api/v2/sdk/bootstrap",
558
- { ...input, appId },
666
+ { ...input, appId: requireAppId() },
559
667
  "transient",
560
668
  validateBootstrap
561
669
  )
562
670
  },
563
671
  sessions: {
564
- start: (input) => post(
672
+ start: async (input) => post(
565
673
  "/api/v2/sdk/sessions",
566
- { ...input, appId },
674
+ { ...input, appId: requireAppId() },
567
675
  "transient",
568
676
  validateGrant
569
677
  ),
570
- refresh: (sessionId, input) => post(
678
+ refresh: async (sessionId, input) => post(
571
679
  `${sessionPath(sessionId)}/refresh`,
572
- { ...input, appId },
680
+ { ...input, appId: requireAppId() },
573
681
  "never",
574
682
  validateRefresh
575
683
  ),
576
684
  end: async (input) => {
577
685
  await post(
578
686
  `${sessionPath(input.sessionId)}/end`,
579
- { ...input, appId },
687
+ { ...input, appId: requireAppId() },
580
688
  "transient",
581
689
  (value) => validateEnd(value, input.sessionId)
582
690
  );
583
691
  },
584
- publish: (sessionId) => post(
692
+ publish: async (sessionId) => post(
585
693
  `${sessionPath(sessionId)}/publish`,
586
- { sessionId, appId },
694
+ { sessionId, appId: requireAppId() },
587
695
  "transient",
588
696
  (value) => validatePublishState(value, sessionId)
589
697
  ),
590
- unpublish: (sessionId) => post(
698
+ unpublish: async (sessionId) => post(
591
699
  `${sessionPath(sessionId)}/unpublish`,
592
- { sessionId, appId },
700
+ { sessionId, appId: requireAppId() },
593
701
  "transient",
594
702
  (value) => validatePublishState(value, sessionId)
595
703
  ),
596
- get: (sessionId) => get(sessionPath(sessionId), validateSession),
597
- getRecordings: (sessionId) => get(
598
- `${sessionPath(sessionId)}/recordings`,
704
+ get: async (sessionId) => {
705
+ requireAppId();
706
+ return get(sessionPath(sessionId), validateSession);
707
+ },
708
+ getRecordings: async (sessionId) => {
709
+ requireAppId();
710
+ return get(
711
+ `${sessionPath(sessionId)}/recordings`,
712
+ (value) => validateRecordings(value, sessionId)
713
+ );
714
+ }
715
+ },
716
+ liveSessions: {
717
+ createCameraSession: (input) => postV1(
718
+ "/api/v1/live-sessions",
719
+ {
720
+ title: input.title,
721
+ matchRef: input.matchRef,
722
+ cameraGrant: true,
723
+ deliveryMode: "device-rtmp",
724
+ ...input.visibility === void 0 ? {} : { visibility: input.visibility },
725
+ ...input.description === void 0 ? {} : { description: input.description },
726
+ ...input.quality === void 0 ? {} : { quality: input.quality },
727
+ ...input.courtRef === void 0 ? {} : { courtRef: input.courtRef }
728
+ },
729
+ // Idempotent theo (host, matchRef): gọi lại chỉ cấp grant mới, an toàn để retry.
730
+ "transient",
731
+ validateCameraSessionCreated
732
+ ),
733
+ cameraGrant: (sessionId) => postV1(
734
+ `${v1SessionPath(sessionId)}/camera-grant`,
735
+ {},
736
+ "transient",
737
+ validateCameraGrant
738
+ ),
739
+ end: (sessionId) => postV1(
740
+ `${v1SessionPath(sessionId)}/end`,
741
+ {},
742
+ "transient",
743
+ validateLegacyEnd,
744
+ "raw"
745
+ ),
746
+ get: (sessionId) => getV1(v1SessionPath(sessionId), validateSession),
747
+ getRecordings: (sessionId) => getV1(
748
+ `${v1SessionPath(sessionId)}/recordings`,
599
749
  (value) => validateRecordings(value, sessionId)
600
750
  )
601
751
  },
752
+ devices: {
753
+ register: async (input) => post(
754
+ "/api/v2/sdk/devices/register",
755
+ { ...input, appId: requireAppId() },
756
+ "transient",
757
+ validateDeviceRegister
758
+ ),
759
+ heartbeat: async (input) => post(
760
+ "/api/v2/sdk/devices/heartbeat",
761
+ { ...input, appId: requireAppId() },
762
+ // Heartbeat có side effect trao token 1 lần — không retry để tránh
763
+ // nhận token đã bị đánh dấu delivered ở lần trước
764
+ "never",
765
+ validateDeviceHeartbeat
766
+ )
767
+ },
602
768
  webhooks: { verify: verifyWebhook }
603
769
  };
604
770
  }
@@ -607,6 +773,7 @@ export {
607
773
  PickleballLiveError,
608
774
  PickleballConfigurationError,
609
775
  PickleballApiError,
776
+ PickleballPartnerApiError,
610
777
  PickleballHttpError,
611
778
  PickleballInvalidResponseError,
612
779
  PickleballTimeoutError,