Skip to content

Commit bf7e011

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-8807-upsert-unnamed-unique-key-merge
2 parents bf46f63 + b50c0ef commit bf7e011

28 files changed

Lines changed: 2829 additions & 510 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(security): `sys_account.password` and `previous_password_hashes` stop serializing on the data API — `internal: true`, with the raw-engine readers converted to the privileged accessor (#8676)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) Two field-level flags
9+
added to one existing declaration, plus one new export on a plugin-internal
10+
helper module (`internal-field-readback.ts`, not an exported surface of the
11+
package). Nothing authorable is renamed, retired or tombstoned, so there is no
12+
conversion to register. The behavioural change is that two columns holding
13+
one-way password hashes stop being returned on the generic data path, while the
14+
ADR-0069 D1 reuse ring and better-auth's sign-in verifier keep reading them
15+
through the engine's privileged accessor. -->
16+
17+
`sys_account.password` (the credential hash) and `previous_password_hashes` (the
18+
ADR-0069 D1 reuse-prevention ring) serialized on `/api/v1/data/sys_account`,
19+
which declares `apiEnabled: true, apiMethods: ['get','list']` — to an **admin
20+
for every user's row**, and to a **member for their own** (the
21+
`sys_account_self` RLS policy grants `select` on `user_id == current_user.id`).
22+
23+
These are one-way hashes, not reversible outbound credentials — which is why
24+
#7987 correctly refused to bundle them with the OAuth tokens. But a served
25+
password hash is an offline-cracking target, and `previous_password_hashes`
26+
multiplies it by the history ring while its own declaration says it is *never
27+
exposed in UI*. This is the disposition #7728 already reached for
28+
`sys_api_key.key`, which was **also** a stored hash and was still ruled unfit to
29+
serialize through the API face.
30+
31+
Neither credential collector could reach them: `collectMaskedReadFields` keys on
32+
the field **TYPE** (`secret` / `password`) *and* exempts objects declaring
33+
`managedBy: 'better-auth'`, which this object is — while these columns are
34+
`text` / `textarea`. Two independent barriers, both missing.
35+
36+
**The fix is two declarations plus two recovery seams**, and the second seam is
37+
the part a bare flag would have missed:
38+
39+
- both columns are declared `internal: true` — the opt-in, type-independent flag
40+
from #7728 meaning *the declared value is never returned on the generic data
41+
path*. Storage, filtering and indexing are untouched: the strip runs on rows
42+
the driver has already produced.
43+
- **better-auth's adapter readers** are recovered by the existing per-object
44+
readback table, widened with `password`: the sign-in verifier compares against
45+
the hash on the row `internalAdapter.findCredentialAccount(userId)` returns,
46+
so the strip alone would break password sign-in for every user.
47+
- **plugin-auth's own RAW-engine readers** are recovered by a new seam in the
48+
same module, `recoverInternalFieldsForSystemRead`. This is the half that makes
49+
the flag safe: the readback table is imported by exactly one file
50+
(better-auth's storage adapter), so it cannot reach a caller that reads the
51+
engine directly — and the engine's strip has **no `isSystem` carve-out** by
52+
#7728's design. Measured against a real ObjectQL engine: the reuse ring's
53+
`findOne` returns `{"id":"a1"}` for a query that names both columns in an
54+
explicit projection under `context: { isSystem: true }`.
55+
56+
Left unrecovered, `assertPasswordNotReused` would become a **silent no-op** —
57+
its comparison list empties, the loop never runs, `PASSWORD_REUSE` is never
58+
thrown, and its own `catch { return undefined }` means nothing announces it.
59+
The ADR-0069 D1 control would report success while accepting every reused
60+
password. Its unit tests would have stayed green throughout, because they use
61+
fake engines that never apply the strip.
62+
63+
**No ADR-0100 guard change, and none was needed.** `Engine.resolveInternalField`
64+
has exactly one predicate — `internal === true` — so flagging the columns makes
65+
them legitimately dereferenceable through the privileged accessor. The ADR-0100
66+
sentence in its refusal message is prose explaining why a *non-flagged* field has
67+
other channels, not a second predicate; the guard stays exactly as selective as
68+
it was, and a non-flagged column on the same object is still refused with
69+
`INVALID_FIELD` / 400.
70+
71+
Regression proof drives both directions on a real booted stack: both columns are
72+
absent for both personas — including a caller who spells them out in `?select=` —
73+
while the values remain on disk and reachable through the privileged accessor,
74+
password sign-in still works, and the reuse ring still grows across a password
75+
change on every transport lane.

‎.claude/workflows/docs-accuracy-audit.js‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,13 @@ const ALL_HANDWRITTEN = [
9898
"content/docs/deployment/cli.mdx",
9999
"content/docs/deployment/environment-variables.mdx",
100100
"content/docs/deployment/index.mdx",
101-
"content/docs/deployment/migration-from-objectql.mdx",
102101
"content/docs/deployment/production-readiness.mdx",
103102
"content/docs/deployment/publish-and-preview.mdx",
104103
"content/docs/deployment/self-hosting.mdx",
105104
"content/docs/deployment/single-project-mode.mdx",
106105
"content/docs/deployment/tenancy-modes.mdx",
107106
"content/docs/deployment/troubleshooting.mdx",
108107
"content/docs/deployment/validating-metadata.mdx",
109-
"content/docs/deployment/vercel.mdx",
110108
"content/docs/getting-started/build-with-claude-code.mdx",
111109
"content/docs/getting-started/common-patterns.mdx",
112110
"content/docs/getting-started/examples.mdx",
@@ -212,6 +210,7 @@ const ALL_HANDWRITTEN = [
212210
"content/docs/ui/setup-app.mdx",
213211
"content/docs/ui/translations.mdx",
214212
"content/docs/ui/views.mdx",
213+
"content/docs/upgrading.mdx",
215214
]
216215
// </generated:docs-audit-scope>
217216

‎.github/workflows/lint.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,18 @@ jobs:
540540
- name: Release-notes drift guard
541541
run: pnpm check:release-notes
542542

543+
# Release-page STATUS guard (#8892). The guard above proves a released
544+
# major has a page; it never reads what the page SAYS. Both of the last
545+
# two GA cuts shipped a page still describing itself as a pre-release —
546+
# v17 for a day ("the train is preparing to ship"), v16 for three weeks
547+
# ("currently published as 16.0.0-rc.0") — with CI fully green each time,
548+
# found only because a human read the page. This asserts that for every
549+
# GA'd major from v16 on, the status blockquote and the index entry both
550+
# describe a shipped release. Scoped to v16+ by maintainer ruling; the
551+
# gate prints that cutoff and its reason on both the OK and failure paths.
552+
- name: Release-page status guard
553+
run: pnpm check:release-page-status
554+
543555
# Release-body limit guard (#4900). The GitHub Releases API rejects any
544556
# body over 125,000 characters, and @objectstack/spec's changelog section
545557
# for one v17 RC is ~343,000 — so the release step 422'd after npm had
@@ -658,6 +670,34 @@ jobs:
658670
- name: Cross-package test inputs
659671
run: pnpm check:cross-package-test-inputs
660672

673+
# The inventory of `packages/**` tests coupled to `examples/**` (#8754).
674+
# Sibling of the gate above, on the axis it cannot see: that one detects
675+
# tests whose FILESYSTEM READS escape their package, this one detects
676+
# tests that IMPORT an example app live. `packages/cli` dynamically
677+
# imports `examples/app-showcase/src/ui/views/contact.view` and asserts
678+
# `toEqual` over a hardcoded `_sections` key list; `packages/lint`
679+
# statically imports the same app's `Contact`/`ContactViews`. Neither
680+
# package declares a dependency on `@objectstack/example-showcase` — the
681+
# coupling is a test-only relative import across a workspace boundary — so
682+
# `turbo ls --affected` cannot reach them from an examples-only diff and
683+
# the first signal is a red `Test Core` in the SHARED merge queue, which
684+
# stalls every lane (queue build 31825946401; it caught #8231's remainder
685+
# twice in one round, the second time through a package nobody had
686+
# thought to check).
687+
# ⛔ This gate grades DISCOVERY ONLY — "here are the coupled files, and
688+
# here is what CI can see of each". It asserts nothing about how any of
689+
# them should be written; adding a new live coupling is allowed, it only
690+
# has to be RECORDED so the next example-app edit can be checked against
691+
# the list. Classification is mechanical (declared workspace dep /
692+
# declared turbo input glob / neither), and only the invisible tier needs
693+
# a hand-written note, so the maintained half stays proportional to the
694+
# gap. Checked in both directions: an undeclared coupling and a stale
695+
# entry both fail, naming themselves.
696+
# Inventory on demand: `pnpm examples:live-imports`.
697+
# Static scan of test sources; sub-second.
698+
- name: examples/** live-import inventory
699+
run: pnpm check:examples-live-imports
700+
661701
# Tests must judge SOURCE, not a sibling's build artifact (#7668/#7849).
662702
# Every publishable package resolves through `exports` to `dist/`, so a
663703
# package whose tests import a workspace dep with no vitest source alias

‎apps/docs/redirects.mjs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,23 @@ export const docsRedirects = [
9292
['/docs/concepts/cluster-semantics', '/docs/kernel/cluster'],
9393
// deployment
9494
['/docs/guides/cloud-deployment', '/docs/deployment'],
95-
['/docs/guides/deployment-vercel', '/docs/deployment/vercel'],
95+
['/docs/guides/deployment-vercel', '/docs/deployment/self-hosting'],
9696
['/docs/guides/production-readiness', '/docs/deployment/production-readiness'],
9797
['/docs/guides/publish-and-preview', '/docs/deployment/publish-and-preview'],
9898
['/docs/guides/environment-variables', '/docs/deployment/environment-variables'],
9999
['/docs/guides/single-project-mode', '/docs/deployment/single-project-mode'],
100100
['/docs/concepts/cloud-artifact-api', '/docs/deployment/cloud-artifact-api'],
101-
['/docs/guides/objectql-migration', '/docs/deployment/migration-from-objectql'],
101+
['/docs/guides/objectql-migration', '/docs/deployment'],
102102
['/docs/guides/troubleshooting', '/docs/deployment/troubleshooting'],
103+
// Retired deployment pages (maintainer ruling 2026-08-15). Docker /
104+
// self-hosting is the shipping path, so the Vercel guide lands on
105+
// self-hosting; the `@objectql/core` port guide has no successor page, so it
106+
// lands on the section index. The two `/docs/guides/*` entries ABOVE are
107+
// re-pointed at these same destinations rather than left aimed here: Next
108+
// matches this table once per request, so a chained entry would cost the old
109+
// URLs a second round trip (and every redirect hop is SEO signal lost).
110+
['/docs/deployment/vercel', '/docs/deployment/self-hosting'],
111+
['/docs/deployment/migration-from-objectql', '/docs/deployment'],
103112
// protocol / releases
104113
['/docs/guides/cheatsheets/protocol-diagram', '/docs/protocol/diagram'],
105114
['/docs/guides/cheatsheets/backward-compatibility', '/docs/protocol/backward-compatibility'],

‎content/docs/api/error-catalog.mdx‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,48 @@ substitute. See the [Data API](/docs/api/data-api).
8686

8787
### `MISSING_REQUIRED_FIELD`
8888
**Cause:** A required field was not provided in the request body.
89-
**Fix:** Include the missing field. Check `fields` for the field name.
89+
**Fix:** Include the missing field. Check `fields` for the field name — **except** on the
90+
`controlled_by_parent` master-reference paths below, where the status is `422` and no
91+
`fields` array is sent; read the field name out of the message there.
9092
**Retry:** `no_retry`
9193

94+
<Callout type="warn">
95+
**`MISSING_REQUIRED_FIELD` is `400` with one exception: an absent `controlled_by_parent`
96+
master reference answers `422`, without `fields`.**
97+
98+
An object whose `sharingModel` is `controlled_by_parent` derives its access from a master
99+
record, so the gate authorizing writes to it resolves that master *before* the executor —
100+
and the executor is where required-field validation runs. Which of the two refuses first
101+
depends on how the master reference is declared. A `master_detail` that is `required` and
102+
neither `readonly` nor `system` reaches validation and answers the documented
103+
`400 VALIDATION_FAILED` with `fields`. The other four declarable shapes never reach it and
104+
answer `422 MISSING_REQUIRED_FIELD` with no `fields`:
105+
106+
- a `master_detail` with no `required`
107+
- a `master_detail` that is `required` + `readonly`
108+
- a `master_detail` that is `required` + `system`
109+
- a `required` `lookup`, when the object declares no `master_detail`
110+
111+
The full matrix is on the protocol page:
112+
[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field).
113+
114+
An update or delete **by id** whose *stored* master reference is null answers the same
115+
`422`, whatever the declaration — the caller sent no such field, so nothing could be named
116+
in `fields` and no payload would fix it.
117+
118+
The refusal itself is correct and is not going to be relaxed: on those four shapes
119+
required-field validation does not fire (it skips `system` and `readonly` fields before its
120+
required check, and never fires on a field that is not `required`), so the gate is the only
121+
thing standing between the request and a detail record with a null master reference — which
122+
the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, leaving
123+
a record readable by nobody.
124+
125+
These shapes are **authorable today**: lint reports a `master_detail` without `required` as
126+
a warning only, and does not report the `readonly`, `system`, or fallback-`lookup` shapes at
127+
all. **Branch on `code`, treat `fields` as optional, and read the status off the response**
128+
rather than deriving it from the table at the end of this page.
129+
</Callout>
130+
92131
### `INVALID_FORMAT`
93132
**Cause:** Field value does not match the expected format (e.g., invalid email, wrong date format).
94133
**Fix:** Ensure the value matches the field's `format` constraint or built-in type validation.
@@ -632,6 +671,7 @@ async function handleApiCall() {
632671
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
633672
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
634673
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` |
674+
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
635675
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
636676
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR`, `TIMEOUT` |
637677
| 502 | `external` | `EXTERNAL_SERVICE_ERROR`, `INTEGRATION_ERROR` |

‎content/docs/deployment/meta.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@
66
"validating-metadata",
77
"self-hosting",
88
"backup-restore",
9-
"vercel",
109
"production-readiness",
1110
"publish-and-preview",
1211
"environment-variables",
1312
"single-project-mode",
1413
"tenancy-modes",
15-
"migration-from-objectql",
1614
"troubleshooting"
1715
]
1816
}

0 commit comments

Comments
 (0)