LightUp 第三方 API 契約草案・端點尚未上線 v1.0.0-draft · a9c0c2b0d98a

API 參考

本頁完全由版本化的 OpenAPI 文件產生,沒有手寫的端點說明。 契約版本 1.0.0-draft,base URL https://api.lightup.tech/v1

操作一覽

操作 需要的能力 operationId
POST https://auth.lightup.tech/oauth2/token issueToken
GET /v1/me lightup.profile.self.read getMe
GET /v1/tenant lightup.tenant.info.read getTenant
GET /v1/persons/{personId} lightup.person.read getPerson
GET /v1/persons/{personId}/extensions lightup.person.metadata.read listPersonExtensions
GET /v1/persons/{personId}/extensions/{extensionId} lightup.person.metadata.read getPersonExtension
PUT /v1/persons/{personId}/extensions/{extensionId} lightup.person.metadata.write putPersonExtension
DELETE /v1/persons/{personId}/extensions/{extensionId} lightup.person.metadata.write deletePersonExtension

POST https://auth.lightup.tech/oauth2/token

operationId issueToken 能力 不需要(這裡是取得 token 的地方)

這個端點不在 API host 上。 完整網址就是上面那一個,沒有 /v1 前綴

換取 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_coderefresh_token,discovery 也只公告這兩個。這裡先寫下契約(ADR-004 D3)。

client 認證方式

client_secret_post(放表單,如下方 schema)或 client_secret_basicAuthorization: Basic base64(client_id:client_secret))。public client 兩者都不用,只靠 PKCE。

不會回什麼

冪等與重試

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

限流

這個端點有自己的限流,與 API 的 tenant/account 雙層限流分開。 超過回 429,看 Retry-After

請求 body

application/x-www-form-urlencoded,schema TokenRequest,必填。

範例 clientCredentials — 機器:client_credentials

{
  "grant_type": "client_credentials",
  "client_id": "tp_asiauni_rehab",
  "client_secret": "<機器憑證,不要寫進版本控制>",
  "scope": "lightup.person.read lightup.person.metadata.write"
}

範例 authorizationCode — 使用者:authorization_code + PKCE

{
  "grant_type": "authorization_code",
  "client_id": "tp_kidpro_parentapp",
  "code": "<授權流程回傳的一次性 code>",
  "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
  "redirect_uri": "https://app.example.com/callback"
}

範例 refreshToken — 使用者:續期

{
  "grant_type": "refresh_token",
  "client_id": "tp_kidpro_parentapp",
  "refresh_token": "<上一次拿到的 refresh token>"
}

回應

狀態 說明
200 已發出 token
400 RFC 6749 §5.2 的 token 錯誤。注意它用 error 而不是本 API
401 client_secret_basic 時 client 認證失敗。
429 token 端點限流。看 Retry-After

200 範例

範例 workload — client_credentials(無 refresh_token)

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "lightup.person.read lightup.person.metadata.write"
}

範例 self — authorization_code(有 refresh_token)

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "lightup.profile.self.read",
  "refresh_token": "v1.MnR..."
}

400 範例

範例 unsupported

{
  "error": "unsupported_grant_type",
  "error_description": "client_credentials is not enabled on this deployment"
}

範例 invalidScope

{
  "error": "invalid_scope",
  "error_description": "requested scope is not granted to this application"
}

GET /v1/me

operationId getMe 能力 lightup.profile.self.read

能力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,天然冪等。不計入寫入額度。

回應

狀態 說明
200 OK
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

200 範例

範例 self — 使用者本人的 token

{
  "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 — 機器 token

{
  "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"
  }
}

GET /v1/tenant

operationId getTenant 能力 lightup.tenant.info.read

能力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。

回應

狀態 說明
200 OK
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

200 範例

範例 default

{
  "tenantId": "ten_0cqqg39h2199x8fd5kf58s0eb5",
  "displayName": "KidPro"
}

GET /v1/persons/{personId}

operationId getPerson 能力 lightup.person.read

能力lightup.person.read(方案 L2 起)

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

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

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

冪等:GET。

參數

名稱 位置 必填 型別 說明
personId path PersonTypeId 人員的 TypeID(psn_…)。不可自行拼裝或解析;只使用平台給過你的值 (ADR-003 §1、計畫 §6)。 pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$

