# LightUp 第三方 API v1 — 契約草案（G1）
#
# 狀態：Proposed。對應 docs/adr/adr-004-third-party-integration.md（D1–D10）。
# 這份 schema 描述的**沒有任何一個 endpoint 今天存在**；third-party-gateway-rs 尚未建立。
#
# 版本前綴：`/v1` 放在 server URL，所以它在完整 URL 中**只出現一次**。
# 每個 operation 的 summary 都寫出完整對外路徑（例如 `GET /v1/me`），
# 實際請求即 `https://api.lightup.tech/v1/me`。
#
# 對外 host 已定案（Steven 2026-09-23，ADR-004 D8／U2）：`api.lightup.tech`。
# 這個網域先前未被使用（無 ingress）；第一方 App 走 api.app.lightup.tech，
# bff-dashboard 走 api.d.lightup.tech，兩者都不是這份契約的一部分。

openapi: 3.1.0

info:
  title: LightUp Third-Party API
  version: 1.0.0-draft
  summary: 第三方應用存取 LightUp 平台能力的正式契約（G1 草案）
  description: |
    給**平台外**執行的客戶應用使用。第一方產品（LightUp 自家 App／Dashboard／Storefront）
    走各自的 BFF，不走這裡。

    ## 兩種授權模式

    | 模式 | grant | token profile | 主體（`sub`） | TTL | refresh |
    |---|---|---|---|---|---|
    | 機器／背景 | `client_credentials` | `workload` | service account（`acc_…`，`kind=service`） | 15 分 | 無 |
    | 使用者本人 | `authorization_code` + PKCE | `self` | 使用者 person（`psn_…`） | 15 分 | 有（嚴格 rotation） |

    **v1 沒有代理模式。** 一張使用者 token 只能代表登入的本人；沒有辦法用第三方 API
    代表另一個人（例如家長代表孩子）操作。代理（`delegated` profile）規劃在 v2，
    見 ADR-004 的「v2 未決事項」。

    ## Token 規則（每一條 Gateway 都會驗）

    - 演算法 ES256；JWKS 固定為 `https://auth.lightup.tech/.well-known/jwks.json`
    - `aud` 固定 `lightup-api`（**不是** client id）
    - JOSE header `typ` 必須是 `at+jwt`。**ID token 不能當 access token**，會被這一關擋掉
    - `scope` 使用 `lightup.<capability>` 命名空間
    - 有效能力 = 請求 scope ∩ tenant 對此 app 的 grant ∩ service account／使用者授權 ∩ 方案上限 ∩ 資源政策

    ## 每個請求都會發生的事

    1. Gateway 本地驗簽（簽章、`iss`、`aud`、`typ`、`exp`、profile）
    2. Gateway 向 Amygdala 查當前授權（**沒有 allow-cache**）
    3. 允許 → 轉呼叫領域服務；拒絕 → 403；判不了 → 503 `authorization_unavailable`

    撤權（grant 撤回、帳號停用、session 撤銷、方案降級）在**下一個請求**生效。
    具體最大傳播時間待實測後公布（ADR-004 未決事項 U3）；在此之前不承諾數值。

    ## 每個回應都有

    - `x-request-id`：支援流程的查詢鍵，也是計量的 idempotency key。回報問題請附上它。

    ## 相容政策

    - 新增欄位**不**升版；client 必須容忍未知欄位
    - 移除欄位或改變語意 → `/v2`
    - 棄用最少 **90 天**公告
  contact:
    name: LightUp Platform
    url: https://api.lightup.tech/docs
  license:
    name: Proprietary — LightUp
    identifier: LicenseRef-LightUp-Proprietary

servers:
  - url: https://{host}/v1
    description: |
      正式環境。完整網址例如 `https://api.lightup.tech/v1/me`。

      host 已定案（Steven 2026-09-23，ADR-004 D8／U2）。`host` 仍保留為 server
      variable，是為了讓 staging／sandbox 之後能沿用同一份 schema，不是因為
      正式值還沒決定。
    variables:
      host:
        default: api.lightup.tech
        enum:
          - api.lightup.tech
        description: |
          第三方 API 的正式入口。

          ℹ️ 這個網域在 2026-09-23 之前沒有對應的服務（打進去是 404）。
          LightUp 第一方 App 走 `api.app.lightup.tech`、後台走
          `api.d.lightup.tech`，兩者都不在本契約範圍內，也不受此決定影響。

tags:
  - name: identity
    description: 這張 token 代表誰
  - name: person
    description: 獲授權人員的最少主檔欄位
  - name: person-extension
    description: 應用自有的人員 metadata（pilot）
  - name: tenant
    description: 租戶公開識別欄位
  - name: oauth
    description: |
      取得 token。**這些端點不在 API host 上**，而在 `https://auth.lightup.tech`
      （已上線的既有 issuer）；路徑也沒有 `/v1` 前綴。

security:
  - bearerAuth: []

