Files
nocobase/scripts/validate-changesets.mjs
T
chenosandClaude Opus 5 c960d07b97 feat(db): add Repository Policy — row scope, read and write shape (#204)
* feat(db): add repository policy normalization

* feat(db): add repository policy binding

* feat(db): apply repository read policy scope

* feat(db): apply mutation policy scopes

* feat(db): apply repository read field policy

* feat(db): apply repository relation read policy

* feat(db): apply relation read policy scope

* feat(db): apply repository write field policy

* feat(db): apply repository relation write policy

* feat(db): apply repository create defaults

* feat(db): enforce policy fields in read filters

* feat(db): enforce policy fields in relation filters

* feat(db): add connection repository policies

* docs(db): add repository policy design proposal

Record the design baseline for the Repository Policy work: the four-node
model with its three invariants, a worked walkthrough, the parameter
reference, the Knex execution details, and the phased implementation
checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(db): settle the open policy decisions before implementation

Record the calls that shape the implementation: the branch lands as a
whole before merging, the `toWritePolicy` bridge stays until relation
write targets force it apart, the `origin` marker stays internal to
`@nocobase/db`, and scope keeps accepting builder callbacks because
binding-time materialization already turns them into an AST.

Also give the phase 1 acceptance list its missing landing point: the
first five items assert on real SQL, which a unit test's fake adapter
cannot do, so they belong in the cross-dialect contract suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(db): extract the policy enforcement helpers

Move the policy code that had accumulated inside repository.ts into
policy/enforce.ts, and lift `invalid` and `isPlainRecord` into a shared
guards module so both files can use them. Behavior is unchanged; this
only gives the enforcement layer somewhere to grow, and keeps the
behavioral changes that follow readable on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): make the read policy a required argument of every read

The policy reached the read validators as trailing optional parameters,
so honouring it was opt-in per call site. Seven of them did not: the
returning `select` of createOne, createMany, updateOne, upsertOne,
updateMany, deleteOne and deleteMany validated without it, and returned
fields the read allowlist forbids. Relation aggregate branches dropped
it the same way.

PolicyReadContext replaces those optional parameters. It is required, a
call site with no policy passes UNRESTRICTED_READ explicitly, and the
compiler now lists every read surface that has not supplied one. That is
what found the seven; the next read path added cannot repeat it.

Descending into a relation goes through relationReadContext, which
returns the target collection's own allowlist rather than threading two
loose parameters, and rejects an unauthorized relation in one place.

Also stop the policy scope from swallowing findOne's determinism guard.
`findOne()` with neither filter nor sort has no defined answer, and a
scope narrowing the rows does not give it one, so the guard now asks
what the caller supplied rather than what the filter became.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): apply the read allowlist to every surface that names a field

`read.fields` limited what a query returned, but the query itself leaks
the same values: an `exists` with a range condition on `budget` returns
no fields and still binary-searches the number out. The filter already
enforced this. Sort, distinct, aggregate and groupBy did not have the
parameter at all, so they named any column they liked.

They take the read context now, and a relation path descends through
relationReadContext so a sort or aggregate through a relation is judged
against that relation's own allowlist.

The cursor needs no check of its own: its axes are derived from the sort
it requires, so the sort check covers it. Neither does groupBy's
`having` or its sort, which run against a synthetic result collection
whose every column came from `by` or `aggregate` and was checked there.

The sort a query did not ask for stays exempt. Sort appends the primary
key to make the order deterministic, which is the Repository's own
condition rather than the caller's, so it is not judged against the
allowlist that governs what the caller may name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): judge filter conditions by the collection they belong to

The read allowlist check walked the whole filter tree against one
collection's field list. A relation quantifier fell through to its
nested conditions carrying the root's allowlist, so `tasks.some(...)`
was judged by the projects allowlist. Both directions were wrong: a
condition on a tasks field passed whenever the root happened to have a
field by that name, and a field the relation node explicitly authorized
was rejected because the root did not have it.

Worse, the relation itself was never checked. A filter could traverse a
relation absent from `read.relations` and reason about rows through it.

