Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/a-default-cannot-read-a-denied-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/builder": patch
"create-nextly-app": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/eslint-plugin": patch
"@nextlyhq/module-specifiers": patch
"nextly": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/ui": patch
"@nextlyhq/plugin-mcp": patch
---

A function default cannot read a field the caller may not write. A `defaultValue` written as a function receives the data built so far, and that data still held every value the caller sent, including one a field rule denies them, so a default could read the forbidden value and carry it into a field the caller IS allowed to write: the denied field was stripped and its value persisted anyway, one column across. The rules are now applied to a COPY, and the copy is what the functions read. The record itself is left alone until the pass that decides what is stored, because a rule may depend on a sibling the caller omitted precisely BECAUSE it has a default, and judging that rule before the defaults exist would deny it and lose the value for good.

That copy is now defaulted and judged a second time before the record reads it. A field the caller may not write still takes its own declared default, and the pass that runs before the defaults has no key to judge because nothing supplied one; left in the copy, a later function default read it and carried it into a field the caller CAN write, so the rule was defeated one column across by the field's own default rather than by the caller's input.

A config reload installs the live config's field functions, replacing them wholesale at the point the reload commits. Replacing rather than adding, so an entity the new config no longer declares stops deciding anything: a collection dropped from the config keeps its registry row and its table so an orphan sweep can find them, so it stays addressable. At the commit point rather than when the config is read, so a reload whose DDL succeeds and whose later sync fails does not leave a rule from a config the process refused deciding writes. The field-level registry holds every field's `access` rules, hooks, `validate` and function `defaultValue`, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. That is wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one being enforced. Applied on the same optimistic terms as the field-type registry, and restored by the same undo when a reload is abandoned.

An access rule declared inside an unnamed container is captured. The registry keyed only named entries, so every rule, hook and validator inside an unnamed presentational group was dropped. `defineCollection` refuses a field with no name, but a plugin contributing raw config is checked on its field TYPES and not their names, so the shape reaches the live config.

A Single's first read no longer invents an incomplete group. Filling a group for the sake of one defaulted child, while a required sibling has no default and no value, stored a document the next create or update would refuse; that insert runs no validation pass of its own. The group is left absent instead.

The documentation no longer claims a field group's children take their defaults before the entry's hooks. They are written in their own pass afterwards, so the parent's hooks see such a child as absent.

A `defaultValue` written as a function now works on a reusable field group's children. A field group's fields are read from its stored definition on every write, and a function does not survive being stored, so only constants applied there: a function default on a field-group child was silently dropped on every write. Field groups now get the same live-config capture collections and Singles have, so the function form resolves from the config at boot, and it is re-read when the dev server reloads the config. It resolves against the instance being built, so a child may compute from a sibling defaulted before it.

Only the default is wired. The same capture holds a field's `access` rules, hooks and `validate`, and nothing reads those for a field group, so registering them changes nothing about whether they are enforced.

The promote gate judges the draft the write actually commits.

The gate that re-judges a Single's pending change before it is published ran before the write transaction, where it could only judge a copy of the world as it was. It now runs inside that transaction, on the draft the transaction has locked, which closes what a pre-flight check could not: a draft saved by another writer between the check and the commit is now the draft that is judged; a `beforeChange` hook that turns a status-less edit into a publish no longer slips past a check that ran before the hooks; and `publishAllLocales`, which applies every language's snapshot to one row, now judges the combined result rather than each language against its own shared values. Resolving the caller's grants is the one thing that cannot happen inside a transaction, since it queries the pooled connection that transaction holds, so it is resolved beforehand and handed in.

A field rule on a child of a group or a repeater row is enforced. The check copied the snapshot shallowly, so the rules deleted a denied nested value from the copy and the original alike and the comparison then saw an unchanged container. A denied child is now found at its own depth and named at its own path.

A publish is no longer refused over a field nobody touched. The comparison read the live document through a read that expands an upload or a relationship into the document behind it, while the snapshot holds the identifier, so an untouched field of either kind looked like an edit. Both sides now go through one conversion.

A draft older than a newly required field is refused rather than published. The check judged the promoted document as a patch, which skips absent properties by design, so a snapshot written before a field became required reported nothing and published a document that violates the contract.

An API key is judged on the grants stamped on the key, not on the database roles of whoever owns it.
Binary file added data/nextly.db
Binary file not shown.
20 changes: 11 additions & 9 deletions docs/configuration/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1025,20 +1025,22 @@ Static value or `(data) => value` function. The function form receives the docum
Both forms are applied on the server, so a default holds for every write
path that applies defaults, not only for the admin form. On an ordinary
collection create through the REST or Direct API, an omitted field takes its
default before any hook runs, at every depth: a group's children, each
supplied repeater row's children, and the children of each new field-group
instance. A function default is read from the live config, since a
default before any hook runs, at every depth: a group's children and each
supplied repeater row's children. A new field-group instance also fills its own
children's defaults, but LATER: a field group's rows are written in their own
pass after the entry's hooks have run, so the parent's hooks see such a child as
absent and cannot read or derive from the value that will be stored. A function
default is read from the live config, since a
collection's stored definition cannot hold a function, and it receives the
data built so far at its own level: the document for a top-level field, the
group or the row for a nested one. Rows are never invented; `minRows` is a
validation rule, not an instruction to create rows.