paths:
  /oauth2/token:
    servers:
      - url: https://auth.lightup.tech
        description: |
          **注意：這個 path 不在 API host 上。** 完整網址是
          `https://auth.lightup.tech/oauth2/token`，沒有 `/v1` 前綴——
          它屬於既有的 OIDC issuer，不是第三方 API 的一部分。
          discovery 在 `https://auth.lightup.tech/.well-known/openid-configuration`。
    post:
      tags: [oauth]
      summary: POST https://auth.lightup.tech/oauth2/token — 取得 access token
      operationId: issueToken
      x-lightup-absolute-url: https://auth.lightup.tech/oauth2/token
      security: []
      description: |
        換取 access token。**不需要 Bearer token**（這是取得 token 的地方），
        client 自己的認證放在表單或 Basic 標頭裡。

        ## v1 支援的 grant

        | `grant_type` | 用途 | 得到的 profile | client 認證 |
        |---|---|---|---|
        | `client_credentials` | 機器／背景 | `workload` | `client_secret`（confidential） |
        | `authorization_code` | 使用者本人授權完成後換 token | `self` | PKCE `code_verifier`＋（confidential 另帶 secret） |
        | `refresh_token` | 續期使用者 token | `self` | 同上 |

        `client_credentials` **尚未實作**：今天 `/oauth2/token` 只認 `authorization_code`
        與 `refresh_token`，discovery 也只公告這兩個。這裡先寫下契約（ADR-004 D3）。

        ## client 認證方式

        `client_secret_post`（放表單，如下方 schema）或 `client_secret_basic`
        （`Authorization: Basic base64(client_id:client_secret)`）。public client
        兩者都不用，只靠 PKCE。

        ## 不會回什麼

        - **`client_credentials` 不回 `refresh_token`**（機器到期直接再換一張，ADR-004 D3）
        - **不回 `id_token` 給資源存取用**。就算流程拿到 `id_token`，它只能用來驗身分；
          拿去打 API 會被擋（`typ` 不是 `at+jwt`）

        ## 冪等與重試

        **`authorization_code` 與 `refresh_token` 都是一次性的。** 重送同一個
        `code` 或已消耗的 `refresh_token` 會得到 `invalid_grant`，而且重放
        refresh 會**殺掉整個 token family**（嚴格 rotation，第三方不放寬）。
        逾時後不要盲目重試——重新走一次流程。`client_credentials` 沒有這個問題，
        可以安全重試。

        ## 限流

        這個端點有自己的限流，與 API 的 tenant／account 雙層限流分開。
        超過回 **429**，看 `Retry-After`。
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/TokenRequest' }
            examples:
              clientCredentials:
                summary: 機器：client_credentials
                value:
                  grant_type: client_credentials
                  client_id: tp_asiauni_rehab
                  client_secret: '<機器憑證，不要寫進版本控制>'
                  scope: lightup.person.read lightup.person.metadata.write
              authorizationCode:
                summary: 使用者：authorization_code + PKCE
                value:
                  grant_type: authorization_code
                  client_id: tp_kidpro_parentapp
                  code: '<授權流程回傳的一次性 code>'
                  code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
                  redirect_uri: https://app.example.com/callback
              refreshToken:
                summary: 使用者：續期
                value:
                  grant_type: refresh_token
                  client_id: tp_kidpro_parentapp
                  refresh_token: '<上一次拿到的 refresh token>'
      responses:
        '200':
          description: 已發出 token
          headers:
            cache-control:
              description: 一律 `no-store`——token 不得被任何中介快取。
              schema: { type: string }
              example: no-store
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
              examples:
                workload:
                  summary: client_credentials（無 refresh_token）
                  value:
                    access_token: eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
                    token_type: Bearer
                    expires_in: 900
                    scope: lightup.person.read lightup.person.metadata.write
                self:
                  summary: authorization_code（有 refresh_token）
                  value:
                    access_token: eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
                    token_type: Bearer
                    expires_in: 900
                    scope: lightup.profile.self.read
                    refresh_token: v1.MnR...
        '400':
          description: |
            **400** — RFC 6749 §5.2 的 token 錯誤。注意它用 `error` 而不是本 API
            其他端點的 `Problem` 形狀：這是 OAuth 既有契約，刻意不改。

            | `error` | 意思 | 怎麼修 |
            |---|---|---|
            | `invalid_client` | client id／secret 不對，或 client 已停用 | 檢查憑證；輪替中要確認用的是還沒過期的那一把 |
            | `invalid_grant` | code／refresh token 無效、過期或**已用過**；PKCE `code_verifier` 不符 | 重新走一次授權流程。**不要重試同一個 code** |
            | `invalid_scope` | 請求的 scope 不在 client 允許範圍、不在 tenant grant、或不在方案上限內——**交集為空就不發 token** | 縮小 scope，或請 tenant 管理員加 grant／升級方案 |
            | `unsupported_grant_type` | 這個 grant 沒開。**`client_credentials` 今天就會是這個答案** | 見上方「v1 支援的 grant」 |
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenError' }
              examples:
                unsupported:
                  value:
                    error: unsupported_grant_type
                    error_description: client_credentials is not enabled on this deployment
                invalidScope:
                  value:
                    error: invalid_scope
                    error_description: requested scope is not granted to this application
        '401':
          description: |
            **401** — 用 `client_secret_basic` 時 client 認證失敗。
            body 同樣是 `invalid_client`，並帶 `WWW-Authenticate: Basic`。
          headers:
            www-authenticate:
              schema: { type: string }
              example: Basic realm="oauth2"
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenError' }
        '429':
          description: |
            **429** — token 端點限流。看 `Retry-After`。
          headers:
            retry-after: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenError' }

  /me:
    get:
      tags: [identity]
      summary: GET /v1/me — 這張 token 代表誰
      operationId: getMe
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.profile.self.read]
        - oauth2ClientCredentials: [lightup.profile.self.read]
      description: |
        **能力**：`lightup.profile.self.read`（方案 L1 起）

        回傳**這張 token 自己**的身分，不是任意人的資料。v1 有兩種 profile：

        | token profile | `subjectType` | `subjectId` | `actor` |
        |---|---|---|---|
        | `self` | `person` | 本人 `psn_…` | 本人 `acc_…` |
        | `workload` | `service_account` | `acc_…`（`kind=service`） | 同 `subjectId` |

        **機器 token 呼叫這支不會「變成某個使用者」**：`workload` 的答案就是 service
        account 自己。機器能讀一群人的資料，不代表平台驗證過任何一位終端使用者
        （計畫 §4.1）。

        **冪等**：GET，天然冪等。不計入寫入額度。
      responses:
        '200':
          description: OK
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            x-ratelimit-remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
              examples:
                self:
                  summary: 使用者本人的 token
                  value:
                    subjectType: person
                    subjectId: psn_01j8m4k2r7e9v3xq5w8n0tza6b
                    tenantId: ten_0cqqg39h2199x8fd5kf58s0eb5
                    clientId: tp_kidpro_parentapp
                    scopes: [lightup.profile.self.read, lightup.person.metadata.read]
                    actor:
                      accountId: acc_01j8m4k2r7e9v3xq5w8n0tzb7c
                workload:
                  summary: 機器 token
                  value:
                    subjectType: service_account
                    subjectId: acc_01j8m4k2r7e9v3xq5w8n0tzc8d
                    tenantId: ten_0cqqg39h2199x8fd5kf58s0eb5
                    clientId: tp_asiauni_rehab
                    scopes: [lightup.person.read, lightup.person.metadata.write]
                    actor:
                      accountId: acc_01j8m4k2r7e9v3xq5w8n0tzc8d
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

  /tenant:
    get:
      tags: [tenant]
      summary: GET /v1/tenant — 這張 token 所屬租戶的公開識別欄位
      operationId: getTenant
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.tenant.info.read]
        - oauth2ClientCredentials: [lightup.tenant.info.read]
      description: |
        **能力**：`lightup.tenant.info.read`（方案 L1 起）

        租戶由 **token 決定**，不由參數決定。沒有 `GET /v1/tenants/{id}`：
        一張 token 只能看自己的租戶（計畫 §9 驗收矩陣第 1 列）。

        回傳的是**識別**欄位，不是租戶設定。不含方案細節、聯絡人、網域設定、
        社群 provider、LDAP 或任何秘密。

        ### 沒有 `state` 欄位（v1 刻意不給）

        這支**不回**租戶的營運狀態。停權租戶不需要用一個欄位來表達：
        它的 token 在**簽發**與**每次判權**兩處都已經被擋下，所以呼叫端
        根本拿不到能成功呼叫的 token——狀態會以 **401／403** 的形式出現，
        而不是以一個要自己判讀的欄位出現。

        少一個欄位也少一種錯誤用法：如果 Gateway 在上游沒給值時填一個預設的
        `active`，partner 會據此決定要不要送出寫入——那正是這種欄位最容易
        造成的傷害。v1 的選擇是不給，而不是猜。

        **冪等**：GET。
      responses:
        '200':
          description: OK
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            x-ratelimit-remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
              examples:
                default:
                  value:
                    tenantId: ten_0cqqg39h2199x8fd5kf58s0eb5
                    displayName: KidPro
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

  /persons/{personId}:
    parameters:
      - $ref: '#/components/parameters/PersonId'
    get:
      tags: [person]
      summary: GET /v1/persons/{personId} — 獲授權人員的最少欄位
      operationId: getPerson
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.person.read]
        - oauth2ClientCredentials: [lightup.person.read]
      description: |
        **能力**：`lightup.person.read`（方案 L2 起）

        **只回三個欄位**：`personId`、`displayName`、`createdAt`。
        沒有 email、沒有手機、沒有生日、沒有關係、沒有訂單、沒有任何 channel identity。
        要更多欄位是新的能力，要新的 grant，不是在這裡加參數。

        **這支不回 metadata**（計畫 §6：「一般主檔 GET 不順便吐出全部 metadata」）。
        metadata 走 `/v1/persons/{personId}/extensions/...`，且需要另外的能力。

        **授權**：除了 capability 之外，Amygdala 還會確認**這個租戶對這個人有有效關係**。
        關係不成立時回 **404**，不是 403 —— caller 不應該從錯誤碼推斷這個 person 是否存在
        （ADR-004 D9）。

        **冪等**：GET。
      responses:
        '200':
          description: OK
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            x-ratelimit-remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Person' }
              examples:
                default:
                  value:
                    personId: psn_01j8m4k2r7e9v3xq5w8n0tza6b
                    displayName: 王小明
                    createdAt: '2025-03-11T02:14:07Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

  /persons/{personId}/extensions:
    parameters:
      - $ref: '#/components/parameters/PersonId'
    get:
      tags: [person-extension]
      summary: GET /v1/persons/{personId}/extensions — 列出這個 app 在這個人身上的 extension（有界分頁）
      operationId: listPersonExtensions
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.person.metadata.read]
        - oauth2ClientCredentials: [lightup.person.metadata.read]
      description: |
        **能力**：`lightup.person.metadata.read`（方案 L2 起）

        **只列出呼叫端自己擁有的 extension。** 同租戶的別的 app 寫的 extension
        不會出現在這裡，也不會被計入 `total`（計畫 §9 驗收矩陣第 2 列）。

        **分頁是有界的**：`limit` 預設 20、**上限 100**。用 `cursor` 取下一頁。
        沒有「全部拉下來」的參數，也沒有任意 JSON 查詢（計畫 §6：v1 只有 key lookup ＋ 有界分頁）。

        **冪等**：GET。
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            x-ratelimit-remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PersonExtensionPage' }
              examples:
                default:
                  value:
                    items:
                      - extensionId: rehab_progress
                        schemaVersion: 3
                        etag: '"17"'
                        updatedAt: '2026-09-20T08:31:55Z'
                        updatedBy: tp_asiauni_rehab
                    nextCursor: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

  /persons/{personId}/extensions/{extensionId}:
    parameters:
      - $ref: '#/components/parameters/PersonId'
      - $ref: '#/components/parameters/ExtensionId'
    get:
      tags: [person-extension]
      summary: GET /v1/persons/{personId}/extensions/{extensionId} — 讀一筆 metadata
      operationId: getPersonExtension
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.person.metadata.read]
        - oauth2ClientCredentials: [lightup.person.metadata.read]
      description: |
        **能力**：`lightup.person.metadata.read`（方案 L2 起）

        回應帶 **`ETag`**。要改這筆資料，把 `ETag` 原封不動放進 `If-Match`。

        **404 的三種情況回答一樣**：extension 未登記、這個 app 不是它的 owner、
        資料不存在。錯誤碼不透露資源是否存在（ADR-004 D9）。

        **冪等**：GET。
      responses:
        '200':
          description: OK
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            etag: { $ref: '#/components/headers/ETag' }
            x-ratelimit-remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PersonExtension' }
              examples:
                default:
                  value:
                    extensionId: rehab_progress
                    personId: psn_01j8m4k2r7e9v3xq5w8n0tza6b
                    schemaVersion: 3
                    etag: '"17"'
                    data:
                      lastSessionAt: '2026-09-19T09:00:00Z'
                      completedUnits: 12
                    createdAt: '2026-04-02T01:00:00Z'
                    updatedAt: '2026-09-20T08:31:55Z'
                    updatedBy: tp_asiauni_rehab
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

    put:
      tags: [person-extension]
      summary: PUT /v1/persons/{personId}/extensions/{extensionId} — 建立或整筆取代 metadata
      operationId: putPersonExtension
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.person.metadata.write]
        - oauth2ClientCredentials: [lightup.person.metadata.write]
      description: |
        **能力**：`lightup.person.metadata.write`（方案 L2 起）

        **整筆取代**，不是 patch：`data` 就是新的完整內容。

        ### 並行控制（必讀）

        **每個 PUT 必須恰好帶一個前置條件標頭**，而且是 RFC 9110 的標準語意，
        沒有自訂延伸：

        - **建立**用 `If-None-Match: *` — 「只有在它還不存在時才寫」（RFC 9110 §13.1.2）
        - **更新**用 `If-Match: "<etag>"` — 「只有在它還是這一版時才寫」（§13.1.1）

        `If-Match` **不接受 `*`**（那是 DELETE 才有的用法）。兩個都不帶、或兩個
        都帶，一律 **428 `precondition_required`**——本 API 沒有「無條件覆寫」這個操作，
        因為那正是遺失更新的來源。

        | 情境 | 送什麼 | 結果 |
        |---|---|---|
        | 建立新的一筆 | `If-None-Match: *` | **201** |
        | 建立但它已經存在 | `If-None-Match: *` | **409** `already_exists` |
        | 更新既有一筆 | `If-Match: "<etag>"` | **200** |
        | 更新但版本已被別人改掉 | `If-Match: "<舊 etag>"` | **409** `revision_conflict` |
        | 更新但那筆已被刪除 | `If-Match: "<etag>"` | **404** |
        | 兩個都沒帶，或兩個都帶 | — | **428** `precondition_required` |

        兩個 client 同時寫，後到的那個會拿到 409，**不會覆蓋**前一個（計畫 §6）。
        正確處理方式：重新 GET、合併、帶新的 `ETag` 重送。

        ℹ️ **為什麼建立衝突是 409 而不是 412。** RFC 9110 對失敗的前置條件給的是
        412；本 API 刻意把它收斂成 **409**，讓「你和別人撞上了」只有一個狀態碼，
        client 的錯誤處理只要分辨 `Problem.error`（`already_exists` vs
        `revision_conflict`）就夠，不必同時處理 409 與 412 兩條路徑。
        428 則保留給「你根本沒表明意圖」，那是 client 的 bug，不是衝突。

        ### 額度（計畫 §6／§7）

        | 限制 | 值 | 超過時 |
        |---|---|---|
        | 單筆 `data` 大小 | **16 KiB** | **413** |
        | 每 tenant 每 extension 筆數 | **50,000** | **422** `quota_exceeded` |
        | 每 tenant extension 數 | **20** | **422** `quota_exceeded` |

        額度判定與寫入在**同一交易內**完成，所以大量並行寫入不會衝破上限
        （計畫 §7「交易內處理並行寫入的額度競爭」）。
        **額度滿不會刪任何既有資料**；讀取與刪除照常可用。

        ### 這裡不能放什麼

        照片、逐題事件、無界成長的列表，以及任何被當成 **permission、餘額、購買權益
        或身份綁定**的欄位（計畫 §6）。平台不會因為 metadata 說了什麼而授予任何權限。

        ### 冪等

        同樣的 `data` ＋ 同樣的 `If-Match` 重送：第一次成功後 `ETag` 就變了，
        第二次會得到 409。要安全重試，重讀後再送。
        **計量以 `x-request-id` 為 idempotency key，重試不重複計量。**
      parameters:
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IfNoneMatch'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PersonExtensionWrite' }
            examples:
              default:
                value:
                  schemaVersion: 3
                  data:
                    lastSessionAt: '2026-09-22T09:00:00Z'
                    completedUnits: 13
      responses:
        '200':
          description: 已更新
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            etag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PersonExtension' }
        '201':
          description: 已建立
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
            etag: { $ref: '#/components/headers/ETag' }
            location: { $ref: '#/components/headers/Location' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PersonExtension' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '428': { $ref: '#/components/responses/PreconditionRequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

    delete:
      tags: [person-extension]
      summary: DELETE /v1/persons/{personId}/extensions/{extensionId} — 刪除一筆 metadata
      operationId: deletePersonExtension
      security:
        - bearerAuth: []
        - oauth2AuthorizationCode: [lightup.person.metadata.write]
        - oauth2ClientCredentials: [lightup.person.metadata.write]
      description: |
        **能力**：`lightup.person.metadata.write`（方案 L2 起）

        **冪等**：已經不存在也回 **204**（計畫 §6「刪除可冪等」）。

        `If-Match` **必填**（`required: true`，schema 驗得到）：

        - `If-Match: "<etag>"` — 只在還是那一版時才刪。版本不符 → **409** `revision_conflict`
        - `If-Match: *` — 不管哪一版都刪（RFC 9110 §13.1.1 的標準 `*` 語意：
          「只要它存在」。這裡合用，因為刪除本來就只對存在的東西有意義）
        - 省略 → **428** `precondition_required`

        「已經不存在」與「存在但版本不符」不同：前者 **204**（冪等），後者 **409**。
        DELETE **不看** `If-None-Match`；送了會被忽略。

        刪除釋放筆數額度，但**不會**回填任何歷史；要保留請先匯出。
      parameters:
        - $ref: '#/components/parameters/IfMatchRequired'
      responses:
        '204':
          description: 已刪除，或原本就不存在（冪等）
          headers:
            x-request-id: { $ref: '#/components/headers/XRequestId' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '428': { $ref: '#/components/responses/PreconditionRequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AuthorizationUnavailable' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: at+jwt
      description: |
        LightUp profile-v2 access token。`Authorization: Bearer <token>`。

        Gateway 驗這些，缺一不可：

        - 簽章 ES256，key 取自固定 JWKS `https://auth.lightup.tech/.well-known/jwks.json`
        - `iss` 是平台 issuer（**不**依 token 自報的 `iss` 去別處取 key）
        - `aud` = `lightup-api`
        - JOSE header `typ` = `at+jwt` → **ID token 在這裡就被擋掉**
        - `profile` ∈ {`self`, `workload`} — **v1 只有這兩種**；`delegated` 與 `shared` 一律拒
        - 未過期

        用錯 token（ID token、第一方 storefront token、內部 `x-system-token`）一律 **401**。

    oauth2AuthorizationCode:
      type: oauth2
      description: |
        使用者授權（互動式）。public client **強制** PKCE S256；
        第三方 confidential client **也必填** PKCE（不吃第一方的相容路徑）。

        發出的 token 是 profile v2 `self`——**代表登入的本人，而且只有本人**。
        v1 不簽發代理（`delegated`）token，同意頁也沒有「選擇被代理對象」這一步。
        refresh 走嚴格 rotation：重放已消耗的 refresh 會殺掉整個 family。
        第三方**不會**拿到 reuse grace、sliding 或 absolute-max 放寬。
      flows:
        authorizationCode:
          authorizationUrl: https://auth.lightup.tech/oauth2/authorize
          tokenUrl: https://auth.lightup.tech/oauth2/token
          refreshUrl: https://auth.lightup.tech/oauth2/token
          scopes:
            lightup.profile.self.read: 讀這張 token 代表的身分
            lightup.person.read: 讀獲授權人員的最少欄位
            lightup.person.metadata.read: 讀本 app 自己的人員 metadata
            lightup.person.metadata.write: 寫／刪本 app 自己的人員 metadata
            lightup.tenant.info.read: 讀租戶公開識別欄位

    oauth2ClientCredentials:
      type: oauth2
      description: |
        機器／背景操作。**主體是 service account，不是 client**：
        client 憑證證明「哪支程式」，token 的 `sub` 是租戶的 service account
        （`acc_…`，`AccountKind::Service`——與人使用同一個 id 前綴）。

        發出的 token 是 profile v2 `workload`，TTL 15 分，**沒有 refresh**
        （到期就重新換一張）。

        實際 scope = 請求的 scope ∩ service account 的 `grantedCapabilities`
        ∩ 租戶方案上限。交集為空 → `invalid_scope`，不會發一張沒有 scope 的 token。

        憑證輪替：建立 next secret → 兩把並存**最長 7 天** → promote，舊的立即失效。
        撤銷：停用帳號或推進 epoch，在途 token 在下一個請求即失效。

        ⚠️ `client_credentials` **尚未**在 `/oauth2/token` 實作，discovery 目前也沒有公告
        （ADR-004 D3 是要做的事，不是現況）。
      flows:
        clientCredentials:
          tokenUrl: https://auth.lightup.tech/oauth2/token
          scopes:
            lightup.profile.self.read: 讀這張 token 代表的 service account
            lightup.person.read: 讀獲授權人員的最少欄位
            lightup.person.metadata.read: 讀本 app 自己的人員 metadata
            lightup.person.metadata.write: 寫／刪本 app 自己的人員 metadata
            lightup.tenant.info.read: 讀租戶公開識別欄位

  parameters:
    PersonId:
      name: personId
      in: path
      required: true
      description: |
        人員的 TypeID（`psn_…`）。**不可自行拼裝或解析**；只使用平台給過你的值
        （ADR-003 §1、計畫 §6）。
      schema: { $ref: '#/components/schemas/PersonTypeId' }

    ExtensionId:
      name: extensionId
      in: path
      required: true
      description: |
        登記時由平台配發的 extension id。**不可**用會改名的功能名稱當身分（計畫 §6）。
        未登記或不屬於呼叫端 → 404。
      schema: { $ref: '#/components/schemas/ExtensionId' }

    Limit:
      name: limit
      in: query
      required: false
      description: 每頁筆數。預設 20，**上限 100**。超過上限 → 422。
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        上一頁回應的 `nextCursor`。不透明字串，**不要解析**；換版本後形狀可能改變。
      schema:
        type: string
        maxLength: 512

    IfMatch:
      name: If-Match
      in: header
      required: false
      description: |
        **更新既有資料時使用**，值是上次讀到的 `ETag`（RFC 9110 §13.1.1）。

        `required: false` 是因為 OpenAPI 無法表達「`If-Match` 與 `If-None-Match`
        恰好擇一」。這不代表可以兩個都不帶——兩個都不帶或兩個都帶都是
        **428 `precondition_required`**。建立新資料請改用 `If-None-Match: *`。

        **不接受 `*`**（schema 就擋掉）。`If-Match: *` 在 RFC 裡的意思是
        「只要它存在」，那是 DELETE 的用法，拿來建立資料會是錯的。
      schema:
        type: string
        pattern: '^"[^"]+"$'
      example: '"17"'

    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: |
        **建立新資料時使用**，唯一合法值是 `*`，意思是「只有在它還不存在時才寫」
        （RFC 9110 §13.1.2）。

        它已經存在 → **409** `already_exists`（不是 412，見 PUT 的說明）。

        `required: false` 的理由同 `If-Match`：兩者恰好擇一，OpenAPI 表達不了，
        所以由伺服器以 **428** 執行。
      schema:
        type: string
        pattern: '^\*$'
      example: '*'

    IfMatchRequired:
      name: If-Match
      in: header
      required: true
      description: |
        **必填**（`required: true`，schema 驗得到；DELETE 只有這一個前置條件標頭，
        不存在擇一的問題）。

        - `"<etag>"` — 只刪那一版；版本不符 → **409** `revision_conflict`
        - `*` — 不論版本都刪（RFC 9110 §13.1.1 的標準語意：「只要它存在」）

        省略 → **428** `precondition_required`。已經不存在 → **204**（冪等）。
      schema:
        type: string
        pattern: '^(\*|"[^"]+")$'
      example: '*'

  headers:
    XRequestId:
      description: |
        這次請求的 id。**每個回應都有**，成功與失敗都是。
        回報問題請附上它；它同時是計量的 idempotency key。
      required: true
      schema: { type: string }
    ETag:
      description: 這筆資料目前的版本。原封不動放進下一次的 `If-Match`。
      required: true
      schema: { type: string }
    Location:
      description: 新建立資源的路徑。
      schema: { type: string }
    RetryAfter:
      description: 建議等待秒數後再重試。
      required: true
      schema: { type: integer, minimum: 1 }
    RateLimitRemaining:
      description: |
        目前視窗內剩餘的請求數（較嚴格的那一層：tenant 或 account）。
        資訊性欄位，不是契約的一部分，別拿它當流量控制的唯一依據。
      schema: { type: integer, minimum: 0 }

  responses:
    Unauthorized:
      description: |
        **401** — token 本身不可用：沒帶、無效、過期、簽章錯、`aud`／`iss`／`typ` 不符
        （例如把 ID token 或第一方 token 拿來用）。

        **`error` 一律是 `invalid_token`，不細分。** 沒帶 token 與帶了壞 token
        回同一個答案，`aud` 錯與簽章錯也回同一個答案——細分等於幫呼叫端
        （包括攻擊者）確認哪一項才是錯的。真正的原因記在伺服器端，
        用 `x-request-id` 查。

        重新取得 token 再試。**重試同一張 token 不會變成成功。**

        ℹ️ `delegated` 與 `shared` profile 的 token **不在這裡被擋**：它們的簽章
        是對的，會走完驗證再由授權決定拒絕，所以拿到的是
        **403 `profile_not_supported`**。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
        www-authenticate:
          schema: { type: string }
          example: 'Bearer error="invalid_token"'
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            idTokenUsed:
              summary: 拿 ID token 當 access token
              value:
                error: invalid_token
                message: token is not an access token
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh2j

    Forbidden:
      description: |
        **403** — token 有效，但**現在**不准做這件事。重試不會變成允許——
        要改的是 grant、方案或請求本身。

        `error` 是**穩定代碼**，逐字來自 Amygdala 的授權決定（ADR-004 D5）。
        程式判斷用它，不要 parse `message`。

        | `error` | 意思 | 怎麼修 |
        |---|---|---|
        | `client_unknown` | 這個 client id 不存在 | 重新登記 app |
        | `client_disabled` | client 存在但已被停用（`status=disabled`）。**與 `client_unknown` 分開**：前者是「被關掉了」，後者是「從來沒有」，處理方式不同 | tenant 管理員重新啟用。停用對**在途 token 也立即生效**——下一個請求就擋 |
        | `actor_unknown` | token 沒有指出誰在操作：`self` 缺 `actor_account_id`，或 `workload` 帶的 `actor_account_id` 不等於 `sub` | 重新取一張 token。**這代表簽發端有問題**，請附 `x-request-id` 回報 |
        | `tenant_suspended` | 租戶被停權。與呼叫端是誰、請求哪一項能力都無關——**整個租戶的第三方存取都停**，讀寫皆然 | 租戶要先恢復。重試沒有用；**即使換一張 token 也一樣**，簽發端同樣會拒 |
        | `client_not_third_party` | 這個 client 不是第三方類別（是第一方或內部工具） | 平台端問題；第三方不該拿到這種 client |
        | `profile_not_supported` | token 的 profile 不在 v1 範圍——**`delegated` 與 `shared` 落在這裡** | 改用本人（`self`）或機器（`workload`）token；代理要等 v2 |
        | `subject_revoked` | service account 或使用者 session 已被撤銷／停用 | tenant 管理員重新啟用，或請使用者重新登入 |
        | `epoch_changed` | token 的 `actor_epoch` 與帳號現值不符——帳號被撤權過 | 重新取一張 token |
        | `capability_not_granted` | tenant 沒把這項能力授權給這個 app | tenant 管理員在後台加授權 |
        | `plan_capability_excluded` | 租戶方案不含這項能力 | 升級方案 |
        | `capability_not_in_token` | 能力有被授權，但**這張 token 沒帶**這個 scope | 換一張帶足 scope 的 token |
        | `capability_unavailable` | 這項能力在本部署尚未開放（目錄狀態不是可呼叫） | 看文件的可用狀態，等它上線 |
        | `resource_type_mismatch` | 能力宣告的資源型別與你打的資源對不上 | 修正呼叫 |
        | `tenant_mismatch` | 資源不屬於這張 token 的租戶 | 修正呼叫 |
        | `no_relationship` | 租戶對這個人沒有有效關係 | 修正呼叫，或先建立關係 |
        | `forbidden` | 兜底：Amygdala 回了本版 Gateway 不認識的 deny 代碼。**仍然是拒絕**，不會變成放行，也不會變成可重試的 503 | 回報並附上 `x-request-id` |

        ⚠️ **`tenant_mismatch` 與 `no_relationship` 打在 person 資源上時會變成 404**，
        不是 403——否則 403 與 404 的差別本身就是一支「這個 person 存不存在」的
        探測器（ADR-004 D9）。**不透露資源是否存在**是刻意的。

        ℹ️ **`tenant_suspended` 不折成 404**，即使打在 person 資源上也一樣回 403。
        它講的是**你的租戶**的狀態，不是目標資源存不存在，所以據此推不出
        任何關於那個 person 的事——沒有要遮蔽的東西。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            missingCapability:
              value:
                error: capability_not_granted
                message: this application is not granted lightup.person.metadata.write
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh3k
            planTooLow:
              value:
                error: plan_capability_excluded
                message: the tenant plan does not include this capability
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh4m

    NotFound:
      description: |
        **404** — 資源不存在，**或**存在但呼叫端連它存不存在都不該知道。
        兩者刻意回同一個答案（ADR-004 D9）。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            default:
              value:
                error: not_found
                message: resource not found
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh5n

    Conflict:
      description: |
        **409** — 你和別人撞上了。**沒有任何東西被寫入。** 兩種：

        - `revision_conflict` — 送出的 `If-Match` 與現行 `ETag` 不符（有人搶先寫了）。
          重新 GET、合併、帶新的 `ETag` 重送。
        - `already_exists` — 送了 `If-None-Match: *` 要建立，但它已經存在。
          改成 GET 讀出現行 `ETag`，再用 `If-Match` 更新。

        **本 API 用 409 表達所有衝突，不用 412。** RFC 9110 對失敗的前置條件
        給的是 412；收斂成單一狀態碼讓 client 只要分辨 `Problem.error` 就夠。
        「前置條件標頭根本沒帶或帶重複」不是衝突，是 **428**，見下。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
        etag:
          description: |
            現行版本，方便你直接比對。`already_exists` 時這就是既有那一筆的
            `ETag`，可以直接拿去當 `If-Match` 重送，省一次 GET。
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            conflict:
              value:
                error: revision_conflict
                message: the resource was modified; re-read and retry
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh6p
            alreadyExists:
              value:
                error: already_exists
                message: the extension already exists; read its ETag and use If-Match
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh7q

    PreconditionRequired:
      description: |
        **428** — 這個寫入沒有表明它的前置條件（RFC 6585 §3）。

        `PUT` 必須**恰好帶一個**：`If-Match: "<etag>"`（更新）或
        `If-None-Match: *`（建立）。`DELETE` 必須帶 `If-Match`。
        兩個都沒帶、或 `PUT` 同時帶兩個，就是這個錯誤。

        **沒有任何東西被寫入**，而且重試同樣的請求永遠是同樣的結果——
        這是 client 的 bug，不是暫時狀況。本 API **沒有**無條件覆寫的操作：
        那正是遺失更新的來源（計畫 §6）。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            missing:
              summary: 兩個都沒帶
              value:
                error: precondition_required
                message: provide exactly one of If-Match or If-None-Match
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh7q
            both:
              summary: 兩個都帶
              value:
                error: precondition_required
                message: If-Match and If-None-Match are mutually exclusive
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh8r

    PayloadTooLarge:
      description: |
        **413** — 單筆 `data` 超過 **16 KiB**。縮小內容，或把大東西放在你自己的資料庫
        （計畫 §6：平台 metadata 不存照片與無界列表）。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            default:
              value:
                error: payload_too_large
                message: extension data exceeds 16 KiB
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh8r

    UnprocessableEntity:
      description: |
        **422** — 請求格式對，但內容不可接受：額度用盡、參數超界、schema 不符。

        **額度用盡不會刪任何資料。** 既有資料照常可讀、可刪、可匯出。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            quota:
              value:
                error: quota_exceeded
                message: tenant reached 50000 records for this extension
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzh9s
            schema:
              value:
                error: schema_invalid
                message: data does not match the registered extension schema
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzj1t

    TooManyRequests:
      description: |
        **429** — 限流。限流有**兩層**：tenant 與 service account／使用者
        （計畫 §7）。兩層都可能觸發，回應不會告訴你是哪一層。

        看 `Retry-After` 再重試。合法的大量同步不會被丟棄，會被要求放慢
        ——需要更高額度請升級方案，別用更多帳號繞過（那會在 tenant 層再撞一次）。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
        retry-after: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            default:
              value:
                error: rate_limited
                message: too many requests
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzj2u

    AuthorizationUnavailable:
      description: |
        **503** — 平台**現在無法判斷**你有沒有權限。兩種，分開是因為要修的地方不同：

        | `error` | 什麼做不完 |
        |---|---|
        | `authorization_unavailable` | 拿不到授權決定——Amygdala 不可用、逾時，或下游資料面不可用 |
        | `token_verification_unavailable` | **驗簽**就做不完——JWKS 取不到，或 unknown `kid` 的有界重取失敗 |

        兩者都**不是**拒絕。平台 fail-closed：判不了就不放行，而不是降級成只驗簽
        （計畫 §5）。**都可以重試**，請用指數退避；`Retry-After` 是建議不是承諾。

        403 和 503 永遠不會長得一樣：前者是「不准」，後者是「現在不知道」。
      headers:
        x-request-id: { $ref: '#/components/headers/XRequestId' }
        retry-after: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            default:
              value:
                error: authorization_unavailable
                message: authorization service temporarily unavailable; retry
                requestId: req_01j8m4k2r7e9v3xq5w8n0tzj3v

  schemas:

    TokenRequest:
      description: |
        `application/x-www-form-urlencoded`。三種 grant 各自需要的欄位不同，
        所以用 `oneOf` 分開——送錯組合在 schema 層就看得出來，不必等伺服器回 400。
      oneOf:
        - $ref: '#/components/schemas/ClientCredentialsRequest'
        - $ref: '#/components/schemas/AuthorizationCodeRequest'
        - $ref: '#/components/schemas/RefreshTokenRequest'
      discriminator:
        propertyName: grant_type
        mapping:
          client_credentials: '#/components/schemas/ClientCredentialsRequest'
          authorization_code: '#/components/schemas/AuthorizationCodeRequest'
          refresh_token: '#/components/schemas/RefreshTokenRequest'

    ClientCredentialsRequest:
      type: object
      title: client_credentials
      description: |
        機器／背景。得到 `workload` token，**沒有 refresh_token**。
        `client_secret` 可改放 `Authorization: Basic`；兩種擇一，不要同時送。
      required: [grant_type, client_id]
      additionalProperties: false
      properties:
        grant_type:
          type: string
          const: client_credentials
        client_id: { $ref: '#/components/schemas/ClientId' }
        client_secret:
          type: string
          description: |
            service account 的機器憑證。用 `client_secret_basic` 時省略。
            **不要放進版本控制、前端 bundle 或 log。**
        scope:
          $ref: '#/components/schemas/ScopeString'

    AuthorizationCodeRequest:
      type: object
      title: authorization_code
      description: |
        使用者授權完成後，用一次性 `code` 換 token。得到 `self` token。
        **PKCE 必填**——第三方 public 與 confidential client 都要。
      required: [grant_type, client_id, code, code_verifier, redirect_uri]
      additionalProperties: false
      properties:
        grant_type:
          type: string
          const: authorization_code
        client_id: { $ref: '#/components/schemas/ClientId' }
        client_secret:
          type: string
          description: confidential client 才有；public client 不送。
        code:
          type: string
          description: '`/oauth2/authorize` 導回時帶的一次性授權碼。**用過就失效。**'
        code_verifier:
          type: string
          minLength: 43
          maxLength: 128
          description: |
            PKCE verifier，43–128 字元。授權請求送的是它的 `S256` 雜湊
            （`code_challenge`），`plain` 不支援。
        redirect_uri:
          type: string
          format: uri
          description: 必須與授權請求送出的那一個**完全相同**，且已登記在 client 上。

    RefreshTokenRequest:
      type: object
      title: refresh_token
      description: |
        續期使用者 token。**嚴格 rotation**：每次成功都會換一張新的
        refresh token，舊的立即失效；重放已消耗的那一張會**殺掉整個 family**
        （第三方不適用任何放寬，見 ADR-004 D1）。
      required: [grant_type, client_id, refresh_token]
      additionalProperties: false
      properties:
        grant_type:
          type: string
          const: refresh_token
        client_id: { $ref: '#/components/schemas/ClientId' }
        client_secret:
          type: string
          description: confidential client 才有。
        refresh_token:
          type: string
          description: 上一次回應給的那一張。不透明字串，不要解析。
        scope:
          allOf:
            - $ref: '#/components/schemas/ScopeString'
          description: |
            可選，只能**縮小**原本的 scope，不能放大。省略就沿用原本的。

    ClientId:
      type: string
      description: '第三方 client id，形狀 `tp_<tenant label>_<slug>`，由 amygdala 配發。'
      pattern: '^tp_[a-z0-9]+(?:-[a-z0-9]+)*_[a-z0-9]+(?:-[a-z0-9]+)*$'
      examples: ['tp_asiauni_rehab']

    ScopeString:
      type: string
      description: |
        以**空白分隔**的能力清單（OAuth 慣例，不是逗號）。
        實際拿到的 scope 是各層交集後的結果，可能比請求的少——
        以回應的 `scope` 欄位為準，不要假設拿到的就是送出去的。
      examples: ['lightup.person.read lightup.person.metadata.write']

    TokenResponse:
      type: object
      description: RFC 6749 §5.1 的 token 回應。
      required: [access_token, token_type, expires_in, scope]
      additionalProperties: false
      properties:
        access_token:
          type: string
          description: |
            profile v2 access token（JWS，header `typ: at+jwt`、`alg: ES256`）。
            **當成不透明字串使用**：要知道自己是誰請呼叫 `GET /v1/me`，
            不要在 client 自行解析 claims 做授權判斷。
        token_type:
          type: string
          const: Bearer
          description: '一律 `Bearer`。放進 `Authorization: Bearer <access_token>`。'
        expires_in:
          type: integer
          description: |
            剩餘秒數。v1 是 **900**（15 分）。**不要寫死這個數字**——
            照回應的值算到期時間。
          examples: [900]
        scope:
          allOf:
            - $ref: '#/components/schemas/ScopeString'
          description: |
            **實際生效**的能力，已是請求 ∩ grant ∩ 方案上限的結果。
            可能少於你請求的；以這裡為準。
        refresh_token:
          type: string
          description: |
            只有 `authorization_code` 與 `refresh_token` 會回。
            **`client_credentials` 不回**——機器到期直接再換一張。
            每次續期都會換新的，舊的立即失效。

    TokenError:
      type: object
      description: |
        RFC 6749 §5.2 的錯誤形狀。**與本 API 其他端點的 `Problem` 不同**——
        那是 OAuth 的既有契約，刻意保持原樣，不要把兩者混在同一個解析路徑。
      required: [error]
      additionalProperties: false
      properties:
        error:
          type: string
          enum:
            - invalid_client
            - invalid_grant
            - invalid_scope
            - unsupported_grant_type
        error_description:
          type: string
          description: 給人看的說明，字串隨時可能改；程式判斷請用 `error`。

    Problem:
      type: object
      description: |
        所有錯誤共用的形狀（RFC 9457 的精神，欄位刻意極簡）。
        `error` 是**穩定代碼**，程式判斷請用它；`message` 是給人看的，隨時可能改字。
      required: [error, requestId]
      additionalProperties: false
      properties:
        error:
          type: string
          description: 穩定的機器可讀代碼。
          enum:
            # 401 — 一律這一個，不細分（細分會讓端點變成探測工具）
            - invalid_token
            # 403 — D5 的穩定 deny 代碼，逐字來自 amygdala 的授權決定
            - client_unknown
            - client_disabled
            - actor_unknown
            - tenant_suspended
            - client_not_third_party
            - profile_not_supported
            - subject_revoked
            - epoch_changed
            - capability_not_granted
            - plan_capability_excluded
            - capability_not_in_token
            - capability_unavailable
            - resource_type_mismatch
            - tenant_mismatch
            - no_relationship
            # 403 — 新版 amygdala 送來本版不認識的 deny 代碼時的兜底
            - forbidden
            # 404
            - not_found
            - revision_conflict
            - already_exists
            - precondition_required
            - payload_too_large
            - quota_exceeded
            - schema_invalid
            - rate_limited
            # 503 — 兩種「現在判不了」，分開是因為要修的地方不同
            - authorization_unavailable
            - token_verification_unavailable
        message:
          type: string
          description: 人類可讀說明。**不含**資源是否存在的線索、內部路徑或任何秘密。
        requestId:
          type: string
          description: 同 `x-request-id`。回報問題請附上。

    PersonTypeId:
      type: string
      pattern: '^psn_[0-9a-hjkmnp-tv-z]{26}$'
      examples: ['psn_01j8m4k2r7e9v3xq5w8n0tza6b']

    TenantTypeId:
      type: string
      pattern: '^ten_[0-9a-hjkmnp-tv-z]{26}$'
      examples: ['ten_0cqqg39h2199x8fd5kf58s0eb5']

    ExtensionId:
      type: string
      pattern: '^[a-z][a-z0-9_]{2,63}$'
      examples: ['rehab_progress']

    Capability:
      type: string
      pattern: '^lightup\.[a-z][a-z0-9.]*$'
      description: |
        能力 id。完整清單是版本化的能力目錄（`crates/lightup-types/capabilities.toml`），
        對外由文件網站發布，每項標明說明、資源型別、最低方案等級與可用狀態
        （`planned` / `preview` / `ga`）。v1 的五項全部是 `preview`。
      examples: ['lightup.person.metadata.write']

    Me:
      type: object
      description: 這張 token 代表誰。
      required: [subjectType, subjectId, tenantId, clientId, scopes, actor]
      additionalProperties: false
      properties:
        subjectType:
          type: string
          enum: [person, service_account]
        subjectId:
          type: string
          description: '`psn_…`（person）或 `acc_…`（service account，`kind=service`）。'
        tenantId: { $ref: '#/components/schemas/TenantTypeId' }
        clientId:
          type: string
          description: '第三方 client id，形狀 `tp_<tenant label>_<slug>`，全域唯一。'
          pattern: '^tp_[a-z0-9]+(?:-[a-z0-9]+)*_[a-z0-9]+(?:-[a-z0-9]+)*$'
        scopes:
          type: array
          description: 這張 token 實際生效的能力（已經是各層交集後的結果）。
          items: { $ref: '#/components/schemas/Capability' }
        actor:
          type: object
          description: 實際執行操作的帳號。
          required: [accountId]
          additionalProperties: false
          properties:
            accountId:
              type: string
              description: '`acc_…`。人與 service account 同一個前綴，靠 `subjectType` 區分。'

    Tenant:
      type: object
      description: |
        租戶的**公開識別**欄位。只有「這是誰」，沒有「它現在怎麼樣」。

        不含租戶設定、方案等級、聯絡人、網域設定、社群 provider、LDAP，
        也**不含營運狀態**（見下）。`additionalProperties: false`——
        內部欄位（例如 amygdala 的 `slug`）不會出現在這裡。
      required: [tenantId, displayName]
      additionalProperties: false
      properties:
        tenantId: { $ref: '#/components/schemas/TenantTypeId' }
        displayName: { type: string }

    Person:
      type: object
      description: |
        **最少欄位**。要更多欄位是新的能力與新的 grant，不是在這裡加參數。
      required: [personId, displayName, createdAt]
      additionalProperties: false
      properties:
        personId: { $ref: '#/components/schemas/PersonTypeId' }
        displayName:
          type: string
          description: 租戶記錄的顯示名稱。**不保證**唯一，也不是身分證明。
        createdAt:
          type: string
          format: date-time

    PersonExtensionWrite:
      type: object
      description: |
        寫入內容。`data` 整筆取代。
        **不要**放照片、逐題事件、無界列表，或任何被當成權限／餘額／權益／身份綁定的欄位。
      required: [schemaVersion, data]
      additionalProperties: false
      properties:
        schemaVersion:
          type: integer
          minimum: 1
          description: 必須是這個 extension 登記過的版本之一。
        data:
          type: object
          description: '序列化後 **≤ 16 KiB**，超過回 413。'

    PersonExtension:
      type: object
      required: [extensionId, personId, schemaVersion, etag, data, createdAt, updatedAt, updatedBy]
      additionalProperties: false
      properties:
        extensionId: { $ref: '#/components/schemas/ExtensionId' }
        personId: { $ref: '#/components/schemas/PersonTypeId' }
        schemaVersion: { type: integer, minimum: 1 }
        etag:
          type: string
          description: 同回應的 `ETag` 標頭。寫入時原封不動放進 `If-Match`。
        data: { type: object }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        updatedBy:
          type: string
          description: |
            最後寫入的 **client id**（不是使用者）。
            要追到人，用 `x-request-id` 查稽核紀錄。

    PersonExtensionSummary:
      type: object
      description: 列表用的摘要，**不含 `data`**。要內容請用 key lookup 逐筆取。
      required: [extensionId, schemaVersion, etag, updatedAt, updatedBy]
      additionalProperties: false
      properties:
        extensionId: { $ref: '#/components/schemas/ExtensionId' }
        schemaVersion: { type: integer, minimum: 1 }
        etag: { type: string }
        updatedAt: { type: string, format: date-time }
        updatedBy: { type: string }

    PersonExtensionPage:
      type: object
      required: [items, nextCursor]
      additionalProperties: false
      properties:
        items:
          type: array
          maxItems: 100
          items: { $ref: '#/components/schemas/PersonExtensionSummary' }
        nextCursor:
          type: ['string', 'null']
          description: 還有下一頁時是不透明字串；沒有下一頁時是 `null`。**不要解析它。**
