diff --git a/.changeset/destructive-ops-rbac-mapping.md b/.changeset/destructive-ops-rbac-mapping.md
new file mode 100644
index 0000000000..126044f515
--- /dev/null
+++ b/.changeset/destructive-ops-rbac-mapping.md
@@ -0,0 +1,16 @@
+---
+"@objectstack/plugin-security": minor
+"@objectstack/spec": patch
+---
+
+feat(security): pre-map `transfer`/`restore`/`purge` to their RBAC bits (#1883)
+
+The permission evaluator now maps the destructive record-lifecycle operations
+to their spec permission bits (`transfer` → `allowTransfer`, `restore` →
+`allowRestore`, `purge` → `allowPurge`) and extends the `modifyAllRecords`
+super-user bypass to cover them. The ObjectQL operations themselves are still
+roadmap M2 — but the gate now exists ahead of them: the moment such an
+operation is dispatched through the security middleware it is denied unless a
+resolved permission set grants the matching bit. Unmapped destructive
+operations continue to fail closed (ADR-0049). Spec descriptions updated from
+`[EXPERIMENTAL — not enforced]` to `[RBAC-gated; operation pending M2]`.
diff --git a/.changeset/require-auth-secure-by-default.md b/.changeset/require-auth-secure-by-default.md
new file mode 100644
index 0000000000..f5fa8f57a1
--- /dev/null
+++ b/.changeset/require-auth-secure-by-default.md
@@ -0,0 +1,45 @@
+---
+"@objectstack/spec": major
+"@objectstack/rest": major
+"@objectstack/verify": patch
+"@objectstack/cli": patch
+---
+
+feat(security)!: `api.requireAuth` now defaults to `true` — anonymous access to the data API is denied by default (ADR-0056 D2 flip)
+
+**BREAKING.** The global `requireAuth` default flipped FROM `false` TO `true`
+(`RestApiConfigSchema.requireAuth` in `@objectstack/spec`, mirrored by
+`RestServer.normalizeConfig` in `@objectstack/rest`). Anonymous requests to
+the `/data/*` CRUD + batch endpoints are now rejected with HTTP 401 unless the
+deployment explicitly opts out. (Scope note: this gate covers the REST
+`/data/*` surface — the metadata read/write endpoints and the dispatcher
+GraphQL route have their own pre-existing anonymous posture, tracked
+separately; this flip does not change them.)
+
+**Migration (one line):** a deployment that intentionally serves data publicly
+(demo / playground / kiosk) sets the flag on the stack config — now a declared
+`ObjectStackDefinitionSchema.api` field, so it survives `defineStack` strict
+parsing (previously an undeclared top-level `api` key was silently stripped):
+
+```ts
+export default defineStack({
+ // …
+ api: { requireAuth: false },
+});
+```
+
+The REST plugin logs a boot warning for the explicit opt-out so a fail-open
+posture is always visible. A misplaced `api.requireAuth` at the plugin level
+(one nesting short) is now also called out with a boot warning instead of
+being silently ignored.
+
+**What keeps working with no action:**
+
+- **Share links** — validate their token, then read under a system context.
+- **Public forms** — self-authorizing via the declaration-derived
+ `publicFormGrant` (create + read-back on the declared target object only);
+ no `guest_portal` profile needed.
+- **Control plane** — `/auth`, `/health`, `/discovery` are exempt.
+- **`objectstack serve` with an auth-less stack** — the CLI passes an explicit
+ `requireAuth: false` for stacks whose tier set has no `auth` (nothing could
+ authenticate against them), with the boot warning.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9695f5b52a..a3b0354176 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed — Security: anonymous access denied by default (BREAKING) + destructive-op RBAC gates
+
+- **`api.requireAuth` now defaults to `true`** (ADR-0056 D2 flip): anonymous
+ requests to `/data/*` CRUD + batch endpoints are rejected with HTTP 401
+ unless the deployment explicitly opts out with `api: { requireAuth: false }`
+ (the REST plugin logs a boot warning for the explicit opt-out). Share-links,
+ public forms (via the declaration-derived `publicFormGrant`), and the
+ control plane (`/auth`, `/health`, `/discovery`) keep working with no
+ action. `objectstack serve` passes an explicit `false` only for stacks with
+ no `auth` tier (nothing could authenticate against them). Conformance row
+ `requireAuth-default-flip` → `enforced`.
+- **`transfer` / `restore` / `purge` are RBAC-gated ahead of the ops** (#1883):
+ the permission evaluator maps them to `allowTransfer` / `allowRestore` /
+ `allowPurge` (with the `modifyAllRecords` super-user bypass), so when the
+ M2 operations ship there is no ungated window. Unmapped destructive
+ operations keep failing closed (ADR-0049).
+
### Added — ObjectUI one-month frontend scan + backend alignment summary
Scanning `../objectui` from 2026-05-08 through 2026-06-08 shows a broad Studio
diff --git a/content/docs/concepts/implementation-status.mdx b/content/docs/concepts/implementation-status.mdx
index 2647e826d1..ab683a9f99 100644
--- a/content/docs/concepts/implementation-status.mdx
+++ b/content/docs/concepts/implementation-status.mdx
@@ -287,7 +287,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
- Client SDK supports bearer token header — but token validation requires the auth plugin
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
- Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin
-- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still falls open by default** (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when `requireAuth` is unset, and public forms no longer depend on the fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
+- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` now accepts the canonical OWD vocabulary (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) alongside the legacy `read` / `read_write` / `full` spellings (D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `roles` (#2054). Permission sets may declare `isDefault: true` to act as the app-declared fallback profile (D7).
---
@@ -429,7 +429,7 @@ cloud / EE.
- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). `object.sharingModel` accepts the canonical OWD vocabulary alongside the legacy `read` / `read_write` / `full` spellings (D1)
- [x] Sharing Rule evaluator — owner + criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); recipients include user / role / group and configurable `role_and_subordinates` role-hierarchy widening (ADR-0056 D6)
- [x] App-declarable default profile — a permission set may set `isDefault: true` to be the fallback profile for unassigned users (ADR-0056 D7)
-- [ ] Default-deny for anonymous traffic — boot-time warning lands when `requireAuth` is unset (ADR-0056 D2) and public forms self-authorize via `publicFormGrant` (Option A); the global default-deny **flip** is release-gated
+- [x] Default-deny for anonymous traffic — the global default-deny **flip landed** (ADR-0056 D2): `requireAuth` defaults to `true`, explicit `requireAuth: false` opt-outs warn at boot, and public forms self-authorize via `publicFormGrant` (Option A)
- [ ] Studio RLS visual editor
- [ ] Per-user×org permission cache
- [ ] Audit UI / denied-access logging
diff --git a/content/docs/guides/cheatsheets/permissions-matrix.mdx b/content/docs/guides/cheatsheets/permissions-matrix.mdx
index 38cb7326f4..c61684373b 100644
--- a/content/docs/guides/cheatsheets/permissions-matrix.mdx
+++ b/content/docs/guides/cheatsheets/permissions-matrix.mdx
@@ -30,7 +30,11 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
| **Modify All** | `modifyAllRecords` | Edit/delete all records regardless of ownership | Full object access (bypass sharing) |
-**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
+**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle class `allowTransfer`/`allowRestore`/`allowPurge`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
+
+
+
+**Lifecycle operations are pending:** the `transfer` / `restore` / `purge` operations do not exist in ObjectQL yet (roadmap M2). Their RBAC gate is already mapped in the permission evaluator — the moment the operations ship they are denied unless the matching flag (or `modifyAllRecords`) is granted — but authoring these flags today grants nothing (#1883).
---
diff --git a/content/docs/guides/security.mdx b/content/docs/guides/security.mdx
index 44d4cd2400..61337996a8 100644
--- a/content/docs/guides/security.mdx
+++ b/content/docs/guides/security.mdx
@@ -7,7 +7,7 @@ description: "Complete guide to implementing enterprise-grade security in Object
Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls.
-> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope: `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still falls open by default** (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when `requireAuth` is unset, and public forms self-authorize via a declaration-derived `publicFormGrant` (ADR-0056 Option A — see [Public Forms](./public-forms)). Organization-Wide Defaults (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) and Sharing Rules (owner + criteria, with `role_and_subordinates` hierarchy widening) are live and dogfood-proven (ADR-0056); the Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.
+> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope: `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms self-authorize via a declaration-derived `publicFormGrant` (ADR-0056 Option A — see [Public Forms](./public-forms)). Organization-Wide Defaults (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) and Sharing Rules (owner + criteria, with `role_and_subordinates` hierarchy widening) are live and dogfood-proven (ADR-0056); the Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.
## Table of Contents
diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx
index bcf6b2563a..59c61e7f5f 100644
--- a/content/docs/protocol/objectql/security.mdx
+++ b/content/docs/protocol/objectql/security.mdx
@@ -100,9 +100,9 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
| Flag | Meaning |
| --- | --- |
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | Standard CRUD |
-| `allowTransfer` | Change record ownership |
-| `allowRestore` | Restore from trash (undelete) |
-| `allowPurge` | Permanently delete (hard delete / GDPR) |
+| `allowTransfer` | Change record ownership — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
+| `allowRestore` | Restore from trash (undelete) — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
+| `allowPurge` | Permanently delete (hard delete / GDPR) — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
| `viewAllRecords` | Read every record, bypassing sharing & ownership |
| `modifyAllRecords` | Write every record, bypassing sharing & ownership |
diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx
index 65a4ff8866..923420de3a 100644
--- a/content/docs/references/api/rest-server.mdx
+++ b/content/docs/references/api/rest-server.mdx
@@ -192,7 +192,7 @@ const result = BatchEndpointsConfig.parse(data);
| **enableOpenApi** | `boolean` | ✅ | Enable OpenAPI 3.1 spec & docs viewer endpoints |
| **enableProjectScoping** | `boolean` | ✅ | Enable project-scoped routing for data/meta/AI APIs |
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | ✅ | Project ID resolution strategy |
-| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on /data/* with HTTP 401 |
+| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on /data/* with HTTP 401 (secure-by-default; set false to serve data publicly) |
| **documentation** | `Object` | optional | OpenAPI/Swagger documentation config |
| **responseFormat** | `Object` | optional | Response format options |
diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx
index e62a73b4cc..2544554b34 100644
--- a/content/docs/references/security/permission.mdx
+++ b/content/docs/references/security/permission.mdx
@@ -68,9 +68,9 @@ const result = FieldPermission.parse(data);
| **allowRead** | `boolean` | ✅ | Read permission |
| **allowEdit** | `boolean` | ✅ | Edit permission |
| **allowDelete** | `boolean` | ✅ | Delete permission |
-| **allowTransfer** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Change record ownership |
-| **allowRestore** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Restore from trash (Undelete) |
-| **allowPurge** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Permanently delete (Hard Delete/GDPR) |
+| **allowTransfer** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Change record ownership |
+| **allowRestore** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
+| **allowPurge** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
| **viewAllRecords** | `boolean` | ✅ | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | ✅ | Modify All Data (Bypass Sharing) |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own|unit|unit_and_below|org |
diff --git a/docs/adr/0056-permission-model-landing-verification.md b/docs/adr/0056-permission-model-landing-verification.md
index 86efad5905..d141b85ce6 100644
--- a/docs/adr/0056-permission-model-landing-verification.md
+++ b/docs/adr/0056-permission-model-landing-verification.md
@@ -199,7 +199,7 @@ The decisions landed incrementally, each with a runtime proof:
| :-- | :-- | :-- |
| OWD scenarios (private + public-read) | ✅ landed | `showcase-private-owd`, `showcase-public-read-owd` dogfood |
| D1 — canonical OWD vocabulary | ✅ landed | `plugin-sharing` units + OWD dogfood |
-| D2 — anonymous deny (warn) | ✅ landed (warn) | `showcase-anonymous-deny` dogfood; **flip release-gated** |
+| D2 — anonymous deny (default flip) | ✅ landed (**enforced**) | `showcase-anonymous-deny` dogfood on the platform default; spec `requireAuth` `default(true)` |
| D4 — RLS compiler no-silent-drop | ✅ landed | `plugin-security` units |
| D6 — configurable role hierarchy | ✅ landed | `RoleGraphService` units (`role_and_subordinates`) |
| D7 — app-declared default profile | ✅ landed | `showcase-default-profile` dogfood |
@@ -223,4 +223,15 @@ pre-flip audit of the legitimate anonymous surfaces found:
**Pre-requisite for the flip:** make public-form submission self-authorizing (grant INSERT
on the form's declared target object) or ship a built-in `guest_portal`, then warn→enforce
-with a migration note. Until then, D2's boot WARN is the landed state.
+with a migration note.
+
+**Flip status (2026-07): LANDED.** The pre-requisite was satisfied by the
+declaration-derived `publicFormGrant` (Option A — self-authorizing public-form submission,
+proven by `showcase-public-form` / `form-self-auth` dogfood). The global default is now
+deny: spec `requireAuth` `default(true)` + `rest-server.ts` `?? true`. An explicit
+`requireAuth: false` opt-out remains available for deployments that intentionally serve
+data publicly and is surfaced with a boot warning (the previous warn-state behavior,
+now scoped to the explicit opt-out). The CLI keeps one carve-out: a stack with no `auth`
+tier cannot authenticate anyone, so `objectstack serve` passes an explicit `false` for it
+(warned). The verify harness boots on the platform default, so every dogfood proof runs
+under the flipped posture. Conformance row: `requireAuth-default-flip` → `enforced`.
diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md
index 31d4174e06..d218c13ffc 100644
--- a/docs/adr/0066-unified-authorization-model.md
+++ b/docs/adr/0066-unified-authorization-model.md
@@ -106,3 +106,5 @@ Captured for the record; **out of scope for Phases 1–4 above**. Each is anchor
- **⑤ Per-operation `requiredPermissions`.** Today object-level `requiredPermissions` gates all of CRUD. ERP routinely needs "read-open / write-gated" (Salesforce & Dataverse separate capability by operation). Allow `requiredPermissions` to be either `string[]` (all operations) or a per-operation map `{ read, create, update, delete }`. Field-level (D3) and action-level (D4) requirements already give finer control; this closes the object-level gap.
- **⑥ Capabilities in the expression surface.** Salesforce *Custom Permissions* are referenceable in formulas / validation / flows (`$Permission.X`). Expose the caller's held capabilities to the CEL/predicate surface (ADR-0058) so `visible` / validation / sharing predicates can branch on a capability. High-leverage once D1 makes capabilities first-class.
- **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9).
+- **⑧ FLS posture: unlisted fields are visible, and field grants union without deny** *(recorded 2026-07 pre-launch architecture assessment)*. Runtime FLS is block-list-shaped: `field-masker.ts` hides a field only when an explicit rule marks it `readable:false`, so an **undeclared** sensitive field is exposed by default, and `getFieldPermissions` merges most-permissively, so one set's `readable:true` permanently out-votes another's `false`. Consequences: protecting a salary/ID-number field relies on discipline (grant it only where needed), not mechanism. The object-level `private` posture (D2/④) mitigates the unlisted-default-visible half for objects that adopt it; the union half is the field-level face of ⑦ — a muting/deny layer must cover **field** grants, not just object grants, when it lands. Until then, treat "sensitive field on a `public` object" as a smell in review.
+- **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer).
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 8a9486474e..ccca3e8429 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -1381,11 +1381,20 @@ export default class Serve extends Command {
// `api.enforceProjectMembership: false`. Undefined → dispatcher default.
const enforceProjectMembership = apiConfig.enforceProjectMembership;
// `requireAuth: true` rejects anonymous requests on `/api/v1/data/*`
- // with HTTP 401 before they reach ObjectQL. Default-on when the
- // stack opts in OR when the resolved tier set includes `auth`
- // (because anonymous data access is almost never desirable when
- // auth is enabled). Apps can override via stack `api.requireAuth`.
- const requireAuth = apiConfig.requireAuth ?? (tierEnabled('auth') ? true : false);
+ // with HTTP 401 before they reach ObjectQL. The platform default is
+ // now secure-by-default (ADR-0056 D2): deny anonymous. The CLI keeps
+ // one deliberate carve-out — a stack with NO `auth` tier has no way
+ // to authenticate anyone, so denying would brick its data API
+ // entirely; such auth-less playgrounds get an EXPLICIT `false`
+ // (fail-open), which the REST plugin surfaces with a boot warning.
+ // Apps can always override via stack `api.requireAuth`.
+ // Auth availability = tier auto-registers it OR the stack mounts
+ // AuthPlugin explicitly (hasAuthPlugin, computed above). Keying only on
+ // the tier would hand an explicit fail-open to a stack that ships auth
+ // via `plugins:` under a minimal tier set — re-opening the very hole
+ // the flip closes. Only a stack with NO auth at all gets the carve-out.
+ const requireAuth = apiConfig.requireAuth
+ ?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false);
try {
const { createRestApiPlugin } = await import('@objectstack/rest');
diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts
index a0d53e0f1c..aead0f2d05 100644
--- a/packages/client/src/client.environment-scoping.test.ts
+++ b/packages/client/src/client.environment-scoping.test.ts
@@ -44,6 +44,9 @@ describe('Project-scoped REST routing (live Hono)', () => {
createRestApiPlugin({
api: {
api: {
+ // Routing test, no auth stack mounted — opt out of the
+ // secure-by-default anonymous deny (ADR-0056 D2).
+ requireAuth: false,
enableProjectScoping: true,
projectResolution: 'auto',
} as any,
diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts
index 4739de7379..89d71a53eb 100644
--- a/packages/dogfood/test/authz-conformance.matrix.ts
+++ b/packages/dogfood/test/authz-conformance.matrix.ts
@@ -78,10 +78,13 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'field-encryption', summary: 'at-rest field encryption', state: 'experimental', note: 'no crypto provider reads the config; marked [EXPERIMENTAL] (D8)' },
{ id: 'data-masking', summary: 'role-based data masking', state: 'experimental', note: 'FLS is the enforced field-visibility path; marked [EXPERIMENTAL] (D8)' },
{ id: 'rls-config-global', summary: 'global RLSConfig / RLSAuditEvent', state: 'experimental', note: 'not read by the RLS path; marked [EXPERIMENTAL] (D8)' },
- { id: 'requireAuth-default-flip', summary: 'flip the global requireAuth default to secure-by-default', state: 'experimental',
- note: 'D2 warn landed; the flip is release-gated. Pre-req: built-in guest_portal so public forms survive (share-links already read as SYSTEM after token validation, so they are safe).' },
+ { id: 'requireAuth-default-flip', summary: 'global requireAuth default is secure-by-default (deny anonymous)', state: 'enforced',
+ enforcement: 'spec/api/rest-server.zod.ts requireAuth default(true) + rest/rest-server.ts normalizeConfig ?? true; explicit requireAuth:false opt-out warns at boot (rest-api-plugin)',
+ proof: 'showcase-anonymous-deny.dogfood.test.ts',
+ note: 'ADR-0056 D2 flip LANDED. The verify harness boots on the platform default (no override), so anonymous-deny AND public-form survival (showcase-public-form.dogfood.test.ts — the publicFormGrant pre-req that unblocked the flip) are proven on the default posture. Share-links read as SYSTEM after token validation. CLI carve-out: auth-less stacks get an explicit fail-open (warned).' },
// ── Removed — by ADR-0049 (roadmap M2) ─────────────────────────────────
- { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops', state: 'removed', note: 'ADR-0049 → roadmap M2' },
+ { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed',
+ note: 'ADR-0049 → roadmap M2. #1883: the ops still do not exist in ObjectQL, but the evaluator PRE-MAPS them (OPERATION_TO_PERMISSION transfer/restore/purge → allowTransfer/allowRestore/allowPurge, modifyAllRecords bypass, unmapped destructive ops fail closed) — there is no ungated window when the ops ship. Unit-proven in plugin-security/security-plugin.test.ts.' },
{ id: 'flow-run-as', summary: 'flow runAs', state: 'removed', note: 'ADR-0049 → roadmap M2' },
];
diff --git a/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts b/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts
index 43eba8e53b..599590b453 100644
--- a/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts
+++ b/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts
@@ -1,11 +1,13 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// ADR-0056 D2 — secure-by-default (anonymous deny) posture, proven on the real
-// showcase HTTP stack. With `requireAuth` on, an UNAUTHENTICATED request to the
-// data API is rejected (401), while authenticated members are unaffected and the
-// control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). This
-// is the enforcement capability D2 builds on; the framework does NOT flip the
-// global default — it makes the deny posture available + warns when it is off.
+// showcase HTTP stack ON THE PLATFORM DEFAULT: the verify harness passes no
+// `requireAuth` override, so this proves the flipped global default
+// (spec `requireAuth` default(true)) rejects an UNAUTHENTICATED request to the
+// data API (401), while authenticated members are unaffected and the
+// control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call).
+// Public forms survive the same default via the declaration-derived
+// publicFormGrant — see showcase-public-form.dogfood.test.ts.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
@@ -18,7 +20,7 @@ describe('showcase: anonymous default-deny (ADR-0056 D2)', () => {
let memberToken: string;
beforeAll(async () => {
- stack = await bootStack(showcaseStack); // harness runs requireAuth: true
+ stack = await bootStack(showcaseStack); // harness boots on the platform default (deny anonymous)
await stack.signIn();
memberToken = await stack.signUp('d2-member@verify.test'); // anonymous /auth call → proves control-plane is open
}, 60_000);
diff --git a/packages/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/dogfood/test/showcase-public-form.dogfood.test.ts
index a045827cda..97e45800ee 100644
--- a/packages/dogfood/test/showcase-public-form.dogfood.test.ts
+++ b/packages/dogfood/test/showcase-public-form.dogfood.test.ts
@@ -4,7 +4,8 @@
// PUBLIC FORM. `views/inquiry.view.ts` declares a FormView with
// `sharing.allowAnonymous: true` + `publicLink: '/forms/contact-us'`, which
// wires the anonymous REST endpoints. This exercises them end-to-end over the
-// real HTTP stack — the harness boots with `requireAuth: true`, so a passing
+// real HTTP stack — the harness boots on the platform DEFAULT, which is now
+// secure-by-default deny (ADR-0056 D2 flip), so a passing
// anonymous submit proves the route works under SECURE-BY-DEFAULT auth, with
// NO `guest_portal` profile, authorized solely by the declaration-derived
// `publicFormGrant` (create + read-back on `showcase_inquiry` ONLY).
diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts
index 275b1abd26..cdbde08ffb 100644
--- a/packages/plugins/plugin-dev/src/dev-plugin.ts
+++ b/packages/plugins/plugin-dev/src/dev-plugin.ts
@@ -528,6 +528,7 @@ export class DevPlugin implements Plugin {
// as part of its manifest), so no separate child plugin is needed.
// 4. Auth Plugin
+ let authMounted = false;
if (enabled('auth')) {
try {
const { AuthPlugin } = await import('@objectstack/plugin-auth') as any;
@@ -536,6 +537,7 @@ export class DevPlugin implements Plugin {
baseUrl: this.options.authBaseUrl,
});
this.childPlugins.push(authPlugin);
+ authMounted = true;
ctx.logger.info(' ✔ Auth plugin enabled (dev credentials)');
} catch {
ctx.logger.warn(' ✘ @objectstack/plugin-auth not installed — skipping auth');
@@ -601,9 +603,21 @@ export class DevPlugin implements Plugin {
if (enabled('rest')) {
try {
const { createRestApiPlugin } = await import('@objectstack/rest') as any;
- const restPlugin = createRestApiPlugin();
+ // Secure-by-default carve-out (mirrors `objectstack serve`, ADR-0056
+ // D2): when NO auth mounted in this dev stack (service disabled or
+ // plugin-auth not installed), nobody could ever authenticate — the
+ // deny default would brick the local playground's data API. Pass an
+ // EXPLICIT fail-open for that case; the REST plugin's boot warning
+ // keeps the posture visible.
+ const restPlugin = authMounted
+ ? createRestApiPlugin()
+ : createRestApiPlugin({ api: { api: { requireAuth: false } } as any });
this.childPlugins.push(restPlugin);
- ctx.logger.info(' ✔ REST API endpoints enabled (CRUD + metadata)');
+ ctx.logger.info(
+ authMounted
+ ? ' ✔ REST API endpoints enabled (CRUD + metadata)'
+ : ' ✔ REST API endpoints enabled (CRUD + metadata; anonymous ALLOWED — no auth mounted)',
+ );
} catch {
ctx.logger.debug(' ℹ @objectstack/rest not installed — skipping REST endpoints');
}
diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts
index f2a248a743..75f2822250 100644
--- a/packages/plugins/plugin-security/src/permission-evaluator.ts
+++ b/packages/plugins/plugin-security/src/permission-evaluator.ts
@@ -3,7 +3,13 @@
import type { PermissionSet, ObjectPermission, FieldPermission } from '@objectstack/spec/security';
/**
- * Operation type mapping to permission checks
+ * Operation type mapping to permission checks.
+ *
+ * `transfer`/`restore`/`purge` are pre-mapped to their RBAC bits (#1883) even
+ * though the ObjectQL operations do not exist yet (roadmap M2): the moment such
+ * an operation is dispatched through the security middleware it is gated by the
+ * corresponding `allow*` bit — deny unless a resolved permission set grants it.
+ * There is no window where the ops could ship ungated.
*/
const OPERATION_TO_PERMISSION: Record = {
find: 'allowRead',
@@ -13,19 +19,39 @@ const OPERATION_TO_PERMISSION: Record = {
insert: 'allowCreate',
update: 'allowEdit',
delete: 'allowDelete',
+ transfer: 'allowTransfer',
+ restore: 'allowRestore',
+ purge: 'allowPurge',
};
/**
* Destructive operation class — operations that must FAIL CLOSED when they are
* not mapped to a concrete permission key. See ADR-0049: an unrecognised
- * destructive operation (e.g. a future `transfer`/`restore`/`purge` added
- * without a matching `OPERATION_TO_PERMISSION` entry, gated by the spec's
- * `allowTransfer`/`allowRestore`/`allowPurge` bits) must be DENIED rather than
- * silently allowed by the default-allow fallthrough. Non-destructive unknown
- * operations retain default-allow so custom read-side operations are not broken.
+ * destructive operation must be DENIED rather than silently allowed by the
+ * default-allow fallthrough. `transfer`/`restore`/`purge` are now mapped above
+ * (#1883), so this set acts as a backstop: it keeps them (and any future
+ * destructive op prefixed here before its mapping lands) fail-closed if the
+ * mapping is ever removed. Non-destructive unknown operations retain
+ * default-allow so custom read-side operations are not broken.
*/
const DESTRUCTIVE_OPERATIONS = new Set(['transfer', 'restore', 'purge']);
+/**
+ * Permission keys covered by the `modifyAllRecords` super-user WRITE bypass:
+ * edit/delete plus the destructive lifecycle class, DERIVED from the two
+ * constants above so a future destructive op added to the map+set is covered
+ * automatically (hand-listing it inline is how bypass gaps happen — #1883).
+ * NOTE this means "Modify All Data" grants (incl. the wildcard on
+ * organization_admin / admin_full_access defaults) will cover
+ * transfer/restore/purge the moment the M2 ops ship — Salesforce semantics,
+ * confirmed in the #1883 disposition; revisit per-op when M2 lands.
+ */
+const MODIFY_ALL_WRITE_KEYS = new Set([
+ 'allowEdit',
+ 'allowDelete',
+ ...[...DESTRUCTIVE_OPERATIONS].map((op) => OPERATION_TO_PERMISSION[op]),
+]);
+
/**
* [ADR-0066 D2] Resolve the object permission a permission set contributes for
* `objectName`, honouring the secure-by-default posture:
@@ -82,8 +108,9 @@ export class PermissionEvaluator {
// but a `private` object is excluded from a non-super-user wildcard.
const objPerm = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
if (objPerm) {
- // Check if modifyAllRecords is set (super-user bypass for write ops)
- if (['allowEdit', 'allowDelete'].includes(permKey) && objPerm.modifyAllRecords) {
+ // Super-user WRITE bypass ("Modify All Data") — covers edit/delete and
+ // the destructive lifecycle class (see MODIFY_ALL_WRITE_KEYS).
+ if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) {
return true;
}
// Check if viewAllRecords is set (super-user bypass for read ops)
diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts
index 8998510059..c1daa0b670 100644
--- a/packages/plugins/plugin-security/src/security-plugin.test.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.test.ts
@@ -292,6 +292,36 @@ describe('SecurityPlugin', () => {
expect(harness.findOne).toHaveBeenCalledTimes(1);
});
+ it('DENIES a purge of a not-owned row via the pre-image check (#1883 — destructive ops inherit row-level gating)', async () => {
+ // The destructive lifecycle class (transfer/restore/purge) is pre-wired
+ // into OPERATION_TO_PERMISSION, so it clears the object-level RBAC gate.
+ // The record-level pre-image RLS check must therefore ALSO cover it —
+ // otherwise a grant-holder could destroy out-of-scope rows by id. purge
+ // maps onto the `delete` RLS class.
+ const purgerSet: PermissionSet = {
+ name: 'purger', label: 'Purger', isProfile: true,
+ objects: { '*': { allowRead: true, allowPurge: true } },
+ rowLevelSecurity: [
+ { name: 'owner_only_deletes', object: '*', operation: 'delete', using: 'created_by = current_user.id' },
+ ],
+ } as any;
+ const plugin = new SecurityPlugin({ fallbackPermissionSet: 'purger' });
+ const harness = makeMiddlewareCtx({
+ permissionSets: [purgerSet],
+ objectFields: ownerFields,
+ findOneImpl: () => null, // row exists but not owned → filtered out → deny
+ });
+ await plugin.init(harness.ctx);
+ await plugin.start(harness.ctx);
+ const opCtx: any = {
+ object: 'task', operation: 'purge',
+ options: { where: { id: 'r1' } },
+ context: memberCtx,
+ };
+ await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' });
+ expect(harness.findOne).toHaveBeenCalledTimes(1);
+ });
+
it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => {
const adminSet: PermissionSet = {
name: 'admin_full_access', label: 'Admin', isProfile: true,
@@ -940,17 +970,40 @@ describe('PermissionEvaluator', () => {
expect(evaluator.checkObjectPermission('unknownOp', 'contact', [])).toBe(true);
});
- it('should fail CLOSED for unmapped destructive operations (ADR-0049)', () => {
+ it('denies transfer/restore/purge without the matching RBAC bit (#1883)', () => {
const evaluator = new PermissionEvaluator();
- // transfer/restore/purge are not in OPERATION_TO_PERMISSION; they must be
- // denied rather than falling through to default-allow — even for an
- // otherwise fully-permissioned set.
- const ps = makePermSet('admin', {
- contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true },
+ // Full CRUD does NOT imply the destructive lifecycle class: each op is
+ // gated by its own bit (allowTransfer/allowRestore/allowPurge) and must
+ // be denied when the bit is absent — never default-allow (ADR-0049).
+ const ps = makePermSet('member', {
+ contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
});
expect(evaluator.checkObjectPermission('transfer', 'contact', [ps])).toBe(false);
expect(evaluator.checkObjectPermission('restore', 'contact', [ps])).toBe(false);
expect(evaluator.checkObjectPermission('purge', 'contact', [ps])).toBe(false);
+ // …and an empty permission-set list denies too (fail-closed baseline).
+ expect(evaluator.checkObjectPermission('purge', 'contact', [])).toBe(false);
+ });
+
+ it('allows transfer/restore/purge via their specific RBAC bits (#1883)', () => {
+ const evaluator = new PermissionEvaluator();
+ const transferOnly = makePermSet('t', { contact: { allowTransfer: true } });
+ const restoreOnly = makePermSet('r', { contact: { allowRestore: true } });
+ const purgeOnly = makePermSet('p', { contact: { allowPurge: true } });
+ expect(evaluator.checkObjectPermission('transfer', 'contact', [transferOnly])).toBe(true);
+ expect(evaluator.checkObjectPermission('restore', 'contact', [restoreOnly])).toBe(true);
+ expect(evaluator.checkObjectPermission('purge', 'contact', [purgeOnly])).toBe(true);
+ // A bit on one op never leaks to another.
+ expect(evaluator.checkObjectPermission('purge', 'contact', [transferOnly])).toBe(false);
+ expect(evaluator.checkObjectPermission('transfer', 'contact', [purgeOnly])).toBe(false);
+ });
+
+ it('modifyAllRecords super-user bypass covers transfer/restore/purge (#1883)', () => {
+ const evaluator = new PermissionEvaluator();
+ const admin = makePermSet('admin', { contact: { modifyAllRecords: true } });
+ expect(evaluator.checkObjectPermission('transfer', 'contact', [admin])).toBe(true);
+ expect(evaluator.checkObjectPermission('restore', 'contact', [admin])).toBe(true);
+ expect(evaluator.checkObjectPermission('purge', 'contact', [admin])).toBe(true);
});
it('should allow via viewAllRecords', () => {
diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts
index 7425a55078..c66d465459 100644
--- a/packages/plugins/plugin-security/src/security-plugin.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.ts
@@ -433,7 +433,7 @@ export class SecurityPlugin implements Plugin {
const sc: any = opCtx.context;
if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
sc.__readScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate });
- } else if (opCtx.operation === 'update' || opCtx.operation === 'delete') {
+ } else if (['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)) {
sc.__writeScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate });
}
}
@@ -457,17 +457,28 @@ export class SecurityPlugin implements Plugin {
// applies — e.g. an admin set with no RLS, or `modifyAllRecords`) the
// check is skipped and behaviour is unchanged.
if (
- (opCtx.operation === 'update' || opCtx.operation === 'delete') &&
+ // update/delete today; transfer/restore/purge are pre-wired (#1883) so
+ // the M2 ops inherit the pre-image check the moment they dispatch —
+ // the CRUD bit alone must never be the only row-level defense.
+ ['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) &&
permissionSets.length > 0 &&
!!opCtx.context?.userId &&
this.ql
) {
const targetId = this.extractSingleId(opCtx);
if (targetId != null) {
+ // RLS policies declare select/insert/update/delete — map the
+ // destructive lifecycle class onto its nearest write class so
+ // authored policies apply (purge destroys like delete;
+ // transfer/restore mutate like update).
+ const rlsOperation =
+ opCtx.operation === 'purge' ? 'delete'
+ : opCtx.operation === 'transfer' || opCtx.operation === 'restore' ? 'update'
+ : opCtx.operation;
const writeFilter = await this.computeRlsFilter(
permissionSets,
opCtx.object,
- opCtx.operation,
+ rlsOperation,
opCtx.context,
);
if (writeFilter) {
@@ -504,7 +515,7 @@ export class SecurityPlugin implements Plugin {
// authored RLS, so the #1994 pre-image check above is a no-op for it; this
// closes the by-id write path by checking the master instead.
if (
- (opCtx.operation === 'insert' || opCtx.operation === 'update' || opCtx.operation === 'delete') &&
+ ['insert', 'update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) &&
permissionSets.length > 0 &&
!!opCtx.context?.userId &&
this.ql
diff --git a/packages/rest/src/analytics-routes.test.ts b/packages/rest/src/analytics-routes.test.ts
index 095d0bfe1e..81f8a4b8ab 100644
--- a/packages/rest/src/analytics-routes.test.ts
+++ b/packages/rest/src/analytics-routes.test.ts
@@ -33,7 +33,7 @@ const selection = { dimensions: ['region'], measures: ['revenue'] };
function buildServer(analyticsProvider?: any) {
const server = mockServer();
const rest = new RestServer(
- server as any, mockProtocol() as any, {} as any,
+ server as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined,
analyticsProvider,
diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts
index 073162db08..e315376458 100644
--- a/packages/rest/src/export-integration.test.ts
+++ b/packages/rest/src/export-integration.test.ts
@@ -185,7 +185,7 @@ async function boot() {
await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u2' });
const protocol = new ObjectStackProtocolImplementation(engine as any);
- const rest = new RestServer(createMockServer() as any, protocol as any);
+ const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
rest.registerRoutes();
const route = rest.getRoutes().find(
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export',
diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts
index ae82f94b74..4a7e7ebb46 100644
--- a/packages/rest/src/import-integration.test.ts
+++ b/packages/rest/src/import-integration.test.ts
@@ -123,7 +123,7 @@ async function boot() {
await engine.insert('user', { id: 'u2', name: '李四', email: 'li@x.com' });
const protocol = new ObjectStackProtocolImplementation(engine as any);
- const rest = new RestServer(createMockServer() as any, protocol as any);
+ const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
rest.registerRoutes();
const route = rest.getRoutes().find(
(r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import',
diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts
index eadc62bc48..b053d75cff 100644
--- a/packages/rest/src/import-job-integration.test.ts
+++ b/packages/rest/src/import-job-integration.test.ts
@@ -129,7 +129,7 @@ async function boot() {
engine.registry.registerObject(SYS_IMPORT_JOB as any);
const protocol = new ObjectStackProtocolImplementation(engine as any);
- const rest = new RestServer(createMockServer() as any, protocol as any);
+ const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
rest.registerRoutes();
const routes = rest.getRoutes();
const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path);
diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts
index 3dfd8d8a90..401146dc47 100644
--- a/packages/rest/src/rest-api-plugin.ts
+++ b/packages/rest/src/rest-api-plugin.ts
@@ -214,17 +214,27 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
ctx.logger.info('REST API successfully registered');
- // ADR-0056 D2 (warn → enforce): surface the fail-open posture.
- // When `requireAuth` is off, anonymous requests reach the data API
- // and read any object with no OWD/RLS — secure-by-default would deny
- // them and route public access through share-links / `publicSharing`.
- // We do NOT flip the default here (it would break deployments that
- // rely on anonymous reads); we make the posture explicit instead.
- if (!(config.api as any)?.requireAuth) {
+ // ADR-0056 D2 (warn → enforce, ENFORCED): the global default is
+ // secure-by-default — anonymous /data/* is denied unless the
+ // deployment explicitly opts out. The warning remains for that
+ // explicit opt-out so a fail-open posture is always visible.
+ if ((config.api as any)?.api?.requireAuth === false) {
ctx.logger.warn(
- '[security] anonymous access to the data API is ALLOWED (api.requireAuth=false) — ' +
- 'objects without OWD/RLS are world-readable. For secure-by-default set ' +
- 'api.requireAuth=true and expose public records via share-links / publicSharing (ADR-0056 D2).',
+ '[security] anonymous access to the data API is ALLOWED (api.requireAuth=false, explicit opt-out) — ' +
+ 'objects without OWD/RLS are world-readable. Remove the opt-out for secure-by-default and ' +
+ 'expose public records via share-links / publicSharing / public forms (ADR-0056 D2).',
+ );
+ }
+ // Misplaced-key guard: the effective key is `api.api.requireAuth`
+ // (RestApiPluginConfig.api is the full RestServerConfig). A flat
+ // `api.requireAuth` is silently ignored by normalizeConfig — under
+ // the deny default that turns an INTENDED public deployment into a
+ // 401 outage with no diagnostic, so name the mistake loudly.
+ if ((config.api as any)?.requireAuth !== undefined) {
+ ctx.logger.warn(
+ '[security] `api.requireAuth` is set at the WRONG nesting level and has NO effect — ' +
+ 'move it to `api.api.requireAuth` (RestServerConfig.api.requireAuth). ' +
+ `The effective value this boot is ${(config.api as any)?.api?.requireAuth ?? true}.`,
);
}
} catch (err: any) {
diff --git a/packages/rest/src/rest-auth-gate.test.ts b/packages/rest/src/rest-auth-gate.test.ts
index 3748ccfc9a..34e3c0fd15 100644
--- a/packages/rest/src/rest-auth-gate.test.ts
+++ b/packages/rest/src/rest-auth-gate.test.ts
@@ -20,7 +20,7 @@ const makeRes = () => {
return { res, state };
};
-const rest = new RestServer(server, protocol, { requireAuth: false } as any);
+const rest = new RestServer(server, protocol, { api: { requireAuth: false } } as any);
describe('RestServer.enforceAuth — ADR-0069 auth-policy gate', () => {
const gate = (req: any, context: any) => {
diff --git a/packages/rest/src/rest-exec-ctx-memo.test.ts b/packages/rest/src/rest-exec-ctx-memo.test.ts
index ecd1c21e00..0619af8fda 100644
--- a/packages/rest/src/rest-exec-ctx-memo.test.ts
+++ b/packages/rest/src/rest-exec-ctx-memo.test.ts
@@ -17,7 +17,7 @@ const protocol: any = {};
describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () => {
it('resolves once per request object and returns the cached instance', async () => {
- const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
+ const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any);
let calls = 0;
rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; };
@@ -30,7 +30,7 @@ describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', ()
});
it('re-resolves for a different request object', async () => {
- const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
+ const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any);
let calls = 0;
rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; };
@@ -41,7 +41,7 @@ describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', ()
});
it('keys the memo by environmentId within the same request', async () => {
- const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
+ const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any);
let calls = 0;
rest.computeExecCtx = async (env: any) => { calls++; return { env }; };
@@ -54,7 +54,7 @@ describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', ()
});
it('caches an anonymous (undefined) resolution so repeat callers do not re-resolve', async () => {
- const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
+ const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any);
let calls = 0;
rest.computeExecCtx = async () => { calls++; return undefined; };
diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts
index 65c497067e..14c8c5e469 100644
--- a/packages/rest/src/rest-server.ts
+++ b/packages/rest/src/rest-server.ts
@@ -1708,7 +1708,7 @@ export class RestServer {
enableSearch: (api as any).enableSearch ?? true,
enableProjectScoping: api.enableProjectScoping ?? false,
projectResolution: api.projectResolution ?? 'auto',
- requireAuth: (api as any).requireAuth ?? false,
+ requireAuth: (api as any).requireAuth ?? true, // secure-by-default (ADR-0056 D2; mirrors RestApiConfigSchema)
documentation: api.documentation,
responseFormat: api.responseFormat,
},
diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts
index fcdf007420..a7f8e721ba 100644
--- a/packages/rest/src/rest.test.ts
+++ b/packages/rest/src/rest.test.ts
@@ -75,6 +75,13 @@ function createMockPluginContext(services: Record = {}) {
/** Dummy handler */
const noop = vi.fn();
+// The platform default is secure-by-default (`requireAuth` defaults to true,
+// ADR-0056 D2). These unit tests dispatch route handlers with NO auth context
+// on purpose — they exercise routing/CRUD mechanics, not the auth gate (that
+// is covered by rest-auth-gate.test.ts + the anonymous-deny dogfood proof) —
+// so they opt out explicitly, like an intentionally-public deployment would.
+const ANON_API = { api: { requireAuth: false } };
+
// ---------------------------------------------------------------------------
// RouteManager
// ---------------------------------------------------------------------------
@@ -286,7 +293,7 @@ describe('RestServer', () => {
describe('constructor', () => {
it('should create a RestServer with default config', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
expect(rest).toBeDefined();
expect(rest.getRouteManager()).toBeInstanceOf(RouteManager);
});
@@ -303,7 +310,7 @@ describe('RestServer', () => {
describe('registerRoutes', () => {
it('should register discovery, metadata, UI, CRUD, and batch routes by default', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const routes = rest.getRoutes();
@@ -382,7 +389,7 @@ describe('RestServer', () => {
protocol.updateManyData = vi.fn().mockResolvedValue([]);
protocol.deleteManyData = vi.fn().mockResolvedValue([]);
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const batchRoutes = rest.getRoutes().filter((r) =>
@@ -392,7 +399,7 @@ describe('RestServer', () => {
});
it('should register UI view endpoint when enableUi is true', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const uiRoutes = rest.getRoutes().filter((r) =>
@@ -402,7 +409,7 @@ describe('RestServer', () => {
});
it('should not register i18n endpoints (i18n routes are self-registered by service-i18n)', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const i18nRoutes = rest.getRoutes().filter((r) =>
@@ -416,7 +423,7 @@ describe('RestServer', () => {
describe('getRouteManager', () => {
it('should return the internal RouteManager instance', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
const rm = rest.getRouteManager();
expect(rm).toBeInstanceOf(RouteManager);
});
@@ -424,7 +431,7 @@ describe('RestServer', () => {
describe('getRoutes', () => {
it('should return an empty array before registerRoutes is called', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
expect(rest.getRoutes()).toEqual([]);
});
});
@@ -438,7 +445,7 @@ describe('RestServer', () => {
}
it('should pass expand and select query params to protocol.getData', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const getByIdRoute = getRoute(rest, ':object/:id');
@@ -473,7 +480,7 @@ describe('RestServer', () => {
});
it('should omit expand/select when not present in query', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const getByIdRoute = getRoute(rest, ':object/:id');
@@ -510,13 +517,13 @@ describe('RestServer', () => {
const mockRes = () => ({ json: vi.fn(), status: vi.fn().mockReturnThis() });
it('registers the clone route alongside CRUD', () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
expect(cloneRoute(rest)).toBeDefined();
});
it('forwards object/id and nested overrides to protocol.cloneData and responds 201', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = cloneRoute(rest);
@@ -531,7 +538,7 @@ describe('RestServer', () => {
});
it('treats a bare body (no `overrides` key) as the override map', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = cloneRoute(rest);
@@ -545,7 +552,7 @@ describe('RestServer', () => {
});
it('maps a CLONE_DISABLED protocol error to 403', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = cloneRoute(rest);
@@ -562,7 +569,7 @@ describe('RestServer', () => {
it('returns 501 when the protocol does not implement cloneData', async () => {
const noClone = { ...protocol, cloneData: undefined };
- const rest = new RestServer(server as any, noClone as any);
+ const rest = new RestServer(server as any, noClone as any, ANON_API as any);
rest.registerRoutes();
const route = cloneRoute(rest);
@@ -588,7 +595,7 @@ describe('RestServer', () => {
});
it('GET /meta/:type forwards previewDrafts to protocol.getMetaItems', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type');
expect(route).toBeDefined();
@@ -603,7 +610,7 @@ describe('RestServer', () => {
});
it('GET /meta/:type omits previewDrafts without the flag', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type');
@@ -619,7 +626,7 @@ describe('RestServer', () => {
// A cached protocol would normally win; preview must skip it (ETags are
// keyed on the published checksum).
protocol.getMetaItemCached = vi.fn();
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name');
expect(route).toBeDefined();
@@ -641,7 +648,7 @@ describe('RestServer', () => {
// shipping the same type/name share one cache entry and the scope hint is
// silently lost.
protocol.getMetaItemCached = vi.fn();
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name');
expect(route).toBeDefined();
@@ -683,7 +690,7 @@ describe('RestServer', () => {
cacheControl: { directives: ['private', 'no-cache'] },
notModified: false,
});
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getMetaItemRoute(rest);
expect(route).toBeDefined();
@@ -710,7 +717,7 @@ describe('RestServer', () => {
cacheControl: { directives: ['public', 'max-age'], maxAge: 3600 },
notModified: false,
});
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getMetaItemRoute(rest);
@@ -734,7 +741,7 @@ describe('RestServer', () => {
}
it('should pass query params including expand to protocol.findData', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const listRoute = getListRoute(rest);
@@ -764,7 +771,7 @@ describe('RestServer', () => {
});
it('should pass populate query param to protocol.findData', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const listRoute = getListRoute(rest);
@@ -819,7 +826,7 @@ describe('RestServer', () => {
}
it('streams CSV with header row and quotes risky cells', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
expect(route).toBeDefined();
@@ -851,7 +858,7 @@ describe('RestServer', () => {
});
it('streams JSON array when format=json', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -872,7 +879,7 @@ describe('RestServer', () => {
});
it('rejects invalid JSON in filter query', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -885,7 +892,7 @@ describe('RestServer', () => {
});
it('honours the hard 50k row cap', async () => {
- const rest = new RestServer(server as any, protocol as any);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -955,7 +962,7 @@ describe('RestServer', () => {
it('formats values readably in CSV using field metadata', async () => {
const p = protocolWithSchema([RAW_TASK_ROW]);
- const rest = new RestServer(server as any, p as any);
+ const rest = new RestServer(server as any, p as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -972,7 +979,7 @@ describe('RestServer', () => {
it('omits the header row when header=false', async () => {
const p = protocolWithSchema([RAW_TASK_ROW]);
- const rest = new RestServer(server as any, p as any);
+ const rest = new RestServer(server as any, p as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -986,7 +993,7 @@ describe('RestServer', () => {
it('injects $expand for reference fields into the findData query', async () => {
const p = protocolWithSchema([RAW_TASK_ROW]);
- const rest = new RestServer(server as any, p as any);
+ const rest = new RestServer(server as any, p as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -1000,7 +1007,7 @@ describe('RestServer', () => {
it('formats values readably in JSON, leaving unknown keys untouched', async () => {
const p = protocolWithSchema([{ ...RAW_TASK_ROW, extra: 'keep' }]);
- const rest = new RestServer(server as any, p as any);
+ const rest = new RestServer(server as any, p as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -1016,7 +1023,7 @@ describe('RestServer', () => {
it('streams a valid xlsx workbook with formatted cells', async () => {
const p = protocolWithSchema([RAW_TASK_ROW]);
- const rest = new RestServer(server as any, p as any);
+ const rest = new RestServer(server as any, p as any, ANON_API as any);
rest.registerRoutes();
const route = getExportRoute(rest);
@@ -1047,7 +1054,7 @@ describe('RestServer', () => {
// `/data/