The walk now carries the collection and the policy node together,
descending through relationReadContext, and intersects each relation
branch with that relation's own scope in the same pass. A quantifier
therefore only ever reasons about rows the caller may see: `none(...)`
means no visible match, `notEmpty` means at least one visible row.

Caller conditions and policy conditions stay apart structurally rather
than by an origin tag on the node: they are two separate values, only
the caller's is walked, and the merge happens after. See decision 0.5
and task 1.5 in the roadmap for why that replaced the tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(db): add the cross-dialect policy contract suite

The claims phase 1 makes are about SQL, and a unit test cannot check them:
its adapter is a stub, so it sees the plan the repository built, never the
statement the database ran or the rows that came back. These run against
every dialect.

Three suites. Pushdown asserts the scope reaches the WHERE clause rather
than being applied to a full read, that a caller's or-group is intersected
with it rather than flattened into it, and that bulk mutations and
`all: true` are narrowed too. Indistinguishability asserts that a row
belonging to another tenant and a row that never existed produce the same
answer from every method, since any difference between them lets a caller
enumerate another tenant's keys. Read shape asserts that a forbidden field
is refused wherever it is named, not quietly trimmed, and that scoping an
expanded relation hides its rows without dropping the root records.

The fixture seeds through the Repository rather than raw SQL so it does not
have to know each dialect's physical column names.

Verified on SQLite; the remaining seven dialects need their services and
have not been run here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(app-server): give the policy error codes their HTTP status

The codes were added to the error enum without a status, so they fell
through to the 400 default. A refusal reported as 400 tells the caller
their request was malformed when it was in fact understood and denied,
and there is nothing for them to fix in the payload.

The read refusals and SCOPE_VIOLATION answer 403; RECORD_OUTSIDE_SCOPE,
which an upsert raises when the target exists outside the scope, answers
409 like the other conflicts.

A scope that simply does not match never reaches this table. It stays a
404 or an empty result, so that forbidden and absent remain one answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): derive a policy-bound connection instead of building a second one

`withPolicies` constructed a whole new KnexDatabaseConnection. Three
things came of that, and binding is meant to happen once per request, so
all three were on the hot path.

It passed `undefined` where the transaction invalidation collector goes,
so a connection bound inside a transaction stopped deferring collection
invalidation to the commit and ran relation validation eagerly. It built
a second collection registry, whose cache starts empty, so the first
read through a bound connection re-introspected the schema. And it
passed a `knexInstance` that is undefined until the connection is first
used, so binding before connecting produced a connection that opened its
own pool, which the original could not close.

PolicyBoundConnection delegates instead. One connection, one registry,
one pool, and transactions run on the connection that knows how to set
them up, so nothing has to be copied across correctly.

`withPolicies` now returns ScopedDatabaseConnection, which has no
`withPolicies` of its own: bindings can be narrowed later but not
replaced. `repository()` still returns an unbound Repository for a
collection the map does not cover, which is what `requireScope` on the
Collection is for rather than something the type can promise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(db): hold the record to its scope after the write

A scope in the WHERE clause decides which rows an operation may touch.
It says nothing about where those rows end up, so a policy that let a
caller write the scope field let them move a record out of their own
tenant, and `create.scope` was normalized and then never read at all —
a caller could create records in someone else's tenant outright. Both
are the escalation invariant 2 exists to stop.

The check runs in the database rather than in memory. An in-memory
evaluator has to reproduce the engine's own comparison semantics, and
one of those cannot be reproduced: the same string equality matches
under MySQL's default collation and not under PostgreSQL's, and the
evaluator cannot see which column it is looking at. A statement issued
inside the transaction against the locked row agrees with the WHERE
clause that selected it by construction, which also retires the
differential testing that an evaluator would have needed.

It stays cheap because a write that touches none of the fields the
scope reads cannot move a record out of it, so the check is skipped
entirely — renaming a project under a tenant scope costs exactly what
it did before. Only a write that reaches a scope field pays for one
primary-key lookup, and that same rule decides when updateMany has to
lock its rows instead of issuing one statement. A create is always
judged, having no prior state to compare against.

