npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

crossline

v0.2.2

Published

Prove that no user can see another user's data — automatically, on every commit.

Readme

Crossline

Prove that no user can see another user's data — automatically, on every commit.

npx crossline

No configuration. No staging environment. No auth profiles to set up.

Crossline 0.2.1  postgres://postgres:***@localhost:54329/app

  ✗ documents
      critical  anyone, signed out, can read, modify, delete  (2 rows reachable)
      critical  any signed-in user can read, modify, delete, forge ownership of

  ✗ tasks
      critical  anyone, signed out, can read, modify, delete  (2 rows reachable)

  57 checks, 14 failing · 8 of 9 tables checked · 11 owner checks passing

  ! 1 table(s) not fully checked:
      audit_events: Ownership could not be determined from the schema, so the
      cross-user rule was not asserted here. Confirm this table in the model.

What it does

Crossline reads your schema, works out who is supposed to see what, then creates two real users and systematically tries to make one reach the other's data — through your API, and through your database directly, which is where row-level security holes actually live.

Anything that succeeds is a failing check with the exact request that shouldn't have worked, and a policy that closes it.

  1. Reads your schema. Tables, columns, foreign keys, constraints, existing policies, and role grants.
  2. Infers ownership. Which tables belong to one user, which belong to an org, which inherit ownership through a foreign key, and which are shared reference data. You confirm it once, in about a minute.
  3. Plants known data. Two users in two different orgs, with rows carrying identifiers only Crossline knows.
  4. Crosses every line. Read, modify, delete, and ownership forgery, as an anonymous client and as a signed-in stranger.
  5. Writes the fix. Generates the policy, applies it to a throwaway copy, re-runs the whole suite, and only shows it to you if the holes closed and every owner still reached their own data. Where the honest answer depends on intent — is a note private to its author, or shared with the workspace? — it writes the private form and offers the shared one alongside it, both tested, and leaves the choice to you.

Why the findings are trustworthy

The hard part of this problem isn't attempting the request. It's knowing whether the response was a leak.

Crossline seeds its own data, so it always knows which primary key and which canary string belong to whom. A finding means a request returned a row we planted for the other user, matched by identifier. There is no similarity scoring, no heuristic, and no judgement call — which is why the suite comes back completely clean on a correctly-secured schema.

Four more things keep it quiet:

  • The two users are in different orgs. The only invariant asserted is that two users with no relationship cannot reach each other's rows. That's true of every multi-user app. Whether teammates should see each other's data needs intent, so Crossline doesn't guess at it.
  • Visibility flags are forced private. A posts table with a published column is legitimately world-readable for some rows, so the seeded row is planted unpublished. A row we marked private cannot leak "on purpose". With no database to write a column in, the resource is created a second time through the application with the flag it showed us set the other way; where the application refuses to make one private, nothing is claimed about it at all.
  • A privilege Crossline granted itself is taken back before anything is reported. Seeding is what makes the oracle exact, and it is also the one way this tool can invent a hole. platform_admins (user_id uuid primary key references users(id)) is shaped exactly like an ordinary user-owned table, so a row went in and promoted the test user to platform administrator before a single probe ran — and a correctly-guarded support function then handed over every tenant's rows, reported as high against a database that does not have that hole. So a crossing that succeeds is re-made with those rows withdrawn, and only reported if it succeeds again. What counts as "those rows" is settled by the database rather than by the table's name: the row is put back as the signed-in role, and if the grant or a policy refuses it, no request could have produced that state. If the test user can enrol themselves, the state is a real user's to reach and the crossing stays a finding — the self-enrolment being the more serious of the two bugs. Nothing is withdrawn quietly: the table, and every check that stopped failing, are printed on the face of the result. The persona's own user row, their org, and their membership of it are never withdrawn, because those are what make them an ordinary signed-up user rather than nobody at all.
  • Silence beats a guess. When ownership can't be determined from the schema, Crossline says so and asserts nothing, rather than inventing a rule.

When your users don't live in your database

Clerk, Auth0, Cognito and Firebase Auth all leave the same shape behind: the owner column is a bare text holding the provider's sub, with no users table for it to reference. There is no foreign key to follow, so every table would degrade to "ownership could not be determined" — while the developer had already written the answer down, in SQL, in the policy beside it:

create policy "User can view their own tasks" on public.tasks
  for select to authenticated
  using ((select auth.jwt()->>'sub') = (user_id)::text);

Crossline reads that. A foreign key is still the stronger evidence and still wins; policy text fills the gap where there is no key, and where the two disagree the disagreement is printed rather than silently resolved.

Believing a policy is a new way to be wrong, so what it takes to be believed is narrow and structural. Permissive policies are OR-ed together by Postgres, so they have to be unanimous: every USING and every WITH CHECK a signed-in caller can reach must confine rows to the same single column, and one using (true) beside a perfect policy means nothing is learned — because true is what the database will actually honour. The expression has to be an equality between a column of that table and the caller's own subject claim, after parentheses, ( select … ) wrappers and casts are peeled; a membership subquery, a nested claim, an any(array[…]) or a shape we do not recognise yields nothing. And the identity has to be proven to answer who: a helper qualifies only if its body reads the sub claim, which is what separates auth.uid() from auth.role() — they are found by the same structural rule and the second one returns authenticated for everybody.

And one thing that matters just as much in the other direction: a check that couldn't be made is never reported as a check that passed. An attempt that failed for a reason unrelated to access control — a handler that returned 500, a trigger that raised, a request that timed out — proves nothing, so it's counted and displayed separately rather than folded into the pass count. Coverage is reported in tables as well as checks, because "0 failing" across four tables of a twelve-table schema is a very different claim from a clean run. A false positive gets investigated; a false negative gets trusted.

The share link problem, and what happens instead of a finding

There is one case where "a row we planted for the other user came back" is a true fact and still the wrong finding. Against an endpoint whose only input is the row's identifier — GET /api/public/forms/{id} — Crossline supplied that identifier itself, and had it only because it planted the row. Every share link, invite link, password reset and unsubscribe URL on the internet works exactly that way. Reporting one as "anyone can read another user's data" is a false positive against a feature somebody asked for on purpose.

