@flitt/api 1.0.0 → 1.2.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/src/client.ts CHANGED
@@ -30,6 +30,7 @@ import type {
30
30
  PlayerList,
31
31
  PlayerStatusFilter,
32
32
  RateLimit,
33
+ Review,
33
34
  ReviewList,
34
35
  ReviewListParams,
35
36
  Server,
@@ -46,6 +47,7 @@ import type {
46
47
  VoteListParams,
47
48
  VoteServer,
48
49
  VoteStats,
50
+ VoteSummary,
49
51
  } from "./types.js";
50
52
 
51
53
  export const DEFAULT_BASE_URL = "https://api.flitt.me";
@@ -74,12 +76,22 @@ export type ClientOptions = {
74
76
  };
75
77
 
76
78
  export type FlittOptions = ClientOptions & {
77
- /** Public API key (`flt_…`, Profile → API). Default: `process.env.FLITT_API_KEY`. */
79
+ /**
80
+ * Account API key (`flt_…`, Profile → API): every endpoint, on every server
81
+ * you own or are a member of. Default: `process.env.FLITT_API_KEY`.
82
+ */
78
83
  apiKey?: string;
84
+ /**
85
+ * Server token (`flv_…`, Dashboard → your server → Votes → API): votes and
86
+ * reviews of that one server only. The credential to put on a game server.
87
+ * Default: `process.env.FLITT_SERVER_TOKEN` (or `FLITT_VOTE_TOKEN`).
88
+ */
89
+ serverToken?: string;
79
90
  };
80
91
 
92
+ /** @deprecated since 1.1: use `new Flitt({ serverToken })` and `flitt.votes`. */
81
93
  export type FlittVotesOptions = ClientOptions & {
82
- /** Vote API token of one server (`flv_…`, Dashboard → server → Votes). Default: `process.env.FLITT_VOTE_TOKEN`. */
94
+ /** Server token (`flv_…`). Default: `process.env.FLITT_VOTE_TOKEN`. */
83
95
  token?: string;
84
96
  };
85
97
 
@@ -137,7 +149,7 @@ function positiveInt(value: number | undefined, name: string, max: number): numb
137
149
  * ```
138
150
  */
139
151
  export class Flitt {
140
- readonly #http: Transport;
152
+ #lastRateLimit: RateLimit | null = null;
141
153
 
142
154
  /** Your account's monthly quota. */
143
155
  readonly usage: {
@@ -148,19 +160,45 @@ export class Flitt {
148
160
  readonly firewall: FirewallResource;
149
161
  readonly discord: DiscordResource;
150
162
  readonly commands: CommandsResource;
163
+ /** Votes: check, claim (idempotent), list, top voters, summary, stats. Account key or server token. */
164
+ readonly votes: VotesResource;
165
+ /** Published reviews. Account key or server token. */
166
+ readonly reviews: ReviewsResource;
151
167
 
152
168
  constructor(options: FlittOptions = {}) {
153
169
  const apiKey = (options.apiKey ?? envVar("FLITT_API_KEY") ?? "").trim();
154
- if (!apiKey) throw new FlittConfigError("Missing API key: pass { apiKey } or set FLITT_API_KEY.");
155
- if (!API_KEY_FORMAT.test(apiKey)) {
170
+ const serverToken = (options.serverToken ?? envVar("FLITT_SERVER_TOKEN") ?? envVar("FLITT_VOTE_TOKEN") ?? "").trim();
171
+ if (!apiKey && !serverToken) {
172
+ throw new FlittConfigError(
173
+ "Missing credentials: pass { apiKey } (flt_…) or { serverToken } (flv_…), or set FLITT_API_KEY / FLITT_SERVER_TOKEN.",
174
+ );
175
+ }
176
+ if (apiKey && !API_KEY_FORMAT.test(apiKey)) {
156
177
  throw new FlittConfigError(
157
178
  apiKey.startsWith("flv_")
158
- ? "This is a Vote API token (flv_…): use `new FlittVotes({ token })` instead."
179
+ ? "This is a server token (flv_…): pass it as { serverToken }."
159
180
  : "Invalid API key format: Flitt API keys start with flt_ (Profile → API).",
160
181
  );
161
182
  }
162
- this.#http = buildTransport(apiKey, options, false);
163
- const http = this.#http;
183
+ if (serverToken && !VOTE_TOKEN_FORMAT.test(serverToken)) {
184
+ throw new FlittConfigError(
185
+ serverToken.startsWith("flt_")
186
+ ? "This is an account API key (flt_…): pass it as { apiKey }."
187
+ : "Invalid server token format: server tokens start with flv_ (Dashboard → your server → Votes → API).",
188
+ );
189
+ }
190
+ const tracked: ClientOptions = {
191
+ ...options,
192
+ onRateLimit: (rl: RateLimit) => {
193
+ this.#lastRateLimit = rl;
194
+ options.onRateLimit?.(rl);
195
+ },
196
+ };
197
+ const accountHttp = apiKey ? buildTransport(apiKey, tracked, false) : null;
198
+ const tokenHttp = serverToken ? buildTransport(serverToken, tracked, false) : null;
199
+ // Account endpoints need the account key; votes and reviews take either (account key first).
200
+ const http = accountHttp ?? (missingApiKey as unknown as Transport);
201
+ const votesHttp = (accountHttp ?? tokenHttp) as Transport;
164
202
  const base = "/v1/public-api";
165
203
 
166
204
  this.usage = { get: async (o) => http.request<Usage>("GET", `${base}/usage`, o) };
@@ -169,19 +207,21 @@ export class Flitt {
169
207
  this.firewall = new FirewallResource(http, base);
170
208
  this.discord = new DiscordResource(http, base);
171
209
  this.commands = new CommandsResource(http, base);
210
+ this.votes = new VotesResource(votesHttp, base);
211
+ this.reviews = new ReviewsResource(votesHttp, base);
172
212
  }
173
213
 
174
214
  /** Quota state from the last response (`null` before the first request). */
175
215
  get rateLimit(): RateLimit | null {
176
- return this.#http.lastRateLimit;
216
+ return this.#lastRateLimit;
177
217
  }
178
218
 
179
- /** Never prints the key. */
219
+ /** Never prints the credentials. */
180
220
  toJSON() {
181
- return { client: "Flitt", apiKey: "[redacted]" };
221
+ return { client: "Flitt", credentials: "[redacted]" };
182
222
  }
183
223
  [INSPECT]() {
184
- return "Flitt { apiKey: [redacted] }";
224
+ return "Flitt { credentials: [redacted] }";
185
225
  }
186
226
  }
187
227
 
@@ -442,6 +482,105 @@ class CommandsResource {
442
482
  }
443
483
  }
444
484
 
485
+ /** Stand-in transport when only a server token was given: account endpoints explain what is missing. */
486
+ const missingApiKey = {
487
+ lastRateLimit: null,
488
+ async request(): Promise<never> {
489
+ throw new FlittConfigError(
490
+ "This endpoint needs an account API key (flt_…, Profile → API). A server token (flv_…) only opens votes and reviews.",
491
+ );
492
+ },
493
+ };
494
+
495
+ class VotesResource {
496
+ constructor(private readonly http: Transport, private readonly base: string) {}
497
+
498
+ private path(serverId: string, rest = ""): string {
499
+ return `${this.base}/servers/${segment(serverId, "serverId")}/votes${rest}`;
500
+ }
501
+
502
+ /** Whether the player has a vote waiting to be claimed (last 24 h). */
503
+ async check(serverId: string, identity: VoteIdentity, options?: RequestOptions): Promise<VoteCheck> {
504
+ return this.http.request<VoteCheck>("GET", this.path(serverId, "/check"), { ...options, query: identityParams(identity) });
505
+ }
506
+
507
+ /**
508
+ * Claims the player's oldest unclaimed vote, exactly once across every
509
+ * channel (addon, API, NuVotifier). Give the reward only when `status` is
510
+ * `CLAIMED_NOW`. An idempotency key is generated so automatic retries can
511
+ * never claim twice; pass your own to make your retries safe too. With an
512
+ * account key, your role needs the "manage votes" permission.
513
+ */
514
+ async claim(serverId: string, identity: VoteIdentity, options?: RequestOptions & { idempotencyKey?: string }): Promise<VoteClaim> {
515
+ const idempotencyKey = options?.idempotencyKey ?? randomKey();
516
+ if (idempotencyKey.length > 128) throw new FlittConfigError("idempotencyKey: 128 characters max.");
517
+ return this.http.request<VoteClaim>("POST", this.path(serverId, "/claim"), { ...options, body: identityParams(identity), idempotencyKey });
518
+ }
519
+
520
+ /** Recent votes, oldest first (up to 30 days back), cursor pagination. */
521
+ async list(serverId: string, params: VoteListParams = {}, options?: RequestOptions): Promise<VoteList> {
522
+ return this.http.request<VoteList>("GET", this.path(serverId), {
523
+ ...options,
524
+ query: { since: params.since, limit: positiveInt(params.limit, "limit", 100), cursor: params.cursor },
525
+ });
526
+ }
527
+
528
+ /** Iterates over every vote since `since`. */
529
+ async *listAll(serverId: string, params: Omit<VoteListParams, "cursor"> = {}, options?: RequestOptions): AsyncGenerator<VoteListItem> {
530
+ let cursor: string | undefined;
531
+ for (;;) {
532
+ const res = await this.list(serverId, { ...params, cursor }, options);
533
+ yield* res.votes;
534
+ if (!res.nextCursor) return;
535
+ cursor = res.nextCursor;
536
+ }
537
+ }
538
+
539
+ /** Top voters of a month (`current_month`, `last_month`, `all_time` or `YYYY-MM`). */
540
+ async top(serverId: string, params: { period?: TopVotersPeriod; limit?: number } = {}, options?: RequestOptions): Promise<TopVoters> {
541
+ return this.http.request<TopVoters>("GET", this.path(serverId, "/top"), {
542
+ ...options,
543
+ query: { period: params.period, limit: positiveInt(params.limit, "limit", 50) },
544
+ });
545
+ }
546
+
547
+ /** Vote counters, ranking, status and rating (and the history with `includeStats`). */
548
+ async summary(serverId: string, params: { includeStats?: boolean } = {}, options?: RequestOptions): Promise<VoteSummary> {
549
+ return this.http.request<VoteSummary>("GET", this.path(serverId, "/summary"), {
550
+ ...options,
551
+ query: { include: params.includeStats ? "stats" : undefined },
552
+ });
553
+ }
554
+
555
+ /** Daily, weekly and monthly votes, page views and join clicks. */
556
+ async stats(serverId: string, options?: RequestOptions): Promise<VoteStats> {
557
+ return this.http.request<VoteStats>("GET", this.path(serverId, "/stats"), options);
558
+ }
559
+ }
560
+
561
+ class ReviewsResource {
562
+ constructor(private readonly http: Transport, private readonly base: string) {}
563
+
564
+ /** Published reviews of a server. */
565
+ async list(serverId: string, params: ReviewListParams = {}, options?: RequestOptions): Promise<ReviewList> {
566
+ return this.http.request<ReviewList>("GET", `${this.base}/servers/${segment(serverId, "serverId")}/reviews`, {
567
+ ...options,
568
+ query: { limit: positiveInt(params.limit, "limit", 50), cursor: params.cursor, sort: params.sort },
569
+ });
570
+ }
571
+
572
+ /** Iterates over every published review. */
573
+ async *listAll(serverId: string, params: Omit<ReviewListParams, "cursor"> = {}, options?: RequestOptions): AsyncGenerator<Review> {
574
+ let cursor: string | undefined;
575
+ for (;;) {
576
+ const res = await this.list(serverId, { ...params, cursor }, options);
577
+ yield* res.reviews;
578
+ if (!res.nextCursor) return;
579
+ cursor = res.nextCursor;
580
+ }
581
+ }
582
+ }
583
+
445
584
  function snowflake(value: string): string {
446
585
  const v = String(value ?? "").trim();
447
586
  if (!/^\d{15,22}$/.test(v)) throw new FlittConfigError("discordId must be a Discord user id (15 to 22 digits).");
@@ -474,6 +613,10 @@ function identityParams(identity: VoteIdentity): Record<string, string> {
474
613
  }
475
614
 
476
615
  /**
616
+ * @deprecated since 1.1 — votes are part of the Public API: use
617
+ * `new Flitt({ serverToken })` and `flitt.votes.claim(serverId, identity)`.
618
+ * Kept so 1.0 code keeps working (it calls the legacy /v1/vote-api routes).
619
+ *
477
620
  * Client of the Flitt Vote API: reward voters from your own scripts when the
478
621
  * Flitt addon is not installed. One token = one server.
479
622
  *
@@ -493,7 +636,7 @@ export class FlittVotes {
493
636
  if (!VOTE_TOKEN_FORMAT.test(token)) {
494
637
  throw new FlittConfigError(
495
638
  token.startsWith("flt_")
496
- ? "This is a Public API key (flt_…): use `new Flitt({ apiKey })` instead."
639
+ ? "This is an account API key (flt_…): use `new Flitt({ apiKey })` instead."
497
640
  : "Invalid vote token format: vote tokens start with flv_ (Dashboard → your server → Votes → Vote API).",
498
641
  );
499
642
  }
package/src/http.ts CHANGED
@@ -191,12 +191,28 @@ function describeError(status: number, statusText: string, body: unknown, text:
191
191
  }
192
192
 
193
193
  export class Transport {
194
- private readonly opts: TransportOptions;
194
+ // Real private fields (not TypeScript `private`): the token and the options
195
+ // holding it cannot be reached by console.log, util.inspect or JSON.stringify.
196
+ readonly #opts: Omit<TransportOptions, "token">;
197
+ readonly #token: string;
195
198
  /** Quota state from the last response carrying X-RateLimit-* headers. */
196
199
  lastRateLimit: RateLimit | null = null;
197
200
 
198
201
  constructor(opts: TransportOptions) {
199
- this.opts = opts;
202
+ const { token, ...rest } = opts;
203
+ this.#token = token;
204
+ this.#opts = rest;
205
+ }
206
+
207
+ toJSON() {
208
+ return { baseUrl: this.#opts.baseUrl, token: "[redacted]" };
209
+ }
210
+ [Symbol.for("nodejs.util.inspect.custom")]() {
211
+ return `Transport { baseUrl: ${this.#opts.baseUrl}, token: [redacted] }`;
212
+ }
213
+
214
+ private get opts() {
215
+ return this.#opts;
200
216
  }
201
217
 
202
218
  async request<T>(method: string, path: string, call: Call = {}): Promise<T> {
@@ -208,7 +224,7 @@ export class Transport {
208
224
  const headers: Record<string, string> = {
209
225
  Accept: "application/json",
210
226
  ...this.opts.headers,
211
- Authorization: `Bearer ${this.opts.token}`,
227
+ Authorization: `Bearer ${this.#token}`,
212
228
  };
213
229
  // Browsers forbid setting User-Agent; elsewhere it identifies the SDK in our logs.
214
230
  if (!isBrowserLike()) headers["User-Agent"] = this.opts.userAgent ?? `flitt-api-js/${VERSION}`;
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @flitt/api — official client of the Flitt Public API and Vote API.
2
+ * @flitt/api — official client of the Flitt Public API (votes included).
3
3
  * https://flitt.me/docs/public-api
4
4
  */
5
5
  export { Flitt, FlittVotes, DEFAULT_BASE_URL } from "./client.js";
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Response types of the Flitt Public API v1 (`/v1/public-api`) and of the
3
- * Vote API (`/v1/vote-api`). Dates are ISO 8601 strings, as sent by the API.
2
+ * Response types of the Flitt Public API v1 (`/v1/public-api`), votes and
3
+ * reviews included. Dates are ISO 8601 strings, as sent by the API.
4
4
  */
5
5
 
6
6
  export type Game = "GMOD" | "FIVEM" | "MC";
@@ -48,6 +48,20 @@ export type Usage = {
48
48
  resetAt: string;
49
49
  };
50
50
 
51
+ /**
52
+ * Extended status set by the owner: maintenance, development or temporary
53
+ * closure. While set, the uptime is paused (checks count neither as online nor
54
+ * offline) and up / down alerts are muted.
55
+ */
56
+ export type ServerHold = {
57
+ kind: "MAINTENANCE" | "DEVELOPMENT" | "CLOSED";
58
+ /** Owner's note for the players, if any. */
59
+ message: string | null;
60
+ since: string;
61
+ /** Planned end (the status ends by itself then), `null` when not announced. */
62
+ until: string | null;
63
+ };
64
+
51
65
  export type ServerSummary = {
52
66
  id: string;
53
67
  game: Game;
@@ -61,6 +75,8 @@ export type ServerSummary = {
61
75
  rating: number;
62
76
  reviewsCount: number;
63
77
  verifiedBadge: boolean;
78
+ /** Maintenance / development / closure, `null` when the server runs normally. */
79
+ hold: ServerHold | null;
64
80
  createdAt: string | null;
65
81
  };
66
82
 
@@ -92,6 +108,8 @@ export type Server = {
92
108
  rating: number;
93
109
  reviewsCount: number;
94
110
  status: OnlineStatus;
111
+ /** Maintenance / development / closure, `null` when the server runs normally. */
112
+ hold: ServerHold | null;
95
113
  accessRole: AccessRole;
96
114
  };
97
115
 
@@ -264,7 +282,9 @@ export type WebhookEventType =
264
282
  | "review_created"
265
283
  | "vote_created"
266
284
  | "affluence_low"
267
- | "affluence_high";
285
+ | "affluence_high"
286
+ | "server_hold_started"
287
+ | "server_hold_ended";
268
288
 
269
289
  /** Body of a generic (HTTP) webhook. */
270
290
  export type WebhookEvent<T = Record<string, unknown> | null> = {
@@ -275,7 +295,7 @@ export type WebhookEvent<T = Record<string, unknown> | null> = {
275
295
  };
276
296
 
277
297
  // ---------------------------------------------------------------------------
278
- // Vote API
298
+ // Votes and reviews (part of the Public API)
279
299
  // ---------------------------------------------------------------------------
280
300
 
281
301
  type IdentityFields = {
@@ -355,7 +375,8 @@ export type VoteStats = {
355
375
  monthly: Array<{ month: string; votes: number; views: number; joinClicks: number; rank: number | null }>;
356
376
  };
357
377
 
358
- export type VoteServer = {
378
+ /** Vote counters, ranking, status and rating of a server (`flitt.votes.summary`). */
379
+ export type VoteSummary = {
359
380
  id: string;
360
381
  name: string | null;
361
382
  game: Game;
@@ -370,6 +391,9 @@ export type VoteServer = {
370
391
  stats?: VoteStats;
371
392
  };
372
393
 
394
+ /** @deprecated since 1.1: renamed VoteSummary. */
395
+ export type VoteServer = VoteSummary;
396
+
373
397
  export type Review = {
374
398
  id: string;
375
399
  rating: number;
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Package version, kept in sync with package.json by scripts/sync-version.mjs. */
2
- export const VERSION = "1.0.0";
2
+ export const VERSION = "1.2.0";