upsertOne gains the three branches the design asks for. It also stops
merging the scope into its filter: an upsert locates its target by a
unique key, and a scope merged in left a filter that was no longer one
key, so binding any non-trivial update scope made every upsert fail
with INVALID_UNIQUE_SELECTOR. Whether the caller may touch the target
is now asked separately, of the record that is actually there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): finish the policy normalization contract

Four things the normalization layer left open.

A scope now has to stay on this collection's own scalar columns. A
relation path or quantifier raises the question a scope cannot answer —
whether the target's own policy applies — and would put a join into
every query that the caller cannot see; a JSON operator stops a scope
being a plain comparable condition. The restriction is about what a
scope should mean rather than what can be evaluated, since the
write-back check is a statement and would handle either.

The scope AST is frozen all the way down. `explainPolicy` hands it out,
and freezing only the root left the conditions inside it mutable, so a
caller could have quietly changed the effective range of every later
query on that repository. A Date used as a create default is copied and
frozen for the same reason.

`create.scope` is checked for satisfiability once the collection
resolves. A scope reading a field that is absent from `create.fields`
and `create.defaults` and has no column default describes a create that
can never succeed: every call inserts, fails the write-back check and
rolls back, reporting an error about `values` that the caller cannot
act on. A field that does have a column default is accepted — whether
that default satisfies the condition is a comparison only the database
can make, and the write-back check makes it.

The normalized scope is resolved once per repository instead of on
every write. A bound policy is frozen, so walking the collection
metadata again each time bought nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(db): let a bound policy be narrowed further

`narrow` completes the binding API: a request that has already bound a
policy can tighten it for one piece of work — a background job that may
only read its own rows, a handler that must not touch relations — without
being able to loosen anything.

Every rule only takes away. Scopes are ANDed, field lists and relations
intersected, and `false` on either side wins, so there is no spelling
that widens and a narrowed Repository cannot climb back out. A patch is
still a patch: a member it does not mention stays as it was, and one it
does mention is intersected, which makes `relations: {}` a way to say
"none of them".

Relation write operations intersect rather than merge. Each of them is
an explicit grant, so keeping one the patch did not name would let a
patch preserve a permission it never asked for.

`scope` is optional in a patch although it is required when a policy is
first bound. Those are not in tension: binding has to say everything out
loud because a half-written policy reads as configured while leaving the
rest open, whereas an omitted member in a patch asks for no change,
which can only keep or tighten what is in force.

Also refresh the public API baseline, which the policy exports had left
stale since the first commit on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(db): expand policy references when the binding is made

`PolicyRef` was a type with no way to produce one and no code to resolve
one: a reference reaching `read.relations` was rejected outright as "not
resolved by Policy". `ref(target)` now exists and is expanded when
`withPolicies` binds the map.

A reference resolves against that map rather than against a policy the
target Collection owns elsewhere. That keeps reuse explicit: reached
through a relation and queried directly stay two separate declarations,
which is what makes either of them possible to reason about, while `ref`
gives the one case where repeating a node would drift.

Expansion happens once at binding, so a cycle, a target the binding does
not cover, and a target whose read is `true` or `false` rather than a set
of rules are all reported then, naming the chain. Dereferencing per query
would have turned the first of those into a stack overflow mid-request.

Only `read.relations` carries references. Relation writes are authorized
by the originating side alone, with the target's own policy deliberately
not taking part, so there is nothing there to refer to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): keep a foreign key and its relation in step

A policy that authorized `owner { id }` while leaving `ownerId` out of
`read.fields` refused the column and handed the same value back through
the relation. The refusal protected nothing and only made the allowlist
read as stricter than it was.

The context now separates two things that were one list. `fields` is the
declared allowlist and still decides what a query with no select returns.
`readableFields` is what a caller may name, which adds the foreign key of
every authorized relation that actually returns the key it points at — so
a relation granted no fields, or granted fields that do not include the
target key, gives nothing away and its foreign key stays hidden.

The implication runs one way only. A readable `ownerId` says nothing
about whether `owner` may be expanded, because the target carries other
fields.

Resolving a relation's policy now takes the target collection rather than
the parent, which it needed anyway: the node governs the target, so its
own relations have to be looked up there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(db): scope the targets a relation write may reach

