app-settings-js 0.1.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 ADDED
@@ -0,0 +1,572 @@
1
+ import { AppSettingsError } from "./errors.ts";
2
+ import { createTransport, request, type FetchLike, type RequestOptions, type TransportConfig, type TransportOptions } from "./http.ts";
3
+ import { SettingsSnapshot } from "./snapshot.ts";
4
+ import type {
5
+ ApiKey,
6
+ CreatedApiKey,
7
+ Environment,
8
+ Group,
9
+ GroupMember,
10
+ Health,
11
+ IntermediateValue,
12
+ PersonalValue,
13
+ Platform,
14
+ ResolveResponse,
15
+ Role,
16
+ Scope,
17
+ ServerValue,
18
+ Setting,
19
+ SettingScope,
20
+ SettingType,
21
+ SettingValue,
22
+ TypeConfig,
23
+ WhoAmI,
24
+ } from "./types.ts";
25
+
26
+ /** How to reach the server, and what to assume when a call does not say. */
27
+ export interface ClientOptions extends TransportOptions {
28
+ /**
29
+ * The environment used by calls that require one. Resolution always needs an
30
+ * environment, so setting it here means most calls take no options at all.
31
+ */
32
+ environment?: string;
33
+ /** The platform filter applied to resolution and to setting lookups. */
34
+ platform?: string | string[];
35
+ }
36
+
37
+ /** Options shared by both resolution calls. */
38
+ export interface ResolveOptions extends RequestOptions {
39
+ /** Overrides the client's environment. Required if the client has none. */
40
+ environment?: string;
41
+ /** Restricts the resolution to these platforms. Capped by the key's own fence. */
42
+ platform?: string | string[];
43
+ /** Resolves as this role instead of the key's. It may not outrank the key. */
44
+ role?: string;
45
+ }
46
+
47
+ /** Resolution for one user. */
48
+ export interface ResolveUserOptions extends ResolveOptions {
49
+ /**
50
+ * Groups to apply without storing membership, which is how an ad-hoc group
51
+ * is used. Saved memberships apply regardless.
52
+ */
53
+ groupId?: string | string[];
54
+ }
55
+
56
+ /** Filters for listing setting definitions. */
57
+ export interface ListSettingsOptions extends RequestOptions {
58
+ environment?: string;
59
+ platform?: string;
60
+ scope?: SettingScope;
61
+ }
62
+
63
+ /** A new setting definition. Type, scope, platform and environment are fixed at creation. */
64
+ export interface CreateSettingInput {
65
+ name: string;
66
+ type: SettingType;
67
+ scope: SettingScope;
68
+ platform: string;
69
+ /** Defaults to the client's environment. */
70
+ environment?: string;
71
+ description?: string;
72
+ /** The rules for this type. See {@link TypeConfig}. */
73
+ typeConfig?: TypeConfig;
74
+ /** Defaults to the calling key's own role. */
75
+ role?: string;
76
+ /** Used when no layer supplies a value. */
77
+ defaultValue?: SettingValue;
78
+ }
79
+
80
+ /** The parts of a definition that are safe to change after creation. */
81
+ export interface UpdateSettingInput {
82
+ description?: string;
83
+ typeConfig?: TypeConfig;
84
+ role?: string;
85
+ defaultValue?: SettingValue;
86
+ }
87
+
88
+ /** Options for writing a group override. */
89
+ export interface GroupValueOptions extends RequestOptions {
90
+ /** Whether the user is meant to observe the override. Defaults to true. */
91
+ visible?: boolean;
92
+ /** Whether the override beats the user's own value. Defaults to false. */
93
+ enforced?: boolean;
94
+ }
95
+
96
+ /** A new group. */
97
+ export interface CreateGroupInput {
98
+ name: string;
99
+ description?: string;
100
+ /** Omit to span every environment, which a fenced key may not do. */
101
+ environment?: string | null;
102
+ /** Higher priority wins when two groups override the same setting. */
103
+ priority?: number;
104
+ /** Marks an ad-hoc group so operators can prune it later. */
105
+ ephemeral?: boolean;
106
+ /** Seeds membership in the same request. */
107
+ members?: string[];
108
+ }
109
+
110
+ /** A new API key. It can never reach further than the key that mints it. */
111
+ export interface CreateKeyInput {
112
+ name: string;
113
+ scopes: Scope[];
114
+ /** Empty inherits the creating key's fence rather than granting everything. */
115
+ environments?: string[];
116
+ platforms?: string[];
117
+ /** Defaults to the lowest-ranked role. May not outrank the creating key. */
118
+ role?: string;
119
+ /** Supply at most one of these. */
120
+ expiresAt?: Date | string;
121
+ /** A Go duration such as `"720h"`. */
122
+ expiresIn?: string;
123
+ }
124
+
125
+ /**
126
+ * A client for the App Settings API.
127
+ *
128
+ * One instance is cheap and holds no connection state, so it is safe to build
129
+ * once at module scope and share it.
130
+ *
131
+ * @example
132
+ * const client = new AppSettingsClient({
133
+ * baseUrl: "https://settings.example.com",
134
+ * apiKey: process.env.SETTINGS_API_KEY!,
135
+ * environment: "production",
136
+ * });
137
+ *
138
+ * const settings = await client.resolveUser("alice");
139
+ * if (settings.boolean("dark_mode")) { ... }
140
+ */
141
+ export class AppSettingsClient {
142
+ readonly #transport: TransportConfig;
143
+ readonly #environment?: string;
144
+ readonly #platform?: string[];
145
+
146
+ constructor(options: ClientOptions) {
147
+ this.#transport = createTransport(options);
148
+ this.#environment = options.environment;
149
+ this.#platform = options.platform === undefined ? undefined : toArray(options.platform);
150
+ }
151
+
152
+ /** The environment this client defaults to, if it has one. */
153
+ get environment(): string | undefined {
154
+ return this.#environment;
155
+ }
156
+
157
+ /** A copy of this client bound to a different environment. */
158
+ withEnvironment(environment: string): AppSettingsClient {
159
+ return new AppSettingsClient({
160
+ ...this.#transport,
161
+ environment,
162
+ platform: this.#platform,
163
+ });
164
+ }
165
+
166
+ // --- Effective settings -------------------------------------------------
167
+
168
+ /**
169
+ * Every setting a user can see, collapsed to one effective value each.
170
+ *
171
+ * This is the call a product backend makes. Precedence, lowest to highest, is
172
+ * `default < server < advisory group < personal < enforced group`.
173
+ */
174
+ async resolveUser(userId: string, options: ResolveUserOptions = {}): Promise<SettingsSnapshot> {
175
+ const response = await this.#request<ResolveResponse>({
176
+ method: "GET",
177
+ path: `/api/v1/resolve/user/${encode(userId)}`,
178
+ query: {
179
+ ...this.#resolveQuery(options),
180
+ group_id: options.groupId === undefined ? undefined : toArray(options.groupId),
181
+ },
182
+ options,
183
+ });
184
+ return new SettingsSnapshot(response);
185
+ }
186
+
187
+ /** The server's own settings, with no user layer applied. */
188
+ async resolveServer(options: ResolveOptions = {}): Promise<SettingsSnapshot> {
189
+ const response = await this.#request<ResolveResponse>({
190
+ method: "GET",
191
+ path: "/api/v1/resolve/server",
192
+ query: this.#resolveQuery(options),
193
+ options,
194
+ });
195
+ return new SettingsSnapshot(response);
196
+ }
197
+
198
+ // --- Identity and health ------------------------------------------------
199
+
200
+ /** Describes the calling key, so a deployment can confirm what it can do. */
201
+ whoami(options?: RequestOptions): Promise<WhoAmI> {
202
+ return this.#request({ method: "GET", path: "/api/v1/whoami", options });
203
+ }
204
+
205
+ /** Whether the process is up. Needs no API key on the server, but sends one. */
206
+ health(options?: RequestOptions): Promise<Health> {
207
+ return this.#request({ method: "GET", path: "/healthz", options });
208
+ }
209
+
210
+ /** Whether the server's dependencies are reachable. */
211
+ ready(options?: RequestOptions): Promise<Health> {
212
+ return this.#request({ method: "GET", path: "/readyz", options });
213
+ }
214
+
215
+ // --- Setting definitions ------------------------------------------------
216
+
217
+ readonly settings = {
218
+ /** Every definition this key may see, narrowed by the given filters. */
219
+ list: async (options: ListSettingsOptions = {}): Promise<Setting[]> => {
220
+ const body = await this.#request<{ settings: Setting[] }>({
221
+ method: "GET",
222
+ path: "/api/v1/settings",
223
+ query: {
224
+ environment: options.environment ?? this.#environment,
225
+ platform: options.platform ?? this.#platform?.[0],
226
+ scope: options.scope,
227
+ },
228
+ options,
229
+ });
230
+ return body.settings ?? [];
231
+ },
232
+
233
+ /** One definition by id. */
234
+ get: (id: string, options?: RequestOptions): Promise<Setting> =>
235
+ this.#request({ method: "GET", path: `/api/v1/settings/${encode(id)}`, options }),
236
+
237
+ /** Defines a new setting. */
238
+ create: async (input: CreateSettingInput, options?: RequestOptions): Promise<Setting> => {
239
+ const environment = input.environment ?? this.#environment;
240
+ if (!environment) throw missingEnvironment("settings.create");
241
+
242
+ return this.#request({
243
+ method: "POST",
244
+ path: "/api/v1/settings",
245
+ body: {
246
+ name: input.name,
247
+ description: input.description ?? "",
248
+ type: input.type,
249
+ type_config: input.typeConfig ?? {},
250
+ role: input.role,
251
+ scope: input.scope,
252
+ platform: input.platform,
253
+ environment,
254
+ default_value: input.defaultValue ?? null,
255
+ },
256
+ options,
257
+ });
258
+ },
259
+
260
+ /** Changes a definition. Omitted fields are left as they are. */
261
+ update: (id: string, input: UpdateSettingInput, options?: RequestOptions): Promise<Setting> =>
262
+ this.#request({
263
+ method: "PATCH",
264
+ path: `/api/v1/settings/${encode(id)}`,
265
+ body: {
266
+ description: input.description,
267
+ type_config: input.typeConfig,
268
+ role: input.role,
269
+ default_value: input.defaultValue,
270
+ },
271
+ options,
272
+ }),
273
+
274
+ /**
275
+ * Removes a definition.
276
+ *
277
+ * A delete that would destroy stored values is refused with a `conflict`
278
+ * naming how many, unless `cascade` says to go ahead.
279
+ */
280
+ delete: (id: string, options: RequestOptions & { cascade?: boolean } = {}): Promise<void> =>
281
+ this.#request({
282
+ method: "DELETE",
283
+ path: `/api/v1/settings/${encode(id)}`,
284
+ query: { cascade: options.cascade ? "true" : undefined },
285
+ options,
286
+ }),
287
+ };
288
+
289
+ // --- Stored values, one namespace per layer -----------------------------
290
+
291
+ readonly values = {
292
+ /** The server-wide layer, beneath every group and user value. */
293
+ server: {
294
+ get: (settingId: string, options?: RequestOptions): Promise<ServerValue> =>
295
+ this.#request({ method: "GET", path: `/api/v1/settings/${encode(settingId)}/server`, options }),
296
+
297
+ set: async (settingId: string, value: SettingValue, options?: RequestOptions): Promise<ServerValue> =>
298
+ this.#request({
299
+ method: "PUT",
300
+ path: `/api/v1/settings/${encode(settingId)}/server`,
301
+ body: { value: requireValue(value) },
302
+ options,
303
+ }),
304
+
305
+ /** Removes the value, falling back to the definition's default. */
306
+ clear: (settingId: string, options?: RequestOptions): Promise<void> =>
307
+ this.#request({ method: "DELETE", path: `/api/v1/settings/${encode(settingId)}/server`, options }),
308
+ },
309
+
310
+ /** One user's own choice. */
311
+ personal: {
312
+ get: (settingId: string, userId: string, options?: RequestOptions): Promise<PersonalValue> =>
313
+ this.#request({
314
+ method: "GET",
315
+ path: `/api/v1/settings/${encode(settingId)}/personal/${encode(userId)}`,
316
+ options,
317
+ }),
318
+
319
+ set: async (settingId: string, userId: string, value: SettingValue, options?: RequestOptions): Promise<PersonalValue> =>
320
+ this.#request({
321
+ method: "PUT",
322
+ path: `/api/v1/settings/${encode(settingId)}/personal/${encode(userId)}`,
323
+ body: { value: requireValue(value) },
324
+ options,
325
+ }),
326
+
327
+ clear: (settingId: string, userId: string, options?: RequestOptions): Promise<void> =>
328
+ this.#request({
329
+ method: "DELETE",
330
+ path: `/api/v1/settings/${encode(settingId)}/personal/${encode(userId)}`,
331
+ options,
332
+ }),
333
+ },
334
+
335
+ /** A group override, in either direction. */
336
+ group: {
337
+ get: (settingId: string, groupId: string, options?: RequestOptions): Promise<IntermediateValue> =>
338
+ this.#request({
339
+ method: "GET",
340
+ path: `/api/v1/settings/${encode(settingId)}/intermediate/${encode(groupId)}`,
341
+ options,
342
+ }),
343
+
344
+ /**
345
+ * Writes an override. `enforced` decides its direction: an enforced
346
+ * override beats the user's own value, an advisory one yields to it.
347
+ */
348
+ set: async (
349
+ settingId: string,
350
+ groupId: string,
351
+ value: SettingValue,
352
+ options: GroupValueOptions = {},
353
+ ): Promise<IntermediateValue> =>
354
+ this.#request({
355
+ method: "PUT",
356
+ path: `/api/v1/settings/${encode(settingId)}/intermediate/${encode(groupId)}`,
357
+ body: { value: requireValue(value), visible: options.visible, enforced: options.enforced },
358
+ options,
359
+ }),
360
+
361
+ clear: (settingId: string, groupId: string, options?: RequestOptions): Promise<void> =>
362
+ this.#request({
363
+ method: "DELETE",
364
+ path: `/api/v1/settings/${encode(settingId)}/intermediate/${encode(groupId)}`,
365
+ options,
366
+ }),
367
+ },
368
+ };
369
+
370
+ // --- Groups -------------------------------------------------------------
371
+
372
+ readonly groups = {
373
+ list: async (
374
+ options: RequestOptions & { environment?: string; includeEphemeral?: boolean } = {},
375
+ ): Promise<Group[]> => {
376
+ const body = await this.#request<{ groups: Group[] }>({
377
+ method: "GET",
378
+ path: "/api/v1/groups",
379
+ query: {
380
+ environment: options.environment ?? this.#environment,
381
+ include_ephemeral: options.includeEphemeral === false ? "false" : undefined,
382
+ },
383
+ options,
384
+ });
385
+ return body.groups ?? [];
386
+ },
387
+
388
+ get: (id: string, options?: RequestOptions): Promise<Group> =>
389
+ this.#request({ method: "GET", path: `/api/v1/groups/${encode(id)}`, options }),
390
+
391
+ create: (input: CreateGroupInput, options?: RequestOptions): Promise<Group> =>
392
+ this.#request({
393
+ method: "POST",
394
+ path: "/api/v1/groups",
395
+ body: {
396
+ name: input.name,
397
+ description: input.description ?? "",
398
+ // null is meaningful here — it spans every environment — so only an
399
+ // omitted field falls back to the client's own.
400
+ environment: input.environment === undefined ? (this.#environment ?? null) : input.environment,
401
+ priority: input.priority ?? 0,
402
+ ephemeral: input.ephemeral ?? false,
403
+ members: input.members,
404
+ },
405
+ options,
406
+ }),
407
+
408
+ update: (
409
+ id: string,
410
+ input: { description?: string; priority?: number },
411
+ options?: RequestOptions,
412
+ ): Promise<Group> =>
413
+ this.#request({ method: "PATCH", path: `/api/v1/groups/${encode(id)}`, body: input, options }),
414
+
415
+ delete: (id: string, options?: RequestOptions): Promise<void> =>
416
+ this.#request({ method: "DELETE", path: `/api/v1/groups/${encode(id)}`, options }),
417
+
418
+ /** Everyone whose membership is saved. Ad-hoc application does not appear here. */
419
+ members: async (id: string, options?: RequestOptions): Promise<GroupMember[]> => {
420
+ const body = await this.#request<{ members: GroupMember[] }>({
421
+ method: "GET",
422
+ path: `/api/v1/groups/${encode(id)}/members`,
423
+ options,
424
+ });
425
+ return body.members ?? [];
426
+ },
427
+
428
+ addMembers: async (id: string, userIds: string[], options?: RequestOptions): Promise<number> => {
429
+ const body = await this.#request<{ added: number }>({
430
+ method: "POST",
431
+ path: `/api/v1/groups/${encode(id)}/members`,
432
+ body: { members: userIds },
433
+ options,
434
+ });
435
+ return body.added ?? 0;
436
+ },
437
+
438
+ removeMembers: async (id: string, userIds: string[], options?: RequestOptions): Promise<number> => {
439
+ const body = await this.#request<{ removed: number }>({
440
+ method: "DELETE",
441
+ path: `/api/v1/groups/${encode(id)}/members`,
442
+ body: { members: userIds },
443
+ options,
444
+ });
445
+ return body.removed ?? 0;
446
+ },
447
+ };
448
+
449
+ // --- Roles, platforms and environments ----------------------------------
450
+
451
+ readonly taxonomy = {
452
+ roles: async (options?: RequestOptions): Promise<Role[]> =>
453
+ (await this.#request<{ roles: Role[] }>({ method: "GET", path: "/api/v1/roles", options })).roles ?? [],
454
+
455
+ /** Creates or updates a role. Rank orders roles and may not exceed the key's. */
456
+ upsertRole: (name: string, input: { rank: number; description?: string }, options?: RequestOptions): Promise<Role> =>
457
+ this.#request({
458
+ method: "PUT",
459
+ path: `/api/v1/roles/${encode(name)}`,
460
+ body: { rank: input.rank, description: input.description ?? "" },
461
+ options,
462
+ }),
463
+
464
+ deleteRole: (name: string, options?: RequestOptions): Promise<void> =>
465
+ this.#request({ method: "DELETE", path: `/api/v1/roles/${encode(name)}`, options }),
466
+
467
+ platforms: async (options?: RequestOptions): Promise<Platform[]> =>
468
+ (await this.#request<{ platforms: Platform[] }>({ method: "GET", path: "/api/v1/platforms", options }))
469
+ .platforms ?? [],
470
+
471
+ upsertPlatform: (name: string, description = "", options?: RequestOptions): Promise<Platform> =>
472
+ this.#request({ method: "PUT", path: `/api/v1/platforms/${encode(name)}`, body: { description }, options }),
473
+
474
+ deletePlatform: (name: string, options?: RequestOptions): Promise<void> =>
475
+ this.#request({ method: "DELETE", path: `/api/v1/platforms/${encode(name)}`, options }),
476
+
477
+ environments: async (options?: RequestOptions): Promise<Environment[]> =>
478
+ (await this.#request<{ environments: Environment[] }>({
479
+ method: "GET",
480
+ path: "/api/v1/environments",
481
+ options,
482
+ })).environments ?? [],
483
+
484
+ upsertEnvironment: (name: string, description = "", options?: RequestOptions): Promise<Environment> =>
485
+ this.#request({ method: "PUT", path: `/api/v1/environments/${encode(name)}`, body: { description }, options }),
486
+
487
+ deleteEnvironment: (name: string, options?: RequestOptions): Promise<void> =>
488
+ this.#request({ method: "DELETE", path: `/api/v1/environments/${encode(name)}`, options }),
489
+ };
490
+
491
+ // --- API keys -----------------------------------------------------------
492
+
493
+ readonly keys = {
494
+ list: async (options: RequestOptions & { includeRevoked?: boolean } = {}): Promise<ApiKey[]> => {
495
+ const body = await this.#request<{ keys: ApiKey[] }>({
496
+ method: "GET",
497
+ path: "/api/v1/keys",
498
+ query: { include_revoked: options.includeRevoked ? "true" : undefined },
499
+ options,
500
+ });
501
+ return body.keys ?? [];
502
+ },
503
+
504
+ /**
505
+ * Mints a key. The returned `token` is the only time it is ever available:
506
+ * only its hash is stored.
507
+ */
508
+ create: (input: CreateKeyInput, options?: RequestOptions): Promise<CreatedApiKey> =>
509
+ this.#request({
510
+ method: "POST",
511
+ path: "/api/v1/keys",
512
+ body: {
513
+ name: input.name,
514
+ scopes: input.scopes,
515
+ environments: input.environments,
516
+ platforms: input.platforms,
517
+ role: input.role,
518
+ expires_at: input.expiresAt instanceof Date ? input.expiresAt.toISOString() : input.expiresAt,
519
+ expires_in: input.expiresIn,
520
+ },
521
+ options,
522
+ }),
523
+
524
+ /** Revokes a key. This is permanent and takes effect immediately. */
525
+ revoke: (id: string, options?: RequestOptions): Promise<void> =>
526
+ this.#request({ method: "DELETE", path: `/api/v1/keys/${encode(id)}`, options }),
527
+ };
528
+
529
+ // --- Internals ----------------------------------------------------------
530
+
531
+ #request<T>(spec: Parameters<typeof request>[1]): Promise<T> {
532
+ return request<T>(this.#transport, spec);
533
+ }
534
+
535
+ /** The query shared by both resolution endpoints. */
536
+ #resolveQuery(options: ResolveOptions): Record<string, string | string[] | undefined> {
537
+ const environment = options.environment ?? this.#environment;
538
+ if (!environment) throw missingEnvironment("resolve");
539
+
540
+ const platform = options.platform === undefined ? this.#platform : toArray(options.platform);
541
+ return { environment, platform, role: options.role };
542
+ }
543
+ }
544
+
545
+ function toArray(value: string | string[]): string[] {
546
+ return Array.isArray(value) ? value : [value];
547
+ }
548
+
549
+ /** Path segments are user data — a user id or a role name — so escape them. */
550
+ function encode(segment: string): string {
551
+ return encodeURIComponent(segment);
552
+ }
553
+
554
+ /** The server treats an absent value as an error, and DELETE as the way to clear. */
555
+ function requireValue(value: SettingValue): SettingValue {
556
+ if (value === undefined) {
557
+ throw new AppSettingsError("`value` is required; use clear() to remove a value", {
558
+ code: "invalid_value",
559
+ });
560
+ }
561
+ return value;
562
+ }
563
+
564
+ function missingEnvironment(operation: string): AppSettingsError {
565
+ return new AppSettingsError(
566
+ `${operation} needs an environment. Pass one as \`environment\` in the call, ` +
567
+ "or set it once on the client.",
568
+ { code: "invalid_request" },
569
+ );
570
+ }
571
+
572
+ export type { FetchLike, RequestOptions };