Inside a reusable field group, only a constant default applies. A field
group's fields are read from its stored definition on every write, and a
function cannot be stored, so a `defaultValue` written as a function on a
field-group child is not applied anywhere: not on a collection create, not on
a Single's first read, and not in the admin form. Give a field-group child a
constant default, or set the value from the parent's own field.
A field-group child takes its default the same way, constant or function. Its
fields are read from a stored definition, which cannot carry a function, so the
function form is read from the live config exactly as a collection's is. It
resolves against the instance being built, so a child may compute from a sibling
that was defaulted before it.

A Single's first read creates its row with the same defaults, top-level and
nested, with two exceptions: a default on a `fieldGroup` field itself is
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* A function default is evaluated on what the caller was ALLOWED to send.
*
* Function defaults receive the data built so far. They ran before field write
* access, so a field this caller may not create was still in the record while
* they ran: a default could read the forbidden value and carry it into a field
* the caller IS allowed to write. The denied field was stripped from the
* record and its value was persisted anyway, one column across, which is the
* field rule defeated by proxy.
*
* Write access now runs before the defaults as well as after the hooks, the
* two passes the read path runs for the same reason: the first decides what
* the caller may put in, the second catches a denied key a hook put back.
*/
import { afterEach, describe, expect, it } from "vitest";

import { defineCollection, text } from "../../config";
import { createTestNextly, type TestNextly } from "../../plugins/test-nextly";
import type { CollectionsHandler } from "../../services/collections-handler";

let current: TestNextly | undefined;
afterEach(async () => {
await current?.destroy();
current = undefined;
});

const CALLER = { id: "u1", email: "u@x.test", roles: [] };

async function boot() {
current = await createTestNextly({
collections: [
defineCollection({
slug: "leaky",
fields: [
text({ name: "title" }),
text({ name: "secret", access: { create: () => false } }),
text({
name: "derived",
defaultValue: d => `saw:${String(d.secret)}`,
}),
text({ name: "open" }),
text({
name: "fromOpen",
defaultValue: d => `saw:${String(d.open)}`,
}),
],
}),
],
});
return current.getService<"collectionsHandler">("collectionsHandler");
}

describe("a function default cannot read a field the caller may not write", () => {
it("evaluates on the authorized record, not the raw request", async () => {
const handler = (await boot()) as unknown as CollectionsHandler;

const created = await handler.createEntry(
{
collectionName: "leaky",
// The route gate has run; the FIELD rules are the ones under test.
routeAuthorized: true,
user: CALLER,
},
{ title: "t", secret: "forbidden-value", open: "fine" }
);

expect(created.success, JSON.stringify(created)).toBe(true);
const row = created.data as Record<string, unknown>;
// The denied field is stripped, as it always was.
expect(row.secret ?? null).toBeNull();
// And its value did not reach the database through the default either.
expect(row.derived).not.toContain("forbidden-value");
expect(row.derived).toBe("saw:undefined");
// A default reading an ALLOWED field still sees it: the pass removes what
// the caller may not send, not everything they sent.
expect(row.fromOpen).toBe("saw:fine");
});

it("still lets a trusted caller's default read the field", async () => {
const handler = (await boot()) as unknown as CollectionsHandler;

const created = await handler.createEntry(
{ collectionName: "leaky", overrideAccess: true },
{ title: "t", secret: "system-value", open: "fine" }
);

expect(created.success, JSON.stringify(created)).toBe(true);
const row = created.data as Record<string, unknown>;
// `overrideAccess` means the field rules do not apply, so the value is
// both stored and visible to the default.
expect(row.secret).toBe("system-value");
expect(row.derived).toBe("saw:system-value");
});
it("hides a denied field's OWN default from a later default", async () => {
current = await createTestNextly({
collections: [
defineCollection({
slug: "defaulted",
fields: [
text({ name: "title" }),
// Denied AND defaulted. Nothing the caller sends fills it, so the
// pass that runs before the defaults has no key to judge.
text({
name: "secret",
defaultValue: "s3cret",
access: { create: () => false },
}),
text({
name: "copy",
defaultValue: d => `saw:${String(d.secret)}`,
}),
],
}),
],
});
const handler = current.getService<"collectionsHandler">(
"collectionsHandler"
) as unknown as CollectionsHandler;

const created = await handler.createEntry(
{ collectionName: "defaulted", routeAuthorized: true, user: CALLER },
{ title: "t" }
);

expect(created.success, JSON.stringify(created)).toBe(true);
const row = created.data as Record<string, unknown>;
// The denied field takes no value, exactly as when the caller sends one.
expect(row.secret ?? null).toBeNull();
// And the field the caller MAY write did not become a copy of it. Stored
// the other way round, the document would describe a `secret` it does not
// hold, and the rule guarding it would be defeated one column across.
expect(row.copy).toBe("saw:undefined");
});

it("keeps a value whose permission a DEFAULT establishes", async () => {
current = await createTestNextly({
collections: [
defineCollection({
slug: "gated",
fields: [
text({ name: "title" }),
text({ name: "kind", defaultValue: "public" }),
text({
name: "note",
access: {
create: ({ data }) =>
(data as { kind?: string } | undefined)?.kind === "public",
},
}),
],
}),
],
});
const handler = current.getService<"collectionsHandler">(
"collectionsHandler"
) as unknown as CollectionsHandler;

const created = await handler.createEntry(
{ collectionName: "gated", routeAuthorized: true, user: CALLER },
// `kind` is omitted on purpose: its default is what satisfies the rule
// guarding `note`.
{ title: "t", note: "should survive" }
);

expect(created.success, JSON.stringify(created)).toBe(true);
const row = created.data as Record<string, unknown>;
expect(row.kind).toBe("public");
// The rules that decide what is STORED run after the defaults, so the
// value the caller sent is still there to be allowed.
expect(row.note).toBe("should survive");
});
});
35 changes: 27 additions & 8 deletions packages/nextly/src/di/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ import type { UserFieldDefinitionService } from "../services/users/user-field-de
import type { UserService } from "../services/users/user-service";
import { assertNoLegacyFieldGroupKey } from "../shared/legacy-field-group-key";
import { assertPluginFieldDeclarations } from "../shared/lib/assert-plugin-field-declarations";
import { registerFieldFunctions } from "../shared/lib/field-level-registry";
import { replaceFieldFunctions } from "../shared/lib/field-level-registry";
import type {
AdminConfig,
AuthConfig,
Expand Down Expand Up @@ -828,6 +828,32 @@ export async function registerServices(
}
}

