@fleetless/sdk 4.2.0 → 4.4.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/dist/index.cjs CHANGED
@@ -275,7 +275,7 @@ function isRenderKind(kind) {
275
275
  }
276
276
  }
277
277
  }
278
- async function forEachWithConcurrency(items, limit, fn) {
278
+ async function forEachWithConcurrency(items, limit2, fn) {
279
279
  let next = 0;
280
280
  let failed = false;
281
281
  let firstError;
@@ -293,7 +293,7 @@ async function forEachWithConcurrency(items, limit, fn) {
293
293
  }
294
294
  }
295
295
  }
296
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
296
+ await Promise.all(Array.from({ length: Math.min(limit2, items.length) }, () => worker()));
297
297
  if (failed) throw firstError;
298
298
  }
299
299
  function createAssetsApi(http) {
@@ -442,7 +442,7 @@ function createAssetsApi(http) {
442
442
  };
443
443
  }
444
444
 
445
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/common.js
445
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/common.js
446
446
  var import_zod = require("zod");
447
447
  var SLUG_RULE = "A name is lower-case: it starts with a letter, continues with letters and digits, and joins further words with a single underscore \u2014 `battery_voltage`. Capitals, dashes, dots, spaces, a leading digit and a doubled or trailing underscore are all refused.";
448
448
  var slug = import_zod.z.string().min(2).max(63).regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/, SLUG_RULE);
@@ -473,7 +473,7 @@ var applyError = import_zod.z.object({
473
473
  details: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
474
474
  });
475
475
 
476
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/mcp.js
476
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/mcp.js
477
477
  var import_zod2 = require("zod");
