@shipeasy/openapi 3.1.0 → 3.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.
@@ -0,0 +1,1747 @@
1
+ {
2
+ "openapi": "3.2.0",
3
+ "info": {
4
+ "title": "Shipeasy Admin API (server-SDK surface)",
5
+ "version": "2.0.0",
6
+ "description": "The slice of the Shipeasy admin API that the published server SDKs expose as their optional `AdminClient`. Authenticate with an admin SDK key (`Authorization: Bearer sdk_admin_…`) and scope every request to a project via the `X-Project-Id` header.\n\nThree capabilities, nothing else:\n\n- **File a public ticket** — a bug or a feature request onto the project's ops queue.\n- **Toggle a kill switch** — flip the switch itself, or one of its named sub-switches, on one environment.\n- **Manage a flag's whitelist** — read, replace, add to, or remove from the allowlist that admits specific identities ahead of every targeting rule.\n\nEverything else in the admin API is reachable through the Shipeasy CLI and MCP server, which speak the complete contract (`openapi.yaml`).",
7
+ "contact": {
8
+ "name": "Shipeasy",
9
+ "url": "https://shipeasy.ai"
10
+ },
11
+ "license": {
12
+ "name": "Proprietary",
13
+ "identifier": "LicenseRef-Proprietary"
14
+ }
15
+ },
16
+ "servers": [
17
+ {
18
+ "url": "https://shipeasy.ai",
19
+ "description": "Production"
20
+ },
21
+ {
22
+ "url": "http://localhost:3000",
23
+ "description": "Local Next.js dev server"
24
+ }
25
+ ],
26
+ "security": [
27
+ {
28
+ "bearerSdkKey": []
29
+ }
30
+ ],
31
+ "tags": [
32
+ {
33
+ "name": "Flags",
34
+ "description": "A feature gate's whitelist — the always-first allowlist that admits named identities before any targeting rule or percentage rollout runs."
35
+ },
36
+ {
37
+ "name": "Killswitch",
38
+ "description": "Per-env boolean overrides for incident response. `toggle` reads the current value and publishes its opposite in one call, on the flat value or on one named sub-switch."
39
+ },
40
+ {
41
+ "name": "Ops",
42
+ "description": "The public ticket queue — filing a bug or a feature request, which also fires the project's GitHub/Slack connectors."
43
+ }
44
+ ],
45
+ "paths": {
46
+ "/ops/bug": {
47
+ "post": {
48
+ "operationId": "createPublicBug",
49
+ "summary": "File a bug",
50
+ "description": "Files one bug onto a project's queue, awaiting human approval. This is the **public** intake: it authenticates with a *client* SDK key rather than an admin key, so it can be called from a CLI, an installer script, a devtools overlay, or any shipped code — the same places a client key already lives.\n\nThree gates decide whether a ticket is filed, and nothing else the caller sends can widen them:\n\n1. the key is a `client` key carrying the `tickets:public_create` scope,\n2. the key's project has public ticket creation enabled, and\n3. the item is filed as `pending_approval` — parked out of the work queue until a human promotes it in the dashboard.\n\nThe project is the key's own project; there is no `X-Project-Id` to pass and no way to file into someone else's queue. Repeat submissions of the same title dedupe against the open ticket already tracking it, which returns `200` with `deduped: true` instead of filing again.\n\nThis endpoint is served by the Shipeasy **edge worker** (`api.shipeasy.ai`), not the admin API — see `servers` below.\n\n**Use case:** `shipeasy setup` fails on a customer's machine and self-reports the failure with the user's consent — `{ \"title\": \"Setup failed at Feature installs\", \"stepsToReproduce\": \"…\", \"actualResult\": \"…\" }`.",
51
+ "tags": [
52
+ "Ops"
53
+ ],
54
+ "servers": [
55
+ {
56
+ "url": "https://api.shipeasy.ai",
57
+ "description": "Shipeasy edge worker (production)"
58
+ },
59
+ {
60
+ "url": "http://localhost:8787",
61
+ "description": "Local `wrangler dev`"
62
+ }
63
+ ],
64
+ "security": [
65
+ {
66
+ "clientSdkKey": []
67
+ }
68
+ ],
69
+ "requestBody": {
70
+ "required": true,
71
+ "content": {
72
+ "application/json": {
73
+ "schema": {
74
+ "$ref": "#/components/schemas/CreatePublicBugRequest"
75
+ },
76
+ "examples": {
77
+ "minimal": {
78
+ "summary": "Just a title",
79
+ "description": "Every other field is optional — the queue triages from `open` at default priority.",
80
+ "value": {
81
+ "title": "Checkout button misaligned on mobile"
82
+ }
83
+ },
84
+ "full": {
85
+ "summary": "A full report",
86
+ "value": {
87
+ "title": "Checkout button misaligned on mobile",
88
+ "stepsToReproduce": "Open the cart on iOS Safari and tap the price row.",
89
+ "actualResult": "The primary CTA overlaps the price.",
90
+ "expectedResult": "The CTA sits below the price with clear spacing.",
91
+ "priority": "high",
92
+ "reporterEmail": "alice@acme.dev",
93
+ "pageUrl": "https://acme.dev/cart"
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ },
100
+ "responses": {
101
+ "200": {
102
+ "description": "An open ticket already tracks this report; nothing was filed",
103
+ "content": {
104
+ "application/json": {
105
+ "schema": {
106
+ "$ref": "#/components/schemas/CreatePublicTicketResponse"
107
+ },
108
+ "example": {
109
+ "number": 7,
110
+ "deduped": true
111
+ }
112
+ }
113
+ }
114
+ },
115
+ "201": {
116
+ "description": "File a bug",
117
+ "content": {
118
+ "application/json": {
119
+ "schema": {
120
+ "$ref": "#/components/schemas/CreatePublicTicketResponse"
121
+ },
122
+ "example": {
123
+ "id": "fb_01j7w8a1b2c3d4e5f6g7h8i9j0",
124
+ "number": 7
125
+ }
126
+ }
127
+ }
128
+ },
129
+ "400": {
130
+ "$ref": "#/components/responses/BadRequest"
131
+ },
132
+ "401": {
133
+ "$ref": "#/components/responses/Unauthorized"
134
+ },
135
+ "403": {
136
+ "$ref": "#/components/responses/Forbidden"
137
+ },
138
+ "413": {
139
+ "$ref": "#/components/responses/PayloadTooLarge"
140
+ },
141
+ "429": {
142
+ "$ref": "#/components/responses/TooManyRequests"
143
+ },
144
+ "503": {
145
+ "$ref": "#/components/responses/ServiceUnavailable"
146
+ }
147
+ },
148
+ "x-error-codes": [
149
+ "BAD_REQUEST",
150
+ "UNAUTHORIZED",
151
+ "FORBIDDEN",
152
+ "VALIDATION"
153
+ ],
154
+ "x-cli": {
155
+ "hidden": true
156
+ }
157
+ }
158
+ },
159
+ "/ops/feature-request": {
160
+ "post": {
161
+ "operationId": "createPublicFeatureRequest",
162
+ "summary": "File a feature request",
163
+ "description": "Files one feature request onto a project's queue, awaiting human approval. The feature-request counterpart to `POST /ops/bug`, with the same three gates: a `client` key carrying `tickets:public_create`, a project that has opted in, and a `pending_approval` state forced server-side.\n\nThe project is the key's own project; there is no `X-Project-Id` to pass. Repeat submissions of the same title dedupe against the open ticket already tracking it, which returns `200` with `deduped: true` instead of filing again.\n\nThis endpoint is served by the Shipeasy **edge worker** (`api.shipeasy.ai`), not the admin API — see `servers` below.\n\n**Use case:** An in-product \"request a feature\" form posts what the user asked for — `{ \"title\": \"Dark mode\", \"useCase\": \"Reduce eye strain at night\" }`.",
164
+ "tags": [
165
+ "Ops"
166
+ ],
167
+ "servers": [
168
+ {
169
+ "url": "https://api.shipeasy.ai",
170
+ "description": "Shipeasy edge worker (production)"
171
+ },
172
+ {
173
+ "url": "http://localhost:8787",
174
+ "description": "Local `wrangler dev`"
175
+ }
176
+ ],
177
+ "security": [
178
+ {
179
+ "clientSdkKey": []
180
+ }
181
+ ],
182
+ "requestBody": {
183
+ "required": true,
184
+ "content": {
185
+ "application/json": {
186
+ "schema": {
187
+ "$ref": "#/components/schemas/CreatePublicFeatureRequestRequest"
188
+ },
189
+ "examples": {
190
+ "minimal": {
191
+ "summary": "Just a title",
192
+ "value": {
193
+ "title": "Dark mode for the dashboard"
194
+ }
195
+ },
196
+ "full": {
197
+ "summary": "A full request",
198
+ "value": {
199
+ "title": "Dark mode for the dashboard",
200
+ "description": "Add a theme toggle that persists per user.",
201
+ "useCase": "Reduce eye strain for users working at night.",
202
+ "priority": "nice_to_have",
203
+ "reporterEmail": "alice@acme.dev"
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ },
210
+ "responses": {
211
+ "200": {
212
+ "description": "An open ticket already tracks this report; nothing was filed",
213
+ "content": {
214
+ "application/json": {
215
+ "schema": {
216
+ "$ref": "#/components/schemas/CreatePublicTicketResponse"
217
+ },
218
+ "example": {
219
+ "number": 7,
220
+ "deduped": true
221
+ }
222
+ }
223
+ }
224
+ },
225
+ "201": {
226
+ "description": "File a feature request",
227
+ "content": {
228
+ "application/json": {
229
+ "schema": {
230
+ "$ref": "#/components/schemas/CreatePublicTicketResponse"
231
+ },
232
+ "example": {
233
+ "id": "fb_01j7w8a1b2c3d4e5f6g7h8i9j0",
234
+ "number": 8
235
+ }
236
+ }
237
+ }
238
+ },
239
+ "400": {
240
+ "$ref": "#/components/responses/BadRequest"
241
+ },
242
+ "401": {
243
+ "$ref": "#/components/responses/Unauthorized"
244
+ },
245
+ "403": {
246
+ "$ref": "#/components/responses/Forbidden"
247
+ },
248
+ "413": {
249
+ "$ref": "#/components/responses/PayloadTooLarge"
250
+ },
251
+ "429": {
252
+ "$ref": "#/components/responses/TooManyRequests"
253
+ },
254
+ "503": {
255
+ "$ref": "#/components/responses/ServiceUnavailable"
256
+ }
257
+ },
258
+ "x-error-codes": [
259
+ "BAD_REQUEST",
260
+ "UNAUTHORIZED",
261
+ "FORBIDDEN",
262
+ "VALIDATION"
263
+ ],
264
+ "x-cli": {
265
+ "hidden": true
266
+ }
267
+ }
268
+ },
269
+ "/api/admin/killswitches/{id}/toggle": {
270
+ "post": {
271
+ "operationId": "toggleKillswitch",
272
+ "summary": "Toggle a killswitch or one of its switches",
273
+ "description": "Flips a killswitch on one environment and publishes a new version there. This is the one-call incident verb: it reads the current value, flips it, and publishes, so you don't have to fetch the killswitch first.\n\nEvery body field is optional, which is what makes the call widen cleanly:\n\n- **Flip the killswitch** — `{}`. Flips the flat `value` on `prod`.\n- **Flip one sub-switch** — `{ \"switchKey\": \"eu_region\" }`. Flips that entry on `prod`, creating it (from `false`) if it isn't in the map yet.\n- **Set it idempotently** — `{ \"switchKey\": \"eu_region\", \"value\": true }`. Publishes exactly that value, so a retried call can't undo the first one. A `null` `value` means \"flip\", not \"set to null\".\n- **Choose the environment** — add `\"env\": \"staging\"`. Omitted, `env` is `prod`.\n\nThe response reports both `previous` and `value`, so a caller that asked for a flip can see what it actually changed.\n\nPrefer this over `PUT /{id}/value` and `PUT /{id}/switch` unless you specifically need those endpoints' unconditional set semantics.",
274
+ "tags": [
275
+ "Killswitch"
276
+ ],
277
+ "parameters": [
278
+ {
279
+ "$ref": "#/components/parameters/ProjectId"
280
+ },
281
+ {
282
+ "name": "id",
283
+ "in": "path",
284
+ "required": true,
285
+ "description": "Stable opaque killswitch id (`ksw_…`) or the killswitch's `name`.",
286
+ "schema": {
287
+ "$ref": "#/components/schemas/ResourceId"
288
+ }
289
+ }
290
+ ],
291
+ "requestBody": {
292
+ "required": false,
293
+ "content": {
294
+ "application/json": {
295
+ "schema": {
296
+ "$ref": "#/components/schemas/ToggleKillswitchRequest"
297
+ },
298
+ "examples": {
299
+ "flipOnProd": {
300
+ "summary": "Flip the killswitch on prod",
301
+ "description": "No body fields — reads the flat `value` on prod and publishes its opposite.",
302
+ "value": {}
303
+ },
304
+ "flipOneSwitch": {
305
+ "summary": "Flip one sub-switch on prod",
306
+ "description": "Flips `switches.eu_region` on prod, leaving the flat `value` and every other key alone.",
307
+ "value": {
308
+ "switchKey": "eu_region"
309
+ }
310
+ },
311
+ "setOneSwitch": {
312
+ "summary": "Trip one sub-switch idempotently",
313
+ "description": "Publishes `switches.eu_region = true` on prod whatever it was before — safe to retry.",
314
+ "value": {
315
+ "switchKey": "eu_region",
316
+ "value": true
317
+ }
318
+ },
319
+ "setOneSwitchOnStaging": {
320
+ "summary": "Trip one sub-switch on a chosen env",
321
+ "description": "The same idempotent set, published on staging instead of prod.",
322
+ "value": {
323
+ "switchKey": "eu_region",
324
+ "value": true,
325
+ "env": "staging"
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
331
+ },
332
+ "responses": {
333
+ "200": {
334
+ "description": "Toggle a killswitch or one of its switches",
335
+ "content": {
336
+ "application/json": {
337
+ "schema": {
338
+ "$ref": "#/components/schemas/ToggleKillswitchResponse"
339
+ },
340
+ "example": {
341
+ "id": "ksw_01j7w9d8h2k4m6n8p0q2r4s6t8",
342
+ "env": "prod",
343
+ "switchKey": "eu_region",
344
+ "previous": false,
345
+ "value": true,
346
+ "version": 7,
347
+ "published": {
348
+ "value": false,
349
+ "switches": {
350
+ "eu_region": true
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ },
357
+ "400": {
358
+ "$ref": "#/components/responses/BadRequest"
359
+ },
360
+ "401": {
361
+ "$ref": "#/components/responses/Unauthorized"
362
+ },
363
+ "403": {
364
+ "$ref": "#/components/responses/Forbidden"
365
+ },
366
+ "404": {
367
+ "$ref": "#/components/responses/NotFound"
368
+ },
369
+ "409": {
370
+ "$ref": "#/components/responses/Conflict"
371
+ },
372
+ "422": {
373
+ "$ref": "#/components/responses/UnprocessableEntity"
374
+ }
375
+ },
376
+ "x-error-codes": [
377
+ "BAD_REQUEST",
378
+ "UNAUTHORIZED",
379
+ "FORBIDDEN",
380
+ "NOT_FOUND",
381
+ "VALIDATION"
382
+ ],
383
+ "x-cli": {
384
+ "name": "toggle",
385
+ "positional": [
386
+ "id"
387
+ ]
388
+ }
389
+ }
390
+ },
391
+ "/api/admin/gates/{id}/whitelist": {
392
+ "get": {
393
+ "operationId": "getGateWhitelist",
394
+ "summary": "Read a gate's whitelist",
395
+ "description": "Returns the gate's whitelist — the always-first allowlist that admits the listed identities before any targeting rule or percentage rollout is evaluated.\n\nA gate with no whitelist returns `entries: []` (and the default `attr`), never a 404 — so a caller can read-then-write without special-casing the empty gate.\n\n**Use case:** Check whether an account is already let through before adding it.",
396
+ "tags": [
397
+ "Flags"
398
+ ],
399
+ "parameters": [
400
+ {
401
+ "$ref": "#/components/parameters/ProjectId"
402
+ },
403
+ {
404
+ "name": "id",
405
+ "in": "path",
406
+ "required": true,
407
+ "description": "Stable opaque gate id (`gate_…`) or the gate's `name`.",
408
+ "schema": {
409
+ "$ref": "#/components/schemas/ResourceId"
410
+ }
411
+ }
412
+ ],
413
+ "responses": {
414
+ "200": {
415
+ "description": "Read a gate's whitelist",
416
+ "content": {
417
+ "application/json": {
418
+ "schema": {
419
+ "$ref": "#/components/schemas/GateWhitelist"
420
+ },
421
+ "example": {
422
+ "id": "gate_01j7w8a1b2c3d4e5f6g7h8i9j0",
423
+ "name": "new_checkout",
424
+ "attr": "email",
425
+ "entries": [
426
+ "alice@acme.dev",
427
+ "bob@acme.dev"
428
+ ]
429
+ }
430
+ }
431
+ }
432
+ },
433
+ "400": {
434
+ "$ref": "#/components/responses/BadRequest"
435
+ },
436
+ "401": {
437
+ "$ref": "#/components/responses/Unauthorized"
438
+ },
439
+ "403": {
440
+ "$ref": "#/components/responses/Forbidden"
441
+ },
442
+ "404": {
443
+ "$ref": "#/components/responses/NotFound"
444
+ },
445
+ "409": {
446
+ "$ref": "#/components/responses/Conflict"
447
+ },
448
+ "422": {
449
+ "$ref": "#/components/responses/UnprocessableEntity"
450
+ }
451
+ },
452
+ "x-error-codes": [
453
+ "BAD_REQUEST",
454
+ "UNAUTHORIZED",
455
+ "FORBIDDEN",
456
+ "NOT_FOUND"
457
+ ],
458
+ "x-cli": {
459
+ "name": "whitelist",
460
+ "positional": [
461
+ "id"
462
+ ]
463
+ }
464
+ },
465
+ "put": {
466
+ "operationId": "setGateWhitelist",
467
+ "summary": "Replace a gate's whitelist",
468
+ "description": "Replaces the gate's whole whitelist with `entries`, creating the block if the gate didn't have one. Idempotent — the same call twice leaves the same list.\n\nThis is the only whitelist call that can switch `attr` (`email` ⇄ `user_id`) or clear the block: `entries: []` removes the whitelist from the gate entirely.\n\n**Use cases**\n\n- **Pin an exact list** — `{ \"entries\": [\"alice@acme.dev\", \"bob@acme.dev\"] }`.\n- **Switch to user ids** — `{ \"attr\": \"user_id\", \"entries\": [\"usr_123\"] }`.\n- **Drop the whitelist** — `{ \"entries\": [] }`.",
469
+ "tags": [
470
+ "Flags"
471
+ ],
472
+ "parameters": [
473
+ {
474
+ "$ref": "#/components/parameters/ProjectId"
475
+ },
476
+ {
477
+ "name": "id",
478
+ "in": "path",
479
+ "required": true,
480
+ "description": "Stable opaque gate id (`gate_…`) or the gate's `name`.",
481
+ "schema": {
482
+ "$ref": "#/components/schemas/ResourceId"
483
+ }
484
+ }
485
+ ],
486
+ "requestBody": {
487
+ "required": true,
488
+ "content": {
489
+ "application/json": {
490
+ "schema": {
491
+ "$ref": "#/components/schemas/SetGateWhitelistRequest"
492
+ },
493
+ "examples": {
494
+ "pinList": {
495
+ "summary": "Pin an exact list of emails",
496
+ "value": {
497
+ "entries": [
498
+ "alice@acme.dev",
499
+ "bob@acme.dev"
500
+ ]
501
+ }
502
+ },
503
+ "switchToUserIds": {
504
+ "summary": "Re-key the whitelist onto user ids",
505
+ "description": "Switching `attr` replaces the entries wholesale — the old emails are not translated.",
506
+ "value": {
507
+ "attr": "user_id",
508
+ "entries": [
509
+ "usr_01j7w8a1b2c3d4e5f6g7h8i9j0"
510
+ ]
511
+ }
512
+ },
513
+ "clear": {
514
+ "summary": "Remove the whitelist",
515
+ "description": "An empty list drops the pinned block from the gate's stack.",
516
+ "value": {
517
+ "entries": []
518
+ }
519
+ }
520
+ }
521
+ }
522
+ }
523
+ },
524
+ "responses": {
525
+ "200": {
526
+ "description": "Replace a gate's whitelist",
527
+ "content": {
528
+ "application/json": {
529
+ "schema": {
530
+ "$ref": "#/components/schemas/GateWhitelist"
531
+ },
532
+ "example": {
533
+ "id": "gate_01j7w8a1b2c3d4e5f6g7h8i9j0",
534
+ "name": "new_checkout",
535
+ "attr": "email",
536
+ "entries": [
537
+ "alice@acme.dev",
538
+ "bob@acme.dev"
539
+ ]
540
+ }
541
+ }
542
+ }
543
+ },
544
+ "400": {
545
+ "$ref": "#/components/responses/BadRequest"
546
+ },
547
+ "401": {
548
+ "$ref": "#/components/responses/Unauthorized"
549
+ },
550
+ "403": {
551
+ "$ref": "#/components/responses/Forbidden"
552
+ },
553
+ "404": {
554
+ "$ref": "#/components/responses/NotFound"
555
+ },
556
+ "409": {
557
+ "$ref": "#/components/responses/Conflict"
558
+ },
559
+ "422": {
560
+ "$ref": "#/components/responses/UnprocessableEntity"
561
+ }
562
+ },
563
+ "x-error-codes": [
564
+ "BAD_REQUEST",
565
+ "UNAUTHORIZED",
566
+ "FORBIDDEN",
567
+ "NOT_FOUND",
568
+ "VALIDATION"
569
+ ],
570
+ "x-cli": {
571
+ "name": "whitelist-set",
572
+ "positional": [
573
+ "id"
574
+ ]
575
+ }
576
+ },
577
+ "post": {
578
+ "operationId": "addToGateWhitelist",
579
+ "summary": "Add entries to a gate's whitelist",
580
+ "description": "Adds identities to the gate's whitelist, creating the block if the gate doesn't have one yet. Entries already on the list are skipped, so the call is idempotent and safe to retry.\n\nAdding to a gate that already has a whitelist keyed on the other attribute is rejected (409) rather than silently re-keying the entries already there — use `PUT` to switch `attr` deliberately.\n\n**Use case:** Let one more customer into a private beta without reading the current list first.",
581
+ "tags": [
582
+ "Flags"
583
+ ],
584
+ "parameters": [
585
+ {
586
+ "$ref": "#/components/parameters/ProjectId"
587
+ },
588
+ {
589
+ "name": "id",
590
+ "in": "path",
591
+ "required": true,
592
+ "description": "Stable opaque gate id (`gate_…`) or the gate's `name`.",
593
+ "schema": {
594
+ "$ref": "#/components/schemas/ResourceId"
595
+ }
596
+ }
597
+ ],
598
+ "requestBody": {
599
+ "required": true,
600
+ "content": {
601
+ "application/json": {
602
+ "schema": {
603
+ "$ref": "#/components/schemas/AddToGateWhitelistRequest"
604
+ },
605
+ "examples": {
606
+ "addOne": {
607
+ "summary": "Admit one more email",
608
+ "value": {
609
+ "entries": [
610
+ "carol@acme.dev"
611
+ ]
612
+ }
613
+ },
614
+ "createOnUserIds": {
615
+ "summary": "Start a whitelist keyed on user ids",
616
+ "description": "`attr` is honoured only because this gate has no whitelist yet.",
617
+ "value": {
618
+ "attr": "user_id",
619
+ "entries": [
620
+ "usr_01j7w8a1b2c3d4e5f6g7h8i9j0"
621
+ ]
622
+ }
623
+ }
624
+ }
625
+ }
626
+ }
627
+ },
628
+ "responses": {
629
+ "200": {
630
+ "description": "Add entries to a gate's whitelist",
631
+ "content": {
632
+ "application/json": {
633
+ "schema": {
634
+ "$ref": "#/components/schemas/GateWhitelist"
635
+ },
636
+ "example": {
637
+ "id": "gate_01j7w8a1b2c3d4e5f6g7h8i9j0",
638
+ "name": "new_checkout",
639
+ "attr": "email",
640
+ "entries": [
641
+ "alice@acme.dev",
642
+ "bob@acme.dev",
643
+ "carol@acme.dev"
644
+ ]
645
+ }
646
+ }
647
+ }
648
+ },
649
+ "400": {
650
+ "$ref": "#/components/responses/BadRequest"
651
+ },
652
+ "401": {
653
+ "$ref": "#/components/responses/Unauthorized"
654
+ },
655
+ "403": {
656
+ "$ref": "#/components/responses/Forbidden"
657
+ },
658
+ "404": {
659
+ "$ref": "#/components/responses/NotFound"
660
+ },
661
+ "409": {
662
+ "$ref": "#/components/responses/Conflict"
663
+ },
664
+ "422": {
665
+ "$ref": "#/components/responses/UnprocessableEntity"
666
+ }
667
+ },
668
+ "x-error-codes": [
669
+ "BAD_REQUEST",
670
+ "UNAUTHORIZED",
671
+ "FORBIDDEN",
672
+ "NOT_FOUND",
673
+ "IMMUTABLE_FIELD",
674
+ "VALIDATION"
675
+ ],
676
+ "x-cli": {
677
+ "name": "whitelist-add",
678
+ "positional": [
679
+ "id"
680
+ ]
681
+ }
682
+ },
683
+ "delete": {
684
+ "operationId": "removeFromGateWhitelist",
685
+ "summary": "Remove entries from a gate's whitelist",
686
+ "description": "Removes identities from the gate's whitelist. Entries that aren't on the list are skipped, so the call is idempotent.\n\nRemoving the last entry leaves an empty whitelist block in place; to drop the block itself use `PUT` with `entries: []`.\n\n**Use case:** Revoke one beta tester's access without touching anyone else's.",
687
+ "tags": [
688
+ "Flags"
689
+ ],
690
+ "parameters": [
691
+ {
692
+ "$ref": "#/components/parameters/ProjectId"
693
+ },
694
+ {
695
+ "name": "id",
696
+ "in": "path",
697
+ "required": true,
698
+ "description": "Stable opaque gate id (`gate_…`) or the gate's `name`.",
699
+ "schema": {
700
+ "$ref": "#/components/schemas/ResourceId"
701
+ }
702
+ }
703
+ ],
704
+ "requestBody": {
705
+ "required": true,
706
+ "content": {
707
+ "application/json": {
708
+ "schema": {
709
+ "$ref": "#/components/schemas/RemoveFromGateWhitelistRequest"
710
+ },
711
+ "examples": {
712
+ "removeOne": {
713
+ "summary": "Revoke one email",
714
+ "value": {
715
+ "entries": [
716
+ "carol@acme.dev"
717
+ ]
718
+ }
719
+ }
720
+ }
721
+ }
722
+ }
723
+ },
724
+ "responses": {
725
+ "200": {
726
+ "description": "Remove entries from a gate's whitelist",
727
+ "content": {
728
+ "application/json": {
729
+ "schema": {
730
+ "$ref": "#/components/schemas/GateWhitelist"
731
+ },
732
+ "example": {
733
+ "id": "gate_01j7w8a1b2c3d4e5f6g7h8i9j0",
734
+ "name": "new_checkout",
735
+ "attr": "email",
736
+ "entries": [
737
+ "alice@acme.dev",
738
+ "bob@acme.dev"
739
+ ]
740
+ }
741
+ }
742
+ }
743
+ },
744
+ "400": {
745
+ "$ref": "#/components/responses/BadRequest"
746
+ },
747
+ "401": {
748
+ "$ref": "#/components/responses/Unauthorized"
749
+ },
750
+ "403": {
751
+ "$ref": "#/components/responses/Forbidden"
752
+ },
753
+ "404": {
754
+ "$ref": "#/components/responses/NotFound"
755
+ },
756
+ "409": {
757
+ "$ref": "#/components/responses/Conflict"
758
+ },
759
+ "422": {
760
+ "$ref": "#/components/responses/UnprocessableEntity"
761
+ }
762
+ },
763
+ "x-error-codes": [
764
+ "BAD_REQUEST",
765
+ "UNAUTHORIZED",
766
+ "FORBIDDEN",
767
+ "NOT_FOUND",
768
+ "VALIDATION"
769
+ ],
770
+ "x-cli": {
771
+ "name": "whitelist-remove",
772
+ "positional": [
773
+ "id"
774
+ ]
775
+ }
776
+ }
777
+ }
778
+ },
779
+ "components": {
780
+ "securitySchemes": {
781
+ "bearerSdkKey": {
782
+ "type": "http",
783
+ "scheme": "bearer",
784
+ "bearerFormat": "sdk_admin_*",
785
+ "description": "Pass an admin SDK key as `Authorization: Bearer sdk_admin_…`. Mint via `POST /api/admin/keys` with `type: \"admin\"`."
786
+ },
787
+ "clientSdkKey": {
788
+ "type": "apiKey",
789
+ "in": "header",
790
+ "name": "X-SDK-Key",
791
+ "description": "Pass a **client** SDK key as `X-SDK-Key: sdk_client_…`. Used only by the public ticket intake (`POST /ops/bug`, `POST /ops/feature-request`), which is served by the edge worker rather than the admin API.\n\nClient keys are designed to be embedded in shipped code — a CLI, an installer, a browser bundle — so presenting one here is safe by construction: it can do nothing but file a rate-limited ticket into its own project, in a state a human must approve. It carries no read access and cannot reach any other operation in this contract.\n\nThe key must additionally carry the `tickets:public_create` scope, and its project must have public ticket creation enabled."
792
+ }
793
+ },
794
+ "schemas": {
795
+ "OpsItemPriority": {
796
+ "type": "string",
797
+ "enum": [
798
+ "nice_to_have",
799
+ "medium",
800
+ "high",
801
+ "critical"
802
+ ],
803
+ "description": "Triage priority of a queue item. The lowest tier is `nice_to_have` (not `low`) — carried over from the feature-request \"importance\" scale this priority set replaced."
804
+ },
805
+ "OpsItemStatus": {
806
+ "type": "string",
807
+ "enum": [
808
+ "open",
809
+ "pending_approval",
810
+ "investigating_by_ai",
811
+ "in_progress",
812
+ "blocked",
813
+ "ready_for_qa",
814
+ "resolved",
815
+ "wont_fix"
816
+ ],
817
+ "description": "Lifecycle status of a queue item. The working flow is `open` → `in_progress` → `ready_for_qa` → `resolved` (or `wont_fix`, terminal from any earlier stage). `blocked` marks an item that can't progress until an external dependency clears — a working state a human sets and clears. `ready_for_qa` is what a developer sets once a fix lands; `resolved` is the QA sign-off, normally flipped in the dashboard after verification — set it directly from code only when the fix has been verified end-to-end. `investigating_by_ai` is a system-owned display state — set when the AI agent (Jarvis) picks an item up to investigate, never chosen by a human — so it is shown but not offered as a manual choice. `pending_approval` is the one human-gated holding state: it parks an item OUT of the work queue until a human promotes it to `open` in the dashboard, so `GET /api/admin/ops` excludes it under `status=all`/default and returns it only when requested as an exact `status`. It covers untriaged inbound that must never be auto-implemented — connector requests filed from a customer's connectors panel, and questions funnelled in from the \"Stuck in onboarding?\" assistant — where approving means flipping the status to `open`. Two earlier values were removed in favour of this single gate: `triage` (the onboarding-help bucket, now `pending_approval`) and `triaged` (a redundant \"looked at but not started\" step, now plain `open`)."
818
+ },
819
+ "NotificationTarget": {
820
+ "anyOf": [
821
+ {
822
+ "type": "object",
823
+ "properties": {
824
+ "slackChannel": {
825
+ "anyOf": [
826
+ {
827
+ "type": "object",
828
+ "properties": {
829
+ "id": {
830
+ "type": "string",
831
+ "minLength": 1
832
+ },
833
+ "name": {
834
+ "type": "string",
835
+ "minLength": 1
836
+ }
837
+ },
838
+ "required": [
839
+ "id",
840
+ "name"
841
+ ],
842
+ "additionalProperties": false
843
+ },
844
+ {
845
+ "type": "null"
846
+ }
847
+ ]
848
+ },
849
+ "email": {
850
+ "anyOf": [
851
+ {
852
+ "type": "string",
853
+ "format": "email"
854
+ },
855
+ {
856
+ "type": "null"
857
+ }
858
+ ]
859
+ }
860
+ },
861
+ "additionalProperties": false
862
+ },
863
+ {
864
+ "type": "null"
865
+ }
866
+ ],
867
+ "description": "Delivery target for a notification; `null` = use the project default."
868
+ },
869
+ "CreatePublicBugRequest": {
870
+ "type": "object",
871
+ "properties": {
872
+ "title": {
873
+ "type": "string",
874
+ "minLength": 1,
875
+ "maxLength": 200,
876
+ "pattern": "^\\S(.*\\S)?$",
877
+ "description": "One-line bug title (no leading/trailing whitespace)."
878
+ },
879
+ "stepsToReproduce": {
880
+ "type": "string",
881
+ "maxLength": 8000,
882
+ "default": "",
883
+ "description": "How to reproduce the bug."
884
+ },
885
+ "actualResult": {
886
+ "type": "string",
887
+ "maxLength": 8000,
888
+ "default": "",
889
+ "description": "What actually happened."
890
+ },
891
+ "expectedResult": {
892
+ "type": "string",
893
+ "maxLength": 8000,
894
+ "default": "",
895
+ "description": "What was expected instead."
896
+ },
897
+ "priority": {
898
+ "anyOf": [
899
+ {
900
+ "$ref": "#/components/schemas/OpsItemPriority"
901
+ },
902
+ {
903
+ "type": "null"
904
+ }
905
+ ],
906
+ "description": "Initial triage priority, or `null`."
907
+ },
908
+ "status": {
909
+ "anyOf": [
910
+ {
911
+ "$ref": "#/components/schemas/OpsItemStatus"
912
+ }
913
+ ],
914
+ "description": "Initial lifecycle status; defaults to `open` when omitted."
915
+ },
916
+ "assigneeId": {
917
+ "anyOf": [
918
+ {
919
+ "type": "string"
920
+ },
921
+ {
922
+ "type": "null"
923
+ }
924
+ ],
925
+ "description": "The `users.id` of the person to assign as owner at creation, or `null`."
926
+ },
927
+ "subscribers": {
928
+ "type": "array",
929
+ "items": {
930
+ "type": "string",
931
+ "format": "email"
932
+ },
933
+ "default": [],
934
+ "description": "Emails of teammates to subscribe to this item's Slack pings at creation."
935
+ },
936
+ "tags": {
937
+ "type": "array",
938
+ "items": {
939
+ "type": "string"
940
+ },
941
+ "default": [],
942
+ "description": "Tag names to attach at creation (get-or-created by name, deduped case-insensitively)."
943
+ },
944
+ "reporterEmail": {
945
+ "anyOf": [
946
+ {
947
+ "type": "string",
948
+ "format": "email"
949
+ },
950
+ {
951
+ "type": "null"
952
+ }
953
+ ],
954
+ "description": "Email of the reporter, or `null`."
955
+ },
956
+ "pageUrl": {
957
+ "anyOf": [
958
+ {
959
+ "type": "string",
960
+ "format": "uri"
961
+ },
962
+ {
963
+ "type": "null"
964
+ }
965
+ ],
966
+ "description": "URL of the page the bug relates to, or `null`."
967
+ },
968
+ "userAgent": {
969
+ "anyOf": [
970
+ {
971
+ "type": "string",
972
+ "maxLength": 500
973
+ },
974
+ {
975
+ "type": "null"
976
+ }
977
+ ],
978
+ "description": "Reporter's user-agent string, or `null`."
979
+ },
980
+ "viewport": {
981
+ "anyOf": [
982
+ {
983
+ "type": "string",
984
+ "maxLength": 40
985
+ },
986
+ {
987
+ "type": "null"
988
+ }
989
+ ],
990
+ "description": "Reporter's viewport (e.g. `1280x720`), or `null`."
991
+ },
992
+ "context": {
993
+ "anyOf": [
994
+ {
995
+ "type": "object",
996
+ "additionalProperties": {}
997
+ },
998
+ {
999
+ "type": "null"
1000
+ }
1001
+ ],
1002
+ "description": "Arbitrary capture context, or `null`."
1003
+ },
1004
+ "notify": {
1005
+ "anyOf": [
1006
+ {
1007
+ "$ref": "#/components/schemas/NotificationTarget"
1008
+ },
1009
+ {
1010
+ "type": "null"
1011
+ }
1012
+ ],
1013
+ "description": "Where this bug's completion notification lands."
1014
+ }
1015
+ },
1016
+ "required": [
1017
+ "title"
1018
+ ],
1019
+ "additionalProperties": false,
1020
+ "description": "Body for `POST /ops/bug`. The same bug fields as `CreateBugRequest`, minus the `type` discriminator — the path already says what is being filed."
1021
+ },
1022
+ "CreatePublicTicketResponse": {
1023
+ "type": "object",
1024
+ "properties": {
1025
+ "id": {
1026
+ "type": "string",
1027
+ "description": "The new item's id. Absent on a deduped response — nothing was created, so there is no new id to report."
1028
+ },
1029
+ "number": {
1030
+ "type": "integer",
1031
+ "description": "Per-project item number. On a deduped response this is the number of the OPEN ticket already tracking the report, which is what a caller should show the user either way."
1032
+ },
1033
+ "deduped": {
1034
+ "type": "boolean",
1035
+ "description": "`true` when an open ticket already tracked this report and nothing was filed (HTTP 200). Absent on a fresh file (HTTP 201)."
1036
+ }
1037
+ },
1038
+ "required": [
1039
+ "number"
1040
+ ],
1041
+ "additionalProperties": false,
1042
+ "description": "Response for the public ticket intake. A fresh file returns `201` with `id` + `number`; a repeat of a report already tracked by an open ticket returns `200` with that ticket's `number` and `deduped: true`."
1043
+ },
1044
+ "ErrorCode": {
1045
+ "type": "string",
1046
+ "description": "Stable, machine-readable error code. Mirrors the `ErrorCode` catalogue in `@shipeasy/core`. Each operation lists the subset it can return under `x-error-codes`.",
1047
+ "enum": [
1048
+ "BAD_REQUEST",
1049
+ "UNAUTHORIZED",
1050
+ "FORBIDDEN",
1051
+ "PLAN_REQUIRED",
1052
+ "NOT_FOUND",
1053
+ "ALREADY_EXISTS",
1054
+ "INVALID_TRANSITION",
1055
+ "IMMUTABLE_FIELD",
1056
+ "READ_ONLY",
1057
+ "REFERENCED_IN_USE",
1058
+ "VALIDATION",
1059
+ "REFERENCED_NOT_FOUND",
1060
+ "GROUPS_WEIGHT_SUM",
1061
+ "EVENT_PENDING",
1062
+ "INTERNAL",
1063
+ "PLAN_LIMIT",
1064
+ "EXPERIMENT_NO_GOAL_METRIC",
1065
+ "EXPERIMENT_ARCHIVED_RESTART",
1066
+ "EXPERIMENT_RESTORE_INVALID",
1067
+ "EXPERIMENT_NOT_RUNNING",
1068
+ "EXPERIMENT_RUNNING_ARCHIVE",
1069
+ "EXPERIMENT_IMMUTABLE_FIELD",
1070
+ "METRIC_NOT_FOUND",
1071
+ "METRIC_UNKNOWN_ID",
1072
+ "AGENT_NOT_CONNECTED"
1073
+ ],
1074
+ "x-enum-descriptions": {
1075
+ "BAD_REQUEST": "Malformed request (bad JSON, missing project scope).",
1076
+ "UNAUTHORIZED": "Missing or invalid admin SDK key.",
1077
+ "FORBIDDEN": "Key is valid but not allowed to act on this project.",
1078
+ "PLAN_REQUIRED": "The requested feature requires a higher plan tier (e.g. sequential testing, custom alpha, holdout, group count).",
1079
+ "NOT_FOUND": "The resource does not exist or is not visible to the caller.",
1080
+ "ALREADY_EXISTS": "A resource with this name already exists in the project.",
1081
+ "INVALID_TRANSITION": "The requested lifecycle transition is not allowed from the current state.",
1082
+ "IMMUTABLE_FIELD": "A field that is immutable in the current state was modified (e.g. editing allocation while running).",
1083
+ "READ_ONLY": "The resource is a read-only built-in (e.g. a built-in gate-rule template) and cannot be modified or deleted.",
1084
+ "REFERENCED_IN_USE": "The resource cannot be archived/deleted while another resource still references it.",
1085
+ "VALIDATION": "The request body failed structural (schema) validation.",
1086
+ "REFERENCED_NOT_FOUND": "A referenced entity (universe, metric, gate, event) does not exist.",
1087
+ "GROUPS_WEIGHT_SUM": "Experiment group weights must sum to 10000 (basis points).",
1088
+ "EVENT_PENDING": "The referenced event is still pending review and cannot back a metric yet.",
1089
+ "INTERNAL": "Unexpected server error.",
1090
+ "PLAN_LIMIT": "The action would exceed a plan quota (e.g. the tier's maximum experiments, metrics, or configs). Upgrade the plan or archive an existing resource.",
1091
+ "EXPERIMENT_NO_GOAL_METRIC": "The experiment has no goal metric set, which the requested action requires.",
1092
+ "EXPERIMENT_ARCHIVED_RESTART": "An archived experiment cannot be restarted.",
1093
+ "EXPERIMENT_RESTORE_INVALID": "The experiment cannot be restored from its current state.",
1094
+ "EXPERIMENT_NOT_RUNNING": "The action requires the experiment to be running.",
1095
+ "EXPERIMENT_RUNNING_ARCHIVE": "A running experiment cannot be archived — stop it first.",
1096
+ "EXPERIMENT_IMMUTABLE_FIELD": "A field that is immutable while the experiment is running was modified.",
1097
+ "METRIC_NOT_FOUND": "The referenced metric does not exist or is not visible to the caller.",
1098
+ "METRIC_UNKNOWN_ID": "The supplied metric id is malformed or not recognised.",
1099
+ "AGENT_NOT_CONNECTED": "The named AI agent type has no connected trigger connector in this project — list the available agents (`ops agents list` / `GET /api/admin/agent-profiles`) and use one of those, or connect the agent under Settings → Triggers."
1100
+ }
1101
+ },
1102
+ "Error": {
1103
+ "type": "object",
1104
+ "description": "Uniform error envelope returned by every admin endpoint on a non-2xx status. `error` is the human-readable message; `code` is the stable machine code (present whenever the failure maps to a catalogued `ErrorCode`); `detail` carries extra context (validation paths, conflicting field); `fields` maps each failing input field to its own message; `instructions` is optional actionable guidance for resolving the error; `schema` echoes back the expected JSON Schema when a value failed schema validation.",
1105
+ "required": [
1106
+ "error"
1107
+ ],
1108
+ "additionalProperties": false,
1109
+ "properties": {
1110
+ "error": {
1111
+ "type": "string",
1112
+ "description": "Human-readable error message."
1113
+ },
1114
+ "code": {
1115
+ "$ref": "#/components/schemas/ErrorCode"
1116
+ },
1117
+ "detail": {
1118
+ "type": "string",
1119
+ "description": "Extra context (validation details, conflicting field, referenced row)."
1120
+ },
1121
+ "fields": {
1122
+ "type": "object",
1123
+ "additionalProperties": {
1124
+ "type": "string"
1125
+ },
1126
+ "description": "Per-field validation messages, keyed by the submitted field's path (dot-notation for nested fields, e.g. `groups.0.weight`). Present when structural validation failed on one or more specific input fields, so a client form can attach each message to its own input instead of collapsing everything into one banner."
1127
+ },
1128
+ "instructions": {
1129
+ "type": "string",
1130
+ "description": "Actionable guidance for resolving this error — what to check or change next."
1131
+ },
1132
+ "schema": {
1133
+ "type": "object",
1134
+ "additionalProperties": true,
1135
+ "description": "The expected JSON Schema (draft 2020-12) the submitted value must satisfy. Returned only when a value failed schema validation (e.g. a config `value` that does not match its config's saved schema), so the caller can correct the value and retry without re-fetching."
1136
+ }
1137
+ }
1138
+ },
1139
+ "CreatePublicFeatureRequestRequest": {
1140
+ "type": "object",
1141
+ "properties": {
1142
+ "title": {
1143
+ "type": "string",
1144
+ "minLength": 1,
1145
+ "maxLength": 200,
1146
+ "pattern": "^\\S(.*\\S)?$",
1147
+ "description": "One-line feature-request title (no leading/trailing whitespace)."
1148
+ },
1149
+ "description": {
1150
+ "type": "string",
1151
+ "maxLength": 8000,
1152
+ "default": "",
1153
+ "description": "What the feature is."
1154
+ },
1155
+ "useCase": {
1156
+ "type": "string",
1157
+ "maxLength": 8000,
1158
+ "default": "",
1159
+ "description": "Why it's needed / the use case."
1160
+ },
1161
+ "priority": {
1162
+ "anyOf": [
1163
+ {
1164
+ "$ref": "#/components/schemas/OpsItemPriority"
1165
+ },
1166
+ {
1167
+ "type": "null"
1168
+ }
1169
+ ],
1170
+ "description": "Initial triage priority, or `null`."
1171
+ },
1172
+ "status": {
1173
+ "anyOf": [
1174
+ {
1175
+ "$ref": "#/components/schemas/OpsItemStatus"
1176
+ }
1177
+ ],
1178
+ "description": "Initial lifecycle status; defaults to `open` when omitted."
1179
+ },
1180
+ "assigneeId": {
1181
+ "anyOf": [
1182
+ {
1183
+ "type": "string"
1184
+ },
1185
+ {
1186
+ "type": "null"
1187
+ }
1188
+ ],
1189
+ "description": "The `users.id` of the person to assign as owner at creation, or `null`."
1190
+ },
1191
+ "subscribers": {
1192
+ "type": "array",
1193
+ "items": {
1194
+ "type": "string",
1195
+ "format": "email"
1196
+ },
1197
+ "default": [],
1198
+ "description": "Emails of teammates to subscribe to this item's Slack pings at creation."
1199
+ },
1200
+ "tags": {
1201
+ "type": "array",
1202
+ "items": {
1203
+ "type": "string"
1204
+ },
1205
+ "default": [],
1206
+ "description": "Tag names to attach at creation (get-or-created by name, deduped case-insensitively)."
1207
+ },
1208
+ "reporterEmail": {
1209
+ "anyOf": [
1210
+ {
1211
+ "type": "string",
1212
+ "format": "email"
1213
+ },
1214
+ {
1215
+ "type": "null"
1216
+ }
1217
+ ],
1218
+ "description": "Email of the reporter, or `null`."
1219
+ },
1220
+ "pageUrl": {
1221
+ "anyOf": [
1222
+ {
1223
+ "type": "string",
1224
+ "format": "uri"
1225
+ },
1226
+ {
1227
+ "type": "null"
1228
+ }
1229
+ ],
1230
+ "description": "URL of the page the request relates to, or `null`."
1231
+ },
1232
+ "userAgent": {
1233
+ "anyOf": [
1234
+ {
1235
+ "type": "string",
1236
+ "maxLength": 500
1237
+ },
1238
+ {
1239
+ "type": "null"
1240
+ }
1241
+ ],
1242
+ "description": "Reporter's user-agent string, or `null`."
1243
+ },
1244
+ "context": {
1245
+ "anyOf": [
1246
+ {
1247
+ "type": "object",
1248
+ "additionalProperties": {}
1249
+ },
1250
+ {
1251
+ "type": "null"
1252
+ }
1253
+ ],
1254
+ "description": "Arbitrary capture context, or `null`."
1255
+ },
1256
+ "notify": {
1257
+ "anyOf": [
1258
+ {
1259
+ "$ref": "#/components/schemas/NotificationTarget"
1260
+ },
1261
+ {
1262
+ "type": "null"
1263
+ }
1264
+ ],
1265
+ "description": "Where this request's completion notification lands."
1266
+ }
1267
+ },
1268
+ "required": [
1269
+ "title"
1270
+ ],
1271
+ "additionalProperties": false,
1272
+ "description": "Body for `POST /ops/feature-request`. The same feature-request fields as `CreateFeatureRequestRequest`, minus the `type` discriminator — the path already says what is being filed."
1273
+ },
1274
+ "ResourceId": {
1275
+ "type": "string",
1276
+ "minLength": 1,
1277
+ "maxLength": 128,
1278
+ "description": "A resource path identifier — an opaque `xxx_<ULID>` id (~30 chars) or the resource's `name`/`key`. 1–128 characters; the upper bound matches the longest name/key any resource accepts, so an over-long value can never name a real row."
1279
+ },
1280
+ "Env": {
1281
+ "type": "string",
1282
+ "enum": [
1283
+ "dev",
1284
+ "staging",
1285
+ "prod"
1286
+ ],
1287
+ "description": "Target environment. One of the project's configured envs (`dev`, `staging`, `prod`)."
1288
+ },
1289
+ "ToggleKillswitchRequest": {
1290
+ "type": "object",
1291
+ "properties": {
1292
+ "switchKey": {
1293
+ "anyOf": [
1294
+ {
1295
+ "type": "string",
1296
+ "maxLength": 64,
1297
+ "pattern": "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$"
1298
+ },
1299
+ {
1300
+ "type": "null"
1301
+ }
1302
+ ],
1303
+ "description": "Which target to flip. Omit (or `null`) to flip the killswitch's own flat `value`; name a switch key to flip that nested sub-switch instead, creating the entry if it doesn't exist yet."
1304
+ },
1305
+ "value": {
1306
+ "anyOf": [
1307
+ {
1308
+ "type": "boolean"
1309
+ },
1310
+ {
1311
+ "type": "null"
1312
+ }
1313
+ ],
1314
+ "description": "The value to publish. Omit (or `null`) to flip whatever is stored now — read-modify-write in one call. Pass an explicit `true`/`false` to make the call idempotent, so a retry can't undo the first attempt."
1315
+ },
1316
+ "env": {
1317
+ "anyOf": [
1318
+ {
1319
+ "$ref": "#/components/schemas/Env"
1320
+ }
1321
+ ],
1322
+ "description": "Environment to publish on. Defaults to `prod` — the environment an incident response means when it says \"kill it\"."
1323
+ }
1324
+ },
1325
+ "additionalProperties": false,
1326
+ "description": "Body for `POST /api/admin/killswitches/{id}/toggle`. Every field is optional, so the four useful calls read as one method with a widening argument list:\n\n- `{}` — flip the flat value on prod.\n- `{ \"switchKey\": \"eu_region\" }` — flip that sub-switch on prod.\n- `{ \"switchKey\": \"eu_region\", \"value\": true }` — set that sub-switch on prod, idempotently.\n- `{ \"switchKey\": \"eu_region\", \"value\": true, \"env\": \"staging\" }` — the same, on a chosen env."
1327
+ },
1328
+ "KillswitchValue": {
1329
+ "type": "object",
1330
+ "properties": {
1331
+ "value": {
1332
+ "type": "boolean",
1333
+ "description": "Default boolean delivered when no `switches` entry matches."
1334
+ },
1335
+ "switches": {
1336
+ "type": "object",
1337
+ "additionalProperties": {
1338
+ "type": "boolean"
1339
+ },
1340
+ "description": "Per-switch overrides; a matching key takes precedence over `value`."
1341
+ }
1342
+ },
1343
+ "required": [
1344
+ "value"
1345
+ ],
1346
+ "additionalProperties": false,
1347
+ "description": "Runtime killswitch payload returned to SDKs."
1348
+ },
1349
+ "ToggleKillswitchResponse": {
1350
+ "type": "object",
1351
+ "properties": {
1352
+ "id": {
1353
+ "type": "string",
1354
+ "description": "Resolved killswitch id."
1355
+ },
1356
+ "env": {
1357
+ "$ref": "#/components/schemas/Env"
1358
+ },
1359
+ "switchKey": {
1360
+ "anyOf": [
1361
+ {
1362
+ "type": "string",
1363
+ "maxLength": 64,
1364
+ "pattern": "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$"
1365
+ },
1366
+ {
1367
+ "type": "null"
1368
+ }
1369
+ ],
1370
+ "description": "The sub-switch that was flipped, or `null` when the flat `value` was."
1371
+ },
1372
+ "previous": {
1373
+ "type": "boolean",
1374
+ "description": "The value that was in effect before this call. Equal to `value` when an idempotent `value` matched what was already stored."
1375
+ },
1376
+ "value": {
1377
+ "type": "boolean",
1378
+ "description": "The value now in effect on `env`."
1379
+ },
1380
+ "version": {
1381
+ "type": "integer",
1382
+ "minimum": -9007199254740991,
1383
+ "maximum": 9007199254740991,
1384
+ "description": "Newly published version on `env`."
1385
+ },
1386
+ "published": {
1387
+ "$ref": "#/components/schemas/KillswitchValue"
1388
+ }
1389
+ },
1390
+ "required": [
1391
+ "id",
1392
+ "env",
1393
+ "switchKey",
1394
+ "previous",
1395
+ "value",
1396
+ "version",
1397
+ "published"
1398
+ ],
1399
+ "additionalProperties": false
1400
+ },
1401
+ "GateWhitelistAttr": {
1402
+ "type": "string",
1403
+ "enum": [
1404
+ "email",
1405
+ "user_id"
1406
+ ],
1407
+ "description": "Which identity attribute the whitelist matches on. `email` compares the caller's `email`; `user_id` compares the caller's `userID`. A whitelist matches on exactly one of the two at a time."
1408
+ },
1409
+ "GateWhitelist": {
1410
+ "type": "object",
1411
+ "properties": {
1412
+ "id": {
1413
+ "type": "string",
1414
+ "description": "Resolved gate id."
1415
+ },
1416
+ "name": {
1417
+ "type": "string",
1418
+ "description": "The gate's stable `name`."
1419
+ },
1420
+ "attr": {
1421
+ "$ref": "#/components/schemas/GateWhitelistAttr"
1422
+ },
1423
+ "entries": {
1424
+ "type": "array",
1425
+ "items": {
1426
+ "type": "string",
1427
+ "minLength": 1
1428
+ },
1429
+ "description": "The whitelisted identities, in the order they are stored. Deduplicated case-sensitively; an empty array means the gate has no whitelist."
1430
+ }
1431
+ },
1432
+ "required": [
1433
+ "id",
1434
+ "name",
1435
+ "attr",
1436
+ "entries"
1437
+ ],
1438
+ "additionalProperties": false,
1439
+ "description": "A gate's whitelist — the always-first allowlist that admits the listed identities before any targeting rule or rollout runs. Backed by the pinned `whitelist` entry at the head of the gate's `stack`, so it is the same list the dashboard's Whitelist block edits."
1440
+ },
1441
+ "SetGateWhitelistRequest": {
1442
+ "type": "object",
1443
+ "properties": {
1444
+ "attr": {
1445
+ "anyOf": [
1446
+ {
1447
+ "$ref": "#/components/schemas/GateWhitelistAttr"
1448
+ }
1449
+ ],
1450
+ "description": "Identity attribute to match on. Defaults to the whitelist's current attribute, or `email` when the gate has no whitelist yet."
1451
+ },
1452
+ "entries": {
1453
+ "type": "array",
1454
+ "items": {
1455
+ "type": "string",
1456
+ "minLength": 1
1457
+ },
1458
+ "description": "The complete whitelist after the call. Pass `[]` to remove the whitelist from the gate entirely."
1459
+ }
1460
+ },
1461
+ "required": [
1462
+ "entries"
1463
+ ],
1464
+ "additionalProperties": false,
1465
+ "description": "Body for `PUT /api/admin/gates/{id}/whitelist`. Replaces the whole list — idempotent, and the only call that can switch `attr` or clear the whitelist."
1466
+ },
1467
+ "AddToGateWhitelistRequest": {
1468
+ "type": "object",
1469
+ "properties": {
1470
+ "attr": {
1471
+ "anyOf": [
1472
+ {
1473
+ "$ref": "#/components/schemas/GateWhitelistAttr"
1474
+ }
1475
+ ],
1476
+ "description": "Identity attribute to match on. Only honoured when the gate has no whitelist yet (this call creates it); passing an attribute that disagrees with an existing whitelist is a 409 rather than a silent re-key of the entries already there."
1477
+ },
1478
+ "entries": {
1479
+ "type": "array",
1480
+ "minItems": 1,
1481
+ "items": {
1482
+ "type": "string",
1483
+ "minLength": 1
1484
+ },
1485
+ "description": "Identities to admit. Already-listed entries are skipped, so the call is idempotent."
1486
+ }
1487
+ },
1488
+ "required": [
1489
+ "entries"
1490
+ ],
1491
+ "additionalProperties": false,
1492
+ "description": "Body for `POST /api/admin/gates/{id}/whitelist`. Adds entries to the whitelist, creating it if the gate doesn't have one."
1493
+ },
1494
+ "RemoveFromGateWhitelistRequest": {
1495
+ "type": "object",
1496
+ "properties": {
1497
+ "entries": {
1498
+ "type": "array",
1499
+ "minItems": 1,
1500
+ "items": {
1501
+ "type": "string",
1502
+ "minLength": 1
1503
+ },
1504
+ "description": "Identities to stop admitting. Entries that aren't listed are skipped, so the call is idempotent."
1505
+ }
1506
+ },
1507
+ "required": [
1508
+ "entries"
1509
+ ],
1510
+ "additionalProperties": false,
1511
+ "description": "Body for `DELETE /api/admin/gates/{id}/whitelist`. Removes entries from the whitelist. Removing the last entry leaves an empty whitelist in place — use `PUT` with `entries: []` to drop the block itself."
1512
+ }
1513
+ },
1514
+ "responses": {
1515
+ "BadRequest": {
1516
+ "description": "The request was malformed (bad JSON or missing project scope).",
1517
+ "content": {
1518
+ "application/json": {
1519
+ "schema": {
1520
+ "$ref": "#/components/schemas/Error"
1521
+ },
1522
+ "examples": {
1523
+ "BAD_REQUEST": {
1524
+ "summary": "Malformed request",
1525
+ "value": {
1526
+ "error": "Malformed request (bad JSON, missing project scope).",
1527
+ "code": "BAD_REQUEST"
1528
+ }
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ },
1534
+ "Unauthorized": {
1535
+ "description": "Missing or invalid admin SDK key.",
1536
+ "content": {
1537
+ "application/json": {
1538
+ "schema": {
1539
+ "$ref": "#/components/schemas/Error"
1540
+ },
1541
+ "examples": {
1542
+ "UNAUTHORIZED": {
1543
+ "summary": "No / invalid key",
1544
+ "value": {
1545
+ "error": "Missing or invalid admin SDK key.",
1546
+ "code": "UNAUTHORIZED"
1547
+ }
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+ },
1553
+ "Forbidden": {
1554
+ "description": "The key is valid but not allowed to perform this action.",
1555
+ "content": {
1556
+ "application/json": {
1557
+ "schema": {
1558
+ "$ref": "#/components/schemas/Error"
1559
+ },
1560
+ "examples": {
1561
+ "FORBIDDEN": {
1562
+ "summary": "Not an admin of this project",
1563
+ "value": {
1564
+ "error": "Key is valid but not allowed to act on this project.",
1565
+ "code": "FORBIDDEN"
1566
+ }
1567
+ },
1568
+ "PLAN_REQUIRED": {
1569
+ "summary": "Feature gated behind a higher plan",
1570
+ "value": {
1571
+ "error": "The requested feature requires a higher plan tier (e.g. sequential testing, custom alpha, holdout, group count).",
1572
+ "code": "PLAN_REQUIRED"
1573
+ }
1574
+ }
1575
+ }
1576
+ }
1577
+ }
1578
+ },
1579
+ "PayloadTooLarge": {
1580
+ "description": "The request body exceeded the intake's fixed size cap (16 KB).",
1581
+ "content": {
1582
+ "application/json": {
1583
+ "schema": {
1584
+ "$ref": "#/components/schemas/Error"
1585
+ },
1586
+ "examples": {
1587
+ "BAD_REQUEST": {
1588
+ "summary": "Body too large",
1589
+ "value": {
1590
+ "error": "Body too large",
1591
+ "code": "BAD_REQUEST"
1592
+ }
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+ },
1598
+ "TooManyRequests": {
1599
+ "description": "The public intake is rate-limited per (project, client IP). Back off and retry;\nthe limit is a burst guard, not a quota, so a modest delay clears it.",
1600
+ "content": {
1601
+ "application/json": {
1602
+ "schema": {
1603
+ "$ref": "#/components/schemas/Error"
1604
+ },
1605
+ "examples": {
1606
+ "BAD_REQUEST": {
1607
+ "summary": "Rate limited",
1608
+ "value": {
1609
+ "error": "Too many requests",
1610
+ "code": "BAD_REQUEST"
1611
+ }
1612
+ }
1613
+ }
1614
+ }
1615
+ }
1616
+ },
1617
+ "ServiceUnavailable": {
1618
+ "description": "A backing store the endpoint needs is not bound in this environment. Transient\nand infrastructural — no stable `ErrorCode` is assigned, so `code` is omitted.",
1619
+ "content": {
1620
+ "application/json": {
1621
+ "schema": {
1622
+ "$ref": "#/components/schemas/Error"
1623
+ },
1624
+ "examples": {
1625
+ "UNAVAILABLE": {
1626
+ "summary": "Storage unbound",
1627
+ "value": {
1628
+ "error": "Unavailable"
1629
+ }
1630
+ }
1631
+ }
1632
+ }
1633
+ }
1634
+ },
1635
+ "NotFound": {
1636
+ "description": "The resource does not exist or is not visible to the caller.",
1637
+ "content": {
1638
+ "application/json": {
1639
+ "schema": {
1640
+ "$ref": "#/components/schemas/Error"
1641
+ },
1642
+ "examples": {
1643
+ "NOT_FOUND": {
1644
+ "summary": "Unknown id or name",
1645
+ "value": {
1646
+ "error": "The resource does not exist or is not visible to the caller.",
1647
+ "code": "NOT_FOUND"
1648
+ }
1649
+ }
1650
+ }
1651
+ }
1652
+ }
1653
+ },
1654
+ "Conflict": {
1655
+ "description": "The mutation conflicts with current state.",
1656
+ "content": {
1657
+ "application/json": {
1658
+ "schema": {
1659
+ "$ref": "#/components/schemas/Error"
1660
+ },
1661
+ "examples": {
1662
+ "ALREADY_EXISTS": {
1663
+ "summary": "Duplicate name",
1664
+ "value": {
1665
+ "error": "A resource with this name already exists in the project.",
1666
+ "code": "ALREADY_EXISTS"
1667
+ }
1668
+ },
1669
+ "REFERENCED_IN_USE": {
1670
+ "summary": "Still referenced by another resource",
1671
+ "value": {
1672
+ "error": "The resource cannot be archived/deleted while another resource still references it.",
1673
+ "code": "REFERENCED_IN_USE"
1674
+ }
1675
+ },
1676
+ "INVALID_TRANSITION": {
1677
+ "summary": "Illegal lifecycle transition",
1678
+ "value": {
1679
+ "error": "The requested lifecycle transition is not allowed from the current state.",
1680
+ "code": "INVALID_TRANSITION"
1681
+ }
1682
+ },
1683
+ "IMMUTABLE_FIELD": {
1684
+ "summary": "Edited an immutable field while running",
1685
+ "value": {
1686
+ "error": "A field that is immutable in the current state was modified (e.g. editing allocation while running).",
1687
+ "code": "IMMUTABLE_FIELD"
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+ }
1693
+ },
1694
+ "UnprocessableEntity": {
1695
+ "description": "The request body failed validation.",
1696
+ "content": {
1697
+ "application/json": {
1698
+ "schema": {
1699
+ "$ref": "#/components/schemas/Error"
1700
+ },
1701
+ "examples": {
1702
+ "VALIDATION": {
1703
+ "summary": "Schema validation failed",
1704
+ "value": {
1705
+ "error": "The request body failed structural (schema) validation.",
1706
+ "code": "VALIDATION"
1707
+ }
1708
+ },
1709
+ "REFERENCED_NOT_FOUND": {
1710
+ "summary": "Referenced entity missing",
1711
+ "value": {
1712
+ "error": "A referenced entity (universe, metric, gate, event) does not exist.",
1713
+ "code": "REFERENCED_NOT_FOUND"
1714
+ }
1715
+ },
1716
+ "GROUPS_WEIGHT_SUM": {
1717
+ "summary": "Group weights must sum to 10000",
1718
+ "value": {
1719
+ "error": "Experiment group weights must sum to 10000 (basis points).",
1720
+ "code": "GROUPS_WEIGHT_SUM"
1721
+ }
1722
+ },
1723
+ "EVENT_PENDING": {
1724
+ "summary": "Event still pending review",
1725
+ "value": {
1726
+ "error": "The referenced event is still pending review and cannot back a metric yet.",
1727
+ "code": "EVENT_PENDING"
1728
+ }
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+ }
1734
+ },
1735
+ "parameters": {
1736
+ "ProjectId": {
1737
+ "name": "X-Project-Id",
1738
+ "in": "header",
1739
+ "required": false,
1740
+ "description": "Project the request operates on. Optional — defaults to the project the SDK key belongs to; pass it only to scope a multi-project key (the generated client sets it once from its configuration, so per-call callers never thread it).",
1741
+ "schema": {
1742
+ "type": "string"
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1747
+ }