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_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。
請求 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 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 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 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 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 起)
只回三個欄位: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。
參數
| 名稱 | 位置 | 必填 | 型別 | 說明 |
|---|---|---|---|---|
personId |
path | 是 | PersonTypeId | 人員的 TypeID(psn_…)。不可自行拼裝或解析;只使用平台給過你的值 (ADR-003 §1、計畫 §6)。 pattern ^psn_[0-9a-hjkmnp-tv-z]{26}$ |
回應
| 狀態 | 說明 |
|---|---|
| 200 | OK |
| 401 | token 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 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 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 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 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 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-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,重試不重複計量。
參數
| 名稱 | 位置 | 必填 | 型別 | 說明 |
|---|---|---|---|---|
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-Match 與 If-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 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 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 驗得到):
If-Match: "<etag>"— 只在還是那一版時才刪。版本不符 → 409revision_conflictIf-Match: *— 不管哪一版都刪(RFC 9110 §13.1.1 的標準*語意: 「只要它存在」。這裡合用,因為刪除本來就只對存在的東西有意義)- 省略 → 428
precondition_required
「已經不存在」與「存在但版本不符」不同:前者 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 本身不可用:沒帶、無效、過期、簽章錯、aud/iss/typ 不符 |
| 403 | token 有效,但現在不准做這件事。重試不會變成允許—— |
| 404 | 資源不存在,或存在但呼叫端連它存不存在都不該知道。 |
| 409 | 你和別人撞上了。沒有任何東西被寫入。 兩種: |
| 428 | 這個寫入沒有表明它的前置條件(RFC 6585 §3)。 |
| 429 | 限流。限流有兩層:tenant 與 service account/使用者 |
| 503 | 平台現在無法判斷你有沒有權限。兩種,分開是因為要修的地方不同: |
共用錯誤回應
Unauthorized
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。
範例 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 沒有指出誰在操作: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 的事——沒有要遮蔽的東西。
範例 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 — 你和別人撞上了。沒有任何東西被寫入。 兩種:
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,見下。
範例 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_token。 client_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+jwt、alg: 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_code 與 refresh_token 會回。 client_credentials 不回——機器到期直接再換一張。 每次續期都會換新的,舊的立即失效。 |
未列出的欄位不被接受(additionalProperties: false)。
TokenError
RFC 6749 §5.2 的錯誤形狀。與本 API 其他端點的 Problem 不同—— 那是 OAuth 的既有契約,刻意保持原樣,不要把兩者混在同一個解析路徑。
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
error |
string | 是 | 列舉:invalid_client、invalid_grant、invalid_scope、unsupported_grant_type |
error_description |
string | 否 | 給人看的說明,字串隨時可能改;程式判斷請用 error。 |
未列出的欄位不被接受(additionalProperties: false)。
Problem
所有錯誤共用的形狀(RFC 9457 的精神,欄位刻意極簡)。 error 是穩定代碼,程式判斷請用它;message 是給人看的,隨時可能改字。
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
error |
string | 是 | 穩定的機器可讀代碼。 列舉:invalid_token、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、forbidden、not_found、revision_conflict、already_exists、precondition_required、payload_too_large、quota_exceeded、schema_invalid、rate_limited、authorization_unavailable、token_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 | 是 | 列舉:person、service_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 驗這些,缺一不可:
- 簽章 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
使用者授權(互動式)。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
- authorizationUrl:
https://auth.lightup.tech/oauth2/authorize - tokenUrl:
https://auth.lightup.tech/oauth2/token - refreshUrl:
https://auth.lightup.tech/oauth2/token
| 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
- tokenUrl:
https://auth.lightup.tech/oauth2/token
| 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 |
讀租戶公開識別欄位 |