@fleetless/contracts 6.0.0 → 6.1.0-next.2

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 +692 -1
  3. package/artifacts/routes.json +101 -0
  4. package/artifacts/schema/addon-catalogue-entry.schema.json +81 -0
  5. package/artifacts/schema/addon-key.schema.json +11 -0
  6. package/artifacts/schema/admin-plan-change-request.schema.json +189 -0
  7. package/artifacts/schema/asset-plan-limit-details.schema.json +115 -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 +44 -0
  12. package/artifacts/schema/org-lock.schema.json +25 -0
  13. package/artifacts/schema/org-locked-details.schema.json +18 -0
  14. package/artifacts/schema/org-plan-usage.schema.json +51 -0
  15. package/artifacts/schema/org-plan.schema.json +500 -0
  16. package/artifacts/schema/pending-plan-change.schema.json +123 -0
  17. package/artifacts/schema/plan-catalogue-entry.schema.json +249 -0
  18. package/artifacts/schema/plan-change-keep.schema.json +49 -0
  19. package/artifacts/schema/plan-change-reason.schema.json +10 -0
  20. package/artifacts/schema/plan-change-request.schema.json +77 -0
  21. package/artifacts/schema/plan-currency.schema.json +8 -0
  22. package/artifacts/schema/plan-features.schema.json +44 -0
  23. package/artifacts/schema/plan-id.schema.json +10 -0
  24. package/artifacts/schema/plan-limit-details.schema.json +94 -0
  25. package/artifacts/schema/plan-limits.schema.json +121 -0
  26. package/artifacts/schema/plan-overrides.schema.json +111 -0
  27. package/artifacts/schema/plan-prices.schema.json +37 -0
  28. package/artifacts/schema/plan-required-details.schema.json +45 -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 +104 -0
  33. package/dist/index.d.ts +6 -2
  34. package/dist/index.js +11 -1
  35. package/dist/plans.d.ts +507 -0
  36. package/dist/plans.js +487 -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 +59 -0
  41. package/package.json +1 -1
