@fleetless/contracts 6.0.0-next.1 → 6.0.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -3
- package/artifacts/openapi.json +18 -25
- package/artifacts/routes.json +27 -21
- package/artifacts/schema/app-invitation.schema.json +4 -11
- package/dist/app-users.d.ts +3 -4
- package/dist/app-users.js +4 -5
- package/dist/errors.js +7 -8
- package/dist/identity.d.ts +3 -5
- package/dist/identity.js +3 -5
- package/dist/routes.d.ts +10 -3
- package/dist/routes.js +39 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -90,6 +90,18 @@ version.
|
|
|
90
90
|
`acceptTeamInviteRequest` takes an optional `display_name` and no
|
|
91
91
|
password. The default invitation mail no longer asks the invitee to
|
|
92
92
|
choose a password.
|
|
93
|
+
- **`appInvitation.accept_url` is never `null`**: an app with no
|
|
94
|
+
`invite_url` gets the hosted invitation page's link.
|
|
95
|
+
- **`appMailTemplateListResponse.templates` holds up to four** (was three),
|
|
96
|
+
with `login_code`.
|
|
97
|
+
- **The portal's identify step mails a sign-in code** (`POST
|
|
98
|
+
/console/oauth/identify`, `POST /mcp/oauth/identify`) instead of handing
|
|
99
|
+
back a password step, and answers the same for every address.
|
|
100
|
+
- **A password reset and a password change are refused with `403
|
|
101
|
+
method_not_allowed`** while the app has the password method off — `POST
|
|
102
|
+
/api/client/password/reset`, `…/password/reset/confirm`,
|
|
103
|
+
`…/password/change` and the developer's `POST
|
|
104
|
+
/api/apps/:id/users/:userId/reset-password`.
|
|
93
105
|
|
|
94
106
|
### Removed
|
|
95
107
|
|
|
@@ -102,11 +114,11 @@ version.
|
|
|
102
114
|
/console/oauth/login` and `POST /mcp/oauth/login`. Fleetless users sign
|
|
103
115
|
in by emailed code or passkey; the portal sign-up is the one way to
|
|
104
116
|
create an organisation. `passwordChangeRequest` stays, for `POST
|
|
105
|
-
/api/client/password/change`.
|
|
106
|
-
|
|
117
|
+
/api/client/password/change`. The newly required fields above
|
|
118
|
+
(`appUser.two_factor`, `org.require_two_factor`,
|
|
107
119
|
`fleetlessUser.two_factor`, `clientIdentity.two_factor_enabled`,
|
|
108
120
|
`clientProviderListResponse.sign_in_methods`) and the narrowed `mcp`
|
|
109
|
-
slice
|
|
121
|
+
slice break existing readers as well.
|
|
110
122
|
|
|
111
123
|
## [5.2.0] — 2026-09-30
|
|
112
124
|
|
package/artifacts/openapi.json
CHANGED
|
@@ -2011,7 +2011,7 @@
|
|
|
2011
2011
|
}
|
|
2012
2012
|
},
|
|
2013
2013
|
"default": {
|
|
2014
|
-
"description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
|
|
2014
|
+
"description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`, `method_not_allowed`.",
|
|
2015
2015
|
"content": {
|
|
2016
2016
|
"application/json": {
|
|
2017
2017
|
"schema": {
|
|
@@ -2021,7 +2021,7 @@
|
|
|
2021
2021
|
}
|
|
2022
2022
|
}
|
|
2023
2023
|
},
|
|
2024
|
-
"description": "The support door beside `POST /api/client/password/reset
|
|
2024
|
+
"description": "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** — the caller is authenticated into the app and can read the user list — 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 — an OIDC-only app user, whom a reset link would hand a second, quieter door — 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."
|
|
2025
2025
|
}
|
|
2026
2026
|
},
|
|
2027
2027
|
"/api/apps/{id}/users/{userId}/two-factor": {
|
|
@@ -3181,7 +3181,7 @@
|
|
|
3181
3181
|
"name": "kind",
|
|
3182
3182
|
"in": "path",
|
|
3183
3183
|
"required": true,
|
|
3184
|
-
"description": "Which of the
|
|
3184
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
|
|
3185
3185
|
"schema": {
|
|
3186
3186
|
"type": "string"
|
|
3187
3187
|
}
|
|
@@ -3236,7 +3236,7 @@
|
|
|
3236
3236
|
"name": "kind",
|
|
3237
3237
|
"in": "path",
|
|
3238
3238
|
"required": true,
|
|
3239
|
-
"description": "Which of the
|
|
3239
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
|
|
3240
3240
|
"schema": {
|
|
3241
3241
|
"type": "string"
|
|
3242
3242
|
}
|
|
@@ -3301,7 +3301,7 @@
|
|
|
3301
3301
|
"name": "kind",
|
|
3302
3302
|
"in": "path",
|
|
3303
3303
|
"required": true,
|
|
3304
|
-
"description": "Which of the
|
|
3304
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
|
|
3305
3305
|
"schema": {
|
|
3306
3306
|
"type": "string"
|
|
3307
3307
|
}
|
|
@@ -3351,7 +3351,7 @@
|
|
|
3351
3351
|
"name": "kind",
|
|
3352
3352
|
"in": "path",
|
|
3353
3353
|
"required": true,
|
|
3354
|
-
"description": "Which of the
|
|
3354
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
|
|
3355
3355
|
"schema": {
|
|
3356
3356
|
"type": "string"
|
|
3357
3357
|
}
|
|
@@ -3418,7 +3418,7 @@
|
|
|
3418
3418
|
"name": "kind",
|
|
3419
3419
|
"in": "path",
|
|
3420
3420
|
"required": true,
|
|
3421
|
-
"description": "Which of the
|
|
3421
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
|
|
3422
3422
|
"schema": {
|
|
3423
3423
|
"type": "string"
|
|
3424
3424
|
}
|
|
@@ -4767,7 +4767,7 @@
|
|
|
4767
4767
|
}
|
|
4768
4768
|
}
|
|
4769
4769
|
},
|
|
4770
|
-
"description": "**`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 — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out
|
|
4770
|
+
"description": "**`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 — 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` — spending the code proves the address, as the verification link would — 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.",
|
|
4771
4771
|
"requestBody": {
|
|
4772
4772
|
"required": true,
|
|
4773
4773
|
"content": {
|
|
@@ -4956,7 +4956,7 @@
|
|
|
4956
4956
|
"description": "Success."
|
|
4957
4957
|
},
|
|
4958
4958
|
"default": {
|
|
4959
|
-
"description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`.",
|
|
4959
|
+
"description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`, `method_not_allowed`.",
|
|
4960
4960
|
"content": {
|
|
4961
4961
|
"application/json": {
|
|
4962
4962
|
"schema": {
|
|
@@ -4966,7 +4966,7 @@
|
|
|
4966
4966
|
}
|
|
4967
4967
|
}
|
|
4968
4968
|
},
|
|
4969
|
-
"description": "The pair of app identifier and address is the identifier: an app user's address is unique only within their app. Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an identity provider — 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.",
|
|
4969
|
+
"description": "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 — 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 — one created through an identity provider — 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.",
|
|
4970
4970
|
"requestBody": {
|
|
4971
4971
|
"required": true,
|
|
4972
4972
|
"content": {
|
|
@@ -5179,7 +5179,7 @@
|
|
|
5179
5179
|
}
|
|
5180
5180
|
},
|
|
5181
5181
|
"default": {
|
|
5182
|
-
"description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `validation_error`, `invalid_credentials`, `target_state_conflict`.",
|
|
5182
|
+
"description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `validation_error`, `invalid_credentials`, `target_state_conflict`, `method_not_allowed`.",
|
|
5183
5183
|
"content": {
|
|
5184
5184
|
"application/json": {
|
|
5185
5185
|
"schema": {
|
|
@@ -5189,7 +5189,7 @@
|
|
|
5189
5189
|
}
|
|
5190
5190
|
}
|
|
5191
5191
|
},
|
|
5192
|
-
"description": "The guard admits all three caller kinds, but a password belongs to an app user specifically — 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** — an OIDC-only app user, which the schema admits — 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.",
|
|
5192
|
+
"description": "`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 — 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** — an OIDC-only app user, which the schema admits — 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.",
|
|
5193
5193
|
"requestBody": {
|
|
5194
5194
|
"required": true,
|
|
5195
5195
|
"content": {
|
|
@@ -5326,9 +5326,9 @@
|
|
|
5326
5326
|
}
|
|
5327
5327
|
}
|
|
5328
5328
|
},
|
|
5329
|
-
"description": "**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. 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.",
|
|
5329
|
+
"description": "**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.",
|
|
5330
5330
|
"requestBody": {
|
|
5331
|
-
"required":
|
|
5331
|
+
"required": false,
|
|
5332
5332
|
"content": {
|
|
5333
5333
|
"application/json": {
|
|
5334
5334
|
"schema": {
|
|
@@ -9853,17 +9853,10 @@
|
|
|
9853
9853
|
"description": "When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does."
|
|
9854
9854
|
},
|
|
9855
9855
|
"accept_url": {
|
|
9856
|
-
"
|
|
9857
|
-
|
|
9858
|
-
|
|
9859
|
-
|
|
9860
|
-
"format": "uri"
|
|
9861
|
-
},
|
|
9862
|
-
{
|
|
9863
|
-
"type": "null"
|
|
9864
|
-
}
|
|
9865
|
-
],
|
|
9866
|
-
"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. Nullable for readers of the earlier shape; the cloud always fills it."
|
|
9856
|
+
"type": "string",
|
|
9857
|
+
"maxLength": 500,
|
|
9858
|
+
"format": "uri",
|
|
9859
|
+
"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."
|
|
9867
9860
|
},
|
|
9868
9861
|
"mail": {
|
|
9869
9862
|
"type": "string",
|
package/artifacts/routes.json
CHANGED
|
@@ -1114,10 +1114,11 @@
|
|
|
1114
1114
|
"token_revoked",
|
|
1115
1115
|
"invalid_uuid",
|
|
1116
1116
|
"not_found",
|
|
1117
|
-
"target_state_conflict"
|
|
1117
|
+
"target_state_conflict",
|
|
1118
|
+
"method_not_allowed"
|
|
1118
1119
|
],
|
|
1119
1120
|
"transport": "http",
|
|
1120
|
-
"notes": "The support door beside `POST /api/client/password/reset
|
|
1121
|
+
"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** — the caller is authenticated into the app and can read the user list — 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 — an OIDC-only app user, whom a reset link would hand a second, quieter door — 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."
|
|
1121
1122
|
},
|
|
1122
1123
|
{
|
|
1123
1124
|
"method": "DELETE",
|
|
@@ -1800,7 +1801,7 @@
|
|
|
1800
1801
|
},
|
|
1801
1802
|
{
|
|
1802
1803
|
"name": "kind",
|
|
1803
|
-
"description": "Which of the
|
|
1804
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`."
|
|
1804
1805
|
}
|
|
1805
1806
|
],
|
|
1806
1807
|
"query": null,
|
|
@@ -1834,7 +1835,7 @@
|
|
|
1834
1835
|
},
|
|
1835
1836
|
{
|
|
1836
1837
|
"name": "kind",
|
|
1837
|
-
"description": "Which of the
|
|
1838
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`."
|
|
1838
1839
|
}
|
|
1839
1840
|
],
|
|
1840
1841
|
"query": null,
|
|
@@ -1870,7 +1871,7 @@
|
|
|
1870
1871
|
},
|
|
1871
1872
|
{
|
|
1872
1873
|
"name": "kind",
|
|
1873
|
-
"description": "Which of the
|
|
1874
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`."
|
|
1874
1875
|
}
|
|
1875
1876
|
],
|
|
1876
1877
|
"query": null,
|
|
@@ -1904,7 +1905,7 @@
|
|
|
1904
1905
|
},
|
|
1905
1906
|
{
|
|
1906
1907
|
"name": "kind",
|
|
1907
|
-
"description": "Which of the
|
|
1908
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`."
|
|
1908
1909
|
}
|
|
1909
1910
|
],
|
|
1910
1911
|
"query": null,
|
|
@@ -1940,7 +1941,7 @@
|
|
|
1940
1941
|
},
|
|
1941
1942
|
{
|
|
1942
1943
|
"name": "kind",
|
|
1943
|
-
"description": "Which of the
|
|
1944
|
+
"description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`."
|
|
1944
1945
|
}
|
|
1945
1946
|
],
|
|
1946
1947
|
"query": null,
|
|
@@ -2406,7 +2407,7 @@
|
|
|
2406
2407
|
"response": null,
|
|
2407
2408
|
"errors": [],
|
|
2408
2409
|
"transport": "http",
|
|
2409
|
-
"notes": "HTML, and a GET rather than the body of the authorize response — 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."
|
|
2410
|
+
"notes": "HTML, and a GET rather than the body of the authorize response — 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."
|
|
2410
2411
|
},
|
|
2411
2412
|
{
|
|
2412
2413
|
"method": "POST",
|
|
@@ -2425,10 +2426,11 @@
|
|
|
2425
2426
|
"errors": [
|
|
2426
2427
|
"rate_limited",
|
|
2427
2428
|
"validation_error",
|
|
2428
|
-
"token_spent"
|
|
2429
|
+
"token_spent",
|
|
2430
|
+
"wrong_browser"
|
|
2429
2431
|
],
|
|
2430
2432
|
"transport": "http",
|
|
2431
|
-
"notes": "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. A browser form post gets the code card; a JSON caller gets `{ \"next\": \"/mcp/oauth/code\" }`, which has no schema. A dead interaction is `410 token_spent`."
|
|
2433
|
+
"notes": "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\": \"/mcp/oauth/code\" }`, which has no schema. A dead interaction is `410 token_spent`."
|
|
2432
2434
|
},
|
|
2433
2435
|
{
|
|
2434
2436
|
"method": "POST",
|
|
@@ -2544,7 +2546,7 @@
|
|
|
2544
2546
|
"wrong_browser"
|
|
2545
2547
|
],
|
|
2546
2548
|
"transport": "http",
|
|
2547
|
-
"notes": "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."
|
|
2549
|
+
"notes": "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 — so `POST /mcp/oauth/passkey` can require it."
|
|
2548
2550
|
},
|
|
2549
2551
|
{
|
|
2550
2552
|
"method": "POST",
|
|
@@ -2808,7 +2810,7 @@
|
|
|
2808
2810
|
"response": null,
|
|
2809
2811
|
"errors": [],
|
|
2810
2812
|
"transport": "http",
|
|
2811
|
-
"notes": "HTML: the email field, `Email me a code`, and `Sign in with a passkey`. 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."
|
|
2813
|
+
"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."
|
|
2812
2814
|
},
|
|
2813
2815
|
{
|
|
2814
2816
|
"method": "POST",
|
|
@@ -2827,10 +2829,11 @@
|
|
|
2827
2829
|
"errors": [
|
|
2828
2830
|
"rate_limited",
|
|
2829
2831
|
"validation_error",
|
|
2830
|
-
"token_spent"
|
|
2832
|
+
"token_spent",
|
|
2833
|
+
"wrong_browser"
|
|
2831
2834
|
],
|
|
2832
2835
|
"transport": "http",
|
|
2833
|
-
"notes": "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. A browser form post gets the code card; a JSON caller gets `{ \"next\": \"/console/oauth/code\" }`, which has no schema. A dead interaction is `410 token_spent`."
|
|
2836
|
+
"notes": "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\": \"/console/oauth/code\" }`, which has no schema. A dead interaction is `410 token_spent`."
|
|
2834
2837
|
},
|
|
2835
2838
|
{
|
|
2836
2839
|
"method": "POST",
|
|
@@ -2946,7 +2949,7 @@
|
|
|
2946
2949
|
"wrong_browser"
|
|
2947
2950
|
],
|
|
2948
2951
|
"transport": "http",
|
|
2949
|
-
"notes": "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."
|
|
2952
|
+
"notes": "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 — so `POST /console/oauth/passkey` can require it."
|
|
2950
2953
|
},
|
|
2951
2954
|
{
|
|
2952
2955
|
"method": "POST",
|
|
@@ -4004,7 +4007,7 @@
|
|
|
4004
4007
|
"method_not_allowed"
|
|
4005
4008
|
],
|
|
4006
4009
|
"transport": "http",
|
|
4007
|
-
"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 — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out
|
|
4010
|
+
"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 — 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` — spending the code proves the address, as the verification link would — 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."
|
|
4008
4011
|
},
|
|
4009
4012
|
{
|
|
4010
4013
|
"method": "POST",
|
|
@@ -4117,10 +4120,11 @@
|
|
|
4117
4120
|
"errors": [
|
|
4118
4121
|
"rate_limited",
|
|
4119
4122
|
"validation_error",
|
|
4120
|
-
"not_found"
|
|
4123
|
+
"not_found",
|
|
4124
|
+
"method_not_allowed"
|
|
4121
4125
|
],
|
|
4122
4126
|
"transport": "http",
|
|
4123
|
-
"notes": "The pair of app identifier and address is the identifier: an app user's address is unique only within their app. Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an identity provider — 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."
|
|
4127
|
+
"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 — 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 — one created through an identity provider — 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."
|
|
4124
4128
|
},
|
|
4125
4129
|
{
|
|
4126
4130
|
"method": "POST",
|
|
@@ -4235,10 +4239,11 @@
|
|
|
4235
4239
|
"forbidden",
|
|
4236
4240
|
"validation_error",
|
|
4237
4241
|
"invalid_credentials",
|
|
4238
|
-
"target_state_conflict"
|
|
4242
|
+
"target_state_conflict",
|
|
4243
|
+
"method_not_allowed"
|
|
4239
4244
|
],
|
|
4240
4245
|
"transport": "http",
|
|
4241
|
-
"notes": "The guard admits all three caller kinds, but a password belongs to an app user specifically — 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** — an OIDC-only app user, which the schema admits — 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."
|
|
4246
|
+
"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 — 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** — an OIDC-only app user, which the schema admits — 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."
|
|
4242
4247
|
},
|
|
4243
4248
|
{
|
|
4244
4249
|
"method": "GET",
|
|
@@ -4299,6 +4304,7 @@
|
|
|
4299
4304
|
"params": [],
|
|
4300
4305
|
"query": null,
|
|
4301
4306
|
"request": "client-two-factor-setup-request",
|
|
4307
|
+
"requestOptional": true,
|
|
4302
4308
|
"response": "two-factor-setup-response",
|
|
4303
4309
|
"errors": [
|
|
4304
4310
|
"rate_limited",
|
|
@@ -4308,7 +4314,7 @@
|
|
|
4308
4314
|
"target_state_conflict"
|
|
4309
4315
|
],
|
|
4310
4316
|
"transport": "http",
|
|
4311
|
-
"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. 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."
|
|
4317
|
+
"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."
|
|
4312
4318
|
},
|
|
4313
4319
|
{
|
|
4314
4320
|
"method": "POST",
|
|
@@ -33,17 +33,10 @@
|
|
|
33
33
|
"description": "When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does."
|
|
34
34
|
},
|
|
35
35
|
"accept_url": {
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"format": "uri"
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"type": "null"
|
|
44
|
-
}
|
|
45
|
-
],
|
|
46
|
-
"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. Nullable for readers of the earlier shape; the cloud always fills it."
|
|
36
|
+
"type": "string",
|
|
37
|
+
"maxLength": 500,
|
|
38
|
+
"format": "uri",
|
|
39
|
+
"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."
|
|
47
40
|
},
|
|
48
41
|
"mail": {
|
|
49
42
|
"type": "string",
|
package/dist/app-users.d.ts
CHANGED
|
@@ -177,9 +177,8 @@ export type CreateAppInvitationRequest = z.infer<typeof createAppInvitationReque
|
|
|
177
177
|
*
|
|
178
178
|
* The link points into the developer's app, at their configured `invite_url`,
|
|
179
179
|
* or at the Fleetless-hosted invitation page (`appAuthConfig.hosted_pages`)
|
|
180
|
-
* when the app has configured none
|
|
181
|
-
*
|
|
182
|
-
* in every case now that a hosted page always exists.
|
|
180
|
+
* when the app has configured none — so there is always a link, and
|
|
181
|
+
* `accept_url` is never `null`.
|
|
183
182
|
*/
|
|
184
183
|
export declare const appInvitation: z.ZodObject<{
|
|
185
184
|
id: z.ZodUUID;
|
|
@@ -187,7 +186,7 @@ export declare const appInvitation: z.ZodObject<{
|
|
|
187
186
|
email: z.ZodEmail;
|
|
188
187
|
role_id: z.ZodUUID;
|
|
189
188
|
expires_at: z.ZodISODateTime;
|
|
190
|
-
accept_url: z.
|
|
189
|
+
accept_url: z.ZodURL;
|
|
191
190
|
mail: z.ZodEnum<{
|
|
192
191
|
sent: "sent";
|
|
193
192
|
not_requested: "not_requested";
|
package/dist/app-users.js
CHANGED
|
@@ -210,9 +210,8 @@ export const createAppInvitationRequest = z
|
|
|
210
210
|
*
|
|
211
211
|
* The link points into the developer's app, at their configured `invite_url`,
|
|
212
212
|
* or at the Fleetless-hosted invitation page (`appAuthConfig.hosted_pages`)
|
|
213
|
-
* when the app has configured none
|
|
214
|
-
*
|
|
215
|
-
* in every case now that a hosted page always exists.
|
|
213
|
+
* when the app has configured none — so there is always a link, and
|
|
214
|
+
* `accept_url` is never `null`.
|
|
216
215
|
*/
|
|
217
216
|
export const appInvitation = z.object({
|
|
218
217
|
id: z.uuid().meta({ description: 'The invitation, as listed and revoked by the developer.' }),
|
|
@@ -220,8 +219,8 @@ export const appInvitation = z.object({
|
|
|
220
219
|
email: z.email().meta({ description: 'The address the invitation was addressed to.' }),
|
|
221
220
|
role_id: 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.' }),
|
|
222
221
|
expires_at: z.iso.datetime().meta({ description: 'When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does.' }),
|
|
223
|
-
accept_url: z.url().max(500).
|
|
224
|
-
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.
|
|
222
|
+
accept_url: z.url().max(500).meta({
|
|
223
|
+
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.',
|
|
225
224
|
}),
|
|
226
225
|
mail: mailStatus.meta({
|
|
227
226
|
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.',
|
package/dist/errors.js
CHANGED
|
@@ -589,10 +589,9 @@ export const ERROR_CODES = [
|
|
|
589
589
|
* string on the validation envelope is not a code a consumer can switch on.
|
|
590
590
|
*
|
|
591
591
|
* Its producers in the two-space model are the ones about an app or an
|
|
592
|
-
* account rather than about a caller
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
* where the session is live and it is the target's state that refuses.
|
|
592
|
+
* account rather than about a caller — for example a password change on an
|
|
593
|
+
* app user who has no password at all, an OIDC-only account, where the
|
|
594
|
+
* session is live and it is the target's state that refuses.
|
|
596
595
|
*
|
|
597
596
|
* **A tier change aimed at somebody who is not a Fleetless user of this org
|
|
598
597
|
* was listed here and stopped being a producer at the cut.** That refusal
|
|
@@ -857,10 +856,10 @@ export const ERROR_CODES = [
|
|
|
857
856
|
*/
|
|
858
857
|
'invalid_code',
|
|
859
858
|
/**
|
|
860
|
-
* `403`: the app does not offer this sign-in method — a password login
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
* no enumeration oracle.
|
|
859
|
+
* `403`: the app does not offer this sign-in method — a password login, a
|
|
860
|
+
* password reset or a password change on a code-only app, or a code request
|
|
861
|
+
* on a password-only one. It names the app's policy, never a person, so it
|
|
862
|
+
* is no enumeration oracle.
|
|
864
863
|
*/
|
|
865
864
|
'method_not_allowed',
|
|
866
865
|
];
|
package/dist/identity.d.ts
CHANGED
|
@@ -220,11 +220,9 @@ export type WaitlistRequest = z.infer<typeof waitlistRequest>;
|
|
|
220
220
|
* no sender can promise that, and this value must never
|
|
221
221
|
* be rendered as if it could.
|
|
222
222
|
* - `not_requested` — no mail was attempted: the caller asked for none
|
|
223
|
-
* (`send_mail: false`)
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* word rather than a reuse of `not_configured`**: the
|
|
227
|
-
* deployment's mailer is irrelevant in both cases, and a
|
|
223
|
+
* (`send_mail: false`). **A fourth word rather than a
|
|
224
|
+
* reuse of `not_configured`**: the deployment's mailer
|
|
225
|
+
* is irrelevant there, and a
|
|
228
226
|
* console reading "mail server not configured" beside an
|
|
229
227
|
* invitation whose mail checkbox was off would send a
|
|
230
228
|
* developer to fix something that is not broken.
|
package/dist/identity.js
CHANGED
|
@@ -225,11 +225,9 @@ export const waitlistRequest = z.object({ email: z.email().max(254) });
|
|
|
225
225
|
* no sender can promise that, and this value must never
|
|
226
226
|
* be rendered as if it could.
|
|
227
227
|
* - `not_requested` — no mail was attempted: the caller asked for none
|
|
228
|
-
* (`send_mail: false`)
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* word rather than a reuse of `not_configured`**: the
|
|
232
|
-
* deployment's mailer is irrelevant in both cases, and a
|
|
228
|
+
* (`send_mail: false`). **A fourth word rather than a
|
|
229
|
+
* reuse of `not_configured`**: the deployment's mailer
|
|
230
|
+
* is irrelevant there, and a
|
|
233
231
|
* console reading "mail server not configured" beside an
|
|
234
232
|
* invitation whose mail checkbox was off would send a
|
|
235
233
|
* developer to fix something that is not broken.
|
package/dist/routes.d.ts
CHANGED
|
@@ -103,9 +103,16 @@ export declare const IN_HANDLER_ROUTES: readonly string[];
|
|
|
103
103
|
*
|
|
104
104
|
* Every step is a page the auth portal serves to itself: `audience:
|
|
105
105
|
* 'internal'`, no request schema — the handlers read form fields by hand — and
|
|
106
|
-
* HTML for a browser form post, JSON for a JSON caller.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
106
|
+
* HTML for a browser form post, JSON for a JSON caller.
|
|
107
|
+
*
|
|
108
|
+
* **The browser-proof cookie binds the interaction to one browser**, and it
|
|
109
|
+
* is set by whichever of these comes first for the interaction: the email
|
|
110
|
+
* card (`GET <prefix>/interaction/:id`), `POST <prefix>/identify`, or `POST
|
|
111
|
+
* <prefix>/passkey/options`. The email card is what a browser normally opens
|
|
112
|
+
* first; the two steps set it for a caller that never loaded the page, so
|
|
113
|
+
* `Sign in with a passkey` works without an email step. Once the interaction
|
|
114
|
+
* is bound, every step — those three included — without the matching cookie
|
|
115
|
+
* renders the `wrong_browser` page. The interaction's ten
|
|
109
116
|
* minutes cover every step; only when the last one is done is anything
|
|
110
117
|
* minted. **For `/mcp/oauth`, "done" means the consent step**, as the
|
|
111
118
|
* password did before.
|
package/dist/routes.js
CHANGED
|
@@ -126,9 +126,16 @@ const CLIENT_GUARD = ['unauthorized', 'token_expired', 'token_revoked', 'forbidd
|
|
|
126
126
|
*
|
|
127
127
|
* Every step is a page the auth portal serves to itself: `audience:
|
|
128
128
|
* 'internal'`, no request schema — the handlers read form fields by hand — and
|
|
129
|
-
* HTML for a browser form post, JSON for a JSON caller.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
129
|
+
* HTML for a browser form post, JSON for a JSON caller.
|
|
130
|
+
*
|
|
131
|
+
* **The browser-proof cookie binds the interaction to one browser**, and it
|
|
132
|
+
* is set by whichever of these comes first for the interaction: the email
|
|
133
|
+
* card (`GET <prefix>/interaction/:id`), `POST <prefix>/identify`, or `POST
|
|
134
|
+
* <prefix>/passkey/options`. The email card is what a browser normally opens
|
|
135
|
+
* first; the two steps set it for a caller that never loaded the page, so
|
|
136
|
+
* `Sign in with a passkey` works without an email step. Once the interaction
|
|
137
|
+
* is bound, every step — those three included — without the matching cookie
|
|
138
|
+
* renders the `wrong_browser` page. The interaction's ten
|
|
132
139
|
* minutes cover every step; only when the last one is done is anything
|
|
133
140
|
* minted. **For `/mcp/oauth`, "done" means the consent step**, as the
|
|
134
141
|
* password did before.
|
|
@@ -150,11 +157,11 @@ export function developerSignInRoutes(prefix) {
|
|
|
150
157
|
params: [], query: null, request: null, response, errors: ['rate_limited', ...errors], transport: 'http', notes,
|
|
151
158
|
});
|
|
152
159
|
return [
|
|
153
|
-
step('/identify', 'Takes the email address, mails a sign-in code and hands back the code step.', null, ['validation_error', 'token_spent'], 'The page answers `Check your email` **for every address**: a known one gets `Your Fleetless sign-in code`, six digits valid ten ' +
|
|
160
|
+
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 ' +
|
|
154
161
|
'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 ' +
|
|
155
162
|
'is closed). So the page never reveals who has an account, and the address is trimmed and compared case-insensitively. A request ' +
|
|
156
163
|
'within sixty seconds of the last one for the same address renders the same page without a second mail. The browser-proof cookie is ' +
|
|
157
|
-
`set here. A browser form post gets the code card; a JSON caller gets \`{ "next": "${prefix}/code" }\`, which has no schema. A dead ` +
|
|
164
|
+
`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 ` +
|
|
158
165
|
'interaction is `410 token_spent`.'),
|
|
159
166
|
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 ' +
|
|
160
167
|
'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 ' +
|
|
@@ -171,7 +178,9 @@ export function developerSignInRoutes(prefix) {
|
|
|
171
178
|
`a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`),
|
|
172
179
|
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` ' +
|
|
173
180
|
'(`Sign in with a passkey`); after the code step they name the account\'s own passkeys. User verification is required. The challenge ' +
|
|
174
|
-
'is bound to the interaction and single-use.'
|
|
181
|
+
'is bound to the interaction and single-use. **This is the first step of a passkey sign-in**, which has no email step: the ' +
|
|
182
|
+
'browser-proof cookie is set here when the interaction has none yet, and checked when it has — so `POST ' + prefix + '/passkey` ' +
|
|
183
|
+
'can require it.'),
|
|
175
184
|
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 ' +
|
|
176
185
|
'second step — used as the second step, it finishes it. An assertion that does not verify, or names no passkey of an account, is ' +
|
|
177
186
|
'`401 invalid_credentials`, the same answer for both. When the organisation requires two-factor, a passkey satisfies it. A browser ' +
|
|
@@ -696,8 +705,9 @@ export const ROUTES = [
|
|
|
696
705
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 202,
|
|
697
706
|
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`.' }],
|
|
698
707
|
query: null, request: null, response: mailOutcome,
|
|
699
|
-
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
|
|
700
|
-
notes: 'The support door beside `POST /api/client/password/reset
|
|
708
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict', 'method_not_allowed'], transport: 'http',
|
|
709
|
+
notes: 'The support door beside `POST /api/client/password/reset`, refused like it with `403 method_not_allowed` while the app has the ' +
|
|
710
|
+
'password method off: the same one-hour token and the same link, triggered by a developer for a ' +
|
|
701
711
|
'user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read ' +
|
|
702
712
|
'the user list — so this one answers what actually happened: `{ "mail": mailStatus }`, where `not_configured` is a deployment without a ' +
|
|
703
713
|
'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 ' +
|
|
@@ -1018,7 +1028,7 @@ export const ROUTES = [
|
|
|
1018
1028
|
method: 'GET', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
1019
1029
|
summary: 'Reads one custom mail template of the app.',
|
|
1020
1030
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1021
|
-
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
|
|
1031
|
+
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 — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
1022
1032
|
query: null, request: null, response: appMailTemplate,
|
|
1023
1033
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
1024
1034
|
notes: 'The `kind` segment is a `mailTemplateKind`, so a fourth word is `400 validation_error` — the path names a set that is closed, and ' +
|
|
@@ -1030,7 +1040,7 @@ export const ROUTES = [
|
|
|
1030
1040
|
method: 'PUT', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
1031
1041
|
summary: 'Stores or replaces the app\'s template for one kind of mail, refusing one that does not render.',
|
|
1032
1042
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1033
|
-
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
|
|
1043
|
+
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 — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
1034
1044
|
query: null, request: putAppMailTemplateRequest, response: appMailTemplate,
|
|
1035
1045
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited'], transport: 'http',
|
|
1036
1046
|
notes: 'The body carries `subject`, `text` and an optional `html`, each a Liquid template; `kind` is in the path and `updated_at` is the ' +
|
|
@@ -1048,7 +1058,7 @@ export const ROUTES = [
|
|
|
1048
1058
|
method: 'DELETE', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
1049
1059
|
summary: 'Drops the app\'s custom template for one kind, returning that mail to the Fleetless default.',
|
|
1050
1060
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
1051
|
-
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
|
|
1061
|
+
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 — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
1052
1062
|
query: null, request: null, response: null,
|
|
1053
1063
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
1054
1064
|
notes: 'The mail keeps being sent — this removes the developer\'s wording, not the message. A kind that already has no custom template answers ' +
|
|
@@ -1059,7 +1069,7 @@ export const ROUTES = [
|
|
|
1059
1069
|
method: 'POST', path: '/api/apps/:id/mail-templates/:kind/preview', section: 'apps',
|
|
1060
1070
|
summary: 'Renders a template with sample data and answers the three parts, storing nothing.',
|
|
1061
1071
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1062
|
-
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
|
|
1072
|
+
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 — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
1063
1073
|
query: null, request: mailTemplatePreviewRequest, response: mailTemplatePreviewResponse,
|
|
1064
1074
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited'], transport: 'http',
|
|
1065
1075
|
notes: 'Takes the same document the PUT does and writes nothing, so a developer can see the rendered subject, text and HTML before anybody ' +
|
|
@@ -1076,7 +1086,7 @@ export const ROUTES = [
|
|
|
1076
1086
|
method: 'POST', path: '/api/apps/:id/mail-templates/:kind/test', section: 'apps',
|
|
1077
1087
|
summary: 'Sends the rendered template as a real mail to the calling developer.',
|
|
1078
1088
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 202,
|
|
1079
|
-
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
|
|
1089
|
+
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 — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
1080
1090
|
query: null, request: mailTemplatePreviewRequest, response: mailOutcome,
|
|
1081
1091
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited', 'target_state_conflict'],
|
|
1082
1092
|
transport: 'http',
|
|
@@ -1296,7 +1306,8 @@ export const ROUTES = [
|
|
|
1296
1306
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1297
1307
|
notes: 'HTML, and a GET rather than the body of the authorize response — so it is reloadable, bookmarkable and survives a back button, which ' +
|
|
1298
1308
|
'the inline page it replaced was not. An expired, consumed, unknown or hand-edited interaction renders one page at `410`, and so does a ' +
|
|
1299
|
-
'client whose dynamic registration lapsed in between.'
|
|
1309
|
+
'client whose dynamic registration lapsed in between. It offers the email field and `Sign in with a passkey`, and opening it binds ' +
|
|
1310
|
+
'the interaction to this browser with the browser-proof cookie, which every sign-in step after it checks.',
|
|
1300
1311
|
},
|
|
1301
1312
|
...developerSignInRoutes('/mcp/oauth'),
|
|
1302
1313
|
{
|
|
@@ -1347,7 +1358,8 @@ export const ROUTES = [
|
|
|
1347
1358
|
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1348
1359
|
params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here.' }],
|
|
1349
1360
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1350
|
-
notes: 'HTML: the email field, `Email me a code`, and `Sign in with a passkey`.
|
|
1361
|
+
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 ' +
|
|
1362
|
+
'browser-proof cookie, which every sign-in step after it checks. An expired, consumed, unknown or hand-edited interaction ' +
|
|
1351
1363
|
'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 ' +
|
|
1352
1364
|
'page resolves nothing about the address typed into it, so there is no enumeration oracle here at all.',
|
|
1353
1365
|
},
|
|
@@ -1613,7 +1625,8 @@ export const ROUTES = [
|
|
|
1613
1625
|
errors: ['rate_limited', 'validation_error', 'not_found', 'method_not_allowed'], transport: 'http',
|
|
1614
1626
|
notes: '**`202` and an empty body for every request the policy allows**, in status, body and timing, whether or not the address names an ' +
|
|
1615
1627
|
'active account of this app — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out ' +
|
|
1616
|
-
'
|
|
1628
|
+
'for an `active` account and for one still `pending_verification` — spending the code proves the address, as the verification link ' +
|
|
1629
|
+
'would — 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 ' +
|
|
1617
1630
|
'the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and ' +
|
|
1618
1631
|
'compared case-insensitively. `404 not_found` is the **app identifier**, never the address; `403 method_not_allowed` when the app has ' +
|
|
1619
1632
|
'the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly.',
|
|
@@ -1697,8 +1710,10 @@ export const ROUTES = [
|
|
|
1697
1710
|
summary: 'Mails an app user a reset link, and answers the same either way.',
|
|
1698
1711
|
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
1699
1712
|
params: [], query: null, request: clientPasswordResetRequest, response: null,
|
|
1700
|
-
errors: ['rate_limited', 'validation_error', 'not_found'], transport: 'http',
|
|
1713
|
+
errors: ['rate_limited', 'validation_error', 'not_found', 'method_not_allowed'], transport: 'http',
|
|
1701
1714
|
notes: 'The pair of app identifier and address is the identifier: an app user\'s address is unique only within their app. ' +
|
|
1715
|
+
'`403 method_not_allowed` when the app has the password method off — a reset link whose confirmation would be refused is not mailed; ' +
|
|
1716
|
+
'the code names the app\'s policy, not a person. ' +
|
|
1702
1717
|
'Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an ' +
|
|
1703
1718
|
'identity provider — is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link ' +
|
|
1704
1719
|
'points at the app\'s `reset_url`, or at the hosted reset page when the app has configured none.',
|
|
@@ -1773,8 +1788,10 @@ export const ROUTES = [
|
|
|
1773
1788
|
summary: "Changes an app user's own password and answers a fresh session.",
|
|
1774
1789
|
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1775
1790
|
params: [], query: null, request: passwordChangeRequest, response: sessionTokens,
|
|
1776
|
-
errors: [...CLIENT_GUARD, 'validation_error', 'invalid_credentials', 'target_state_conflict'], transport: 'http',
|
|
1777
|
-
notes: '
|
|
1791
|
+
errors: [...CLIENT_GUARD, 'validation_error', 'invalid_credentials', 'target_state_conflict', 'method_not_allowed'], transport: 'http',
|
|
1792
|
+
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 ' +
|
|
1793
|
+
'changed either. ' +
|
|
1794
|
+
'The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key ' +
|
|
1778
1795
|
'reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made ' +
|
|
1779
1796
|
'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** — ' +
|
|
1780
1797
|
'an OIDC-only app user, which the schema admits — answers `409 target_state_conflict` naming the `password` field with rule `not_set`, ' +
|
|
@@ -1806,10 +1823,11 @@ export const ROUTES = [
|
|
|
1806
1823
|
method: 'POST', path: '/api/client/two-factor/setup', section: 'client-auth',
|
|
1807
1824
|
summary: 'Starts an authenticator setup and answers its secret and otpauth URL.',
|
|
1808
1825
|
audience: 'client', auth: 'in_handler', rateLimited: true, ownerTier: false, status: 200,
|
|
1809
|
-
params: [], query: null, request: clientTwoFactorSetupRequest, response: twoFactorSetupResponse,
|
|
1826
|
+
params: [], query: null, request: clientTwoFactorSetupRequest, requestOptional: true, response: twoFactorSetupResponse,
|
|
1810
1827
|
errors: ['rate_limited', 'validation_error', 'token_spent', 'unauthorized', 'target_state_conflict'], transport: 'http',
|
|
1811
1828
|
notes: '**Two ways in, decided in the handler.** During sign-in the body carries the `two_factor_setup_required` challenge, and that is the ' +
|
|
1812
|
-
'credential; from the app\'s own account settings the app user\'s bearer is, with no challenge
|
|
1829
|
+
'credential; from the app\'s own account settings the app user\'s bearer is, with no challenge and an empty or missing body. Neither ' +
|
|
1830
|
+
'is `401 unauthorized`, and a ' +
|
|
1813
1831
|
'dead challenge is `410 token_spent`. `409 target_state_conflict` names `two_factor` with rule `off` when the app\'s policy is `off`. ' +
|
|
1814
1832
|
'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 ' +
|
|
1815
1833
|
'secret, and an account that already has an authenticator keeps it until the new one is confirmed.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetless/contracts",
|
|
3
|
-
"version": "6.0.0-next.
|
|
3
|
+
"version": "6.0.0-next.2",
|
|
4
4
|
"description": "Fleetless wire contracts: the bridge-cloud protocol, the REST API schemas and the error codes, as zod schemas with generated JSON Schema and OpenAPI artifacts.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dehne Robotik GmbH",
|