@fleetless/contracts 6.0.0-next.3 → 6.1.0-next.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/artifacts/openapi.json +636 -1
  3. package/artifacts/routes.json +101 -0
  4. package/artifacts/schema/addon-catalogue-entry.schema.json +75 -0
  5. package/artifacts/schema/addon-key.schema.json +11 -0
  6. package/artifacts/schema/admin-plan-change-request.schema.json +171 -0
  7. package/artifacts/schema/asset-plan-limit-details.schema.json +108 -0
  8. package/artifacts/schema/audit-actor.schema.json +2 -1
  9. package/artifacts/schema/audit-event.schema.json +2 -1
  10. package/artifacts/schema/audit-list-response.schema.json +2 -1
  11. package/artifacts/schema/org-addons.schema.json +39 -0
  12. package/artifacts/schema/org-lock.schema.json +23 -0
  13. package/artifacts/schema/org-locked-details.schema.json +17 -0
  14. package/artifacts/schema/org-plan-usage.schema.json +45 -0
  15. package/artifacts/schema/org-plan.schema.json +450 -0
  16. package/artifacts/schema/pending-plan-change.schema.json +112 -0
  17. package/artifacts/schema/plan-catalogue-entry.schema.json +226 -0
  18. package/artifacts/schema/plan-change-keep.schema.json +45 -0
  19. package/artifacts/schema/plan-change-reason.schema.json +10 -0
  20. package/artifacts/schema/plan-change-request.schema.json +71 -0
  21. package/artifacts/schema/plan-currency.schema.json +8 -0
  22. package/artifacts/schema/plan-features.schema.json +37 -0
  23. package/artifacts/schema/plan-id.schema.json +10 -0
  24. package/artifacts/schema/plan-limit-details.schema.json +87 -0
  25. package/artifacts/schema/plan-limits.schema.json +113 -0
  26. package/artifacts/schema/plan-overrides.schema.json +103 -0
  27. package/artifacts/schema/plan-prices.schema.json +33 -0
  28. package/artifacts/schema/plan-required-details.schema.json +42 -0
  29. package/dist/audit.d.ts +10 -0
  30. package/dist/audit.js +8 -1
  31. package/dist/errors.d.ts +138 -1
  32. package/dist/errors.js +94 -0
  33. package/dist/index.d.ts +6 -2
  34. package/dist/index.js +11 -1
  35. package/dist/plans.d.ts +525 -0
  36. package/dist/plans.js +442 -0
  37. package/dist/realtime.d.ts +2 -0
  38. package/dist/realtime.js +6 -0
  39. package/dist/routes.d.ts +5 -1
  40. package/dist/routes.js +58 -0
  41. package/package.json +1 -1