The naive rule — stop counting a read whenever the caller supplied an id — would suppress insecure direct object reference, which is the most common real authorization bug there is. So the question is never was an id supplied. It is could a stranger have obtained it, and Crossline demotes a granted read only when all three of these are established from the run itself:

  1. Entropy, read off the schema. The column's type and default, not the value we planted — a gen_random_uuid() default is 122 random bits; a sequence enumerates; gen_random_bytes(2) is 65,536 values and is treated as guessable.
  2. Reachability, read off this run. No request this caller could make handed the identifier back. If the app gives the id out, it is not a secret however random it looks.
  3. Deliberateness, read off this run. Another endpoint performs the same lookup and answers it differently by who asks — it serves the owner and refuses this caller. Two id-addressed doors onto one table, one gated and one not, is what sharing looks like from outside. A merely scoped collection is not enough, deliberately: a list showing only your rows beside an item lookup showing anybody's is the textbook broken object-level check.

All three, or it stays a finding. What it becomes is not silence either — the endpoint, the caller and the reason are printed on the face of the result, on a pass as prominently as anywhere else, because this is the one thing in a run only the developer can settle:

  i 2 endpoint(s) return another user's row to anyone holding the link — confirm that is intended:
      GET /api/public/forms/:id → Form
        Served to anyone, signed out or signed in, who has the identifier.
        The link is the credential: id is a uuid — 122 random bits, which cannot be
        enumerated or guessed from another one.
        Nothing in this run handed that identifier to the caller, and GET /api/forms/:id
        does refuse them — so this reads as a share link rather than a hole. No policy is
        proposed for it.

The residual risk is a genuine hole on a resource guarded elsewhere — an unprotected /download beside a protected /:id — and it is bounded by the class being named rather than dropped. This rule is not theoretical: it is the one that took our own corpus measurement from 17.8% to 6.7%, which is written up in docs/corpus-wave-1.md.

Install

npx crossline init   # see the model, confirm it
npx crossline        # run the checks
npx crossline fix    # generate and verify the policies

On a Next.js + Supabase project this needs no arguments and no configuration: Crossline reads supabase/config.toml for the port your local stack's Postgres listens on, which is the only machine-readable database there is in a repository whose .env.local holds the project URL and the anon key and no connection string at all.

Otherwise, point it at your database with DATABASE_URL, --db, or crossline.config.json. In precedence order, first one wins:

| | | | --- | --- | | --db <url> | the flag | | crossline.config.json | the db key, usually "env:DATABASE_URL" | | the environment | CROSSLINE_DB_URL, DATABASE_URL, SUPABASE_DB_URL, POSTGRES_URL, POSTGRES_URL_NON_POOLING | | the repository | supabase/config.toml, then .env.local, .env.development, .env — searched in the current directory, up to the repository root, and one or two levels down into a workspace |

Every run prints which one it used, and where, before it touches anything:

Using DATABASE_URL from .env.local (localhost:5432/app_dev)

A database found in a file rather than named by you is not one that --api or fix --apply will write to unless it is on this machine — their writes are real and cannot be rolled back. Pass --allow-remote-db if you meant it. Nothing found in a file is ever written into crossline.config.json: a file path is not a name that would resolve the same way in CI.

If none of those names a database, and your application does not have one for Crossline to read — a Rails app on MySQL, a service on DynamoDB — npx crossline --api http://localhost:3000 checks it through its own endpoints instead and needs no connection string. See If there is no database Crossline can read at all. A database that is named and merely unreachable is an error, never a quieter check.

The whole command surface

Nine commands, and no hidden ones:

| | | | --- | --- | | crossline (or crossline test) | run the checks. The default command. | | crossline verify | check a running database against what was proved. Read-only — safe to point at production | | crossline init | print the inferred model and write the config file | | crossline ci | write the GitHub Actions workflow that runs this on every pull request | | crossline fix | generate policies, verify each on a throwaway copy, write the ones that passed to .crossline/fix.sql. --apply also applies them to the database | | crossline report | re-render the last run from .crossline/last-run.json | | crossline schedule | run verify on a clock, so a change nobody deployed is still caught. --every <hourly\|daily\|weekly\|cron>, --remove, or nothing to see what is scheduled | | crossline enroll | connect this installation to the hosted service. The service does not exist yet — the command reports that it could not reach it | | crossline mcp | run as an MCP server on stdio, for a coding agent to call |

Shared flags: --db <url>, --schemas <list>, --read-only, --api <url>, --allow-remote-db. test adds --json, --sarif <file>, --markdown <file>, --verbose, and --fail-on <severity> (default medium; none never fails the build). verify takes only --db, --schemas and --json; it has nothing to --read-only, because it is nothing else. fix adds --apply and --out <file> (default .crossline/fix.sql). report re-renders with --markdown or --sarif to stdout. ci takes neither a database nor the shared flags — it only reads files — and adds --yes and --migrations <supabase|prisma|drizzle|sql|none>. schedule, enroll and mcp take no shared flags either; schedule takes --every and --remove.

Exit codes: 0 pass, 1 findings at or above --fail-on, 2 inconclusive — and 2 is also what any run that errors out exits with, so treat it as "no answer" rather than specifically "not established".

Every run writes .crossline/last-run.json: the whole report, which is what crossline report and the GitHub Action read back.

Is the database you deployed still the one you proved?

crossline proves isolation before it ships, against the schema as written. It cannot be pointed at production: it plants rows to do its work.

crossline verify is the other half, and it is read-only.

npx crossline verify --db "$PRODUCTION_DATABASE_URL"
  ✓ The authorization model running here is the one that was proved.
      Every table's row-level security, every policy expression, every grant and every
      SECURITY DEFINER function matches crossline.lock, recorded 2026-08-04 22:41.

  ✓ Nothing in the catalogue is open on its face.

  9 table(s) examined in public, auth. No row was read.

This exists because a schema proved correct in CI and a schema running in production are not the same object. One real database we looked at had seven migrations applied by hand through a SQL editor and never recorded — so a green build and a leaking database could coexist, and nothing would say so.

Where the record lives. A passing crossline writes crossline.lock into your repository. Commit it; your agent's next git add . already will. It is a statement about your schema, and your schema is in git — the same reason package-lock.json lives beside package.json. That is also what makes it work on three different machines: CI is not your laptop and production is neither, and anything written into .crossline/ (which is gitignored) would be invisible to the other two. There is no file to copy anywhere.