A root scope constrains the root record. A relation operation reaches its
target straight from the target table by selector, so it was not
constrained at all: a caller confined to their own tenant could
`connect` another tenant's task into their project, or `update` and
`delete` one through the relation. The root scope stopped them touching
the wrong project, not the wrong task.

`RelationWriteNode.scope` now reaches the queries that locate a target.
The policy's relation tree is resolved against the collection graph into
a scope tree that travels alongside the mutation AST, and the adapter
intersects it with each locating query. A relation reached through more
than one nested shape gets every scope ANDed, since none of them granted
more than the others.

A created target is deliberately not constrained: its ownership comes
from the relation key, so it necessarily lands under the originator. Its
own nested relations still are.

An out-of-scope target raises the same error as one that does not exist,
rather than a distinct code. Telling them apart would rebuild the
existence oracle the root scope closes, this time reachable through a
relation.

The `toWritePolicy` bridge survives after all. The scope travels beside
the legacy structure rather than through it, so "a relation scope has
nowhere to live in the old shape" is no longer what forces the bridge
apart; roadmap decision 0.9 is updated to say what does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(app-server): let a route action declare a repository policy

Every action now accepts a `policy`, which is normalized when the routes
are defined — a malformed one fails then rather than on whichever
request first reaches it — and bound to the Repository the handler runs
against.

A caller cannot supply one. `readInput` already validates the body key by
key against a per-action allowlist, and neither `policy` nor `scope`
appears on any of them, so both were refused before this change; the
tests now pin that down, since the leak would otherwise reopen silently
the next time someone widens a list.

Deny-by-default is deliberately not part of this. Flipping the default
would invalidate every route declaration that exists today, in this
repository and in consumers, so it belongs in the migration that updates
them rather than ahead of it. The roadmap records that.

`RepositoryOperations` is the type a handler takes now: a plain and a
policy-bound Repository share their operations but neither is a subtype
of the other, since `withPolicy` must not be reachable once a policy is
bound and `narrow` means nothing before one is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(db): degrade the return type when a read policy is bound

A policy-bound read does not return the whole record: with no select it
returns `read.fields`, and an omitted `fields` is an empty allowlist
rather than a free pass. The type said otherwise, so every caller of a
scoped Repository was handed a `TRecord` whose fields might not be there.

`withPolicy` captures the policy literal through a `const` type
parameter, and `PolicyRecord` degrades the record to `Partial<TRecord>`
whenever `read` is a set of rules. `read: true` adds no limits and keeps
the full type.

Spike 0.6 asked whether the literal could drive this, and it can — but
the judgement is coarser than the spike proposed. It asks whether `read`
is a rule object rather than whether `fields` is a string array, because
omitting `fields` is the same empty allowlist and deserves the same type.
Narrowing further, to exactly the listed fields, would need the literal
array to survive inference at every call site; a `Partial` that is honest
beats a precise type that widens the moment someone stores the policy in
a variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(db): bring the policy documents up to what was built

The status banners still read "proposal, not yet implemented" while
phases 1 through 3 had landed. They now say what is in place, where the
implementation and the cross-dialect contract tests live, and which
decisions came out differently from the design — the write-back check
done in SQL rather than in memory, caller and policy conditions kept
apart structurally rather than by an origin tag, an out-of-scope relation
target sharing its error with a missing one.

The migration is recorded as not started, and the reason is stated
plainly: removing `writePolicy` means changing four kinds of consumer at
once, and deleting it first would break every route declaration and
plugin that exists today. Both semantics therefore coexist for now,
which is exactly the shape the design warns about, so the note says so
rather than letting it look settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(db): add a Policy quick start

The policy documents under `proposals/` are a design record: they explain
why each rule exists, at a length that suits deciding the rules rather
than using them. Nothing told someone with a multi-tenant table what to
write.

This walks through binding a policy, what reads and writes actually do,
what going out of scope looks like from the caller's side, and the four
mistakes the design makes easy: reading an omitted `fields` as "no
limit", expecting `scope` to fill values into a new record, putting a
relation path in a scope, and writing a `create.scope` over a field the
create can never set.

