Skip to main content

isb_server/auth/http/
spec.rs

1//! The identity endpoints as data: every route [`super::AuthApi`] answers,
2//! with who may call it, its body and its answer, and the MCP tool that
3//! does the same (or why there is none). The OpenAPI document is built from
4//! this table, and a test holds it to the router's own `match` arms, so the
5//! two cannot drift apart.
6
7use serde_json::{Value, json};
8
9/// Where the same capability is for agents.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Agents {
12    /// This tool does the same, with the same rules.
13    Tool(&'static str),
14    /// Deliberately not a tool, and why.
15    BrowserOnly(&'static str),
16}
17
18/// One identity endpoint.
19#[derive(Debug, Clone, Copy)]
20pub struct Route {
21    pub method: &'static str,
22    /// Under `/api/v1/auth/`, with `{param}`s.
23    pub path: &'static str,
24    pub summary: &'static str,
25    /// Who may call it.
26    pub who: &'static str,
27    /// The JSON body, when it takes one.
28    pub body: Option<fn() -> Value>,
29    /// The success status.
30    pub ok: u16,
31    /// The success answer (`None`: no body, or a redirect).
32    pub answer: Option<fn() -> Value>,
33    pub agents: Agents,
34}
35
36const SIGN_IN: &str =
37    "a browser sign-in flow: it sets the session cookie a person's browser carries";
38const WAYS_IN: &str = "a way into the account: changed from a signed-in browser session only, so a leaked token cannot lock its owner out or let an attacker in";
39const NO_CREDENTIAL: &str =
40    "for someone who holds no credential yet: the token or address they bring is the credential";
41
42fn r(name: &str) -> Value {
43    json!({"$ref": format!("#/components/schemas/{name}")})
44}
45
46fn obj(props: Value, required: &[&str]) -> Value {
47    json!({"type": "object", "properties": props, "required": required})
48}
49
50fn list(key: &str, item: &str) -> Value {
51    obj(json!({key: {"type": "array", "items": r(item)}}), &[key])
52}
53
54fn session_answer() -> Value {
55    r("SessionAnswer")
56}
57fn me() -> Value {
58    r("Me")
59}
60fn setup_get() -> Value {
61    obj(json!({"needed": {"type": "boolean"}}), &["needed"])
62}
63fn setup_body() -> Value {
64    obj(
65        json!({"setup_token": {"type": "string"}, "email": {"type": "string"}, "name": {"type": "string"}, "password": {"type": "string"}}),
66        &["setup_token", "email", "password"],
67    )
68}
69fn login_body() -> Value {
70    obj(
71        json!({"email": {"type": "string"}, "password": {"type": "string"}}),
72        &["email", "password"],
73    )
74}
75fn sessions() -> Value {
76    list("sessions", "Session")
77}
78fn invite_body() -> Value {
79    obj(
80        json!({"org": {"type": "string"}, "email": {"type": "string"}, "role": r("Role")}),
81        &["org", "email"],
82    )
83}
84fn invite_answer() -> Value {
85    obj(
86        json!({"invitation": r("Invitation"), "token": {"type": "string"}, "link": {"type": ["string", "null"]}}),
87        &["invitation", "token", "link"],
88    )
89}
90fn token_body() -> Value {
91    obj(json!({"token": {"type": "string"}}), &["token"])
92}
93fn invitation_info() -> Value {
94    r("InvitationInfo")
95}
96fn accept_body() -> Value {
97    obj(
98        json!({"token": {"type": "string"}, "name": {"type": "string"}, "password": {"type": "string"}}),
99        &["token"],
100    )
101}
102fn accept_answer() -> Value {
103    obj(
104        json!({"user": r("User"), "membership": r("Membership"), "created": {"type": "boolean"}}),
105        &["user", "membership", "created"],
106    )
107}
108fn tokens() -> Value {
109    list("tokens", "ApiToken")
110}
111fn new_token_body() -> Value {
112    obj(
113        json!({
114            "name": {"type": "string"},
115            "org": {"type": ["string", "null"]},
116            "expires": {"type": ["string", "null"], "description": "90d, 12h; absent: never."},
117            "scopes": {"type": "array", "items": {"type": "string"}, "description": "read, deploy, admin, tool:GLOB."},
118        }),
119        &["name"],
120    )
121}
122fn new_token_answer() -> Value {
123    obj(
124        json!({"token": {"type": "string"}, "info": r("ApiToken")}),
125        &["token", "info"],
126    )
127}
128fn ssh_keys() -> Value {
129    list("ssh_keys", "SshKey")
130}
131fn ssh_key_body() -> Value {
132    obj(
133        json!({"public_key": {"type": "string"}, "name": {"type": "string"}}),
134        &["public_key"],
135    )
136}
137fn ssh_key_answer() -> Value {
138    obj(json!({"ssh_key": r("SshKey")}), &["ssh_key"])
139}
140fn password_body() -> Value {
141    obj(
142        json!({"current_password": {"type": "string"}, "new_password": {"type": "string"}}),
143        &["current_password", "new_password"],
144    )
145}
146fn email_body() -> Value {
147    obj(json!({"email": {"type": "string"}}), &["email"])
148}
149fn ok_answer() -> Value {
150    obj(json!({"ok": {"type": "boolean"}}), &["ok"])
151}
152fn reset_body() -> Value {
153    obj(
154        json!({"token": {"type": "string"}, "password": {"type": "string"}}),
155        &["token", "password"],
156    )
157}
158fn members() -> Value {
159    list("members", "Member")
160}
161fn role_body() -> Value {
162    obj(json!({"role": r("Role")}), &["role"])
163}
164fn role_answer() -> Value {
165    obj(
166        json!({"user_id": {"type": "integer"}, "role": r("Role")}),
167        &["user_id", "role"],
168    )
169}
170fn invitations() -> Value {
171    list("invitations", "Invitation")
172}
173fn org_tokens() -> Value {
174    list("tokens", "OrgToken")
175}
176/// Who reaches every org without a mapping: a count for any member, the
177/// names for owners and admins only.
178fn reach(you: bool) -> Value {
179    let mut props = json!({
180        "count": {"type": "integer"},
181        "who": {"type": "array", "items": {"type": "string"}, "description": "Emails, logins or client ids; empty unless the caller owns or administers the org."},
182    });
183    let mut req = vec!["count", "who"];
184    if you {
185        props["you"] = json!({"type": "boolean", "description": "The caller is one of them."});
186        req.push("you");
187    }
188    obj(props, &req)
189}
190fn agent_identities() -> Value {
191    obj(
192        json!({
193            "identities": {"type": "array", "items": r("AgentIdentity")},
194            "available": obj(
195                json!({
196                    "tailnet_listen": {"type": "array", "items": {"type": "string"}, "description": "The tailnet --listen addresses; empty when no tailnet peer can reach the server."},
197                    "access": {"type": "boolean", "description": "Cloudflare Access guards a listener."},
198                    "public_url": {"type": ["string", "null"], "description": "The server's --public-url, null when unset."},
199                    "reach": obj(
200                        json!({
201                            "platform_admins": reach(false),
202                            "access_superadmins": reach(true),
203                            "tailnet_superadmins": reach(true),
204                        }),
205                        &["platform_admins", "access_superadmins", "tailnet_superadmins"],
206                    ),
207                }),
208                &["tailnet_listen", "access", "public_url", "reach"],
209            ),
210        }),
211        &["identities", "available"],
212    )
213}
214fn agent_identity_body() -> Value {
215    obj(
216        json!({
217            "kind": {"type": "string", "enum": ["tailnet", "access"]},
218            "subject": {"type": "string", "description": "Tailnet: a login name or tag:name. Access: the email of someone who is not an isb user, or a service token's client id."},
219            "role": {"type": "string", "enum": ["admin", "member", "viewer"]},
220            "note": {"type": "string"},
221        }),
222        &["kind", "subject", "role"],
223    )
224}
225fn agent_identity_answer() -> Value {
226    obj(json!({"identity": r("AgentIdentity")}), &["identity"])
227}
228fn users() -> Value {
229    list("users", "AdminUser")
230}
231fn user_change() -> Value {
232    json!({"type": "object", "properties": {"disabled": {"type": "boolean"}, "platform_admin": {"type": "boolean"}}, "additionalProperties": false})
233}
234fn user_answer() -> Value {
235    obj(json!({"user": r("User")}), &["user"])
236}
237fn providers() -> Value {
238    r("Providers")
239}
240fn oauth_body() -> Value {
241    obj(
242        json!({"next": {"type": "string"}, "invite": {"type": "string"}, "intent": {"type": "string", "enum": ["login", "link"]}}),
243        &[],
244    )
245}
246fn url_answer() -> Value {
247    obj(json!({"url": {"type": "string"}}), &["url"])
248}
249fn identities() -> Value {
250    list("identities", "Identity")
251}
252fn passkeys() -> Value {
253    list("passkeys", "Passkey")
254}
255fn public_key_options() -> Value {
256    obj(
257        json!({"publicKey": {"type": "object", "additionalProperties": true, "description": "WebAuthn options, in the JSON form of PublicKeyCredential.parse*OptionsFromJSON()."}}),
258        &["publicKey"],
259    )
260}
261fn passkey_login_body() -> Value {
262    obj(json!({"email": {"type": "string"}}), &[])
263}
264fn credential_body() -> Value {
265    obj(
266        json!({"name": {"type": "string"}, "credential": {"type": "object", "additionalProperties": true, "description": "The browser's credential.toJSON()."}}),
267        &["credential"],
268    )
269}
270fn passkey_answer() -> Value {
271    obj(json!({"passkey": r("Passkey")}), &["passkey"])
272}
273
274macro_rules! route {
275    ($m:literal $p:literal, $summary:literal, $who:literal, $body:expr, $ok:literal, $answer:expr, $agents:expr) => {
276        Route {
277            method: $m,
278            path: $p,
279            summary: $summary,
280            who: $who,
281            body: $body,
282            ok: $ok,
283            answer: $answer,
284            agents: $agents,
285        }
286    };
287}
288
289use Agents::{BrowserOnly as B, Tool as T};
290
291/// Every identity endpoint.
292pub const ROUTES: &[Route] = &[
293    route!("GET" "setup", "Is first-run setup needed", "anyone", None, 200, Some(setup_get), B("first-run setup happens once, in a browser, with the setup token from the host")),
294    route!("POST" "setup", "Create the first platform admin", "anyone, with the setup token", Some(setup_body), 201, Some(session_answer), B("first-run setup happens once, in a browser, with the setup token from the host")),
295    route!("POST" "login", "Sign in with a password", "anyone", Some(login_body), 200, Some(session_answer), B(SIGN_IN)),
296    route!("POST" "logout", "Sign out", "anyone", None, 204, None, B(SIGN_IN)),
297    route!("GET" "me", "Who is calling", "signed in", None, 200, Some(me), T("whoami")),
298    route!("GET" "sessions", "Your sessions", "signed in", None, 200, Some(sessions), T("session_list")),
299    route!("DELETE" "sessions/{id}", "End a session", "signed in", None, 204, None, T("session_revoke")),
300    route!("POST" "invitations", "Invite someone to an org", "org owners and admins", Some(invite_body), 201, Some(invite_answer), T("invitation_create")),
301    route!("POST" "invitations/inspect", "What an invitation is for", "anyone with the token", Some(token_body), 200, Some(invitation_info), B(NO_CREDENTIAL)),
302    route!("POST" "invitations/accept", "Accept an invitation", "anyone with the token", Some(accept_body), 200, Some(accept_answer), B(NO_CREDENTIAL)),
303    route!("GET" "tokens", "Your API tokens", "signed in", None, 200, Some(tokens), T("token_list")),
304    route!("POST" "tokens", "Create an API token", "signed in with a session or an Access or tailnet identity; never with a token", Some(new_token_body), 201, Some(new_token_answer), T("token_create")),
305    route!("DELETE" "tokens/{id}", "Revoke an API token", "its user, or the org's owners and admins", None, 204, None, T("token_revoke")),
306    route!("GET" "ssh-keys", "Your SSH keys", "signed in", None, 200, Some(ssh_keys), T("ssh_key_list")),
307    route!("POST" "ssh-keys", "Add an SSH key", "signed in, with an account", Some(ssh_key_body), 201, Some(ssh_key_answer), T("ssh_key_add")),
308    route!("DELETE" "ssh-keys/{id}", "Remove an SSH key", "signed in, with an account", None, 204, None, T("ssh_key_remove")),
309    route!("POST" "password", "Change your password", "signed in with a session", Some(password_body), 204, None, B(WAYS_IN)),
310    route!("POST" "password-reset/request", "Ask for a password reset", "anyone", Some(email_body), 202, Some(ok_answer), B(NO_CREDENTIAL)),
311    route!("POST" "password-reset/confirm", "Set a password with a reset token", "anyone with the token", Some(reset_body), 204, None, B(NO_CREDENTIAL)),
312    route!("GET" "providers", "How one can sign in here", "anyone", None, 200, Some(providers), B(SIGN_IN)),
313    route!("GET" "oauth/{provider}/start", "Start a provider sign-in (redirect)", "anyone", None, 303, None, B(SIGN_IN)),
314    route!("POST" "oauth/{provider}/start", "Start a provider sign-in", "anyone", Some(oauth_body), 200, Some(url_answer), B(SIGN_IN)),
315    route!("GET" "oauth/{provider}/callback", "The provider's redirect back", "the provider's redirect", None, 303, None, B(SIGN_IN)),
316    route!("GET" "identities", "Your linked sign-in identities", "signed in", None, 200, Some(identities), B(WAYS_IN)),
317    route!("DELETE" "identities/{id}", "Unlink a sign-in identity", "signed in with a session", None, 204, None, B(WAYS_IN)),
318    route!("GET" "passkeys", "Your passkeys", "signed in", None, 200, Some(passkeys), B(WAYS_IN)),
319    route!("DELETE" "passkeys/{id}", "Remove a passkey", "signed in with a session", None, 204, None, B(WAYS_IN)),
320    route!("POST" "passkeys/register/options", "Begin adding a passkey", "signed in with a session", None, 200, Some(public_key_options), B(WAYS_IN)),
321    route!("POST" "passkeys/register/verify", "Finish adding a passkey", "signed in with a session", Some(credential_body), 201, Some(passkey_answer), B(WAYS_IN)),
322    route!("POST" "passkeys/login/options", "Begin a passkey sign-in", "anyone", Some(passkey_login_body), 200, Some(public_key_options), B(SIGN_IN)),
323    route!("POST" "passkeys/login/verify", "Finish a passkey sign-in", "anyone", Some(credential_body), 200, Some(session_answer), B(SIGN_IN)),
324    route!("GET" "admin/users", "Every user", "platform admins", None, 200, Some(users), T("user_list")),
325    route!("PATCH" "admin/users/{id}", "Disable a user, or grant platform admin", "platform admins", Some(user_change), 200, Some(user_answer), T("user_update")),
326    route!("GET" "orgs/{org}/members", "An org's members", "org members", None, 200, Some(members), T("member_list")),
327    route!("PUT" "orgs/{org}/members/{user_id}", "Change a member's role", "org owners and admins", Some(role_body), 200, Some(role_answer), T("member_update")),
328    route!("DELETE" "orgs/{org}/members/{user_id}", "Remove a member (or leave)", "org owners and admins, or the member leaving", None, 204, None, T("member_remove")),
329    route!("GET" "orgs/{org}/invitations", "An org's pending invitations", "org owners and admins", None, 200, Some(invitations), T("invitation_list")),
330    route!("DELETE" "orgs/{org}/invitations/{id}", "Revoke an invitation", "org owners and admins", None, 204, None, T("invitation_revoke")),
331    route!("GET" "orgs/{org}/agent-identities", "An org's tailnet and Access agent identities", "org members", None, 200, Some(agent_identities), T("agent_identity_list")),
332    route!("PUT" "orgs/{org}/agent-identities", "Map a tailnet or Access identity to a role in the org", "org owners and admins", Some(agent_identity_body), 200, Some(agent_identity_answer), T("agent_identity_set")),
333    route!("DELETE" "orgs/{org}/agent-identities/{id}", "Remove an agent identity", "org owners and admins", None, 204, None, T("agent_identity_remove")),
334    route!("GET" "orgs/{org}/tokens", "Every API token in an org", "org owners and admins", None, 200, Some(org_tokens), T("token_list")),
335];
336
337/// The schemas the identity endpoints answer with, by name.
338pub fn schemas() -> Value {
339    let user = obj(
340        json!({
341            "id": {"type": "integer"}, "email": {"type": "string"}, "name": {"type": "string"},
342            "platform_admin": {"type": "boolean"}, "created_at": {"type": "integer"},
343            "disabled": {"type": "boolean"}, "has_password": {"type": "boolean"},
344        }),
345        &[
346            "id",
347            "email",
348            "name",
349            "platform_admin",
350            "created_at",
351            "disabled",
352            "has_password",
353        ],
354    );
355    let token_props = json!({
356        "id": {"type": "integer"}, "name": {"type": "string"}, "user_id": {"type": "integer"},
357        "org": {"type": ["string", "null"]}, "created_at": {"type": "integer"},
358        "last_used": {"type": ["integer", "null"]}, "expires_at": {"type": ["integer", "null"]},
359        "scopes": {"type": "array", "items": {"type": "string"}},
360    });
361    let token_req = [
362        "id",
363        "name",
364        "user_id",
365        "org",
366        "created_at",
367        "last_used",
368        "expires_at",
369        "scopes",
370    ];
371    let mut org_token_props = token_props.clone();
372    org_token_props["user"] = obj(
373        json!({"id": {"type": "integer"}, "email": {"type": "string"}, "name": {"type": "string"}}),
374        &["id", "email", "name"],
375    );
376    let mut org_token_req = token_req.to_vec();
377    org_token_req.push("user");
378    let mut admin_user = user.clone();
379    admin_user["properties"]["memberships"] = json!({"type": "array", "items": r("Membership")});
380    admin_user["properties"]["last_active"] = json!({"type": ["integer", "null"]});
381    for k in ["memberships", "last_active"] {
382        admin_user["required"]
383            .as_array_mut()
384            .expect("required")
385            .push(json!(k));
386    }
387    let mut out = json!({
388        "Role": {"type": "string", "enum": ["owner", "admin", "member", "viewer"]},
389        "AuthError": obj(json!({"error": {"type": "string"}, "message": {"type": "string"}}), &["error", "message"]),
390        "User": user,
391        "AdminUser": admin_user,
392        "Membership": obj(json!({"org": {"type": "string"}, "role": r("Role")}), &["org", "role"]),
393        "Member": obj(json!({"user": r("User"), "role": r("Role"), "last_active": {"type": ["integer", "null"]}}), &["user", "role", "last_active"]),
394        "AgentIdentity": obj(json!({
395            "id": {"type": "integer"}, "org": {"type": "string"},
396            "kind": {"type": "string", "enum": ["tailnet", "access"]},
397            "subject": {"type": "string"}, "role": r("Role"), "note": {"type": "string"},
398            "created_at": {"type": "integer"}, "created_by": {"type": "string"},
399        }), &["id", "org", "kind", "subject", "role", "note", "created_at", "created_by"]),
400        "Session": obj(json!({
401            "id": {"type": "integer"}, "user_id": {"type": "integer"}, "created_at": {"type": "integer"},
402            "last_seen": {"type": "integer"}, "expires_at": {"type": "integer"}, "idle_expires_at": {"type": "integer"},
403            "user_agent": {"type": ["string", "null"]}, "ip": {"type": ["string", "null"]}, "current": {"type": "boolean"},
404        }), &["id", "user_id", "created_at", "last_seen", "expires_at", "idle_expires_at", "user_agent", "ip", "current"]),
405        "ApiToken": obj(token_props, &token_req),
406        "OrgToken": obj(org_token_props, &org_token_req),
407        "Invitation": obj(json!({
408            "id": {"type": "integer"}, "org": {"type": "string"}, "email": {"type": "string"}, "role": r("Role"),
409            "invited_by": {"type": ["integer", "null"]}, "created_at": {"type": "integer"},
410            "expires_at": {"type": "integer"}, "accepted_at": {"type": ["integer", "null"]},
411        }), &["id", "org", "email", "role", "invited_by", "created_at", "expires_at", "accepted_at"]),
412        "InvitationInfo": obj(json!({
413            "org": {"type": "string"}, "email": {"type": "string"}, "role": r("Role"),
414            "expires_at": {"type": "integer"}, "account_exists": {"type": "boolean"},
415        }), &["org", "email", "role", "expires_at", "account_exists"]),
416        "SshKey": obj(json!({
417            "id": {"type": "integer"}, "user_id": {"type": "integer"}, "name": {"type": "string"},
418            "algorithm": {"type": "string"}, "public_key": {"type": "string"}, "fingerprint": {"type": "string"},
419            "created_at": {"type": "integer"}, "last_used": {"type": ["integer", "null"]},
420        }), &["id", "user_id", "name", "algorithm", "public_key", "fingerprint", "created_at", "last_used"]),
421    });
422    if let (Some(s), Value::Object(more)) = (out.as_object_mut(), sign_in_schemas()) {
423        s.extend(more);
424    }
425    out
426}
427
428/// The schemas of the sign-in answers: sessions, identities, passkeys,
429/// providers and `me`.
430fn sign_in_schemas() -> Value {
431    let superadmin_via = json!({"type": "object", "properties": {"kind": {"type": "string", "enum": ["token", "tailnet", "access"]}}, "required": ["kind"], "additionalProperties": true});
432    json!({
433        "SessionAnswer": obj(json!({
434            "user": r("User"),
435            "memberships": {"type": "array", "items": r("Membership")},
436            "session": obj(json!({"id": {"type": "integer"}, "expires_at": {"type": "integer"}, "idle_expires_at": {"type": "integer"}}), &["id", "expires_at", "idle_expires_at"]),
437        }), &["user", "memberships", "session"]),
438        "Identity": obj(json!({
439            "id": {"type": "integer"}, "user_id": {"type": "integer"}, "provider": {"type": "string"},
440            "provider_id": {"type": ["string", "null"]}, "label": {"type": "string"}, "subject": {"type": "string"},
441            "email": {"type": ["string", "null"]}, "email_verified": {"type": "boolean"},
442            "created_at": {"type": "integer"}, "last_used": {"type": ["integer", "null"]},
443        }), &["id", "user_id", "provider", "provider_id", "label", "subject", "email", "email_verified", "created_at", "last_used"]),
444        "Passkey": obj(json!({
445            "id": {"type": "integer"}, "user_id": {"type": "integer"}, "credential_id": {"type": "string"},
446            "name": {"type": "string"}, "alg": {"type": "integer"}, "sign_count": {"type": "integer"},
447            "transports": {"type": "array", "items": {"type": "string"}}, "aaguid": {"type": ["string", "null"]},
448            "created_at": {"type": "integer"}, "last_used": {"type": ["integer", "null"]},
449        }), &["id", "user_id", "credential_id", "name", "alg", "sign_count", "transports", "aaguid", "created_at", "last_used"]),
450        "Provider": obj(json!({
451            "id": {"type": "string"}, "label": {"type": "string"},
452            "kind": {"type": "string", "enum": ["oauth2", "oidc"]}, "start": {"type": "string"},
453        }), &["id", "label", "kind", "start"]),
454        "Providers": obj(json!({
455            "providers": {"type": "array", "items": r("Provider")}, "password": {"type": "boolean"},
456            "passkeys": {"type": "boolean"}, "open_signup": {"type": "boolean"},
457        }), &["providers", "password", "passkeys", "open_signup"]),
458        "Me": obj(json!({
459            "user": r("User"),
460            "platform_admin": {"type": "boolean"},
461            "memberships": {"type": "array", "items": r("Membership")},
462            "orgs": {"type": "array", "items": {"type": "string"}, "description": "Every org this caller can open."},
463            "auth": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["session", "api_token", "access", "superadmin", "workspace", "agent"]}}, "required": ["kind"], "additionalProperties": true, "description": "How the caller signed in: {kind: session, id}, {kind: api_token, id, org, name, scopes?}, {kind: access}, {kind: superadmin, source}, {kind: workspace, org, name}, {kind: agent, label}."},
464            "superadmin": {"oneOf": [{"type": "null"}, obj(json!({"source": {"type": "string"}, "via": superadmin_via, "account": {"type": "boolean"}}), &["source", "via", "account"])]},
465        }), &["user", "platform_admin", "memberships", "orgs", "auth", "superadmin"]),
466    })
467}
468
469/// The OpenAPI path items for the identity endpoints, keyed by full path.
470pub fn paths() -> serde_json::Map<String, Value> {
471    let mut out = serde_json::Map::new();
472    for rt in ROUTES {
473        let full = format!("{}{}", super::PREFIX, rt.path);
474        let params: Vec<Value> = rt
475            .path
476            .split('/')
477            .filter_map(|s| s.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
478            .map(|p| {
479                let ty = if matches!(p, "id" | "user_id") {
480                    "integer"
481                } else {
482                    "string"
483                };
484                json!({"name": p, "in": "path", "required": true, "schema": {"type": ty}})
485            })
486            .collect();
487        let mut op = json!({
488            "operationId": operation_id(rt),
489            "tags": ["identity"],
490            "summary": rt.summary,
491            "description": format!("Who: {}.", rt.who),
492            "responses": {"default": {"description": "An error", "content": {"application/json": {"schema": r("AuthError")}}}},
493        });
494        match rt.agents {
495            Agents::Tool(t) => op["x-isb-tool"] = json!(t),
496            Agents::BrowserOnly(why) => op["x-isb-browser-only"] = json!(why),
497        }
498        if !params.is_empty() {
499            op["parameters"] = json!(params);
500        }
501        if let Some(b) = rt.body {
502            op["requestBody"] =
503                json!({"required": true, "content": {"application/json": {"schema": b()}}});
504        }
505        op["responses"][rt.ok.to_string()] = match rt.answer {
506            Some(a) => {
507                json!({"description": "OK", "content": {"application/json": {"schema": a()}}})
508            }
509            None if rt.ok == 303 => json!({"description": "A redirect (Location)"}),
510            None => json!({"description": "No content"}),
511        };
512        let item = out.entry(full).or_insert_with(|| json!({}));
513        item[rt.method.to_ascii_lowercase()] = op;
514    }
515    out
516}
517
518/// `auth_get_orgs_org_members` and the like: unique and stable.
519fn operation_id(rt: &Route) -> String {
520    let path: String = rt
521        .path
522        .chars()
523        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
524        .collect();
525    let path = path
526        .split('_')
527        .filter(|s| !s.is_empty())
528        .collect::<Vec<_>>()
529        .join("_");
530    format!("auth_{}_{path}", rt.method.to_ascii_lowercase())
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    /// The router's `("METHOD", ["seg", var, ...])` arms, as `METHOD a/{}/b`.
538    fn router_arms() -> Vec<String> {
539        let arm = regex_lite_arms("");
540        let mut out = Vec::new();
541        // The org endpoints are in their own file: every arm there is under
542        // `orgs/{org}/`.
543        for (src, org) in [
544            (include_str!("../http.rs"), false),
545            (include_str!("org.rs"), true),
546        ] {
547            for line in src.lines() {
548                for (m, segs) in arm(line) {
549                    let p = segs.join("/");
550                    out.push(if org {
551                        format!("{m} orgs/{{}}/{p}")
552                    } else {
553                        format!("{m} {p}")
554                    });
555                }
556            }
557        }
558        out.sort();
559        out.dedup();
560        out
561    }
562
563    /// A tiny matcher for `("GET", ["a", b, "c"])`, without a regex crate.
564    fn regex_lite_arms(_src: &str) -> impl Fn(&str) -> Vec<(String, Vec<String>)> {
565        |line: &str| {
566            let mut found = Vec::new();
567            let mut rest = line;
568            while let Some(i) = rest.find("(\"") {
569                let after = &rest[i + 2..];
570                let Some(q) = after.find('"') else { break };
571                let method = &after[..q];
572                let tail = &after[q + 1..];
573                rest = tail;
574                if !matches!(method, "GET" | "POST" | "PUT" | "PATCH" | "DELETE") {
575                    continue;
576                }
577                let Some(tail) = tail.strip_prefix(", [") else {
578                    continue;
579                };
580                let Some(end) = tail.find("])") else { continue };
581                let segs: Vec<String> = tail[..end]
582                    .split(',')
583                    .map(str::trim)
584                    .filter(|s| !s.is_empty())
585                    .map(
586                        |s| match s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
587                            Some(lit) => lit.to_string(),
588                            None => "{}".to_string(),
589                        },
590                    )
591                    .collect();
592                found.push((method.to_string(), segs));
593            }
594            found
595        }
596    }
597
598    #[test]
599    fn the_table_is_the_router() {
600        let mut table: Vec<String> = ROUTES
601            .iter()
602            .map(|r| {
603                let p: Vec<&str> = r
604                    .path
605                    .split('/')
606                    .map(|s| if s.starts_with('{') { "{}" } else { s })
607                    .collect();
608                format!("{} {}", r.method, p.join("/"))
609            })
610            .collect();
611        table.sort();
612        let before = table.len();
613        table.dedup();
614        assert_eq!(before, table.len(), "a route is listed twice");
615        assert_eq!(table, router_arms());
616    }
617
618    #[test]
619    fn paths_and_operations_are_unique() {
620        let p = paths();
621        let ops: Vec<String> = ROUTES.iter().map(operation_id).collect();
622        let mut sorted = ops.clone();
623        sorted.sort();
624        sorted.dedup();
625        assert_eq!(sorted.len(), ops.len());
626        assert!(p["/api/v1/auth/orgs/{org}/members/{user_id}"]["put"].is_object());
627        assert_eq!(
628            p["/api/v1/auth/tokens"]["post"]["x-isb-tool"],
629            "token_create"
630        );
631    }
632
633    /// The hand-written schemas name exactly the fields the types serialize.
634    #[test]
635    fn schemas_match_the_types() {
636        use crate::auth::{ApiToken, Invitation, Membership, Role, Session, User};
637        use crate::org::OrgId;
638        let s = schemas();
639        let keys = |v: Value| -> Vec<String> {
640            let mut k: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
641            k.sort();
642            k
643        };
644        let props = |name: &str| -> Vec<String> {
645            let mut k: Vec<String> = s[name]["properties"]
646                .as_object()
647                .unwrap()
648                .keys()
649                .cloned()
650                .collect();
651            k.sort();
652            k
653        };
654        let user = User {
655            id: 1,
656            email: "a@x.io".into(),
657            name: "A".into(),
658            platform_admin: false,
659            created_at: 0,
660            disabled: false,
661            has_password: true,
662        };
663        assert_eq!(keys(serde_json::to_value(&user).unwrap()), props("User"));
664        let org = OrgId::new("acme").unwrap();
665        let m = Membership {
666            org: org.clone(),
667            role: Role::Admin,
668        };
669        assert_eq!(keys(serde_json::to_value(&m).unwrap()), props("Membership"));
670        let t = ApiToken {
671            id: 1,
672            name: "t".into(),
673            user_id: 1,
674            org: Some(org.clone()),
675            created_at: 0,
676            last_used: None,
677            expires_at: None,
678            scopes: vec![],
679        };
680        assert_eq!(keys(serde_json::to_value(&t).unwrap()), props("ApiToken"));
681        let i = Invitation {
682            id: 1,
683            org,
684            email: "a@x.io".into(),
685            role: Role::Member,
686            invited_by: None,
687            created_at: 0,
688            expires_at: 0,
689            accepted_at: None,
690        };
691        assert_eq!(keys(serde_json::to_value(&i).unwrap()), props("Invitation"));
692        let sess = Session {
693            id: 1,
694            user_id: 1,
695            created_at: 0,
696            last_seen: 0,
697            expires_at: 0,
698            idle_expires_at: 0,
699            user_agent: None,
700            ip: None,
701        };
702        let mut v = serde_json::to_value(&sess).unwrap();
703        v["current"] = json!(true);
704        assert_eq!(keys(v), props("Session"));
705        let k = crate::auth::ssh_keys::SshKey {
706            id: 1,
707            user_id: 1,
708            name: "k".into(),
709            algorithm: "ssh-ed25519".into(),
710            public_key: "x".into(),
711            fingerprint: "SHA256:x".into(),
712            created_at: 0,
713            last_used: None,
714        };
715        assert_eq!(keys(serde_json::to_value(&k).unwrap()), props("SshKey"));
716        let pk = crate::auth::external::Passkey {
717            id: 1,
718            user_id: 1,
719            credential_id: "c".into(),
720            name: "n".into(),
721            alg: -7,
722            sign_count: 0,
723            transports: vec![],
724            aaguid: "00".into(),
725            created_at: 0,
726            last_used: None,
727        };
728        assert_eq!(keys(serde_json::to_value(&pk).unwrap()), props("Passkey"));
729    }
730}