package/dist/plans.js ADDED
@@ -0,0 +1,487 @@
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 are
9
+ * bought on a plan with the `addons` feature; see `ADDONS`.
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.meta({ description: 'Developers, owners included, plus pending team invitations.' }),
38
+ robots: limit.meta({ description: 'Robots in the org.' }),
39
+ apps: limit.meta({ description: 'Apps in the org.' }),
40
+ app_users: limit.meta({ description: 'App users across every app of the org, plus pending app-user invitations.' }),
41
+ live_video_ms_per_month: limit.meta({
42
+ description: 'Live video watched by app users in one UTC calendar month, in milliseconds, across the org. Console sessions do not count.',
43
+ }),
44
+ asset_bytes_per_robot: limit.meta({
45
+ description: 'Asset storage per robot, in bytes (decimal: 1 GB = 1,000,000,000). The org stores up to robots × this value in total.',
46
+ }),
47
+ history_days: limit.meta({ description: 'Days the org\'s robot history is kept.' }),
48
+ audit_days: limit.meta({ description: 'Days the org\'s audit log is kept.' }),
49
+ });
50
+ /**
51
+ * What a plan unlocks beyond a number: `app_mcp` (the app's MCP server),
52
+ * `two_factor` (a developer may turn it on), `require_two_factor` (an owner
53
+ * may force it org-wide), `app_oidc` (an app may federate sign-in to an
54
+ * external IdP), `hosted_logo` (a custom logo on the app's hosted pages),
55
+ * `audit_export` (the audit log's CSV export) and `addons` (whether add-ons
56
+ * may be bought at all — `pro` and `enterprise`).
57
+ */
58
+ export const planFeature = z.enum(['app_mcp', 'two_factor', 'require_two_factor', 'app_oidc', 'hosted_logo', 'audit_export', 'addons']);
59
+ const FEATURE_DESCRIPTIONS = {
60
+ app_mcp: 'An app\'s own MCP endpoint, for its app users.',
61
+ two_factor: 'Two-factor sign-in for developers.',
62
+ require_two_factor: 'An owner may require two-factor sign-in for every developer of the org.',
63
+ app_oidc: 'An app may let its users sign in through an OpenID Connect identity provider.',
64
+ hosted_logo: 'An app\'s hosted pages show its logo and accent colour, not only its name.',
65
+ audit_export: 'The audit log can be exported as CSV.',
66
+ addons: 'Add-ons can be bought on top of the plan.',
67
+ };
68
+ export const planFeatures = z.object(Object.fromEntries(planFeature.options.map((f) => [f, z.boolean().meta({ description: FEATURE_DESCRIPTIONS[f] })])));
69
+ /** Integer cents, excluding VAT. */
70
+ const cents = z.number().int().nonnegative();
71
+ export const planPrices = z.object({
72
+ eur_month: cents.meta({ description: 'Per month in euro cents, excluding VAT.' }),
73
+ usd_month: cents.meta({ description: 'Per month in US dollar cents, excluding VAT: the euro price × 1.15, rounded up to a whole dollar.' }),
74
+ eur_year: cents.meta({ description: 'Per year in euro cents, excluding VAT: twelve months less 15 %.' }),
75
+ usd_year: cents.meta({ description: 'Per year in US dollar cents, excluding VAT: the yearly euro price × 1.15, rounded up to a whole dollar.' }),
76
+ });
77
+ export const planSupport = z.enum(['community', 'email', 'priority', 'named_contact']);
78
+ export const planCatalogueEntry = z.object({
79
+ id: planId.meta({ description: 'The plan.' }),
80
+ name: z.string().min(1).meta({ description: 'The plan\'s display name: Basic, Plus, Pro or Enterprise.' }),
81
+ limits: planLimits.meta({ description: 'What the plan allows. `null` means by contract.' }),
82
+ features: planFeatures.meta({ description: 'What the plan unlocks.' }),
83
+ prices: planPrices.nullable().meta({ description: '`null` for Enterprise: sold by contract, on request.' }),
84
+ support: planSupport.meta({ description: 'The support that comes with the plan.' }),
85
+ });
86
+ /**
87
+ * What can be bought on top of a plan, each raising exactly one
88
+ * `planLimitKey` by `per_unit`. Only a plan with the `addons` feature may
89
+ * buy them (`planFeature.addons`: `pro` and `enterprise`).
90
+ */
91
+ export const addonKey = z.enum(['seats', 'robots', 'apps', 'app_user_packs', 'live_video_packs']);
92
+ export const addonCatalogueEntry = z.object({
93
+ key: addonKey.meta({ description: 'The add-on.' }),
94
+ raises: planLimitKey.meta({ description: 'The plan limit this add-on raises.' }),
95
+ per_unit: z.number().int().positive().meta({ description: 'How much one unit of this add-on raises `raises` by.' }),
96
+ prices: planPrices.meta({ description: 'The price of one unit.' }),
97
+ });
98
+ /**
99
+ * **No floating-point money, anywhere.** `eurCents * 1.15` would introduce
100
+ * the fraction of a cent that floating point cannot hold exactly, so the
101
+ * conversion is integer arithmetic throughout: scale by 115, add 9_999 to
102
+ * round the result up to the next whole 10_000 (i.e. the next whole dollar)
103
+ * and only then divide back down. Rounds *up*, never to nearest: a plan that
104
+ * reads cheaper in dollars than its true euro equivalent is the error this
105
+ * guards against, not the one it risks.
106
+ */
107
+ export function usdCentsFromEurCents(eurCents) {
108
+ return Math.floor((eurCents * 115 + 9_999) / 10_000) * 100;
109
+ }
110
+ /** Yearly is twelve months at 15% off, rounded to the nearest cent. */
111
+ export function yearlyEurCents(monthEurCents) {
112
+ return Math.round((monthEurCents * 12 * 85) / 100);
113
+ }
114
+ /**
115
+ * The catalogue writes every price through this one function, from the
116
+ * monthly EUR cents alone — never as the other three numbers as literals —
117
+ * so the USD and yearly rules can only ever be applied once, here.
118
+ */
119
+ export function pricesFromEurMonth(eurMonthCents) {
120
+ const eurYear = yearlyEurCents(eurMonthCents);
121
+ return {
122
+ eur_month: eurMonthCents,
123
+ usd_month: usdCentsFromEurCents(eurMonthCents),
124
+ eur_year: eurYear,
125
+ usd_year: usdCentsFromEurCents(eurYear),
126
+ };
127
+ }
128
+ /**
129
+ * The catalogue, exactly as the decision table (2026-09-30) states it.
130
+ * Live video is in milliseconds, asset storage in decimal bytes per robot;
131
+ * Enterprise's limits and features are `null` / `true` throughout because
132
+ * they are set by contract, not read off this table.
133
+ */
134
+ export const PLANS = {
135
+ basic: {
136
+ id: 'basic',
137
+ name: 'Basic',
138
+ limits: {
139
+ seats: 1,
140
+ robots: 1,
141
+ apps: 1,
142
+ app_users: 10,
143
+ live_video_ms_per_month: 36_000_000,
144
+ asset_bytes_per_robot: 1_000_000_000,
145
+ history_days: 7,
146
+ audit_days: 7,
147
+ },
148
+ features: {
149
+ app_mcp: false,
150
+ two_factor: false,
151
+ require_two_factor: false,
152
+ app_oidc: false,
153
+ hosted_logo: false,
154
+ audit_export: false,
155
+ addons: false,
156
+ },
157
+ prices: pricesFromEurMonth(0),
158
+ support: 'community',
159
+ },
160
+ plus: {
161
+ id: 'plus',
162
+ name: 'Plus',
163
+ limits: {
164
+ seats: 3,
165
+ robots: 3,
166
+ apps: 3,
167
+ app_users: 25,
168
+ live_video_ms_per_month: 360_000_000,
169
+ asset_bytes_per_robot: 2_000_000_000,
170
+ history_days: 30,
171
+ audit_days: 30,
172
+ },
173
+ features: {
174
+ app_mcp: true,
175
+ two_factor: true,
176
+ require_two_factor: false,
177
+ app_oidc: true,
178
+ hosted_logo: true,
179
+ audit_export: false,
180
+ addons: false,
181
+ },
182
+ prices: pricesFromEurMonth(2900),
183
+ support: 'email',
184
+ },
185
+ pro: {
186
+ id: 'pro',
187
+ name: 'Pro',
188
+ limits: {
189
+ seats: 5,
190
+ robots: 5,
191
+ apps: 5,
192
+ app_users: 50,
193
+ live_video_ms_per_month: 900_000_000,
194
+ asset_bytes_per_robot: 3_000_000_000,
195
+ history_days: 90,
196
+ audit_days: 90,
197
+ },
198
+ features: {
199
+ app_mcp: true,
200
+ two_factor: true,
201
+ require_two_factor: true,
202
+ app_oidc: true,
203
+ hosted_logo: true,
204
+ audit_export: true,
205
+ addons: true,
206
+ },
207
+ prices: pricesFromEurMonth(14900),
208
+ support: 'priority',
209
+ },
210
+ enterprise: {
211
+ id: 'enterprise',
212
+ name: 'Enterprise',
213
+ limits: {
214
+ seats: null,
215
+ robots: null,
216
+ apps: null,
217
+ app_users: null,
218
+ live_video_ms_per_month: null,
219
+ asset_bytes_per_robot: null,
220
+ history_days: null,
221
+ audit_days: null,
222
+ },
223
+ features: {
224
+ app_mcp: true,
225
+ two_factor: true,
226
+ require_two_factor: true,
227
+ app_oidc: true,
228
+ hosted_logo: true,
229
+ audit_export: true,
230
+ addons: true,
231
+ },
232
+ prices: null,
233
+ support: 'named_contact',
234
+ },
235
+ };
236
+ /**
237
+ * The add-on catalogue. Every price goes through `pricesFromEurMonth`, same
238
+ * discipline as `PLANS`.
239
+ */
240
+ export const ADDONS = {
241
+ seats: { key: 'seats', raises: 'seats', per_unit: 1, prices: pricesFromEurMonth(900) },
242
+ robots: { key: 'robots', raises: 'robots', per_unit: 1, prices: pricesFromEurMonth(1900) },
243
+ apps: { key: 'apps', raises: 'apps', per_unit: 1, prices: pricesFromEurMonth(900) },
244
+ app_user_packs: { key: 'app_user_packs', raises: 'app_users', per_unit: 5, prices: pricesFromEurMonth(1000) },
245
+ live_video_packs: { key: 'live_video_packs', raises: 'live_video_ms_per_month', per_unit: 900_000_000, prices: pricesFromEurMonth(900) },
246
+ };
247
+ /** The cheapest plan that has the feature. */
248
+ export function requiredPlanFor(feature) {
249
+ for (const plan of PLAN_ORDER) {
250
+ if (PLANS[plan].features[feature])
251
+ return plan;
252
+ }
253
+ // Every feature is true on `enterprise`, the last entry in `PLAN_ORDER`, so
254
+ // this is unreachable — kept as a defined return rather than a non-null
255
+ // assertion at the call site.
256
+ return PLAN_ORDER[PLAN_ORDER.length - 1];
257
+ }
258
+ /**
259
+ * The cheapest plan above `plan` whose catalogue limit for `key` is higher
260
+ * than `plan`'s own (`null` counts as higher, since it means unlimited);
261
+ * `null` when no plan above it raises the limit any further.
262
+ */
263
+ export function nextPlanRaising(plan, key) {
264
+ const at = PLAN_ORDER.indexOf(plan);
265
+ const current = PLANS[plan].limits[key];
266
+ if (current === null)
267
+ return null; // already unlimited, nothing raises it further
268
+ for (let i = at + 1; i < PLAN_ORDER.length; i++) {
269
+ const candidate = PLAN_ORDER[i];
270
+ const candidateLimit = PLANS[candidate].limits[key];
271
+ if (candidateLimit === null || candidateLimit > current)
272
+ return candidate;
273
+ }
274
+ return null;
275
+ }
276
+ /*
277
+ * ---------------------------------------------------------------------------
278
+ * The organization's plan (2026-10-02, fleetless/fleetless#103).
279
+ *
280
+ * Everything above is the catalogue: the same four rows for every org. What
281
+ * follows is one org's position in it — what it bought, what it is using,
282
+ * and the change it has queued but not yet crossed into.
283
+ * ---------------------------------------------------------------------------
284
+ */
285
+ /** What an org is billed in. The catalogue's prices are fixed in both; this picks which one an invoice reads in. */
286
+ export const planCurrency = z.enum(['eur', 'usd']);
287
+ /**
288
+ * How many units of each add-on an org has bought. Only a plan with the
289
+ * `addons` feature may buy them (see `planFeature.addons`); `orgPlan.limits`
290
+ * already includes what they add.
291
+ */
292
+ const addonCount = z.number().int().nonnegative();
293
+ export const orgAddons = z.object({
294
+ seats: addonCount.meta({ description: 'Extra developer seats, one each.' }),
295
+ robots: addonCount.meta({ description: 'Extra robots, one each.' }),
296
+ apps: addonCount.meta({ description: 'Extra apps, one each.' }),
297
+ app_user_packs: addonCount.meta({ description: 'Packs of five extra app users.' }),
298
+ live_video_packs: addonCount.meta({ description: 'Packs of 250 extra hours of app-user live video per month.' }),
299
+ });
300
+ /**
301
+ * What the org is using right now, counted the way each limit refuses
302
+ * against: `seats` is developers, owners included, plus pending team
303
+ * invitations; `app_users` is app users of every app plus pending app-user
304
+ * invitations; `live_video_ms_this_month` is this UTC calendar month's
305
+ * app-attributed live video only — a console session never counts and is
306
+ * never ended for it; `asset_bytes` is the sum of every robot's stored
307
+ * assets across the whole org. Read fresh on every call, the same discipline
308
+ * `GET /api/org/quotas` already keeps, so a number here is never one call
309
+ * behind the limit it is compared against.
310
+ */
311
+ const usageCount = z.number().int().nonnegative();
312
+ export const orgPlanUsage = z.object({
313
+ seats: usageCount.meta({ description: 'Developers, owners included, plus pending team invitations.' }),
314
+ robots: usageCount.meta({ description: 'Robots in the org.' }),
315
+ apps: usageCount.meta({ description: 'Apps in the org.' }),
316
+ app_users: usageCount.meta({ description: 'App users across every app, plus pending app-user invitations.' }),
317
+ live_video_ms_this_month: usageCount.meta({
318
+ description: 'Live video watched by app users in the current UTC calendar month, in milliseconds. Console sessions do not count.',
319
+ }),
320
+ asset_bytes: usageCount.meta({ description: 'Bytes the assets of every robot of the org occupy, together.' }),
321
+ });
322
+ /**
323
+ * What a downward plan change keeps, by id, when the target plan cannot hold
324
+ * everything the org has today. `owners` is deliberately not a field: an
325
+ * owner is never a candidate for deletion and always stays, so a chooser
326
+ * cannot even name one here — but an owner still counts against the target
327
+ * plan's `seats`, and if the owners alone already exceed it the change is
328
+ * refused `409 plan_limit` naming `seats`, before anything else about the
329
+ * choice is even considered. `.strict()` so an extra key — `owners` most of
330
+ * all — is refused at the door rather than silently ignored.
331
+ */
332
+ export const planChangeKeep = z.object({
333
+ robots: z.array(z.uuid()).meta({ description: 'The robots that stay, by id. Every other robot is deleted when the change takes effect.' }),
334
+ apps: z.array(z.uuid()).meta({ description: 'The apps that stay, by id. Every other app is deleted when the change takes effect.' }),
335
+ app_users: z.array(z.uuid()).meta({
336
+ description: 'The app users that stay, by id, across every app. Every other app user is deleted when the change takes effect.',
337
+ }),
338
+ developers: z.array(z.uuid()).meta({
339
+ description: 'The developers that stay, by user id, owners never among them: every owner stays. Every other developer is removed from the org ' +
340
+ 'when the change takes effect.',
341
+ }),
342
+ }).strict();
343
+ /**
344
+ * Why a change is pending. `downgrade` and `cancel` (to Basic) are a
345
+ * developer's own choice — `PUT /api/org/plan/change` only ever moves an org
346
+ * down; an upgrade or an add-on needs payment this route does not collect,
347
+ * and today goes through a Feedback request that Fleetless applies through
348
+ * the admin route. `migration` is a plan the platform is moving every org on
349
+ * the beta through; `lock` is a change the platform queued because the org
350
+ * is locked (see `orgLock`) and must land on Basic.
351
+ */
352
+ export const planChangeReason = z.enum(['downgrade', 'cancel', 'migration', 'lock']);
353
+ /**
354
+ * A downward plan change the org has chosen, or been queued for by the
355
+ * platform, not yet in effect. `effective_at` is normally
356
+ * `orgPlan.period_ends_at`, so the org keeps full use of what it has until
357
+ * the billing period actually turns over — except a choice made while the
358
+ * org was locked, which takes effect at once, and a `migration`, which takes
359
+ * effect at the platform's switch date instead; it is `null` only while that
360
+ * date is not yet set. `keep` is `null` when the org's usage already fits
361
+ * the target plan outright and nothing is deleted; otherwise it is exactly
362
+ * what the confirmation counted — anything created while the choice is still
363
+ * pending is checked against the target plan too and, passing, is folded
364
+ * into `keep`, so what lands at `effective_at` is never a surprise. A later
365
+ * `PUT /api/org/plan/change` replaces a still-pending choice outright;
366
+ * `DELETE` withdraws it and leaves the org on its current plan.
367
+ * `history_days_after` is what the confirmation announces to whoever chose
368
+ * it, so the warning they read before confirming is the same number that
369
+ * lands.
370
+ */
371
+ export const pendingPlanChange = z.object({
372
+ target_plan: planId.meta({ description: 'The plan the org moves to.' }),
373
+ reason: planChangeReason.meta({ description: 'Why the change is pending: `downgrade`, `cancel`, `migration` or `lock`.' }),
374
+ effective_at: z.iso.datetime().nullable().meta({
375
+ description: 'When the change takes effect. `null` only for a move off the beta whose date is not set yet.',
376
+ }),
377
+ keep: planChangeKeep.nullable().meta({
378
+ description: 'What stays. `null` when the org already fits the target plan and nothing is deleted.',
379
+ }),
380
+ history_days_after: z.number().int().positive().meta({
381
+ description: 'Days of history and audit log the org keeps on the target plan.',
382
+ }),
383
+ chosen_by: z.uuid().meta({ description: 'The user id of the owner who chose the change, or the nil UUID when Fleetless queued it.' }),
384
+ chosen_at: z.iso.datetime().meta({ description: 'When the change was chosen.' }),
385
+ });
386
+ /**
387
+ * An org that is locked: every sign-in and token refresh except an owner's
388
+ * answers `403 org_locked`, and its bridges are refused; nothing is
389
+ * deleted. `payment` is a missing payment; `migration` is the platform's
390
+ * own move off the beta. Either way the only plan an owner may choose
391
+ * while locked is Basic —
392
+ * see `target_state_conflict` with rule `locked_basic_only` on
393
+ * `PUT /api/org/plan/change`; a choice made while locked takes effect at
394
+ * once rather than waiting for the billing period to turn over.
395
+ */
396
+ export const orgLock = z.object({
397
+ reason: z.enum(['payment', 'migration']).meta({
398
+ description: '`payment`: a payment is missing. `migration`: the org did not choose what stays when the beta ended.',
399
+ }),
400
+ since: z.iso.datetime().meta({ description: 'When the org was locked.' }),
401
+ });
402
+ /**
403
+ * **The one read everything about an org's plan comes from**: the console's
404
+ * Plan & billing page, its usage and limit gauges, every upgrade prompt and
405
+ * every gate a feature check renders. `limits` is the *effective* ceiling —
406
+ * the catalogue row plus `addons`, or an operator's `overrides` in place of
407
+ * either — so a consumer never has to recompute it from the catalogue and
408
+ * the add-on counts itself. `switch` is set only for an organization still
409
+ * on the beta and carries the date its plan changes on its own, and whether
410
+ * it still has to choose one.
411
+ */
412
+ export const orgPlan = z.object({
413
+ plan: planId.meta({ description: 'The org\'s current plan.' }),
414
+ currency: planCurrency.meta({ description: 'The currency the org\'s prices are shown and billed in.' }),
415
+ period_ends_at: z.iso.datetime().meta({
416
+ description: "The end of the organization's current billing period; while no payment period exists yet, the end of the current UTC calendar " +
417
+ 'month. A pending downward plan change normally takes effect at this exact instant — except one chosen while the org was locked, ' +
418
+ "which lands at once, and the platform's own move off the beta, which lands at its switch date instead.",
419
+ }),
420
+ addons: orgAddons.meta({ description: 'The add-ons the org has bought. All zero on a plan without the `addons` feature.' }),
421
+ limits: planLimits.meta({
422
+ description: 'The org\'s effective limits: the plan\'s, raised by its add-ons, or an operator\'s override in their place. `null` means ' +
423
+ 'unlimited for a count, and 90 days for `history_days` and `audit_days`.',
424
+ }),
425
+ features: planFeatures.meta({ description: 'What the org\'s plan unlocks.' }),
426
+ usage: orgPlanUsage.meta({ description: 'What the org uses now, counted the way each limit counts it.' }),
427
+ pending_change: pendingPlanChange.nullable().meta({ description: 'A move to a lower plan that has not taken effect yet, or `null`.' }),
428
+ lock: orgLock.nullable().meta({ description: 'Why and since when the org is locked, or `null` when it is not.' }),
429
+ switch: z
430
+ .object({
431
+ at: z.iso.datetime().nullable().meta({
432
+ description: 'When the org moves from the beta onto its plan. `null` while the date is not set.',
433
+ }),
434
+ needs_choice: z.boolean().meta({
435
+ description: 'Whether the org uses more than Basic allows, so an owner has to choose what stays before `at`.',
436
+ }),
437
+ })
438
+ .nullable()
439
+ .meta({ description: 'Set only while the org is still on the beta; `null` for every other org.' }),
440
+ });
441
+ /**
442
+ * What a developer may ask for on `PUT /api/org/plan/change` — **a downward
443
+ * move only**: a lower plan, or Basic as a cancellation. An upgrade or an
444
+ * add-on needs payment this route does not collect, and is not reachable
445
+ * through it at all today; it goes through a Feedback request that Fleetless
446
+ * applies through the admin route instead. `.strict()` for the same reason
447
+ * `planChangeKeep` is: a caller cannot send a field this shape does not
448
+ * name, `keep` included, and an owner cannot be smuggled into it through a
449
+ * typo that `.strict()` would otherwise swallow in silence.
450
+ */
451
+ export const planChangeRequest = z.object({
452
+ target_plan: planId.meta({ description: 'The lower plan to move to; `basic` cancels.' }),
453
+ keep: planChangeKeep.nullable().meta({
454
+ description: 'What stays. `null` when the org already fits the target plan, so nothing is deleted.',
455
+ }),
456
+ }).strict();
457
+ /**
458
+ * An operator's per-org replacement for one or more catalogue limits —
459
+ * Enterprise's contracted numbers, most of all, which exist nowhere else.
460
+ * Every key is optional, so an operator sets only what differs from the
461
+ * plan; a key present with `null` clears an earlier override back to the
462
+ * plan's own number, which is why every value is nullable as well as
463
+ * optional — omitted and `null` are two different instructions.
464
+ */
465
+ export const planOverrides = z.object(Object.fromEntries(planLimitKey.options.map((k) => [
466
+ k,
467
+ limit.optional().meta({ description: `Replaces the plan's \`${k}\`. \`null\` clears the override.` }),
468
+ ]))).strict();
469
+ /**
470
+ * What the operator sends on `PATCH /api/admin/orgs/:id/plan`. `plan` is
471
+ * required: every request names the plan the org ends up on, even when only
472
+ * an add-on, an override, the currency or the period end changes. Every
473
+ * other field is optional, so one route carries every shape of change an
474
+ * operator makes to an org's plan. `addons` is a
475
+ * partial `orgAddons`: only the counts named change, the rest are left as
476
+ * they are. `overrides` follows `planOverrides`: a key present with `null`
477
+ * clears that override.
478
+ */
479
+ export const adminPlanChangeRequest = z.object({
480
+ plan: planId.meta({ description: 'The plan the org is on after this request.' }),
481
+ addons: orgAddons.partial().strict().optional().meta({ description: 'Add-on counts to set. Counts not named stay as they are.' }),
482
+ overrides: planOverrides.optional().meta({ description: 'Limits to override. `null` clears an override.' }),
483
+ currency: planCurrency.optional().meta({ description: 'The currency the org is billed in.' }),
484
+ period_ends_at: z.iso.datetime().nullable().optional().meta({
485
+ description: 'The end of the org\'s billing period. `null` falls back to the end of the current UTC month.',
486
+ }),
487
+ }).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,64 @@ 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, never queued as a `pending_change`, ' +
2844
+ 'and only when the org\'s current usage fits the result: `409 plan_limit` when a lower plan, a lowered override or a removed add-on ' +
2845
+ 'would leave the org over a limit — the operator never deletes an org\'s things, only the owner\'s own choice does. On success it ' +
2846
+ 'also withdraws any `pending_change`, ends a beta org\'s wait for the switch and lifts a lock. Audited as `org.plan_changed` with ' +
2847
+ 'the `fleetless` actor, never a developer\'s — the row names what an operator did, not who in the org asked for it.',
2848
+ },
2790
2849
  /* ------------------------------------------------- assets (robot upload) */
2791
2850
  {
2792
2851
  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",
3
+ "version": "6.1.0-next.2",
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",