Skip to content
1 change: 1 addition & 0 deletions .changeset/preview-mint-entry-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
---

Minting a preview link now authorizes the entry it names, not just the collection: a caller bounded by a row-level rule can no longer mint a working link for a document they cannot read themselves.
149 changes: 149 additions & 0 deletions packages/nextly/src/api/preview-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Access gate for minting a preview link.
*
* A preview token is a bearer credential: whoever holds it reads the entry it
* names, with no session of their own, and the runtime consumes it with
* `draft: true` and `overrideAccess: true`. So minting must authorize the view
* the token will actually hand out, not a weaker one that happens to be easier
* to ask for.
*
* Two questions, because the token's view needs both and they are decided by
* different rules:
*
* 1. **May this caller read this entry at all** — including one that has never
* been published, which is the case previews exist for.
* 2. **May this caller edit this entry** — which is what the draft overlay
* itself requires before surfacing a working draft.
*
* Both are asked through `collectionsHandler`, the instance that actually
* serves collection reads and writes, so a verdict here is the verdict the real
* operation would reach. This mirrors `api/versions-access.ts`, which gates
* version history the same way and for the same reason.
*
* @module api/preview-access
*/

import type { AuthenticatedScope } from "../auth/authenticated-scope";
import { getService } from "../di";
import type { UserContext } from "../domains/singles/types";
import { errorFromServiceEnvelope } from "../errors/from-service-envelope";
import { NextlyError } from "../errors/nextly-error";

/**
* Turn a read outcome into a verdict, keeping "denied" and "could not ask"
* apart.
*
* A 403 or 404 is an answer: this caller does not get this row, and the two are
* deliberately collapsed because telling them apart would reveal which entry ids
* exist.
*
* Anything else is the read failing rather than refusing, and it keeps the
* status the service gave it. Flattening every other outcome to 500 would erase
* what the caller needs to act on — a rate limit's 429 and its retry interval
* become an opaque server error, and a validation failure loses its field
* detail. The shared converter rebuilds the service's own error, which is what
* the Direct API boundary does with the same envelope.
*/
function readVerdict(read: {
success: boolean;
statusCode: number;
code?: string;
message?: string;
messageKey?: string;
publicData?: unknown;
}): boolean {
if (read.success) return true;
if (read.statusCode === 403 || read.statusCode === 404) return false;
throw errorFromServiceEnvelope(
{ ...read, message: read.message ?? "Preview authorization read failed" },
{ reason: "preview-mint-probe-failed" }
);
}