`write-policy.md` and the Repository overview now point at it, since
"limit which fields" and "limit which rows" are easy to confuse and the
answer is a different API.

Writing it turned up a real gap, fixed here: a scope written as
`{ 'owner.tenantId': 'T1' }` kept the dotted name as a single path
segment, so the check that rejects relation paths never saw it and the
policy bound successfully, only to fail on every query as an unknown
field. `fields` already refused dotted names; scope now agrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): stop relation writes escaping their policy scope

The relation scope reached the queries that *locate* a target but not the
ones that *change* it. Five ways out, one root cause, all five reported by
the PR review and each now pinned by a test that failed before this
commit.

A to-one relation stores its foreign key on the root table, so
`owner.connect` writes `ownerId` without ever naming it in `values`. The
root write-back check only inspected the submitted values, so a caller
could carry their own record into another tenant through a relation and
commit — the one outright escalation of the five. The check now also asks
whether any relation being written is a to-one whose foreign key the
scope reads.

`belongsTo` + `set` is resolved while the row is still being built, a
different path from every other relation operation, and it was passing no
scope at all: `create.relations.<field>.scope` did not apply to it.

Detaching is still reaching for a row. `disconnect` on a to-one cleared
whatever was attached, and `set` removed every current target not in the
desired list, neither consulting the scope — so a caller could let go of
rows they were never allowed to see. Both are now restricted to targets
the scope can locate, and a to-one target it cannot locate raises the
same error as one that does not exist, keeping forbidden and absent
indistinguishable.

Invariant 2 applies to a relation target as much as to a root record:
locating it inside the scope says nothing about where the submitted
values leave it. A relation `update` is judged again afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): stop the policy refusing its own configuration

Two legitimate configurations were rejected.

A relation `combine` branch passed `scope.filter` down as the filter every
branch inherits, and that value already carries the relation scope. The
recursive validation then walked it as caller conditions, so a scope field
the read allowlist omits — `tenantId`, in every multi-tenant policy —
failed with FIELD_READ_FORBIDDEN naming a field the caller never wrote.
The branch now inherits the caller's own filter; the scope is re-applied
by each recursion regardless, so nothing is lost by leaving it out.

This is the case the design warned about and I claimed did not apply. The
separation of caller and policy conditions is structural, but `combine`
fed an already-merged filter back into validation, which is exactly the
ordering assumption the warning was about.

Narrowing `create.defaults` raised "Unsupported Policy option: defaults"
because the shared write-node helper normalized every patch as an
`update`, and only `create` accepts defaults. The node kind travels with
the patch now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): stop the policy promising more than it enforces

Four places where the guarantee and the behavior had drifted apart.

A Repository from a policy-bound Connection is still a DefaultRepository
and the Connection types it as `Repository`, so `withPolicy` stayed
reachable and replaced the binding outright — handing back an
unrestricted Repository and undoing the Connection's authorization.
Rebinding is refused now; `narrow` remains the way to tighten.

`PolicyRecord` asked whether the whole `read` union is an object. A
policy held in a variable of type `RepositoryPolicy<T>` has
`read: true | false | ReadNode`, which is not, so the type handed back
the complete record for exactly the policies most likely to restrict it.
It now asks whether the union contains a rule node.

`through` accepted anything truthy and read it as an empty allowlist, so
`through: true` — the obvious way to write "allow a payload" — silently
became a different permission, and unknown keys inside the rule passed
unnoticed. It is `false` or a rule object, and anything else is refused.

`Object.freeze` does not reach a Date's internal time, and
`explainPolicy` returned the very object the writes read from, so a
caller could change a server-assigned create default after binding.
The policy is handed out with its Date defaults copied.

Finally, `validateMutation` for a create ran no scope check, so a policy
whose `create.scope` can never be satisfied was reported valid and only
failed later through an insert that rolls back every time — which is the
opposite of what that method is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(db): correct the execution notes, and catch a split changeset