回應

狀態 說明
200 OK
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
404 資源不存在,存在但呼叫端連它存不存在都不該知道。
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

200 範例

範例 default

{
  "personId": "psn_01j8m4k2r7e9v3xq5w8n0tza6b",
  "displayName": "王小明",
  "createdAt": "2025-03-11T02:14:07Z"
}

GET /v1/persons/{personId}/extensions

operationId listPersonExtensions 能力 lightup.person.metadata.read

能力lightup.person.metadata.read(方案 L2 起)

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

分頁是有界的limit 預設 20、上限 100。用 cursor 取下一頁。 沒有「全部拉下來」的參數,也沒有任意 JSON 查詢(計畫 §6:v1 只有 key lookup + 有界分頁)。

冪等:GET。

參數

名稱 位置 必填 型別 說明
personId path PersonTypeId 人員的 TypeID(psn_…)。不可自行拼裝或解析;只使用平台給過你的值 (ADR-003 §1、計畫 §6)。 pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$
limit query integer 每頁筆數。預設 20,上限 100。超過上限 → 422。 預設 20;最小 1;最大 100
cursor query string 上一頁回應的 nextCursor。不透明字串,不要解析;換版本後形狀可能改變。 最長 512

回應

狀態 說明
200 OK
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
404 資源不存在,存在但呼叫端連它存不存在都不該知道。
422 請求格式對,但內容不可接受:額度用盡、參數超界、schema 不符。
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

200 範例

範例 default

{
  "items": [
    {
      "extensionId": "rehab_progress",
      "schemaVersion": 3,
      "etag": "\"17\"",
      "updatedAt": "2026-09-20T08:31:55Z",
      "updatedBy": "tp_asiauni_rehab"
    }
  ],
  "nextCursor": null
}

GET /v1/persons/{personId}/extensions/{extensionId}

operationId getPersonExtension 能力 lightup.person.metadata.read

能力lightup.person.metadata.read(方案 L2 起)

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

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

冪等:GET。

參數

名稱 位置 必填 型別 說明
personId path PersonTypeId 人員的 TypeID(psn_…)。不可自行拼裝或解析;只使用平台給過你的值 (ADR-003 §1、計畫 §6)。 pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$
extensionId path ExtensionId 登記時由平台配發的 extension id。不可用會改名的功能名稱當身分(計畫 §6)。 未登記或不屬於呼叫端 → 404。 pattern ^[a-z][a-z0-9_]{2,63}$

回應

狀態 說明
200 OK
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
404 資源不存在,存在但呼叫端連它存不存在都不該知道。
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

200 範例

範例 default

{
  "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"
}

PUT /v1/persons/{personId}/extensions/{extensionId}

operationId putPersonExtension 能力 lightup.person.metadata.write

能力lightup.person.metadata.write(方案 L2 起)

整筆取代,不是 patch:data 就是新的完整內容。

並行控制(必讀)

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

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.erroralready_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,重試不重複計量。

參數

名稱 位置 必填 型別 說明
personId path PersonTypeId 人員的 TypeID(psn_…)。不可自行拼裝或解析;只使用平台給過你的值 (ADR-003 §1、計畫 §6)。 pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$
extensionId path ExtensionId 登記時由平台配發的 extension id。不可用會改名的功能名稱當身分(計畫 §6)。 未登記或不屬於呼叫端 → 404。 pattern ^[a-z][a-z0-9_]{2,63}$
If-Match header string 更新既有資料時使用,值是上次讀到的 ETag(RFC 9110 §13.1.1)。 required: false 是因為 OpenAPI 無法表達「If-MatchIf-None-Match 恰好擇一」。這不代表可以兩個都不帶——兩個都不帶或兩個都帶都是 428 precondition_required。建立新資料請改用 If-None-Match: *不接受 *(schema 就擋掉)。If-Match: * 在 RFC 裡的意思是 「只要它存在」,那是 DELETE 的用法,拿來建立資料會是錯的。 pattern ^"[^"]+"$
If-None-Match header string 建立新資料時使用,唯一合法值是 *,意思是「只有在它還不存在時才寫」 (RFC 9110 §13.1.2)。 它已經存在 → 409 already_exists(不是 412,見 PUT 的說明)。 required: false 的理由同 If-Match:兩者恰好擇一,OpenAPI 表達不了, 所以由伺服器以 428 執行。 pattern ^\*$