/**
* Confirm the caller may be handed a preview link for this entry.
*
* Throws `forbidden` when they may not, with the same answer for a row hidden by
* a rule, an id that matches nothing, and an entry the caller may read but not
* edit. Those are different reasons and one answer on purpose: a caller who is
* refused a link learns only that they are refused.
*
* @throws {NextlyError} `forbidden` when no link may be minted.
*/
export async function assertEntryPreviewable(
collection: string,
entryId: string,
user: UserContext,
actor?: AuthenticatedScope
): Promise<void> {
const collections = getService("collectionsHandler");

// Enforced and as the caller, which is the same evaluation the bearer's read
// will face. `status: "all"` is the part a plain by-id read cannot express: a
// status-enabled collection otherwise filters to published only, so an entry
// that has never been published reports as missing — exactly the entry an
// editor most wants to share for review.
const read = await collections.getEntry({
collectionName: collection,
entryId,
depth: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authorize relationships populated by the preview read

When the entry contains a relationship to a row the caller cannot read, this depth-0 probe authorizes only the root document, but the standard preview resolver defaults to depth 1 and consumes the token through findByID with overrideAccess: true. Relationship expansion therefore skips the target collection's row-level read rules and can return the related row—including an unpublished one—even though the minting caller would receive only its reference ID; either authorize the same populated graph the bearer receives or prevent trusted relationship expansion for token-backed reads.

AGENTS.md reference: packages/nextly/AGENTS.md:L23-L24

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and deliberately not fixed here — this is #601's finding 6, owned by task 186 and the read-path lane's #671.

Your description matches the finding as filed: the probe reads at depth: 0 so relations stay as reference ids, while the preview resolver defaults to depth 1 and consumes the token through a trusted read, so expansion skips the target collection's row-level rules.

Why it is not fixed in this PR, and it is not scope avoidance. #650 already merged a bound on relationship expansion. Another lane has #671 open specifically to make that bound complete, with the required-field change measured at 35 construction sites across five files. Shipping a third independently-derived bound from a preview PR is how two rules end up disagreeing about the same behaviour — which is the failure #650 itself demonstrates.

Deferred to task 186 by explicit agreement with that lane, and named in #601's thread rather than closed, so it stays visible if 186 stalls.

One thing from this PR that the owner should have. The four-entry-point gate on any such fix is a hard requirement: ContentPage, generateMetadata, generateStaticParams (which calls nextly.find directly and does NOT go through resolveContent), and the working-draft overlay's by-id re-read. #650's two tests both asserted through ContentPage, which is exactly why the static path survived it. Mutate the fix and confirm four failures, not one.

Recorded in tasks/left-tasks/192-*.md alongside the observation that this, the hook-context finding above, and field-level redaction share one root: the preview consumption path discards the minter's identity and reads as an anonymous trusted caller. Each is a different consequence of that single fact, which is worth knowing before anyone fixes them individually.

overrideAccess: false,
user,
Comment on lines +86 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authorize the row resolved in the bearer context

When a read beforeOperation hook branches on user, this probe runs the hook as the minting caller and may authorize row B, but the token still signs the original entryId; the public preview path later calls findByID with user undefined and overrideAccess: true, so the same hook can redirect that ID to row C and expose C without its update check ever running. Fresh evidence beyond the earlier fix is that the bearer read does not share the minting user context, so extracting B from read.data.id does not ensure both reads resolve the same row; bind the credential to an authoritative row identity that consumption cannot re-resolve differently.

AGENTS.md reference: AGENTS.md:L201-L204

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this defeats the fix I just made — recording that plainly rather than claiming partial credit.

b26249deb made the edit gate authorize the id the READ returned, on the reasoning that the bearer's read would run the same hook and reach the same row. Your evidence is that the premise is false: the bearer read runs with user undefined and overrideAccess: true, so a beforeOperation hook branching on user resolves in a different context. The minter's context yields B; the bearer's can yield C. Binding B therefore binds the wrong row, and no amount of care at the mint gate fixes it, because the mint cannot observe the context the bearer will read in.

That means my fix narrowed one gap (raw id vs read id, same context) and left the real one open (minter context vs bearer context). It is strictly better than before and it is not sufficient, and it would be wrong to let the resolved thread above imply otherwise.

This is #601's open finding "Keep hook resolution identical when consuming preview links", which merged unfixed. It belongs there, not here, because the fix is not at the mint at all — it is making the bearer's read resolve hooks the way the authorized read did, which means the preview consumption path carries the minter's identity rather than reading as an anonymous trusted caller.

Leaving unresolved. Recorded in tasks/left-tasks/192-*.md with your evidence, and specifically the part I had wrong: a hook that branches on user makes "the same id" mean different rows in different contexts, so an id is not a safe unit of authorization across a trust boundary. The unit has to be the row, resolved in the context that will actually be served.

One consequence worth flagging for whoever takes it: the same reasoning applies to status and to field redaction, because all three are decided by a context the bearer read does not share. That is three findings with one root — the preview consumption path discards the minter's identity — and fixing them one at a time will keep producing this shape of finding.

authenticatedScope: actor,
status: "all",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve field-level read rules in newly enabled draft previews

For a never-published entry, status: "all" is what now lets minting proceed, but a successful getEntry only proves that some redacted representation is readable: the caller may update the row while one or more field-level access.read rules hide sensitive fields. The preview consumer subsequently calls findByID with overrideAccess: true, which bypasses applyFieldReadAccess, so the bearer receives fields the minting caller could not read; the parent implementation returned 404 for these unpublished rows, making this exposure newly reachable. Preserve the caller's field-level projection during preview consumption or refuse to mint unless the full preview payload is authorized.

AGENTS.md reference: packages/nextly/AGENTS.md:L23-L24

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed as a real defect, and deliberately not fixed in this PR — left unresolved so it stays visible.

This is #601's finding 4, which merged unfixed. Your framing is sharper than the original: a successful getEntry proves only that some redacted representation is readable. The bearer's read then runs with overrideAccess: true, which bypasses applyFieldReadAccess, so the token can deliver fields the minting caller could not see. status: "all" widening the lifecycle filter genuinely enlarges the population this applies to, so this PR makes the finding more reachable even though it does not cause it.

Why it is not fixed here. The founder decided the shape: the preview read enforces the minter's field access, rather than refusing to mint. That is not a change to this gate — it is a change to how the bearer's read is performed, in runtime/routing/**, and it must cover four entry points: ContentPage, generateMetadata, generateStaticParams (which calls nextly.find directly and does NOT go through resolveContent), and the working-draft overlay's by-id re-read inside resolveContent.

That last one is not pedantry. #650 shipped a fix whose two tests both asserted through ContentPage, so the static path stayed open and the bug survived a green review. The verification bar for this work is to mutate the fix and confirm four failures, not one.

Landing that inside a PR whose subject is the mint gate would mix a route-layer redaction change into an access-gate fix and make both harder to review. It needs its own PR, and its tests belong beside the routing entry points rather than at the mint.

Recorded with the four-entry-point gate and the #650 precedent in tasks/left-tasks/192-*.md. Also flagged there: this PR's helper inherits the same getEntry limitation behind finding 5 — the by-id path discards a stored custom rule's query constraint while listEntries, countEntries and the relationship service all apply it. Three of four read paths agree; getEntry is the odd one, and that is a query-service fix, not a preview one.

Comment on lines +90 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply custom read constraints before minting draft-only rows

For a status-enabled, never-published entry hidden by a custom read rule that returns a query constraint, status: "all" now makes the row reachable, but CollectionQueryService.getEntry treats that constraint as an allowed verdict and only folds owner-only constraints into its by-ID query. If the caller has broad update access, this probe succeeds and the trusted bearer read exposes the hidden draft; the parent implementation's published-only read returned 404 for the same row. Apply the custom constraint to the selected row, as the list path does, before minting.

AGENTS.md reference: AGENTS.md:L201-L204

Useful? React with 👍 / 👎.

});

if (!readVerdict(read)) {
throw NextlyError.forbidden({
logContext: {
reason: "preview-link-entry-not-visible",
collection,
entryId,
},
});
}

// The token's view is the DRAFT, and the draft overlay surfaces a pending
// working draft only to a caller trusted to edit the document. Reading the
// published row proves nothing about that: where a collection allows broad
// reads but restricts updates per row, a caller can read another author's
// published entry and would otherwise mint a credential exposing that
// author's unpublished edits.
//
// The subject is the REQUESTED id, deliberately — not one derived from the
// returned document. `read.data` is presentation data: `afterRead` may remove
// `id` or rewrite it to another row's, so no value in it can be trusted as the
// identity of what was fetched, and deriving the gate's subject from it
// authorized a row the bearer will not receive whenever a hook reshaped that
// field.
//
// The token signs the requested id, so that is the id whose editability is
// asserted here. Where a `beforeOperation` hook maps ids, the bearer's read
// resolves in ITS OWN context — `user` undefined, `overrideAccess: true` —
// which this boundary cannot observe. Closing that needs the consumption path
// to carry the minter's identity rather than a better guess here.
//
// `routeAuthorized: true`: the mint route already ran
// `requireRouteCollectionAccess(req, "update", collection)`, and this flag
// skips ONLY that coarse RBAC/code-access gate. The stored owner-only,
// role-based and custom rules still evaluate against the loaded document with
// the real user, which is the part that answers this question.

const mayEdit = await collections.canUpdateEntry({
collectionName: collection,
entryId,
user,
routeAuthorized: true,
Comment on lines +132 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve failures from the update probe

When a stored custom update rule throws or its metadata lookup fails, CollectionAccessService.checkCollectionAccess returns a 500 failure envelope, but canUpdateEntry collapses every non-null result to false, so this new route reports the outage as a 403 denial. Fresh evidence beyond the routeAuthorized: true fix is that this flag skips only the coarse RBAC check; stored-rule evaluation still runs and can return that 500. Use a probe that preserves the service failure status instead of reducing every outcome to a boolean.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and not fixable at this boundary — leaving unresolved rather than closing it with a workaround.

Verified: canUpdateEntry returns Promise<boolean>, so a 500 envelope from a stored custom update rule and an honest denial arrive here as the same false. This route cannot tell them apart, and any attempt to (a timeout, a probe, a heuristic) would be guessing at a distinction the service already has and discards.

That makes it the same defect class as the read side, which I DID fix on this PR — readVerdict now rebuilds the service's error through errorFromServiceEnvelope so a 429 keeps its status. The asymmetry is not a design choice: getEntry returns an envelope carrying statusCode and code, and canUpdateEntry returns a bare boolean. The read side could preserve failure semantics because the service exposed them; the update side cannot because it does not.

So the fix belongs in collections-handler.ts / collection-mutation-service.ts: canUpdateEntry should return the verdict AND the failure, the way the read path does, rather than collapsing every non-null result to false. That is a service-signature change affecting every caller of that method, not a preview concern, and it should not ride a PR about the mint gate.

Recorded in tasks/left-tasks/192-*.md with the observation that matters for whoever takes it: "denied" and "could not ask" are different answers, and a boolean return type makes it impossible for any caller to keep them apart. The type is the defect, not the call site — which is why fixing it here would only move the guess.

Worth noting the consequence you named is real and user-visible: an outage in a custom update rule currently reports to the operator as a permission denial, which sends them looking at roles rather than at the rule that threw.

authenticatedScope: actor,
});

if (!mayEdit) {
throw NextlyError.forbidden({
logContext: {
reason: "preview-link-draft-not-editable",
collection,
entryId,
},
});
}
}
Loading
Loading