`policies-internals.md` was the one document the implementation changes
never reached. It still described an `origin` tag on merged filter nodes
and an in-memory `evaluateScope` with a `hasWriteTriggers` fallback,
neither of which exists — so a reader following it would have built
toward a representation the code cannot produce.

It now describes what runs: caller and policy filters kept apart
structurally, and a write-back check issued as a statement. Two things
are recorded that the original could not have known. The structural
separation failed once, in the relation `combine` branch, and the note
says how; and the check has a second way into the root record, since a
to-one relation writes its foreign key there without ever appearing in
`values`.

`policy-http-action-slot.md` carried two frontmatter blocks. Changesets
parses only the first, so the `@nocobase/db` half was body text and that
package would not have been released from this file. The repository's own
validator did not notice, because it reads the first block and never
looks at the body; it does now, and refuses a stray declaration block
with an explanation of why it silently fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(app-server): give an exposure one policy instead of nine

The Repository API had two authorization mechanisms side by side, and the
older one stood in front of the newer: `writePolicy` defaulted to `false`
when an action omitted it, so an action that declared only a `policy` was
refused before the Policy it declared ever ran. The slot was usable for
reads and dead for writes.

`writePolicy` is gone from a route declaration. `policy` moves from the
action to the exposure and is required. An action now says only that an
endpoint exists; what it may do is the exposure's Policy, which governs
reading, creating, updating and deleting together.

Requiring it is the point. `writePolicy` refused writes when it was
missing, while a missing Policy restricts nothing, so an optional `policy`
would have turned every declaration that exists today from "refuse every
write" into "allow everything" with nothing to see in the diff. Putting it
on the exposure rather than the action is what makes that affordable:
`RepositoryPolicy` requires all four nodes, and per action that meant nine
copies per collection that could disagree with one another. It also settles
a conflict that had no good answer -- `read` governs the record a write
returns, so a `createOne` action carrying `read: false` failed the create it
was meant to permit.

A Policy may now be a function of a principal, paired with a
`principal(context)` resolver the application supplies, which is what lets a
scope name the caller. Without it a Policy can only express a constant
scope, and multi-tenancy -- the reason Policies exist -- stays unwritable;
the slot would have been a wordier `writePolicy`. A resolver that returns
nothing refuses the request with 403 rather than binding a Policy built from
a principal that is not there, and a Policy function without a resolver
fails at declaration.

What that costs is checking time: a fixed Policy is still normalized when
the routes are defined, but one built per request can only be validated per
request. Such a failure is `INVALID_POLICY`, and it now propagates as a
server error instead of a 400 -- a Policy is server-owned, so its mistakes
are not the caller's to be told about in those terms. `ref()` is refused
outright on both paths: it resolves against a `withPolicies` map, and these
routes bind one Policy per exposure, so an unexpanded reference would reach
a request as a 403 on a relation the Policy appears to grant.

`@nocobase/db` gains `buildRepositoryPolicy`, whose unmentioned nodes are
denied. Without it the four-node requirement would have meant writing
`delete: false` into every exposure, and the one declaration with seven
relation operations open would have been unreadable as a literal.

Two defects surfaced while rewriting the tests and are fixed here. A `false`
write node was resolved after the payload had been normalized, so `update:
false` with `values: {}` reported 400 for invalid input rather than 403 for
a write that was never permitted -- the old option short-circuited on the
first line and the Policy did not. And a `create` node granting `update`,
`upsert`, `disconnect`, `set` or `delete` on a relation was accepted
although a root create performs none of them; that check lived in the HTTP
layer and now runs during normalization, for every consumer.

File exposures declare a Policy too, and it reaches uploads. An upload
supplies no caller fields at all, so it binds a Policy derived from the
exposure's: `create.scope` and `create.defaults` are inherited and the field
allowlist is replaced by the file columns. A file uploaded under a scoped
Policy therefore lands inside the scope the same exposure reads from,
instead of vanishing from the listing that just accepted it. Only the upload
writes are bound -- the Repository the manager hands back is the plugin's
server-side API, as trusted as a `db.repository()` call, and binding it
would restrict that rather than the HTTP surface. The public content route
under `accessPath` is unchanged and deliberately outside all of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:58:47 -07:00

