{"openapi":"3.1.0","info":{"title":"mind-mapper API","version":"1.0.0","description":"The mind mapper's routes, as the worker answers them: every path, the key it takes and the refusals it gives, so a caller can read this document instead of the worker's source. It describes the browser routes the app's own SPA calls AND the estate's machine address, because both answer, and a caller with a number and no browser reaches both.\n\n## Who a caller is\n\nAn account number: sixteen digits, four groups of four, kept by the person. It is a bearer secret and it is the whole credential: send it as `Authorization: Bearer <number>`, or as `x-account-number`. The second credential is the fleet's Better Auth session (the cookie `session`, scoped to `.zpe.vc`, minted with a passkey at auth.zpe.vc); this worker verifies it at the door and resolves it to the same principal. A number wins when both are present. Either credential satisfies the `security` requirement below; the routes marked `security: []` take none.\n\n## Two bases\n\n`/api/...` is the app's own API under its mount, and is what its pages call. `/_/api/v1/...` is the estate's machine address (decision D-006): one address per kind, the same on every app, so a machine caller learns it once. Both bases answer HTTP clients; only `/_/api/v1/results` is published on the machine base today.\n\n## The one refusal a caller must read\n\nA wrong number, an unknown number, a revoked number and a claimed one are INDISTINGUISHABLE: every guarded route answers 401 `{\"error\": \"unknown_account\"}` for all four, so the number space cannot be probed. An anonymous read is not a redirect and never a challenge header. On the machine base the one exception is a request that presented NO credential at all, which gets the estate's RFC 9457 document (`unauthenticated`) instead, because a caller with no key is owed the sentence that says so.\n\n## What is answered outside the routes\n\n`OPTIONS` on any `/api/...` path answers 204 with `allow: GET, POST, PUT, DELETE, OPTIONS`. A browser request whose `Origin` host is not the site's own (or loopback) answers 403 `{\"error\": \"forbidden_origin\"}` before routing, and a request with no `Origin` is not cross-origin and passes. A method a path does not answer falls through to `{\"error\": \"not_found\"}` 404 on the browser base, while the machine base answers 405 problem+json with `allow`. An unhandled failure answers 500 `{\"error\": \"server_error\"}`; a body over 1000000 bytes answers 413.\n\n## Coverage\n\nTwenty paths, twenty-three operations: three paths answer two methods (`/api/results`, `/api/profile`, `/api/groups`). `x-zpe-routes` counts operations, as the estate's coverage gate reads it, and nothing this app answers is left out of this document."},"servers":[{"url":"/mindmap","description":"mind-mapper on zpe.vc"}],"security":[{"accountNumber":[]},{"doorSession":[]}],"paths":{"/api/health":{"get":{"operationId":"getHealth","summary":"Liveness, and whether the gate, the database, the assets and the human check are wired.","description":"The one route a monitor can call without a credential. `gateConfigured` is false when the door's address (`DOOR_INTERNAL_URL`) or its bearer secret (`DOOR_INTERNAL_TOKEN`) is unbound, which is the difference between a worker that refuses everybody and one that is running. This Worker holds no identity database of its own; it asks the door. `humanCheck` names whether the Turnstile secret is bound: an unarmed production mint is visible here rather than silent.","tags":["account"],"security":[],"responses":{"200":{"description":"The worker is up, and each boolean says whether that binding is present.","content":{"application/json":{"schema":{"type":"object","required":["ok","gateConfigured","dbConfigured","assetsBound","humanCheck"],"properties":{"ok":{"type":"boolean","const":true},"gateConfigured":{"type":"boolean"},"dbConfigured":{"type":"boolean"},"assetsBound":{"type":"boolean"},"humanCheck":{"type":"boolean"}}}}}}}}},"/api/session":{"get":{"operationId":"getSession","summary":"Is there a live fleet sign-in on this request? The passkey door, read.","description":"Answers before a principal exists, so a person signed in at the door with a passkey and no number on the device is not shown Sign In. `linked` is the second half of the truth: signed in at the door but no principal in the gate yet. No cookie, an unreachable door and a refused session are one answer, signed out. Nothing here creates or changes a row.","tags":["account"],"security":[],"responses":{"200":{"description":"Signed in or not, and whether the gate already holds a principal for that person.","content":{"application/json":{"schema":{"type":"object","required":["signedIn","linked","name"],"properties":{"signedIn":{"type":"boolean"},"linked":{"type":"boolean"},"name":{"type":["string","null"],"description":"The door's own name for the person. No email, nothing else."}}}}}}}}},"/api/signout":{"post":{"operationId":"postSignout","summary":"End the door's session, carrying the request's cookies to the door and the cleared ones back.","description":"One gate, so the session is the door's to end and this app only carries the act. A door that does not answer is named in the reply (`door: \"did_not_answer\"`) rather than hidden: a browser whose cookie was not cleared still looks signed in on the next load, and a silent success would be a lie. GET on this path is not a route and answers the router's 404.","tags":["account"],"security":[],"responses":{"200":{"description":"Whether the door answered. `set-cookie` carries the door's own clearing headers.","content":{"application/json":{"schema":{"type":"object","required":["ok","door"],"properties":{"ok":{"type":"boolean"},"door":{"type":"string","enum":["ended","did_not_answer"]}}}}}}}}},"/api/me":{"get":{"operationId":"getMindMapperMe","summary":"Resolve the presented credential to one principal, and summarise what this app holds for it.","description":"Null is a clean answer here, not an error: a request with no credential and one whose credential resolves to nobody both answer `principal: null`, so this route never tells a stranger whether a number exists. `via` is the resolver's own word for which credential answered (`account` for a number, `identity` for a door session).","tags":["account"],"security":[],"responses":{"200":{"description":"The principal, or null; with the counts and the profile's update time when there is one.","content":{"application/json":{"schema":{"type":"object","required":["principal","via","results","profile"],"properties":{"principal":{"oneOf":[{"$ref":"#/components/schemas/Principal"},{"type":"null"}],"description":"Null when no credential resolved."},"via":{"type":["string","null"],"enum":["account","identity",null]},"results":{"type":"object","required":["count","lastUpdatedAt"],"properties":{"count":{"type":"integer","minimum":0},"lastUpdatedAt":{"type":["string","null"],"format":"date-time"}}},"profile":{"oneOf":[{"type":"object","required":["updatedAt"],"properties":{"updatedAt":{"type":"string","format":"date-time"}}},{"type":"null"}]}}}}}}}}},"/api/account/start":{"post":{"operationId":"postAccountStart","summary":"Mint a new anonymous number. The number is returned once and never again.","description":"This route is retired as the way a person gets a number (DECISIONS.md D-005: a number is minted at the door), but the worker still answers it, and this contract describes what it answers rather than what the app recommends. The number is returned ONCE: the identity database stores only a peppered hash, so this response is the only copy that will exist. Rate limited by counting rows minted in the last hour; no IP, no fingerprint, no identifier is stored. When `TURNSTILE_SECRET` is bound the call needs a `turnstileToken` in the body or the maintenance key in `x-mm-mint-key`, and the check FAILS CLOSED: a siteverify outage refuses the mint rather than opening it.","tags":["account"],"security":[],"parameters":[{"name":"x-mm-mint-key","in":"header","required":false,"description":"The maintenance key, for scripts that cannot solve a Turnstile widget. A worker secret; nothing else uses it.","schema":{"type":"string"}}],"requestBody":{"required":false,"description":"Optional. A missing or unparseable body is read as `{}`, so the refusal is the human check, not a 400.","content":{"application/json":{"schema":{"type":"object","properties":{"turnstileToken":{"type":"string","description":"The widget's token, verified against Cloudflare's siteverify."}}}}}},"responses":{"201":{"description":"The only copy of the number that will ever exist.","content":{"application/json":{"schema":{"type":"object","required":["number","last4","principalId","created","note"],"properties":{"number":{"type":"string","description":"Sixteen digits as four groups of four, e.g. 0000-0000-0000-0000. A bearer secret."},"last4":{"type":"string","description":"The last four digits, for the app's card."},"principalId":{"type":"string"},"created":{"type":"boolean","description":"True when this call made the principal, false when it resolved one that already existed."},"note":{"type":"string"}}}}}},"403":{"description":"The human check refused, so nothing was created. `reason` is the check's own word.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"human_check_required","reason":"missing_token"}}}}},"429":{"description":"The hourly mint cap was reached. The cap is counted from rows, never from an address.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"rate_limited"}}}}},"503":{"description":"The gate is not wired on this deployment (`DOOR_INTERNAL_URL` or the `DOOR_INTERNAL_TOKEN` secret unbound).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_configured: gate"}}}}}}}},"/api/account/attach":{"post":{"operationId":"postAccountAttach","summary":"Adopt an existing number on this device. A wrong number and an unknown one answer alike.","description":"The one route that takes a number in a BODY rather than a header. A number that does not normalise, an unknown number, a revoked number and a claimed one all answer the same 401 `unknown_account`, because telling them apart is what would let a stranger probe the space.","tags":["account"],"security":[],"requestBody":{"required":true,"description":"The number to adopt.","content":{"application/json":{"schema":{"type":"object","required":["number"],"properties":{"number":{"type":"string","description":"Sixteen digits, with or without the dashes."}}}}}},"responses":{"200":{"description":"The principal that number resolves to, and its last four.","content":{"application/json":{"schema":{"type":"object","required":["principal"],"properties":{"principal":{"type":"object","required":["principalId","last4"],"properties":{"principalId":{"type":"string"},"last4":{"type":["string","null"]}}}}}}}},"400":{"description":"The body is not JSON, is larger than 1 MB, or carries the wrong shape. `bad_request` for a body that will not parse, `body_too_large` past 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/account":{"delete":{"operationId":"deleteAccount","summary":"Delete what this app holds for the resolved principal, and nothing else.","description":"The only destructive route in the app, and its scope is narrow: groups the principal owns (ownership passes to the earliest-joined remaining member, or the group goes), invite codes they minted, their membership rows, their results and their profile. The central identity database is NOT touched: deleting a principal there is the gate's business, not this app's. Idempotent, so a second call reports zero deletions.","tags":["account"],"responses":{"200":{"description":"What went, counted. `gate` is always `untouched`. On a deployment with no `DB` bound the same 200 carries `{error: \"not_configured\"}` instead.","content":{"application/json":{"schema":{"type":"object","required":["ok","deleted","gate"],"properties":{"ok":{"type":"boolean"},"deleted":{"type":"object","required":["results","profile","memberships","invites","groupsDeleted","groupsTransferred"],"properties":{"results":{"type":"integer","minimum":0},"profile":{"type":"integer","minimum":0},"memberships":{"type":"integer","minimum":0},"invites":{"type":"integer","minimum":0},"groupsDeleted":{"type":"integer","minimum":0},"groupsTransferred":{"type":"integer","minimum":0}}},"gate":{"type":"string","const":"untouched"},"error":{"type":"string","description":"Present only on a deployment with no DB bound."}}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}}}}},"/api/results":{"get":{"operationId":"getResults","summary":"Every stored result for the resolved principal.","description":"Scoped to the principal the credential resolves to; there is no parameter that names somebody else. `results` is a map keyed by test id, and each payload is whatever the app saved, so this contract does not shape it.","tags":["results"],"responses":{"200":{"description":"The caller's own rows, keyed by test id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultSet"}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}}}},"put":{"operationId":"putResults","summary":"Upsert results, scoped to the resolved principal.","description":"A per-test upsert: a key present in `results` replaces that row's payload, a key absent is left alone (this is not a replace-all). A test id is truncated to 64 characters and a payload larger than 1 MB is skipped rather than refused, so one oversized value does not cost the caller the rest of the write. The answer is the same shape a read would give.","tags":["results"],"requestBody":{"required":true,"description":"The rows to write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultSetWrite"}}}},"responses":{"200":{"description":"The write landed; the counts are what the store holds now.","content":{"application/json":{"schema":{"type":"object","required":["ok","count","lastUpdatedAt"],"properties":{"ok":{"type":"boolean","const":true},"count":{"type":"integer","minimum":0},"lastUpdatedAt":{"type":["string","null"],"format":"date-time"}}}}}},"400":{"description":"`results` is missing, or is not an object, or the body is not JSON.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/profile":{"get":{"operationId":"getProfile","summary":"This app's profile row for the resolved principal.","description":"`null` when the principal has never saved one, which is not an error.","tags":["profile"],"responses":{"200":{"description":"The profile, or null.","content":{"application/json":{"schema":{"type":"object","required":["profile"],"properties":{"profile":{"oneOf":[{"$ref":"#/components/schemas/Profile"},{"type":"null"}]}}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}}}},"put":{"operationId":"putProfile","summary":"Upsert the resolved principal's profile row.","description":"Every field is trimmed and capped at 200 characters; an empty or non-string field is stored as null rather than refused, so a person can clear a field by sending nothing in it.","tags":["profile"],"requestBody":{"required":true,"description":"The fields to store.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWrite"}}}},"responses":{"200":{"description":"The row as stored.","content":{"application/json":{"schema":{"type":"object","required":["ok","profile"],"properties":{"ok":{"type":"boolean","const":true},"profile":{"$ref":"#/components/schemas/Profile"}}}}}},"400":{"description":"The body is not JSON, is larger than 1 MB, or carries the wrong shape. `bad_request` for a body that will not parse, `body_too_large` past 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups":{"post":{"operationId":"postGroups","summary":"Create a group. The creator is owner and a member, and an invite is returned once.","description":"The creator joins at share level `none`, so creating a group shares nothing by itself. The invite code is returned in this response and stored only as a hash, so it cannot be handed back a second time: only its last four are readable later.","tags":["groups"],"requestBody":{"required":true,"description":"The group's name.","content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Trimmed and capped at 80 characters."}}}}}},"responses":{"200":{"description":"The group, and the one-time invite.","content":{"application/json":{"schema":{"type":"object","required":["group","invite"],"properties":{"group":{"$ref":"#/components/schemas/Group"},"invite":{"$ref":"#/components/schemas/Invite"}}}}}},"400":{"description":"The name is missing or blank.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"name_required"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}},"get":{"operationId":"getGroups","summary":"Every group the resolved principal is a member of, with their own share level.","description":"Newest group first. `memberCount` counts members, never results.","tags":["groups"],"responses":{"200":{"description":"The caller's groups.","content":{"application/json":{"schema":{"type":"object","required":["groups"],"properties":{"groups":{"type":"array","items":{"$ref":"#/components/schemas/GroupMembership"}}}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}}}}},"/api/groups/join":{"post":{"operationId":"postGroupsJoin","summary":"Join a group by its invite code.","description":"An unknown, expired, exhausted and foreign code all answer the same 404 `invalid_code`: the four causes are indistinguishable, so a code cannot be probed. A member who presents a live code again answers `alreadyMember: true` rather than an error.","tags":["groups"],"requestBody":{"required":true,"description":"The invite code, as the person was given it.","content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string"}}}}}},"responses":{"200":{"description":"The group, whether the caller was already in it, and the code's last four.","content":{"application/json":{"schema":{"type":"object","required":["group","alreadyMember","inviteLast4"],"properties":{"group":{"$ref":"#/components/schemas/Group"},"alreadyMember":{"type":"boolean"},"inviteLast4":{"type":"string"}}}}}},"400":{"description":"The body is not JSON, is larger than 1 MB, or carries the wrong shape. `bad_request` for a body that will not parse, `body_too_large` past 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"The code is unknown, expired, exhausted or belongs to another account.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"invalid_code"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups/leave":{"post":{"operationId":"postGroupsLeave","summary":"Leave a group the resolved principal is a member of.","description":"An unknown group and a group the caller is not in answer the same 404 `not_found`.","tags":["groups"],"requestBody":{"required":true,"description":"The group to leave.","content":{"application/json":{"schema":{"type":"object","required":["groupId"],"properties":{"groupId":{"type":"string"}}}}}},"responses":{"200":{"description":"The membership row is gone.","content":{"application/json":{"schema":{"type":"object","required":["ok","left"],"properties":{"ok":{"type":"boolean","const":true},"left":{"type":"string"}}}}}},"400":{"description":"The body is not JSON, is larger than 1 MB, or carries the wrong shape. `bad_request` for a body that will not parse, `body_too_large` past 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not a member of it.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups/{id}":{"get":{"operationId":"getGroupsById","summary":"A group's detail: membership, the caller's share level, their own per-test switches.","description":"Membership and each member's sharing state are visible to any member; the invite metadata is the owner's alone (`invites` is null for everybody else). No member's results are ever included here. `shareableTests` is what the CALLER could switch on, not what anybody else holds.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}}],"responses":{"200":{"description":"The group as this caller may read it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupDetail"}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not a member of it.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}}}}},"/api/groups/{id}/share":{"put":{"operationId":"putGroupsByIdShare","summary":"Set the caller's OWN share level in this group.","description":"A member can only change their own level; there is no field for somebody else's. `group` puts their values into the group profile, `pair` additionally allows a pairwise read with another member who is also at `pair`, and `none` shares nothing.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}}],"requestBody":{"required":true,"description":"The caller's new level. `shareLevel` is accepted as a spelling.","content":{"application/json":{"schema":{"type":"object","required":["share_level"],"properties":{"share_level":{"$ref":"#/components/schemas/ShareLevel"}}}}}},"responses":{"200":{"description":"The level as stored.","content":{"application/json":{"schema":{"type":"object","required":["ok","groupId","shareLevel"],"properties":{"ok":{"type":"boolean","const":true},"groupId":{"type":"string"},"shareLevel":{"$ref":"#/components/schemas/ShareLevel"}}}}}},"400":{"description":"The level is not one of `none`, `group`, `pair`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_share_level"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not a member of it.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups/{id}/dots":{"put":{"operationId":"putGroupsByIdDots","summary":"Show the caller's own value as a dot in this group's profile, or stop showing it.","description":"The choice is the caller's alone. A reader always sees their own value; this switch decides whether OTHER members do. `show_dots`, `showDots` and `show` are all read, and anything not true is stored as false.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}}],"requestBody":{"required":true,"description":"Whether to show the caller's own values as dots.","content":{"application/json":{"schema":{"type":"object","required":["show_dots"],"properties":{"show_dots":{"type":"boolean"}}}}}},"responses":{"200":{"description":"The switch as stored.","content":{"application/json":{"schema":{"type":"object","required":["ok","groupId","showDots"],"properties":{"ok":{"type":"boolean","const":true},"groupId":{"type":"string"},"showDots":{"type":"boolean"}}}}}},"400":{"description":"The body is not JSON, is larger than 1 MB, or carries the wrong shape. `bad_request` for a body that will not parse, `body_too_large` past 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not a member of it.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups/{id}/tests":{"put":{"operationId":"putGroupsByIdTests","summary":"Set the caller's OWN per-test exclusions in this group.","description":"An excluded test is filtered in the query before anything is loaded, so its values cannot reach a dimension, a median or a payload: it lowers n rather than contributing a zero. A test id the caller holds no result for is refused (`unknown_test`) rather than stored.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}}],"requestBody":{"required":true,"description":"The exclusions. An empty array shares every test the caller holds.","content":{"application/json":{"schema":{"type":"object","required":["excluded"],"properties":{"excluded":{"type":"array","items":{"type":"string"},"description":"The test ids to switch OFF for this group."}}}}}},"responses":{"200":{"description":"The exclusions as stored.","content":{"application/json":{"schema":{"type":"object","required":["ok","groupId","excludedTests"],"properties":{"ok":{"type":"boolean","const":true},"groupId":{"type":"string"},"excludedTests":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"`excluded` is not an array, or names a test the caller holds no result for.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_excluded_tests"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not a member of it.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups/{id}/invites":{"post":{"operationId":"postGroupsByIdInvites","summary":"The owner mints another invite. The code is returned once, here.","description":"The owner's alone: a member who is not the owner, and a group that does not exist, answer the same 404 `not_found`. The code is stored only as a sha256, so this response is the only copy that will exist; later reads show its last four, its uses and its expiry.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}}],"requestBody":{"required":false,"description":"Optional. A missing body is read as `{}`.","content":{"application/json":{"schema":{"type":"object","properties":{"maxUses":{"type":"integer","minimum":1,"description":"How many joins the code allows. Defaults to 25."},"expiresAt":{"type":"string","format":"date-time","description":"When the code stops working. Absent means it does not expire."},"principalId":{"type":"string","description":"Address the code to one principal."}}}}}},"responses":{"200":{"description":"The one-time code, and what it allows.","content":{"application/json":{"schema":{"type":"object","required":["invite"],"properties":{"invite":{"$ref":"#/components/schemas/Invite"}}}}}},"400":{"description":"The body is not JSON, is larger than 1 MB, or carries the wrong shape. `bad_request` for a body that will not parse, `body_too_large` past 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"bad_request"}}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not its owner.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}},"413":{"description":"The request body is larger than 1000000 bytes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"body_too_large"}}}}}}}},"/api/groups/{id}/stats":{"get":{"operationId":"getGroupsByIdStats","summary":"The group profile: a median per dimension, with n, and the dots members chose to show.","description":"Median, never mean. A dimension with fewer than `minSharers` contributors answers `status: \"insufficient\"` with its n and no median, range or spread: the gap is named and nothing is invented. Members who are not sharing at `group` or `pair` appear in `absent` with their level, never as a zero.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}}],"responses":{"200":{"description":"The group's profile as this caller may read it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupStats"}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"No such group, or the caller is not a member of it.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"not_found"}}}}}}}},"/api/groups/{id}/pair/{principalId}":{"get":{"operationId":"getGroupsByIdPairByPrincipalId","summary":"Pairwise divergence with one other member.","description":"Refused uniformly unless BOTH principals are members of this group AND sharing at `pair`: an unknown member, a non-member, a member at `group` and a request naming the caller themselves all answer the same 404 `pair_unavailable`. Only dimensions both members hold a value for are returned; each side's per-test exclusions are applied before the read.","tags":["groups"],"parameters":[{"name":"id","in":"path","required":true,"description":"The group id, a UUID.","schema":{"type":"string"}},{"name":"principalId","in":"path","required":true,"description":"The other member's principal id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The dimensions both share, with the absolute difference and who is higher.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PairStats"}}}},"401":{"description":"No credential, or one that resolves to nobody. A wrong number, an unknown number and a revoked number all answer this same body, so a stranger cannot probe the number space.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"unknown_account"}}}}},"404":{"description":"Both principals are not members sharing at `pair`, or the request names the caller.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AppError"}],"example":{"error":"pair_unavailable"}}}}}}}},"/_/api/v1/results":{"get":{"operationId":"getMachineResults","summary":"The estate's machine address for this app: the same read as /api/results, keyed by the number.","description":"`<mount>/_/api/v1/<tail>` is the address a caller with no browser uses. The guard is the same resolver the browser routes use, and its verdict is final: no principal reads nothing here. A request that presented no credential gets the estate's RFC 9457 `unauthenticated` document; one that presented a number which resolves to nobody gets the app's uniform 401 `unknown_account`, so unknown, revoked and claimed numbers stay indistinguishable.\n\nThe method is checked BEFORE the credential, so a caller learns the method without being asked for a key. Any other path under `/_/` is a `not-found` problem document, never the SPA shell.","tags":["machine"],"responses":{"200":{"description":"The caller's own rows, keyed by test id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultSet"}}}},"401":{"description":"One of two documents, by whether a credential was presented at all. No credential: the estate's problem document. A credential that resolves to nobody: the app's uniform `unknown_account`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}},"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"405":{"description":"A method this path does not answer. The `allow` header names GET.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}}},"components":{"securitySchemes":{"accountNumber":{"type":"http","scheme":"bearer","description":"The person's sixteen-digit account number, with or without dashes, sent as `Authorization: Bearer <number>` (or `x-account-number`). A bearer secret: this worker never stores or logs it, and the resolver hashes it with the fleet's pepper before it reaches the identity database."},"doorSession":{"type":"apiKey","in":"cookie","name":"session","description":"The door's own session cookie, set by auth.zpe.vc when a person signs in with a passkey. Scoped to `.zpe.vc`, so it already arrives; this app verifies it at the door and cannot mint one."}},"schemas":{"Problem":{"type":"object","description":"RFC 9457 problem+json, the estate's one refusal document: `type` names the code, `title` is the fixed sentence, `instance` is the path the caller asked for, and `detail` is added only when there is a sentence for a person.","required":["type","title","status","code","instance"],"additionalProperties":false,"properties":{"type":{"type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string","enum":["unauthenticated","not-found","method-not-allowed"],"description":"Only the codes this surface can answer are listed."},"instance":{"type":"string"},"detail":{"type":"string"}}},"AppError":{"type":"object","description":"This app's own refusal: one closed word in `error`, and `detail`/`reason` when there is a sentence or a cause for a person. It is the same shape a wrong number, an unknown group and a rejected invite answer, which is what makes them indistinguishable.","required":["error"],"properties":{"error":{"type":"string","enum":["unknown_account","forbidden_origin","not_found","invalid_code","pair_unavailable","name_required","bad_share_level","unknown_test","bad_excluded_tests","bad_request","body_too_large","human_check_required","rate_limited","not_configured","not_configured: gate","server_error"]},"detail":{"type":"string"},"reason":{"type":"string"}},"example":{"error":"unknown_account"}},"Principal":{"type":"object","required":["principalId","kind","last4","claimed"],"properties":{"principalId":{"type":"string"},"kind":{"type":"string","enum":["anon","user"]},"last4":{"type":["string","null"],"description":"The number's last four, never the number."},"claimed":{"type":"boolean"}}},"ResultSet":{"type":"object","required":["results","count","lastUpdatedAt"],"properties":{"results":{"type":"object","additionalProperties":true,"description":"Keyed by test id. Each payload is whatever the app saved for that test; this contract does not shape it."},"count":{"type":"integer","minimum":0},"lastUpdatedAt":{"type":["string","null"],"format":"date-time"}}},"ResultSetWrite":{"type":"object","required":["results"],"properties":{"results":{"type":"object","additionalProperties":true,"description":"The tests to write. A key present replaces that row; a key absent is left alone."}}},"Profile":{"type":"object","required":["displayName","dateOfBirth","timeOfBirth","placeOfBirth","updatedAt"],"properties":{"displayName":{"type":["string","null"]},"dateOfBirth":{"type":["string","null"]},"timeOfBirth":{"type":["string","null"]},"placeOfBirth":{"type":["string","null"]},"updatedAt":{"type":"string","format":"date-time"}}},"ProfileWrite":{"type":"object","properties":{"displayName":{"type":"string"},"dateOfBirth":{"type":"string"},"timeOfBirth":{"type":"string"},"placeOfBirth":{"type":"string"}}},"ShareLevel":{"type":"string","enum":["none","group","pair"],"description":"`none` shares nothing, `group` puts the member's values into the group profile, `pair` additionally allows a pairwise read."},"Group":{"type":"object","required":["id","name","ownerPrincipalId","createdAt","isOwner"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"ownerPrincipalId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"isOwner":{"type":"boolean"}}},"GroupMembership":{"allOf":[{"$ref":"#/components/schemas/Group"},{"type":"object","required":["myShareLevel","joinedAt","memberCount"],"properties":{"myShareLevel":{"$ref":"#/components/schemas/ShareLevel"},"joinedAt":{"type":"string","format":"date-time"},"memberCount":{"type":"integer","minimum":0}}}]},"Invite":{"type":"object","required":["code","last4","createdAt","expiresAt","maxUses"],"description":"The one-time code and what it allows. Only the hash is stored, so `code` cannot be read back later.","properties":{"code":{"type":"string"},"last4":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":["string","null"],"format":"date-time"},"maxUses":{"type":"integer","minimum":1}}},"InviteRow":{"type":"object","required":["last4","createdAt","expiresAt","maxUses","uses","addressed"],"description":"What the OWNER can read about an invite later: never the code.","properties":{"last4":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":["string","null"],"format":"date-time"},"maxUses":{"type":"integer","minimum":0},"uses":{"type":"integer","minimum":0},"addressed":{"type":"boolean"}}},"GroupMember":{"type":"object","required":["principalId","displayName","shareLevel","showDots","joinedAt","isOwner","isMe"],"properties":{"principalId":{"type":"string"},"displayName":{"type":["string","null"]},"shareLevel":{"$ref":"#/components/schemas/ShareLevel"},"showDots":{"type":"boolean"},"joinedAt":{"type":"string","format":"date-time"},"isOwner":{"type":"boolean"},"isMe":{"type":"boolean"}}},"GroupDetail":{"type":"object","required":["group","myShareLevel","myShowDots","myExcludedTests","shareableTests","members","invites"],"properties":{"group":{"$ref":"#/components/schemas/Group"},"myShareLevel":{"$ref":"#/components/schemas/ShareLevel"},"myShowDots":{"type":"boolean"},"myExcludedTests":{"type":"array","items":{"type":"string"}},"shareableTests":{"type":"array","items":{"type":"object","required":["testId","label"],"properties":{"testId":{"type":"string"},"label":{"type":"string"}}}},"members":{"type":"array","items":{"$ref":"#/components/schemas/GroupMember"}},"invites":{"oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/InviteRow"}},{"type":"null"}],"description":"The owner's alone; null for every other member."}}},"GroupStats":{"type":"object","required":["groupId","name","memberCount","rule","minSharers","contributors","dimensions","absent"],"properties":{"groupId":{"type":"string"},"name":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"rule":{"type":"string","const":"median-not-mean"},"minSharers":{"type":"integer","minimum":1},"contributors":{"type":"integer","minimum":0},"dimensions":{"type":"array","items":{"type":"object","required":["dimension","n","status"],"properties":{"dimension":{"type":"string"},"testId":{"type":"string"},"testLabel":{"type":"string"},"label":{"type":"string"},"n":{"type":"integer","minimum":0},"status":{"type":"string","enum":["ok","insufficient"]},"dots":{"type":"array","items":{"type":"object"}},"dotsHidden":{"type":"integer","minimum":0}}}},"absent":{"type":"array","items":{"type":"object","required":["principalId","shareLevel"],"properties":{"principalId":{"type":"string"},"shareLevel":{"$ref":"#/components/schemas/ShareLevel"}}}}}},"PairStats":{"type":"object","required":["groupId","you","them","rule","shared","dimensions"],"properties":{"groupId":{"type":"string"},"you":{"type":"object","required":["principalId"],"properties":{"principalId":{"type":"string"}}},"them":{"type":"object","required":["principalId","displayName"],"properties":{"principalId":{"type":"string"},"displayName":{"type":["string","null"]}}},"rule":{"type":"string","const":"absolute-difference"},"shared":{"type":"integer","minimum":0},"dimensions":{"type":"array","items":{"type":"object","required":["dimension","you","them","diff","direction"],"properties":{"dimension":{"type":"string"},"testLabel":{"type":"string"},"label":{"type":"string"},"you":{"type":"number"},"them":{"type":"number"},"diff":{"type":"number"},"direction":{"type":"string","enum":["you","them","equal"]}}}}}}}},"x-surface":{"app":"zpe-mind-mapper","base":"/_/api/v1","document":"/_/api/v1/openapi.json","generated_by":"hand-authored from worker/src/index.js (plan 86376af27531f3f1, Task 6); this worker serves these bytes at /_/api/v1/openapi.json (api-registry task t_a43bdfc5)"},"x-zpe-routes":23,"x-zpe-documented":23,"x-zpe-internal":[]}