Skip to content

Commit b5492dc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21817-scoped-list-fallback
2 parents 1d226f1 + 9f9510f commit b5492dc

40 files changed

Lines changed: 1725 additions & 130 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
A permission set an organization owns, a clone of a packaged set, and a set saved into a writable runtime package are no longer locked as if a code package shipped them
6+
7+
Clause-②: no
8+
9+
The packaged-permission-set lock decides "is this set shipped by a code package?" from the engine registry. The registry also holds the stored definition rows, and a metadata list read (`GET /api/v1/meta/permission`, which every Studio page load issues) stamps a stored row's package binding onto it. A set saved into a writable runtime package (`PUT /api/v1/meta/permission/:name?package=<id>`) therefore looked code-shipped after the first list read, and every later edit of it answered `403 NOT_OVERRIDABLE` at both the metadata door and the data door. The lock now skips a stored row by its provenance (`_provenance: 'org'`, which every stored row carries), the same test the platform's code-artifact check applies, so those edits are accepted again.
10+
11+
The read had the matching defect. The security plugin keeps a marked in-memory copy of each stored definition for the permission evaluator, and the layered read (`GET /api/v1/meta/permission/:name/layers`) serves that copy as the item's `code` layer. The copy carried no provenance, so an org's own set, a clone and a runtime-package set all reported a `code` layer with no `provenance`, which the console's permission-matrix editor renders as "locked by a code package" while the server accepted the save. The copy now carries `_provenance: 'org'` exactly when the lock judges the set not code-shipped, so the layered read reports `provenance: 'org'` for those sets.
12+
13+
Unchanged: a set a code package ships is still refused at both doors with `403 NOT_OVERRIDABLE` and the same message naming the clone path, and its layered read still reports `provenance: 'package'`, its package id and `editable: false`. No error code, route or field moves.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
Phone-number OTP with no deliverable SMS service now answers `400 SMS_SERVICE_REQUIRED` instead of a `500` with an empty body (#21793).
7+
8+
Clause-②: yes (widening)
9+
10+
- **`@objectstack/plugin-auth`.** `POST /api/v1/auth/phone-number/send-otp` on a deployment that turned phone sign-in on but has no SMS service that can deliver a code (none wired, or only the log transport in production) used to answer `500` with a `null` body: the send callback threw a plain `Error`, and better-auth's router turns anything but its own `APIError` into a bare 500. The login page had nothing to branch on and showed a generic failure. It now answers `400` with the body `{ "code": "SMS_SERVICE_REQUIRED", "message": "…" }`, a typed `APIError`, as the daily-quota branch of the same send already was. The message names the missing SMS delivery service and where an administrator configures it, and never carries the one-time code. `request-password-reset` is unchanged: it still answers `{ "status": true }` and sends nothing, so it reveals nothing about which numbers are registered.
11+
- **`@objectstack/spec`.** `SMS_SERVICE_REQUIRED` is registered for `@objectstack/plugin-auth` in the ADR-0112 error-code ledger, beside its email sibling `EMAIL_SERVICE_REQUIRED`. `ErrorCode` (and so `ApiErrorSchema.code`) accepts one more value. Nothing that parsed before is refused now.
12+
13+
**Action for clients.** A client that branched on the old `500` for this case should branch on `code === 'SMS_SERVICE_REQUIRED'` instead. The public config already advertises the capability as `features.phoneNumberOtp`, which stays `false` on such a deployment.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
`reseed-sample-data` and `purge-sample-data` on an installed package that this runtime refused to load now answer `422 OS_PROTOCOL_INCOMPATIBLE` before they change anything. Before, both acted on such a package anyway.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** On a restart, a ledger entry whose `engines.protocol` range excludes this runtime is not loaded: nothing is registered, synced, bound or seeded for it. `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` on that entry loaded the package's translations into the i18n service and merged its seed datasets into the kernel's shared `seed-datasets` list, and then failed with `400 RESEED_SKIPPED` because the package's objects were never registered. `POST …/:manifestId/purge-sample-data` answered `200` with every record counted in `errors`, and set the ledger's `withSampleData` to `false` with no row deleted.
10+
- **What it does now.** Both doors run the protocol check on the ledger entry right after reading it. An entry whose declared range excludes this runtime gets the answer the install route gives the same manifest: `422`, `error.code` `OS_PROTOCOL_INCOMPATIBLE`, the check's own message, and `error.details` with `requiredRange`, `rangeSource`, `protocolVersion`, `targetMajor` and `migrateCommand`. No translation is loaded, no dataset is merged, no seed row is read or deleted, and the ledger is not written. The refusal comes before the organization check too, so a session with no active organization on a walled deployment also gets the `422` for such an entry.
11+
- **Unchanged.** An entry this runtime loads is answered exactly as before. An entry that declares no range, or a range the check cannot read, is admitted as before, with no new warning. `DELETE /api/v1/marketplace/install-local/:manifestId` still removes a refused entry, and installing a compatible version over it makes both doors act on it again.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,11 @@ const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-nu
384384
#### OTP sign-in and self-service reset (requires SMS delivery)
385385

386386
The OTP surface opens only when a **deliverable SMS service** is configured
387-
(`services.sms`, Setup → Settings → SMS Delivery); without one these endpoints
388-
fail loudly with `NOT_SUPPORTED`. The public config advertises real
387+
(`services.sms`, Setup → Settings → SMS Delivery). Without one — no SMS
388+
service wired, or only the log transport in production — `send-otp` answers
389+
`400` with `code: "SMS_SERVICE_REQUIRED"` and a message naming the fix, while
390+
`request-password-reset` keeps answering `{status:true}` (it never reveals
391+
whether a number is registered) and sends nothing. The public config advertises real
389392
availability as `features.phoneNumberOtp`, which is what the Console login UI
390393
gates its "Sign in with verification code" mode and the SMS reset branch on.
391394

@@ -756,7 +759,8 @@ The OTP surfaces — `POST /phone-number/send-otp` + `POST /phone-number/verify`
756759
(sign-in / verification) and `POST /phone-number/request-password-reset` +
757760
`POST /phone-number/reset-password` (self-service reset) — only work when an
758761
SMS service is wired (`@objectstack/service-sms` registers the `sms` kernel
759-
service). Without one, `send-otp` fails loudly instead of silently dropping
762+
service). Without one, `send-otp` fails loudly — `400` with
763+
`code: "SMS_SERVICE_REQUIRED"` — instead of silently dropping
760764
the code; phone sign-in is then password-based only (an admin sets the
761765
password — see Admin User Management below). OTP requests are rate-limited
762766
per phone number to prevent SMS-pumping abuse.

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -321,6 +321,7 @@ const result = ApiErrorSchema.parse(data);
321321
* `SHARE_REVOKE_FAILED`
322322
* `SHARING_NOT_ENABLED`
323323
* `SIGN_IN_REQUIRED`
324+
* `SMS_SERVICE_REQUIRED`
324325
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
325326
* `SSO_REGISTER_FAILED`
326327
* `SSO_REGISTER_FORBIDDEN`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ const result = ErrorCode.parse(data);
488488
* `SHARE_REVOKE_FAILED`
489489
* `SHARING_NOT_ENABLED`
490490
* `SIGN_IN_REQUIRED`
491+
* `SMS_SERVICE_REQUIRED`
491492
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
492493
* `SSO_REGISTER_FAILED`
493494
* `SSO_REGISTER_FORBIDDEN`

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,15 @@
151151
"title": "Every supported auth method signs in when enabled, is absent when disabled, and is advertised exactly as configured",
152152
"since": "v16",
153153
"status": "active",
154-
"revision": 4,
154+
"revision": 5,
155155
"priority": "P1",
156156
"surface": "mixed",
157157
"personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"],
158158
"fixtures": {
159159
"app": "showcase",
160160
"requires": [
161161
"per-variant env boots: the matrix flips AuthPluginConfigSchema flags (phoneNumber, twoFactor, deviceAuthorization, oidcProvider) and the socialProviders/oidcProviders maps — each variant names which boot it needs",
162-
"an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud NOT_SUPPORTED)"
162+
"an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud 400 SMS_SERVICE_REQUIRED)"
163163
],
164164
"knownGaps": [
165165
"magic-link and passkeys: the AuthPluginConfigSchema flags still exist, but objectui ships NO login UI for either, so #7481 withdrew both from the /api/v1/auth/config payload (protocol 17) — features.magicLink / features.passkeys are now ABSENT by design, not false. Run these variants as blocked(dependency, objectui#4179) at the browser lane. The flag-advertisement clause inverts here: seeing either key in the payload is itself a FAIL now, and magic-link's endpoints (/magic-link/send, /magic-link/verify) stay live and drivable without a UI",
@@ -171,7 +171,7 @@
171171
"for each variant: boot with the method configured ON, GET /api/v1/auth/config, and record the advertisement (features.* flag, emailPassword block, socialProviders list)",
172172
"email+password: POST /api/v1/auth/sign-up/email (when sign-up enabled), POST /api/v1/auth/sign-in/email, GET /api/v1/auth/get-session, POST /api/v1/auth/sign-out — capture each response and the session cookie lifecycle",
173173
"phone+password: create a phone-carrying user with a known password, POST the phone sign-in (better-auth /sign-in/phone-number surface — 'always works' when the plugin is on per the spec), verify get-session identifies the user",
174-
"phone OTP with NO SMS service configured: request /phone-number/send-otp and capture the loud NOT_SUPPORTED rejection (never a silent 200, never a hang)",
174+
"phone OTP with NO SMS service configured: request /phone-number/send-otp and capture the loud 400 SMS_SERVICE_REQUIRED rejection (never a silent 200, never a hang, never a bare 500)",
175175
"SSO/OIDC: with an oidcProviders[] entry configured, verify the login page shows the SSO button (features.sso is refined to 'usable' — ≥1 provider); drive the authorization-code round trip or cite the pinned OIDC dogfood test per rule 6",
176176
"device authorization: with deviceAuthorization on, drive the RFC 8628 flow (POST /device/code, approve via /device/approve in a signed-in browser, poll /device/token) against the LIVE server's routes, recording which path spelling the server actually serves",
177177
"2FA: with twoFactor on, enable it for a user (sys_user enable_two_factor action, gated on features.twoFactor), sign in, and verify the server-driven challenge (ADR-0069) interrupts before a session is granted",
@@ -200,9 +200,9 @@
200200
"evidence": "the sign-in + session trace"
201201
},
202202
{
203-
"clause": "phone OTP without a deliverable SMS service fails LOUDLY (NOT_SUPPORTED) — the capability degrades to a named error, never a silent success or a hang",
203+
"clause": "phone OTP without a deliverable SMS service fails LOUDLY (400 SMS_SERVICE_REQUIRED) — the capability degrades to a named error, never a silent success, a hang or a bare 500",
204204
"oracle": "api",
205-
"verify": "the send-otp response is a non-2xx carrying the not-supported error; features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)",
205+
"verify": "the send-otp response is 400 with a JSON body whose code is SMS_SERVICE_REQUIRED (pinned by packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts); features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)",
206206
"evidence": "the rejection + the /auth/config read"
207207
},
208208
{
@@ -244,15 +244,15 @@
244244
],
245245
"negative": [
246246
"a silent 200 on any disabled method's endpoint is a FAIL — a gate that only hides the button is not a gate",
247-
"phone OTP hanging or returning 2xx with no SMS service is a FAIL (the spec's own contract is 'loudly NOT_SUPPORTED')",
247+
"phone OTP hanging, returning 2xx, or answering a bare 500 with an empty body with no SMS service is a FAIL (the contract is a loud 400 SMS_SERVICE_REQUIRED)",
248248
"ticking magic-link or passkeys as pass at the browser lane is a false positive — there is no UI to drive (objectui#4179); the honest verdict is blocked",
249249
"features.magicLink or features.passkeys appearing in the /api/v1/auth/config payload is a FAIL — #7481 withdrew both until objectui#4179 ships the UI, so their return means the stop-advertising posture regressed",
250250
"a discovery document whose issuer/endpoints point at a base the server does not actually mount is a FAIL — a wrong .well-known breaks every downstream RP/relying party silently"
251251
],
252252
"variants": [
253253
"email+password (POST /api/v1/auth/sign-in/email, /sign-up/email, /sign-out, /get-session)",
254254
"phone+password (better-auth phone-number plugin sign-in surface)",
255-
"phone OTP sign-in + reset (requires SMS service; loud NOT_SUPPORTED without — #2780)",
255+
"phone OTP sign-in + reset (requires SMS service; loud 400 SMS_SERVICE_REQUIRED on send-otp without — #2780)",
256256
"enterprise SSO / generic OIDC (oidcProviders[] via genericOAuth; login button gated on usable providers)",
257257
"social OAuth (socialProviders map, per-provider enabled)",
258258
"device authorization grant (RFC 8628 — CLI/TV login)",
@@ -274,7 +274,8 @@
274274
{ "revision": 1, "date": "2026-08-07", "change": "new matrix item: per-method sign-in proof with both-sides gate checks and advertisement parity, grounded in the spec's plugin config + public feature registry", "ref": "claude/platform-test-checklist-ocwugl" },
275275
{ "revision": 2, "date": "2026-08-08", "change": "added the .well-known/openid-configuration + oauth-authorization-server discovery-document clause (issuer/endpoints match the mounted base, jwks cross-check) and self-service change-email + delete-user clauses; recorded the live-route divergences (device flow, password reset) from the spec paths (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" },
276276
{ "revision": 3, "date": "2026-08-20", "change": "scoped scan-functionality (扫描功能) sweep: the 2FA clause asserted the interrupt but cited no pin — added packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts to source and noted in the clause verify + variant row that cookie-lane completion is pinned end-to-end by that test (cite per rule 6 instead of re-deriving). Enrollment lifecycle around the gate now owned by the four new identity-auth.two-factor-* items. No restructuring", "ref": "claude/new-session-0pv25p" },
277-
{ "revision": 4, "date": "2026-10-05", "change": "clause 10 and step 9 re-pointed: the delete-user half only (stale, #21784 VF4). plugin-auth's auth-route-ledger books POST /api/v1/auth/delete-user with disposition 'disabled': user.deleteUser is deliberately unconfigured (auth-manager), per the maintainer ruling of 2026-08-12 on #7735, so it answers 404 to every caller. The clause dates from revision 1 (2026-08-07) and predates the ruling. The verifier's wording is used. The change-email half is unchanged", "ref": "#21797" }
277+
{ "revision": 4, "date": "2026-10-05", "change": "clause 10 and step 9 re-pointed: the delete-user half only (stale, #21784 VF4). plugin-auth's auth-route-ledger books POST /api/v1/auth/delete-user with disposition 'disabled': user.deleteUser is deliberately unconfigured (auth-manager), per the maintainer ruling of 2026-08-12 on #7735, so it answers 404 to every caller. The clause dates from revision 1 (2026-08-07) and predates the ruling. The verifier's wording is used. The change-email half is unchanged", "ref": "#21797" },
278+
{ "revision": 5, "date": "2026-10-05", "change": "clause 4, its negative, step 4, the fixtures row and the phone-OTP variant re-pointed from NOT_SUPPORTED to the shipped refusal: with no deliverable SMS service, send-otp answers 400 with code SMS_SERVICE_REQUIRED (registered for @objectstack/plugin-auth in the ADR-0112 ledger) instead of a 500 with an empty body (#21784 A4). A bare 500 is now named a FAIL. The clause verify cites the door pin", "ref": "#21793" }
278279
]
279280
},
280281
{

0 commit comments

Comments
 (0)