The side benefit is the one people notice: when a migration drops a policy, the lock changes in the pull request. Nobody reads a migration for the policy that is no longer there. Everybody reads a removed line in a lockfile.

What it checks, in two claims of different strength.

Has it drifted? Per table — row-level security enabled and forced, every policy expression verbatim, every grant, and which roles can execute each SECURITY DEFINER function. Anything that moved is named: a dropped policy, a widened grant, a table that appeared with no policy at all. Nothing else is in there, so adding a column or an index reports nothing, by construction.

Is anything open on its face? Row-level security off on a table that holds one user's rows while an ordinary role holds SELECT; a policy whose USING is the constant true; a policy whose expression names no column of its own table and therefore cannot tell one user's rows from another's. Each one is gated on the schema proving who owns the rows — which is why the plans price list in our own fixture, with USING (true) to anon, is correct and stays silent.

What it will not claim. It cannot prove isolation. It reads the catalogue; it never plants a row and never crosses a line, so it confirms the model is intact and nothing is open on its face, and it says exactly that every time it runs. It also says what it declined to settle: a SECURITY DEFINER function reachable by anon runs outside row-level security, and whether it checks its caller is not readable from the catalogue — crossline settles that by calling it with planted rows, and this command names it and stops.

Exit codes: 0 clean, or no lock recorded yet (a fact about your repository, not your database — a gate that fires on it gets switched off). 1 for findings, or for any drift at all, including drift that narrows access: "the model we proved is the model running" is false either way, and re-running crossline re-records the lock.

It is read-only, and you can check

Three guards, any one of which would do:

  • every statement it sends must begin with select — anything else throws before it reaches the wire;
  • the session runs inside set transaction read only, so the server refuses a write even if the first guard were deleted. Nothing is written and rolled back; nothing is attempted at all;
  • it reads pg_catalog and never information_schema, so it works for a role that holds nothing but CONNECT.

That last one is the point. Make a role that cannot read one row:

create role crossline_verify login password '…';   -- and nothing else

select * from documents as that role is permission denied. crossline verify run as that role returns byte-for-byte what it returns as the owner. The suite asserts exactly that.

Is it safe to run?

The data-plane checks happen inside a single transaction that is rolled back. Seeded rows, attempted updates, attempted deletes — none of it reaches disk. Crossline leaves your database exactly as it found it.

Three caveats worth stating plainly:

  • A rollback does not un-fire a trigger. If your tables have triggers with outside effects, use --read-only.
  • Some tables refuse every row anyone could invent — a format check, a trigger enforcing a business rule. Where that happens, and only where it happens, Crossline briefly drops that CHECK constraint or disables that trigger inside the same rolled-back transaction, plants its row, and puts it back before it asks the schema a single question. It never touches row-level security, a policy, a grant, or a foreign key — those are the thing being tested, and no code path exists that could express it. Every table this happened to is named in the output. A lock it cannot take immediately, or a database user that does not own the table, costs that one table and is reported; --read-only disables it entirely.
  • The API-plane checks (--api) cannot use that trick, because your app is a separate process that has to see committed rows. Those writes are real, and they include the session row Crossline inserts to become a logged-in user when your app keeps its sessions in the database. Point --api at a preview deployment or a database branch, never production. Crossline removes everything it created — seeded rows, test users, and the session — and reports anything it couldn't.

On a clock

A hole introduced by code shows up when you deploy, and the pull request check catches it. Somebody running SQL by hand in the Supabase dashboard is invisible to every deploy hook there is, and only something on a timer ever finds it. One real database we examined had seven migrations applied exactly that way, none of them recorded.

npx crossline schedule --every daily

That writes .github/workflows/crossline-watch.yml, which runs crossline verify — the read-only half — against the database you point it at. Two things it cannot do for you, and it says so: commit the file, and add your production connection string as a repository secret named DATABASE_URL. crossline schedule on its own says what is scheduled; --remove stops it. A workflow you have edited is yours, and neither writing nor removing will touch it.

Or from inside your own application

If you would rather not put your production connection string into CI, the check can run where the connection already is:

// app/api/crossline/route.ts
import { watch } from "crossline/runtime";

export const GET = watch();

with a cron entry in vercel.json pointing at it. The handler is a plain (Request) => Promise<Response>, so Remix, Hono, SvelteKit and Cloudflare Workers take it unchanged. It needs CRON_SECRET set in your deployment and refuses to run without one rather than answering unauthenticated, and it replies with a verdict and nothing else — no findings, no table names, nothing about the database when it cannot connect. docs/inside-your-app.md has the detail.

There is a crossline enroll command, which connects an installation to a hosted service that records these results over time. That service is not available yet: the command exists, the exchange is built and tested against a stub, and running it today tells you it could not reach anything. Everything above works without it.

In CI

npx crossline ci

That writes the whole workflow — the throwaway database, your migration step, permissions, checkout, Node, and the Crossline step — into .github/workflows/crossline.yml, and tells you what it read your repository as so you can check it in the diff:

  Run Crossline on every pull request.

    Migrations   Supabase — supabase/config.toml; supabase/migrations/ holds 14 .sql files
    Node         22 — from .nvmrc
    Branch       main — from origin/HEAD

  ✓ Wrote .github/workflows/crossline.yml.

It knows four shapes — supabase/migrations beside a supabase/config.toml, prisma/schema.prisma, drizzle.config.*, and plain .sql migrations — and tells the two variants of each apart, because prisma migrate deploy on a project with no prisma/migrations exits 0 having applied nothing, and an empty database is a check with nothing to check. Where it cannot tell, it leaves the step commented and marked FILL THIS IN rather than guessing: an inferred migration step is the one thing in that file which cannot be verified without running it, and every finding is a claim about the schema it applies. Pass --migrations <tool> to overrule the guess.

It never overwrites. A workflow that already runs Crossline is left alone and named; a crossline.yml belonging to somebody else is written alongside rather than over; with nowhere free it prints the file and leaves the writing to you. Coding agents can do the same through the crossline_setup_ci MCP tool.

The step itself, for a workflow you would rather assemble by hand:

- uses: jdora09769-bot/crossline@v1
  with:
    database-url: postgres://postgres:postgres@localhost:5432/postgres
    fail-on: high

