@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.
- package/CHANGELOG.md +42 -0
- package/artifacts/openapi.json +636 -1
- package/artifacts/routes.json +101 -0
- package/artifacts/schema/addon-catalogue-entry.schema.json +75 -0
- package/artifacts/schema/addon-key.schema.json +11 -0
- package/artifacts/schema/admin-plan-change-request.schema.json +171 -0
- package/artifacts/schema/asset-plan-limit-details.schema.json +108 -0
- package/artifacts/schema/audit-actor.schema.json +2 -1
- package/artifacts/schema/audit-event.schema.json +2 -1
- package/artifacts/schema/audit-list-response.schema.json +2 -1
- package/artifacts/schema/org-addons.schema.json +39 -0
- package/artifacts/schema/org-lock.schema.json +23 -0
- package/artifacts/schema/org-locked-details.schema.json +17 -0
- package/artifacts/schema/org-plan-usage.schema.json +45 -0
- package/artifacts/schema/org-plan.schema.json +450 -0
- package/artifacts/schema/pending-plan-change.schema.json +112 -0
- package/artifacts/schema/plan-catalogue-entry.schema.json +226 -0
- package/artifacts/schema/plan-change-keep.schema.json +45 -0
- package/artifacts/schema/plan-change-reason.schema.json +10 -0
- package/artifacts/schema/plan-change-request.schema.json +71 -0
- package/artifacts/schema/plan-currency.schema.json +8 -0
- package/artifacts/schema/plan-features.schema.json +37 -0
- package/artifacts/schema/plan-id.schema.json +10 -0
- package/artifacts/schema/plan-limit-details.schema.json +87 -0
- package/artifacts/schema/plan-limits.schema.json +113 -0
- package/artifacts/schema/plan-overrides.schema.json +103 -0
- package/artifacts/schema/plan-prices.schema.json +33 -0
- package/artifacts/schema/plan-required-details.schema.json +42 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +8 -1
- package/dist/errors.d.ts +138 -1
- package/dist/errors.js +94 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +11 -1
- package/dist/plans.d.ts +525 -0
- package/dist/plans.js +442 -0
- package/dist/realtime.d.ts +2 -0
- package/dist/realtime.js +6 -0
- package/dist/routes.d.ts +5 -1
- package/dist/routes.js +58 -0
- 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();
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|