vairified 0.3.1 → 0.4.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/README.md CHANGED
@@ -69,12 +69,13 @@ const member = await client.members.get('vair_mem_xxx');
69
69
  console.log(member.name); // Full name
70
70
  console.log(member.displayName); // "Mike B."
71
71
  console.log(member.ratingFor('pickleball')); // 3.915
72
- console.log(member.status.isVairified); // true
72
+ console.log(member.sport.get('pickleball')?.isVairified); // true (per-sport)
73
73
 
74
74
  // Dict-like access to rating splits for a specific sport
75
75
  const pb = member.sport.get('pickleball');
76
76
  if (pb) {
77
77
  console.log(pb.rating, pb.abbr); // 3.915 VO
78
+ console.log(pb.isVairified, pb.isVairPro); // per-sport status flags
78
79
  console.log(pb.get('overall-open')?.rating); // 3.915
79
80
  console.log(pb.has('singles-open')); // true
80
81
  for (const [key, split] of pb) {
@@ -357,6 +358,60 @@ export VAIRIFIED_ENV="staging" # optional; default: production
357
358
  const client = new Vairified(); // reads both env vars
358
359
  ```
359
360
 
361
+ ## React Native
362
+
363
+ The SDK keeps its **zero-dependency** promise on React Native too — it doesn't bundle
364
+ any polyfills. Instead it feature-detects the platform primitives it needs and expects
365
+ your app to provide the two that Hermes lacks.
366
+
367
+ ### Required polyfills
368
+
369
+ Install them and import each **once, at your app entry** (e.g. the top of `index.js`),
370
+ before any SDK call:
371
+
372
+ ```bash
373
+ npm install react-native-get-random-values react-native-url-polyfill
374
+ ```
375
+
376
+ ```ts
377
+ // index.js — must run before `import { Vairified } from 'vairified'`
378
+ import 'react-native-get-random-values'; // Web Crypto for generateState()
379
+ import 'react-native-url-polyfill/auto'; // WHATWG URL / URLSearchParams
380
+ ```
381
+
382
+ - **`react-native-get-random-values`** backs `crypto.getRandomValues`, which
383
+ `generateState()` uses for CSRF tokens. Without it, `generateState()` throws a
384
+ descriptive error (not a bare `ReferenceError`); you can also skip it and pass your
385
+ own high-entropy `state` string to `oauth.authorize()`.
386
+ - **`react-native-url-polyfill`** provides a complete `URL`/`URLSearchParams`. Hermes'
387
+ built-ins are incomplete, so request-URL and authorization-URL building need this.
388
+
389
+ ### Pass `apiKey` and `env` explicitly
390
+
391
+ React Native has no `process.env`, so the `VAIRIFIED_API_KEY` / `VAIRIFIED_ENV` fallbacks
392
+ never resolve there (the SDK guards against the missing global rather than crashing).
393
+ Always construct the client explicitly:
394
+
395
+ ```ts
396
+ const client = new Vairified({ apiKey: 'vair_pk_xxx', env: 'production' });
397
+ ```
398
+
399
+ ### Token topology — never ship the API key in the app
400
+
401
+ The secret partner API key (`X-API-Key`) **must never ship in a mobile app binary**.
402
+ Split the OAuth flow across the app and your backend:
403
+
404
+ 1. **App:** open the authorization URL using your public `client_id` (your `PartnerApp`
405
+ slug) — build it with `getAuthorizationUrl({ redirectUri, clientId })` — and capture
406
+ the `myapp://callback` deep link to read the `code` (and `state`).
407
+ 2. **Your backend:** perform the code exchange there with a `Vairified` client that holds
408
+ the secret key — `client.oauth.exchangeToken({ code, redirectUri })`, and likewise
409
+ `refresh()` / `revoke()`. The SDK injects `X-API-Key` on these calls, so they belong
410
+ on the server, never in the app.
411
+
412
+ The app sends the captured `code` to your backend over your own authenticated channel;
413
+ the backend returns only the resulting access token (or a session) to the app.
414
+
360
415
  ## Error Handling
361
416
 
362
417
  The SDK maps HTTP status codes to typed exceptions. All typed exceptions inherit from
@@ -410,7 +465,8 @@ member.displayName // "Mike B."
410
465
  member.firstName / lastName
411
466
  member.gender // 'MALE' | 'FEMALE' | 'OTHER' | 'UNKNOWN' | null
412
467
  member.age / city / state / zip / country
413
- member.status.isVairified // grouped status flags
468
+ member.status.isWheelchair // global status flags
469
+ member.status.isAmbassador
414
470
  member.status.isConnected
415
471
  member.sport // MemberSportMap
416
472
  member.sports // readonly string[] of sport codes
@@ -418,12 +474,19 @@ member.ratingFor('pickleball') // number | null
418
474
  member.split('overall-open') // RatingSplitWire | null
419
475
  ```
420
476
 
477
+ VAIRification & VAIR-Pro status are **per-sport** — read them off each
478
+ `member.sport` entry, not `member.status` (see `SportRating` below).
479
+
421
480
  ### `SportRating` (dict-like)
422
481
 
423
482
  ```ts
424
483
  const pb = member.sport.get('pickleball');
425
484
  pb?.rating // Primary rating for this sport
426
485
  pb?.abbr // "VO", "VG", etc.
486
+ pb?.isVairified // per-sport VAIRified flag (Vairified#783)
487
+ pb?.isRater // per-sport rater flag
488
+ pb?.isVairPro // per-sport VAIR-Pro flag
489
+ pb?.isVairProStatus // 'PENDING' | 'ACTIVE' | null
427
490
  pb?.get('overall-open') // Any split key
428
491
  pb?.size // Number of splits
429
492
  pb?.has('singles-40+') // Membership check
@@ -432,6 +495,16 @@ for (const [key, split] of pb ?? []) { /* iterate */ }
432
495
 
433
496
  ## Migrating
434
497
 
498
+ **From 0.3.x → 0.4.0 (breaking):** VAIRification & VAIR-Pro status are now
499
+ **per-sport** (Vairified#783). `isVairified`, `isRater`, `isVairPro`, and
500
+ `isVairProStatus` moved off the member `status` object onto each per-sport
501
+ entry — read them via `member.sport.get(code)?.isVairified` instead of
502
+ `member.status.isVairified`. The `status` object keeps only the genuinely
503
+ global flags (`isWheelchair`, `isAmbassador`, `isConnected`). This mirrors
504
+ the backend: a player can be VAIRified / a VAIR Pro in one sport but not
505
+ another. Publish only after the backend #788 reaches production — against
506
+ the old prod shape the per-sport flags default to `false`/`null`.
507
+
435
508
  **From 0.2.x → 0.3.0:** All OAuth scope strings gained a `user:` prefix
436
509
  (`profile:read` → `user:profile:read`). Update any hardcoded scope arrays.
437
510
  New: `members.getBulk()`, `matches.tournamentImport()`, `webhooks.deliveries()`.
package/dist/index.cjs CHANGED
@@ -419,10 +419,22 @@ var SportRating = class {
419
419
  rating;
420
420
  /** Category abbreviation for the primary rating (e.g. `'VO'`). */
421
421
  abbr;
422
+ /** Player is VAIRified in this sport (has a verified, non-recreational rating). */
423
+ isVairified;
424
+ /** Player is an active VAIR Pro (can rate) in this sport. Alias of {@link isVairPro}. */
425
+ isRater;
426
+ /** Player is an active VAIR Pro (can rate) in this sport. */
427
+ isVairPro;
428
+ /** VAIR-Pro lifecycle status in this sport (`'ACTIVE'` / `'PENDING'` / `null`). */
429
+ isVairProStatus;
422
430
  #splits;
423
431
  constructor(wire) {
424
432
  this.rating = wire.rating;
425
433
  this.abbr = wire.abbr;
434
+ this.isVairified = wire.isVairified ?? false;
435
+ this.isRater = wire.isRater ?? false;
436
+ this.isVairPro = wire.isVairPro ?? false;
437
+ this.isVairProStatus = wire.isVairProStatus ?? null;
426
438
  this.#splits = new Map(Object.entries(wire.ratingSplits ?? {}));
427
439
  Object.freeze(this);
428
440
  }
@@ -655,7 +667,7 @@ var MembersResource = class {
655
667
  * ```
656
668
  */
657
669
  async get(playerId, options = {}) {
658
- const query = { id: playerId };
670
+ const query = { memberId: playerId };
659
671
  if (options.sport !== void 0) {
660
672
  query.sport = Array.isArray(options.sport) ? options.sport.join(",") : options.sport;
661
673
  }
@@ -846,9 +858,12 @@ function getAuthorizationUrl(config, options = {}) {
846
858
  const scopeList = ensureProfileRead(options.scopes ?? DEFAULT_SCOPES);
847
859
  const params = new URLSearchParams({
848
860
  redirect_uri: config.redirectUri,
849
- scope: scopeList.join(","),
861
+ scope: scopeList.join(" "),
850
862
  response_type: "code"
851
863
  });
864
+ if (config.clientId) {
865
+ params.set("client_id", config.clientId);
866
+ }
852
867
  if (options.state) {
853
868
  params.set("state", options.state);
854
869
  }
@@ -870,13 +885,31 @@ function describeScopes(scopes) {
870
885
  }));
871
886
  }
872
887
  function generateState() {
873
- const bytes = new Uint8Array(32);
874
- crypto.getRandomValues(bytes);
875
- let binary = "";
876
- for (const byte of bytes) {
877
- binary += String.fromCharCode(byte);
888
+ const webCrypto = globalThis.crypto;
889
+ if (typeof webCrypto?.getRandomValues !== "function") {
890
+ throw new Error(
891
+ "generateState() needs Web Crypto (crypto.getRandomValues), which is unavailable in this runtime. Node 19+ and browsers have it built in; on React Native / Hermes, install 'react-native-get-random-values' and import it once at your app entry before using the SDK \u2014 or pass your own high-entropy `state` string to oauth.authorize()."
892
+ );
878
893
  }
879
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
894
+ const bytes = new Uint8Array(32);
895
+ webCrypto.getRandomValues(bytes);
896
+ return base64UrlNoPad(bytes);
897
+ }
898
+ var B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
899
+ function base64UrlNoPad(bytes) {
900
+ let out = "";
901
+ for (let i = 0; i < bytes.length; i += 3) {
902
+ const b0 = bytes[i] ?? 0;
903
+ const b1 = bytes[i + 1] ?? 0;
904
+ const b2 = bytes[i + 2] ?? 0;
905
+ const hasB1 = i + 1 < bytes.length;
906
+ const hasB2 = i + 2 < bytes.length;
907
+ out += B64URL_ALPHABET[b0 >> 2] ?? "";
908
+ out += B64URL_ALPHABET[(b0 & 3) << 4 | b1 >> 4] ?? "";
909
+ if (hasB1) out += B64URL_ALPHABET[(b1 & 15) << 2 | b2 >> 6] ?? "";
910
+ if (hasB2) out += B64URL_ALPHABET[b2 & 63] ?? "";
911
+ }
912
+ return out;
880
913
  }
881
914
  function ensureProfileRead(scopes) {
882
915
  if (scopes.includes("user:profile:read")) {
@@ -909,13 +942,13 @@ var OAuthResource = class {
909
942
  method: "POST",
910
943
  path: "/partner/oauth/authorize",
911
944
  body: {
912
- redirectUri: options.redirectUri,
913
- scope: scopeList.join(","),
945
+ redirect_uri: options.redirectUri,
946
+ scope: scopeList.join(" "),
914
947
  state: options.state
915
948
  }
916
949
  });
917
950
  return {
918
- authorizationUrl: data?.authorizationUrl ?? "",
951
+ authorizationUrl: data?.authorization_url ?? "",
919
952
  code: data?.code ?? "",
920
953
  state: options.state
921
954
  };
@@ -925,7 +958,13 @@ var OAuthResource = class {
925
958
  const data = await this.#http.request({
926
959
  method: "POST",
927
960
  path: "/partner/oauth/token",
928
- body: { code: options.code, redirectUri: options.redirectUri }
961
+ // RFC 6749 §4.1.3 the endpoint requires `grant_type` and the
962
+ // snake_case `redirect_uri` (which must match the authorize request).
963
+ body: {
964
+ grant_type: "authorization_code",
965
+ code: options.code,
966
+ redirect_uri: options.redirectUri
967
+ }
929
968
  });
930
969
  return tokenResponseFromWire(data);
931
970
  }
@@ -934,7 +973,8 @@ var OAuthResource = class {
934
973
  const data = await this.#http.request({
935
974
  method: "POST",
936
975
  path: "/partner/oauth/refresh",
937
- body: { refreshToken }
976
+ // RFC 6749 §6 — refresh grant, snake_case body.
977
+ body: { grant_type: "refresh_token", refresh_token: refreshToken }
938
978
  });
939
979
  return tokenResponseFromWire(data);
940
980
  }
@@ -943,7 +983,7 @@ var OAuthResource = class {
943
983
  const data = await this.#http.request({
944
984
  method: "POST",
945
985
  path: "/partner/oauth/revoke",
946
- body: { playerId }
986
+ body: { player_id: playerId }
947
987
  });
948
988
  return data ?? {};
949
989
  }
@@ -961,13 +1001,13 @@ var OAuthResource = class {
961
1001
  };
962
1002
  function tokenResponseFromWire(data) {
963
1003
  const scopeRaw = data?.scope ?? "";
964
- const scopeList = scopeRaw.length > 0 ? scopeRaw.split(",") : [];
1004
+ const scopeList = scopeRaw.length > 0 ? scopeRaw.split(/\s+/).filter(Boolean) : Array.isArray(data?.scopes) ? data.scopes : [];
965
1005
  return {
966
- accessToken: data?.accessToken ?? "",
967
- refreshToken: data?.refreshToken ?? null,
968
- expiresIn: data?.expiresIn ?? 3600,
1006
+ accessToken: data?.access_token ?? "",
1007
+ refreshToken: data?.refresh_token ?? null,
1008
+ expiresIn: data?.expires_in ?? 3600,
969
1009
  scope: Object.freeze(scopeList),
970
- playerId: data?.playerId ?? ""
1010
+ playerId: data?.player_id ?? ""
971
1011
  };
972
1012
  }
973
1013
 
@@ -1071,6 +1111,14 @@ var ENVIRONMENTS = Object.freeze({
1071
1111
  local: "http://localhost:3001/api/v1"
1072
1112
  });
1073
1113
  var DEFAULT_TIMEOUT_MS = 3e4;
1114
+ function readEnv(name) {
1115
+ try {
1116
+ if (typeof process === "undefined") return void 0;
1117
+ return process.env?.[name];
1118
+ } catch {
1119
+ return void 0;
1120
+ }
1121
+ }
1074
1122
  var Vairified = class {
1075
1123
  /** The resolved API key this client is using. */
1076
1124
  apiKey;
@@ -1092,7 +1140,7 @@ var Vairified = class {
1092
1140
  webhooks;
1093
1141
  #transport;
1094
1142
  constructor(options = {}) {
1095
- const apiKey = options.apiKey ?? process.env.VAIRIFIED_API_KEY ?? "";
1143
+ const apiKey = options.apiKey ?? readEnv("VAIRIFIED_API_KEY") ?? "";
1096
1144
  if (apiKey.length === 0) {
1097
1145
  throw new Error("API key required. Pass { apiKey } or set VAIRIFIED_API_KEY.");
1098
1146
  }
@@ -1101,7 +1149,7 @@ var Vairified = class {
1101
1149
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1102
1150
  this.env = options.env ?? "production";
1103
1151
  } else {
1104
- const envName = options.env ?? process.env.VAIRIFIED_ENV ?? "production";
1152
+ const envName = options.env ?? readEnv("VAIRIFIED_ENV") ?? "production";
1105
1153
  if (options.env && !(envName in ENVIRONMENTS)) {
1106
1154
  throw new Error(
1107
1155
  `Unknown environment: ${envName}. Use one of: ${Object.keys(ENVIRONMENTS).join(", ")}`