Results are uploaded as SARIF, so findings appear in GitHub's Security tab, and posted as a pull request comment. See docs/example-usage.yml for the fully assembled version.

fail-on is the one knob that decides whether a build goes red, so the ranking behind it is worth stating rather than leaving to be discovered:

| | | | --- | --- | | critical | anyone signed out reaching a row that belongs to someone, and any signed-out write at all; a signed-in stranger modifying or deleting a row | | high | a signed-in stranger reading a row that belongs to someone; forging ownership on insert; a signed-out read of data that belongs to nobody in particular |

medium exists as a floor and nothing currently lands there: the only classifications left below high are ones whose cross-user reads are the intended behaviour, so they never become findings at all.

The consequence that matters: a row that belongs to an org, or that is reached through a membership roster, counts as belonging to someone. A cross-tenant read is ranked high, not medium, so it fails a fail-on: high build. That is deliberate, and it is the kind of thing that quietly stops working — there is a test whose whole job is to assert that no cross-user read is ranked below high anywhere in the model.

For coding agents

Crossline ships an MCP server, so the agent that wrote your access control can check it before it commits.

{
  "mcpServers": {
    "crossline": {
      "command": "npx",
      "args": ["-y", "-p", "crossline", "crossline-mcp"],
      "env": { "DATABASE_URL": "postgres://..." }
    }
  }
}

Nine tools. Five are about a check: crossline_check, crossline_explain, crossline_fix, crossline_declare, and crossline_verify — the last asks whether the running database still matches what was proved, without re-proving it, and it is the only one that may be aimed at production.

Two set things up. crossline_setup_ci writes the GitHub Actions workflow described above, so an agent asked to set this up does it properly rather than improvising YAML; it returns the workflow as text by default, only saves it with write: true, and will not overwrite an existing workflow either way. crossline_schedule does the same for the clock — show, set, remove — and refuses to touch a workflow it did not write.

The last two, crossline_enroll and crossline_enroll_status, connect an installation to the hosted service. That service does not exist yet, so they report that they could not reach it; their descriptions say so, and say to use crossline_schedule instead, which needs no service at all.

crossline_declare is the one that reads the agent rather than the repository. It wrote the endpoint, so it knows three things no file records: the route, a body the handler accepts, and — for a Next.js server action — the argument list, which next build does not write down. Those are the two surfaces that were named and unchecked without it, and both are described under a framework Crossline can't read. What is declared is treated as data, not truth: joined to what discovery found, reported when it contradicts the parser, and unable to move the bar for a finding.

The first three read crossline.config.json from the project directory, so an agent gets the same two planes a human does: the database always, and the running app when api.target is set (or passed as the api argument, with appDir if the route handlers live somewhere other than the project root). That second plane is the whole answer for a Prisma, Drizzle or Express app, where authorization lives in route handlers and there may be no row-level security at all — and its requests write for real, so it wants a dev server or a preview deployment, never production. With nothing configured the run stays database-only and says so rather than guessing a URL.

Sign-in credentials — the JWT secret, the Supabase secret key, the header template — are only ever read from the config file. There is no tool argument that carries them, so an agent cannot hand one over even by mistake, and they do not appear in a response: the tests assert that a signing secret, a replayable session token, and a password in a target URL are all absent from every tool result. The database connection string is the one exception, because db is a tool argument; a password in it is masked on the way out, but if you would rather it never reached the tool at all, put it in crossline.config.json as env:DATABASE_URL and leave the argument unset.

Add this to your CLAUDE.md or equivalent:

After changing any migration, RLS policy, or data-access code, call crossline_check before committing.

Writing that config file by hand is the step that stops this being a default. server.json at the repository root is the official MCP Registry entry that removes it; what the registries require, and what has to be true before the entry can be published, is in docs/mcp-registry.md.

Configuration

crossline.config.json is optional. It exists for two things: correcting the inferred model, and pointing at a running app.

{
  "db": "env:DATABASE_URL",
  "schemas": ["public"],
  "overrides": {
    "public.posts": { "classification": "public" }
  },
  "roles": { "anonymous": "web_anon", "authenticated": "app_user" },
  "api": {
    "target": "http://localhost:3000",
    "auth": { "kind": "hs256_jwt", "secret": "env:SUPABASE_JWT_SECRET" }
  }
}

Every key under api is optional too, including api itself. On a database that enforces nothing of its own, Crossline starts the application and detects the auth provider without any of this; target, server and auth are for correcting what it worked out, or for a stack it could not.

Only tables Crossline was unsure about get written into overrides, so inference stays live for everything else and a table added next week is still classified.

userTable names the table that holds your application's users, e.g. "core.app_users". Normally it is found without help: auth.users when present, otherwise the table that other tables point owner-shaped columns at (user_id, author_id, created_by, …), corroborated by name, email, and credential columns. init states which table it chose and why, and writes the answer into the config so correcting it is a one-line edit rather than a setting you'd have to know existed. A configured name that doesn't exist is an error, never a silent fallback.

This is the decision everything else rests on: with the wrong user table, ownership can't be inferred for anything, every table degrades to shared or unknown, and the run has nothing left to assert. So a run that can't identify one is inconclusive rather than clean — as is any run that ends with zero tables actually checked.

roles is the database roles your requests actually run as. These are inferred from grants and existing policies — anon/authenticated on Supabase, web_anon/app_user on a stock PostgREST deployment — and only need setting when the names are unconventional enough that inference can't tell which is which. When that happens Crossline says so and lists the roles it found, rather than guessing and testing as the wrong identity.

Classifications: user_owned, org_scoped, child_owned, membership, reference, public, unknown.

"mode": "read_only" is --read-only written down, for a repository where it should always apply: no write, delete or forgery probe is attempted on either plane, and the report says [read-only] on its own header line so nobody reads the narrower run as the full one.

Two more keys exist for the awkward corners of api.auth, and are worth knowing before you conclude something is unsupported: hs256_jwt takes an optional anonKey sent alongside the token, and authjs_session takes columns (token, user, expires) for an adapter that named them differently, plus a template if the session travels somewhere other than the default cookie.

One more key, for the rare table that genuinely cannot be checked — a primary key no oracle can point back at, a constraint no generated row can satisfy:

{
  "acceptUnchecked": ["public.pulses"]
}