118 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 校验 .changeset/*.md 的格式。这一层是阻塞性的:文件本身写错了,机器能
// 确定判断,没有让它流到发版时才炸的理由。
//
// 只检查已经存在的文件是否合法,不判断「该不该有 changeset」——那是
// require-changesets.mjs 的职责。
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
const changesetDir = path.join(root, '.changeset');
const VALID_BUMPS = new Set(['patch', 'minor', 'major']);
// packages/ 是两层的:packages/<分类>/<包>,分类目录本身没有 package.json。
// 只读一层会得到空集合,于是每个 changeset 里的包名都会被判成「未知的包名」。
function readWorkspacePackages() {
const packagesDir = path.join(root, 'packages');
const names = new Set();
if (!fs.existsSync(packagesDir)) return names;
for (const category of fs.readdirSync(packagesDir, { withFileTypes: true })) {
if (!category.isDirectory()) continue;
const categoryDir = path.join(packagesDir, category.name);
for (const entry of fs.readdirSync(categoryDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifest = path.join(categoryDir, entry.name, 'package.json');
if (!fs.existsSync(manifest)) continue;
names.add(JSON.parse(fs.readFileSync(manifest, 'utf8')).name);
}
}
return names;
}
const known = readWorkspacePackages();
const errors = [];
if (!fs.existsSync(changesetDir)) {
console.log('没有 .changeset 目录,跳过。');
process.exit(0);
}
// 也要扫 .changeset/pre/。prerelease 模式下 `changeset version` 每次都会把整个 pre/ 目录重新读一遍来计算累积 bump,所以里面的包名同样必须在 workspace 里存在。删包时漏改这里,本地和 CI 都不会报错,一直到发版那一刻才炸成 `Found changeset ... which is not in the workspace`。
function collectChangesetFiles() {
const files = [];
for (const dir of [changesetDir, path.join(changesetDir, 'pre')]) {
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md') || entry.name === 'README.md') continue;
files.push(path.join(dir, entry.name));
}
}
return files;
}
const files = collectChangesetFiles();
for (const full of files) {
const file = path.relative(changesetDir, full);
const raw = fs.readFileSync(full, 'utf8');
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) {
errors.push(`${file}: 缺少 YAML frontmatter`);
continue;
}
const body = raw.slice(match[0].length).trim();
if (!body) {
errors.push(`${file}: 缺少变更说明正文`);
}
// changesets 只解析第一个 frontmatter 块。正文里再出现一个包声明块时,
// 那半截会被当作说明文字塞进前一个包的 CHANGELOG,而它声明的包根本不会
// 被发布——不报错,只是静默失效,所以在这里挡住。
const strayBlock = body.match(
/^---\r?\n\s*(?:"[^"]+"|'[^']+'|[^:\n]+)\s*:\s*(?:patch|minor|major)\b/m,
);
if (strayBlock) {
errors.push(
`${file}: 正文里出现了第二个 frontmatter 块。changesets 只解析第一个,多出来的声明会被当成说明文字而不会发布;请把所有包合并进同一个块`,
);
}
const lines = match[1].split(/\r?\n/).filter((l) => l.trim());
if (!lines.length) {
errors.push(`${file}: frontmatter 为空,至少要声明一个包`);
continue;
}
for (const line of lines) {
const entry = line.match(
/^\s*(?:"([^"]+)"|'([^']+)'|([^:]+?))\s*:\s*(.+?)\s*$/,
);
if (!entry) {
errors.push(`${file}: 无法解析的声明 -> ${line.trim()}`);
continue;
}
const name = (entry[1] ?? entry[2] ?? entry[3]).trim();
const bump = entry[4].replace(/^["']|["']$/g, '').trim();
if (!known.has(name)) {
errors.push(`${file}: 未知的包名 "${name}"`);
}
if (!VALID_BUMPS.has(bump)) {
errors.push(
`${file}: 非法的 bump 类型 "${bump}"(只允许 patch / minor / major)`,
);
}
}
}
if (errors.length) {
console.error('changeset 校验失败:\n');
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log(`changeset 校验通过(${files.length} 个文件)。`);