package/dist/plans.js ADDED
@@ -0,0 +1,442 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { z } from 'zod';
3
+ /**
4
+ * The plan catalogue.
5
+ *
6
+ * Four plans, cheapest first: `basic` (free), `plus`, `pro` and
7
+ * `enterprise` (sold by contract, never self-service — its limits and
8
+ * features are the catalogue's ceiling, not a priced row). Add-ons exist on
9
+ * `pro` only; see `ADDONS` and the `addons` feature.
10
+ */
11
+ export const planId = z.enum(['basic', 'plus', 'pro', 'enterprise']);
12
+ /**
13
+ * Cheapest first. `requiredPlanFor` and `nextPlanRaising` both read "higher"
14
+ * and "lower" from this order, so a plan's position here — not its name — is
15
+ * what decides whether a change is an upgrade or a downgrade.
16
+ */
17
+ export const PLAN_ORDER = ['basic', 'plus', 'pro', 'enterprise'];
18
+ /**
19
+ * The limits a plan ties a number to. `history_days` and `audit_days` are
20
+ * not refused against — they size a retention window, not a quota that an
21
+ * action can exceed — but they share this vocabulary because they are read
22
+ * off the same catalogue row.
23
+ */
24
+ export const planLimitKey = z.enum([
25
+ 'seats',
26
+ 'robots',
27
+ 'apps',
28
+ 'app_users',
29
+ 'live_video_ms_per_month',
30
+ 'asset_bytes_per_robot',
31
+ 'history_days',
32
+ 'audit_days',
33
+ ]);
34
+ /** `null` = by contract (Enterprise) or unlimited; see `orgPlan.limits`. */
35
+ const limit = z.number().int().positive().nullable();
36
+ export const planLimits = z.object({
37
+ seats: limit,
38
+ robots: limit,
39
+ apps: limit,
40
+ app_users: limit,
41
+ live_video_ms_per_month: limit,
42
+ asset_bytes_per_robot: limit,
43
+ history_days: limit,
44
+ audit_days: limit,
45
+ });
46
+ /**
47
+ * What a plan unlocks beyond a number: `app_mcp` (the app's MCP server),
48
+ * `two_factor` (a developer may turn it on), `require_two_factor` (an owner
49
+ * may force it org-wide), `app_oidc` (an app may federate sign-in to an
50
+ * external IdP), `hosted_logo` (a custom logo on the app's hosted pages),
51
+ * `audit_export` (the audit log's CSV export) and `addons` (whether add-ons
52
+ * may be bought at all — `pro` only).
53
+ */
54
+ export const planFeature = z.enum(['app_mcp', 'two_factor', 'require_two_factor', 'app_oidc', 'hosted_logo', 'audit_export', 'addons']);
55
+ export const planFeatures = z.object(Object.fromEntries(planFeature.options.map((f) => [f, z.boolean()])));
56
+ /** Integer cents, excluding VAT. */
57
+ const cents = z.number().int().nonnegative();
58
+ export const planPrices = z.object({
59
+ eur_month: cents,
60
+ usd_month: cents,
61
+ eur_year: cents,
62
+ usd_year: cents,
63
+ });
64
+ export const planSupport = z.enum(['community', 'email', 'priority', 'named_contact']);
65
+ export const planCatalogueEntry = z.object({
66
+ id: planId,
67
+ name: z.string().min(1).meta({ description: 'The plan\'s display name: Basic, Plus, Pro or Enterprise.' }),
68
+ limits: planLimits,
69
+ features: planFeatures,
70
+ prices: planPrices.nullable().meta({ description: '`null` for Enterprise: sold by contract, on request.' }),
71
+ support: planSupport,
72
+ });
73
+ /**
74
+ * What can be bought on top of a plan, each raising exactly one
75
+ * `planLimitKey` by `per_unit`. Exist on `pro` only: on any other plan an
76
+ * org's add-on counts are zero, and a plan change away from `pro` resets
77
+ * them to zero (the add-ons feature gate, `planFeature.addons`).
78
+ */
79
+ export const addonKey = z.enum(['seats', 'robots', 'apps', 'app_user_packs', 'live_video_packs']);
80
+ export const addonCatalogueEntry = z.object({
81
+ key: addonKey,
82
+ raises: planLimitKey.meta({ description: 'The plan limit this add-on raises.' }),
83
+ per_unit: z.number().int().positive().meta({ description: 'How much one unit of this add-on raises `raises` by.' }),
84
+ prices: planPrices,
85
+ });
86
+ /**
87
+ * **No floating-point money, anywhere.** `eurCents * 1.15` would introduce
88
+ * the fraction of a cent that floating point cannot hold exactly, so the
89
+ * conversion is integer arithmetic throughout: scale by 115, add 9_999 to
90
+ * round the result up to the next whole 10_000 (i.e. the next whole dollar)
91
+ * and only then divide back down. Rounds *up*, never to nearest: a plan that
92
+ * reads cheaper in dollars than its true euro equivalent is the error this
93
+ * guards against, not the one it risks.
94
+ */
95
+ export function usdCentsFromEurCents(eurCents) {
96
+ return Math.floor((eurCents * 115 + 9_999) / 10_000) * 100;
97
+ }
98
+ /** Yearly is twelve months at 15% off, rounded to the nearest cent. */
99
+ export function yearlyEurCents(monthEurCents) {
100
+ return Math.round((monthEurCents * 12 * 85) / 100);
101
+ }
102
+ /**
103
+ * The catalogue writes every price through this one function, from the
104
+ * monthly EUR cents alone — never as the other three numbers as literals —
105
+ * so the USD and yearly rules can only ever be applied once, here.
106
+ */
107
+ export function pricesFromEurMonth(eurMonthCents) {
108
+ const eurYear = yearlyEurCents(eurMonthCents);
109
+ return {
110
+ eur_month: eurMonthCents,
111
+ usd_month: usdCentsFromEurCents(eurMonthCents),
112
+ eur_year: eurYear,
113
+ usd_year: usdCentsFromEurCents(eurYear),
114
+ };
115
+ }
116
+ /**
117
+ * The catalogue, exactly as the decision table (2026-09-30) states it.
118
+ * Live video is in milliseconds, asset storage in decimal bytes per robot;
119
+ * Enterprise's limits and features are `null` / `true` throughout because
120
+ * they are set by contract, not read off this table.
121
+ */
122
+ export const PLANS = {
123
+ basic: {
124
+ id: 'basic',
125
+ name: 'Basic',
126
+ limits: {
127
+ seats: 1,
128
+ robots: 1,
129
+ apps: 1,
130
+ app_users: 10,
131
+ live_video_ms_per_month: 36_000_000,
132
+ asset_bytes_per_robot: 1_000_000_000,
133
+ history_days: 7,
134
+ audit_days: 7,
135
+ },
136
+ features: {
137
+ app_mcp: false,
138
+ two_factor: false,
139
+ require_two_factor: false,
140
+ app_oidc: false,
141
+ hosted_logo: false,
142
+ audit_export: false,
143
+ addons: false,
144
+ },
145
+ prices: pricesFromEurMonth(0),
146
+ support: 'community',
147
+ },
148
+ plus: {
149
+ id: 'plus',
150
+ name: 'Plus',
151
+ limits: {
152
+ seats: 3,
153
+ robots: 3,
154
+ apps: 3,
155
+ app_users: 25,
156
+ live_video_ms_per_month: 360_000_000,
157
+ asset_bytes_per_robot: 2_000_000_000,
158
+ history_days: 30,
159
+ audit_days: 30,
160
+ },
161
+ features: {
162
+ app_mcp: true,
163
+ two_factor: true,
164
+ require_two_factor: false,
165
+ app_oidc: true,
166
+ hosted_logo: true,
167
+ audit_export: false,
168
+ addons: false,
169
+ },
170
+ prices: pricesFromEurMonth(2900),
171
+ support: 'email',
172
+ },
173
+ pro: {
174
+ id: 'pro',
175
+ name: 'Pro',
176
+ limits: {
177
+ seats: 5,
178
+ robots: 5,
179
+ apps: 5,
180
+ app_users: 50,
181
+ live_video_ms_per_month: 900_000_000,
182
+ asset_bytes_per_robot: 3_000_000_000,
183
+ history_days: 90,
184
+ audit_days: 90,
185
+ },
186
+ features: {
187
+ app_mcp: true,
188
+ two_factor: true,
189
+ require_two_factor: true,
190
+ app_oidc: true,
191
+ hosted_logo: true,
192
+ audit_export: true,
193
+ addons: true,
194
+ },
195
+ prices: pricesFromEurMonth(14900),
196
+ support: 'priority',
197
+ },
198
+ enterprise: {
199
+ id: 'enterprise',
200
+ name: 'Enterprise',
201
+ limits: {
202
+ seats: null,
203
+ robots: null,
204
+ apps: null,
205
+ app_users: null,
206
+ live_video_ms_per_month: null,
207
+ asset_bytes_per_robot: null,
208
+ history_days: null,
209
+ audit_days: null,
210
+ },
211
+ features: {
212
+ app_mcp: true,
213
+ two_factor: true,
214
+ require_two_factor: true,
215
+ app_oidc: true,
216
+ hosted_logo: true,
217
+ audit_export: true,
218
+ addons: true,
219
+ },
220
+ prices: null,
221
+ support: 'named_contact',
222
+ },
223
+ };
224
+ /**
225
+ * The add-on catalogue. Every price goes through `pricesFromEurMonth`, same
226
+ * discipline as `PLANS`.
227
+ */
228
+ export const ADDONS = {
229
+ seats: { key: 'seats', raises: 'seats', per_unit: 1, prices: pricesFromEurMonth(900) },
230
+ robots: { key: 'robots', raises: 'robots', per_unit: 1, prices: pricesFromEurMonth(1900) },
231
+ apps: { key: 'apps', raises: 'apps', per_unit: 1, prices: pricesFromEurMonth(900) },
232
+ app_user_packs: { key: 'app_user_packs', raises: 'app_users', per_unit: 5, prices: pricesFromEurMonth(1000) },
233
+ live_video_packs: { key: 'live_video_packs', raises: 'live_video_ms_per_month', per_unit: 900_000_000, prices: pricesFromEurMonth(900) },
234
+ };
235
+ /** The cheapest plan that has the feature. */
236
+ export function requiredPlanFor(feature) {
237
+ for (const plan of PLAN_ORDER) {
238
+ if (PLANS[plan].features[feature])
239
+ return plan;
240
+ }
241
+ // Every feature is true on `enterprise`, the last entry in `PLAN_ORDER`, so
242
+ // this is unreachable — kept as a defined return rather than a non-null
243
+ // assertion at the call site.
244
+ return PLAN_ORDER[PLAN_ORDER.length - 1];
245
+ }
246
+ /**
247
+ * The cheapest plan above `plan` whose catalogue limit for `key` is higher
248
+ * than `plan`'s own (`null` counts as higher, since it means unlimited);
249
+ * `null` when no plan above it raises the limit any further.
250
+ */
251
+ export function nextPlanRaising(plan, key) {
252
+ const at = PLAN_ORDER.indexOf(plan);
253
+ const current = PLANS[plan].limits[key];
254
+ if (current === null)
255
+ return null; // already unlimited, nothing raises it further
256
+ for (let i = at + 1; i < PLAN_ORDER.length; i++) {
257
+ const candidate = PLAN_ORDER[i];
258
+ const candidateLimit = PLANS[candidate].limits[key];
259
+ if (candidateLimit === null || candidateLimit > current)
260
+ return candidate;
261
+ }
262
+ return null;
263
+ }
264
+ /*
265
+ * ---------------------------------------------------------------------------
266
+ * The organization's plan (2026-10-02, fleetless/fleetless#103).
267
+ *
268
+ * Everything above is the catalogue: the same four rows for every org. What
269
+ * follows is one org's position in it — what it bought, what it is using,
270
+ * and the change it has queued but not yet crossed into.
271
+ * ---------------------------------------------------------------------------
272
+ */
273
+ /** What an org is billed in. The catalogue's prices are fixed in both; this picks which one an invoice reads in. */
274
+ export const planCurrency = z.enum(['eur', 'usd']);
275
+ /**
276
+ * How many units of each add-on an org has bought. Meaningful on `pro` only
277
+ * — see `planFeature.addons` — and zero on every other plan: a plan change
278
+ * away from `pro` resets every count here to zero rather than leaving them
279
+ * stored and merely unread.
280
+ */
281
+ export const orgAddons = z.object({
282
+ seats: z.number().int().nonnegative(),
283
+ robots: z.number().int().nonnegative(),
284
+ apps: z.number().int().nonnegative(),
285
+ app_user_packs: z.number().int().nonnegative(),
286
+ live_video_packs: z.number().int().nonnegative(),
287
+ });
288
+ /**
289
+ * What the org is using right now, counted the way each limit refuses
290
+ * against: `seats` is developers, owners included, plus pending team
291
+ * invitations; `app_users` is app users of every app plus pending app-user
292
+ * invitations; `live_video_ms_this_month` is this UTC calendar month's
293
+ * app-attributed live video only — a console session never counts and is
294
+ * never ended for it; `asset_bytes` is the sum of every robot's stored
295
+ * assets across the whole org. Read fresh on every call, the same discipline
296
+ * `GET /api/org/quotas` already keeps, so a number here is never one call
297
+ * behind the limit it is compared against.
298
+ */
299
+ export const orgPlanUsage = z.object({
300
+ seats: z.number().int().nonnegative(),
301
+ robots: z.number().int().nonnegative(),
302
+ apps: z.number().int().nonnegative(),
303
+ app_users: z.number().int().nonnegative(),
304
+ live_video_ms_this_month: z.number().int().nonnegative(),
305
+ asset_bytes: z.number().int().nonnegative(),
306
+ });
307
+ /**
308
+ * What a downward plan change keeps, by id, when the target plan cannot hold
309
+ * everything the org has today. `owners` is deliberately not a field: an
310
+ * owner is never a candidate for deletion and always stays, so a chooser
311
+ * cannot even name one here — but an owner still counts against the target
312
+ * plan's `seats`, and if the owners alone already exceed it the change is
313
+ * refused `409 plan_limit` naming `seats`, before anything else about the
314
+ * choice is even considered. `.strict()` so an extra key — `owners` most of
315
+ * all — is refused at the door rather than silently ignored.
316
+ */
317
+ export const planChangeKeep = z.object({
318
+ robots: z.array(z.uuid()),
319
+ apps: z.array(z.uuid()),
320
+ app_users: z.array(z.uuid()),
321
+ developers: z.array(z.uuid()),
322
+ }).strict();
323
+ /**
324
+ * Why a change is pending. `downgrade` and `cancel` (to Basic) are a
325
+ * developer's own choice — `PUT /api/org/plan/change` only ever moves an org
326
+ * down; an upgrade or an add-on needs payment this route does not collect,
327
+ * and today goes through a Feedback request that Fleetless applies through
328
+ * the admin route. `migration` is a plan the platform is moving every org on
329
+ * the beta through; `lock` is a change the platform queued because the org
330
+ * is locked (see `orgLock`) and must land on Basic.
331
+ */
332
+ export const planChangeReason = z.enum(['downgrade', 'cancel', 'migration', 'lock']);
333
+ /**
334
+ * A downward plan change the org has chosen, or been queued for by the
335
+ * platform, not yet in effect. `effective_at` is normally
336
+ * `orgPlan.period_ends_at`, so the org keeps full use of what it has until
337
+ * the billing period actually turns over — except a choice made while the
338
+ * org was locked, which takes effect at once, and a `migration`, which takes
339
+ * effect at the platform's switch date instead; it is `null` only while that
340
+ * date is not yet set. `keep` is `null` when the org's usage already fits
341
+ * the target plan outright and nothing is deleted; otherwise it is exactly
342
+ * what the confirmation counted — anything created while the choice is still
343
+ * pending is checked against the target plan too and, passing, is folded
344
+ * into `keep`, so what lands at `effective_at` is never a surprise. A later
345
+ * `PUT /api/org/plan/change` replaces a still-pending choice outright;
346
+ * `DELETE` withdraws it and leaves the org on its current plan.
347
+ * `history_days_after` is what the confirmation announces to whoever chose
348
+ * it, so the warning they read before confirming is the same number that
349
+ * lands.
350
+ */
351
+ export const pendingPlanChange = z.object({
352
+ target_plan: planId,
353
+ reason: planChangeReason,
354
+ effective_at: z.iso.datetime().nullable(),
355
+ keep: planChangeKeep.nullable(),
356
+ history_days_after: z.number().int().positive(),
357
+ chosen_by: z.uuid(),
358
+ chosen_at: z.iso.datetime(),
359
+ });
360
+ /**
361
+ * An org restricted, while locked, to moving straight to Basic — narrower
362
+ * still than the already-downward-only `PUT /api/org/plan/change`. `payment`
363
+ * is a failed charge; `migration` is the platform's own move off the beta.
364
+ * Either way the only plan a developer may choose while locked is Basic —
365
+ * see `target_state_conflict` with rule `locked_basic_only` on
366
+ * `PUT /api/org/plan/change`; a choice made while locked takes effect at
367
+ * once rather than waiting for the billing period to turn over.
368
+ */
369
+ export const orgLock = z.object({
370
+ reason: z.enum(['payment', 'migration']),
371
+ since: z.iso.datetime(),
372
+ });
373
+ /**
374
+ * **The one read everything about an org's plan comes from**: the console's
375
+ * Plan & billing page, its usage and limit gauges, every upgrade prompt and
376
+ * every gate a feature check renders. `limits` is the *effective* ceiling —
377
+ * the catalogue row plus `addons`, or an operator's `overrides` in place of
378
+ * either — so a consumer never has to recompute it from the catalogue and
379
+ * the add-on counts itself. `switch` is set only for an organization still
380
+ * on the beta and carries the date its plan changes on its own, and whether
381
+ * it still has to choose one.
382
+ */
383
+ export const orgPlan = z.object({
384
+ plan: planId,
385
+ currency: planCurrency,
386
+ period_ends_at: z.iso.datetime().meta({
387
+ description: "The end of the organization's current billing period; while no payment period exists yet, the end of the current UTC calendar " +
388
+ 'month. A pending downward plan change normally takes effect at this exact instant — except one chosen while the org was locked, ' +
389
+ "which lands at once, and the platform's own move off the beta, which lands at its switch date instead.",
390
+ }),
391
+ addons: orgAddons,
392
+ limits: planLimits,
393
+ features: planFeatures,
394
+ usage: orgPlanUsage,
395
+ pending_change: pendingPlanChange.nullable(),
396
+ lock: orgLock.nullable(),
397
+ switch: z.object({
398
+ at: z.iso.datetime().nullable(),
399
+ needs_choice: z.boolean(),
400
+ }).nullable(),
401
+ });
402
+ /**
403
+ * What a developer may ask for on `PUT /api/org/plan/change` — **a downward
404
+ * move only**: a lower plan, or Basic as a cancellation. An upgrade or an
405
+ * add-on needs payment this route does not collect, and is not reachable
406
+ * through it at all today; it goes through a Feedback request that Fleetless
407
+ * applies through the admin route instead. `.strict()` for the same reason
408
+ * `planChangeKeep` is: a caller cannot send a field this shape does not
409
+ * name, `keep` included, and an owner cannot be smuggled into it through a
410
+ * typo that `.strict()` would otherwise swallow in silence.
411
+ */
412
+ export const planChangeRequest = z.object({
413
+ target_plan: planId,
414
+ keep: planChangeKeep.nullable(),
415
+ }).strict();
416
+ /**
417
+ * An operator's per-org replacement for one or more catalogue limits —
418
+ * Enterprise's contracted numbers, most of all, which exist nowhere else.
419
+ * Every key is optional, so an operator sets only what differs from the
420
+ * plan; a key present with `null` clears an earlier override back to the
421
+ * plan's own number, which is why every value is nullable as well as
422
+ * optional — omitted and `null` are two different instructions.
423
+ */
424
+ export const planOverrides = z.object(Object.fromEntries(planLimitKey.options.map((k) => [k, limit.optional()]))).strict();
425
+ /**
426
+ * What the operator sends on `PATCH /api/admin/orgs/:id/plan`. Every field
427
+ * is optional because this one route carries every shape of change an
428
+ * operator makes to an org's plan — switching it, adding or removing
429
+ * add-ons, overriding a limit, changing the billing currency, or moving the
430
+ * period boundary — and a request that touched all of them at once would be
431
+ * no easier to audit than four small ones in its place. `addons` is a
432
+ * partial `orgAddons`: only the counts named change, the rest are left as
433
+ * they are. `overrides` follows `planOverrides`: a key present with `null`
434
+ * clears that override.
435
+ */
436
+ export const adminPlanChangeRequest = z.object({
437
+ plan: planId,
438
+ addons: orgAddons.partial().strict().optional(),
439
+ overrides: planOverrides.optional(),
440
+ currency: planCurrency.optional(),
441
+ period_ends_at: z.iso.datetime().nullable().optional(),
442
+ }).strict();
@@ -263,6 +263,7 @@ export declare const liveSessionEndReason: z.ZodEnum<{
263
263
  publish_failed: "publish_failed";
264
264
  robot_offline: "robot_offline";
265
265
  config_changed: "config_changed";
266
+ plan_limit: "plan_limit";
266
267
  released_by_peer: "released_by_peer";
267
268
  revoked: "revoked";
268
269
  expired: "expired";
@@ -294,6 +295,7 @@ export declare const liveSessionEvent: z.ZodObject<{
294
295
  publish_failed: "publish_failed";
295
296
  robot_offline: "robot_offline";
296
297
  config_changed: "config_changed";
298
+ plan_limit: "plan_limit";
297
299
  released_by_peer: "released_by_peer";
298
300
  revoked: "revoked";
299
301
  expired: "expired";
package/dist/realtime.js CHANGED
@@ -292,6 +292,12 @@ export const liveSessionEndReason = z.enum([
292
292
  'expired',
293
293
  /** The robot was deleted out from under the session. */
294
294
  'robot_deleted',
295
+ /**
296
+ * The organization's live video for app users reached its plan's monthly
297
+ * limit (2026-10-02, fleetless/fleetless#103); `detail` carries the
298
+ * sentence the viewer shows.
299
+ */
300
+ 'plan_limit',
295
301
  /**
296
302
  * The cloud ended it and cannot say which of the above applied. **Kept
297
303
  * deliberately**: a channel that cannot say "I do not know" will say
package/dist/routes.d.ts CHANGED
@@ -30,7 +30,11 @@ import type { ZodType } from 'zod';
30
30
  import type { ErrorCode } from './errors.js';
31
31
  export type RouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
32
32
  export type RouteAudience = 'developer' | 'client' | 'internal';
33
- export type RouteAuth = 'developer' | 'developer_or_client' | 'none' | 'robot_upload' | 'in_handler';
33
+ /**
34
+ * `ops` (2026-10-02, fleetless/fleetless#103): a bearer token the operator
35
+ * holds (`OPS_API_TOKEN`); never reachable through a public host.
36
+ */
37
+ export type RouteAuth = 'developer' | 'developer_or_client' | 'none' | 'robot_upload' | 'in_handler' | 'ops';
34
38
  export type RouteTransport = 'http' | 'websocket';
35
39
  export type RouteSection = 'health' | 'developer-auth' | 'client-auth' | 'org' | 'users' | 'apps' | 'robots' | 'config' | 'alerts' | 'commands' | 'cameras' | 'assets' | 'mcp' | 'transports';
36
40
  export interface RouteParam {
package/dist/routes.js CHANGED
@@ -11,6 +11,7 @@ import { acceptTeamInviteRequest, authMeResponse, createPasskeyRequest, createPa
11
11
  import { jobRunListResponse, jobRunQuery, jobRunSummary, jobRunSummaryQuery } from './jobs.js';
12
12
  import { MCP_APP_PATHS, mcpRobotDatasheet, mcpRolePreviewResponse } from './mcp.js';
13
13
  import { authorizationServerMetadata, dynamicClientRegistrationRequest, dynamicClientRegistrationResponse, oauthAuthorizeQuery, oauthRedirectResponse, oauthTokenRequest, oauthTokenResponse, protectedResourceMetadata, } from './oauth.js';
14
+ import { adminPlanChangeRequest, orgPlan, planChangeRequest } from './plans.js';
14
15
  import { cameraListResponse, cancelRequest, configDraftResponse, configVersionResponse, configVersionsResponse, createRobotRequest, createRobotResponse, datapointListResponse, datapointValue, exposureListResponse, fetchTypesRequest, fetchTypesResponse, historyQuery, historyResponse, introspectionResponse, invokeOrServiceResponse, invokeRequest, jobResponse, liveSessionResponse, orgHealthQuery, orgLatencyQuery, orgLatencyResponse, orgQuotaUsage, orgUsageQuery, orgUsageResponse, patchRobotRequest, patchRobotResponse, publishConfigResponse, publishRequest, putConfigDraftRequest, putRobotDetailsRequest, putRobotDetailsResponse, robotTokenRotateResponse, jointStatePutRequest, jointStatePutResponse, releaseLiveQuery, renameSlugRequest, renameSlugResponse, robotDeleteQuery, resourceHealthListResponse, robotDeletionSummary, robotDetailResponse, robotJobsResponse, robotListResponse, slugUsageResponse, snapshotMetaResponse, typesResponse, } from './rest.js';
15
16
  export const ROUTE_SECTIONS = [
16
17
  { id: 'health', title: 'Health' },
@@ -2787,6 +2788,63 @@ export const ROUTES = [
2787
2788
  '`not_configured` when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers ' +
2788
2789
  '`429 rate_limited` with `retry_after_ms`. Replies come by mail, to the sender\'s address.',
2789
2790
  },
2791
+ /* ----------------------------------------------------------- org (plan) */
2792
+ {
2793
+ method: 'GET', path: '/api/org/plan', section: 'org',
2794
+ summary: "Reads the org's plan: its limits, its usage against them, its add-ons and any change already queued.",
2795
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
2796
+ params: [], query: null, request: null, response: orgPlan,
2797
+ errors: [...DEVELOPER_GUARD], transport: 'http',
2798
+ notes: "The one read the console's Plan & billing page, its usage and limit gauges, and every upgrade prompt and feature gate draw from — " +
2799
+ 'nothing else computes `limits` or `usage` on its own. `limits` is already the effective ceiling, the catalogue row raised by ' +
2800
+ '`addons` or replaced by an operator\'s override, so a consumer never recomputes it from the catalogue. `usage` is counted fresh on ' +
2801
+ 'every call, never cached. `switch` is present only for an organization still on the beta that has not yet landed on a priced plan.',
2802
+ },
2803
+ {
2804
+ method: 'PUT', path: '/api/org/plan/change', section: 'org',
2805
+ summary: "Moves the org's plan down — a lower plan or a cancellation to Basic — queuing the change rather than applying it at once.",
2806
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 200,
2807
+ params: [], query: null, request: planChangeRequest, response: orgPlan,
2808
+ errors: [...DEVELOPER_GUARD, 'tier_required', 'validation_error', 'plan_limit', 'target_state_conflict'], transport: 'http',
2809
+ notes: 'Owner tier, and **downward only**: this route moves the org to a lower plan or cancels it outright to Basic. It never moves the org ' +
2810
+ 'up — until payment exists, an upgrade or an add-on is not this route\'s job at all, and is handled today as a Feedback request that ' +
2811
+ 'Fleetless then applies through the admin route. `409 target_state_conflict` names `target_plan` with rule `not_lower` when the ' +
2812
+ 'chosen plan is not below the org\'s current one; with rule `locked_basic_only` when the org is locked (`orgLock`) and the chosen ' +
2813
+ 'plan is anything but Basic; and with rule `migration_basic_only` when the org is still on the beta, awaiting the switch to priced ' +
2814
+ 'plans, and the chosen plan is anything but Basic — that choice is exactly what the org lands on at the switch. An owner is never ' +
2815
+ 'named in `keep` and always stays, but still counts against the target plan\'s `seats`; `409 plan_limit` names `seats` when the ' +
2816
+ 'owners alone already exceed it, and names whichever other limit `keep` still exceeds otherwise. `keep` is `null` when the org\'s ' +
2817
+ 'current usage already fits the target plan outright and nothing is deleted; named, it lists exactly the robots, apps, app users and ' +
2818
+ 'developers that stay. The choice is stored as `pending_change` and takes effect at `period_ends_at` — **everything of the chosen ' +
2819
+ 'kind not named in `keep` is deleted at that instant, never before** — except a choice made while the org is locked, which takes ' +
2820
+ 'effect at once, and a beta org\'s choice, which takes effect at the switch date instead. Anything created while the choice is ' +
2821
+ 'pending is checked against the target plan too and, when it passes, is folded into `keep`, so exactly what the confirmation counted ' +
2822
+ 'is what is actually deleted. A later `PUT` replaces a still-pending choice outright.',
2823
+ },
2824
+ {
2825
+ method: 'DELETE', path: '/api/org/plan/change', section: 'org',
2826
+ summary: 'Withdraws a plan change that was queued but has not taken effect yet.',
2827
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 204,
2828
+ params: [], query: null, request: null, response: null,
2829
+ errors: [...DEVELOPER_GUARD, 'tier_required', 'not_found'], transport: 'http',
2830
+ notes: 'Owner tier. `404 not_found` when the org has no `pending_change` to withdraw. The org stays on its current plan, unchanged, as if ' +
2831
+ 'the choice had never been made; a developer who wants a different one sends a new `PUT`, which would have replaced this one outright ' +
2832
+ 'anyway.',
2833
+ },
2834
+ {
2835
+ method: 'PATCH', path: '/api/admin/orgs/:id/plan', section: 'org',
2836
+ summary: "Changes an org's plan, add-ons, limit overrides, currency or billing period as the operator.",
2837
+ audience: 'internal', auth: 'ops', rateLimited: true, ownerTier: false, status: 200,
2838
+ params: [{ name: 'id', description: 'The org\'s uuid, whose plan, add-ons or overrides the operator is changing.' }],
2839
+ query: null, request: adminPlanChangeRequest, response: orgPlan,
2840
+ errors: ['unauthorized', 'not_found', 'validation_error', 'plan_limit', 'rate_limited'], transport: 'http',
2841
+ notes: 'Every public host answers `404 not_found` for every `/api/admin/*` path — this route is reachable only on the cloud\'s private ' +
2842
+ 'address — and that same address answers `404` here too while `OPS_API_TOKEN` is not configured, so a door with no key behind it ' +
2843
+ 'reads exactly like one nobody opened. An unknown `:id` is the same `404`. Applies at once: an operator\'s plan, add-on or override ' +
2844
+ 'change is never queued as a `pending_change`, unlike a developer\'s own downgrade. `409 plan_limit` when a lowered override or a ' +
2845
+ 'removed add-on would leave the org\'s current usage over its new ceiling. Every change here is audited with the `fleetless` actor, ' +
2846
+ 'never a developer\'s — the row names what an operator did, not who in the org asked for it.',
2847
+ },
2790
2848
  /* ------------------------------------------------- assets (robot upload) */
2791
2849
  {
2792
2850
  method: 'POST', path: '/api/bridge/assets', section: 'assets',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetless/contracts",
3
- "version": "6.0.0-next.3",
3
+ "version": "6.1.0-next.1",
4
4
  "description": "Fleetless wire contracts: the bridge-cloud protocol, the REST API schemas and the error codes, as zod schemas with generated JSON Schema and OpenAPI artifacts.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dehne Robotik GmbH",