A private table that goes unchecked makes the whole run inconclusive (exit 2), because "no user can reach another user's data" was not established for it. Listing it here records that a human saw the gap and chose to proceed: the verdict can pass again, but the table stays in the coverage list on every run.

One case is deliberately not that, and it used to be the commonest reason anyone ever reached for this key. On a run where the API plane is the only thing that could settle anything, a private table that no discovered endpoint addresses is not a check that failed — it is outside the surface the claim is about, and the tick already says through the application. Every auth library brings tables like that (users, sessions, accounts), no application has CRUD endpoints for them, and on Supabase they never showed at all because auth.users lives outside the scanned schemas.

So the run says it instead, on the face of the pass, every time:

  ! What this run did not establish
      Nothing was asked of public.accounts, public.sessions, public.users. None of
      the 4 endpoint(s) discovered addresses those tables, so there was no request
      to make and no crossing was settled there either way — public.sessions and
      public.users are owned by your sign-in mechanism rather than by the
      application, which is why no route serves them. If an endpoint does serve one
      under a path that does not name it, the route list below shows which
      endpoints resolved to no table; naming it under `routes` in
      crossline.config.json gets it checked like any other.

That is stricter than what it replaced, not looser. Blocking pushed developers towards acceptUnchecked, and a table named there is silenced permanently — so an invoices table served by /api/billing/:id, a path no table name matches, would have been written into the config once and never asked about again. The statement above is re-read on every run and points at the key that gets the table checked. It applies only where the data plane structurally could not run, the API plane did carry the claim as a signed-in user, route discovery read every registration it saw, and no discovered route resolves to the table. A table an endpoint does serve, whose checks then collapsed, blocks exactly as before.

What it supports