478
478
  function mcpAppEndpointPath(appIdentifier2) {
479
479
  return `/mcp/${appIdentifier2}`;
@@ -522,10 +522,10 @@ var mcpRolePreviewResponse = import_zod2.z.object({
522
522
  });
523
523
  var MCP_ASSET_LINK_TTL_MS = 15 * 60 * 1e3;
524
524
 
525
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/protocol.js
525
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/protocol.js
526
526
  var import_zod8 = require("zod");
527
527
 
528
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/assets.js
528
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/assets.js
529
529
  var import_zod3 = require("zod");
530
530
  var assetKind = import_zod3.z.enum(["urdf", "mesh", "texture"]);
531
531
  var asset = import_zod3.z.object({
@@ -845,10 +845,10 @@ var assetSyncBusyDetails = import_zod3.z.object({
845
845
  started_at_ms: import_zod3.z.number().int().nonnegative()
846
846
  });
847
847
 
848
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/config.js
848
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/config.js
849
849
  var import_zod5 = require("zod");
850
850
 
851
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/alerts.js
851
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/alerts.js
852
852
  var import_zod4 = require("zod");
853
853
  var alertRowCondition = import_zod4.z.discriminatedUnion("kind", [
854
854
  import_zod4.z.strictObject({
@@ -918,7 +918,7 @@ var putDatapointDisplayRequest = import_zod4.z.object({
918
918
  y_max: import_zod4.z.number().finite().nullable()
919
919
  }).strict();
920
920
 
921
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/config.js
921
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/config.js
922
922
  var RTSP_URL_RULE = "The URL has to begin with `rtsp://` or `rtsps://` \u2014 `rtsp://cam-1.plant.local/stream1`. No other scheme is accepted: the bridge opens this with a library that would equally honour `file:`.";
923
923
  var MJPEG_URL_RULE = "The URL has to begin with `http://` or `https://` \u2014 `http://cam-1.plant.local/video.mjpg`. No other scheme is accepted: the bridge opens this with a library that would equally serve `file:`.";
924
924
  var DEVICE_PATH_RULE = "A capture device is a path under `/dev/`, and the character straight after it is a letter or a digit \u2014 `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Nothing outside `/dev/` is accepted: the string reaches OpenCV, which would as happily open an ordinary file.";
@@ -2010,7 +2010,7 @@ var configState = import_zod5.z.object({
2010
2010
  applied_errors: import_zod5.z.array(applyError).nullable()
2011
2011
  });
2012
2012
 
2013
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/introspection.js
2013
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/introspection.js
2014
2014
  var import_zod6 = require("zod");
2015
2015
  var rosGraphEntry = import_zod6.z.object({
2016
2016
  name: rosName,
@@ -2049,7 +2049,7 @@ var typeDefinition = import_zod6.z.discriminatedUnion("kind", [
2049
2049
  })
2050
2050
  ]);
2051
2051
 
2052
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/jobs.js
2052
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/jobs.js
2053
2053
  var import_zod7 = require("zod");
2054
2054
  var jobState = import_zod7.z.enum(["running", "unknown", "succeeded", "failed", "cancelled", "lost"]);
2055
2055
  var reportedJobState = jobState.exclude(["unknown"]);
@@ -2164,6 +2164,20 @@ var jobActor = import_zod7.z.object({
2164
2164
  */
2165
2165
  label: import_zod7.z.string().min(1).max(200).meta({
2166
2166
  description: "A display name taken at invoke time \u2014 the email for a Fleetless user or an app user, the key's own name for a server key. Storing it rather than joining is the point: renaming a key afterwards does not rewrite history."
2167
+ }),
2168
+ /**
2169
+ * The person's display name, snapshotted beside `label` for the same
2170
+ * reason. **Required and nullable**, so a consumer never has to tell
2171
+ * "absent" from "null": every run a 5.3.0 cloud answers carries it, and
2172
+ * `null` means there is no name to show — a server key, a person without
2173
+ * one, or a run recorded before the field existed.
2174
+ *
2175
+ * `jobActor` stays a plain object, not `.strict()`: a consumer still on an
2176
+ * older contracts version then parses a newer cloud's answer by stripping
2177
+ * the key instead of refusing the whole run.
2178
+ */
2179
+ name: import_zod7.z.string().min(1).max(200).nullable().meta({
2180
+ description: "The person's display name when the job started: the Fleetless user's `display_name` for a developer, the app user's `display_name` for an app user. `null` for a server key, when the person had no name set, and for runs recorded before contracts 5.3.0. Show `label` when it is null."
2167
2181
  })
2168
2182
  });
2169
2183
  var jobRunKind = import_zod7.z.enum(["action", "service"]);
@@ -2272,7 +2286,7 @@ var jobRunSummary = import_zod7.z.object({
2272
2286
  since_ms: import_zod7.z.number().int().nonnegative()
2273
2287
  });
2274
2288
 
2275
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/protocol.js
2289
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/protocol.js
2276
2290
  var DAY_MS = 24 * 60 * 60 * 1e3;
2277
2291
  var MAX_PATIENCE_MS = 12e4;
2278
2292
  var MIN_PATIENCE_MS = 1e3;
@@ -2405,7 +2419,8 @@ var cloudCancel = import_zod8.z.object({
2405
2419
  type: import_zod8.z.literal("cancel"),
2406
2420
  request_id: import_zod8.z.string().min(1).max(64),
2407
2421
  slug,
2408
- job_id: import_zod8.z.uuid().nullable()
2422
+ job_id: import_zod8.z.uuid().nullable(),
2423
+ own_only: import_zod8.z.boolean().optional()
2409
2424
  });
2410
2425
  var CANCEL_RETURN_CODES = { none: 0, rejected: 1, unknown_goal_id: 2, goal_terminated: 3 };
2411
2426
  var cancelReturnCode = import_zod8.z.number().int().min(0).max(3);
@@ -2669,11 +2684,11 @@ var bridgeCameraState = import_zod8.z.object({
2669
2684
  request_id: import_zod8.z.string().min(1).max(64).nullable()
2670
2685
  });
2671
2686
 
2672
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/config-issues.js
2687
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/config-issues.js
2673
2688
  var EXPOSURE_SECTIONS = ["datapoints", "actions", "services", "publishers", "cameras"];
2674
2689
  var EXPOSURE_SECTION_NAMES = new Set(EXPOSURE_SECTIONS);
2675
2690
 
2676
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/rest.js
2691
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/rest.js
2677
2692
  var import_zod9 = require("zod");
2678
2693
  var robot = import_zod9.z.object({
2679
2694
  id: import_zod9.z.uuid().meta({
@@ -3438,13 +3453,13 @@ var slugUsageResponse = import_zod9.z.object({
3438
3453
  alert_count: import_zod9.z.number().int().nonnegative()
3439
3454
  });
3440
3455
 
3441
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/realtime.js
3456
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/realtime.js
3442
3457
  var import_zod14 = require("zod");
3443
3458
 
3444
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/client-auth.js
3459
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/client-auth.js
3445
3460
  var import_zod13 = require("zod");
3446
3461
 
3447
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/apps.js
3462
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/apps.js
3448
3463
  var import_zod10 = require("zod");
3449
3464
  var appIdentifier = slug;
3450
3465
  var app = import_zod10.z.object({
@@ -3585,7 +3600,7 @@ var role = import_zod10.z.object({
3585
3600
  description: "The role's name, shown wherever a user's access is chosen. The two roles every app starts with are named `observe` and `operate`."
3586
3601
  }),
3587
3602
  builtin: import_zod10.z.boolean().meta({
3588
- description: "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` \u2014 the flag exists so the console can explain where they came from, not to protect them. It does not make them renamable or deletable \u2014 no route does that for any role."
3603
+ description: "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` \u2014 the flag exists so the console can explain where they came from, not to protect them. Built-in roles can be renamed and deleted like any other; the flag only records that the cloud seeded them."
3589
3604
  })
3590
3605
  });
3591
3606
  var roleListResponse = import_zod10.z.object({
@@ -3593,6 +3608,27 @@ var roleListResponse = import_zod10.z.object({
3593
3608
  description: "The app's roles, built-in and custom alike, ordered by `created_at` and then by `name`. The tie-break is not cosmetic \u2014 the two built-in roles are inserted in one statement and share a creation time to the microsecond, so never read a role by position."
3594
3609
  })
3595
3610
  });
3611
+ var roleRenameRequest = import_zod10.z.object({
3612
+ name: import_zod10.z.string().trim().min(1).max(60).meta({
3613
+ description: "The new name, trimmed, 1 to 60 characters. Unique per app: another role of this app with the same name answers `409 role_name_taken`. The role's users keep it under its new name."
3614
+ })
3615
+ }).strict();
3616
+ var roleDeleteQuery = import_zod10.z.object({
3617
+ move_to: import_zod10.z.uuid().optional().meta({
3618
+ description: "Another role of the same app that takes over the deleted role's app users, pending invitations and, when it applies, the app's default. The role itself or a role of another app answers `400 validation_error`."
3619
+ })
3620
+ }).strict();
3621
+ var roleInUseDetails = import_zod10.z.object({
3622
+ users: import_zod10.z.number().int().nonnegative().meta({
3623
+ description: "App users whose role this is."
3624
+ }),
3625
+ invitations: import_zod10.z.number().int().nonnegative().meta({
3626
+ description: "Pending invitations that would grant this role when accepted."
3627
+ }),
3628
+ is_default: import_zod10.z.boolean().meta({
3629
+ description: "`true` when this is the app's `default_role_id`; the default then moves with the users to `move_to`."
3630
+ })
3631
+ }).strict();
3596
3632
  var rolePermissions = import_zod10.z.object({
3597
3633
  role_id: import_zod10.z.uuid(),
3598
3634
  /**
@@ -3646,10 +3682,10 @@ var rolePermissions = import_zod10.z.object({
3646
3682
  })
3647
3683
  });
3648
3684
 
3649
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/app-users.js
3685
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/app-users.js
3650
3686
  var import_zod12 = require("zod");
3651
3687
 
3652
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/identity.js
3688
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/identity.js
3653
3689
  var import_zod11 = require("zod");
3654
3690
  var password = import_zod11.z.string().min(12).max(256);
3655
3691
  var USER_DISPLAY_NAME_MAX = 120;
@@ -3661,6 +3697,9 @@ var org = import_zod11.z.object({
3661
3697
  name: import_zod11.z.string().min(1).max(120).meta({
3662
3698
  description: "The organisation's display name. Free text, changed through `PATCH /api/org`."
3663
3699
  }),
3700
+ require_two_factor: import_zod11.z.boolean().meta({
3701
+ description: "Whether every member must have a second factor \u2014 a passkey or an authenticator app. A member without one sets it up at their next sign-in, before any session exists; nobody is signed out when it is switched on. It covers the console and the central MCP endpoint; server keys and robot bridges are not people and are not affected. Owners change it through `PATCH /api/org`."
3702
+ }),
3664
3703
  created_at: import_zod11.z.iso.datetime().meta({
3665
3704
  description: "When the organisation was created, as an ISO 8601 timestamp."
3666
3705
  })
@@ -3686,6 +3725,12 @@ var fleetlessUser = import_zod11.z.object({
3686
3725
  tier: orgAdminTier.meta({
3687
3726
  description: "The console powers this person holds. **Required** \u2014 every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
3688
3727
  }),
3728
+ two_factor: import_zod11.z.object({
3729
+ passkeys: import_zod11.z.number().int().min(0).meta({ description: "How many passkeys the person has registered." }),
3730
+ authenticator: import_zod11.z.boolean().meta({ description: "Whether the person has a confirmed authenticator app." })
3731
+ }).meta({
3732
+ description: "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
3733
+ }),
3689
3734
  created_at: import_zod11.z.iso.datetime().meta({
3690
3735
  description: "When the account was created, as an ISO 8601 timestamp."
3691
3736
  })
@@ -3707,21 +3752,19 @@ var sessionTokens = import_zod11.z.object({
3707
3752
  })
3708
3753
  });
3709
3754
  var refreshRequest = import_zod11.z.object({ refresh_token: import_zod11.z.string().min(1) });
3710
- var signUpRequest = import_zod11.z.object({
3711
- org_name: import_zod11.z.string().min(1).max(120),
3712
- email: import_zod11.z.email(),
3713
- password
3714
- });
3715
- var signUpResponse = import_zod11.z.object({
3716
- org,
3717
- user: fleetlessUser,
3718
- tokens: sessionTokens
3755
+ var loginCode = import_zod11.z.string().regex(/^\d{6}$/, "must be exactly six digits");
3756
+ var totpCode = loginCode;
3757
+ var recoveryCode = import_zod11.z.string().regex(/^[a-zA-Z2-7]{5}-[a-zA-Z2-7]{5}$/, "must be two groups of five characters, xxxxx-xxxxx");
3758
+ var recoveryCodesList = import_zod11.z.array(import_zod11.z.string().regex(/^[a-z2-7]{5}-[a-z2-7]{5}$/)).length(10);
3759
+ var twoFactorSetupResponse = import_zod11.z.object({
3760
+ secret: import_zod11.z.string().min(1).meta({
3761
+ description: "The shared secret, base32, for an authenticator app that cannot scan a QR code. Shown once; the cloud stores it encrypted."
3762
+ }),
3763
+ otpauth_url: import_zod11.z.string().startsWith("otpauth://totp/").meta({
3764
+ description: "The same secret as an `otpauth://totp/` URL, to render as a QR code. It carries the secret: never log it."
3765
+ })
3719
3766
  });
3720
3767
  var waitlistRequest = import_zod11.z.object({ email: import_zod11.z.email().max(254) });
3721
- var developerLoginRequest = import_zod11.z.object({
3722
- email: import_zod11.z.email(),
3723
- password: import_zod11.z.string().min(1)
3724
- });
3725
3768
  var mailStatus = import_zod11.z.enum(["sent", "not_requested", "not_configured", "failed"]);
3726
3769
  var createTeamInviteRequest = import_zod11.z.object({
3727
3770
  email: import_zod11.z.email().meta({
@@ -3762,8 +3805,8 @@ var acceptTeamInviteRequest = import_zod11.z.object({
3762
3805
  token: import_zod11.z.string().min(1).meta({
3763
3806
  description: "The opaque invitation token from the link. Unknown, expired and already-accepted all collapse into `410 token_spent` \u2014 telling them apart would say whether a token ever existed."
3764
3807
  }),
3765
- password: password.meta({
3766
- description: "The password the new Fleetless account will use. At least 12 characters."
3808
+ display_name: import_zod11.z.string().min(1).max(USER_DISPLAY_NAME_MAX).nullable().optional().meta({
3809
+ description: "An optional name, overriding whatever the invitation pre-filled. Absent keeps it."
3767
3810
  })
3768
3811
  }).strict();
3769
3812
  var patchFleetlessUserRequest = import_zod11.z.object({
@@ -3785,13 +3828,6 @@ var passwordChangeRequest = import_zod11.z.object({
3785
3828
  description: "The replacement password. Every other session is revoked when it is accepted, while the session that made the change survives \u2014 logging somebody out of the tab they just used is indistinguishable from the change having failed."
3786
3829
  })
3787
3830
  });
3788
- var passwordResetRequest = import_zod11.z.object({
3789
- email: import_zod11.z.email()
3790
- });
3791
- var passwordResetConfirm = import_zod11.z.object({
3792
- token: import_zod11.z.string().min(1),
3793
- new_password: password
3794
- });
3795
3831
  var idpIssuer = import_zod11.z.url().max(500).refine((v) => {
3796
3832
  let url;
3797
3833
  try {
@@ -3808,10 +3844,66 @@ var idpIssuer = import_zod11.z.url().max(500).refine((v) => {
3808
3844
  return url.hostname.length > 0;
3809
3845
  }, { message: "issuer must be an http(s) URL with no credentials, query or fragment" });
3810
3846
  var authMeResponse = import_zod11.z.object({ org, user: fleetlessUser });
3811
- var patchOrgRequest = import_zod11.z.object({ name: import_zod11.z.string().min(1).max(120) }).strict();
3847
+ var patchOrgRequest = import_zod11.z.object({
3848
+ name: import_zod11.z.string().min(1).max(120).optional().meta({
3849
+ description: "The organisation's new display name. Absent leaves it alone."
3850
+ }),
3851
+ require_two_factor: import_zod11.z.boolean().optional().meta({
3852
+ description: "Whether every member must have a second factor. Turning it on signs nobody out: each member without one sets it up at their next sign-in. Absent leaves it alone."
3853
+ })
3854
+ }).strict().refine((b) => b.name !== void 0 || b.require_two_factor !== void 0, { message: "Send name, require_two_factor, or both." });
3812
3855
  var patchAuthMeRequest = import_zod11.z.object({ display_name: import_zod11.z.string().min(1).max(USER_DISPLAY_NAME_MAX).nullable() }).strict();
3856
+ var webauthnJson = import_zod11.z.record(import_zod11.z.string(), import_zod11.z.unknown());
3857
+ var webauthnOptionsResponse = import_zod11.z.object({
3858
+ options: webauthnJson.meta({
3859
+ description: "The `PublicKeyCredentialCreationOptionsJSON` or `PublicKeyCredentialRequestOptionsJSON` to pass to the browser. Its challenge is single-use and short-lived."
3860
+ })
3861
+ });
3862
+ var developerPasskey = import_zod11.z.object({
3863
+ id: import_zod11.z.uuid().meta({ description: "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`." }),
3864
+ name: import_zod11.z.string().min(1).max(80).meta({ description: "What the person called it, such as the device it lives on." }),
3865
+ created_at: import_zod11.z.iso.datetime().meta({ description: "When it was registered." }),
3866
+ last_used_at: import_zod11.z.iso.datetime().nullable().meta({ description: "When it last signed the person in or confirmed a sign-in, or `null` if never." }),
3867
+ synced: import_zod11.z.boolean().nullable().meta({
3868
+ description: "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
3869
+ })
3870
+ });
3871
+ var developerTwoFactor = import_zod11.z.object({
3872
+ passkeys: import_zod11.z.array(developerPasskey).meta({ description: "Every passkey the caller has registered, oldest first. Empty when none." }),
3873
+ authenticator: import_zod11.z.object({ created_at: import_zod11.z.iso.datetime().meta({ description: "When the authenticator was confirmed." }) }).nullable().meta({ description: "The confirmed authenticator app, or `null` when there is none. At most one." }),
3874
+ recovery_codes_left: import_zod11.z.number().int().min(0).max(10).meta({
3875
+ description: "How many of the ten recovery codes are unspent. `0` while the caller has no second factor."
3876
+ }),
3877
+ required_by_org: import_zod11.z.boolean().meta({
3878
+ description: "Whether the organisation requires a second factor. While it does, the last one cannot be removed."
3879
+ })
3880
+ });
3881
+ var createPasskeyRequest = import_zod11.z.object({
3882
+ name: import_zod11.z.string().min(1).max(80).meta({ description: "What to call the passkey, such as the device it lives on." }),
3883
+ credential: webauthnJson.meta({ description: "The browser's `RegistrationResponseJSON` for the options `POST /api/auth/passkeys/options` answered." })
3884
+ }).strict();
3885
+ var createPasskeyResponse = import_zod11.z.object({
3886
+ passkey: developerPasskey.meta({ description: "The passkey as it is now stored." }),
3887
+ recovery_codes: recoveryCodesList.nullable().meta({
3888
+ description: "The ten recovery codes, shown once, when this passkey is the account's first second factor; `null` when the account already had one and its codes stay valid."
3889
+ })
3890
+ });
3891
+ var renamePasskeyRequest = import_zod11.z.object({
3892
+ name: import_zod11.z.string().min(1).max(80).meta({ description: "The new name." })
3893
+ }).strict();
3894
+ var totpConfirmRequest = import_zod11.z.object({
3895
+ code: totpCode.meta({ description: "A code the new authenticator shows now. It proves the secret was copied correctly before anything depends on it." })
3896
+ }).strict();
3897
+ var totpConfirmResponse = import_zod11.z.object({
3898
+ recovery_codes: recoveryCodesList.nullable().meta({
3899
+ description: "The ten recovery codes, shown once, when this is the account's first second factor; `null` otherwise."
3900
+ })
3901
+ });
3902
+ var recoveryCodesResponse = import_zod11.z.object({
3903
+ recovery_codes: recoveryCodesList.meta({ description: "The ten new recovery codes, lowercase, shown once. Every earlier code is void." })
3904
+ });
3813
3905
 
3814
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/app-users.js
3906
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/app-users.js
3815
3907
  var APP_USER_DISPLAY_NAME_MAX = 120;
3816
3908
  var providerSlug = import_zod12.z.string().max(40).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, "must be lowercase and hyphen-separated, starting with a letter");
3817
3909
  var appUserStatus = import_zod12.z.enum(["pending_verification", "active", "blocked"]);
@@ -3849,6 +3941,19 @@ var appUser = import_zod12.z.object({
3849
3941
  last_login_at: import_zod12.z.iso.datetime().nullable().meta({
3850
3942
  description: "When this user last signed in, or `null` if they never have. Required and nullable rather than optional, so *never logged in* stays distinguishable from *this field was not loaded*."
3851
3943
  }),
3944
+ two_factor: import_zod12.z.object({
3945
+ enabled: import_zod12.z.boolean().meta({
3946
+ description: "Whether the account has a confirmed authenticator app (TOTP). When it has, every sign-in that yields a session asks for a code as well \u2014 whatever the app's policy \u2014 except a sign-in through an identity provider, which owns that sign-in."
3947
+ }),
3948
+ enabled_at: import_zod12.z.iso.datetime().nullable().meta({
3949
+ description: "When the authenticator was confirmed, or `null` while `enabled` is `false`."
3950
+ }),
3951
+ recovery_codes_left: import_zod12.z.number().int().min(0).max(10).meta({
3952
+ description: "How many of the ten single-use recovery codes are still unspent. `0` while `enabled` is `false`."
3953
+ })
3954
+ }).meta({
3955
+ description: "The account's second factor, as a developer's user list shows it. No secret and no code travels here; resetting it is `DELETE /api/apps/:id/users/:userId/two-factor`."
3956
+ }),
3852
3957
  created_at: import_zod12.z.iso.datetime().meta({
3853
3958
  description: "When the account was created, as an ISO 8601 timestamp."
3854
3959
  })
@@ -3894,7 +3999,7 @@ var createAppInvitationRequest = import_zod12.z.object({
3894
3999
  description: "An optional name to pre-fill the account with; the invitee can change it afterwards."
3895
4000
  }),
3896
4001
  send_mail: import_zod12.z.boolean().meta({
3897
- description: "Whether Fleetless mails the invitation. **Refused with `409 target_state_conflict` naming `invite_url` when the app has configured none** \u2014 there would be nowhere for the link to point, and a mail carrying a Fleetless-hosted page is a surface this product does not have."
4002
+ description: "Whether Fleetless mails the invitation. The link points at the app's `invite_url`, or at the Fleetless-hosted invitation page when the app has configured none \u2014 so the mail always leads somewhere, and nothing is refused for a missing URL."
3898
4003
  })
3899
4004
  }).strict();
3900
4005
  var appInvitation = import_zod12.z.object({
@@ -3903,11 +4008,11 @@ var appInvitation = import_zod12.z.object({
3903
4008
  email: import_zod12.z.email().meta({ description: "The address the invitation was addressed to." }),
3904
4009
  role_id: import_zod12.z.uuid().meta({ description: "The role the invitee holds once they accept. Resolved at creation, so a later change to the app's default role does not silently re-aim an outstanding invitation." }),
3905
4010
  expires_at: import_zod12.z.iso.datetime().meta({ description: "When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does." }),
3906
- accept_url: import_zod12.z.url().max(500).nullable().meta({
3907
- description: "The link to give the invitee, built from the app's `invite_url` with the token substituted for `{token}`. **`null` when the app has configured no `invite_url`** \u2014 there is nowhere for the link to point, and Fleetless serves no page of its own for an app user. Bounded like every other URL that gets mailed, logged and rendered."
4011
+ accept_url: import_zod12.z.url().max(500).meta({
4012
+ description: "The link to give the invitee: the app's `invite_url` with the token substituted for `{token}`, or the Fleetless-hosted invitation page when the app has configured none. Bounded like every other URL that gets mailed, logged and rendered."
3908
4013
  }),
3909
4014
  mail: mailStatus.meta({
3910
- description: "What happened to the mail: `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted \u2014 the caller asked for none, or the app has no `invite_url` for a link to point at; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody's attention."
4015
+ description: "What happened to the mail: `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted because the caller asked for none; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody's attention."
3911
4016
  })
3912
4017
  });
3913
4018
  var pendingAppInvitation = appInvitation.omit({ accept_url: true, mail: true });
@@ -4000,6 +4105,31 @@ var allowedOrigin = import_zod12.z.string().max(200).refine((v) => {
4000
4105
  }
4001
4106
  }, { message: "must be a bare origin (scheme, host, port) over https, or over http on localhost" });
4002
4107
  var emailDomain = import_zod12.z.string().min(1).max(253).regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/, "must be a lowercase domain name with at least two labels");
4108
+ var appSignInMethods = import_zod12.z.object({
4109
+ password: import_zod12.z.boolean().meta({
4110
+ description: "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
4111
+ }),
4112
+ email_code: import_zod12.z.boolean().meta({
4113
+ description: "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
4114
+ })
4115
+ }).strict().refine((m) => m.password || m.email_code, { message: "At least one sign-in method must be on.", path: ["password"] });
4116
+ var appTwoFactorPolicy = import_zod12.z.enum(["off", "optional", "required"]);
4117
+ var appHomeUrl = import_zod12.z.url().max(500).refine((v) => {
4118
+ try {
4119
+ const u = new URL(v);
4120
+ const hostOk = u.protocol === "https:" || u.protocol === "http:" && ["localhost", "127.0.0.1"].includes(u.hostname);
4121
+ return hostOk && !v.includes("{");
4122
+ } catch {
4123
+ return false;
4124
+ }
4125
+ }, { message: "An https URL (http only on localhost) without a placeholder." });
4126
+ var hostedAccent = import_zod12.z.string().regex(/^#[0-9a-f]{6}$/, "must be a lowercase hex colour, #rrggbb");
4127
+ var appHostedPages = import_zod12.z.object({
4128
+ invite_url: import_zod12.z.url().meta({ description: "The hosted invitation page, `<portal>/app/<identifier>/invite/{token}`." }),
4129
+ verify_url: import_zod12.z.url().meta({ description: "The hosted email-confirmation page, `<portal>/app/<identifier>/verify/{token}`." }),
4130
+ reset_url: import_zod12.z.url().meta({ description: "The hosted new-password page, `<portal>/app/<identifier>/reset/{token}`." }),
4131
+ mcp_login_url: import_zod12.z.url().meta({ description: "The hosted MCP sign-in, `<portal>/app/<identifier>/mcp/{interaction}`." })
4132
+ });
4003
4133
  var appAuthConfig = import_zod12.z.object({
4004
4134
  self_registration: import_zod12.z.boolean().meta({
4005
4135
  description: "Whether a stranger may create an account in this app. Off refuses `POST /api/client/register` with `403 registration_closed`, and refuses an unknown identity at an OIDC callback with the same reasoning \u2014 one switch for one decision, whichever door the person arrives at."
@@ -4014,16 +4144,34 @@ var appAuthConfig = import_zod12.z.object({
4014
4144
  description: "Whether this app serves an MCP endpoint at `/mcp/<identifier>`. Off refuses the whole OAuth surface for the app, not merely the tool calls, and is re-read on every request rather than cached off a token."
4015
4145
  }),
4016
4146
  invite_url: appUrlTemplate("{token}").nullable().meta({
4017
- description: "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` when unconfigured, and then an invitation still issues but `send_mail` is refused with `409 target_state_conflict` \u2014 there would be nowhere for the link to point."
4147
+ description: "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
4018
4148
  }),
4019
4149
  verify_url: appUrlTemplate("{token}").nullable().meta({
4020
- description: "The page that confirms a new address, with `{token}` where the token goes. Self-registration needs it: without a page to send people to, a registration would leave an account nobody can activate."
4150
+ description: "The page that confirms a new address, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
4021
4151
  }),
4022
4152
  reset_url: appUrlTemplate("{token}").nullable().meta({
4023
- description: "The page that takes a new password, with `{token}` where the token goes."
4153
+ description: "The page that takes a new password, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
4024
4154
  }),
4025
4155
  mcp_login_url: appUrlTemplate("{interaction}").nullable().meta({
4026
- description: "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it."
4156
+ description: "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it. `null` means the hosted MCP sign-in in `hosted_pages` is used."
4157
+ }),
4158
+ app_url: appHomeUrl.nullable().meta({
4159
+ description: "The app's own home page, linked as `Open <app>` when a hosted flow is done. `null` makes the hosted done page say `You can close this tab`."
4160
+ }),
4161
+ sign_in_methods: appSignInMethods.meta({
4162
+ description: "Which sign-in methods the app offers: password, emailed code, or both \u2014 at least one. Identity providers stay on top of either. The default is password only."
4163
+ }),
4164
+ two_factor: appTwoFactorPolicy.meta({
4165
+ description: "Whether the app asks for an authenticator code: `off` (the default), `optional` or `required`. A person with a confirmed authenticator is asked at every sign-in whatever the policy; a sign-in through an identity provider is never asked."
4166
+ }),
4167
+ hosted_logo_url: import_zod12.z.url().nullable().meta({
4168
+ description: "Where the hosted pages load the app's logo from, `<portal>/app/<identifier>/logo`, or `null` when no logo is stored. **Read-only** \u2014 the logo is written through `PUT /api/apps/:id/auth-config/logo`."
4169
+ }),
4170
+ hosted_accent: hostedAccent.nullable().meta({
4171
+ description: "The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell's own."
4172
+ }),
4173
+ hosted_pages: appHostedPages.meta({
4174
+ description: "The Fleetless-hosted pages an unset URL falls back to, as templates. **Read-only**: minted by the cloud from the auth portal and the app's identifier."
4027
4175
  }),
4028
4176
  oidc_callback_url: import_zod12.z.url().meta({
4029
4177
  description: "The one callback URL to register at every identity provider, the same for every app and every provider. **Read-only** \u2014 it is minted by the cloud from its own public base URL, and a writable version of this field would let a caller point the return leg, which carries an authorization code, at a host they own."
@@ -4031,18 +4179,20 @@ var appAuthConfig = import_zod12.z.object({
4031
4179
  updated_at: import_zod12.z.iso.datetime().meta({ description: "When the configuration was last written, as an ISO 8601 timestamp." })
4032
4180
  });
4033
4181
  var putAppAuthRegistrationRequest = appAuthConfig.pick({ self_registration: true, allowed_domains: true, allowed_origins: true }).strict();
4034
- var putAppAuthUrlsRequest = appAuthConfig.pick({ invite_url: true, verify_url: true, reset_url: true }).strict();
4035
- var putAppAuthMcpRequest = appAuthConfig.pick({ mcp_enabled: true, mcp_login_url: true }).strict();
4036
- var mailTemplateKind = import_zod12.z.enum(["invite", "verify", "reset"]);
4182
+ var putAppAuthSignInRequest = appAuthConfig.pick({ sign_in_methods: true, two_factor: true }).strict();
4183
+ var putAppAuthUrlsRequest = appAuthConfig.pick({ app_url: true, invite_url: true, verify_url: true, reset_url: true, mcp_login_url: true }).strict();
4184
+ var putAppAuthMcpRequest = appAuthConfig.pick({ mcp_enabled: true }).strict();
4185
+ var putAppAuthLookRequest = appAuthConfig.pick({ hosted_accent: true }).strict();
4186
+ var mailTemplateKind = import_zod12.z.enum(["invite", "verify", "reset", "login_code"]);
4037
4187
  var appMailTemplate = import_zod12.z.object({
4038
- kind: mailTemplateKind.meta({ description: "Which of the three mails this template replaces." }),
4188
+ kind: mailTemplateKind.meta({ description: "Which of the four mails this template replaces." }),
4039
4189
  subject: import_zod12.z.string().min(1).max(200).meta({ description: "The subject line, a Liquid template. Bounded because a subject is rendered into a header." }),
4040
4190
  text: import_zod12.z.string().min(1).max(2e4).meta({ description: "The plain-text body, a Liquid template. Required even when an HTML part is given: a mail with no text part is unreadable to a client that refuses HTML." }),
4041
4191
  html: import_zod12.z.string().min(1).max(1e5).nullable().meta({ description: "The optional HTML body, a Liquid template. `null` means this template is text-only, which is a complete mail and not a half-configured one." }),
4042
4192
  updated_at: import_zod12.z.iso.datetime().meta({ description: "When the template was last written, as an ISO 8601 timestamp." })
4043
4193
  });
4044
4194
  var appMailTemplateListResponse = import_zod12.z.object({
4045
- templates: import_zod12.z.array(appMailTemplate).max(3).meta({
4195
+ templates: import_zod12.z.array(appMailTemplate).max(4).meta({
4046
4196
  description: "The app's custom templates. A kind that does not appear is one using the Fleetless default text \u2014 an ordinary state, not a missing row."
4047
4197
  })
4048
4198
  });
@@ -4064,7 +4214,7 @@ var mailOutcome = import_zod12.z.object({
4064
4214
  })
4065
4215
  });
4066
4216
 
4067
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/client-auth.js
4217
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/client-auth.js
4068
4218
  var clientLoginRequest = import_zod13.z.object({
4069
4219
  app_identifier: appIdentifier.meta({
4070
4220
  description: "The app being logged in to: its globally unique, lowercase, underscore-separated identifier, chosen by the developer at creation. There is no organisation context at login, so this is what decides which app the credentials are checked for."
@@ -4086,6 +4236,49 @@ var clientLogoutRequest = import_zod13.z.object({
4086
4236
  description: "Any refresh token of the session to end. The whole token family is revoked server-side, so a token stolen before this call stops working too \u2014 clearing a client-side store is a gesture, not a revocation. The answer is `204`: a token the server does not recognise gets it too, since that is the end state being asked for."
4087
4237
  })
4088
4238
  });
4239
+ var clientLoginCodeRequest = import_zod13.z.object({
4240
+ app_identifier: appIdentifier.meta({ description: "The app to sign in to. An identifier no app carries is `404 not_found`; the address is never the subject of a refusal." }),
4241
+ email: import_zod13.z.email().meta({
4242
+ description: "The address to mail the code to, trimmed and compared case-insensitively. `202` whether or not it names an account of this app."
4243
+ })
4244
+ }).strict();
4245
+ var clientLoginCodeVerifyRequest = import_zod13.z.object({
4246
+ app_identifier: appIdentifier.meta({ description: "The app the code was requested for." }),
4247
+ email: import_zod13.z.email().meta({ description: "The address the code was mailed to, as typed when it was requested; trimmed and compared case-insensitively." }),
4248
+ code: loginCode.meta({ description: "The six digits from the mail, exactly \u2014 leading zeros included, no spaces." })
4249
+ }).strict();
4250
+ var twoFactorChallenge = import_zod13.z.object({
4251
+ status: import_zod13.z.enum(["two_factor_required", "two_factor_setup_required"]).meta({
4252
+ description: "`two_factor_required`: ask for the authenticator code. `two_factor_setup_required`: the app requires two-factor and the person has none yet, so set one up before any session exists."
4253
+ }),
4254
+ challenge: import_zod13.z.string().min(1).meta({
4255
+ description: "The handle the next step spends. Valid five minutes; afterwards it answers `410 token_spent` and the sign-in starts over."
4256
+ })
4257
+ });
4258
+ var clientSignInResult = import_zod13.z.union([sessionTokens, twoFactorChallenge]);
4259
+ var clientTwoFactorVerifyRequest = import_zod13.z.object({
4260
+ challenge: import_zod13.z.string().min(1).meta({ description: "The challenge the sign-in step answered." }),
4261
+ code: totpCode.optional().meta({ description: "The six-digit code the authenticator shows now. A code already accepted once is refused, so a replay of a seen code does not sign anybody in." }),
4262
+ recovery_code: recoveryCode.optional().meta({ description: "One of the ten recovery codes, `xxxxx-xxxxx`, in either case. Spent by its use." })
4263
+ }).strict().refine((b) => b.code === void 0 !== (b.recovery_code === void 0), { message: "Send exactly one of code and recovery_code." });
4264
+ var clientTwoFactorSetupRequest = import_zod13.z.object({
4265
+ challenge: import_zod13.z.string().min(1).optional().meta({
4266
+ description: "The `two_factor_setup_required` challenge, during sign-in. Absent when the call carries the app user's bearer instead."
4267
+ })
4268
+ }).strict();
4269
+ var clientTwoFactorSetupConfirmRequest = import_zod13.z.object({
4270
+ challenge: import_zod13.z.string().min(1).optional().meta({ description: "The same challenge as at `setup`, during sign-in; absent with a bearer." }),
4271
+ code: totpCode.meta({ description: "A code the new authenticator shows now. It proves the secret was copied correctly before anything depends on it." })
4272
+ }).strict();
4273
+ var clientTwoFactorSetupConfirmResponse = import_zod13.z.object({
4274
+ recovery_codes: recoveryCodesList.meta({
4275
+ description: "The ten single-use recovery codes, lowercase, shown once. Any earlier set is void."
4276
+ }),
4277
+ session: sessionTokens.meta({ description: "The session the sign-in was waiting for, or a fresh one for the account settings." })
4278
+ });
4279
+ var clientTwoFactorDisableRequest = import_zod13.z.object({
4280
+ code: totpCode.meta({ description: "A code the authenticator shows now." })
4281
+ }).strict();
4089
4282
  var clientRegisterRequest = import_zod13.z.object({
4090
4283
  app_identifier: appIdentifier.meta({
4091
4284
  description: "The app to register with. An identifier no app carries is `404 not_found` \u2014 an identifier is public, so naming it is no disclosure, and collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. The **address** is never the subject of a refusal."
@@ -4093,8 +4286,8 @@ var clientRegisterRequest = import_zod13.z.object({
4093
4286
  email: import_zod13.z.email().meta({
4094
4287
  description: "The address to register. Unique per app, case-insensitively. An address this app already knows still answers `202`, without a mail \u2014 the answer may not say whether an account exists."
4095
4288
  }),
4096
- password: password.meta({
4097
- description: "The password for the new account. At least 12 characters; length only, because a rule a user cannot predict is a rule they work around."
4289
+ password: password.optional().meta({
4290
+ description: "The password for the new account, at least 12 characters. **Required while the app's password method is on, refused while it is off** \u2014 both as `400 validation_error` naming `password`. An email-code-only app registers people without one."
4098
4291
  }),
4099
4292
  display_name: import_zod13.z.string().min(1).max(APP_USER_DISPLAY_NAME_MAX).nullable().optional().meta({
4100
4293
  description: "An optional human name for the account. The developer's own UI decides whether to ask for it."
@@ -4129,7 +4322,9 @@ var clientAcceptInvitationRequest = import_zod13.z.object({
4129
4322
  token: import_zod13.z.string().min(1).meta({
4130
4323
  description: "The opaque token from the invitation link, valid seven days. Unknown, expired, revoked and already-accepted all answer `410 token_spent`."
4131
4324
  }),
4132
- password: password.meta({ description: "The password the new account will use." }),
4325
+ password: password.optional().meta({
4326
+ description: "The password the new account will use, at least 12 characters. **Required while the app's password method is on, refused while it is off** \u2014 both as `400 validation_error` naming `password`. An email-code-only app accepts invitations without one."
4327
+ }),
4133
4328
  display_name: import_zod13.z.string().min(1).max(APP_USER_DISPLAY_NAME_MAX).nullable().optional().meta({
4134
4329
  description: "An optional name, overriding whatever the invitation pre-filled."
4135
4330
  })
@@ -4143,7 +4338,10 @@ var clientProviderListResponse = import_zod13.z.object({
4143
4338
  slug: providerSlug.meta({ description: "The handle to put in the start URL: `GET /api/client/oidc/<slug>/start`." }),
4144
4339
  name: import_zod13.z.string().meta({ description: "What to write on the button, as the developer configured it." })
4145
4340
  })).meta({
4146
- description: "The app's **enabled** providers, slug and display name only. An app with none answers an empty array, which is the state of an app that offers password login alone."
4341
+ description: "The app's **enabled** providers, slug and display name only. An app with none answers an empty array, which is the state of an app that offers password or code sign-in alone."
4342
+ }),
4343
+ sign_in_methods: appSignInMethods.meta({
4344
+ description: "Which of password and emailed code the app accepts, so its sign-in page draws the right fields without guessing. The same value the developer set; public, like the provider buttons."
4147
4345
  })
4148
4346
  });
4149
4347
  var clientOidcStartQuery = import_zod13.z.object({
@@ -4192,7 +4390,8 @@ var clientOidcErrorCode = import_zod13.z.enum([
4192
4390
  "provider_misconfigured",
4193
4391
  "provider_disabled",
4194
4392
  "invalid_request",
4195
- "quota_exceeded"
4393
+ "quota_exceeded",
4394
+ "plan_limit"
4196
4395
  ]);
4197
4396
  var clientMcpInteraction = import_zod13.z.object({
4198
4397
  id: import_zod13.z.string().meta({ description: "The interaction, as it arrived in the app's `mcp_login_url`. Not a credential: it names a pending request the server already holds, and approving it needs the app user's own access token." }),
@@ -4250,10 +4449,13 @@ var clientIdentity = import_zod13.z.object({
4250
4449
  }),
4251
4450
  email: import_zod13.z.email().nullable().meta({
4252
4451
  description: "The address of the Fleetless user or app user behind this session, and `null` for a server key, which is not a person."
4452
+ }),
4453
+ two_factor_enabled: import_zod13.z.boolean().nullable().meta({
4454
+ description: "Whether the app user has a confirmed authenticator, so the app's account settings can offer to turn it on or off. `null` unless `kind` is `app_user`."
4253
4455
  })
4254
4456
  });
4255
4457
 
4256
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/realtime.js
4458
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/realtime.js
4257
4459
  var clientAuth = import_zod14.z.object({
4258
4460
  type: import_zod14.z.literal("auth"),
4259
4461
  token: import_zod14.z.string().min(1)
@@ -4421,6 +4623,12 @@ var liveSessionEndReason = import_zod14.z.enum([
4421
4623
  "expired",
4422
4624
  /** The robot was deleted out from under the session. */
4423
4625
  "robot_deleted",
4626
+ /**
4627
+ * The organization's live video for app users reached its plan's monthly
4628
+ * limit (2026-10-02, fleetless/fleetless#103); `detail` carries the
4629
+ * sentence the viewer shows.
4630
+ */
4631
+ "plan_limit",
4424
4632
  /**
4425
4633
  * The cloud ended it and cannot say which of the above applied. **Kept
4426
4634
  * deliberately**: a channel that cannot say "I do not know" will say
@@ -4531,34 +4739,59 @@ var orgEventDropped = import_zod14.z.object({
4531
4739
  dropped: import_zod14.z.number().int().positive()
4532
4740
  }).strict();
4533
4741
 
4534
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/client-robots.js
4742
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/feedback.js
4535
4743
  var import_zod15 = require("zod");
4536
- var clientRobotListItem = import_zod15.z.object({
4744
+ var FEEDBACK_KINDS = ["idea", "problem", "question", "other"];
4745
+ var feedbackKind = import_zod15.z.enum(FEEDBACK_KINDS);
4746
+ var FEEDBACK_MESSAGE_MAX = 5e3;
4747
+ var feedbackRequest = import_zod15.z.object({
4748
+ kind: feedbackKind.meta({
4749
+ description: "What the message is: an `idea`, a `problem`, a `question` or `other`. It only sorts the inbox; it changes nothing about how the message is handled."
4750
+ }),
4751
+ message: import_zod15.z.string().trim().min(1).max(FEEDBACK_MESSAGE_MAX).meta({
4752
+ description: "What the developer wrote, trimmed. At most 5000 characters; a message that is only whitespace is refused."
4753
+ }),
4754
+ page: import_zod15.z.string().startsWith("/").max(512).meta({
4755
+ description: "The console path the message was sent from, e.g. `/robots/:id/jobs` with its real id. A path, never a full URL, so no host and no query string reach the inbox by accident."
4756
+ })
4757
+ }).strict();
4758
+ var feedbackResponse = import_zod15.z.object({
4759
+ id: import_zod15.z.uuid().meta({
4760
+ description: "The stored message. It exists whatever `mail` says."
4761
+ }),
4762
+ mail: mailStatus.meta({
4763
+ description: "What happened to the notification mail: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. The message is stored in every case, so a client shows success for all three."
4764
+ })
4765
+ }).strict();
4766
+
4767
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/client-robots.js
4768
+ var import_zod16 = require("zod");
4769
+ var clientRobotListItem = import_zod16.z.object({
4537
4770
  ...robot.shape,
4538
4771
  bridge_state: bridgeState.meta({
4539
4772
  description: "The built-in `bridge_state` datapoint as the cloud observes it right now: whether the bridge is connected, and its latency when it is."
4540
4773
  }),
4541
- published_version: import_zod15.z.number().int().positive().nullable().meta({
4774
+ published_version: import_zod16.z.number().int().positive().nullable().meta({
4542
4775
  description: 'The published configuration version, or `null` when nothing has been published yet. A robot with nothing published is still listed \u2014 "not configured yet" is a real state, and the caller is entitled to it \u2014 and its datasheet answers an empty exposure list.'
4543
4776
  })
4544
4777
  });
4545
- var clientRobotListResponse = import_zod15.z.object({
4546
- robots: import_zod15.z.array(clientRobotListItem).meta({
4778
+ var clientRobotListResponse = import_zod16.z.object({
4779
+ robots: import_zod16.z.array(clientRobotListItem).meta({
4547
4780
  description: "Every robot the caller reaches, in name order with the id as the tiebreak. An app user reaches the robots their app attaches on which their role grants at least one slug or capability; a server key reaches every robot its app attaches; a developer reaches every robot of the organisation."
4548
4781
  })
4549
4782
  });
4550
4783
 
4551
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/audit.js
4552
- var import_zod16 = require("zod");
4553
- var auditActor = import_zod16.z.object({
4554
- kind: import_zod16.z.enum(["developer", "end_user", "app_user", "server_key", "bridge"]),
4555
- id: import_zod16.z.uuid(),
4556
- label: import_zod16.z.string().min(1).max(200)
4557
- });
4558
- var auditEvent = import_zod16.z.object({
4559
- id: import_zod16.z.uuid(),
4560
- org_id: import_zod16.z.uuid(),
4561
- at: import_zod16.z.iso.datetime(),
4784
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/audit.js
4785
+ var import_zod17 = require("zod");
4786
+ var auditActor = import_zod17.z.object({
4787
+ kind: import_zod17.z.enum(["developer", "end_user", "app_user", "server_key", "bridge", "fleetless"]),
4788
+ id: import_zod17.z.uuid(),
4789
+ label: import_zod17.z.string().min(1).max(200)
4790
+ });
4791
+ var auditEvent = import_zod17.z.object({
4792
+ id: import_zod17.z.uuid(),
4793
+ org_id: import_zod17.z.uuid(),
4794
+ at: import_zod17.z.iso.datetime(),
4562
4795
  /**
4563
4796
  * A monotonic counter, ascending in write order, unique across the log.
4564
4797
  *
@@ -4577,18 +4810,18 @@ var auditEvent = import_zod16.z.object({
4577
4810
  * Required, not optional: an event without a sequence cannot be ordered
4578
4811
  * against one that has it, and a log with two orderings has none.
4579
4812
  */
4580
- seq: import_zod16.z.number().int().positive(),
4813
+ seq: import_zod17.z.number().int().positive(),
4581
4814
  actor: auditActor,
4582
4815
  /** Stable dotted name, e.g. `app_user.login`, `config.published`. */
4583
- action: import_zod16.z.string().min(1).max(80),
4816
+ action: import_zod17.z.string().min(1).max(80),
4584
4817
  /**
4585
4818
  * What the action was about, if anything — a robot, an app, a user. Free
4586
4819
  * of ids the console cannot resolve: carry the label with it.
4587
4820
  */
4588
- target: import_zod16.z.object({
4589
- kind: import_zod16.z.string().min(1).max(40),
4590
- id: import_zod16.z.string().min(1),
4591
- label: import_zod16.z.string().min(1).max(200)
4821
+ target: import_zod17.z.object({
4822
+ kind: import_zod17.z.string().min(1).max(40),
4823
+ id: import_zod17.z.string().min(1),
4824
+ label: import_zod17.z.string().min(1).max(200)
4592
4825
  }).nullable(),
4593
4826
  /**
4594
4827
  * Action-specific extras.
@@ -4602,10 +4835,10 @@ var auditEvent = import_zod16.z.object({
4602
4835
  * So: never credentials, never tokens. That is a rule, not a guarantee the
4603
4836
  * schema enforces.
4604
4837
  */
4605
- details: import_zod16.z.record(import_zod16.z.string(), import_zod16.z.unknown()).nullable()
4838
+ details: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).nullable()
4606
4839
  });
4607
4840
  var auditTimestampMs = wireTimestampMs;
4608
- var auditQuery = import_zod16.z.object({
4841
+ var auditQuery = import_zod17.z.object({
4609
4842
  /** Only events with a smaller `seq` — the next, older page. */
4610
4843
  before_seq: wireSeqCursor.optional(),
4611
4844
  /**
@@ -4614,9 +4847,9 @@ var auditQuery = import_zod16.z.object({
4614
4847
  * coercion's result in either `io` direction, so the artifact would describe
4615
4848
  * a shape a query string can never carry.
4616
4849
  */
4617
- limit: import_zod16.z.union([import_zod16.z.string().regex(/^\d{1,4}$/), import_zod16.z.number().int()]).transform((v) => Number(v)).pipe(import_zod16.z.number().int().positive().max(500)).optional(),
4850
+ limit: import_zod17.z.union([import_zod17.z.string().regex(/^\d{1,4}$/), import_zod17.z.number().int()]).transform((v) => Number(v)).pipe(import_zod17.z.number().int().positive().max(500)).optional(),
4618
4851
  /** Exact action name, e.g. `config.published`. No prefix matching: a filter that matches more than it says is not one. */
4619
- action: import_zod16.z.string().min(1).max(80).optional(),
4852
+ action: import_zod17.z.string().min(1).max(80).optional(),
4620
4853
  /**
4621
4854
  * Everything under a dotted prefix, e.g. `server_key.` for all three
4622
4855
  * server-key actions.
@@ -4633,7 +4866,7 @@ var auditQuery = import_zod16.z.object({
4633
4866
  * happily. The cloud is the only enforcement point — the same residual
4634
4867
  * `orgLatencyQuery` and `orgUsageQuery` already name.
4635
4868
  */
4636
- action_prefix: import_zod16.z.string().min(1).max(80).optional(),
4869
+ action_prefix: import_zod17.z.string().min(1).max(80).optional(),
4637
4870
  /**
4638
4871
  * Only events by this actor.
4639
4872
  *
@@ -4645,9 +4878,21 @@ var auditQuery = import_zod16.z.object({
4645
4878
  * Not an injection question — the query is parameterised either way. It is a
4646
4879
  * **500 where a 400 belongs**, and a 500 is the answer that explains nothing.
4647
4880
  */
4648
- actor_id: import_zod16.z.uuid().optional(),
4881
+ actor_id: import_zod17.z.uuid().optional(),
4649
4882
  /** Only events about this kind of target, e.g. `robot`. */
4650
- target_kind: import_zod16.z.string().min(1).max(40).optional(),
4883
+ target_kind: import_zod17.z.string().min(1).max(40).optional(),
4884
+ /**
4885
+ * Only events about this target — and, for a robot, also the events that
4886
+ * name it in `details.robot_id`, so a robot's log includes what was
4887
+ * started on it.
4888
+ *
4889
+ * **A string, not `z.uuid()`**, unlike `actor_id` above: `target.id` is a
4890
+ * string in this contract and text in the cloud's table, so a uuid rule
4891
+ * here would refuse ids the log can hold, and no value can fail a cast.
4892
+ */
4893
+ target_id: import_zod17.z.string().min(1).max(200).optional().meta({
4894
+ description: "Events whose target is this id; for a robot also the events that name it in `details.robot_id` (`action.invoked`, `service.called`, \u2026), so a robot's events include what was started on it."
4895
+ }),
4651
4896
  /**
4652
4897
  * Absolute bounds in unix milliseconds, **half-open `[from, to)`** — the
4653
4898
  * same rule the history shapes follow.
@@ -4663,8 +4908,8 @@ var auditQuery = import_zod16.z.object({
4663
4908
  message: "action and action_prefix cannot be combined",
4664
4909
  path: ["action_prefix"]
4665
4910
  });
4666
- var auditListResponse = import_zod16.z.object({
4667
- events: import_zod16.z.array(auditEvent),
4911
+ var auditListResponse = import_zod17.z.object({
4912
+ events: import_zod17.z.array(auditEvent),
4668
4913
  /**
4669
4914
  * The `seq` a caller sends as `before_seq` to keep reading — or `null` when
4670
4915
  * there is nothing further.
@@ -4675,32 +4920,361 @@ var auditListResponse = import_zod16.z.object({
4675
4920
  * not mean *no more* here. The same distinction `historySamples` was given
4676
4921
  * `truncated` for.
4677
4922
  */
4678
- next_cursor: import_zod16.z.number().int().positive().nullable()
4923
+ next_cursor: import_zod17.z.number().int().positive().nullable()
4679
4924
  });
4680
4925
 
4681
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/errors.js
4682
- var import_zod17 = require("zod");
4683
- var apiError = import_zod17.z.object({
4684
- code: import_zod17.z.string().min(1),
4685
- message: import_zod17.z.string().min(1),
4686
- details: import_zod17.z.unknown().optional()
4926
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/errors.js
4927
+ var import_zod19 = require("zod");
4928
+
4929
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/plans.js
4930
+ var import_zod18 = require("zod");
4931
+ var planId = import_zod18.z.enum(["basic", "plus", "pro", "enterprise"]);
4932
+ var planLimitKey = import_zod18.z.enum([
4933
+ "seats",
4934
+ "robots",
4935
+ "apps",
4936
+ "app_users",
4937
+ "live_video_ms_per_month",
4938
+ "asset_bytes_per_robot",
4939
+ "history_days",
4940
+ "audit_days"
4941
+ ]);
4942
+ var limit = import_zod18.z.number().int().positive().nullable();
4943
+ var planLimits = import_zod18.z.object({
4944
+ seats: limit.meta({ description: "Developers, owners included, plus pending team invitations." }),
4945
+ robots: limit.meta({ description: "Robots in the org." }),
4946
+ apps: limit.meta({ description: "Apps in the org." }),
4947
+ app_users: limit.meta({ description: "App users across every app of the org, plus pending app-user invitations." }),
4948
+ live_video_ms_per_month: limit.meta({
4949
+ description: "Live video watched by app users in one UTC calendar month, in milliseconds, across the org. Console sessions do not count."
4950
+ }),
4951
+ asset_bytes_per_robot: limit.meta({
4952
+ description: "Asset storage per robot, in bytes (decimal: 1 GB = 1,000,000,000). The org stores up to robots \xD7 this value in total."
4953
+ }),
4954
+ history_days: limit.meta({ description: "Days the org's robot history is kept." }),
4955
+ audit_days: limit.meta({ description: "Days the org's audit log is kept." })
4956
+ });
4957
+ var planFeature = import_zod18.z.enum(["app_mcp", "two_factor", "require_two_factor", "app_oidc", "hosted_logo", "audit_export", "addons"]);
4958
+ var FEATURE_DESCRIPTIONS = {
4959
+ app_mcp: "An app's own MCP endpoint, for its app users.",
4960
+ two_factor: "Two-factor sign-in for developers.",
4961
+ require_two_factor: "An owner may require two-factor sign-in for every developer of the org.",
4962
+ app_oidc: "An app may let its users sign in through an OpenID Connect identity provider.",
4963
+ hosted_logo: "An app's hosted pages show its logo and accent colour, not only its name.",
4964
+ audit_export: "The audit log can be exported as CSV.",
4965
+ addons: "Add-ons can be bought on top of the plan."
4966
+ };
4967
+ var planFeatures = import_zod18.z.object(Object.fromEntries(planFeature.options.map((f) => [f, import_zod18.z.boolean().meta({ description: FEATURE_DESCRIPTIONS[f] })])));
4968
+ var cents = import_zod18.z.number().int().nonnegative();
4969
+ var planPrices = import_zod18.z.object({
4970
+ eur_month: cents.meta({ description: "Per month in euro cents, excluding VAT." }),
4971
+ usd_month: cents.meta({ description: "Per month in US dollar cents, excluding VAT: the euro price \xD7 1.15, rounded up to a whole dollar." }),
4972
+ eur_year: cents.meta({ description: "Per year in euro cents, excluding VAT: twelve months less 15 %." }),
4973
+ usd_year: cents.meta({ description: "Per year in US dollar cents, excluding VAT: the yearly euro price \xD7 1.15, rounded up to a whole dollar." })
4974
+ });
4975
+ var planSupport = import_zod18.z.enum(["community", "email", "priority", "named_contact"]);
4976
+ var planCatalogueEntry = import_zod18.z.object({
4977
+ id: planId.meta({ description: "The plan." }),
4978
+ name: import_zod18.z.string().min(1).meta({ description: "The plan's display name: Basic, Plus, Pro or Enterprise." }),
4979
+ limits: planLimits.meta({ description: "What the plan allows. `null` means by contract." }),
4980
+ features: planFeatures.meta({ description: "What the plan unlocks." }),
4981
+ prices: planPrices.nullable().meta({ description: "`null` for Enterprise: sold by contract, on request." }),
4982
+ support: planSupport.meta({ description: "The support that comes with the plan." })
4983
+ });
4984
+ var addonKey = import_zod18.z.enum(["seats", "robots", "apps", "app_user_packs", "live_video_packs"]);
4985
+ var addonCatalogueEntry = import_zod18.z.object({
4986
+ key: addonKey.meta({ description: "The add-on." }),
4987
+ raises: planLimitKey.meta({ description: "The plan limit this add-on raises." }),
4988
+ per_unit: import_zod18.z.number().int().positive().meta({ description: "How much one unit of this add-on raises `raises` by." }),
4989
+ prices: planPrices.meta({ description: "The price of one unit." })
4990
+ });
4991
+ function usdCentsFromEurCents(eurCents) {
4992
+ return Math.floor((eurCents * 115 + 9999) / 1e4) * 100;
4993
+ }
4994
+ function yearlyEurCents(monthEurCents) {
4995
+ return Math.round(monthEurCents * 12 * 85 / 100);
4996
+ }
4997
+ function pricesFromEurMonth(eurMonthCents) {
4998
+ const eurYear = yearlyEurCents(eurMonthCents);
4999
+ return {
5000
+ eur_month: eurMonthCents,
5001
+ usd_month: usdCentsFromEurCents(eurMonthCents),
5002
+ eur_year: eurYear,
5003
+ usd_year: usdCentsFromEurCents(eurYear)
5004
+ };
5005
+ }
5006
+ var PLANS = {
5007
+ basic: {
5008
+ id: "basic",
5009
+ name: "Basic",
5010
+ limits: {
5011
+ seats: 1,
5012
+ robots: 1,
5013
+ apps: 1,
5014
+ app_users: 10,
5015
+ live_video_ms_per_month: 36e6,
5016
+ asset_bytes_per_robot: 1e9,
5017
+ history_days: 7,
5018
+ audit_days: 7
5019
+ },
5020
+ features: {
5021
+ app_mcp: false,
5022
+ two_factor: false,
5023
+ require_two_factor: false,
5024
+ app_oidc: false,
5025
+ hosted_logo: false,
5026
+ audit_export: false,
5027
+ addons: false
5028
+ },
5029
+ prices: pricesFromEurMonth(0),
5030
+ support: "community"
5031
+ },
5032
+ plus: {
5033
+ id: "plus",
5034
+ name: "Plus",
5035
+ limits: {
5036
+ seats: 3,
5037
+ robots: 3,
5038
+ apps: 3,
5039
+ app_users: 25,
5040
+ live_video_ms_per_month: 36e7,
5041
+ asset_bytes_per_robot: 2e9,
5042
+ history_days: 30,
5043
+ audit_days: 30
5044
+ },
5045
+ features: {
5046
+ app_mcp: true,
5047
+ two_factor: true,
5048
+ require_two_factor: false,
5049
+ app_oidc: true,
5050
+ hosted_logo: true,
5051
+ audit_export: false,
5052
+ addons: false
5053
+ },
5054
+ prices: pricesFromEurMonth(2900),
5055
+ support: "email"
5056
+ },
5057
+ pro: {
5058
+ id: "pro",
5059
+ name: "Pro",
5060
+ limits: {
5061
+ seats: 5,
5062
+ robots: 5,
5063
+ apps: 5,
5064
+ app_users: 50,
5065
+ live_video_ms_per_month: 9e8,
5066
+ asset_bytes_per_robot: 3e9,
5067
+ history_days: 90,
5068
+ audit_days: 90
5069
+ },
5070
+ features: {
5071
+ app_mcp: true,
5072
+ two_factor: true,
5073
+ require_two_factor: true,
5074
+ app_oidc: true,
5075
+ hosted_logo: true,
5076
+ audit_export: true,
5077
+ addons: true
5078
+ },
5079
+ prices: pricesFromEurMonth(14900),
5080
+ support: "priority"
5081
+ },
5082
+ enterprise: {
5083
+ id: "enterprise",
5084
+ name: "Enterprise",
5085
+ limits: {
5086
+ seats: null,
5087
+ robots: null,
5088
+ apps: null,
5089
+ app_users: null,
5090
+ live_video_ms_per_month: null,
5091
+ asset_bytes_per_robot: null,
5092
+ history_days: null,
5093
+ audit_days: null
5094
+ },
5095
+ features: {
5096
+ app_mcp: true,
5097
+ two_factor: true,
5098
+ require_two_factor: true,
5099
+ app_oidc: true,
5100
+ hosted_logo: true,
5101
+ audit_export: true,
5102
+ addons: true
5103
+ },
5104
+ prices: null,
5105
+ support: "named_contact"
5106
+ }
5107
+ };
5108
+ var ADDONS = {
5109
+ seats: { key: "seats", raises: "seats", per_unit: 1, prices: pricesFromEurMonth(900) },
5110
+ robots: { key: "robots", raises: "robots", per_unit: 1, prices: pricesFromEurMonth(1900) },
5111
+ apps: { key: "apps", raises: "apps", per_unit: 1, prices: pricesFromEurMonth(900) },
5112
+ app_user_packs: { key: "app_user_packs", raises: "app_users", per_unit: 5, prices: pricesFromEurMonth(1e3) },
5113
+ live_video_packs: { key: "live_video_packs", raises: "live_video_ms_per_month", per_unit: 9e8, prices: pricesFromEurMonth(900) }
5114
+ };
5115
+ var planCurrency = import_zod18.z.enum(["eur", "usd"]);
5116
+ var addonCount = import_zod18.z.number().int().nonnegative();
5117
+ var orgAddons = import_zod18.z.object({
5118
+ seats: addonCount.meta({ description: "Extra developer seats, one each." }),
5119
+ robots: addonCount.meta({ description: "Extra robots, one each." }),
5120
+ apps: addonCount.meta({ description: "Extra apps, one each." }),
5121
+ app_user_packs: addonCount.meta({ description: "Packs of five extra app users." }),
5122
+ live_video_packs: addonCount.meta({ description: "Packs of 250 extra hours of app-user live video per month." })
5123
+ });
5124
+ var usageCount = import_zod18.z.number().int().nonnegative();
5125
+ var orgPlanUsage = import_zod18.z.object({
5126
+ seats: usageCount.meta({ description: "Developers, owners included, plus pending team invitations." }),
5127
+ robots: usageCount.meta({ description: "Robots in the org." }),
5128
+ apps: usageCount.meta({ description: "Apps in the org." }),
5129
+ app_users: usageCount.meta({ description: "App users across every app, plus pending app-user invitations." }),
5130
+ live_video_ms_this_month: usageCount.meta({
5131
+ description: "Live video watched by app users in the current UTC calendar month, in milliseconds. Console sessions do not count."
5132
+ }),
5133
+ asset_bytes: usageCount.meta({ description: "Bytes the assets of every robot of the org occupy, together." })
5134
+ });
5135
+ var planChangeKeep = import_zod18.z.object({
5136
+ robots: import_zod18.z.array(import_zod18.z.uuid()).meta({ description: "The robots that stay, by id. Every other robot is deleted when the change takes effect." }),
5137
+ apps: import_zod18.z.array(import_zod18.z.uuid()).meta({ description: "The apps that stay, by id. Every other app is deleted when the change takes effect." }),
5138
+ app_users: import_zod18.z.array(import_zod18.z.uuid()).meta({
5139
+ description: "The app users that stay, by id, across every app. Every other app user is deleted when the change takes effect."
5140
+ }),
5141
+ developers: import_zod18.z.array(import_zod18.z.uuid()).meta({
5142
+ description: "The developers that stay, by user id, owners never among them: every owner stays. Every other developer is removed from the org when the change takes effect."
5143
+ })
5144
+ }).strict();
5145
+ var planChangeReason = import_zod18.z.enum(["downgrade", "cancel", "migration", "lock"]);
5146
+ var pendingPlanChange = import_zod18.z.object({
5147
+ target_plan: planId.meta({ description: "The plan the org moves to." }),
5148
+ reason: planChangeReason.meta({ description: "Why the change is pending: `downgrade`, `cancel`, `migration` or `lock`." }),
5149
+ effective_at: import_zod18.z.iso.datetime().nullable().meta({
5150
+ description: "When the change takes effect. `null` only for a move off the beta whose date is not set yet."
5151
+ }),
5152
+ keep: planChangeKeep.nullable().meta({
5153
+ description: "What stays. `null` when the org already fits the target plan and nothing is deleted."
5154
+ }),
5155
+ history_days_after: import_zod18.z.number().int().positive().meta({
5156
+ description: "Days of history and audit log the org keeps on the target plan."
5157
+ }),
5158
+ chosen_by: import_zod18.z.uuid().meta({ description: "The user id of the owner who chose the change, or the nil UUID when Fleetless queued it." }),
5159
+ chosen_at: import_zod18.z.iso.datetime().meta({ description: "When the change was chosen." })
5160
+ });
5161
+ var orgLock = import_zod18.z.object({
5162
+ reason: import_zod18.z.enum(["payment", "migration"]).meta({
5163
+ description: "`payment`: a payment is missing. `migration`: the org did not choose what stays when the beta ended."
5164
+ }),
5165
+ since: import_zod18.z.iso.datetime().meta({ description: "When the org was locked." })
5166
+ });
5167
+ var orgPlan = import_zod18.z.object({
5168
+ plan: planId.meta({ description: "The org's current plan." }),
5169
+ currency: planCurrency.meta({ description: "The currency the org's prices are shown and billed in." }),
5170
+ period_ends_at: import_zod18.z.iso.datetime().meta({
5171
+ description: "The end of the organization's current billing period; while no payment period exists yet, the end of the current UTC calendar month. A pending downward plan change normally takes effect at this exact instant \u2014 except one chosen while the org was locked, which lands at once, and the platform's own move off the beta, which lands at its switch date instead."
5172
+ }),
5173
+ addons: orgAddons.meta({ description: "The add-ons the org has bought. All zero on a plan without the `addons` feature." }),
5174
+ limits: planLimits.meta({
5175
+ description: "The org's effective limits: the plan's, raised by its add-ons, or an operator's override in their place. `null` means unlimited for a count, and 90 days for `history_days` and `audit_days`."
5176
+ }),
5177
+ features: planFeatures.meta({ description: "What the org's plan unlocks." }),
5178
+ usage: orgPlanUsage.meta({ description: "What the org uses now, counted the way each limit counts it." }),
5179
+ pending_change: pendingPlanChange.nullable().meta({ description: "A move to a lower plan that has not taken effect yet, or `null`." }),
5180
+ lock: orgLock.nullable().meta({ description: "Why and since when the org is locked, or `null` when it is not." }),
5181
+ switch: import_zod18.z.object({
5182
+ at: import_zod18.z.iso.datetime().nullable().meta({
5183
+ description: "When the org moves from the beta onto its plan. `null` while the date is not set."
5184
+ }),
5185
+ needs_choice: import_zod18.z.boolean().meta({
5186
+ description: "Whether the org uses more than Basic allows, so an owner has to choose what stays before `at`."
5187
+ })
5188
+ }).nullable().meta({ description: "Set only while the org is still on the beta; `null` for every other org." })
4687
5189
  });
4688
- var parameterViolation = import_zod17.z.object({
4689
- field: import_zod17.z.string().min(1),
5190
+ var planChangeRequest = import_zod18.z.object({
5191
+ target_plan: planId.meta({ description: "The lower plan to move to; `basic` cancels." }),
5192
+ keep: planChangeKeep.nullable().meta({
5193
+ description: "What stays. `null` when the org already fits the target plan, so nothing is deleted."
5194
+ })
5195
+ }).strict();
5196
+ var planOverrides = import_zod18.z.object(Object.fromEntries(planLimitKey.options.map((k) => [
5197
+ k,
5198
+ limit.optional().meta({ description: `Replaces the plan's \`${k}\`. \`null\` clears the override.` })
5199
+ ]))).strict();
5200
+ var adminPlanChangeRequest = import_zod18.z.object({
5201
+ plan: planId.meta({ description: "The plan the org is on after this request." }),
5202
+ addons: orgAddons.partial().strict().optional().meta({ description: "Add-on counts to set. Counts not named stay as they are." }),
5203
+ overrides: planOverrides.optional().meta({ description: "Limits to override. `null` clears an override." }),
5204
+ currency: planCurrency.optional().meta({ description: "The currency the org is billed in." }),
5205
+ period_ends_at: import_zod18.z.iso.datetime().nullable().optional().meta({
5206
+ description: "The end of the org's billing period. `null` falls back to the end of the current UTC month."
5207
+ })
5208
+ }).strict();
5209
+
5210
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/errors.js
5211
+ var apiError = import_zod19.z.object({
5212
+ code: import_zod19.z.string().min(1),
5213
+ message: import_zod19.z.string().min(1),
5214
+ details: import_zod19.z.unknown().optional()
5215
+ });
5216
+ var parameterViolation = import_zod19.z.object({
5217
+ field: import_zod19.z.string().min(1),
4690
5218
  /** Which rule failed — `min`, `max`, `enum`, `pattern`, `required`, `undeclared`. */
4691
- rule: import_zod17.z.string().min(1),
4692
- message: import_zod17.z.string().min(1)
5219
+ rule: import_zod19.z.string().min(1),
5220
+ message: import_zod19.z.string().min(1)
5221
+ });
5222
+ var parameterInvalidDetails = import_zod19.z.object({
5223
+ violations: import_zod19.z.array(parameterViolation).min(1)
5224
+ });
5225
+ var cancelRejectedDetails = import_zod19.z.object({
5226
+ goals: import_zod19.z.array(bridgeCancelResultEntry).min(1)
5227
+ });
5228
+ var invalidCodeDetails = import_zod19.z.object({
5229
+ attempts_left: import_zod19.z.number().int().min(0).meta({
5230
+ description: "How many more wrong codes this code or challenge takes before it is spent. `0` means the next attempt answers `410 token_spent`."
5231
+ })
5232
+ });
5233
+ var planLimitDetails = import_zod19.z.object({
5234
+ limit: planLimitKey.exclude(["history_days", "audit_days"]).meta({ description: "The limit the action would exceed." }),
5235
+ used: import_zod19.z.number().int().nonnegative().meta({ description: "How much of the limit the org uses now." }),
5236
+ max: import_zod19.z.number().int().nonnegative().meta({
5237
+ description: "The limit. For `asset_bytes_per_robot`, the org's whole pool: robots \xD7 the plan's bytes per robot."
5238
+ }),
5239
+ plan: planId.meta({ description: "The plan whose limit refused: the target plan while a move to a lower plan is pending." }),
5240
+ lifted_by: import_zod19.z.object({
5241
+ plan: planId.nullable().meta({ description: "The cheapest higher plan that raises this limit, or `null` when none does." }),
5242
+ addon: addonKey.nullable().meta({
5243
+ description: "The add-on that raises this limit, when the org's plan can buy add-ons; otherwise `null`."
5244
+ })
5245
+ }).meta({ description: "What would lift the limit." })
5246
+ });
5247
+ var assetPlanLimitDetails = planLimitDetails.extend({
5248
+ ...assetStoreRefusedDetails.shape,
5249
+ store_bytes: assetStoreRefusedDetails.shape.store_bytes.meta({
5250
+ description: "The org's asset pool, in bytes: `robots \xD7 asset_bytes_per_robot`."
5251
+ }),
5252
+ used_bytes: assetStoreRefusedDetails.shape.used_bytes.meta({
5253
+ description: "Bytes the org's assets occupy, across every robot, before this upload."
5254
+ })
4693
5255
  });
4694
- var parameterInvalidDetails = import_zod17.z.object({
4695
- violations: import_zod17.z.array(parameterViolation).min(1)
5256
+ var planRequiredDetails = import_zod19.z.object({
5257
+ feature: planFeature.meta({ description: "The feature the action needs." }),
5258
+ plan: planId.meta({ description: "The org's current plan." }),
5259
+ required_plan: planId.meta({ description: "The cheapest plan that has the feature." })
5260
+ });
5261
+ var orgLockedDetails = import_zod19.z.object({
5262
+ reason: import_zod19.z.enum(["payment", "migration"]).meta({
5263
+ description: "`payment`: a payment is missing. `migration`: the org did not choose what stays when the beta ended."
5264
+ })
4696
5265
  });
4697
- var cancelRejectedDetails = import_zod17.z.object({
4698
- goals: import_zod17.z.array(bridgeCancelResultEntry).min(1)
5266
+ var fileTooLargeDetails = import_zod19.z.object({
5267
+ max_bytes: import_zod19.z.number().int().positive().meta({
5268
+ description: "The most one asset file can be, in bytes: `ASSET_FILE_MAX_BYTES`, the same on every plan."
5269
+ }),
5270
+ size_bytes: import_zod19.z.number().int().positive().nullable().meta({
5271
+ description: "The refused file's size, in bytes: the announced size, or the bytes that arrived when none (or a false one) was announced; `null` only when the body limit stopped the upload and nobody counted the bytes."
5272
+ })
4699
5273
  });
4700
5274
 
4701
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/oauth.js
4702
- var import_zod18 = require("zod");
4703
- var oauthErrorCode = import_zod18.z.enum([
5275
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/oauth.js
5276
+ var import_zod20 = require("zod");
5277
+ var oauthErrorCode = import_zod20.z.enum([
4704
5278
  "invalid_request",
4705
5279
  "invalid_client",
4706
5280
  "invalid_grant",
@@ -4713,11 +5287,11 @@ var oauthErrorCode = import_zod18.z.enum([
4713
5287
  /** RFC 8707: the `resource` named is not one this server issues tokens for. */
4714
5288
  "invalid_target"
4715
5289
  ]);
4716
- var oauthError = import_zod18.z.object({
5290
+ var oauthError = import_zod20.z.object({
4717
5291
  error: oauthErrorCode,
4718
- error_description: import_zod18.z.string().min(1).max(500).optional(),
5292
+ error_description: import_zod20.z.string().min(1).max(500).optional(),
4719
5293
  /** Echoed back per RFC 6749 §4.1.2.1 so a client can match the response. */
4720
- state: import_zod18.z.string().min(1).max(500).optional(),
5294
+ state: import_zod20.z.string().min(1).max(500).optional(),
4721
5295
  /**
4722
5296
  * **A Fleetless reason carried inside a standard envelope, and it exists
4723
5297
  * because the alternative lost a distinction.**
@@ -4735,9 +5309,9 @@ var oauthError = import_zod18.z.object({
4735
5309
  * our own tooling switches on. RFC 6749 §5.2 permits additional members, and
4736
5310
  * a client that ignores this one still behaves correctly.
4737
5311
  */
4738
- fleetless_code: import_zod18.z.string().min(1).max(60).optional()
5312
+ fleetless_code: import_zod20.z.string().min(1).max(60).optional()
4739
5313
  });
4740
- var redirectUri = import_zod18.z.string().min(1).max(2e3).refine((v) => {
5314
+ var redirectUri = import_zod20.z.string().min(1).max(2e3).refine((v) => {
4741
5315
  let url;
4742
5316
  try {
4743
5317
  url = new URL(v);
@@ -4752,180 +5326,180 @@ var redirectUri = import_zod18.z.string().min(1).max(2e3).refine((v) => {
4752
5326
  return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
4753
5327
  return false;
4754
5328
  }, { message: "redirect_uri must be an https URL, or http on an explicit loopback address, and carry no fragment" });
4755
- var codeChallengeMethod = import_zod18.z.enum(["S256"]);
5329
+ var codeChallengeMethod = import_zod20.z.enum(["S256"]);
4756
5330
  var MCP_DCR_MAX_REDIRECT_URIS = 5;
4757
- var dynamicClientRegistrationRequest = import_zod18.z.object({
4758
- redirect_uris: import_zod18.z.array(redirectUri).min(1).max(MCP_DCR_MAX_REDIRECT_URIS).meta({
5331
+ var dynamicClientRegistrationRequest = import_zod20.z.object({
5332
+ redirect_uris: import_zod20.z.array(redirectUri).min(1).max(MCP_DCR_MAX_REDIRECT_URIS).meta({
4759
5333
  description: `Where the authorization code may be returned, and the one field a registration cannot omit. Each must be an \`https\` URL, or \`http\` on an explicit loopback address for a native app that cannot hold a certificate, and none may carry a fragment. Between \`1\` and \`${MCP_DCR_MAX_REDIRECT_URIS}\` of them; duplicates are collapsed rather than counted twice. Matched **exactly** at the authorize step against what was registered here.`
4760
5334
  }),
4761
- client_name: import_zod18.z.string().min(1).max(200).optional().meta({
5335
+ client_name: import_zod20.z.string().min(1).max(200).optional().meta({
4762
5336
  description: 'The name the client calls itself. Optional: RFC 7591 makes every metadata field optional, so a registration without one is recorded under a default name. It is **not** vouched for by Fleetless and must never be rendered as if it were: a self-registered client chooses this string, and one has called itself *"Fleetless Official Helper"*.'
4763
5337
  }),
4764
- token_endpoint_auth_method: import_zod18.z.enum(["none"]).optional().meta({
5338
+ token_endpoint_auth_method: import_zod20.z.enum(["none"]).optional().meta({
4765
5339
  description: "`none`, RFC 7591's value for a public client, and the only value either server registers. Any other value is **refused rather than silently downgraded**: a client that believes it holds a secret and does not has a wrong mental model of its own security. There is no client secret to hold \u2014 mandatory PKCE (`S256`) is the defence."
4766
5340
  }),
4767
- grant_types: import_zod18.z.array(import_zod18.z.enum(["authorization_code", "refresh_token"])).optional().meta({
5341
+ grant_types: import_zod20.z.array(import_zod20.z.enum(["authorization_code", "refresh_token"])).optional().meta({
4768
5342
  description: "Accepted for conformance with RFC 7591 and then **ignored**: both MCP authorization servers grant `authorization_code` and `refresh_token` to every registration, and the answer states what was granted (\xA73.2.1) rather than what was asked."
4769
5343
  }),
4770
- response_types: import_zod18.z.array(import_zod18.z.enum(["code"])).optional().meta({
5344
+ response_types: import_zod20.z.array(import_zod20.z.enum(["code"])).optional().meta({
4771
5345
  description: "Accepted for conformance and then **ignored**; the response names `code`, which is the only response type OAuth 2.1 leaves, the implicit grant having been removed."
4772
5346
  }),
4773
- scope: import_zod18.z.string().max(500).optional().meta({
5347
+ scope: import_zod20.z.string().max(500).optional().meta({
4774
5348
  description: "Accepted for conformance and then **ignored**. This authorization server issues no scopes at all, which is why the registration answer carries no `scope` field to echo one back in."
4775
5349
  })
4776
5350
  }).meta({
4777
5351
  description: "What an MCP client sends to register itself, per RFC 7591. Unknown metadata is ignored rather than refused (\xA73.1), and the answer states what was actually granted rather than what was asked for (\xA73.2.1)."
4778
5352
  });
4779
- var dynamicClientRegistrationResponse = import_zod18.z.object({
4780
- client_id: import_zod18.z.string().min(1).max(200).meta({
5353
+ var dynamicClientRegistrationResponse = import_zod20.z.object({
5354
+ client_id: import_zod20.z.string().min(1).max(200).meta({
4781
5355
  description: "The identifier this client sends at the authorize and token endpoints. Opaque, and not the app identifier."
4782
5356
  }),
4783
- client_name: import_zod18.z.string().min(1).max(200).meta({
5357
+ client_name: import_zod20.z.string().min(1).max(200).meta({
4784
5358
  description: "The name the client registered under, echoed back. Chosen by the client and not vouched for by Fleetless."
4785
5359
  }),
4786
- redirect_uris: import_zod18.z.array(redirectUri).meta({
5360
+ redirect_uris: import_zod20.z.array(redirectUri).meta({
4787
5361
  description: "The redirect URIs this registration was accepted for. A code is returned to one of these and nowhere else."
4788
5362
  }),
4789
- grant_types: import_zod18.z.array(import_zod18.z.string()).meta({
5363
+ grant_types: import_zod20.z.array(import_zod20.z.string()).meta({
4790
5364
  description: 'The grants this client may use. Always exactly `["authorization_code", "refresh_token"]` \u2014 an exchange mints a refresh token and the token endpoint rotates it.'
4791
5365
  }),
4792
- response_types: import_zod18.z.array(import_zod18.z.string()).meta({
5366
+ response_types: import_zod20.z.array(import_zod20.z.string()).meta({
4793
5367
  description: "The response types this client may ask for: `code`."
4794
5368
  }),
4795
- token_endpoint_auth_method: import_zod18.z.literal("none").meta({
5369
+ token_endpoint_auth_method: import_zod20.z.literal("none").meta({
4796
5370
  description: "`none` \u2014 this server registers public clients only, and PKCE rather than a secret is what protects the exchange."
4797
5371
  }),
4798
- client_id_issued_at: import_zod18.z.number().int().nonnegative().meta({
5372
+ client_id_issued_at: import_zod20.z.number().int().nonnegative().meta({
4799
5373
  description: "When the registration was created, in seconds since the epoch, per RFC 7591."
4800
5374
  }),
4801
- client_secret_expires_at: import_zod18.z.literal(0).meta({
5375
+ client_secret_expires_at: import_zod20.z.literal(0).meta({
4802
5376
  description: "Always `0`, which is RFC 7591's way of saying the client secret never expires \u2014 there is none. The **registration** itself does expire: a self-registered client that never completes a flow is an unauthenticated write somebody left behind."
4803
5377
  })
4804
5378
  });
4805
- var oauthCodeTokenRequest = import_zod18.z.object({
4806
- grant_type: import_zod18.z.literal("authorization_code").meta({
5379
+ var oauthCodeTokenRequest = import_zod20.z.object({
5380
+ grant_type: import_zod20.z.literal("authorization_code").meta({
4807
5381
  description: "`authorization_code`: this request exchanges the code from the authorize redirect for an access token and a refresh token."
4808
5382
  }),
4809
- code: import_zod18.z.string().min(1).max(500).meta({
5383
+ code: import_zod20.z.string().min(1).max(500).meta({
4810
5384
  description: "The authorization code from the redirect. It may be exchanged once; a second presentation is `invalid_grant`, the same answer a fabricated code gets."
4811
5385
  }),
4812
5386
  redirect_uri: redirectUri.meta({
4813
5387
  description: "The same redirect URI the authorize request used. It is compared, not merely recorded."
4814
5388
  }),
4815
- client_id: import_zod18.z.string().min(1).max(200).meta({
5389
+ client_id: import_zod20.z.string().min(1).max(200).meta({
4816
5390
  description: "The client making the exchange, as registered."
4817
5391
  }),
4818
- code_verifier: import_zod18.z.string().regex(/^[A-Za-z0-9\-._~]{43,128}$/, "code_verifier must be 43-128 unreserved characters (RFC 7636 \xA74.1)").meta({
5392
+ code_verifier: import_zod20.z.string().regex(/^[A-Za-z0-9\-._~]{43,128}$/, "code_verifier must be 43-128 unreserved characters (RFC 7636 \xA74.1)").meta({
4819
5393
  description: "The PKCE verifier whose `S256` hash was sent as the challenge at the authorize step. Between `43` and `128` unreserved characters, per RFC 7636 \xA74.1 \u2014 it is compared rather than parsed, so a length nobody checks is a length an attacker chooses. PKCE is mandatory for every client under OAuth 2.1."
4820
5394
  }),
4821
- resource: import_zod18.z.url().optional().meta({
5395
+ resource: import_zod20.z.url().optional().meta({
4822
5396
  description: "The resource the token is requested for, per RFC 8707. It must match the audience the code was authorized for, or the answer is `invalid_target`; omitted, the code's own audience stands. It becomes the token's `aud`, and a resource refuses a token whose audience names something else \u2014 which is what keeps a token minted for one app out of another app's endpoint."
4823
5397
  })
4824
5398
  }).meta({
4825
5399
  description: "RFC 6749 \xA74.1.3's authorization-code exchange with PKCE, as either MCP authorization server reads it. Sent as `application/x-www-form-urlencoded`, per \xA74.1.3, though the server accepts a JSON body too."
4826
5400
  });
4827
- var oauthRefreshTokenRequest = import_zod18.z.object({
4828
- grant_type: import_zod18.z.literal("refresh_token").meta({
5401
+ var oauthRefreshTokenRequest = import_zod20.z.object({
5402
+ grant_type: import_zod20.z.literal("refresh_token").meta({
4829
5403
  description: "`refresh_token`: this request rotates a refresh token into a new access token and a new refresh token. The presented token is consumed; presenting it again revokes the whole session."
4830
5404
  }),
4831
- refresh_token: import_zod18.z.string().min(1).max(500).meta({
5405
+ refresh_token: import_zod20.z.string().min(1).max(500).meta({
4832
5406
  description: "The refresh token from the last token response. Bound to the client that received it and to one identity space: presented by another client, or at the other MCP server, it is `invalid_grant` and stays unconsumed."
4833
5407
  }),
4834
- client_id: import_zod18.z.string().min(1).max(200).meta({
5408
+ client_id: import_zod20.z.string().min(1).max(200).meta({
4835
5409
  description: "The client the refresh token was issued to, as registered. A refresh token is not transferable between clients."
4836
5410
  }),
4837
- resource: import_zod18.z.url().optional().meta({
5411
+ resource: import_zod20.z.url().optional().meta({
4838
5412
  description: "The resource the new token is for, per RFC 8707. Optional; when named it must be the audience the session was issued for, or the answer is `invalid_target` and the refresh token is left untouched. The successor carries the same audience either way."
4839
5413
  })
4840
5414
  }).meta({
4841
5415
  description: "RFC 6749 \xA76's refresh, as either MCP authorization server reads it. Every use rotates: the answer carries a new refresh token and the presented one is dead."
4842
5416
  });
4843
- var oauthTokenRequest = import_zod18.z.discriminatedUnion("grant_type", [oauthCodeTokenRequest, oauthRefreshTokenRequest]).meta({
5417
+ var oauthTokenRequest = import_zod20.z.discriminatedUnion("grant_type", [oauthCodeTokenRequest, oauthRefreshTokenRequest]).meta({
4844
5418
  description: "What an MCP token endpoint accepts: the authorization-code exchange, or a refresh. Any other `grant_type` is `unsupported_grant_type`, refused before a lookup happens."
4845
5419
  });
4846
- var oauthTokenResponse = import_zod18.z.object({
4847
- access_token: import_zod18.z.string().min(1).meta({
5420
+ var oauthTokenResponse = import_zod20.z.object({
5421
+ access_token: import_zod20.z.string().min(1).meta({
4848
5422
  description: "The bearer token. It is the same token the client login mints \u2014 only the envelope differs, because an RFC-compliant client parses this one and knows nothing about Fleetless."
4849
5423
  }),
4850
- token_type: import_zod18.z.literal("Bearer").meta({
5424
+ token_type: import_zod20.z.literal("Bearer").meta({
4851
5425
  description: "`Bearer`. RFC 6749 \xA75.1 makes the value case-insensitive for a client reading it; this is the spelling this server emits."
4852
5426
  }),
4853
- expires_in: import_zod18.z.number().int().positive().meta({
5427
+ expires_in: import_zod20.z.number().int().positive().meta({
4854
5428
  description: "How long the access token is valid, in **seconds**, per RFC 6749 \xA75.1. Not a timestamp, and not milliseconds."
4855
5429
  }),
4856
- refresh_token: import_zod18.z.string().min(1).optional().meta({
5430
+ refresh_token: import_zod20.z.string().min(1).optional().meta({
4857
5431
  description: "The refresh token. Both MCP token endpoints issue one on every exchange and every refresh; it rotates on every use, lives ninety days from its last use, and dies with the account's sessions \u2014 a block, a password change, a withdrawn consent. The console's own OAuth portal issues none."
4858
5432
  }),
4859
- scope: import_zod18.z.string().max(500).optional().meta({
5433
+ scope: import_zod20.z.string().max(500).optional().meta({
4860
5434
  description: "The scopes the issued token actually carries, space-separated."
4861
5435
  })
4862
5436
  });
4863
- var authorizationServerMetadata = import_zod18.z.object({
4864
- issuer: import_zod18.z.url().meta({
5437
+ var authorizationServerMetadata = import_zod20.z.object({
5438
+ issuer: import_zod20.z.url().meta({
4865
5439
  description: "The issuer identifier of this authorization server, per RFC 8414 \xA72. It is what a client checks a token's `iss` against."
4866
5440
  }),
4867
- authorization_endpoint: import_zod18.z.url().meta({
5441
+ authorization_endpoint: import_zod20.z.url().meta({
4868
5442
  description: "Where a client sends the user to authorize."
4869
5443
  }),
4870
- token_endpoint: import_zod18.z.url().meta({
5444
+ token_endpoint: import_zod20.z.url().meta({
4871
5445
  description: "The URL where a client exchanges an authorization code, or a refresh token, for tokens."
4872
5446
  }),
4873
- registration_endpoint: import_zod18.z.url().optional().meta({
5447
+ registration_endpoint: import_zod20.z.url().optional().meta({
4874
5448
  description: "The URL where a client may register itself, per RFC 7591. Absent when the app does not accept dynamic clients."
4875
5449
  }),
4876
- response_types_supported: import_zod18.z.array(import_zod18.z.literal("code")).meta({
5450
+ response_types_supported: import_zod20.z.array(import_zod20.z.literal("code")).meta({
4877
5451
  description: "The response types this server offers: `code` only, the implicit grant being gone with OAuth 2.1."
4878
5452
  }),
4879
- grant_types_supported: import_zod18.z.array(import_zod18.z.enum(["authorization_code", "refresh_token"])).meta({
5453
+ grant_types_supported: import_zod20.z.array(import_zod20.z.enum(["authorization_code", "refresh_token"])).meta({
4880
5454
  description: "The grants this server offers: `authorization_code` and `refresh_token`. OAuth 2.1 removes the implicit and password grants, so neither appears here."
4881
5455
  }),
4882
- code_challenge_methods_supported: import_zod18.z.array(codeChallengeMethod).meta({
5456
+ code_challenge_methods_supported: import_zod20.z.array(codeChallengeMethod).meta({
4883
5457
  description: "The PKCE challenge methods accepted: `S256` only. `plain` is not offered \u2014 a challenge equal to its verifier defends against nothing, and offering it would make a downgrade negotiable."
4884
5458
  }),
4885
- token_endpoint_auth_methods_supported: import_zod18.z.array(import_zod18.z.literal("none")).meta({
5459
+ token_endpoint_auth_methods_supported: import_zod20.z.array(import_zod20.z.literal("none")).meta({
4886
5460
  description: "How a client authenticates at the token endpoint: `none`, the public-client method, with PKCE protecting the exchange."
4887
5461
  }),
4888
- scopes_supported: import_zod18.z.array(import_zod18.z.string()).optional().meta({
5462
+ scopes_supported: import_zod20.z.array(import_zod20.z.string()).optional().meta({
4889
5463
  description: "The scopes this server knows about, where it publishes a list."
4890
5464
  })
4891
5465
  });
4892
- var protectedResourceMetadata = import_zod18.z.object({
4893
- resource: import_zod18.z.url().meta({
5466
+ var protectedResourceMetadata = import_zod20.z.object({
5467
+ resource: import_zod20.z.url().meta({
4894
5468
  description: "The resource identifier this document describes, per RFC 9728. A token whose audience names something else is rejected here rather than merely noted."
4895
5469
  }),
4896
- authorization_servers: import_zod18.z.array(import_zod18.z.url()).min(1).meta({
5470
+ authorization_servers: import_zod20.z.array(import_zod20.z.url()).min(1).meta({
4897
5471
  description: "The authorization servers that may issue tokens for this resource. There is always at least one."
4898
5472
  }),
4899
- bearer_methods_supported: import_zod18.z.array(import_zod18.z.literal("header")).meta({
5473
+ bearer_methods_supported: import_zod20.z.array(import_zod20.z.literal("header")).meta({
4900
5474
  description: "How a token may be presented: in the `Authorization` header only, never in a query parameter or a form field."
4901
5475
  }),
4902
- scopes_supported: import_zod18.z.array(import_zod18.z.string()).optional().meta({
5476
+ scopes_supported: import_zod20.z.array(import_zod20.z.string()).optional().meta({
4903
5477
  description: "The scopes this resource understands, where it publishes a list."
4904
5478
  })
4905
5479
  });
4906
- var oauthRedirectResponse = import_zod18.z.object({
4907
- redirect_to: import_zod18.z.string().min(1).max(2e3)
5480
+ var oauthRedirectResponse = import_zod20.z.object({
5481
+ redirect_to: import_zod20.z.string().min(1).max(2e3)
4908
5482
  });
4909
- var oauthAuthorizeQuery = import_zod18.z.object({
4910
- response_type: import_zod18.z.literal("code").meta({
5483
+ var oauthAuthorizeQuery = import_zod20.z.object({
5484
+ response_type: import_zod20.z.literal("code").meta({
4911
5485
  description: "Always `code`. RFC 6749 \xA74.1.2.1 names `unsupported_response_type` for any other value, but `oauthErrorCode` has no such member \u2014 this server issues no other grant from this endpoint \u2014 so an unsupported value comes back on the callback as `invalid_request`."
4912
5486
  }),
4913
- client_id: import_zod18.z.string().min(1).meta({
5487
+ client_id: import_zod20.z.string().min(1).meta({
4914
5488
  description: "The OAuth client, self-registered or the one well-known central client \u2014 **not** the app identifier. Unknown, expired-dynamic and mismatched clients all collapse into the same `400 invalid_client`, answered without a redirect."
4915
5489
  }),
4916
- redirect_uri: import_zod18.z.string().min(1).meta({
5490
+ redirect_uri: import_zod20.z.string().min(1).meta({
4917
5491
  description: "One of the client's registered redirect URIs, compared **exactly** \u2014 string equality against the registered list, never a prefix or a host match. Both the shape (`redirectUri`) and the registration are checked, and a failure of either is a `400 invalid_request` with no redirect."
4918
5492
  }),
4919
- code_challenge: import_zod18.z.string().min(1).meta({
5493
+ code_challenge: import_zod20.z.string().min(1).meta({
4920
5494
  description: "The PKCE challenge; the verifier is presented at the token endpoint. Only non-emptiness is checked here \u2014 length and alphabet are not \u2014 since the verifier is what has to match."
4921
5495
  }),
4922
- code_challenge_method: import_zod18.z.literal("S256").meta({
5496
+ code_challenge_method: import_zod20.z.literal("S256").meta({
4923
5497
  description: "Only `S256`. `plain` is refused: a challenge equal to its verifier defends against nothing."
4924
5498
  }),
4925
- state: import_zod18.z.string().optional().meta({
5499
+ state: import_zod20.z.string().optional().meta({
4926
5500
  description: "Returned unchanged on the callback, and on the error redirect too, so a client can bind either answer to its own request."
4927
5501
  }),
4928
- resource: import_zod18.z.string().optional().meta({
5502
+ resource: import_zod20.z.string().optional().meta({
4929
5503
  description: "RFC 8707 resource indicator: the API origin or the MCP endpoint the token is for. Checked against the resources this server issues tokens for **on behalf of this client's app**; a mismatch is `invalid_target` on the callback."
4930
5504
  })
4931
5505
  // **No `scope`, because this authorization server issues none.** The field
@@ -4938,7 +5512,7 @@ var oauthAuthorizeQuery = import_zod18.z.object({
4938
5512
  description: "The authorization request an MCP client sends, per RFC 6749 \xA74.1.1 with mandatory PKCE. The handler reads it parameter by parameter rather than through one parse, because the answers differ: `client_id` and `redirect_uri` are refused flat, with no redirect, since until both are confirmed there is no trusted target to bounce a browser to, and everything after them is reported to the client's own callback as query parameters."
4939
5513
  });
4940
5514
 
4941
- // node_modules/.pnpm/@fleetless+contracts@5.1.0/node_modules/@fleetless/contracts/dist/routes.js
5515
+ // node_modules/.pnpm/@fleetless+contracts@6.2.0-next.2/node_modules/@fleetless/contracts/dist/routes.js
4942
5516
  var MCP_APP = MCP_APP_PATHS(":appIdentifier");
4943
5517
  var APP_IDENTIFIER = {
4944
5518
  name: "appIdentifier",
@@ -4955,11 +5529,123 @@ var IN_HANDLER_ROUTES = [
4955
5529
  // The one route whose bearer is **optional**: it answers the same document
4956
5530
  // with or without one, and only `already_granted` moves.
4957
5531
  "GET /api/client/mcp/interactions/:id",
5532
+ // Two-factor setup: during sign-in the challenge in the body is the
5533
+ // credential, from the app's account settings the app user's bearer is.
5534
+ // Either one, decided in the handler.
5535
+ "POST /api/client/two-factor/setup",
5536
+ "POST /api/client/two-factor/setup/confirm",
4958
5537
  "GET /api/asset-links/missing",
4959
5538
  "GET /api/asset-links/:token"
4960
5539
  ];
4961
5540
  var DEVELOPER_GUARD = ["unauthorized", "token_expired", "token_revoked"];
4962
5541
  var CLIENT_GUARD = ["unauthorized", "token_expired", "token_revoked", "forbidden"];
5542
+ function developerSignInRoutes(prefix) {
5543
+ const section = prefix === "/console/oauth" ? "developer-auth" : "mcp";
5544
+ const done = prefix === "/console/oauth" ? "the console callback with the authorization code" : "the consent screen for a self-registered client, or straight to the callback for the central one";
5545
+ const page = (path, summary, notes) => ({
5546
+ method: "GET",
5547
+ path: `${prefix}${path}`,
5548
+ section,
5549
+ summary,
5550
+ audience: "internal",
5551
+ auth: "none",
5552
+ rateLimited: false,
5553
+ ownerTier: false,
5554
+ status: 200,
5555
+ params: [{ name: "id", description: "The interaction id of this sign-in; the step before redirects the browser here." }],
5556
+ query: null,
5557
+ request: null,
5558
+ response: null,
5559
+ errors: [],
5560
+ transport: "http",
5561
+ notes
5562
+ });
5563
+ const step = (path, summary, response, errors, notes) => ({
5564
+ method: "POST",
5565
+ path: `${prefix}${path}`,
5566
+ section,
5567
+ summary,
5568
+ audience: "internal",
5569
+ auth: "none",
5570
+ rateLimited: true,
5571
+ ownerTier: false,
5572
+ status: 200,
5573
+ params: [],
5574
+ query: null,
5575
+ request: null,
5576
+ response,
5577
+ errors: ["rate_limited", ...errors],
5578
+ transport: "http",
5579
+ notes
5580
+ });
5581
+ return [
5582
+ step("/identify", "Takes the email address, mails a sign-in code and hands back the code step.", null, ["validation_error", "token_spent", "wrong_browser"], `The page answers \`Check your email\` **for every address**: a known one gets \`Your Fleetless sign-in code\`, six digits valid ten minutes; an unknown one gets a mail saying no Fleetless account uses it, with a link to sign up (or the waiting list while sign-up is closed). So the page never reveals who has an account, and the address is trimmed and compared case-insensitively. A request within sixty seconds of the last one for the same address renders the same page without a second mail. The browser-proof cookie is set here when the interaction has none yet, and checked when it has. A browser form post gets the code card; a JSON caller gets \`{ "next": "${prefix}/code" }\`, which has no schema. A dead interaction is \`410 token_spent\`.`),
5583
+ step("/code", "Checks the emailed code and finishes the sign-in, or hands back the second step.", oauthRedirectResponse, ["validation_error", "token_spent", "wrong_browser", "invalid_code"], `A wrong code renders the code card again with the attempts left (\`400 invalid_code\`, \`details.attempts_left\`); a code spent, past its ten minutes or out of its five attempts is \`410 token_spent\` and a new one has to be asked for. Spaces inside a typed code are removed before it is checked. With no second factor to give, a browser gets a \`303\` to ${done}, and a JSON caller that URL as \`redirect_to\`. Otherwise the next step: a JSON caller gets \`{ "next" }\` naming \`` + prefix + "/two-factor` \u2014 the person has a passkey or an authenticator \u2014 or `" + prefix + "/two-factor/setup` \u2014 the organisation requires one and the person has none \u2014 and a browser the page itself. Audited as `developer.login` with `details.method` `email_code` once the sign-in completes."),
5584
+ page("/two-factor/:id", "Serves the second step: the authenticator code, or the passkey prompt.", "HTML. Six boxes for the authenticator code, `Use a passkey`, and `Use a recovery code`; a developer with passkeys only sees the passkey prompt directly. The browser-proof cookie is checked on this GET too. A dead interaction renders the `410` page."),
5585
+ page("/two-factor/:id/recovery", "Serves the recovery-code page of the second step.", "HTML. One field for a recovery code, and the way out when none is left: an owner of the organisation can reset the member's two-factor in Settings \u203A Team."),
5586
+ step("/two-factor", "Checks an authenticator code or a recovery code and finishes the sign-in.", oauthRedirectResponse, ["validation_error", "token_spent", "wrong_browser", "invalid_code"], `The form carries either \`code\` or \`recovery_code\`. **An authenticator code is accepted at most once**, so the same code sent twice finishes one sign-in. A recovery code is spent by its use. Five wrong codes end the interaction (\`410 token_spent\`). A browser gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`),
5587
+ step("/passkey/options", "Answers the WebAuthn request options for a passkey sign-in or second step.", webauthnOptionsResponse, ["token_spent", "wrong_browser"], "Before an address is known the options name no credential, so the browser offers every discoverable passkey for `fleetless.dev` (`Sign in with a passkey`); after the code step they name the account's own passkeys. User verification is required. The challenge is bound to the interaction and single-use. **This is the first step of a passkey sign-in**, which has no email step: the browser-proof cookie is set here when the interaction has none yet, and checked when it has \u2014 so `POST " + prefix + "/passkey` can require it."),
5588
+ step("/passkey", "Checks a passkey assertion; a passkey completes the sign-in on its own.", oauthRedirectResponse, ["validation_error", "token_spent", "wrong_browser", "invalid_credentials"], `**A passkey is a full sign-in**: it proves possession and user verification, two factors, so it skips the emailed code and the second step \u2014 used as the second step, it finishes it. An assertion that does not verify, or names no passkey of an account, is \`401 invalid_credentials\`, the same answer for both. When the organisation requires two-factor, a passkey satisfies it. A browser gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`. Audited as \`developer.login\` with \`details.method\` \`passkey\`.`),
5589
+ page("/two-factor/setup/:id", 'Serves the "your organisation requires two-factor" choice between a passkey and an authenticator.', "HTML. Reached when the organisation requires two-factor and the person has none; no session exists until the setup is done. Two options, the passkey recommended because it also signs the person in without an emailed code. `Signed in as <email> \xB7 Sign out` under it."),
5590
+ step("/two-factor/setup/totp", "Starts an authenticator setup inside the sign-in and hands back its QR code and key.", twoFactorSetupResponse, ["token_spent", "wrong_browser"], "A browser gets the page with the QR code, the key and the confirm field; a JSON caller the secret and `otpauth_url`. Nothing is stored as confirmed yet."),
5591
+ step("/two-factor/setup/totp/confirm", "Confirms the new authenticator and hands back the ten recovery codes.", recoveryCodesResponse, ["validation_error", "token_spent", "wrong_browser", "invalid_code"], "A code that does not match is `400 invalid_code`. On success the authenticator is on and ten recovery codes are shown once; `I saved my recovery codes` then finishes the sign-in. Audited as `developer.two_factor_added` with `details.kind` `authenticator`."),
5592
+ step("/two-factor/setup/passkey/options", "Answers the WebAuthn creation options for a passkey set up inside the sign-in.", webauthnOptionsResponse, ["token_spent", "wrong_browser"], "The same options `POST /api/auth/passkeys/options` answers a signed-in developer: relying party `fleetless.dev`, user verification required, a discoverable credential."),
5593
+ step("/two-factor/setup/passkey", "Registers the passkey and hands back the ten recovery codes.", recoveryCodesResponse, ["validation_error", "token_spent", "wrong_browser"], "A ceremony that does not verify is `400 validation_error`. On success the passkey is stored, named after the browser's device where it says so, and ten recovery codes are shown once. Audited as `developer.two_factor_added` with `details.kind` `passkey`."),
5594
+ step("/two-factor/setup/done", "Finishes the sign-in once the recovery codes are saved.", oauthRedirectResponse, ["token_spent", "wrong_browser"], `\`I saved my recovery codes\` gates the button on the page; the step itself only checks that a second factor now exists. A browser gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`)
5595
+ ];
5596
+ }
5597
+ var HOSTED_TOKEN = {
5598
+ name: "token",
5599
+ description: "The opaque token from the mailed link; it is never sent as a query parameter."
5600
+ };
5601
+ var HOSTED_INTERACTION = {
5602
+ name: "interaction",
5603
+ description: "The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL."
5604
+ };
5605
+ function hostedPage(method, path, summary, notes, params = []) {
5606
+ return {
5607
+ method,
5608
+ path: `/app/:appIdentifier${path}`,
5609
+ section: "client-auth",
5610
+ summary,
5611
+ audience: "internal",
5612
+ auth: "none",
5613
+ rateLimited: method === "POST",
5614
+ ownerTier: false,
5615
+ status: 200,
5616
+ params: [APP_IDENTIFIER, ...params],
5617
+ query: null,
5618
+ request: null,
5619
+ response: null,
5620
+ errors: method === "POST" ? ["rate_limited"] : [],
5621
+ transport: "http",
5622
+ notes
5623
+ };
5624
+ }
5625
+ var HOSTED_FORM = "Renders a form; only its `POST` spends the token, so a mail scanner opening the link changes nothing. ";
5626
+ var HOSTED_DEAD = "A spent, expired or unknown token renders the `410` page with the next step for its kind.";
5627
+ var HOSTED_POST = "HTML: the next page on success, the same page with the problem named on a refusal. ";
5628
+ var HOSTED_APP_ROUTES = [
5629
+ hostedPage("GET", "/logo", "Serves the app's logo for its hosted pages.", "The stored PNG or SVG, as written through `PUT /api/apps/:id/auth-config/logo`; `404` when the app has none. **Served sandboxed**: `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`, so a script inside an SVG never runs, even opened directly. The hosted pages embed it as an image only."),
5630
+ hostedPage("GET", "/mcp/:interaction", "Serves the hosted MCP sign-in, the page an unset `mcp_login_url` falls back to.", "HTML: the app's identity providers, then the sign-in the app offers \u2014 email and password with `Email me a sign-in code instead`, or email and `Email me a code` for a code-only app \u2014 and `Create one` when self-registration is on. A dead interaction renders the `410` page. The browser-proof cookie is set here.", [HOSTED_INTERACTION]),
5631
+ hostedPage("POST", "/mcp/:interaction/password", "Checks the email and password of the hosted MCP sign-in.", HOSTED_POST + "One refusal for every miss, as `POST /api/client/login` answers. With a second factor to give, the two-factor page follows; otherwise the consent page.", [HOSTED_INTERACTION]),
5632
+ hostedPage("POST", "/mcp/:interaction/code", "Mails a sign-in code for the hosted MCP sign-in and renders the code page.", HOSTED_POST + "The same page for a known and an unknown address, as `POST /api/client/login/code` answers; six boxes, `Resend code in 0:42`, and `Use password instead` where the app has passwords on.", [HOSTED_INTERACTION]),
5633
+ hostedPage("POST", "/mcp/:interaction/code/verify", "Checks the emailed code of the hosted MCP sign-in.", HOSTED_POST + "A wrong code renders the code page with the attempts left; a spent one asks for a new code. With a second factor to give, the two-factor page follows; otherwise the consent page.", [HOSTED_INTERACTION]),
5634
+ hostedPage("POST", "/mcp/:interaction/consent", "Records the allow-or-deny of the hosted MCP sign-in and sends the browser back to the client.", "Answers a `303` to the MCP client's callback, carrying the code or `error=access_denied`, as `POST /api/client/mcp/interactions/:id/approve` and `\u2026/deny` do. Fail-closed: anything but the Allow value denies. The browser-proof cookie set at the sign-in must match, or the `wrong_browser` page renders.", [HOSTED_INTERACTION]),
5635
+ hostedPage("POST", "/two-factor", "Checks an authenticator or recovery code on a hosted page and finishes the sign-in it interrupted.", HOSTED_POST + "The form carries the challenge the step before answered and either `code` or `recovery_code`, checked as `POST /api/client/two-factor/verify` checks them. Success continues where the sign-in was going: the MCP consent, or the done page."),
5636
+ hostedPage("POST", "/two-factor/setup", "Starts an authenticator setup on a hosted page, when the app requires two-factor.", HOSTED_POST + "Renders the QR code, the key with `Copy key`, and the confirm field, for the challenge the step before answered."),
5637
+ hostedPage("POST", "/two-factor/setup/confirm", "Confirms the new authenticator on a hosted page and shows the ten recovery codes.", HOSTED_POST + "A wrong code renders the setup page again. On success the ten recovery codes are shown once, with `Copy` and `Download .txt`; `I saved my recovery codes` gates `Continue`."),
5638
+ hostedPage("GET", "/invite/:token", "Serves the hosted invitation page, the one an unset `invite_url` falls back to.", "HTML. " + HOSTED_FORM + "Asks for a name, and a password only while the app has passwords on; announces the two-factor setup when the app requires it. " + HOSTED_DEAD, [HOSTED_TOKEN]),
5639
+ hostedPage("POST", "/invite", "Accepts an invitation from the hosted invitation page.", HOSTED_POST + "Spends the token as `POST /api/client/invitations/accept` does; the two-factor setup follows when the app requires it, then the done page."),
5640
+ hostedPage("GET", "/reset/:token", "Serves the hosted new-password page, the one an unset `reset_url` falls back to.", "HTML. " + HOSTED_FORM + "Says that two-factor stays on. " + HOSTED_DEAD, [HOSTED_TOKEN]),
5641
+ hostedPage("POST", "/reset", "Sets the new password from the hosted new-password page.", HOSTED_POST + "Spends the token as `POST /api/client/password/reset/confirm` does; a person with an authenticator gives a code before the done page."),
5642
+ hostedPage("GET", "/forgot", 'Serves the hosted "forgot your password" page.', "HTML: the address field. Reached from the hosted sign-in and the dead-link page."),
5643
+ hostedPage("POST", "/forgot", 'Mails a reset link from the hosted "forgot your password" page.', HOSTED_POST + 'The same "check your mail" page for a known and an unknown address, as `POST /api/client/password/reset` answers.'),
5644
+ hostedPage("GET", "/sign-up", "Serves the hosted sign-up page, when the app has self-registration on.", 'HTML: the address, a password while the app has passwords on, and an optional name. With self-registration off it renders the "registration closed" page.'),
5645
+ hostedPage("POST", "/sign-up", "Creates an account from the hosted sign-up page and mails the confirmation link.", HOSTED_POST + 'Registers as `POST /api/client/register` does, and renders the same "check your mail" page for a new and a known address.'),
5646
+ hostedPage("GET", "/verify/:token", "Serves the hosted email-confirmation page, the one an unset `verify_url` falls back to.", "HTML. " + HOSTED_FORM + HOSTED_DEAD, [HOSTED_TOKEN]),
5647
+ hostedPage("POST", "/verify", "Confirms the address from the hosted email-confirmation page.", HOSTED_POST + "Spends the token as `POST /api/client/verify-email` does; the two-factor setup follows when the app requires it, then the done page.")
5648
+ ];
4963
5649
  var ROUTES = [
4964
5650
  /* ------------------------------------------------------------- health */
4965
5651
  {
@@ -4981,24 +5667,6 @@ var ROUTES = [
4981
5667
  notes: "Answers `{ ok, dependencies: { database, storage, liveKit } }` \u2014 a cloud-local shape, not a wire contract, so nothing here pins it. The status is always `200`: each dependency is probed independently and a failure is reported in the body rather than thrown, because `/healthz` must never itself be a reason the process looks down. Read `ok`, not the status code."
4982
5668
  },
4983
5669
  /* ----------------------------------------------------- developer auth */
4984
- {
4985
- method: "POST",
4986
- path: "/api/auth/signup",
4987
- section: "developer-auth",
4988
- summary: "Creates an org and its founding Owner, and answers a developer session.",
4989
- audience: "developer",
4990
- auth: "none",
4991
- rateLimited: true,
4992
- ownerTier: false,
4993
- status: 201,
4994
- params: [],
4995
- query: null,
4996
- request: signUpRequest,
4997
- response: signUpResponse,
4998
- errors: ["rate_limited", "signup_closed", "validation_error", "email_taken"],
4999
- transport: "http",
5000
- notes: "While the deployment runs in closed beta this answers `403 signup_closed` before it looks at the body \u2014 there is nothing for a validation message, or an `email_taken` answer, to be right about when nothing will be created. Email is globally unique, so an address already registered in any org is refused."
5001
- },
5002
5670
  {
5003
5671
  method: "POST",
5004
5672
  path: "/api/auth/refresh",
@@ -5015,7 +5683,7 @@ var ROUTES = [
5015
5683
  response: sessionTokens,
5016
5684
  errors: ["rate_limited", "validation_error", "token_expired", "token_revoked"],
5017
5685
  transport: "http",
5018
- notes: "The whole family is re-checked here, not just the token: an account that has been removed from the org, or whose `token_version` was bumped by a password change, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes would leave a session that is dead everywhere but here."
5686
+ notes: "The whole family is re-checked here, not just the token: an account that has been removed from the org, or whose `token_version` was bumped by an owner's two-factor reset, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes would leave a session that is dead everywhere but here."
5019
5687
  },
5020
5688
  {
5021
5689
  method: "POST",
@@ -5070,11 +5738,12 @@ var ROUTES = [
5070
5738
  transport: "http",
5071
5739
  notes: "No Owner tier: this can only ever touch the caller's own row, so there is nothing for a tier check to gate. Saving the name already held writes nothing and records no audit event \u2014 the org activity stream reaches every developer with the console open, and an event for a no-op would misreport that something changed."
5072
5740
  },
5741
+ /* ------------------------------------ a developer's own second factors */
5073
5742
  {
5074
- method: "POST",
5075
- path: "/api/auth/password/change",
5743
+ method: "GET",
5744
+ path: "/api/auth/two-factor",
5076
5745
  section: "developer-auth",
5077
- summary: "Verifies the current password, sets a new one and answers a fresh session.",
5746
+ summary: "Answers the calling developer's passkeys, authenticator, recovery codes left and the org's policy.",
5078
5747
  audience: "developer",
5079
5748
  auth: "developer",
5080
5749
  rateLimited: false,
@@ -5082,102 +5751,173 @@ var ROUTES = [
5082
5751
  status: 200,
5083
5752
  params: [],
5084
5753
  query: null,
5085
- request: passwordChangeRequest,
5086
- response: sessionTokens,
5087
- errors: [...DEVELOPER_GUARD, "validation_error", "invalid_credentials"],
5754
+ request: null,
5755
+ response: developerTwoFactor,
5756
+ errors: [...DEVELOPER_GUARD],
5088
5757
  transport: "http",
5089
- notes: "Every session of this account ends, including the caller's \u2014 the request carries nothing identifying its own refresh family, so there is none to spare. The answer is a working replacement pair, which is what the promise has to mean when nothing distinguishes one session from another."
5758
+ notes: "What Settings \u203A Profile \u203A Security draws. No key material, secret or code travels here \u2014 the passkeys are names and dates, the authenticator is a date, the recovery codes are a count."
5090
5759
  },
5091
5760
  {
5092
5761
  method: "POST",
5093
- path: "/api/auth/password/reset",
5762
+ path: "/api/auth/passkeys/options",
5094
5763
  section: "developer-auth",
5095
- summary: "Mails a password-reset link to the address, and answers the same either way.",
5764
+ summary: "Answers the WebAuthn creation options for registering a passkey.",
5096
5765
  audience: "developer",
5097
- auth: "none",
5098
- rateLimited: true,
5766
+ auth: "developer",
5767
+ rateLimited: false,
5099
5768
  ownerTier: false,
5100
- status: 202,
5769
+ status: 200,
5101
5770
  params: [],
5102
5771
  query: null,
5103
- request: passwordResetRequest,
5104
- response: null,
5105
- errors: ["rate_limited", "validation_error"],
5772
+ request: null,
5773
+ response: webauthnOptionsResponse,
5774
+ errors: [...DEVELOPER_GUARD],
5106
5775
  transport: "http",
5107
- notes: 'Status, body and timing are identical for a known and an unknown address \u2014 any difference is an account-enumeration oracle, which is why the unknown branch still pays a real SMTP round trip to a discard address. An account provisioned through OIDC has no Fleetless password and is mailed nothing. A browser form post gets a `303` to the "check your mail" card instead of this `202`.'
5776
+ notes: "Hand `options` to the browser's WebAuthn API as it is. The relying party is `fleetless.dev`, so the passkey works on the auth portal and in the console alike; user verification is required and the credential is discoverable, so it can sign the person in without an address. The passkeys the caller already has are excluded. The challenge is single-use and expires with the ceremony."
5108
5777
  },
5109
- /* ------------------------------------------- client auth (portal pages) */
5110
5778
  {
5111
- method: "GET",
5112
- path: "/reset-password",
5113
- section: "client-auth",
5114
- summary: `Serves the auth portal's "forgot your password" card as an HTML page.`,
5115
- audience: "internal",
5116
- auth: "none",
5779
+ method: "POST",
5780
+ path: "/api/auth/passkeys",
5781
+ section: "developer-auth",
5782
+ summary: "Registers a passkey from the browser's answer to the creation options.",
5783
+ audience: "developer",
5784
+ auth: "developer",
5117
5785
  rateLimited: false,
5118
5786
  ownerTier: false,
5119
- status: 200,
5787
+ status: 201,
5120
5788
  params: [],
5121
5789
  query: null,
5122
- request: null,
5123
- response: null,
5124
- errors: [],
5790
+ request: createPasskeyRequest,
5791
+ response: createPasskeyResponse,
5792
+ errors: [...DEVELOPER_GUARD, "validation_error"],
5125
5793
  transport: "http",
5126
- notes: 'HTML, not JSON: this is a page a person opens, served by the cloud from the auth portal origin. `?sent=1` draws the "check your mail" state instead \u2014 one path, because that second card has no inputs and a second path would exist only to be redirected to. The value is caller-settable and discloses nothing, since the page it draws is a constant.'
5794
+ notes: "A ceremony that does not verify \u2014 a wrong challenge, origin or relying party, no user verification \u2014 is `400 validation_error` naming `credential`. When this is the account's first second factor, ten recovery codes are issued and answered once; otherwise `recovery_codes` is `null` and the existing ones stay valid. Audited as `developer.two_factor_added` with `details.kind` `passkey`."
5127
5795
  },
5128
5796
  {
5129
- method: "GET",
5130
- path: "/reset-password/:token",
5131
- section: "client-auth",
5132
- summary: 'Serves the "pick a new password" page for a mailed reset link.',
5133
- audience: "internal",
5134
- auth: "none",
5797
+ method: "PATCH",
5798
+ path: "/api/auth/passkeys/:id",
5799
+ section: "developer-auth",
5800
+ summary: "Renames one of the caller's passkeys.",
5801
+ audience: "developer",
5802
+ auth: "developer",
5135
5803
  rateLimited: false,
5136
5804
  ownerTier: false,
5137
5805
  status: 200,
5138
- params: [{ name: "token", description: "The opaque reset token from the mailed link; it is never sent as a query parameter." }],
5806
+ params: [{ name: "id", description: "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`." }],
5807
+ query: null,
5808
+ request: renamePasskeyRequest,
5809
+ response: developerPasskey,
5810
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5811
+ transport: "http"
5812
+ },
5813
+ {
5814
+ method: "DELETE",
5815
+ path: "/api/auth/passkeys/:id",
5816
+ section: "developer-auth",
5817
+ summary: "Removes one of the caller's passkeys.",
5818
+ audience: "developer",
5819
+ auth: "developer",
5820
+ rateLimited: false,
5821
+ ownerTier: false,
5822
+ status: 204,
5823
+ params: [{ name: "id", description: "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`." }],
5139
5824
  query: null,
5140
5825
  request: null,
5141
5826
  response: null,
5142
- errors: [],
5827
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict"],
5143
5828
  transport: "http",
5144
- notes: 'HTML. An unknown, spent or expired token renders one "link no longer valid" page at `410` \u2014 they are one refusal on the wire already, and splitting them here would tell a stranger which tokens ever existed. No rate limiter: the GET changes nothing, and the POST it leads to is limited per IP.'
5829
+ notes: "`409 target_state_conflict` names `two_factor` with rule `required_by_org` when this is the caller's last second factor and the organisation requires one. Removing the last one otherwise also voids the recovery codes. Audited as `developer.two_factor_removed` with `details.kind` `passkey`."
5145
5830
  },
5146
5831
  {
5147
- method: "GET",
5148
- path: "/favicon.svg",
5149
- section: "client-auth",
5150
- summary: "Serves the Fleetless icon for the auth portal's and the MCP welcome page's browser tab.",
5151
- audience: "internal",
5152
- auth: "none",
5832
+ method: "POST",
5833
+ path: "/api/auth/totp",
5834
+ section: "developer-auth",
5835
+ summary: "Starts an authenticator setup and answers its secret and otpauth URL.",
5836
+ audience: "developer",
5837
+ auth: "developer",
5153
5838
  rateLimited: false,
5154
5839
  ownerTier: false,
5155
5840
  status: 200,
5156
5841
  params: [],
5157
5842
  query: null,
5158
5843
  request: null,
5159
- response: null,
5160
- errors: [],
5844
+ response: twoFactorSetupResponse,
5845
+ errors: [...DEVELOPER_GUARD],
5161
5846
  transport: "http",
5162
- notes: "An SVG, not JSON. Those pages carry a Content-Security-Policy that admits no `data:` image, so the icon is a file on their own origin \u2014 the one source `img-src 'self'` names. Cached for a day: the bytes change when the brand does, not per deploy."
5847
+ notes: "The secret is pending until `POST /api/auth/totp/confirm` accepts a code from it; a second call replaces a pending secret. A developer who already has an authenticator keeps it until the new one is confirmed, which is how `Replace\u2026` works."
5163
5848
  },
5164
5849
  {
5165
5850
  method: "POST",
5166
- path: "/api/auth/password/reset/confirm",
5851
+ path: "/api/auth/totp/confirm",
5167
5852
  section: "developer-auth",
5168
- summary: "Spends a reset token, sets the new password and ends every session of the account.",
5853
+ summary: "Confirms the pending authenticator with a code it shows now.",
5169
5854
  audience: "developer",
5170
- auth: "none",
5855
+ auth: "developer",
5171
5856
  rateLimited: true,
5172
5857
  ownerTier: false,
5173
- status: 204,
5858
+ status: 200,
5859
+ params: [],
5860
+ query: null,
5861
+ request: totpConfirmRequest,
5862
+ response: totpConfirmResponse,
5863
+ errors: [...DEVELOPER_GUARD, "rate_limited", "validation_error", "invalid_code", "token_spent"],
5864
+ transport: "http",
5865
+ notes: "A code that does not match the pending secret is `400 invalid_code`; no pending setup is `410 token_spent`. On success the new authenticator replaces any earlier one. Ten recovery codes are answered when it is the account's first second factor, otherwise `null`. Audited as `developer.two_factor_added` with `details.kind` `authenticator`."
5866
+ },
5867
+ {
5868
+ method: "DELETE",
5869
+ path: "/api/auth/totp",
5870
+ section: "developer-auth",
5871
+ summary: "Removes the caller's authenticator app.",
5872
+ audience: "developer",
5873
+ auth: "developer",
5874
+ rateLimited: false,
5875
+ ownerTier: false,
5876
+ status: 204,
5174
5877
  params: [],
5175
5878
  query: null,
5176
- request: passwordResetConfirm,
5879
+ request: null,
5177
5880
  response: null,
5178
- errors: ["rate_limited", "validation_error", "token_spent"],
5881
+ errors: [...DEVELOPER_GUARD, "not_found", "target_state_conflict"],
5179
5882
  transport: "http",
5180
- notes: "Unknown, spent and expired tokens all answer `410 token_spent`. Sessions are revoked under the account's actual kind \u2014 a console admin holds developer sessions, an app user holds end-user ones \u2014 so an app user's open `/realtime` socket does not outlive the reset. A browser form post gets the rendered \"done\" page instead of this `204`."
5883
+ notes: "`404 not_found` when there is no authenticator. `409 target_state_conflict` names `two_factor` with rule `required_by_org` when it is the caller's last second factor and the organisation requires one. Audited as `developer.two_factor_removed` with `details.kind` `authenticator`."
5884
+ },
5885
+ {
5886
+ method: "POST",
5887
+ path: "/api/auth/recovery-codes",
5888
+ section: "developer-auth",
5889
+ summary: "Issues ten new recovery codes and voids the old ones.",
5890
+ audience: "developer",
5891
+ auth: "developer",
5892
+ rateLimited: false,
5893
+ ownerTier: false,
5894
+ status: 200,
5895
+ params: [],
5896
+ query: null,
5897
+ request: null,
5898
+ response: recoveryCodesResponse,
5899
+ errors: [...DEVELOPER_GUARD, "target_state_conflict"],
5900
+ transport: "http",
5901
+ notes: "The codes are shown this once. `409 target_state_conflict` names `two_factor` with rule `off` when the caller has no second factor: recovery codes only stand in for one. Audited as `developer.recovery_codes_generated`."
5902
+ },
5903
+ /* ------------------------------------------- client auth (portal pages) */
5904
+ {
5905
+ method: "GET",
5906
+ path: "/favicon.svg",
5907
+ section: "client-auth",
5908
+ summary: "Serves the Fleetless icon for the auth portal's and the MCP welcome page's browser tab.",
5909
+ audience: "internal",
5910
+ auth: "none",
5911
+ rateLimited: false,
5912
+ ownerTier: false,
5913
+ status: 200,
5914
+ params: [],
5915
+ query: null,
5916
+ request: null,
5917
+ response: null,
5918
+ errors: [],
5919
+ transport: "http",
5920
+ notes: "An SVG, not JSON. Those pages carry a Content-Security-Policy that admits no `data:` image, so the icon is a file on their own origin \u2014 the one source `img-src 'self'` names. Cached for a day: the bytes change when the brand does, not per deploy."
5181
5921
  },
5182
5922
  {
5183
5923
  method: "POST",
@@ -5360,7 +6100,7 @@ var ROUTES = [
5360
6100
  response: role,
5361
6101
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5362
6102
  transport: "http",
5363
- notes: 'The body is `{ "name": string }` \u2014 non-empty, trimmed, at most 120 characters \u2014 and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it.'
6103
+ notes: 'The body is `{ "name": string }` \u2014 non-empty, trimmed, at most 60 characters as on `role.name` \u2014 and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it.'
5364
6104
  },
5365
6105
  {
5366
6106
  method: "GET",
@@ -5442,6 +6182,48 @@ var ROUTES = [
5442
6182
  transport: "http",
5443
6183
  notes: "Built by the same builder the MCP server's own `robot_describe` uses, so the two cannot drift. It answers what the role *would* be offered and consults nothing about any user's actual MCP entitlement. A robot the role grants nothing on still appears, with an empty `exposures` \u2014 dropping it would read as \"not attached\", which is a different fact."
5444
6184
  },
6185
+ {
6186
+ method: "PATCH",
6187
+ path: "/api/apps/:id/roles/:roleId",
6188
+ section: "apps",
6189
+ summary: "Renames a role; its users keep it.",
6190
+ audience: "developer",
6191
+ auth: "developer",
6192
+ rateLimited: false,
6193
+ ownerTier: false,
6194
+ status: 200,
6195
+ params: [
6196
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
6197
+ { name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
6198
+ ],
6199
+ query: null,
6200
+ request: roleRenameRequest,
6201
+ response: role,
6202
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "role_name_taken"],
6203
+ transport: "http",
6204
+ notes: "Names are unique per app, compared exactly as stored after trimming. Built-in roles can be renamed."
6205
+ },
6206
+ {
6207
+ method: "DELETE",
6208
+ path: "/api/apps/:id/roles/:roleId",
6209
+ section: "apps",
6210
+ summary: "Deletes a role, moving its users, pending invitations and default-role status to another role.",
6211
+ audience: "developer",
6212
+ auth: "developer",
6213
+ rateLimited: false,
6214
+ ownerTier: false,
6215
+ status: 204,
6216
+ params: [
6217
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
6218
+ { name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
6219
+ ],
6220
+ query: roleDeleteQuery,
6221
+ request: null,
6222
+ response: null,
6223
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "role_in_use", "last_role"],
6224
+ transport: "http",
6225
+ notes: "Without `move_to`, a role that app users or pending invitations hold, or that is the app's default, answers `409 role_in_use` with `{ users, invitations, is_default }`. With `move_to` \u2014 another role of the same app, else `400 validation_error` \u2014 one transaction moves `app_users.role_id`, pending invitations and `default_role_id`, then deletes the role and its permissions. The app's only role answers `409 last_role`. Built-in roles can be deleted like any other."
6226
+ },
5445
6227
  {
5446
6228
  method: "POST",
5447
6229
  path: "/api/apps/:id/server-keys",
@@ -5625,9 +6407,27 @@ var ROUTES = [
5625
6407
  query: null,
5626
6408
  request: null,
5627
6409
  response: mailOutcome,
5628
- errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict"],
6410
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict", "method_not_allowed"],
5629
6411
  transport: "http",
5630
- notes: "The support door beside `POST /api/client/password/reset`: the same one-hour token and the same link, triggered by a developer for a user who asked them rather than the form. **No enumeration discipline applies** \u2014 the caller is authenticated into the app and can read the user list \u2014 so this one answers what actually happened: `{ \"mail\": mailStatus }`, where `not_configured` is a deployment without a mailer and `failed` is the state worth somebody's attention. `409 target_state_conflict` names `reset_url` when the app has configured none: the token would be minted and the link would point nowhere, so nothing is minted. The same `409` names `password` with rule `not_set` for an account that has none \u2014 an OIDC-only app user, whom a reset link would hand a second, quieter door \u2014 and `status` with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers' credentials."
6412
+ notes: "The support door beside `POST /api/client/password/reset`, refused like it with `403 method_not_allowed` while the app has the password method off: the same one-hour token and the same link, triggered by a developer for a user who asked them rather than the form. **No enumeration discipline applies** \u2014 the caller is authenticated into the app and can read the user list \u2014 so this one answers what actually happened: `{ \"mail\": mailStatus }`, where `not_configured` is a deployment without a mailer and `failed` is the state worth somebody's attention. The link points at the app's `reset_url`, or at the hosted reset page when the app has configured none. `409 target_state_conflict` names `password` with rule `not_set` for an account that has none \u2014 an OIDC-only app user, whom a reset link would hand a second, quieter door \u2014 and `status` with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers' credentials."
6413
+ },
6414
+ {
6415
+ method: "DELETE",
6416
+ path: "/api/apps/:id/users/:userId/two-factor",
6417
+ section: "apps",
6418
+ summary: "Removes an app user's authenticator and recovery codes and ends every session they hold.",
6419
+ audience: "developer",
6420
+ auth: "developer",
6421
+ rateLimited: false,
6422
+ ownerTier: false,
6423
+ status: 204,
6424
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "userId", description: "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`." }],
6425
+ query: null,
6426
+ request: null,
6427
+ response: null,
6428
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6429
+ transport: "http",
6430
+ notes: "The support door for a person who lost their authenticator and their recovery codes. The authenticator and every recovery code go, and so does every session of the account \u2014 whoever held one may be the reason for the reset. **A user with no second factor answers `204` too**: that is the end state being asked for. When the app requires two-factor, the person sets it up again at their next sign-in, before any session exists. Audited as `app_user.two_factor_reset`."
5631
6431
  },
5632
6432
  /* ---------------------------- the MCP clients one app user has connected */
5633
6433
  {
@@ -5704,7 +6504,7 @@ var ROUTES = [
5704
6504
  response: appInvitation,
5705
6505
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found", "email_taken", "target_state_conflict", "rate_limited"],
5706
6506
  transport: "http",
5707
- notes: "**An app user, not a team member.** `POST /api/org/invitations` is the other space and leads to the console; this link leads into the developer's own app. The role is resolved and stored now, so a later change to `default_role_id` does not re-aim a link already sent. An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`, which is `null` when the app has configured no `invite_url` \u2014 there is nowhere for the link to point, and Fleetless serves an app user no page of its own. That is a `201` with a null link, not a refusal: the invitation exists and a developer may hand the token over by another route. Asking to **mail** it in that state is `409 target_state_conflict` naming `invite_url`, because a mail carrying a dead link is worse than no mail. The same `409` names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 email_taken` is an address the app already has as a user; `404 not_found` is the app or a `role_id` that is not one of its roles. \n\nCreating shares the reissue route's ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with `retry_after_ms`: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be a limit on the wrong door."
6507
+ notes: "**An app user, not a team member.** `POST /api/org/invitations` is the other space and leads to the console; this link leads into the developer's own app. The role is resolved and stored now, so a later change to `default_role_id` does not re-aim a link already sent. An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`: the app's `invite_url` with the token in it, or the Fleetless-hosted invitation page when the app has configured none \u2014 so mailing it is never refused for a missing URL. `409 target_state_conflict` names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 email_taken` is an address the app already has as a user; `404 not_found` is the app or a `role_id` that is not one of its roles. \n\nCreating shares the reissue route's ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with `retry_after_ms`: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be a limit on the wrong door."
5708
6508
  },
5709
6509
  {
5710
6510
  method: "POST",
@@ -5838,7 +6638,7 @@ var ROUTES = [
5838
6638
  method: "GET",
5839
6639
  path: "/api/apps/:id/auth-config",
5840
6640
  section: "apps",
5841
- summary: "Reads the app's auth settings: self-registration, domains, origins, URLs and the MCP switch.",
6641
+ summary: "Reads the app's auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.",
5842
6642
  audience: "developer",
5843
6643
  auth: "developer",
5844
6644
  rateLimited: false,
@@ -5850,7 +6650,7 @@ var ROUTES = [
5850
6650
  response: appAuthConfig,
5851
6651
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5852
6652
  transport: "http",
5853
- notes: "One row per app, created with the app and never absent \u2014 an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice write below for a second reason: a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. `updated_at` is read-only for a duller one: the server stamps it on every write, and a client-supplied value would be a lie about when the row last changed."
6653
+ notes: "One row per app, created with the app and never absent \u2014 an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice write below for a second reason: a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. `updated_at` is read-only for a duller one: the server stamps it on every write, and a client-supplied value would be a lie about when the row last changed. `hosted_pages` and `hosted_logo_url` are read-only as well: the cloud mints both from the auth portal's base URL and the app's identifier."
5854
6654
  },
5855
6655
  {
5856
6656
  method: "PUT",
@@ -5868,13 +6668,31 @@ var ROUTES = [
5868
6668
  response: appAuthConfig,
5869
6669
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5870
6670
  transport: "http",
5871
- notes: "**A replace, not a merge, and `.strict()`**: `self_registration`, `allowed_domains` and `allowed_origins` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one can never match a lowercased address, and an entry in `allowed_origins` must be a bare scheme-host-port with no path, since a browser sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. \n\nThe merge is server-side against the stored row, so this write never disturbs the urls or mcp slice."
6671
+ notes: "**A replace, not a merge, and `.strict()`**: `self_registration`, `allowed_domains` and `allowed_origins` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one can never match a lowercased address, and an entry in `allowed_origins` must be a bare scheme-host-port with no path, since a browser sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. \n\nThe merge is server-side against the stored row, so this write never disturbs another slice."
6672
+ },
6673
+ {
6674
+ method: "PUT",
6675
+ path: "/api/apps/:id/auth-config/sign-in",
6676
+ section: "apps",
6677
+ summary: "Replaces how the app's users sign in and whether they give a second factor.",
6678
+ audience: "developer",
6679
+ auth: "developer",
6680
+ rateLimited: false,
6681
+ ownerTier: false,
6682
+ status: 200,
6683
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6684
+ query: null,
6685
+ request: putAppAuthSignInRequest,
6686
+ response: appAuthConfig,
6687
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
6688
+ transport: "http",
6689
+ notes: "**A replace, not a merge, and `.strict()`**: `sign_in_methods` and `two_factor` both arrive or the write is refused. Both methods off is `400 validation_error` naming `sign_in_methods.password` \u2014 an app needs at least one door besides its identity providers. \n\nTurning a method off refuses its routes with `method_not_allowed` from the next request on; a stored password stays stored. Setting `two_factor` to `required` signs nobody out: each person without an authenticator sets one up at their next sign-in, before any session exists. Audited with both old and new values. The merge is server-side against the stored row, so this write never disturbs another slice."
5872
6690
  },
5873
6691
  {
5874
6692
  method: "PUT",
5875
6693
  path: "/api/apps/:id/auth-config/urls",
5876
6694
  section: "apps",
5877
- summary: "Replaces the three pages Fleetless's mails point at.",
6695
+ summary: "Replaces the app's home page and the four pages Fleetless's mails and MCP sign-in point at.",
5878
6696
  audience: "developer",
5879
6697
  auth: "developer",
5880
6698
  rateLimited: false,
@@ -5886,13 +6704,13 @@ var ROUTES = [
5886
6704
  response: appAuthConfig,
5887
6705
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5888
6706
  transport: "http",
5889
- notes: "**A replace, not a merge, and `.strict()`**: `invite_url`, `verify_url` and `reset_url` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`400 validation_error` is where the field rule lands: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once \u2014 a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent. \n\nThe merge is server-side against the stored row, so this write never disturbs the registration or mcp slice."
6707
+ notes: "**A replace, not a merge, and `.strict()`**: `app_url`, `invite_url`, `verify_url`, `reset_url` and `mcp_login_url` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\nEach may be `null`, and then the Fleetless-hosted page in `hosted_pages` stands in for it: nothing is refused for a missing URL. `400 validation_error` is where the field rules land: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once \u2014 a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent \u2014 and `app_url` takes the same host rule with no placeholder. `mcp_login_url` moved here from the `mcp` slice, because one screen owns all four pages. \n\nThe merge is server-side against the stored row, so this write never disturbs another slice."
5890
6708
  },
5891
6709
  {
5892
6710
  method: "PUT",
5893
6711
  path: "/api/apps/:id/auth-config/mcp",
5894
6712
  section: "apps",
5895
- summary: "Replaces the MCP switch and its login URL together.",
6713
+ summary: "Turns the app's MCP endpoint on or off.",
5896
6714
  audience: "developer",
5897
6715
  auth: "developer",
5898
6716
  rateLimited: false,
@@ -5904,7 +6722,61 @@ var ROUTES = [
5904
6722
  response: appAuthConfig,
5905
6723
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5906
6724
  transport: "http",
5907
- notes: "**A replace, not a merge, and `.strict()`**: `mcp_enabled` and `mcp_login_url` both arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`mcp_login_url` answers to the same rule as the `urls` slice's three templates \u2014 https (or `http` on `localhost`), its placeholder exactly once \u2014 refused as `400 validation_error` rather than left to fail mid-OAuth, in a client's browser where no console screen is watching. \n\nThe merge is server-side against the stored row, so this write never disturbs the registration or urls slice."
6725
+ notes: "**A replace, not a merge, and `.strict()`**: `mcp_enabled` arrives or the write is refused. It used to take `mcp_login_url` as well, because on without a URL refused every sign-in; the hosted MCP sign-in now stands in for an unset URL, and the URL moved to the `urls` slice. A body still carrying it is `400 validation_error`. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's. The merge is server-side against the stored row, so this write never disturbs another slice."
6726
+ },
6727
+ {
6728
+ method: "PUT",
6729
+ path: "/api/apps/:id/auth-config/look",
6730
+ section: "apps",
6731
+ summary: "Replaces the hosted pages' accent colour.",
6732
+ audience: "developer",
6733
+ auth: "developer",
6734
+ rateLimited: false,
6735
+ ownerTier: false,
6736
+ status: 200,
6737
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6738
+ query: null,
6739
+ request: putAppAuthLookRequest,
6740
+ response: appAuthConfig,
6741
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
6742
+ transport: "http",
6743
+ notes: "**A replace, and `.strict()`**: `hosted_accent` arrives, `#rrggbb` in lowercase, or `null` for the neutral shell's own accent. The logo is its own write, `PUT /api/apps/:id/auth-config/logo`, because it is an image rather than a field. The merge is server-side against the stored row, so this write never disturbs another slice."
6744
+ },
6745
+ {
6746
+ method: "PUT",
6747
+ path: "/api/apps/:id/auth-config/logo",
6748
+ section: "apps",
6749
+ summary: "Stores the logo the hosted pages show above the app's name.",
6750
+ audience: "developer",
6751
+ auth: "developer",
6752
+ rateLimited: false,
6753
+ ownerTier: false,
6754
+ status: 200,
6755
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6756
+ query: null,
6757
+ request: null,
6758
+ response: appAuthConfig,
6759
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found", "unsupported_media_type"],
6760
+ transport: "http",
6761
+ notes: "The body is the **raw image**, not JSON, so it has no request schema: `Content-Type` is one of `HOSTED_LOGO_TYPES` (`image/png`, `image/svg+xml`) and anything else is `415 unsupported_media_type`. At most `HOSTED_LOGO_MAX_BYTES` (100 KB); a larger body, or one that is not the image its type names, is `400 validation_error`. A new logo replaces the stored one. The hosted pages load it from `hosted_logo_url` as an image only, and the cloud serves an SVG sandboxed, so a script inside one never runs."
6762
+ },
6763
+ {
6764
+ method: "DELETE",
6765
+ path: "/api/apps/:id/auth-config/logo",
6766
+ section: "apps",
6767
+ summary: "Removes the logo from the hosted pages.",
6768
+ audience: "developer",
6769
+ auth: "developer",
6770
+ rateLimited: false,
6771
+ ownerTier: false,
6772
+ status: 200,
6773
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6774
+ query: null,
6775
+ request: null,
6776
+ response: appAuthConfig,
6777
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6778
+ transport: "http",
6779
+ notes: "Answers the whole configuration, with `hosted_logo_url` now `null`; the hosted pages show the app's name alone. An app with no logo answers the same: that is the end state being asked for."
5908
6780
  },
5909
6781
  {
5910
6782
  method: "GET",
@@ -5922,7 +6794,7 @@ var ROUTES = [
5922
6794
  response: appMailTemplateListResponse,
5923
6795
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5924
6796
  transport: "http",
5925
- notes: 'Answers `{ "templates": [appMailTemplate, \u2026] }` with **only the kinds that have a custom template** \u2014 at most three. A kind that does not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a team invitation or a console password reset, are not in this list and are deliberately not customisable: they are about this platform, not about the developer\'s product.'
6797
+ notes: 'Answers `{ "templates": [appMailTemplate, \u2026] }` with **only the kinds that have a custom template** \u2014 at most four. A kind that does not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a team invitation or a console sign-in code, are not in this list and are deliberately not customisable: they are about this platform, not about the developer\'s product.'
5926
6798
  },
5927
6799
  {
5928
6800
  method: "GET",
@@ -5934,7 +6806,7 @@ var ROUTES = [
5934
6806
  rateLimited: false,
5935
6807
  ownerTier: false,
5936
6808
  status: 200,
5937
- params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the three mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify` or `reset`." }],
6809
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the four mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`." }],
5938
6810
  query: null,
5939
6811
  request: null,
5940
6812
  response: appMailTemplate,
@@ -5952,7 +6824,7 @@ var ROUTES = [
5952
6824
  rateLimited: false,
5953
6825
  ownerTier: false,
5954
6826
  status: 200,
5955
- params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the three mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify` or `reset`." }],
6827
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the four mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`." }],
5956
6828
  query: null,
5957
6829
  request: putAppMailTemplateRequest,
5958
6830
  response: appMailTemplate,
@@ -5970,7 +6842,7 @@ var ROUTES = [
5970
6842
  rateLimited: false,
5971
6843
  ownerTier: false,
5972
6844
  status: 204,
5973
- params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the three mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify` or `reset`." }],
6845
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the four mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`." }],
5974
6846
  query: null,
5975
6847
  request: null,
5976
6848
  response: null,
@@ -5988,7 +6860,7 @@ var ROUTES = [
5988
6860
  rateLimited: false,
5989
6861
  ownerTier: false,
5990
6862
  status: 200,
5991
- params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the three mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify` or `reset`." }],
6863
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the four mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`." }],
5992
6864
  query: null,
5993
6865
  request: mailTemplatePreviewRequest,
5994
6866
  response: mailTemplatePreviewResponse,
@@ -6006,7 +6878,7 @@ var ROUTES = [
6006
6878
  rateLimited: false,
6007
6879
  ownerTier: false,
6008
6880
  status: 202,
6009
- params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the three mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify` or `reset`." }],
6881
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }, { name: "kind", description: "Which of the four mails this template replaces \u2014 a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`." }],
6010
6882
  query: null,
6011
6883
  request: mailTemplatePreviewRequest,
6012
6884
  response: mailOutcome,
@@ -6126,7 +6998,7 @@ var ROUTES = [
6126
6998
  method: "POST",
6127
6999
  path: "/api/org/invitations/accept",
6128
7000
  section: "users",
6129
- summary: "Spends an invitation token and creates the login it was addressed to.",
7001
+ summary: "Spends an invitation token and creates the account it was addressed to.",
6130
7002
  audience: "developer",
6131
7003
  auth: "none",
6132
7004
  rateLimited: true,
@@ -6138,7 +7010,7 @@ var ROUTES = [
6138
7010
  response: null,
6139
7011
  errors: ["rate_limited", "validation_error", "token_spent", "email_taken"],
6140
7012
  transport: "http",
6141
- notes: '**`204`, not a session.** The console signs in through its own OAuth portal, so a session minted here would be a second credential door for one account \u2014 and every security property would then have to be right in two places. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. A browser form post gets the rendered "you\'re in" page instead.'
7013
+ notes: '**`204`, not a session.** The console signs in through its own OAuth portal, so a session minted here would be a second credential door for one account \u2014 and every security property would then have to be right in two places. **No password**: the mailed link proves the address, so accepting needs no code either, and the new member signs in by emailed code from then on. When the organisation requires two-factor, the member sets one up at their first sign-in. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. A browser form post gets the rendered "you\'re in" page instead.'
6142
7014
  },
6143
7015
  {
6144
7016
  method: "GET",
@@ -6156,7 +7028,7 @@ var ROUTES = [
6156
7028
  response: null,
6157
7029
  errors: [],
6158
7030
  transport: "http",
6159
- notes: 'HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/invitations/accept`. An unknown, spent or expired token renders the "link no longer valid" page at `410`, which offers the password-reset page \u2014 the only self-service door the portal has, since an invitation cannot be re-issued by the person holding it.'
7031
+ notes: 'HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/invitations/accept`. **The GET spends nothing** \u2014 a mail scanner opening the link must not accept the invitation \u2014 only the form\'s POST does. An unknown, spent or expired token renders the "link no longer valid" page at `410`, which says to ask the organisation for a new invitation: the person holding a dead link cannot re-issue it.'
6160
7032
  },
6161
7033
  {
6162
7034
  method: "PATCH",
@@ -6212,11 +7084,29 @@ var ROUTES = [
6212
7084
  transport: "http",
6213
7085
  notes: "Owner tier, unconditionally \u2014 this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this org answers `404 not_found`, the same as one that does not exist anywhere: the `409 target_state_conflict` documented here until the two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is `409 last_owner`, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it."
6214
7086
  },
7087
+ {
7088
+ method: "DELETE",
7089
+ path: "/api/org/users/:id/two-factor",
7090
+ section: "users",
7091
+ summary: "Removes a team member's passkeys, authenticator and recovery codes and ends their sessions.",
7092
+ audience: "developer",
7093
+ auth: "developer",
7094
+ rateLimited: false,
7095
+ ownerTier: true,
7096
+ status: 204,
7097
+ params: [{ name: "id", description: "The Fleetless user's uuid, as listed by `GET /api/org/users`." }],
7098
+ query: null,
7099
+ request: null,
7100
+ response: null,
7101
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "target_state_conflict"],
7102
+ transport: "http",
7103
+ notes: "Owner tier: the door for a member who lost every second factor and every recovery code. Every session of the member ends, and when the organisation requires two-factor they set one up again at their next sign-in. **An owner cannot reset their own** \u2014 `409 target_state_conflict` naming `user_id` with rule `self`; Settings \u203A Profile is where they change it. A member with no second factor answers `204` too. Audited as `developer.two_factor_reset`, naming the owner who did it."
7104
+ },
6215
7105
  {
6216
7106
  method: "PATCH",
6217
7107
  path: "/api/org",
6218
7108
  section: "org",
6219
- summary: "Renames the org.",
7109
+ summary: "Renames the org, requires two-factor for its members, or both.",
6220
7110
  audience: "developer",
6221
7111
  auth: "developer",
6222
7112
  rateLimited: false,
@@ -6228,7 +7118,7 @@ var ROUTES = [
6228
7118
  response: patchOrgResponse,
6229
7119
  errors: [...DEVELOPER_GUARD, "tier_required", "validation_error"],
6230
7120
  transport: "http",
6231
- notes: 'Answers `{ "org": org }`. Owner tier, and the gate runs before the body is looked at, so a malformed rename and a forbidden one answer the same way. Renaming to the name already held writes nothing and records no audit event.'
7121
+ notes: 'Answers `{ "org": org }`. Owner tier, and the gate runs before the body is looked at, so a malformed patch and a forbidden one answer the same way \u2014 `403 tier_required` for a developer, whichever field they sent. An empty body is `400 validation_error`. Writing the values already held writes nothing and records no audit event. \n\n`require_two_factor` on signs nobody out: each member without a passkey or authenticator sets one up at their next sign-in, before any session exists, on the console and the central MCP endpoint alike. Server keys and robot bridges are not people and are not affected. Audited as `org.two_factor_required_changed`.'
6232
7122
  },
6233
7123
  /* --------------------------------------------------------------- mcp */
6234
7124
  {
@@ -6301,7 +7191,7 @@ var ROUTES = [
6301
7191
  response: null,
6302
7192
  errors: [],
6303
7193
  transport: "http",
6304
- notes: "**The query schema is what this endpoint accepts, not what it parses**: the handler reads it parameter by parameter because the answers differ, and one parse would collapse them. Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds \u2014 the loopback-port wildcard of RFC 8252 \xA77.3 belongs to the one central client alone, whose URIs are configured ahead of time and cannot name an ephemeral port. A client that registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` may send a browser. Nothing about the person is decided here \u2014 the next card asks for an email address and the password step after it resolves the account; this route knows only the client."
7194
+ notes: "**The query schema is what this endpoint accepts, not what it parses**: the handler reads it parameter by parameter because the answers differ, and one parse would collapse them. Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds \u2014 the loopback-port wildcard of RFC 8252 \xA77.3 belongs to the one central client alone, whose URIs are configured ahead of time and cannot name an ephemeral port. A client that registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` may send a browser. Nothing about the person is decided here \u2014 the next card asks for an email address, or a passkey, and the steps after it resolve the account; this route knows only the client."
6305
7195
  },
6306
7196
  {
6307
7197
  method: "GET",
@@ -6319,44 +7209,9 @@ var ROUTES = [
6319
7209
  response: null,
6320
7210
  errors: [],
6321
7211
  transport: "http",
6322
- notes: "HTML, and a GET rather than the body of the authorize response \u2014 so it is reloadable, bookmarkable and survives a back button, which the inline page it replaced was not. An expired, consumed, unknown or hand-edited interaction renders one page at `410`, and so does a client whose dynamic registration lapsed in between."
6323
- },
6324
- {
6325
- method: "POST",
6326
- path: "/mcp/oauth/identify",
6327
- section: "mcp",
6328
- summary: "Takes the email address and hands back the password step.",
6329
- audience: "internal",
6330
- auth: "none",
6331
- rateLimited: true,
6332
- ownerTier: false,
6333
- status: 200,
6334
- params: [],
6335
- query: null,
6336
- request: null,
6337
- response: null,
6338
- errors: ["rate_limited", "validation_error", "token_spent"],
6339
- transport: "http",
6340
- notes: 'The identifier-first step, with nothing left to identify: Fleetless users are password-only, so **this step does not read the address at all** \u2014 it renders the password card for a known address, an unknown one and an empty one alike, and the login step below answers the same `401` for all three. That is a property of the shape rather than of two branches agreeing: there is no lookup here whose result could differ. A browser form post gets the password card; a JSON caller gets `{ "next" }`, which has no schema. Still rate limited per (route, ip, email), because it is an unauthenticated endpoint that renders a page.'
6341
- },
6342
- {
6343
- method: "POST",
6344
- path: "/mcp/oauth/login",
6345
- section: "mcp",
6346
- summary: "Checks the password and hands back where the MCP sign-in continues.",
6347
- audience: "internal",
6348
- auth: "none",
6349
- rateLimited: true,
6350
- ownerTier: false,
6351
- status: 200,
6352
- params: [],
6353
- query: null,
6354
- request: null,
6355
- response: oauthRedirectResponse,
6356
- errors: ["rate_limited", "validation_error", "token_spent", "invalid_credentials"],
6357
- transport: "http",
6358
- notes: 'The body is `{ "interaction_id", "email", "password" }`, read field by field rather than through a contract shape. A browser gets a `303` \u2014 to the consent screen for a self-registered client, or straight to the callback for the central one \u2014 where a JSON caller gets this `200` and `redirect_to`.'
7212
+ notes: "HTML, and a GET rather than the body of the authorize response \u2014 so it is reloadable, bookmarkable and survives a back button, which the inline page it replaced was not. An expired, consumed, unknown or hand-edited interaction renders one page at `410`, and so does a client whose dynamic registration lapsed in between. It offers the email field and `Sign in with a passkey`, and opening it binds the interaction to this browser with the browser-proof cookie, which every sign-in step after it checks."
6359
7213
  },
7214
+ ...developerSignInRoutes("/mcp/oauth"),
6360
7215
  {
6361
7216
  method: "GET",
6362
7217
  path: "/mcp/oauth/consent/:id",
@@ -6367,7 +7222,7 @@ var ROUTES = [
6367
7222
  rateLimited: false,
6368
7223
  ownerTier: false,
6369
7224
  status: 200,
6370
- params: [{ name: "id", description: "The interaction id from the sign-in; the login step redirects the browser here." }],
7225
+ params: [{ name: "id", description: "The interaction id from the sign-in; the last sign-in step redirects the browser here." }],
6371
7226
  query: null,
6372
7227
  request: null,
6373
7228
  response: null,
@@ -6446,31 +7301,32 @@ var ROUTES = [
6446
7301
  response: null,
6447
7302
  errors: [],
6448
7303
  transport: "http",
6449
- notes: "HTML. An expired, consumed, unknown or hand-edited interaction renders one page at `410`: which of the four it was is not a fact a stranger may learn, and to the person it is one fact anyway. The page resolves nothing about the address typed into it, so there is no enumeration oracle here at all."
7304
+ notes: "HTML: the email field, `Email me a code`, and `Sign in with a passkey`. Opening it binds the interaction to this browser with the browser-proof cookie, which every sign-in step after it checks. An expired, consumed, unknown or hand-edited interaction renders one page at `410`: which of the four it was is not a fact a stranger may learn, and to the person it is one fact anyway. The page resolves nothing about the address typed into it, so there is no enumeration oracle here at all."
6450
7305
  },
7306
+ ...developerSignInRoutes("/console/oauth"),
6451
7307
  {
6452
- method: "POST",
6453
- path: "/console/oauth/identify",
7308
+ method: "GET",
7309
+ path: "/console/oauth/signup/:id",
6454
7310
  section: "developer-auth",
6455
- summary: "Takes the email address and hands back the password step.",
7311
+ summary: "Serves step one of console sign-up, the email card.",
6456
7312
  audience: "internal",
6457
7313
  auth: "none",
6458
- rateLimited: true,
7314
+ rateLimited: false,
6459
7315
  ownerTier: false,
6460
7316
  status: 200,
6461
- params: [],
7317
+ params: [{ name: "id", description: "The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`." }],
6462
7318
  query: null,
6463
7319
  request: null,
6464
7320
  response: null,
6465
- errors: ["rate_limited", "token_spent"],
7321
+ errors: [],
6466
7322
  transport: "http",
6467
- notes: 'A browser form post gets the password card as HTML; a JSON caller gets `{ "next": "/console/oauth/login" }`, which has no schema \u2014 the step made no decision, and it says so rather than inventing a redirect. A dead interaction is `410 token_spent`. Rate limited despite spending no credential: it is an unauthenticated endpoint that renders a page.'
7323
+ notes: 'HTML: the email field and `Email me a code`, the first of three steps \u2014 email, code, organization. While the deployment runs in closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive and pointing back at sign-in and the waiting list \u2014 the person may well already have an account.'
6468
7324
  },
6469
7325
  {
6470
7326
  method: "POST",
6471
- path: "/console/oauth/login",
7327
+ path: "/console/oauth/signup",
6472
7328
  section: "developer-auth",
6473
- summary: "Checks the password and mints the authorization code the console exchanges.",
7329
+ summary: "Takes the sign-up email, mails a code and hands back the code step.",
6474
7330
  audience: "internal",
6475
7331
  auth: "none",
6476
7332
  rateLimited: true,
@@ -6479,34 +7335,16 @@ var ROUTES = [
6479
7335
  params: [],
6480
7336
  query: null,
6481
7337
  request: null,
6482
- response: oauthRedirectResponse,
6483
- errors: ["rate_limited", "token_spent", "invalid_credentials"],
6484
- transport: "http",
6485
- notes: 'This is the only place a Fleetless developer password may be typed; `POST /api/auth/login` is gone, because a second credential door means every security property has to be right in two places. A browser form post gets a `303` to the callback URL; a JSON caller gets that same URL as `redirect_to` at `200`. An argon2 verify runs whether or not the address exists, and the Org Admins check runs after it \u2014 filtering first would hand back a faster "no" for a non-admin account, which is a timing oracle. Wrong password, unknown address and "not an org admin" render identical bytes under one `401`.'
6486
- },
6487
- {
6488
- method: "GET",
6489
- path: "/console/oauth/signup/:id",
6490
- section: "developer-auth",
6491
- summary: "Serves step one of console sign-up, the account card.",
6492
- audience: "internal",
6493
- auth: "none",
6494
- rateLimited: false,
6495
- ownerTier: false,
6496
- status: 200,
6497
- params: [{ name: "id", description: "The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`." }],
6498
- query: null,
6499
- request: null,
6500
7338
  response: null,
6501
- errors: [],
7339
+ errors: ["rate_limited", "token_spent", "signup_closed", "validation_error", "email_taken"],
6502
7340
  transport: "http",
6503
- notes: 'HTML. While the deployment runs in closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive and pointing back at sign-in \u2014 the person may well already have an account.'
7341
+ notes: 'A browser form post gets the code card; a JSON caller gets `{ "next", "email" }`, which has no schema. **No password**: the code mailed here, six digits valid ten minutes, proves the address. A per-interaction proof cookie is set here \u2014 it is what stops a third party from finishing a sign-up somebody else started. Sign-up is the one surface whose job is to say an address is taken, so `409 email_taken` is not a leak here.'
6504
7342
  },
6505
7343
  {
6506
7344
  method: "POST",
6507
- path: "/console/oauth/signup",
7345
+ path: "/console/oauth/signup/code",
6508
7346
  section: "developer-auth",
6509
- summary: "Takes the sign-up email and password and hands back the organization step.",
7347
+ summary: "Checks the sign-up code and hands back the organization step.",
6510
7348
  audience: "internal",
6511
7349
  auth: "none",
6512
7350
  rateLimited: true,
@@ -6516,9 +7354,9 @@ var ROUTES = [
6516
7354
  query: null,
6517
7355
  request: null,
6518
7356
  response: null,
6519
- errors: ["rate_limited", "token_spent", "signup_closed", "validation_error", "email_taken"],
7357
+ errors: ["rate_limited", "token_spent", "signup_closed", "wrong_browser", "validation_error", "invalid_code"],
6520
7358
  transport: "http",
6521
- notes: 'A browser form post gets the organization card; a JSON caller gets `{ "next", "email" }`, which has no schema. The plaintext password exists for this one request: what is stored is its argon2 hash, on the interaction row, which expires with it. A per-interaction proof cookie is set here \u2014 it is what stops a third party from finishing a sign-up somebody else started. Sign-up is the one surface whose job is to say an address is taken, so `409 email_taken` is not a leak here.'
7359
+ notes: 'A wrong code renders the code card again with the attempts left (`400 invalid_code`); a spent, expired or exhausted one is `410 token_spent`. The step before must have run in **this** browser (`401 wrong_browser`). A browser form post gets the organization card, the address shown `confirmed`; a JSON caller gets `{ "next" }`, which has no schema.'
6522
7360
  },
6523
7361
  {
6524
7362
  method: "POST",
@@ -6536,7 +7374,7 @@ var ROUTES = [
6536
7374
  response: oauthRedirectResponse,
6537
7375
  errors: ["rate_limited", "token_spent", "signup_closed", "wrong_browser", "validation_error", "email_taken"],
6538
7376
  transport: "http",
6539
- notes: "The same single transaction `POST /api/auth/signup` runs. Step one must have run in **this** browser: a missing or mismatched proof cookie is `401 wrong_browser` and the person is sent back to step one. A browser form post gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`."
7377
+ notes: "The form carries `org_name` only. The org and its founding Owner are created in one transaction, and only after the code step confirmed the address. Both steps before must have run in **this** browser: a missing or mismatched proof cookie is `401 wrong_browser` and the person is sent back to step one. `409 email_taken` when the address was taken meanwhile. A browser form post gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`. This is the only way an organisation is created: `POST /api/auth/signup` is gone, because without a password it would hand a session to anybody who names an address."
6540
7378
  },
6541
7379
  {
6542
7380
  method: "POST",
@@ -6556,6 +7394,8 @@ var ROUTES = [
6556
7394
  transport: "http",
6557
7395
  notes: "Called by the console's own server, never by a browser. Refusals use RFC 6749 \xA75.2's flat `oauthError` shape \u2014 it is a token endpoint, and that is the dialect a caller of one expects \u2014 so it emits none of the codes in this reference. Proof of possession is checked before the replay check, and the single-use consume is atomic, so exactly one caller ever mints. The Org Admins membership is re-read here: the code was minted earlier, and a user moved out in between must not get a console session."
6558
7396
  },
7397
+ /* --------------------------------- the Fleetless-hosted app pages */
7398
+ ...HOSTED_APP_ROUTES,
6559
7399
  /* ---------------------------------------------------- mcp (the endpoint) */
6560
7400
  {
6561
7401
  method: "GET",
@@ -6682,7 +7522,7 @@ var ROUTES = [
6682
7522
  response: authorizationServerMetadata,
6683
7523
  errors: ["not_found"],
6684
7524
  transport: "http",
6685
- notes: "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` \u2014 the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It redirects to the app's own `mcp_login_url`, which is on the developer's origin already."
7525
+ notes: "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` \u2014 the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**: the authorization step renders no page itself. It redirects to the app's own `mcp_login_url`, or to the hosted MCP sign-in on the auth portal when the app has configured none."
6686
7526
  },
6687
7527
  {
6688
7528
  method: "POST",
@@ -6706,7 +7546,7 @@ var ROUTES = [
6706
7546
  method: "GET",
6707
7547
  path: MCP_APP.authorize,
6708
7548
  section: "mcp",
6709
- summary: "Starts an MCP sign-in and redirects the browser to the app's own login page.",
7549
+ summary: "Starts an MCP sign-in and redirects the browser to the app's own login page, or to the hosted one.",
6710
7550
  audience: "client",
6711
7551
  auth: "none",
6712
7552
  rateLimited: false,
@@ -6716,9 +7556,9 @@ var ROUTES = [
6716
7556
  query: oauthAuthorizeQuery,
6717
7557
  request: null,
6718
7558
  response: null,
6719
- errors: ["not_found", "target_state_conflict"],
7559
+ errors: ["not_found"],
6720
7560
  transport: "http",
6721
- notes: 'The same query as `GET /mcp/oauth/authorize`, read the same way \u2014 parameter by parameter, because the answers differ and one parse would collapse them. \n\n**Fleetless renders no page here**, and that is the whole of it. The route writes an interaction \u2014 ten minutes, as the OIDC ones live \u2014 and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own UI, reads `GET /api/client/mcp/interactions/:id` to show the client\'s claimed name and the scopes it asked for, and calls approve or deny. \n\nClient and `redirect_uri` are validated first and a failure there never redirects \u2014 the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep \u2014 and those refusals are RFC 6749\'s flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe two codes above are the `apiError` envelope because they are refusals about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** \u2014 the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between "turned off" and "mistyped"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app \u2014 the two decision routes under `/api/client/mcp/interactions/:id`. `409 target_state_conflict` names `mcp_login_url` with rule `not_set`: MCP is enabled and no page is configured to send the person to. It is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one \u2014 Fleetless has nowhere to redirect, and rendering a page of its own would contradict the rule that Fleetless shows an app user no page.'
7561
+ notes: 'The same query as `GET /mcp/oauth/authorize`, read the same way \u2014 parameter by parameter, because the answers differ and one parse would collapse them. \n\n**This route renders no page.** It writes an interaction \u2014 ten minutes, as the OIDC ones live \u2014 and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own UI, reads `GET /api/client/mcp/interactions/:id` to show the client\'s claimed name and the scopes it asked for, and calls approve or deny. **An app with no `mcp_login_url` is redirected to the hosted MCP sign-in** (`GET /app/:appIdentifier/mcp/:interaction`), which runs the same steps on the auth portal; nothing is refused for a missing URL. \n\nClient and `redirect_uri` are validated first and a failure there never redirects \u2014 the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep \u2014 and those refusals are RFC 6749\'s flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe code above is the `apiError` envelope because it is a refusal about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** \u2014 the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between "turned off" and "mistyped"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app \u2014 the two decision routes under `/api/client/mcp/interactions/:id`.'
6722
7562
  },
6723
7563
  {
6724
7564
  method: "POST",
@@ -6752,10 +7592,46 @@ var ROUTES = [
6752
7592
  params: [],
6753
7593
  query: null,
6754
7594
  request: clientLoginRequest,
6755
- response: sessionTokens,
6756
- errors: ["rate_limited", "validation_error", "invalid_credentials"],
7595
+ response: clientSignInResult,
7596
+ errors: ["rate_limited", "validation_error", "invalid_credentials", "method_not_allowed"],
7597
+ transport: "http",
7598
+ notes: 'One refusal for every miss \u2014 unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` \u2014 because the caller supplies the `app_identifier` unauthenticated, so "this app knows this user" is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either. \n\n**The answer is a `clientSignInResult`**: session tokens, or a `twoFactorChallenge` when the person has a confirmed authenticator or the app requires one \u2014 then no session exists until `POST /api/client/two-factor/verify` or the setup is done. `403 method_not_allowed` when the app has the password method off; it names the app\'s policy, not a person.'
7599
+ },
7600
+ {
7601
+ method: "POST",
7602
+ path: "/api/client/login/code",
7603
+ section: "client-auth",
7604
+ summary: "Mails a six-digit sign-in code, and answers the same whether or not the address exists.",
7605
+ audience: "client",
7606
+ auth: "none",
7607
+ rateLimited: true,
7608
+ ownerTier: false,
7609
+ status: 202,
7610
+ params: [],
7611
+ query: null,
7612
+ request: clientLoginCodeRequest,
7613
+ response: null,
7614
+ errors: ["rate_limited", "validation_error", "not_found", "method_not_allowed"],
6757
7615
  transport: "http",
6758
- notes: 'One refusal for every miss \u2014 unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` \u2014 because the caller supplies the `app_identifier` unauthenticated, so "this app knows this user" is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either.'
7616
+ notes: "**`202` and an empty body for every request the policy allows**, in status, body and timing, whether or not the address names an active account of this app \u2014 a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out for an `active` account and for one still `pending_verification` \u2014 spending the code proves the address, as the verification link would \u2014 and never for a `blocked` one or an unknown address. The code is six digits, valid ten minutes, takes five wrong attempts, and a new request expires the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and compared case-insensitively. `404 not_found` is the **app identifier**, never the address; `403 method_not_allowed` when the app has the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly."
7617
+ },
7618
+ {
7619
+ method: "POST",
7620
+ path: "/api/client/login/code/verify",
7621
+ section: "client-auth",
7622
+ summary: "Spends a mailed sign-in code and answers a session or a two-factor challenge.",
7623
+ audience: "client",
7624
+ auth: "none",
7625
+ rateLimited: true,
7626
+ ownerTier: false,
7627
+ status: 200,
7628
+ params: [],
7629
+ query: null,
7630
+ request: clientLoginCodeVerifyRequest,
7631
+ response: clientSignInResult,
7632
+ errors: ["rate_limited", "validation_error", "invalid_code", "token_spent", "method_not_allowed"],
7633
+ transport: "http",
7634
+ notes: "A wrong code is `400 invalid_code` with `details.attempts_left` (`invalidCodeDetails`). A code that is spent, past its ten minutes, out of attempts, or was never mailed is `410 token_spent` \u2014 one answer, because telling them apart would say whether a code was ever sent to that address; the recovery is the same, ask for a new code. The address is trimmed and compared case-insensitively, so the address typed at the request and here need not match in case. \n\n**The answer is a `clientSignInResult`**, like the password login: tokens, or a `twoFactorChallenge` when the person has an authenticator or the app requires one. A pending-verification account that spends a code is activated \u2014 reading a mail at that address is the proof verification asks for."
6759
7635
  },
6760
7636
  {
6761
7637
  method: "POST",
@@ -6773,7 +7649,7 @@ var ROUTES = [
6773
7649
  response: null,
6774
7650
  errors: ["rate_limited", "validation_error", "not_found", "registration_closed", "domain_not_allowed", "target_state_conflict", "quota_exceeded"],
6775
7651
  transport: "http",
6776
- notes: "**`202` and an empty body for every request policy allows** \u2014 a new address, one this app already knows and one it does not answer identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole client family is built to avoid. The account cannot log in until the mailed link is spent; `POST /api/client/verify-email` is what does that. \n\n**An address on an account still `pending_verification` is re-registered, not ignored.** The password and display name from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an `active` account changes nothing and sends nothing \u2014 that account has already been proven, and its way back in is `POST /api/client/password/reset`. Neither case is visible in the answer. \n\nThe refusals it *does* make are about policy or about what the caller typed, never about a person. `403 registration_closed` when the app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer's own configuration, and a stranger learns the app's policy rather than who is in it. **A password under twelve characters is part of that `400 validation_error`** and not a code of its own \u2014 the minimum is the `password` field's schema rule, and the error names the field, which is what a form needs to mark it. `404 not_found` names an **app identifier no app carries**, and never an address: an app identifier is already public (it is in the MCP metadata path and in the developer's own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. `409 target_state_conflict` when the app has configured no `verify_url` or has no default role \u2014 there would be nowhere to send the person and no role to give them, and mailing a link that leads nowhere is worse than refusing. \n\n**`409 quota_exceeded` when the org is at its `max_end_users` limit**, counted across every app of the org. It is the one refusal here that is answered **before the address is looked at** \u2014 and that ordering is the point rather than an implementation detail: a quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused identically, including one that would only have re-mailed a pending account's link."
7652
+ notes: "**`202` and an empty body for every request policy allows** \u2014 a new address, one this app already knows and one it does not answer identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole client family is built to avoid. The account cannot log in until the mailed link is spent; `POST /api/client/verify-email` is what does that. \n\n**An address on an account still `pending_verification` is re-registered, not ignored.** The password and display name from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an `active` account changes nothing and sends nothing \u2014 that account has already been proven, and its way back in is `POST /api/client/password/reset`. Neither case is visible in the answer. \n\nThe refusals it *does* make are about policy or about what the caller typed, never about a person. `403 registration_closed` when the app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer's own configuration, and a stranger learns the app's policy rather than who is in it. **A password under twelve characters is part of that `400 validation_error`** and not a code of its own \u2014 the minimum is the `password` field's schema rule, and the error names the field, which is what a form needs to mark it. The same `400` names `password` when one is missing while the app's password method is on, or sent while it is off: an email-code-only app registers people without one. `404 not_found` names an **app identifier no app carries**, and never an address: an app identifier is already public (it is in the MCP metadata path and in the developer's own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. `409 target_state_conflict` when the app has no default role \u2014 there would be no role to give the person. An app with no `verify_url` is not refused: the mailed link points at the hosted confirmation page instead. \n\n**`409 quota_exceeded` when the org is at its `max_end_users` limit**, counted across every app of the org. It is the one refusal here that is answered **before the address is looked at** \u2014 and that ordering is the point rather than an implementation detail: a quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused identically, including one that would only have re-mailed a pending account's link."
6777
7653
  },
6778
7654
  {
6779
7655
  method: "POST",
@@ -6788,10 +7664,10 @@ var ROUTES = [
6788
7664
  params: [],
6789
7665
  query: null,
6790
7666
  request: clientVerifyEmailRequest,
6791
- response: sessionTokens,
7667
+ response: clientSignInResult,
6792
7668
  errors: ["rate_limited", "validation_error", "token_spent"],
6793
7669
  transport: "http",
6794
- notes: "**The answer is a session, not a `204`.** Somebody who has just proved they can read the mail should not be asked to type their password again on the next screen, and the app has an access token to carry them into it. The token is spent first and the account is activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that died between them would leave a spent token on an account still `pending_verification`, whose recovery is `POST /api/client/resend-verification`. Spending the token also proves the address, so a later `PATCH` may return the account to `active` after a block. \n\n**One refusal for every token that does not work: `410 token_spent`** \u2014 unknown, past its twenty-four hours, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same in all three cases: ask for a fresh link with `POST /api/client/resend-verification`. An app rendering this refusal should offer that and nothing conditional on which of the three it was."
7670
+ notes: "**The answer is a session, not a `204`** \u2014 or, as on every sign-in step, a `twoFactorChallenge` when the app requires two-factor (`clientSignInResult`). Somebody who has just proved they can read the mail should not be asked to type their password again on the next screen, and the app has an access token to carry them into it. The token is spent first and the account is activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that died between them would leave a spent token on an account still `pending_verification`, whose recovery is `POST /api/client/resend-verification`. Spending the token also proves the address, so a later `PATCH` may return the account to `active` after a block. \n\n**One refusal for every token that does not work: `410 token_spent`** \u2014 unknown, past its twenty-four hours, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same in all three cases: ask for a fresh link with `POST /api/client/resend-verification`. An app rendering this refusal should offer that and nothing conditional on which of the three it was."
6795
7671
  },
6796
7672
  {
6797
7673
  method: "POST",
@@ -6825,9 +7701,9 @@ var ROUTES = [
6825
7701
  query: null,
6826
7702
  request: clientPasswordResetRequest,
6827
7703
  response: null,
6828
- errors: ["rate_limited", "validation_error", "not_found"],
7704
+ errors: ["rate_limited", "validation_error", "not_found", "method_not_allowed"],
6829
7705
  transport: "http",
6830
- notes: "**The app-user twin of `POST /api/auth/password/reset`, and a different shape** because the two surfaces name a person differently: a Fleetless address is globally unique and resolves alone, an app user's is unique only within their app, so the pair is the identifier. Status, body and timing are identical for a known and an unknown address. An account with no Fleetless password \u2014 one created through an identity provider \u2014 is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link points at the app's `reset_url`; an app that has configured none can send no mail, which the `202` does not distinguish, because saying so would answer for the address as well."
7706
+ notes: "The pair of app identifier and address is the identifier: an app user's address is unique only within their app. `403 method_not_allowed` when the app has the password method off \u2014 a reset link whose confirmation would be refused is not mailed; the code names the app's policy, not a person. Status, body and timing are identical for a known and an unknown address. An account with no password \u2014 one created through an identity provider \u2014 is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link points at the app's `reset_url`, or at the hosted reset page when the app has configured none."
6831
7707
  },
6832
7708
  {
6833
7709
  method: "POST",
@@ -6842,10 +7718,10 @@ var ROUTES = [
6842
7718
  params: [],
6843
7719
  query: null,
6844
7720
  request: clientPasswordResetConfirmRequest,
6845
- response: sessionTokens,
6846
- errors: ["rate_limited", "validation_error", "token_spent"],
7721
+ response: clientSignInResult,
7722
+ errors: ["rate_limited", "validation_error", "token_spent", "method_not_allowed"],
6847
7723
  transport: "http",
6848
- notes: "**Every refresh family of that account is revoked**, then a fresh pair is minted for the caller \u2014 a forgotten password is one of the two states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. \n\n**One refusal for every token that does not work: `410 token_spent`** \u2014 unknown, past its hour, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new link. A replacement password under twelve characters is a `400 validation_error` naming the `new_password` field \u2014 the twelve-character minimum is that field's schema rule, and it is refused the way any other malformed field is."
7724
+ notes: "**A new password does not bypass the second factor**: a person with an authenticator, or in an app that requires one, gets a `twoFactorChallenge` instead of tokens (`clientSignInResult`), and the authenticator stays on. `403 method_not_allowed` when the app has the password method off. \n\n**Every refresh family of that account is revoked**, then a fresh pair is minted for the caller \u2014 a forgotten password is one of the two states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. \n\n**One refusal for every token that does not work: `410 token_spent`** \u2014 unknown, past its hour, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new link. A replacement password under twelve characters is a `400 validation_error` naming the `new_password` field \u2014 the twelve-character minimum is that field's schema rule, and it is refused the way any other malformed field is."
6849
7725
  },
6850
7726
  {
6851
7727
  method: "POST",
@@ -6860,10 +7736,10 @@ var ROUTES = [
6860
7736
  params: [],
6861
7737
  query: null,
6862
7738
  request: clientAcceptInvitationRequest,
6863
- response: sessionTokens,
7739
+ response: clientSignInResult,
6864
7740
  errors: ["rate_limited", "validation_error", "token_spent", "email_taken", "target_state_conflict", "quota_exceeded"],
6865
7741
  transport: "http",
6866
- notes: "**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a session, because the person is landing in the developer's app and there is no second door for them to sign in through. The role is the one the invitation fixed at creation, so a later change to the app's default role does not re-aim a link already in somebody's inbox, and the invitation **bypasses `allowed_domains`** \u2014 a developer inviting somebody by hand has already made the decision the whitelist automates. \n\n**One refusal for every token that does not work: `410 token_spent`** \u2014 unknown, expired past the seven days, revoked by the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because the one thing the holder of a dead link can do is ask the developer for a new one, whichever of the four it was. A chosen password under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app has acquired since the invitation was written **as an account that is already in use** \u2014 the invitation stays outstanding rather than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still `pending_verification` is not that state: accepting sets the password the invitee just chose, activates the account and gives it the invitation's role, because reading the invitation mail proves the address the verification link was waiting on. \n\n`409 target_state_conflict` names `role_id` with rule `not_set` when the role the invitation was fixed to has since been deleted and the app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse than saying so. \n\n**`409 quota_exceeded` when accepting would CREATE an account and the org is at its `max_end_users` limit**, counted across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an unfinished self-registration, both finish an account that already counts \u2014 those are not refused, because the org is not one account larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link still works."
7742
+ notes: "**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a session, because the person is landing in the developer's app and there is no second door for them to sign in through. The role is the one the invitation fixed at creation, so a later change to the app's default role does not re-aim a link already in somebody's inbox, and the invitation **bypasses `allowed_domains`** \u2014 a developer inviting somebody by hand has already made the decision the whitelist automates. The answer is a `clientSignInResult`: a `twoFactorChallenge` instead of tokens when the app requires two-factor. `password` is required while the app's password method is on and refused while it is off, both as `400 validation_error` naming the field. \n\n**One refusal for every token that does not work: `410 token_spent`** \u2014 unknown, expired past the seven days, revoked by the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because the one thing the holder of a dead link can do is ask the developer for a new one, whichever of the four it was. A chosen password under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app has acquired since the invitation was written **as an account that is already in use** \u2014 the invitation stays outstanding rather than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still `pending_verification` is not that state: accepting sets the password the invitee just chose, activates the account and gives it the invitation's role, because reading the invitation mail proves the address the verification link was waiting on. \n\n`409 target_state_conflict` names `role_id` with rule `not_set` when the role the invitation was fixed to has since been deleted and the app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse than saying so. \n\n**`409 quota_exceeded` when accepting would CREATE an account and the org is at its `max_end_users` limit**, counted across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an unfinished self-registration, both finish an account that already counts \u2014 those are not refused, because the org is not one account larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link still works."
6867
7743
  },
6868
7744
  {
6869
7745
  method: "POST",
@@ -6915,9 +7791,9 @@ var ROUTES = [
6915
7791
  query: null,
6916
7792
  request: passwordChangeRequest,
6917
7793
  response: sessionTokens,
6918
- errors: [...CLIENT_GUARD, "validation_error", "invalid_credentials", "target_state_conflict"],
7794
+ errors: [...CLIENT_GUARD, "validation_error", "invalid_credentials", "target_state_conflict", "method_not_allowed"],
6919
7795
  transport: "http",
6920
- notes: 'The guard admits all three caller kinds, but a password belongs to an app user specifically \u2014 a developer bearer or a server key reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made the change stays signed in. An app user belongs to one app, so "every session" is this app\'s. An account that has **no password** \u2014 an OIDC-only app user, which the schema admits \u2014 answers `409 target_state_conflict` naming the `password` field with rule `not_set`, not `401`: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign in again sends them round a loop that ends here.'
7796
+ notes: '`403 method_not_allowed` when the app has the password method off: a stored password stays stored but is not in use, so it is not changed either. The guard admits all three caller kinds, but a password belongs to an app user specifically \u2014 a developer bearer or a server key reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made the change stays signed in. An app user belongs to one app, so "every session" is this app\'s. An account that has **no password** \u2014 an OIDC-only app user, which the schema admits \u2014 answers `409 target_state_conflict` naming the `password` field with rule `not_set`, not `401`: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign in again sends them round a loop that ends here.'
6921
7797
  },
6922
7798
  {
6923
7799
  method: "GET",
@@ -6937,6 +7813,80 @@ var ROUTES = [
6937
7813
  transport: "http",
6938
7814
  notes: "The one route that answers for all three caller kinds \u2014 a developer bearer, an app-user bearer and a server key \u2014 which is why the shape names each of `developer_id`, `app_user_id` and `server_key_id` and fills exactly one."
6939
7815
  },
7816
+ /* ------------------------------------------------ app-user two-factor */
7817
+ {
7818
+ method: "POST",
7819
+ path: "/api/client/two-factor/verify",
7820
+ section: "client-auth",
7821
+ summary: "Answers a two-factor challenge with an authenticator or recovery code, and answers the session.",
7822
+ audience: "client",
7823
+ auth: "none",
7824
+ rateLimited: true,
7825
+ ownerTier: false,
7826
+ status: 200,
7827
+ params: [],
7828
+ query: null,
7829
+ request: clientTwoFactorVerifyRequest,
7830
+ response: sessionTokens,
7831
+ errors: ["rate_limited", "validation_error", "invalid_code", "token_spent"],
7832
+ transport: "http",
7833
+ notes: "The challenge is the one a sign-in step answered with `two_factor_required`; it lives five minutes and takes five wrong codes, after which it is `410 token_spent` and the sign-in starts over. A wrong code is `400 invalid_code` with `details.attempts_left`. **A code is accepted at most once**: the same authenticator code sent twice, even at the same moment, signs in exactly once. A recovery code is spent by its use and audited as `app_user.recovery_code_used`. Exactly one of `code` and `recovery_code`, or `400 validation_error`."
7834
+ },
7835
+ {
7836
+ method: "POST",
7837
+ path: "/api/client/two-factor/setup",
7838
+ section: "client-auth",
7839
+ summary: "Starts an authenticator setup and answers its secret and otpauth URL.",
7840
+ audience: "client",
7841
+ auth: "in_handler",
7842
+ rateLimited: true,
7843
+ ownerTier: false,
7844
+ status: 200,
7845
+ params: [],
7846
+ query: null,
7847
+ request: clientTwoFactorSetupRequest,
7848
+ requestOptional: true,
7849
+ response: twoFactorSetupResponse,
7850
+ errors: ["rate_limited", "validation_error", "token_spent", "unauthorized", "target_state_conflict"],
7851
+ transport: "http",
7852
+ notes: "**Two ways in, decided in the handler.** During sign-in the body carries the `two_factor_setup_required` challenge, and that is the credential; from the app's own account settings the app user's bearer is, with no challenge and an empty or missing body. Neither is `401 unauthorized`, and a dead challenge is `410 token_spent`. `409 target_state_conflict` names `two_factor` with rule `off` when the app's policy is `off`. The secret is not in use until `POST /api/client/two-factor/setup/confirm` accepts a code from it; a second call replaces a pending secret, and an account that already has an authenticator keeps it until the new one is confirmed."
7853
+ },
7854
+ {
7855
+ method: "POST",
7856
+ path: "/api/client/two-factor/setup/confirm",
7857
+ section: "client-auth",
7858
+ summary: "Confirms the new authenticator with a code and answers the recovery codes and a session.",
7859
+ audience: "client",
7860
+ auth: "in_handler",
7861
+ rateLimited: true,
7862
+ ownerTier: false,
7863
+ status: 200,
7864
+ params: [],
7865
+ query: null,
7866
+ request: clientTwoFactorSetupConfirmRequest,
7867
+ response: clientTwoFactorSetupConfirmResponse,
7868
+ errors: ["rate_limited", "validation_error", "invalid_code", "token_spent", "unauthorized"],
7869
+ transport: "http",
7870
+ notes: "The same two ways in as `setup`. A code that does not match the pending secret is `400 invalid_code`; no pending setup, or a dead challenge, is `410 token_spent`. On success the authenticator is on, ten recovery codes are issued \u2014 shown this once, any earlier set void \u2014 and the answer carries a session: the one the sign-in was waiting for, or, from account settings, a fresh one while every other session of the account ends. Audited as `app_user.two_factor_enabled`."
7871
+ },
7872
+ {
7873
+ method: "DELETE",
7874
+ path: "/api/client/two-factor",
7875
+ section: "client-auth",
7876
+ summary: "Turns the signed-in app user's authenticator off.",
7877
+ audience: "client",
7878
+ auth: "developer_or_client",
7879
+ rateLimited: true,
7880
+ ownerTier: false,
7881
+ status: 204,
7882
+ params: [],
7883
+ query: null,
7884
+ request: clientTwoFactorDisableRequest,
7885
+ response: null,
7886
+ errors: [...CLIENT_GUARD, "rate_limited", "validation_error", "invalid_code", "target_state_conflict"],
7887
+ transport: "http",
7888
+ notes: "The app user's own door; a developer bearer or a server key is `401 unauthorized`, because the factor is the person's. A current code proves they still hold the authenticator: a stolen session alone cannot remove it. The authenticator and every recovery code go. `409 target_state_conflict` names `two_factor` with rule `required` while the app requires two-factor, and with rule `off` when there is none to remove. Audited as `app_user.two_factor_disabled`. The developer's support door is `DELETE /api/apps/:id/users/:userId/two-factor`."
7889
+ },
6940
7890
  /* ---------------------------------------- app-user sign-in through an IdP */
6941
7891
  {
6942
7892
  method: "GET",
@@ -6990,7 +7940,7 @@ var ROUTES = [
6990
7940
  response: null,
6991
7941
  errors: ["rate_limited"],
6992
7942
  transport: "http",
6993
- notes: "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` \u2014 the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org \u2014 an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome \u2014 it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds \u2014 RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=\u2026&state=\u2026` when a session was resolved, `?error=<clientOidcErrorCode>&state=\u2026` when it was not, so the app renders its own message and can bind either answer to the request it started. Fleetless shows an app user no page. \n\n**The one exception is a `state` that resolves to no interaction** \u2014 unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at `400`. That is the only Fleetless-rendered surface an app user can reach. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
7943
+ notes: "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` \u2014 the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org \u2014 an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome \u2014 it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds \u2014 RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=\u2026&state=\u2026` when a session was resolved, `?error=<clientOidcErrorCode>&state=\u2026` when it was not, so the app renders its own message and can bind either answer to the request it started. This route renders no page for an outcome. \n\n**The one exception is a `state` that resolves to no interaction** \u2014 unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at `400`. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
6994
7944
  },
6995
7945
  {
6996
7946
  method: "POST",
@@ -8181,6 +9131,97 @@ var ROUTES = [
8181
9131
  transport: "http",
8182
9132
  notes: "A window longer than `USAGE_WINDOW_MAX_DAYS` is refused naming the field, not silently capped: a caller who asked for more than the platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a cross-field rule no JSON Schema can express and is enforced here. The window is echoed back."
8183
9133
  },
9134
+ {
9135
+ method: "POST",
9136
+ path: "/api/feedback",
9137
+ section: "org",
9138
+ summary: "Sends a message from a developer to the people who build Fleetless.",
9139
+ audience: "developer",
9140
+ auth: "developer",
9141
+ rateLimited: true,
9142
+ ownerTier: false,
9143
+ status: 202,
9144
+ params: [],
9145
+ query: null,
9146
+ request: feedbackRequest,
9147
+ response: feedbackResponse,
9148
+ errors: [...DEVELOPER_GUARD, "validation_error", "rate_limited"],
9149
+ transport: "http",
9150
+ notes: "The message is stored before any mail is tried, so `202` means it is kept whatever `mail` says: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers `429 rate_limited` with `retry_after_ms`. Replies come by mail, to the sender's address."
9151
+ },
9152
+ /* ----------------------------------------------------------- org (plan) */
9153
+ {
9154
+ method: "GET",
9155
+ path: "/api/org/plan",
9156
+ section: "org",
9157
+ summary: "Reads the org's plan: its limits, its usage against them, its add-ons and any change already queued.",
9158
+ audience: "developer",
9159
+ auth: "developer",
9160
+ rateLimited: false,
9161
+ ownerTier: false,
9162
+ status: 200,
9163
+ params: [],
9164
+ query: null,
9165
+ request: null,
9166
+ response: orgPlan,
9167
+ errors: [...DEVELOPER_GUARD],
9168
+ transport: "http",
9169
+ notes: "The one read the console's Plan & billing page, its usage and limit gauges, and every upgrade prompt and feature gate draw from \u2014 nothing else computes `limits` or `usage` on its own. `limits` is already the effective ceiling, the catalogue row raised by `addons` or replaced by an operator's override, so a consumer never recomputes it from the catalogue. `usage` is counted fresh on every call, never cached. `switch` is present only for an organization still on the beta that has not yet landed on a priced plan."
9170
+ },
9171
+ {
9172
+ method: "PUT",
9173
+ path: "/api/org/plan/change",
9174
+ section: "org",
9175
+ summary: "Moves the org's plan down \u2014 a lower plan or a cancellation to Basic \u2014 queuing the change rather than applying it at once.",
9176
+ audience: "developer",
9177
+ auth: "developer",
9178
+ rateLimited: false,
9179
+ ownerTier: true,
9180
+ status: 200,
9181
+ params: [],
9182
+ query: null,
9183
+ request: planChangeRequest,
9184
+ response: orgPlan,
9185
+ errors: [...DEVELOPER_GUARD, "tier_required", "validation_error", "plan_limit", "target_state_conflict"],
9186
+ transport: "http",
9187
+ 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 up \u2014 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 Fleetless then applies through the admin route. `409 target_state_conflict` names `target_plan` with rule `not_lower` when the chosen plan is not below the org's current one; with rule `locked_basic_only` when the org is locked (`orgLock`) and the chosen plan is anything but Basic; and with rule `migration_basic_only` when the org is still on the beta, awaiting the switch to priced plans, and the chosen plan is anything but Basic \u2014 that choice is exactly what the org lands on at the switch. An owner is never named in `keep` and always stays, but still counts against the target plan's `seats`; `409 plan_limit` names `seats` when the owners alone already exceed it, and names whichever other limit `keep` still exceeds otherwise. `keep` is `null` when the org's current usage already fits the target plan outright and nothing is deleted; named, it lists exactly the robots, apps, app users and developers that stay. The choice is stored as `pending_change` and takes effect at `period_ends_at` \u2014 **everything of the chosen kind not named in `keep` is deleted at that instant, never before** \u2014 except a choice made while the org is locked, which takes effect at once, and a beta org's choice, which takes effect at the switch date instead. Anything created while the choice is pending is checked against the target plan too and, when it passes, is folded into `keep`, so exactly what the confirmation counted is what is actually deleted. A later `PUT` replaces a still-pending choice outright."
9188
+ },
9189
+ {
9190
+ method: "DELETE",
9191
+ path: "/api/org/plan/change",
9192
+ section: "org",
9193
+ summary: "Withdraws a plan change that was queued but has not taken effect yet.",
9194
+ audience: "developer",
9195
+ auth: "developer",
9196
+ rateLimited: false,
9197
+ ownerTier: true,
9198
+ status: 204,
9199
+ params: [],
9200
+ query: null,
9201
+ request: null,
9202
+ response: null,
9203
+ errors: [...DEVELOPER_GUARD, "tier_required", "not_found"],
9204
+ transport: "http",
9205
+ 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 the choice had never been made; a developer who wants a different one sends a new `PUT`, which would have replaced this one outright anyway."
9206
+ },
9207
+ {
9208
+ method: "PATCH",
9209
+ path: "/api/admin/orgs/:id/plan",
9210
+ section: "org",
9211
+ summary: "Changes an org's plan, add-ons, limit overrides, currency or billing period as the operator.",
9212
+ audience: "internal",
9213
+ auth: "ops",
9214
+ rateLimited: true,
9215
+ ownerTier: false,
9216
+ status: 200,
9217
+ params: [{ name: "id", description: "The org's uuid, whose plan, add-ons or overrides the operator is changing." }],
9218
+ query: null,
9219
+ request: adminPlanChangeRequest,
9220
+ response: orgPlan,
9221
+ errors: ["unauthorized", "not_found", "validation_error", "plan_limit", "rate_limited"],
9222
+ transport: "http",
9223
+ notes: "Every public host answers `404 not_found` for every `/api/admin/*` path \u2014 this route is reachable only on the cloud's private address \u2014 and that same address answers `404` here too while `OPS_API_TOKEN` is not configured, so a door with no key behind it reads exactly like one nobody opened. An unknown `:id` is the same `404`. Applies at once, never queued as a `pending_change`, 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 would leave the org over a limit \u2014 the operator never deletes an org's things, only the owner's own choice does. On success it also withdraws any `pending_change`, ends a beta org's wait for the switch and lifts a lock. Audited as `org.plan_changed` with the `fleetless` actor, never a developer's \u2014 the row names what an operator did, not who in the org asked for it."
9224
+ },
8184
9225
  /* ------------------------------------------------- assets (robot upload) */
8185
9226
  {
8186
9227
  method: "POST",
@@ -8196,9 +9237,9 @@ var ROUTES = [
8196
9237
  query: null,
8197
9238
  request: null,
8198
9239
  response: asset,
8199
- errors: ["unauthorized", "rate_limited", "validation_error", "not_found", "quota_exceeded", "bad_request"],
9240
+ errors: ["unauthorized", "rate_limited", "validation_error", "not_found", "file_too_large", "plan_limit", "quota_exceeded", "bad_request"],
8200
9241
  transport: "http",
8201
- notes: "The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file \u2014 its kind, its name, its sync id and its announced size \u2014 rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than following it: a `preHandler` would already have buffered the whole file. **Nothing is refused for its own size** \u2014 the robot's asset store is the only limit, so the announced size is checked there against `ROBOT_ASSET_STORE_BYTES` and a file with no room left answers `409 quota_exceeded` carrying `store_bytes`, `used_bytes` and `size_bytes`, while the sync carries on with the next file. Past that, the server's own body limit answers a bare `413 bad_request` with none of those numbers in it. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler registered on this route. The URDF itself is never refused for the store; only meshes and textures are charged against it."
9242
+ notes: "The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file \u2014 its kind, its name, its sync id and its announced size \u2014 rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than following it: a `preHandler` would already have buffered the whole file. **One file can be at most `ASSET_FILE_MAX_BYTES`**, on every plan and for every kind, the URDF included: an announced size over it answers `413 file_too_large` with `max_bytes` and `size_bytes` before a byte is buffered; without a truthful size, a body over the limit answers the same code with the bytes that arrived as `size_bytes`, or with `size_bytes: null` when it ran past the server's body limit and nobody counted the bytes. Retrying does not help. A file that fits but finds the store full answers `409 plan_limit` (or `409 quota_exceeded` for an organisation still on the beta) carrying `store_bytes`, `used_bytes` and `size_bytes`, while the sync carries on with the next file. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler registered on this route. The URDF itself is never refused for the store; only meshes and textures are charged against it."
8202
9243
  },
8203
9244
  /* ------------------------------------ realtime and bridge transports */
8204
9245
  {
@@ -8323,13 +9364,19 @@ var ServerKeyCredentials = class {
8323
9364
  function displayNameField(displayName) {
8324
9365
  return displayName === void 0 ? {} : { display_name: displayName };
8325
9366
  }
9367
+ function passwordField(password2) {
9368
+ return password2 === void 0 ? {} : { password: password2 };
9369
+ }
9370
+ function challengeField(challenge) {
9371
+ return challenge === void 0 ? {} : { challenge };
9372
+ }
8326
9373
  function createPublicAuthCalls(http, appIdentifier2) {
8327
9374
  return {
8328
9375
  async register(input) {
8329
9376
  const body = {
8330
9377
  app_identifier: appIdentifier2,
8331
9378
  email: input.email,
8332
- password: input.password,
9379
+ ...passwordField(input.password),
8333
9380
  ...displayNameField(input.displayName)
8334
9381
  };
8335
9382
  await http.request("/api/client/register", { method: "POST", skipAuth: true, expectEmptyBody: true, body });
@@ -8341,6 +9388,10 @@ function createPublicAuthCalls(http, appIdentifier2) {
8341
9388
  async requestPasswordReset(email) {
8342
9389
  const body = { app_identifier: appIdentifier2, email };
8343
9390
  await http.request("/api/client/password/reset", { method: "POST", skipAuth: true, expectEmptyBody: true, body });
9391
+ },
9392
+ async requestLoginCode(email) {
9393
+ const body = { app_identifier: appIdentifier2, email };
9394
+ await http.request("/api/client/login/code", { method: "POST", skipAuth: true, expectEmptyBody: true, body });
8344
9395
  }
8345
9396
  };
8346
9397
  }
@@ -8348,6 +9399,11 @@ function createSessionAuth(http, tokenStore, appIdentifier2) {
8348
9399
  async function storeSession(tokens) {
8349
9400
  await tokenStore.save(tokens);
8350
9401
  }
9402
+ async function completeSignIn(result) {
9403
+ if ("status" in result) return { status: result.status, challenge: result.challenge };
9404
+ await storeSession(result);
9405
+ return { status: "signed_in" };
9406
+ }
8351
9407
  async function identity() {
8352
9408
  return http.request("/api/client/me", {});
8353
9409
  }
@@ -8355,13 +9411,18 @@ function createSessionAuth(http, tokenStore, appIdentifier2) {
8355
9411
  ...createPublicAuthCalls(http, appIdentifier2),
8356
9412
  async verifyEmail(token) {
8357
9413
  const body = { token };
8358
- const tokens = await http.request("/api/client/verify-email", { method: "POST", skipAuth: true, body });
8359
- await storeSession(tokens);
9414
+ const result = await http.request("/api/client/verify-email", { method: "POST", skipAuth: true, body });
9415
+ return completeSignIn(result);
8360
9416
  },
8361
9417
  async login(email, password2) {
8362
9418
  const body = { app_identifier: appIdentifier2, email, password: password2 };
8363
- const tokens = await http.request("/api/client/login", { method: "POST", skipAuth: true, body });
8364
- await storeSession(tokens);
9419
+ const result = await http.request("/api/client/login", { method: "POST", skipAuth: true, body });
9420
+ return completeSignIn(result);
9421
+ },
9422
+ async verifyLoginCode(email, code) {
9423
+ const body = { app_identifier: appIdentifier2, email, code };
9424
+ const result = await http.request("/api/client/login/code/verify", { method: "POST", skipAuth: true, body });
9425
+ return completeSignIn(result);
8365
9426
  },
8366
9427
  async logout() {
8367
9428
  const session = await tokenStore.load();
@@ -8375,6 +9436,13 @@ function createSessionAuth(http, tokenStore, appIdentifier2) {
8375
9436
  await tokenStore.save(null);
8376
9437
  },
8377
9438
  async me() {
9439
+ const session = await tokenStore.load();
9440
+ if (!session) {
9441
+ throw new FleetlessError(
9442
+ "no_session",
9443
+ "auth.me() has no session to ask about \u2014 call login()/verifyEmail()/confirmPasswordReset()/acceptInvitation() and get back { status: 'signed_in' } first; a pending two-factor challenge does not count as signed in."
9444
+ );
9445
+ }
8378
9446
  return identity();
8379
9447
  },
8380
9448
  async changePassword(currentPassword, newPassword) {
@@ -8384,23 +9452,61 @@ function createSessionAuth(http, tokenStore, appIdentifier2) {
8384
9452
  },
8385
9453
  async confirmPasswordReset(token, newPassword) {
8386
9454
  const body = { token, new_password: newPassword };
8387
- const tokens = await http.request("/api/client/password/reset/confirm", { method: "POST", skipAuth: true, body });
8388
- await storeSession(tokens);
9455
+ const result = await http.request("/api/client/password/reset/confirm", { method: "POST", skipAuth: true, body });
9456
+ return completeSignIn(result);
8389
9457
  },
8390
9458
  async acceptInvitation(input) {
8391
9459
  const body = {
8392
9460
  token: input.token,
8393
- password: input.password,
9461
+ ...passwordField(input.password),
8394
9462
  ...displayNameField(input.displayName)
8395
9463
  };
8396
- const tokens = await http.request("/api/client/invitations/accept", { method: "POST", skipAuth: true, body });
9464
+ const result = await http.request("/api/client/invitations/accept", { method: "POST", skipAuth: true, body });
9465
+ return completeSignIn(result);
9466
+ },
9467
+ async verifyTwoFactor(input) {
9468
+ const body = {
9469
+ challenge: input.challenge,
9470
+ ...input.code !== void 0 ? { code: input.code } : {},
9471
+ ...input.recoveryCode !== void 0 ? { recovery_code: input.recoveryCode } : {}
9472
+ };
9473
+ const tokens = await http.request("/api/client/two-factor/verify", { method: "POST", skipAuth: true, body });
8397
9474
  await storeSession(tokens);
8398
9475
  },
9476
+ async beginTwoFactorSetup(input) {
9477
+ const challenge = input?.challenge;
9478
+ const body = challengeField(challenge);
9479
+ const response = await http.request("/api/client/two-factor/setup", {
9480
+ method: "POST",
9481
+ skipAuth: challenge !== void 0,
9482
+ body
9483
+ });
9484
+ return { secret: response.secret, otpauthUrl: response.otpauth_url };
9485
+ },
9486
+ async confirmTwoFactorSetup(input) {
9487
+ const body = { code: input.code, ...challengeField(input.challenge) };
9488
+ const response = await http.request("/api/client/two-factor/setup/confirm", {
9489
+ method: "POST",
9490
+ skipAuth: input.challenge !== void 0,
9491
+ body
9492
+ });
9493
+ await storeSession(response.session);
9494
+ return { recoveryCodes: response.recovery_codes };
9495
+ },
9496
+ async disableTwoFactor(code) {
9497
+ const body = { code };
9498
+ await http.request("/api/client/two-factor", { method: "DELETE", expectEmptyBody: true, body });
9499
+ },
8399
9500
  async listProviders() {
8400
9501
  const query = new URLSearchParams({ app_identifier: appIdentifier2 });
8401
9502
  const response = await http.request(`/api/client/providers?${query.toString()}`, { skipAuth: true });
8402
9503
  return response.providers;
8403
9504
  },
9505
+ async signInMethods() {
9506
+ const query = new URLSearchParams({ app_identifier: appIdentifier2 });
9507
+ const response = await http.request(`/api/client/providers?${query.toString()}`, { skipAuth: true });
9508
+ return { password: response.sign_in_methods.password, emailCode: response.sign_in_methods.email_code };
9509
+ },
8404
9510
  async beginOidcLogin(input) {
8405
9511
  const state = generateState();
8406
9512
  const codeVerifier = generateCodeVerifier();
@@ -8484,11 +9590,11 @@ function createServerKeyAuth(http, appIdentifier2, option = "serverKey") {
8484
9590
  const subject = option === "serverKey" ? "a server key" : "a supplied credential";
8485
9591
  const holder = option === "serverKey" ? "a server-key client" : "a client with a supplied credential";
8486
9592
  return {
8487
- // **`register`, `resendVerification` and `requestPasswordReset` are
8488
- // allowed here** — see `createPublicAuthCalls`. They are public routes
8489
- // that name their own subject and answer nothing, so a server-rendered
8490
- // sign-up or forgot-password page can use the one client its backend
8491
- // already has.
9593
+ // **`register`, `resendVerification`, `requestPasswordReset` and
9594
+ // `requestLoginCode` are allowed here** — see `createPublicAuthCalls`.
9595
+ // They are public routes that name their own subject and answer
9596
+ // nothing, so a server-rendered sign-up or forgot-password (or
9597
+ // sign-in-by-code) page can use the one client its backend already has.
8492
9598
  ...createPublicAuthCalls(http, appIdentifier2),
8493
9599
  async verifyEmail() {
8494
9600
  serverKeyRefusal("verifyEmail", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
@@ -8496,6 +9602,9 @@ function createServerKeyAuth(http, appIdentifier2, option = "serverKey") {
8496
9602
  async login() {
8497
9603
  serverKeyRefusal("login", `${subject} IS the credential; there is nothing to exchange`);
8498
9604
  },
9605
+ async verifyLoginCode() {
9606
+ serverKeyRefusal("verifyLoginCode", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
9607
+ },
8499
9608
  async logout() {
8500
9609
  serverKeyRefusal("logout", `${subject} holds no session to end`);
8501
9610
  },
@@ -8511,11 +9620,31 @@ function createServerKeyAuth(http, appIdentifier2, option = "serverKey") {
8511
9620
  async acceptInvitation() {
8512
9621
  serverKeyRefusal("acceptInvitation", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
8513
9622
  },
9623
+ async verifyTwoFactor() {
9624
+ serverKeyRefusal("verifyTwoFactor", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
9625
+ },
9626
+ async beginTwoFactorSetup() {
9627
+ serverKeyRefusal("beginTwoFactorSetup", `a two-factor setup is a person's own account's, and ${subject} is not a person's session`);
9628
+ },
9629
+ async confirmTwoFactorSetup() {
9630
+ serverKeyRefusal(
9631
+ "confirmTwoFactorSetup",
9632
+ `a two-factor setup is a person's own account's, and ${holder} is not a person's session and has nowhere to store the one this route answers with`
9633
+ );
9634
+ },
9635
+ async disableTwoFactor() {
9636
+ serverKeyRefusal("disableTwoFactor", `turning an authenticator off is a person's own decision, and ${subject} is not a person`);
9637
+ },
8514
9638
  async listProviders() {
8515
9639
  const query = new URLSearchParams({ app_identifier: appIdentifier2 });
8516
9640
  const response = await http.request(`/api/client/providers?${query.toString()}`, { skipAuth: true });
8517
9641
  return response.providers;
8518
9642
  },
9643
+ async signInMethods() {
9644
+ const query = new URLSearchParams({ app_identifier: appIdentifier2 });
9645
+ const response = await http.request(`/api/client/providers?${query.toString()}`, { skipAuth: true });
9646
+ return { password: response.sign_in_methods.password, emailCode: response.sign_in_methods.email_code };
9647
+ },
8519
9648
  async beginOidcLogin() {
8520
9649
  serverKeyRefusal("beginOidcLogin", "a federated sign-in is inherently an app user's browser flow");
8521
9650
  },