// The function-bearing half of the live config, installed the same way a
// reload installs it: one replacement built from the whole config.
//
// Outside the resolver gate above, deliberately. That gate is about runtime
// TABLES, and it has a supported failure path where the registry is absent;
// registering in there meant a caller's `access` rules, hooks, validators
// and function defaults silently did not exist whenever it took that path.
// None of this needs a schema registry to be true.
replaceFieldFunctions([
...(transformedConfig.collections ?? []).map(entity => ({
kind: "collection" as const,
slug: (entity as { slug?: string }).slug ?? "",
fields: (entity as { fields?: unknown[] }).fields ?? [],
})),
...(transformedConfig.singles ?? []).map(entity => ({
kind: "single" as const,
slug: (entity as { slug?: string }).slug ?? "",
fields: (entity as { fields?: unknown[] }).fields ?? [],
})),
...(transformedConfig.fieldGroups ?? []).map(entity => ({
kind: "fieldGroup" as const,
slug: (entity as { slug?: string }).slug ?? "",
fields: (entity as { fields?: unknown[] }).fields ?? [],
})),
]);

// Finalize the deferred Builder-lane targets now the DB is reachable (P8/D3).
// Builder/UI entities live in the dynamic_* registry tables (loaded by
// `loadDynamicTables` above), not in `config`, so their slugs weren't knowable
Expand Down Expand Up @@ -1736,11 +1762,6 @@ async function registerConfigTablesInResolver(
const dbName = (collection as { dbName?: string }).dbName;
const fields = (collection as { fields?: unknown[] }).fields ?? [];
if (!slug || !Array.isArray(fields) || fields.length === 0) continue;
// Capture function-bearing field configs (validate/access/hooks)
// from the LIVE config: the DB-backed registry serializes fields,
// which drops functions, so the write/read services resolve them
// through the field-level registry instead.
registerFieldFunctions("collection", slug, fields);
const baseTableName = dbName ?? slug.replace(/-/g, "_");
const tableName = baseTableName.startsWith("dc_")
? baseTableName
Expand Down Expand Up @@ -1802,8 +1823,6 @@ async function registerConfigTablesInResolver(
const dbName = (single as { dbName?: string }).dbName;
const fields = (single as { fields?: unknown[] }).fields ?? [];
if (!slug || !Array.isArray(fields) || fields.length === 0) continue;
// Same live-config capture as the collections branch above.
registerFieldFunctions("single", slug, fields);
const tableName = resolveSingleTableName({ slug, dbName });
// Why: forward the code-first `status: true` flag for singles too —
// mirrors the collection branch above. Same Draft/Published runtime
Expand Down
Loading
Loading