| | | Exercised in the suite against | | --- | --- | --- | | Databases | Postgres | plain Postgres, a Supabase-shaped schema, and a real pgbouncer in transaction mode | | Schema shapes | composite primary keys, keys that are not uuid, tables outside public, arrays and enum arrays, CHECK constraints, foreign-key cycles, SECURITY DEFINER functions, and partitioned tables — range- and list-partitioned, where the policy belongs on the parent | a fixture per shape, each in a leaky and a corrected form: the leak fully caught, the corrected twin completely clean, and the generated policy applied to a throwaway copy and re-tested before it is offered. Every table that cannot be seeded is named as unchecked rather than counted as passing | | Auth | Supabase Auth with no secret key at all (publishable key only), Supabase Auth via the Admin API (asymmetric or legacy secret), any HS256 bearer token, Auth.js database sessions, Clerk, Auth0, Cognito, Firebase Auth, custom headers — detected from package.json and .env where possible | a real gotrue in Docker, three times, once of them with MAILER_AUTOCONFIRM=false as a hosted project is; a real Auth.js session table; the four external providers against stubs of their documented endpoints, end to end through a leaky app and its corrected twin, but never against a live tenant; detection covered per provider, including the refusals (a Clerk production instance, Auth.js's JWT strategy); the header template is implemented but has no test of its own | | API routes | Next.js App Router, Next.js Pages Router, SvelteKit, Nuxt/Nitro, anything emitting OpenAPI 3.x (Nest, FastAPI, Django REST, Go), Express-style, plus routes you list — read out of each framework's own route table, so basePath, router prefixes and global prefixes are included | the real next and express packages; a checked-in build from Next 15.5.22, plus 13.5.11 and 14.2.35 manifest shapes; real artifacts from @sveltejs/kit 2.63.0, nuxt 4.5.1 / nitropack 2.13.4, FastAPI 0.141.1, @nestjs/core 11.1.28 with @nestjs/swagger 11.4.6 | | API routes Crossline declines | Rails, Laravel, Django URLconfs, Remix — each named in the report with the command that answers and where to put the result, because the only exact answer boots your application | a fixture per framework asserting it is named, never silently zero | | Monorepos | workspace packages, from pnpm-workspace.yaml, package.json workspaces, or turbo.json | a repository root that finds exactly what the app's own directory finds, end to end against the running app | | Starting your app | dev, or start with no build step; any command you name | three skeleton projects — Next + Prisma + Auth.js, Next + Drizzle + Supabase, Express + raw pg — each started by the binary from a bare crossline --db … and each reaching exit 0 with no configuration file at all | | Output | terminal, JSON, SARIF, markdown | all four |

Neon and RDS are not in that right-hand column and are not claimed as tested. What is tested is the mechanism they need — TLS negotiation against a server that may or may not speak it, and a pooled connection string — because those are the two places connecting to managed Postgres actually failed.

The data plane works against any Postgres, with or without Supabase. Role impersonation uses SET LOCAL ROLE plus the request.jwt.claims setting, which is exactly how PostgREST evaluates a request — so policies are tested as they actually run, not approximated.

If your authorization lives in application code

Prisma, Drizzle and raw pg all default to one connection string, one trusted database role, and no row-level security — every decision is a where: { userId: session.user.id } in a route handler. Crossline does not report that as a pile of exposed tables. There is no second database principal in that architecture, so "user A" and "user B" are not database identities and the data plane has no line to cross; announcing a hole there would be reporting something that isn't true as stated.

The claim is still perfectly testable, just on the other plane — asking as one user for another user's row is a fact whichever layer enforces the answer. So:

npx crossline

That is the whole command. On a database with no authorization model of its own, Crossline starts your application itself rather than asking you to: it reads the start command off package.json, runs it, waits for a port it chose, and stops it afterwards. It works out how to be a logged-in user the same way — from your dependencies and your .env — because unlike the connection string, the auth provider genuinely is written down on disk. Both are stated on the face of the result, because running a command out of somebody's package.json is not a thing to do silently:

  App: started your application with `npm run dev` (package.json has a `dev` script)
       and waited for http://127.0.0.1:52324.
  API: 4 of 4 discovered route(s) checked · 2 of 5 tables reached through the app
  Signed in as the test user with an Auth.js database session planted in
  public.sessions — next-auth is a dependency and the adapter's session table is
  in the schema.

Nothing is started when the database does enforce its own authorization: the data plane can answer there, so no command is run. Nothing is started for a project that does not serve HTTP either — a web framework has to be a runtime dependency, not a devDependency. --api http://localhost:3000 still points at a server you are already running, and one already listening at a configured api.target is reused rather than duplicated.

A run that could neither reach nor start an application comes back inconclusive (exit 2) and says why, because it genuinely established nothing. A run that reached your endpoints and settled the crossings can pass — and says, on the face of the passing result, exactly which claim it did not make:

  ✓ No user can reach another user's data through the application.

  26 checks, 0 failing · 3 tables checked · 5 owner checks passing

  ! What this run did not establish
      The database enforces nothing itself: no row-level security, and no
      database role for a request to run as other than the application's own.
      So nothing here is a claim about a direct database connection — anything
      holding this connection string reads and writes every row, whether that
      is a leaked .env file, an edge function, or an analytics job. What was
      checked, and what the result above is about, is the application: asking
      as one user for another user's rows.

That is deliberately not a finding and deliberately not silence. If roles with real access to your tables do exist and Crossline can't tell which is which, that is a different situation — the data plane applies and did not run, so the run stays inconclusive no matter how clean the API result was.

Starting your app

Playwright's webServer, to the letter, because it is the proven shape for this and because a developer who has configured one has configured this:

{
  "api": {
    "server": {
      "command": "pnpm dev",              // read off package.json when omitted
      "url": "http://localhost:3000",     // a free port is chosen and passed as $PORT when omitted
      "timeout": 120000,
      "reuseExistingServer": true
    }
  }
}

Every field is optional, and "server": false refuses to start anything at all.

The command is inferred only where the answer is unambiguous: a dev script, or a start script in a project with no build script — which is what an Express service looks like, and where start cannot mean "serve a build that does not exist yet". A start beside a build is exactly the ambiguous case, so it is asked rather than guessed at: running somebody's build because a test wanted a server is not on offer. Whatever is chosen is printed with the reason it was chosen, and a command that dies is reported with its own last output rather than as a timeout.

Being a logged-in user

The check that matters most is one signed-in customer reaching another's rows, so Crossline has to be able to be somebody. It will not ask you for a credential and it will not forge one — but it will look, because which provider an application uses is written down in package.json and in the .env beside it:

| found on disk | what happens | | --- | --- | | next-auth / @auth/* and a session table in the schema | a session row is planted; no secret needed | | next-auth and no session table | refused, naming AUTH_SECRET — that is the JWT strategy, whose cookie Crossline will not forge | | @supabase/supabase-js and SUPABASE_JWT_SECRET | an HS256 token is minted | | @supabase/supabase-js, NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY | your project's Auth server issues a real session — no secret key needed, and this is preferred even when a secret key is also set | | the same, but your users are not in auth.users, plus SUPABASE_SECRET_KEY | your project's Auth server is asked for a real session through the Admin API | | @clerk/* and CLERK_SECRET_KEY | Clerk's Backend API is asked for a session — see below. A sk_live_… key is refused, naming why | | @auth0/* with AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_AUDIENCE | a user is created through the Management API and a token obtained for them | | a Cognito SDK with COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID, and AWS credentials exported or in ~/.aws/credentials | AdminCreateUser then AdminInitiateAuth | | firebase and a Web API key | an account is created through the Identity Toolkit; no secret needed at all | | no auth package, but a session table storing its token in plaintext | a session row is planted anyway — that is a fact off the schema, not a guess about the stack | | none of the above | refused, naming what was looked at |

Nothing has to be exported. The .env files searched are the ones Next.js itself loads — .env.local, .env.development.local, .env.development, .env — and they are searched the same way the connection string is: the directory you ran in, then out to the repository root, then back down into it. So crossline typed at a monorepo root finds the key in apps/web/.env.local, and the run prints which file it came from. Amazon's is the one credential that conventionally lives outside the repository, so ~/.aws/credentials and ~/.aws/config are read too, under $AWS_PROFILE or default; a profile that authenticates through SSO, role_arn or credential_process is skipped rather than half-read.

A live environment variable beats the same name in a .env file, which is what CI relies on, and both beat the shared AWS files. Detection never invents anything: a provider found without its material is a refusal that names the missing variable, never a fallback to something weaker that would send meaningless requests under a signed-in label.

Nothing found this way is ever written down. It is not recorded in crossline.config.json, in .crossline/last-run.json, in a report, in a reproduction command or in a log line — only the name of the file it came from is. That is why crossline.config.json still refuses a literal secret and takes only env:NAME: that file is committed, and a value in it is a value pushed to GitHub, whereas a gitignored .env.local your own application already reads is not a secret this tool moved anywhere.

Where detection is wrong, or where none applies, say so explicitly:

// crossline.config.json
{
  "api": {
    "target": "http://localhost:3000",
    "auth": { "kind": "authjs_session" }        // Auth.js / NextAuth, database sessions
    // "auth": { "kind": "supabase_anon", "url": "env:NEXT_PUBLIC_SUPABASE_URL",
    //           "publishableKey": "env:NEXT_PUBLIC_SUPABASE_ANON_KEY" }   // no secret key
    // "auth": { "kind": "supabase_admin", "url": "env:SUPABASE_URL",
    //           "secretKey": "env:SUPABASE_SECRET_KEY",
    //           "publishableKey": "env:SUPABASE_PUBLISHABLE_KEY" }
    // "auth": { "kind": "hs256_jwt", "secret": "env:JWT_SECRET" }
    // "auth": { "kind": "header", "template": { "authorization": "Bearer {{userId}}" } }
  }
}

hs256_jwt used to be called supabase_jwt, which told a Prisma or Drizzle developer it was somebody else's setting when it is the only strategy that fits them — an ordinary bearer token whose sub is the user id. The old name still works and behaves identically.

{{userId}} and {{email}} are substituted in any header template. The header kind is the escape hatch for a stack none of the others fit; it is implemented and it is the same substitution path authjs_session uses for its own template, but it has no test of its own, so treat it as the least-proven of the four.

hs256_jwt is the only place Crossline mints a token. The registered claims are the ordinary ones — sub is the user id, which is what every HS256 stack reads — and three Supabase-specific ones (role and aud of authenticated, plus app_metadata) ride along beside them, because PostgREST needs them and nothing else looks at them. So it is not a Supabase-only mode: it is a standard token that also satisfies Supabase, and it is the right answer for a hand-rolled Prisma or Drizzle app that validates a bearer token. An application that signs HS256 and reads different claims than sub needs header instead.

Which Supabase one? Projects created from 1 October 2025 sign sessions with an asymmetric key (ES256 by default); the shared JWT secret is legacy. Crossline cannot sign an ES256 token and would not take your private key if offered it, so on those projects hs256_jwt produces a token the project rejects outright — a real Auth server answers 403 with bad_jwt, every request fails at the door, and the run says it established nothing. Use supabase_admin there. It signs nothing: it asks your project's Auth server to issue a real session for a test user Crossline created, and sends that. The identity stays exact because the token grant's own response names the user it was issued for, and a session issued for anyone else is refused rather than used. supabase_admin works on legacy projects too, so it is the safe choice if you are unsure.

Supabase without a secret key

supabase_anon is the mode to use, and it asks for nothing secret. The two values it needs — your project URL and its publishable (anon) key — are already in .env.local and already shipped in the JavaScript bundle your app sends to every visitor. If they are set, Crossline picks this mode on its own, ahead of a secret key that is also present.

It works because the service_role key was only ever buying two facts: that the test persona has a password Crossline knows, and that its email counts as confirmed. Both of those are columns on auth.users — and Crossline is already connected to the database that holds them, with enough privilege to have planted the persona rows there in the first place. A connection string is strictly more powerful than a service_role key, so asking for the key as well bought nothing. Crossline writes the two columns (bcrypt, via pgcrypto, on the two rows it planted and nowhere else) and then signs in at POST /auth/v1/token?grant_type=password carrying only the publishable key — the same request, with the same key, that your own sign-in page makes.

The identity bar does not move: that response names the user the session belongs to, and a session issued for anybody else is refused rather than used.

What it deliberately does not do is call POST /auth/v1/signup. That endpoint does work with the publishable key alone, but it assigns the user id itself — so that user would own none of the planted rows — and on a hosted project "Confirm email" is on by default, so it returns a user and no session. It would also spend the built-in mailer's default budget of two emails per hour, which a test that runs on every commit cannot afford. All three of those are proved rather than asserted: the suite runs a real gotrue with MAILER_AUTOCONFIRM=false and checks that the signup endpoint returns 200 with no access_token.

Where it cannot do the work — no database connection, users that do not live in auth.users, no encrypted_password column, or no pgcrypto and no right to install one — it declines and names which, rather than falling back to something weaker. Each of those refusals has a test.

Supabase with one, when the users are elsewhere

supabase_admin remains for the case the above cannot cover: an application whose users are its own table rather than auth.users, where there is no Supabase password column to write. It is then the one place Crossline takes a credential: secretKey is your project's secret (service_role) key, and it must be written as env:NAME — Crossline will not read it out of a file meant to be committed. It is sent to your Supabase Auth server and to nothing else: never to your application, so it cannot reach a reproduction command, .crossline/last-run.json or a report, which are built only from the headers the probes actually sent.

authjs_session needs no secret and no cryptography. An Auth.js database session is a row: the adapter stores the session token in plaintext and that stored value is the cookie your browser sends back, so Crossline inserts a session for a user it already created and makes requests as that user. Same guarantee as everywhere else — we created the session, so "this request is Alice" is a fact rather than a guess. The session table is found from your schema; name it under api.auth.table if it can't be identified with certainty, and Crossline says so rather than picking one. Two of those refusals are proved by tests — no session table at all, and a table whose user column points somewhere else. The branch for two plausible session tables is written but no fixture reaches it, so treat "it will name the ambiguity rather than guess" as implemented and unproven.

One case is out of scope by design, because it'd mean handling your real signing key: Auth.js's JWT strategy, whose cookie is encrypted with AUTH_SECRET. It is detected, and the failure message names AUTH_SECRET and says Crossline will not forge it.

When the users are somebody else's

Clerk, Auth0, Cognito and Firebase Auth sign with keys that never leave the provider, so nothing can be minted for them. All four will issue a session for a user you create, which is what Crossline does:

| provider | the flow | | --- | --- | | Clerk | POST /v1/users → POST /v1/sessions → POST /v1/sessions/{id}/tokens → Authorization: Bearer <jwt>. Testing docs, shapes from the published OpenAPI spec. POST /v1/sessions is "intended only for use in testing, and is not available for production instances" — so a sk_live_… key is refused up front rather than failing halfway | | Auth0 | client-credentials token → POST /api/v2/users → password-realm grant → GET /userinfo | | Cognito | AdminCreateUser → AdminSetUserPassword → AdminInitiateAuth (ADMIN_USER_PASSWORD_AUTH) → GetUser | | Firebase | accounts:signUp → accounts:lookup. The Web API key is public — this one needs no secret |

The order of the run changes for these, and that is the whole difficulty. Every one of them assigns the user id. Crossline normally generates the persona ids and then plants rows under them; done that way the provider's session would belong to a user who owns none of the planted rows, every probe would come back empty, and empty is what a correctly-secured app returns — a green tick over a check that never ran. So the provider's users are created first, their ids become the personas' ids, and only then is anything seeded. becomePersonas refuses these strategies outright, because by the time it is called the ids are already wrong.

The identity is the provider's own statement. Clerk's session object names a user_id, Auth0's /userinfo a sub, Cognito's GetUser a sub attribute, Firebase's accounts:lookup a localId — and if any of them names anybody but the account we asked for, nothing is claimed and the run says so. A 200 is not proof.

What this is tested against. A stub implementing each provider's documented endpoint shapes, end to end: a leaky app behind it is caught as two genuinely different signed-in users and its corrected twin is completely silent; a provider that refuses to issue a session checks nothing and says why. It has not run against a live Clerk instance, Auth0 tenant, Cognito pool or Firebase project, and two questions are open: whether a Bearer-only request with no __client cookie resolves to signed-in at Clerk rather than triggering its handshake redirect, and whether Clerk honours or caps expires_in_seconds. Both would surface as the owner check failing, not as a false finding.

Every admin key is env:NAME only, is sent to its own provider and to nothing else, and never reaches a report, a reproduction command or last-run.json.

Why three of these still need a key, and Supabase and Firebase do not

Asking a founder to go and find an admin key is the thing that stops a tool being installed, so the obvious question is whether the public key each of these providers already hands the browser — the one in NEXT_PUBLIC_…, shipped in the bundle, designed to be seen — is enough to sign two test users up the same way the app's own sign-up page does. It was investigated properly. The answer is yes for Firebase, yes for Supabase by a different route, and no for Clerk, Auth0 and Cognito. The reasons are specific and worth writing down, because they are the kind of thing that looks solvable until you check:

  • Clerk — blocked by bot protection, and we will not defeat a bot check. The publishable key is not a credential at all: it is your Frontend API hostname in base64 (pk_test_ZXhhbXBsZS5hY2NvdW50cy5kZXYk decodes to example.accounts.dev$), and FAPI carries no key in any header or query param — its own client sends none. Email verification is not the obstacle either: Clerk documents test addresses (…+clerk_test@…) that verify with a fixed code and need no credential. The obstacle is bot sign-up protection: when it is on, POST /v1/client/sign_ups answers requires_captcha / captcha_invalid, and the only sanctioned way past it is a Testing Token minted through the Backend API — which needs the secret key, putting us back where we started. Solving the Turnstile challenge instead would be defeating a bot check, which is not something this project will do. So Clerk needs CLERK_SECRET_KEY — which is not a thing to go and find: @clerk/nextjs cannot start without it, so it is already in .env.local and Crossline reads it from there. On a Clerk app whose authorization lives in the database rather than in handlers, the data plane covers it without one.
  • Auth0 — user creation is public, tokens are not. POST /dbconnections/signup genuinely needs only the connection name and the public client_id. Getting a token for that user does not follow: Auth0 creates public applications with implicit, authorization_code and refresh_token only, so the password grant is off by default and can be turned on only from the dashboard or the Management API. The alternative, cross-origin authentication, is browser- and third-party-cookie-bound. A user we cannot become is no use.
  • Cognito — sign-up is public, confirmation is not. SignUp is explicitly an unauthenticated operation and works with a public app client and no secret. But the new user is UNCONFIRMED, and "Users who sign themselves up must be confirmed before they can sign in" — every route from there to a token (AdminConfirmSignUp, a pre-sign-up Lambda, turning off Cognito-assisted verification) needs admin access, and the emailed code is not something a test can read.
  • Firebase — already needs nothing. accounts:signUp with the Web API key returns an idToken and a localId immediately, with no verification step in the path. This is the shape the others were checked against.

This was settled from the providers' own documentation and, for Clerk, from its open-source client. It has not been run against a live Clerk instance, Auth0 tenant or Cognito pool, so treat the three "no"s as well-evidenced rather than observed. If Clerk's bot protection turns out to be off on a given instance, the sign-up path there would open up — the instance reports that itself, in display_config.captcha_widget_type on the unauthenticated /v1/environment response, so it is a fact that could be read rather than guessed at. Nothing in Crossline reads it today.

Given none of these, Crossline does not send the signed-out request under a signed-in label. It emits no signed-in probes at all, says so, and the run is inconclusive — half a suite that looks like a whole one is worse than a missing one.

A framework Crossline can't read

Discovery asks each framework for the route table it already wrote down — Next.js's build manifest, SvelteKit's route_meta_data.json, Nitro's resolved route types, or an openapi.json from Nest, FastAPI or Django REST — and reads Express-style registration out of source, best-effort, because Express writes nothing down.

The frameworks that will only answer by being run are declined by name in the report, with the command that answers and where to put the result: rails routes, php artisan route:list and manage.py show_urls each boot the application, run its initializers and connect to whatever database the environment points at, and a test meant to run on every commit does not do that to the project it is checking. For those, and for anything else discovery misses, list the endpoints and they are checked like any others:

{ "routes": ["GET /api/documents/:id", "PATCH /api/documents/:id", "DELETE /api/tasks/:id"] }

They're added to whatever discovery finds, not swapped for it. A malformed entry is an error, never a silent skip.

Two things a path alone can't carry, for the endpoints that need them:

{
  "routes": [
    // The URL doesn't name the table it serves, so nothing tells the run whose
    // row to ask for. Without `resource` this endpoint is reported unreached.
    { "method": "GET", "path": "/v2/records/:id", "resource": "public.documents" },
    // The handler validates its body. Without a body it accepts, the *owner's*
    // own write is rejected too — and an endpoint whose owner-proof failed
    // settles nothing, correctly and uselessly.
    { "method": "PUT", "path": "/v2/records/:id", "resource": "public.documents",
      "example": { "kind": "note" } }
  ]
}

example is merged underneath the fields a probe needs — the identifier it is crossing with and the marker it plants — so it can satisfy a validator and cannot overwrite what the check is about. A resource no table in the schema answers to is an error, not a hint: falling back to the path would check a different resource than the one that was declared.

Next.js server actions

A server action is a POST to its page's own URL carrying an opaque id in a Next-Action header. Nothing in app/ describes one, so route discovery finds none of them — and on an application whose mutations are all server actions, that is the entire write surface. Crossline reads the ids out of .next/server/server-reference-manifest.json, so it can name every one exactly, with its URL, its source file and its export name.

What the build does not record is the argument list, and that is the one thing needed to call one. Guessing it is not on offer: a wrong shape is rejected by the framework before the action's own body decides anything, and reading that rejection as a refusal would score an exception as authorization. So the shape is told to Crossline by whoever wrote the action:

{
  "serverActions": [
    // getDocument(id)
    { "exportedName": "getDocument", "source": "app/documents/actions.js",
      "args": ["{id}"] },
    // renameDocument(id, title)
    { "exportedName": "renameDocument", "source": "app/documents/actions.js",
      "args": ["{id}", "{marker}"] }
  ]
}

{id} is replaced with the identifier of the row being crossed to, {marker} with a value the run mints and then looks for in the database — so an action carrying {marker} is checked as a write and settled against the rows, never against the reply. Both are substituted anywhere they appear, including inside a nested object. An action with no {id} is checked as a collection read. Keyed on the export name and file rather than the id, because the id is a hash of the built module and changes on every build.

Coding agents declare both of these through the crossline_declare MCP tool rather than by editing the file — the agent that wrote the handler is the one that knows the route, the body and the argument list. It is saved to crossline.config.json either way, so the same knowledge serves the next run, the human, and CI.

A declaration widens reach and does not lower the bar. It is joined to what discovery found rather than replacing it: a duplicate is probed once, a declared method