請求 body

application/json,schema PersonExtensionWrite,必填。

範例 default

{
  "schemaVersion": 3,
  "data": {
    "lastSessionAt": "2026-09-22T09:00:00Z",
    "completedUnits": 13
  }
}

回應

狀態 說明
200 已更新
201 已建立
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
404 資源不存在,存在但呼叫端連它存不存在都不該知道。
409 你和別人撞上了。沒有任何東西被寫入。 兩種:
413 單筆 data 超過 16 KiB。縮小內容,或把大東西放在你自己的資料庫
422 請求格式對,但內容不可接受:額度用盡、參數超界、schema 不符。
428 這個寫入沒有表明它的前置條件(RFC 6585 §3)。
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

DELETE /v1/persons/{personId}/extensions/{extensionId}

operationId deletePersonExtension 能力 lightup.person.metadata.write

能力lightup.person.metadata.write(方案 L2 起)

冪等:已經不存在也回 204(計畫 §6「刪除可冪等」)。

If-Match 必填required: true,schema 驗得到):

「已經不存在」與「存在但版本不符」不同:前者 204(冪等),後者 409。 DELETE 不看 If-None-Match;送了會被忽略。

刪除釋放筆數額度,但不會回填任何歷史;要保留請先匯出。

參數

名稱 位置 必填 型別 說明
personId path PersonTypeId 人員的 TypeID(psn_…)。不可自行拼裝或解析;只使用平台給過你的值 (ADR-003 §1、計畫 §6)。 pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$
extensionId path ExtensionId 登記時由平台配發的 extension id。不可用會改名的功能名稱當身分(計畫 §6)。 未登記或不屬於呼叫端 → 404。 pattern ^[a-z][a-z0-9_]{2,63}$
If-Match header string 必填required: true,schema 驗得到;DELETE 只有這一個前置條件標頭, 不存在擇一的問題)。 - "<etag>" — 只刪那一版;版本不符 → 409 revision_conflict - * — 不論版本都刪(RFC 9110 §13.1.1 的標準語意:「只要它存在」) 省略 → 428 precondition_required。已經不存在 → 204(冪等)。 pattern `^(*

回應

狀態 說明
204 已刪除,或原本就不存在(冪等)
401 token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符
403 token 有效,但現在不准做這件事。重試不會變成允許——
404 資源不存在,存在但呼叫端連它存不存在都不該知道。
409 你和別人撞上了。沒有任何東西被寫入。 兩種:
428 這個寫入沒有表明它的前置條件(RFC 6585 §3)。
429 限流。限流有兩層:tenant 與 service account/使用者
503 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同:

共用錯誤回應

Unauthorized

401 — token 本身不可用:沒帶、無效、過期、簽章錯、audisstyp 不符 (例如把 ID token 或第一方 token 拿來用)。

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

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

ℹ️ delegatedshared profile 的 token 不在這裡被擋:它們的簽章 是對的,會走完驗證再由授權決定拒絕,所以拿到的是 403 profile_not_supported

範例 idTokenUsed — 拿 ID token 當 access token

{
  "error": "invalid_token",
  "message": "token is not an access token",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh2j"
}

Forbidden

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 沒有指出誰在操作:selfactor_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 範圍——delegatedshared 落在這裡 改用本人(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_mismatchno_relationship 打在 person 資源上時會變成 404, 不是 403——否則 403 與 404 的差別本身就是一支「這個 person 存不存在」的 探測器(ADR-004 D9)。不透露資源是否存在是刻意的。

ℹ️ tenant_suspended 不折成 404,即使打在 person 資源上也一樣回 403。 它講的是你的租戶的狀態,不是目標資源存不存在,所以據此推不出 任何關於那個 person 的事——沒有要遮蔽的東西。

範例 missingCapability

{
  "error": "capability_not_granted",
  "message": "this application is not granted lightup.person.metadata.write",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh3k"
}

範例 planTooLow

{
  "error": "plan_capability_excluded",
  "message": "the tenant plan does not include this capability",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh4m"
}

NotFound

404 — 資源不存在,存在但呼叫端連它存不存在都不該知道。 兩者刻意回同一個答案(ADR-004 D9)。

範例 default

{
  "error": "not_found",
  "message": "resource not found",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh5n"
}

Conflict

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

本 API 用 409 表達所有衝突,不用 412。 RFC 9110 對失敗的前置條件 給的是 412;收斂成單一狀態碼讓 client 只要分辨 Problem.error 就夠。 「前置條件標頭根本沒帶或帶重複」不是衝突,是 428,見下。

範例 conflict

{
  "error": "revision_conflict",
  "message": "the resource was modified; re-read and retry",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh6p"
}

範例 alreadyExists

{
  "error": "already_exists",
  "message": "the extension already exists; read its ETag and use If-Match",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh7q"
}

PreconditionRequired

428 — 這個寫入沒有表明它的前置條件(RFC 6585 §3)。

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

沒有任何東西被寫入,而且重試同樣的請求永遠是同樣的結果—— 這是 client 的 bug,不是暫時狀況。本 API 沒有無條件覆寫的操作: 那正是遺失更新的來源(計畫 §6)。

範例 missing — 兩個都沒帶

{
  "error": "precondition_required",
  "message": "provide exactly one of If-Match or If-None-Match",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh7q"
}

範例 both — 兩個都帶

{
  "error": "precondition_required",
  "message": "If-Match and If-None-Match are mutually exclusive",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh8r"
}

PayloadTooLarge

413 — 單筆 data 超過 16 KiB。縮小內容,或把大東西放在你自己的資料庫 (計畫 §6:平台 metadata 不存照片與無界列表)。

範例 default

{
  "error": "payload_too_large",
  "message": "extension data exceeds 16 KiB",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh8r"
}

UnprocessableEntity

422 — 請求格式對,但內容不可接受:額度用盡、參數超界、schema 不符。

額度用盡不會刪任何資料。 既有資料照常可讀、可刪、可匯出。

範例 quota

{
  "error": "quota_exceeded",
  "message": "tenant reached 50000 records for this extension",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzh9s"
}

範例 schema

{
  "error": "schema_invalid",
  "message": "data does not match the registered extension schema",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzj1t"
}

TooManyRequests

429 — 限流。限流有兩層:tenant 與 service account/使用者 (計畫 §7)。兩層都可能觸發,回應不會告訴你是哪一層。

Retry-After 再重試。合法的大量同步不會被丟棄,會被要求放慢 ——需要更高額度請升級方案,別用更多帳號繞過(那會在 tenant 層再撞一次)。

範例 default

{
  "error": "rate_limited",
  "message": "too many requests",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzj2u"
}

AuthorizationUnavailable

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

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

兩者都不是拒絕。平台 fail-closed:判不了就不放行,而不是降級成只驗簽 (計畫 §5)。都可以重試,請用指數退避;Retry-After 是建議不是承諾。

403 和 503 永遠不會長得一樣:前者是「不准」,後者是「現在不知道」。

範例 default

{
  "error": "authorization_unavailable",
  "message": "authorization service temporarily unavailable; retry",
  "requestId": "req_01j8m4k2r7e9v3xq5w8n0tzj3v"
}

Schema

TokenRequest

application/x-www-form-urlencoded。三種 grant 各自需要的欄位不同, 所以用 oneOf 分開——送錯組合在 schema 層就看得出來,不必等伺服器回 400。

型別

ClientCredentialsRequest

機器/背景。得到 workload token,沒有 refresh_tokenclient_secret 可改放 Authorization: Basic;兩種擇一,不要同時送。

欄位 型別 必填 說明
grant_type string
client_id ClientId
client_secret string service account 的機器憑證。用 client_secret_basic 時省略。 不要放進版本控制、前端 bundle 或 log。
scope ScopeString

未列出的欄位不被接受(additionalProperties: false)。

AuthorizationCodeRequest

使用者授權完成後,用一次性 code 換 token。得到 self token。 PKCE 必填——第三方 public 與 confidential client 都要。

欄位 型別 必填 說明
grant_type string
client_id ClientId
client_secret string confidential client 才有;public client 不送。
code string /oauth2/authorize 導回時帶的一次性授權碼。用過就失效。
code_verifier string PKCE verifier,43–128 字元。授權請求送的是它的 S256 雜湊 (code_challenge),plain 不支援。 最長 128
redirect_uri string 必須與授權請求送出的那一個完全相同,且已登記在 client 上。 format uri

未列出的欄位不被接受(additionalProperties: false)。

RefreshTokenRequest

續期使用者 token。嚴格 rotation:每次成功都會換一張新的 refresh token,舊的立即失效;重放已消耗的那一張會殺掉整個 family (第三方不適用任何放寬,見 ADR-004 D1)。

欄位 型別 必填 說明
grant_type string
client_id ClientId
client_secret string confidential client 才有。
refresh_token string 上一次回應給的那一張。不透明字串,不要解析。
scope 可選,只能縮小原本的 scope,不能放大。省略就沿用原本的。

未列出的欄位不被接受(additionalProperties: false)。

ClientId

第三方 client id,形狀 tp_<tenant label>_<slug>,由 amygdala 配發。

型別 string。pattern ^tp_[a-z0-9]+(?:-[a-z0-9]+)*_[a-z0-9]+(?:-[a-z0-9]+)*$

ScopeString

空白分隔的能力清單(OAuth 慣例,不是逗號)。 實際拿到的 scope 是各層交集後的結果,可能比請求的少—— 以回應的 scope 欄位為準,不要假設拿到的就是送出去的。

型別 string

TokenResponse

RFC 6749 §5.1 的 token 回應。

欄位 型別 必填 說明
access_token string profile v2 access token(JWS,header typ: at+jwtalg: ES256)。 當成不透明字串使用:要知道自己是誰請呼叫 GET /v1/me, 不要在 client 自行解析 claims 做授權判斷。
token_type string 一律 Bearer。放進 Authorization: Bearer <access_token>
expires_in integer 剩餘秒數。v1 是 900(15 分)。不要寫死這個數字—— 照回應的值算到期時間。
scope 實際生效的能力,已是請求 ∩ grant ∩ 方案上限的結果。 可能少於你請求的;以這裡為準。
refresh_token string 只有 authorization_coderefresh_token 會回。 client_credentials 不回——機器到期直接再換一張。 每次續期都會換新的,舊的立即失效。

未列出的欄位不被接受(additionalProperties: false)。

TokenError

RFC 6749 §5.2 的錯誤形狀。與本 API 其他端點的 Problem 不同—— 那是 OAuth 的既有契約,刻意保持原樣,不要把兩者混在同一個解析路徑。

欄位 型別 必填 說明
error string 列舉:invalid_clientinvalid_grantinvalid_scopeunsupported_grant_type
error_description string 給人看的說明,字串隨時可能改;程式判斷請用 error

未列出的欄位不被接受(additionalProperties: false)。

Problem

所有錯誤共用的形狀(RFC 9457 的精神,欄位刻意極簡)。 error穩定代碼,程式判斷請用它;message 是給人看的,隨時可能改字。

欄位 型別 必填 說明
error string 穩定的機器可讀代碼。 列舉:invalid_tokenclient_unknownclient_disabledactor_unknowntenant_suspendedclient_not_third_partyprofile_not_supportedsubject_revokedepoch_changedcapability_not_grantedplan_capability_excludedcapability_not_in_tokencapability_unavailableresource_type_mismatchtenant_mismatchno_relationshipforbiddennot_foundrevision_conflictalready_existsprecondition_requiredpayload_too_largequota_exceededschema_invalidrate_limitedauthorization_unavailabletoken_verification_unavailable
message string 人類可讀說明。不含資源是否存在的線索、內部路徑或任何秘密。
requestId string x-request-id。回報問題請附上。

未列出的欄位不被接受(additionalProperties: false)。

PersonTypeId

型別 string。pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$

TenantTypeId

型別 string。pattern ^ten_[0-9a-hjkmnp-tv-z]{26}$

ExtensionId

型別 string。pattern ^[a-z][a-z0-9_]{2,63}$

Capability

能力 id。完整清單是版本化的能力目錄(crates/lightup-types/capabilities.toml), 對外由文件網站發布,每項標明說明、資源型別、最低方案等級與可用狀態 (planned / preview / ga)。v1 的五項全部是 preview

型別 string。pattern ^lightup\.[a-z][a-z0-9.]*$

Me

這張 token 代表誰。

欄位 型別 必填 說明
subjectType string 列舉:personservice_account
subjectId string psn_…(person)或 acc_…(service account,kind=service)。
tenantId TenantTypeId
clientId string 第三方 client id,形狀 tp_<tenant label>_<slug>,全域唯一。 pattern ^tp_[a-z0-9]+(?:-[a-z0-9]+)*_[a-z0-9]+(?:-[a-z0-9]+)*$
scopes array<Capability> 這張 token 實際生效的能力(已經是各層交集後的結果)。
actor object 實際執行操作的帳號。
actor.accountId string acc_…。人與 service account 同一個前綴,靠 subjectType 區分。

未列出的欄位不被接受(additionalProperties: false)。

Tenant

租戶的公開識別欄位。只有「這是誰」,沒有「它現在怎麼樣」。 不含租戶設定、方案等級、聯絡人、網域設定、社群 provider、LDAP, 也不含營運狀態(見下)。additionalProperties: false—— 內部欄位(例如 amygdala 的 slug)不會出現在這裡。

欄位 型別 必填 說明
tenantId TenantTypeId
displayName string

未列出的欄位不被接受(additionalProperties: false)。

Person

最少欄位。要更多欄位是新的能力與新的 grant,不是在這裡加參數。

欄位 型別 必填 說明
personId PersonTypeId
displayName string 租戶記錄的顯示名稱。不保證唯一,也不是身分證明。
createdAt string format date-time

未列出的欄位不被接受(additionalProperties: false)。

PersonExtensionWrite

寫入內容。data 整筆取代。 不要放照片、逐題事件、無界列表,或任何被當成權限/餘額/權益/身份綁定的欄位。

欄位 型別 必填 說明
schemaVersion integer 必須是這個 extension 登記過的版本之一。 最小 1
data object 序列化後 ≤ 16 KiB,超過回 413。

未列出的欄位不被接受(additionalProperties: false)。

PersonExtension

欄位 型別 必填 說明
extensionId ExtensionId
personId PersonTypeId
schemaVersion integer 最小 1
etag string 同回應的 ETag 標頭。寫入時原封不動放進 If-Match
data object
createdAt string format date-time
updatedAt string format date-time
updatedBy string 最後寫入的 client id(不是使用者)。 要追到人,用 x-request-id 查稽核紀錄。

未列出的欄位不被接受(additionalProperties: false)。

PersonExtensionSummary

列表用的摘要,不含 data。要內容請用 key lookup 逐筆取。

欄位 型別 必填 說明
extensionId ExtensionId
schemaVersion integer 最小 1
etag string
updatedAt string format date-time
updatedBy string

未列出的欄位不被接受(additionalProperties: false)。

PersonExtensionPage

欄位 型別 必填 說明
items array<PersonExtensionSummary> 最多 100 筆
nextCursor string | null 還有下一頁時是不透明字串;沒有下一頁時是 null不要解析它。

未列出的欄位不被接受(additionalProperties: false)。

安全機制

bearerAuth

LightUp profile-v2 access token。Authorization: Bearer <token>

Gateway 驗這些,缺一不可:

用錯 token(ID token、第一方 storefront token、內部 x-system-token)一律 401

oauth2AuthorizationCode

使用者授權(互動式)。public client 強制 PKCE S256; 第三方 confidential client 也必填 PKCE(不吃第一方的相容路徑)。

發出的 token 是 profile v2 self——代表登入的本人,而且只有本人。 v1 不簽發代理(delegated)token,同意頁也沒有「選擇被代理對象」這一步。 refresh 走嚴格 rotation:重放已消耗的 refresh 會殺掉整個 family。 第三方不會拿到 reuse grace、sliding 或 absolute-max 放寬。

flow authorizationCode

scope 說明
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

機器/背景操作。主體是 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 是要做的事,不是現況)。

flow clientCredentials

scope 說明
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 讀租戶公開識別欄位