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 +75 -2
- package/dist/index.cjs +69 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -5
- package/dist/index.d.ts +44 -5
- package/dist/index.js +69 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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 = {
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
967
|
-
refreshToken: data?.
|
|
968
|
-
expiresIn: data?.
|
|
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?.
|
|
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 ??
|
|
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 ??
|
|
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(", ")}`
|