{"openapi":"3.1.0","info":{"title":"wellbore-service","summary":"井身结构图渲染服务","version":"0.1.0"},"paths":{"/api/input/normalize":{"post":{"summary":"Normalize Input","description":"Read-only editor conversion through the existing CLI input gate; no job or quota.","operationId":"normalize_input","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/jobs":{"post":{"summary":"Submit Job","description":"收下一份井数据，**回 202** 与它此刻的状态（恒为 `queued`）。\n\n**202 而不是 200**：答案还不在——产物要等后台那一趟跑完（`docs/adr/0081` §三）。\n回 200 就是在承诺一件没发生的事，而那正是从前那条同步端点 200 的反面：那一条之所以\n能回 200，是因为渲染跑完时请求还没有返回（那条路已经删掉，`docs/adr/0081` §三）。\n\n**受理与渲染分开**：这个协程一行都不等渲染，`JobStore.submit` 只是把那一趟排进事件\n循环。所以「渲染跑几秒」不再决定这个请求挂多久——那正是网页体验页与 MCP 客户端要\n的形状（`docs/adr/0081` 的背景那一段）。\n\n前两步的次序（判据三：同一个问题不该有第二个答案）：**415 在最前**（它只看头、\n不碰 body，一个字节都不用读），然后才是读 body（413 在这一步）。**受理排在最后**：\n槽不由这里去取，那是后台那一趟自己的事——正是这一步的差别让这个请求不再挂几秒。\n\n**受理那一步里有可能抛 503**（#62）：队里已经排满 `WELLBORE_JOB_QUEUE_DEPTH` 条时，\n`submit` 当场回绝。它排在读 body 之后是为了**先把「这份请求本身就不该收」\n（415 / 413）挑出去，再判「现在还收不收得下」**——反过来写的话，一个超限的请求会先\n占着一个受理名额，轮到了才被告知 body 太大。\n\n**提交这件事要求登录，而且这一次提交记在提交人名下**（#78）。\n\n判定是**这个签名上那个依赖**（`auth.require_user`），不是散在函数体里的一句判断，也\n不是中间件：**它必须有一个返回值**——`artifacts` 要记的那个人就在它手里（#73 的 (c)\n点名了这件事），把它做成中间件就得再查一遍「这一趟是谁」，那是同一个量的第二个来源\n（判据三）。**挂在这一条路由上而不是整张表上**：`GET /api/jobs/{id}` 与那条 SSE\n**不要凭据**（谁看得到一个 job 的状态是 #79 / #80 的话题），而挂 router 级依赖会把\n它们一起圈进来。两种凭据都认（会话 cookie 与 `Authorization: Bearer`），先看头、头\n在场就以它为准、不回落——那一整条判定只有 `require_user` 一处实现。\n\n**依赖跑在这个函数体之前，所以 401 排在 422 / 415 / 413 前面。** 这不是排版，是\nFastAPI 的调用次序（依赖先解完，端点才被调）——**未带凭据的请求连 body 都不读**\n就回 401。代价写在明处：`service/tests/test_error_surface.py` 里那两发 413 / 415\n必须带着凭据才有意义（不带的话它们量到的是 401）——**判据一条没变，只是路上多了一\n道门**。同一件事的另一面是：一个超限的请求在没登录时不会被读一个字节，那正是这条\n次序想要的结果。\n\n**受理成功之后才记归属**：`record_artifact` 排在 `submit` 返回之后。四个出口\n（422 / 415 / 413 / 503）里没有一个该在 `artifacts` 里留行，而 503 是 `submit`\n自己抛的——**「写在它返回之后」这一条位置本身就答完了这件事**，库里不必再判一次\n（判据一）。\n\n**代价写一句**：这一次 INSERT 真出错时（盘满、库被锁），这一发会回 500，而那条 job\n已经在后台跑起来了。两个次序都躲不开这一格（反过来写就是「记了一个还没受理的归属」），\n要修得引一个补偿动作，而这一格今天没有观察者——所以如实报出去，不在这里吞掉。","operationId":"submit_job","responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/jobs/{job_id}":{"get":{"summary":"Get Job","description":"查一个 job 此刻的状态。**未知 id 回 404**，走 `problems.py` 那个出口。\n\n这里**每问一次都是现读的**：`app.state.jobs` 是唯一来源（判据三），响应不是任何\n地方的缓存。于是「轮询到状态变了」这件事对调用方是真的——这也是 #60 那条 SSE 的\n底：那一帧与这一份 body 是同一个形状、同一份数据。\n\n`operation_id` 显式给：将来 MCP 适配层要从这些端点转出去（#48 的 Out of Scope），\n名字漂了那边跟着漂。\n\n**重启之后同一个 id 会回 404**，而它指的那些产物目录还在盘上——这个分裂是决定，\n不是缺陷（`docs/adr/0081` §五）。","operationId":"get_job","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/jobs/{job_id}/events":{"get":{"summary":"Job Events","description":"跟住一个 job：**连上推一帧当前状态，之后每次迁移推一帧，终态推完关流**（#60）。\n\n形状（一种事件、与查询端点同形、不做重放、终态由客户端关）整个住在\n`docs/adr/0081` §四，本函数只是把它摆出来：帧怎么拼在 `_sse_frame`、推的节奏在 `_sse_frames`、\n状态与「变没变」在 `jobs.JobFeed`。\n\n**未知 id 回 404，走 `problems.py` 那个出口**——与 `GET /api/jobs/{id}` 那一道是\n同一个 404（`_no_such_job`），形状由 `docs/adr/0077` 定。这一处**必须在返回响应\n之前抛**：`ProblemError` 抛在 `_sse_frames` 那个生成器里的话，响应头早就发出去了，它\n只会变成一条断掉的连接，而调用方看到的是一个「没有 body 的 200」——最难查的一类错。\n所以 `follow()` 在这里**先调、先判**，`StreamingResponse` 是判断之后的产物。\n\n**终态那一帧推完，服务端关流**（`_sse_frames` 返回）。这里有一个 SSE 协议自带的坑，\n要写给调用方：**`EventSource` 遇到服务端关闭会自己重连**——它没有「服务端体面结束」\n这个信号，连接断了就是它的重连条件。所以**客户端收到终态事件后必须自己 `close()`**，\n否则它会对着一条已经结束的 job 无限重连（每次都拿到同一帧终态、每次都再重连一次）。\n「结束了」与「断线了」这两件事，在 SSE 里只能由**客户端看 `data` 里的 `status`** 分辨。\n\n**不加心跳**：一次渲染 1.6–1.8 秒，连接活得比它还短，没有空闲超时可谈。真要出现的是\n排长队的情形（8 个并发槽后面排几十个），那时加 `: ping` 那种 comment 帧——\n`docs/adr/0081`「已知的窄缝」那一节把这条留着，等它被观测到再说。\n\n**失败也推、也收口**：`_sse_frames` 等的是「迁移」而不是「超时」，而渲染失败现在就是\n一次迁移（`jobs.JobStore._settled` 把状态落成 `failed`），于是这一条循环在推到那一帧\n时看见 `TERMINAL_STATUSES` 里的值、当场返回——与 `success` 那条路一个字都不差。\n`service/tests/test_job_events.py` 里有一条用例钉着它（连接在 job 还在跑的时候就建好，\n一直读到流被服务端关掉）。","operationId":"job_events","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/renders/{render_id}.zip":{"get":{"summary":"Download Render Archive","description":"把一次成功渲染的全部产物打成一个压缩包。\n\n**要登录，而且只打自己的那一份**（#79）。不带凭据 → **401**（依赖跑在函数体之前，\n抛的是 `auth.NO_CREDENTIALS` 那一句），带了但不是自己的 → **404**。\n**不带凭据去取一个不存在的 id 也是 401 而不是 404**：这个服务不告诉任何一个未认证的\n人「什么东西存在」——所以「401 排在 404 前面」不是次序上的细节，是这一条本身。","operationId":"download_render_archive","security":[{"HTTPBearer":[]}],"parameters":[{"name":"render_id","in":"path","required":true,"schema":{"type":"string","title":"Render Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/renders/{render_id}/well_data.yaml":{"get":{"summary":"Download Render Yaml","description":"Download the normalized request as valid YAML (JSON is a YAML subset).","operationId":"download_render_yaml","security":[{"HTTPBearer":[]}],"parameters":[{"name":"render_id","in":"path","required":true,"schema":{"type":"string","title":"Render Id"}}],"responses":{"200":{"description":"Successful Response","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/renders/{render_id}/{name}":{"get":{"summary":"Download Artifact","description":"取这次渲染的某一份产物。主图可用短时签名，详见 download_user / ADR 0095。\n\n下文的 API key / cookie 行为保留；签名失败另走统一 403 出口。\n\n**要登录，而且只取自己的那一份**（#79）。两档分清楚：**没带凭据 → 401**；\n**带了、不是自己的 → 404**——后者与「这次渲染没产出这个名字」**逐字同形**\n（`_owned_render_directory` 回 `None` 之后落进的是同一个 `raise`），所以一个已认证的人\n从答复上分不出「那个 id 存在但不是你的」与「那个 id 不存在」。\n\n两段路径各挡一层，判据不同：\n\n- **`{render_id}` 必须是个 UUID，而且必须是你自己的**。`..` 不是 UUID，于是穿越在\n  第一个路径段就被堵死——这是 #48 定下的形状，也是这里唯一真正承重的那一道。\n  归属那一半是 #79 加的，两件事合在 `_owned_render_directory` 里。\n- **`{name}` 必须是一个平铺的名字**（`Path(name).name == name`），而且必须在那个目录\n  里**真有一份**。头一句挡的是「名字里带分隔符」：`{name}` 的路由段是 `[^/]+`，所以\n  `/` 本来就到不了，但 **`\\` 到得了**——而它在 Windows 上是个真的分隔符，\n  `..\\..\\某个文件` 于是能拼出一条跑到渲染根外面的路径。**这一行在 Windows 上因此\n  是承重的**（同一段代码在 POSIX 上比它松：`\\` 不是分隔符，那一支也就没什么可挡的\n  ——判据交给本机的 `Path`，不自己列一张分隔符清单）。后一句挡的是「取一个这次渲染\n  没产出的名字」——#48 的用户故事 19：那种情况要明确 404，不能让人误以为那份文件\n  存在。\n\n**不问那张 job 表**：「这次渲染产出了什么」就是那个目录里真在的文件（判据三）。\n**但它问 `artifacts`**（#79）——问的不是「产出了什么」，是「这一份是谁的」，那是另一\n个问题、另一张表（`db.py` 的模块 docstring 说过这两张表各是谁）。404 与「目录不存在」\n与「不是你的」是同一个答复——过期链接、打错名字、别人手里的链接，对调用方是同一件事。\n\n两条路径都不含井数据，所以这里一行 `raise` 就够，不需要 `render_kernel._suffix_of`\n那类分派。","operationId":"download_artifact","security":[{"HTTPBearer":[]}],"parameters":[{"name":"render_id","in":"path","required":true,"schema":{"type":"string","title":"Render Id"}},{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}}],"responses":{"200":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/register":{"post":{"summary":"Register","description":"收下一个邀请码、一个用户名、一个密码，**当场把这个人变成登录态**。\n\n次序（判据三：同一个问题不该有第二个答案）：**先读 body**（413 在那一步判）→ 再解析\n与校验（422）→ 最后才碰库（403 / 409 或建成）。越靠前的越便宜，而且先判完这两档就不会\n出现「body 都没读全就去占一行」。\n\n**cookie 那五格在 `_set_session_cookie` 里**（与登录那条路共用一份，理由见它）。","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string","minLength":1,"title":"Code"},"username":{"type":"string","minLength":1,"title":"Username"},"password":{"type":"string","minLength":1,"title":"Password"}},"type":"object","required":["code","username","password"],"title":"Registration","description":"`POST /api/register` 的请求体。**这一份就是「契约里有哪些字段」的唯一来源**：\n校验按它走、`/docs` 里那张表也从它来（`openapi_extra`）。\n\n**没有 `email`**，字段叫 `username`——进门靠邀请码，邮箱不承担任何信任功能，而叫\n`email` 又从不发信，会让人以为「我应该会收到一封信」（#73「不要邮箱」那一节）。\n找回密码由管理员发放临时密码，再由用户设置新密码（docs/adr/0093）。\n\n`min_length=1` 只答「这一格不能是空串」——**它不是密码强度策略**（那一条在 #73 里\n明写着不做）：长度、字符种类、是不是字典词，这一层一概不管。空白串也不在这里剥\n（不当场替用户改他填的东西）。"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}}}}},"/api/login":{"post":{"summary":"Login","description":"用户名 + 密码 → **落一条新的会话**，并把那枚 cookie 发下去。\n\n次序与注册那条同一条（判据三）：**先读 body**（413 在那一步判）→ 再解析与校验（422）\n→ 最后才碰库（401 或登入）。越靠前的越便宜，而且先判完这两档就不会出现「body 都没读全\n就去查库」。\n\n**口令比对的两半各归各家**：盐与哈希由库那一层给（`db.credentials`，它认识自己的列），\n怎么算、怎么比由 `auth.password_matches` 给（它认识 scrypt 的配方）。这一处只负责把\n两半接起来、把结果翻成一句 401。\n\n**回 200 而不是 201**：这一条没有建出任何**调用方看得见**的东西——会话是服务端表里的\n一行，而 body 里那份「你是谁」说的是一个早就存在的账户。201 说的是「有一个新资源在你\n给的那个地址上」，`/api/login` 不是那个地址（对比：注册回的 201 说的是「账户建出来了」）。\n\n**每次登录都新落一条会话**（不在旧的那条上续期）：一个人可以同时有几条（笔记本、手机、\n别人的机器上临时登一次），而登出作废的只是他手里那一条。代价写在明处：**同一台机器上\n反复登录会攒下多条已经用不上的会话行**——它们各自到期之前都还有效（今天没有回收它们的\n地方，见 `db.session_of` 的 docstring）。","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"properties":{"username":{"type":"string","minLength":1,"title":"Username"},"password":{"type":"string","minLength":1,"title":"Password"}},"type":"object","required":["username","password"],"title":"Login","description":"`POST /api/login` 的请求体：**两个字段**。\n\n与 `Registration` 差一个 `code`——进门那一刻才需要邀请码，之后靠的是口令。\n\n`min_length=1` 与注册那边同一条：只答「这一格不能是空串」，不是密码强度策略（#73\n明写着不做）。"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}}}}},"/api/logout":{"post":{"summary":"Logout","description":"**收尾**：把这条会话删掉，同一枚 cookie 从这一刻起就不认了（#73 用户故事 13）。\n\n**它不要求登录**，认不出来的 cookie 也照回 **204**：登出答的是「让我不再是登录态」，\n而一个手里那枚 cookie 早就作废的人再来点一次，这件事本来就该是成功的——报 401 只会让\n页面多一条没法处理的错误（服务端那一条 `db.close_session` 的 docstring 说得更细）。\n**代价**：这一条不是「你是谁」的判定，所以它产不出「这一趟是谁」——那条判据是\n`GET /api/account` 与将来那几条要凭据的路的（#73 的 (c) 点的是它们）。\n\n**顺带把浏览器里那枚 cookie 抹掉**（`delete_cookie`）：删掉库里那一行已经让它没用了，\n但留着它在浏览器里，下一次请求还会带着它来、还会挨一次 401——那不是「登出」该有的样子。\n抹掉这一下**不靠 `Secure` 那一格**：删除匹配的是名字与路径，不是旗标（http 上那枚\ncookie 本来就存不下来，抹不掉也无所谓）。\n\n**回 204**：没有 body 可说——调用方要的答案在状态码里（`Response` 直接构造，因为\n`delete_cookie` 得写在**这一份**响应上；往注入的 `response` 上写、再 `return` 一个\n新的，那一格会被丢掉）。","operationId":"logout","responses":{"204":{"description":"Successful Response"}}}},"/api/account":{"get":{"summary":"Account","description":"**这一趟是谁**——#73 里 (c) 的落点，也是「认证判定必须有返回值」那句话的落点\n（#72 那条「依赖没有返回值」的反面）。\n\n这一条**自己没有一行判定**：认不认、是谁，全在 `auth.require_user` 那个依赖里（它\n不在场时直接 401）。这里只把那个返回值摆成一份 body——**同一个 `Account` 模型**，\n与注册回的那一份逐字段同形，因为两条路答的是同一个问题。\n\n**#77 时它是 `require_user` 唯一一条端点**（key 那三条走的是 `require_session`，\n见下）；**#78 之后 `POST /api/jobs` 也挂着同一个依赖**——那一条要的那个人正是\n`artifacts` 记的归属，所以「依赖必须有返回值」这句话今天有两个落点。\n`artifact_endpoint` 挂上它是 #79 的事（`app.py` 那一处点着这一条）。于是**两种凭据、\n一个量**这件事的两条出口共用同一份判定——「拿一把 API key 去问我是谁」与「拿会话\ncookie 去问我是谁」在这里回**逐字段相同**的一份 body，而**「带上一个不对的\n`Authorization` 头、即便 cookie 在场也是 401」**这条顺序也只有它答得了（#77 验收里\n那一条的落点）。","operationId":"account","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}}},"security":[{"HTTPBearer":[]}]},"patch":{"summary":"Update Account","operationId":"update_account","requestBody":{"content":{"application/json":{"schema":{"properties":{"display_name":{"type":"string","maxLength":80,"title":"Display Name","default":""},"bio":{"type":"string","maxLength":500,"title":"Bio","default":""},"avatar":{"anyOf":[{"type":"string","maxLength":700000},{"type":"null"}],"title":"Avatar"}},"type":"object","title":"ProfileUpdate","description":"Editable profile fields. The username remains the login identifier."}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountProfile"}}}}}}},"/api/account/profile":{"get":{"summary":"Account Profile","operationId":"account_profile","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountProfile"}}}}}}},"/api/account/password":{"post":{"summary":"Change Password","operationId":"change_password","requestBody":{"content":{"application/json":{"schema":{"properties":{"current_password":{"type":"string","minLength":1,"title":"Current Password"},"new_password":{"type":"string","maxLength":256,"minLength":8,"title":"New Password"}},"type":"object","required":["current_password","new_password"],"title":"PasswordChange"}}},"required":true},"responses":{"204":{"description":"Successful Response"}}}},"/api/account/artifacts":{"get":{"summary":"List Artifacts","description":"**我提交过哪些井，它们的图还在不在**（#80）。\n\n**没有第二张历史表**：`artifacts` 那一张就是历史——#78 让每一次受理落一行（`job_id`\n/ `user_id` / `created_at`），这一条只是把「我的那几行」读回来，再按盘筛一遍。\n\n**只有我自己看得见**：`db.artifacts_of` 按 `user.id` 查，而这个 id 来自\n`auth.require_user`（认会话 cookie 与 `Authorization: Bearer` 两种凭据，两种都指着\n同一个「这一趟是谁」）。别人的行在这条路上不存在——不是「筛掉了」，是压根没有一条\n会去读它们的路径（判据一）。\n\n**凭据用 `require_user` 而不是 `require_session`**（#77 给 key 那三条用的那个）：这\n一条读的是**自己的数据**，与 `GET /api/account`、`POST /api/jobs` 同一档（那两条也认\nAPI key）。`require_session` 挡的是另一件事——**API key 不能管 key**（`auth.SESSION_ONLY`\n：一把能给自己续的钥匙会让「撤销」不再是泄漏的解药），而「看自己提交过什么」没有\n那个性质。\n\n**筛盘上还在不在**（`render_root` 由 `app.state` 给，这一层不自己解析环境变量——那是\n`config.resolve_render_root` 与 `lifespan` 的事，判据三）：表里有哪几行是库说的，\n**产物还在不在是盘说的**。`render_root / job_id` 就是那个产物目录（`docs/adr/0081`\n§三：job id 就是目录名），于是：\n\n- **目录不在** → 这一行不出现。部署侧清了渲染根（`README.md`「渲染根与清理」）之后，\n  库里那些行照旧在，而这一条**不把它们摆出来**——点不开的东西不该是一条历史。\n- **目录在、里面一个文件都没有** → 同样不出现（理由在 `HistoryEntry` 上那段）。\n- **在的那些** → 连文件名一起带回去（同上）。\n\n**这就是这一票认下的代价，写在这儿**：job 的**运行态一个字节都不动**——队列、超时、\n那条 SSE、以及那张 32 条上限的 job 表（`jobs.JobStore`）**仍然只在内存里**。这一条\n读的是库与盘，两样都活得比进程长，所以**重启之后**历史里剩的是「产物还在的那些」；\n而正在排队的、失败的、以及 `WELLBORE_JOB_HISTORY` 丢掉的那些 job，**在那张内存表里\n才有的东西**（状态、告警、错误）重启之后一个字都查不到了——同一时刻\n`GET /api/jobs/{id}` 会回 404（`docs/adr/0081` §五）。**两者不矛盾，是同一件事的两面**：\n「这个 id 是不是一条我提交过的记录」问库，问得出来；「它当时跑成什么样」问内存，\n问不出来。**把运行态也持久化是另一票的事**，这一票明说不做——那要一张新表、一个\n写点、以及「进程被杀在半路时那一行算哪个状态」这个问题，而今天没有人问它。\n\n**回的是裸数组**，与 `GET /api/keys` 同一个形状（同一个理由：调用方要的就是这一串，\n包一层对象只是给一个还没有名字的将来留位置）。空表是正常答复：一次都没提交过。\n\n历史按账户等级保留最近的记录；超过上限时永久清理最旧的目录及归属。\n运行中的任务不参与清理。","operationId":"list_artifacts","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/HistoryEntry"},"type":"array","title":"Response List Artifacts"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/keys":{"get":{"summary":"List Keys","description":"**这个人手里的钥匙**：标识 / 名字 / 前缀 / 什么时候生成的（#77）。\n\n**拿不回明文，而且这不是「这一条没实现」**：库里根本没有它（见 `db.ApiKey`）。\n所以这一条与上一条合起来才是「生成 → 只显示一次」那句话：**要看钥匙只有摇一把新的\n——旧的丢了就是丢了**，那正是它换来「数据库被谁看到都不等于泄漏」的代价。\n\n**只列自己的**（`db.api_keys_of` 按 `user_id` 查）：这一条问的是「我有哪些钥匙」，\n别人的钥匙在这条路上不存在。\n\n**回的是裸数组**：调用方要的就是这一串，包一层对象只是给将来留位置，而那个将来今天\n没有名字（判据一）。空表是正常答复（一把都没有）。","operationId":"list_keys","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/KeySummary"},"type":"array","title":"Response List Keys"}}}}}},"post":{"summary":"Create Key","description":"**摇一把钥匙，明文只在这一份响应里出现这一次**（#77）。\n\n次序与注册那条同一条（判据三）：**先判定**（依赖先跑：不是会话就 403 / 401）→ 再读\nbody（413 在那一步判）→ 再解析与校验（422）→ 最后才摇 key、落库。越靠前的越便宜，\n而且先判完前面那几档就不会出现「body 都没读全就摇了一把钥匙出来」。\n\n**「只在这一次显示」是这一条的全部意义**（#73 用户故事 18）。它落地的方式不是一句\n承诺，是**库里没有那个数**：`db.create_api_key` 存的是 SHA-256 与前缀，返回的\n`db.ApiKey` 里也没有明文——这里手上这串 `key` 是它唯一的一份，出了这个函数就只剩\n这一份响应体（`test_api_keys.py` 里那条「再查列表拿不回明文」问的就是这件事）。\n\n**`auth.new_api_key()` 摇、`db.create_api_key` 切前缀与哈希**：摇法归 `auth`（它是\n「凭据怎么产生」的唯一来源），存法归 `db`（那是它的列）。这一处只负责把两半接起来。\n\n**没有开关**（判据二）：一个人可以有零把、一把、多把，这一条一个字都不问——「一个人\n最多几把」这个问题今天没有人问过，而先立一个上限就要先回答它。","operationId":"create_key","requestBody":{"content":{"application/json":{"schema":{"properties":{"label":{"type":"string","minLength":1,"title":"Label"}},"type":"object","required":["label"],"title":"KeyLabel","description":"`POST /api/keys` 的请求体：**一个 `label`**（#77）。\n\n与 `Registration` / `Login` 同一条写法（`_read_body` + `_contract_of`，理由见模块\ndocstring），`min_length=1` 也同一条意思：只答「这一格不能是空串」。\n\n**名字是必填的，而且这是有意的**（本票落地时拿的一个主意，规格没有点它的名）。\n#73 用户故事 21 要的是「笔记本一把、服务器一把」——一个人同时有多把、撤销一把不惊动\n另一把。**没有名字的 key 在列表里长得一模一样**\n（两行十六进制前缀），于是「我要撤的是哪一把」这个问题就没有答案，而它是这一票全部\n交互里唯一一个必须答对的问题（撤错一把 = 那台机器当场停工）。**默认值也不给**\n（比如「未命名」）：那等于把「想一个名字」这件事推给一个不存在的未来，而所有人都会\n得到同一串字。\n\n**它不是标识**：标识是 `id`（地址那条路）与 `prefix`（认钥匙那条路），`label` 只给\n人看，**可以重复、可以改**（今天没有改的那条路）。"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewKey"}}}}}}},"/api/keys/{key_id}":{"delete":{"summary":"Revoke Key","description":"**撤掉一把**——那一行删了，同一串 key 从这一刻起在哪条路上都不认（#77）。\n\n**`key_id` 写 `str` 而不是 `int`**：写 `int` 就补上本服务第一个带类型的入参，于是\n`DELETE /api/keys/abc` 会落进 `problems.py` 里那条「已知的窄缝」（框架自己那份\n`{\"detail\": [...]}`，绕过那个出口）。判据与 `{job_id}` / `{name}` 那两处一字不差，\n模块 docstring 里那一段是完整的理由。解析不出来的**就是一个 404**：它不是一个地址。\n\n**「不是你的」与「不存在」是同一个 404**（`db.revoke_api_key` 把两种情形合成一个\n`False`）：一个已认证的人不该从一个错误里读出「这个 id 存在，只是不归你」——那是\n`artifact_endpoint` 取产物那条路同一条判据（`#73` 的「不告诉一个已认证的人什么东西\n存在」，措辞在那一份的 docstring 里）。\n\n**撤销是全局的、不可逆的**：删掉的是那一行本身，不是「标记成失效」——所以撤销之后\n`db.user_of_api_key` 查不到它，那条路回 401「凭据不对」（#77 验收里那一格）。\n**不做软删**：软删要多一个「哪些行算数」的判据，而它每读一次都要带上（判据二）。\n\n**回 204**：没有 body 可说——调用方要的答案在状态码里。**而且这一条不是幂等的**：\n第二次撤同一把回 404（那一行不在了），与第一次的 204 不同。这是有意的——它删的是\n**一个有地址的东西**，而那个地址已经指不到任何东西了（对比 `logout` 那条：它答的是\n「让我不再是登录态」，一个手里凭据早就作废的人再来点一次本来就该成功）。","operationId":"revoke_key","parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string","title":"Key Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/account/quota":{"get":{"summary":"Account Quota","operationId":"account_quota","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Account Quota"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/account/artifacts/delete":{"post":{"summary":"Delete Selected History","operationId":"delete_selected_history","requestBody":{"content":{"application/json":{"schema":{"properties":{"ids":{"items":{"type":"string"},"type":"array","maxItems":1000,"minItems":1,"title":"Ids"}},"additionalProperties":false,"type":"object","required":["ids"],"title":"HistorySelection"}}},"required":true},"responses":{"204":{"description":"Successful Response"}}}},"/api/account/artifacts/{job_id}":{"delete":{"summary":"Delete History","operationId":"delete_history","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"summary":"Update Project","operationId":"update_project_api_account_artifacts__job_id__patch","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/admin/accounts":{"get":{"summary":"Admin Accounts","operationId":"admin_accounts","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Response Admin Accounts"}}}}}}},"/api/admin/accounts/{user_id}/tier":{"patch":{"summary":"Admin Set Tier","operationId":"admin_set_tier","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"integer","title":"User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Admin Set Tier"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"tier":{"enum":["free","go","plus","pro"],"title":"Tier","type":"string"}},"required":["tier"],"title":"TierUpdate","type":"object"}}}}}},"/api/admin/accounts/{user_id}/temporary-password":{"post":{"summary":"Admin Temporary Password","operationId":"admin_temporary_password","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/admin/accounts/{user_id}/restriction":{"patch":{"summary":"Admin Account Restriction","operationId":"admin_account_restriction","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Admin Account Restriction"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"disabled":{"title":"Disabled","type":"boolean"}},"required":["disabled"],"title":"AccountRestriction","type":"object"}}}}}},"/api/password-reset":{"post":{"summary":"Reset Password","operationId":"reset_password","requestBody":{"content":{"application/json":{"schema":{"properties":{"username":{"type":"string","minLength":1,"title":"Username"},"temporary_password":{"type":"string","maxLength":256,"minLength":1,"title":"Temporary Password"},"new_password":{"type":"string","minLength":8,"title":"New Password"}},"additionalProperties":false,"type":"object","required":["username","temporary_password","new_password"],"title":"PasswordReset"}}},"required":true},"responses":{"204":{"description":"Successful Response"}}}},"/api/account/artifacts/{job_id}/copy":{"post":{"summary":"Copy History","operationId":"copy_history_api_account_artifacts__job_id__copy_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/account/projects":{"post":{"summary":"Create Project","operationId":"create_project_api_account_projects_post","responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/health":{"get":{"summary":"Health","description":"健康检查：答的是「**这个实例能不能接活**」（#48 的运维故事 24）。\n\n200 只说明进程活着、路由通得到——**它不探渲染**：那要起一个子进程、画完一整张图，\n会把一个本该毫秒级的探针变成秒级，编排器于是会判一个健康的实例不健康。\n\n`renderRoot` 是运维那一格（故事 20）：它由 `lifespan` 给出，**这里不重算**\n（重算就是同一个量的第二个来源）。`request.app.state` 上那个值必然在场——服务只有\n两条入口（`uvicorn` 与 `TestClient` 的上下文管理器），两条都跑 `lifespan`。\n\n`operation_id` 显式给：将来 MCP 适配层要从这些端点转出去（#48 的 Out of Scope\n最后一节），名字漂了那边跟着漂。","operationId":"health","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Health"}}}}}}}},"components":{"schemas":{"Account":{"properties":{"id":{"type":"integer","title":"Id"},"username":{"type":"string","title":"Username"}},"type":"object","required":["id","username"],"title":"Account","description":"成功时回的那份 body：**「你是谁」**。两个字段，不多不少。\n\n`id` 是 `users.id`（那条邀请码的 `used_by` 记的也是它，`artifacts.user_id` 将来记的\n还是它——#78），`username` 是那个名字（注册那条路是刚定下来的，`GET /api/account`\n那条路是登录着的这个）。\n\n**不回 `created_at`**：调用方问的是「我是谁」，账户是哪天建的与这个问题无关；而加一格\n就要有一个人回答「它为什么在这儿」（判据一）。\n\n**注册与「我是谁」共用一个模型**（#75 的两条路与 #76 的第三条都回它）：两处各写一个\n同形状的模型就是同一个量摆了两份，哪天加一格会漏掉一边——而调用方看到的是「同一个\n概念换了形状」。"},"AccountProfile":{"properties":{"id":{"type":"integer","title":"Id"},"username":{"type":"string","title":"Username"},"tier":{"type":"string","title":"Tier"},"is_admin":{"type":"boolean","title":"Is Admin"},"quota":{"additionalProperties":true,"type":"object","title":"Quota"},"display_name":{"type":"string","title":"Display Name"},"bio":{"type":"string","title":"Bio"},"avatar":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Avatar"}},"type":"object","required":["id","username","tier","is_admin","quota","display_name","bio","avatar"],"title":"AccountProfile"},"ArtifactModel":{"properties":{"name":{"type":"string","title":"Name"},"bytes":{"type":"integer","title":"Bytes"}},"type":"object","required":["name","bytes"],"title":"ArtifactModel","description":"一份产物：**名字**（basename，不含目录）+ 真实字节数。\n\n直接搬摘要里的 `artifacts`（判据三）。名字不带目录是有意的：按名字取产物\n（`GET /api/renders/{id}/{name}`）时，目录是调用方自己给出去的那个渲染根，\n整条路径在响应里是冗余的。"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HistoryEntry":{"properties":{"id":{"type":"string","title":"Id"},"created_at":{"type":"string","title":"Created At"},"files":{"items":{"type":"string"},"type":"array","title":"Files"},"name":{"type":"string","title":"Name","default":"未命名项目"},"custom_name":{"type":"string","title":"Custom Name","default":""},"name_is_default":{"type":"boolean","title":"Name Is Default","default":false},"favorite":{"type":"boolean","title":"Favorite","default":false},"draft":{"type":"boolean","title":"Draft","default":false}},"type":"object","required":["id","created_at","files"],"title":"HistoryEntry","description":"`GET /api/account/artifacts` 里的一行：**一次提交**（#80）。\n\n三格：\n\n- `id`——那次受理的 id，**同时是产物目录名**（`docs/adr/0081` §三），于是它直接拼得\n  进取产物那两条地址（`/api/renders/{id}/{name}` 与 `{id}.zip`）。名字用 `id` 而不是\n  `job_id`：它与 `JobStatus.id` 是同一个东西，两个名字会让人以为是两个数。\n- `created_at`——受理那一刻（`db.Artifact` 给的原样，带微秒的 UTC ISO-8601）。\n- `files`——**那个目录里此刻真在的文件名**（平铺，不含路径）。\n\n**`files` 为什么在这一份里**：调用方要「点一条重新打开那张图」，而结构图叫什么名字\n它猜不出来——提交时可以选 PNG 或 SVG，于是那一份可能是 `well_structure_plot.png`、\n也可能是 `well_structure_plot.svg`，**「是哪一份」只有盘知道**。让前端去猜（先试\npng、404 了再试 svg）就是把一个盘上的事实在第二个地方重写一遍（判据三）；而这一条\n反正已经要 `iterdir()` 一次才筛得出「这个目录还在不在」（见那个端点），顺手把名字\n带回来不多花一毫秒。\n\n**`files` 是空的那些行不出现在历史里**（端点那一层筛掉）：一个目录还在、里面却一个\n文件都没有，是取产物那条路自己认的 404（`download_render_archive`：「这次渲染没有\n可下载的产物」）——摆进历史里等于给了一条点开就 404 的行。"},"JobStatus":{"properties":{"id":{"type":"string","title":"Id"},"archive_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Archive Id"},"status":{"type":"string","enum":["queued","running","success","failed","cancelled"],"title":"Status"},"warnings":{"items":{"$ref":"#/components/schemas/WarningModel"},"type":"array","title":"Warnings"},"files":{"items":{"$ref":"#/components/schemas/ArtifactModel"},"type":"array","title":"Files"},"error":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Error"}},"type":"object","required":["id","status","warnings","files","error"],"title":"JobStatus","description":"一次渲染的**当前状态**：提交的 202 body、`GET /api/jobs/{id}` 的 200 body。\n\n**一份形状、两处使用**（`docs/adr/0081` §四：一个量一个来源）——SSE 那一帧\n（#60）也是它，接口层不另发明一套扁平事件。\n\n`warnings` / `files` 就是一次渲染跑完之后那两样（**一个字不改**，类型也是同一对\n`WarningModel` / `ArtifactModel`，见 `render_kernel.RenderOutcome`）。`error` 是\n**那份七字段的 problem 的第三处使用**\n（另外两处是 HTTP 的响应体与 stdout 那行日志，`docs/adr/0077`）：job 被 202 收下之后，\n渲染失败**不再是一个 HTTP 错误**，于是它原样装进这一格——**`status: \"failed\"` 的时候\n必然是一份真的 problem**（`_settled` 是唯一的写点，它只从 `problems.problem_body_for`\n拿值）；其余时候是 `None`。\n\n`error` 那一格在哪条路上是什么，是这一票的判据（`docs/adr/0081` §四）：一个\n`failed` 的 job 查询照旧回 **200**——查询成功了，答案是「那次渲染失败了」。这与\n「未知 id 回 404」是两件事，别合。"},"KeySummary":{"properties":{"id":{"type":"integer","title":"Id"},"label":{"type":"string","title":"Label"},"prefix":{"type":"string","title":"Prefix"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["id","label","prefix","created_at"],"title":"KeySummary","description":"`GET /api/keys` 里的一行——**也是生成那一次回的那份 body 少一格**（#77）。\n\n`id` / `label` / `prefix` / `created_at` 四格，与 `db.ApiKey` 逐字段同形（那一份的\ndocstring 说清了每格答什么）。**`token_hash` 不在其中**，明文更不在——这不是「这一份\n恰好没带」，是「列表这条路拿不回钥匙」这件事本身（#73 用户故事 18），守卫在\n`test_api_keys.py` 里那条「整个响应体里搜不到那把 key」。"},"NewKey":{"properties":{"id":{"type":"integer","title":"Id"},"label":{"type":"string","title":"Label"},"prefix":{"type":"string","title":"Prefix"},"created_at":{"type":"string","title":"Created At"},"key":{"type":"string","title":"Key"}},"type":"object","required":["id","label","prefix","created_at","key"],"title":"NewKey","description":"`POST /api/keys` 成功时的 body：上面那四格 **+ `key`**（#77）。\n\n**`key` 是明文，而这一份 body 是它唯一一次露面**——之后连部署者都拿不回来（库里存的是\nSHA-256 与前缀）。它回的是 **201**：这一刻真的建出了一个新东西，而它有一个地址\n（`/api/keys/{id}`）。\n\n**继承 `KeySummary` 而不是把那四格再写一遍**：两个 body 装的确实是同一件事的两种面目\n（「你有哪些钥匙」与「刚给你的这一把是什么」），各写一份的话，加一格时会漏掉一边，\n而调用方看到的是同一个概念换了形状（判据三；`Account` 那两个消费者是同一条判据）。"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"WarningModel":{"properties":{"step":{"type":"string","title":"Step"},"message":{"type":"string","title":"Message"}},"type":"object","required":["step","message"],"title":"WarningModel","description":"一条降级告警 + 它属于哪一步。`step` 为「启动」时它发生在任何步骤之前。"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer","bearerFormat":"API key"}}}}