naro-cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,9 +91,11 @@ naro db dump --output backup.sql SQL dump, streamed from Cloudflare (stdout wi
91
91
  naro db restore --bookmark <id> Time Travel, last 30 days (or --timestamp <time>);
92
92
  asks first, prints the command that undoes it
93
93
  naro db pull Existing database, no migrations yet: writes its schema
94
- as migrations/0001_remote_schema.sql, recorded as applied
94
+ and policies as migrations/0001_remote_schema.sql,
95
+ recorded as applied
95
96
  naro db reset [--dry-run] Drop every table, view and trigger, clear the ledger and
96
- apply ./migrations again (asks first; --yes in CI)
97
+ the policies, and apply ./migrations again (asks first;
98
+ --yes in CI; refuses files the server would refuse)
97
99
  naro db lint [--strict] Check the schema for what D1 actually trips on; exits 1
98
100
  on an error, and on a warning too with --strict
99
101
  naro db table create posts --column "id:integer:pk:autoincrement" --column "title:text:notnull" --column "created_at:datetime:notnull:default=current_timestamp" --owner user_id
@@ -144,12 +146,19 @@ naro backend auth redirects remove https://app.example.com/auth/
144
146
  Exits 1, changing nothing, when it is not in the list
145
147
  naro backend auth require-verification on
146
148
  Refuse sign-in until the email is confirmed (needs email)
147
- naro policy list Per-table rules and conditions of the Data API
149
+ naro policy list [--table todos] Row-level security policies, as CREATE POLICY
150
+ naro policy create 'CREATE POLICY "own todos" ON todos TO authenticated USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())'
151
+ naro policy create --file policies.sql
152
+ CREATE POLICY and DROP POLICY statements, run in order;
153
+ --allow-unindexed accepts a subquery on the outer row
154
+ that no index serves (refused otherwise)
155
+ naro policy drop "own todos" --table todos
156
+ DROP POLICY (asks first)
148
157
  naro policy set todos --select owner --insert owner --update owner --delete owner --owner user_id
149
158
  naro policy set posts --select-when 'or(is_public.eq.true,user_id.eq.auth.uid())' --insert-when 'user_id.eq.auth.uid()'
150
159
  naro policy set posts --from-file policy.json
151
- The policy as JSON, conditions and all
152
- naro policy remove todos Back to deny (asks first)
160
+ Shorthand: writes the policies naro_select … naro_delete
161
+ naro policy remove todos Drop every policy on the table: back to deny (asks first)
153
162
 
154
163
  naro promote <deployment-id> Publish a build to production
155
164
  naro rollback [deployment-id] Back to the previous production build
@@ -184,12 +193,43 @@ reads the bookmark from just before the drops and prints it, so
184
193
  `naro db restore --bookmark <that>` puts the data back. `--dry-run` lists what
185
194
  it would drop and re-apply without touching anything.
186
195
 
196
+ The backend's accounts, sessions, settings and the other `_naro_` tables are
197
+ kept — except its policies: they are cleared with the schema and the migration
198
+ files create them again. Before it reads anything, `db reset` checks the files
199
+ as `naro db push --dry-run` does: when the control plane would refuse one, the
200
+ re-apply would stop there with the schema already dropped, so the reset is
201
+ refused with `VALIDATION_FAILED`, each file as `<file>: Statement <i> of <n>:
202
+ …` (`--dry-run` prints them and exits 1). A policy that exists only in the
203
+ database would be lost — one made with `naro policy create` or
204
+ `naro policy set`, and the owner policy that `naro db table create --owner`,
205
+ MCP's `create_table` and the table editor add, which no migration file holds.
206
+ So before it asks, `db reset` lists the policies your files do not make, each
207
+ with the SQL that makes it again — its `CREATE POLICY` and, for an owner rule,
208
+ the `COMMENT ON POLICY … IS 'naro:owner <column>'` that keeps its column
209
+ (`--json`: `policiesNotInFiles[].sql`); add it to a migration first to keep
210
+ them. The policies of a table your files do not create go with that table and
211
+ are named apart, with no SQL (`--json`: `policiesOfTablesNotInFiles`): a
212
+ migration that made them would stop at a table that is not there. A file the
213
+ re-apply would stop at that the check above does not catch (one whose SQL
214
+ cannot be split) is named instead — `<file> would be refused: <reason>`
215
+ (`--json`: `wouldBeRefused`) — and then no policy is called lost. A policy row
216
+ that cannot be read is listed as a `-- Policy … cannot be read` comment
217
+ instead: copying it keeps nothing, so drop that policy and create it again. A
218
+ backend whose worker predates named policies is refused with
219
+ `409 BACKEND_OUTDATED`: run `naro backend enable` first.
220
+
187
221
  `naro db lint` reads the schema and runs the checks D1 allows. Its rules are
188
222
  the ones D1 actually trips on — orphaned rows, a failed `quick_check`, a
189
223
  missing or nullable primary key, a foreign key no index starts with, a trigger
190
224
  the REST API cannot recreate, a virtual table that makes `db dump` refuse the
191
- whole database. There is no "unused index" guess: D1 keeps no execution
192
- statistics to base one on. An error exits 1, a warning exits 0 — `--strict`
225
+ whole database. On a project with a backend it also checks the policies:
226
+ `rls_no_policy` (a table the Data API refuses entirely),
227
+ `rls_policy_text_default` (a number column a policy reads as a boolean whose
228
+ `DEFAULT` is text, such as `BOOLEAN DEFAULT 'true'`) and
229
+ `rls_policy_unindexed` (warnings), and `rls_policy_invalid`, a policy the
230
+ backend cannot apply — one that reads a text column as true or false among
231
+ them (an error). There is no "unused index" guess: D1 keeps
232
+ no execution statistics to base one on. An error exits 1, a warning exits 0 — `--strict`
193
233
  makes warnings fail too, so CI can gate on it:
194
234
 
195
235
  ```bash
@@ -210,7 +250,9 @@ from a fixed list — and applies it as one migration, recorded in
210
250
  `d1_migrations` and written to `./migrations` as `NNNN_schema_<op>_<table>.sql`,
211
251
  numbered after your local files and the ledger. `naro migration list`,
212
252
  `db pull` and `db reset` see it like any other migration; with `--no-write`
213
- there is no file, so `db reset` will not re-apply it. `--dry-run` prints the
253
+ there is no file, so `db reset` will not re-apply it. The owner policy
254
+ `--owner` adds is not in that file either: `db reset` lists it, with its SQL,
255
+ before it removes it — add that SQL to a migration to keep it. `--dry-run` prints the
214
256
  SQL and changes nothing. While a local migration is not in the ledger yet, a
215
257
  change is refused — `naro db push` it first, or the change would run before it.
216
258
 
@@ -225,11 +267,30 @@ A column is `name:type[:modifier…]`:
225
267
  Every primary key but a single `integer` one is `NOT NULL`; a foreign key must
226
268
  point at its table's primary key or a unique column. `--owner user_id` adds
227
269
  `user_id TEXT NOT NULL` and, when the backend is on and ready, an owner policy for all
228
- four actions; any other new table is closed to the Data API until you run
229
- `naro policy set` (the command says so). Policies are kept by table name: a
230
- drop removes the table's policy whenever the project has a backend, on or off
231
- (the command says so), and a rename leaves the old one behind — the command
232
- prints what to run.
270
+ four actions; any other new table is closed to the Data API until you give it
271
+ a policy (`CREATE POLICY` in a migration, or `naro policy create` or
272
+ `naro policy set`; the command says so).
273
+ Policies are kept by table name: a drop removes the table's policies whenever
274
+ the project has a backend, on or off (the command says so). A rename leaves
275
+ them under the old name, where they apply to no table — the command prints
276
+ what to run to move them. Move them then: the next time `naro db table`
277
+ creates a table as that name or renames one to it, renaming the table back
278
+ included, the policies stored under the name are removed first, so a new
279
+ table never inherits an earlier one's (except, on a project without a
280
+ backend, one changed through `naro db table`, the table editor or MCP) — and
281
+ neither does a table made by `naro db push` or `naro db query`. Whatever a command removes it prints
282
+ so it can be made again: as `CREATE POLICY` statements, which
283
+ `naro policy create` takes back, and — for owner rules, whose owner column
284
+ `CREATE POLICY` cannot say — as the `naro policy set <table> … --owner <column>`
285
+ command that sets them again. A statement that cannot be shown as it is (a
286
+ control character, or a line break inside a string) prints as a `--` comment
287
+ in its place; `--json` has the exact text.
288
+
289
+ A policy reads columns and, in a subquery, other tables. When a rename or a
290
+ drop takes one of them away, the Data API answers `500 POLICY_INVALID` to the
291
+ requests that policy applies to — on another table, when the policy of `docs`
292
+ reads `members`. The command names each policy the change breaks and why;
293
+ `--dry-run` names them before anything is applied.
233
294
 
234
295
  SQLite cannot add a PRIMARY KEY, UNIQUE or NOT-NULL-without-default column, or
235
296
  drop a primary key, a unique or indexed column, or a table another table
@@ -411,21 +472,245 @@ A backend turned on before user management answers the `users` commands
411
472
  `409 BACKEND_OUTDATED`: run `naro backend enable` once to update this
412
473
  project's backend, then run the command again.
413
474
 
414
- Policies are default deny: a table without one refuses every anon and
415
- signed-in request. Each of `--select`, `--insert`, `--update` and `--delete`
416
- takes `none`, `public`, `authenticated` or `owner`; `owner` lets a signed-in
417
- user reach only rows whose `--owner` column holds their user id, and fills
418
- that column in on insert. What a `policy set` leaves out keeps its stored
419
- value. A change is live within about 10 seconds. `naro gen types` reads the
420
- policies too: with `--insert owner --owner user_id`, `user_id` is optional in
421
- the generated `Insert` even when it is `NOT NULL` with no default — run it
475
+ ### Row-level security
476
+
477
+ Policies are Postgres's row-level security, as in Supabase. A table without a
478
+ policy refuses every anon and signed-in request; the service key skips
479
+ policies. Write a table's policies in the migration that creates it —
480
+ `CREATE POLICY` after the `CREATE TABLE`, in the same file. `naro db push`,
481
+ `naro migration up`, `naro db query` and the MCP tools `apply_migration` and
482
+ `execute_sql` apply them together with the table, all or nothing. Three shapes
483
+ cover most apps:
484
+
485
+ ```sql
486
+ -- migrations/0002_todos.sql: each signed-in user reads and changes their own rows.
487
+ CREATE TABLE todos (
488
+ id integer PRIMARY KEY,
489
+ user_id uuid NOT NULL,
490
+ title text NOT NULL
491
+ );
492
+ CREATE POLICY "own rows" ON todos FOR ALL TO authenticated
493
+ USING ((select auth.uid()) = user_id)
494
+ WITH CHECK ((select auth.uid()) = user_id);
495
+
496
+ -- migrations/0003_posts.sql: anyone reads, signed in or not.
497
+ CREATE TABLE posts (id integer PRIMARY KEY, title text NOT NULL);
498
+ CREATE POLICY "read" ON posts FOR SELECT TO anon, authenticated USING (true);
499
+
500
+ -- migrations/0004_reports.sql: an app only its admins sign in to. First run
501
+ -- naro backend auth signups off and create the admin accounts: authenticated
502
+ -- then means exactly those accounts.
503
+ CREATE TABLE reports (id integer PRIMARY KEY, body text NOT NULL);
504
+ CREATE POLICY "admins" ON reports FOR ALL TO authenticated USING (true) WITH CHECK (true);
505
+ ```
506
+
507
+ A migration or a query takes `CREATE POLICY`; `DROP POLICY [IF EXISTS]` (`IF
508
+ EXISTS` skips a missing table too, as in Postgres); `COMMENT ON POLICY p ON t
509
+ IS 'naro:owner <column>'`, which makes `p` the owner rule `naro policy set
510
+ --owner` writes (any other comment is ignored); and `ALTER TABLE t ENABLE |
511
+ FORCE | NO FORCE ROW LEVEL SECURITY`, which changes nothing because
512
+ row-level security is always on. Before anything runs, naro refuses these with
513
+ `400 VALIDATION_FAILED` "Statement <i> of <n>: …":
514
+
515
+ - `ALTER TABLE … DISABLE ROW LEVEL SECURITY` — it is always on; to let
516
+ everyone through, create a policy: `CREATE POLICY "open" ON t USING (true)
517
+ WITH CHECK (true);`
518
+ - `GRANT` and `REVOKE` — every table is behind row-level security, so write a
519
+ policy instead;
520
+ - `ALTER POLICY` — `DROP POLICY` and `CREATE POLICY` again;
521
+ - a table named `public.x` in `CREATE TABLE`, `DROP TABLE` or a rename: `SQLite
522
+ has no schema "public": write the table name without "public."`
523
+ - a `CREATE POLICY` that reads a column inside a subquery with no table in
524
+ front of it, when a later statement of the same file creates, drops, renames
525
+ or alters the policy's table or a table its subqueries read: `a later
526
+ statement changes a table, so name the table of every column inside this
527
+ policy's subqueries (for example members.user_id).` Postgres decides whose
528
+ column it is when the policy is created; naro binds it once the file is in,
529
+ when the later statement may have added that name to the subquery's table.
530
+ A later statement that changes only other tables — the next table of a file
531
+ grouped table by table — leaves the policy alone.
532
+
533
+ The statements of one request apply all-or-nothing, the policies with the
534
+ tables: a policy on a table that does not exist
535
+ (`relation "nope" does not exist`), a name already taken, or a 33rd policy on
536
+ one table fails the whole request with `400 DATABASE_QUERY_FAILED`, names the
537
+ statement, and applies nothing — not even the `CREATE TABLE` before it. Policy
538
+ statements, and statements that create, drop or rename a table, take no
539
+ `params` (`400 VALIDATION_FAILED`): write the values into the SQL.
540
+
541
+ Policies go with their table, as in Postgres: `DROP TABLE` drops them, and a
542
+ new table never inherits the leftovers of an earlier one with the same name
543
+ (except tables changed through the table editor on a project without a backend).
544
+ SQLite changes a column by building the table again — `CREATE TABLE
545
+ __new_x`, copy the rows, `DROP TABLE x`, `ALTER TABLE __new_x RENAME TO x` — so
546
+ the drop removes `x`'s policies and the rename does not bring them back. Write
547
+ the `CREATE POLICY` statements again after the rename, in the same migration.
548
+ A plain `ALTER TABLE a RENAME TO b` does not move them either: they stay under
549
+ `a`, and `b` has none until you create them again.
550
+ The answer's `warnings` lists the policies a migration removed, with SQL you
551
+ can paste; it also says when the project has no backend yet (the policies are
552
+ saved and apply once `naro backend enable` turns it on) and when a policy was
553
+ applied that the Data API refuses, with the reason. `naro db push` and
554
+ `naro db query` print each warning as `⚠ …` on stderr (`--json` has it whole
555
+ in `warnings`, a query's in `result.warnings`).
556
+
557
+ `naro db push --dry-run` parses the policy statements of the pending files on
558
+ your machine and prints what it would refuse as `<file>: Statement <i> of <n>:
559
+ …` (exit 1); whether a table or a policy exists is known only when the file is
560
+ applied. `naro db pull` writes the policies that exist at the end of the
561
+ baseline file — `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` for each table,
562
+ then each `CREATE POLICY …;`, an owner rule's column as `COMMENT ON POLICY …
563
+ IS 'naro:owner <column>';` (a database without policies pulls as plain
564
+ SQLite). `naro db reset` clears the policies with the schema and creates them
565
+ again from the migration files, so a policy that exists only in the database
566
+ is lost; it lists those before it asks, each with the SQL to add to a
567
+ migration to keep it (an owner rule's column as its `COMMENT ON POLICY`; a
568
+ row that cannot be read is only a comment — drop it and create it again), and
569
+ names apart, without SQL, the policies of tables your files do not create. It
570
+ refuses to start when the control plane would refuse one of the files.
571
+ `naro db lint`, on a
572
+ project with a backend, reports `rls_no_policy` (a table the Data API refuses
573
+ entirely), `rls_policy_text_default` and `rls_policy_unindexed` as warnings,
574
+ and `rls_policy_invalid` — a
575
+ policy the backend cannot apply, such as one that reads a text column as true
576
+ or false — as an error.
577
+
578
+ `naro policy create` sends the same statements to the database directly: one
579
+ in quotes, or `--file` with several, `DROP POLICY` included. Nothing records
580
+ them in `./migrations`, so `naro db reset` removes what your files do not
581
+ make; use it for a table that already exists. A change is live within about 10
582
+ seconds, and `naro policy list` prints the policies back as SQL. More complete
583
+ policies — they give each user their own todos, everyone the published posts
584
+ and team members their team's docs, and hide deleted docs from all of them:
585
+
586
+ ```sql
587
+ -- Each signed-in user reads and changes their own todos.
588
+ CREATE POLICY "own todos" ON todos TO authenticated
589
+ USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
590
+
591
+ -- Anyone reads published posts.
592
+ CREATE POLICY "published posts" ON posts FOR SELECT USING (published);
593
+
594
+ -- Members read their team's docs. The subquery reads members through
595
+ -- members' own select policies, so members needs one.
596
+ CREATE POLICY "own memberships" ON members FOR SELECT TO authenticated
597
+ USING (user_id = auth.uid());
598
+ CREATE POLICY "team docs" ON docs FOR SELECT TO authenticated
599
+ USING (EXISTS (SELECT 1 FROM members m
600
+ WHERE m.team_id = docs.team_id AND m.user_id = auth.uid()));
601
+
602
+ -- Deleted docs stay hidden, whatever else lets them through.
603
+ CREATE POLICY "hide deleted" ON docs AS RESTRICTIVE FOR SELECT
604
+ USING (deleted_at IS NULL);
605
+ ```
606
+
607
+ A request passes when at least one permissive policy for its command and role
608
+ passes and every restrictive one does. `FOR ALL` (the default) is every
609
+ command; `TO public` (the default) is anon and signed-in users. As in
610
+ Postgres:
611
+
612
+ - select reaches the rows the select policies pass;
613
+ - insert writes a row only when it passes `WITH CHECK`, and with `.select()`
614
+ the select policies too;
615
+ - update reaches the rows its `USING` and the select policies pass, and each
616
+ changed row must pass `WITH CHECK` (`USING` when there is none) and the
617
+ select policies — so an update that moves a row out of what select shows,
618
+ like a soft delete, is refused;
619
+ - delete reaches the rows its `USING` and the select policies pass.
620
+
621
+ A row outside `USING` is skipped without a word. A written row that misses a
622
+ check writes nothing and answers `403 PERMISSION_DENIED` "New row violates
623
+ row-level security policy for table todos." (with the name of a restrictive
624
+ policy it missed). With no permissive policy for the command, the request is
625
+ refused — `403 PERMISSION_DENIED` "This request is not allowed by the update
626
+ policy of todos." — where Postgres would answer no rows.
627
+
628
+ Expressions: columns, literals, comparisons, `AND`/`OR`/`NOT`, `IS [NOT] NULL`,
629
+ `IS [NOT] DISTINCT FROM`, `[NOT] IN (…)`, `[NOT] LIKE`/`ILIKE`, `BETWEEN`,
630
+ `EXISTS (SELECT …)` and `IN (SELECT …)`, `auth.uid()`, `auth.role()`,
631
+ `auth.jwt() ->> 'sub' | 'role' | 'email' | 'email_verified'`, and `lower`,
632
+ `upper`, `coalesce`, `length`, `now()`. Supabase's `(select auth.uid())` reads
633
+ as `auth.uid()`. A subquery's table is read through its own select policies;
634
+ policies that would expand themselves are refused ("Infinite recursion
635
+ detected in policy for relation members."). A statement's policies bind at
636
+ most 50 values and write at most 50,000 bytes of SQL, leaving the rest of
637
+ D1's 100 values and 100,000 bytes to the request. A `LIKE` or `ILIKE` pattern
638
+ is at most 50 bytes of UTF-8, which is D1's limit; `LIKE` counts each `*`,
639
+ `?` and `[` as 3.
640
+
641
+ A subquery that reads the outer row (`m.team_id = docs.team_id`) runs once for
642
+ every row the statement looks at, so it needs an index to find its rows: the
643
+ control plane refuses such a policy until a column its WHERE compares with `=`
644
+ (or `IN`) to a value from outside it starts an index, and names the index to
645
+ create — `naro db index create members team_id`. `--allow-unindexed` accepts
646
+ it anyway; every run then reads the whole table.
647
+
648
+ For anon, `auth.uid()` is NULL and `auth.jwt()` holds only `role` `'anon'`, as
649
+ in Supabase, and NULL logic is Postgres's: `auth.uid() IS NULL` is true for
650
+ anon, `user_id = auth.uid()` and its `NOT` match no row, and a `NOT EXISTS`
651
+ over another table's policy reads that policy the same way. Target anon with
652
+ `TO anon` or `auth.role() = 'anon'`. `auth.jwt() ->> 'email'` is the user's
653
+ address only once it is verified, and NULL before — sign-up signs a user in
654
+ before the address is confirmed, so an unverified one is only what was typed;
655
+ `email_verified` says which. It is the text `'true'` or `'false'`: write
656
+ `(auth.jwt() ->> 'email_verified') = 'true'`. `IS TRUE`, `IS FALSE`, `NOT x`
657
+ and a value alone read a number or boolean column; on a claim, `auth.uid()`,
658
+ a string, a function or a TEXT column they are refused, with the comparison
659
+ to write — as text, `'true'` would read as false. So is comparing text with
660
+ `TRUE` or `FALSE` — `<> false`, `= true`, `IN (true)`, `BETWEEN`,
661
+ `IS DISTINCT FROM`, also through `coalesce` — since the text `'true'` is not
662
+ `TRUE`: `<> false` would hold for everyone.
663
+
664
+ Where naro differs from Postgres: no policy for a command is a 403, not zero
665
+ rows; row-level security is always on; roles are only `anon` and
666
+ `authenticated`, with no `security definer` functions; comparisons follow
667
+ SQLite (column affinity and collation), and `lower`, `upper` and `ILIKE` fold
668
+ ASCII only; `now()` is UTC text as `CURRENT_TIMESTAMP` stores it.
669
+
670
+ `naro policy create --file` parses every statement first, so a syntax error
671
+ sends none; then it runs them in order and stops at the first one refused,
672
+ keeping those before it. `naro policy list` prints what `naro policy create`
673
+ takes back, except the owner comment below. A policy whose name, table or
674
+ string holds a control character (a line break, an escape) is not printed as
675
+ a statement — with the character replaced it would create a different policy
676
+ — but as a `--` comment that says so; `naro policy list --json` has its exact
677
+ text.
678
+
679
+ `naro policy drop <name>` takes the name as you type it: the policy of exactly
680
+ that name, or else the one of its ASCII lower case. `CREATE POLICY MyPolicy`
681
+ stores `mypolicy`, as in Postgres, and `naro policy drop MyPolicy --table
682
+ todos` drops it; a policy created as `"MyPolicy"` is the one dropped when both
683
+ exist.
684
+
685
+ `naro policy set` is shorthand: it writes the policies `naro_select`,
686
+ `naro_insert`, `naro_update` and `naro_delete` of a table and leaves its other
687
+ policies alone. Each of `--select`, `--insert`, `--update` and `--delete`
688
+ takes `none` (drops that policy), `public`, `authenticated` or `owner`;
689
+ `owner` lets a signed-in user reach only rows whose `--owner` column holds
690
+ their user id, and fills that column in on insert — a Naro extension SQL
691
+ cannot say, so `naro policy list` prints it as a `-- naro: owner` comment.
692
+ What a `policy set` leaves out keeps its stored value. `naro gen types` reads
693
+ the policies too: with `--insert owner --owner user_id`, `user_id` is optional
694
+ in the generated `Insert` even when it is `NOT NULL` with no default — run it
422
695
  again after changing a policy.
423
696
 
424
- Instead of a rule, an action can hold a condition on the row, like a Supabase
425
- row-level security policy, written in the filter syntax of `naro-js`'s
426
- `.or()` (commas are AND) with `auth.uid()` for the signed-in user's id —
427
- unquoted; `"auth.uid()"` is the text, and `auth.uid` without `()` is refused
428
- rather than read as text:
697
+ A backend turned on before named policies answers the commands that write a
698
+ policy — `naro policy create`, `naro policy drop` and `naro policy set` — with
699
+ `409 BACKEND_OUTDATED`:
700
+ ``The project backend is older than named policies. Run `naro backend enable` to update it, then try again.``
701
+ `naro backend enable` uploads the current backend and turns the stored rules
702
+ into named policies; it also turns a backend that is off back on, so run
703
+ `naro backend disable` again afterwards if it should stay off. `naro policy
704
+ list` and `naro policy remove` work in the meantime. A migration or a query
705
+ that holds a `CREATE POLICY` or `DROP POLICY` gets the same answer before
706
+ anything runs; SQL without one runs as before.
707
+
708
+ `naro policy set` refuses with `409 POLICY_EXISTS` when the table's `naro_<action>` policy is not one it wrote (for example one made with `naro policy create`): `Policy <name> on <table> is not one naro policy set wrote. Change it with naro policy create, or drop it first: naro policy drop <name> --table <table>.` A `naro_<action>` policy that cannot be read as stored (the table answers `500 POLICY_INVALID`) is not refused: setting a rule replaces it, and `none` drops it.
709
+
710
+ Instead of a rule, an action can hold a condition on the row, written in the
711
+ filter syntax of `naro-js`'s `.or()` (commas are AND) with `auth.uid()` for
712
+ the signed-in user's id — unquoted; `"auth.uid()"` is the text, and
713
+ `auth.uid` without `()` is refused rather than read as text:
429
714
 
430
715
  - `--select-when` and `--delete-when`: the rows reads and deletes reach.
431
716
  - `--insert-when`: what every new row must meet.
@@ -437,13 +722,26 @@ rather than read as text:
437
722
  so `not(and(is_public.eq.1,user_id.eq.auth.uid()))` matches every row that is
438
723
  not public, anon included — write what a condition lets in.
439
724
 
440
- When any row of an insert or an update misses its condition, nothing is
441
- written and the Data API answers `403 PERMISSION_DENIED` ("The new row does not
442
- meet the insert policy of posts."). A check reads a row as it will be stored
443
- and compares it with the column's collation (`COLLATE NOCASE`, `RTRIM`). So:
725
+ A check — `WITH CHECK`, and the select policies an update's new row must
726
+ pass — reads a row as it will be stored and compares it with the column's
727
+ collation (`COLLATE NOCASE`, `RTRIM`). So:
444
728
 
445
729
  - a number column it reads takes numbers, booleans and null, not strings, and
446
- cannot be compared with `like` or `ilike`;
730
+ cannot be compared with `like` or `ilike` — the Data API refuses a string
731
+ for a column that any policy of any table reads (an owner rule's own id
732
+ column excepted) with `400 INVALID_QUERY`. For a column whose declared type
733
+ has `INT`, `REAL`, `FLOA`, `DOUB`, `NUM`, `DEC`, `BOOL` or `BIT` in it, that
734
+ is every string — and even when no policy reads it, a string SQLite would
735
+ keep as text (`'abc'`, `'true'`; `'7'` is stored as 7), which Postgres
736
+ refuses as a type error and a policy written later would read inside out. A
737
+ column declared another type (`uuid`, `date`, `timestamptz`, `json`) keeps a
738
+ string as text, so Supabase's `user_id uuid` with `auth.uid() = user_id`
739
+ works; there the Data API refuses only a string SQLite would store as a
740
+ number (`'12'`), a string with a NUL character, and any string when a
741
+ policy reads the column as true or false or compares it with a number. On such a
742
+ column a `WITH CHECK` that orders it against a number (`<`, `>`, `BETWEEN`)
743
+ can judge a new text differently from the row it becomes, and `LIKE` or a
744
+ text function on it is refused as on a number column;
447
745
  - a primary key column it reads must be sent, and not as null;
448
746
  - any other column it reads may be left out only when it has no default or a
449
747
  plain one — `NULL`, `TRUE`, `FALSE`, a number, or a quoted string on a column
@@ -451,30 +749,56 @@ and compares it with the column's collation (`COLLATE NOCASE`, `RTRIM`). So:
451
749
  another expression must be sent;
452
750
  - it cannot read a generated column or a virtual table's column.
453
751
 
454
- One policy's conditions use at most 50 values together (an update's check
455
- counts each column it reads once more), leaving 50 of D1's 100 per statement
456
- for the request — 48 for a write with a check, which also binds the table's
457
- schema to make sure it checks against the table as it is now. `--from-file`
458
- takes the PUT body, or a policy object from `--json`
752
+ The 50 values count an update's check once more for each column it reads, and
753
+ a write with a check binds the table's schema too (48 left), to make sure it
754
+ checks against the table as it is now. `--from-file` takes the PUT body, or a
755
+ policy object from `--json`
459
756
  (`naro policy set posts --json | jq .policy > policy.json`), where
460
757
  `auth.uid()` is `{"auth":"uid"}`; its `table`, if it has one, must name the
461
758
  table being set. It cannot be combined with the other policy options.
462
- `naro policy list` prints each condition in filter syntax that reads back as
463
- the same condition, or as JSON where filter syntax cannot say it exactly
464
- (`deleted_at.eq.null` would be the text `"null"`, and `= NULL` is not
465
- `IS NULL`); `--json` gives the exact JSON.
466
-
467
- Two things a condition does not do for you. An update or delete is held to its
468
- own condition only, not to select's, unless it asks for rows back with
469
- `.select()`. With an update condition broader than select's, a caller can tell
470
- rows it cannot select apart without `.select()`: an update that sets a value
471
- the check refuses answers 403 when a hidden row matches its filters and 200
472
- when none does, so filter by filter it learns what those rows hold. Keep
473
- update and delete conditions no broader than select's. And an `--update-check`
474
- you give replaces the default: without `user_id.eq.auth.uid()` in it, a user
475
- can give a row away by changing its `user_id`. Write
759
+ `naro policy set` prints the table's rules — each condition in filter syntax
760
+ that reads back as the same condition, or as JSON where filter syntax cannot
761
+ say it exactly (`deleted_at.eq.null` would be the text `"null"`, and `= NULL`
762
+ is not `IS NULL`) — and then its policies as `CREATE POLICY`.
763
+
764
+ An `--update-check` you give replaces the default: without
765
+ `user_id.eq.auth.uid()` in it, a user can give a row away by changing its
766
+ `user_id`. Write
476
767
  `--update-when 'user_id.eq.auth.uid()' --update-check 'user_id.eq.auth.uid(),status.eq.draft'`.
477
768
 
769
+ ## Release notes
770
+
771
+ ### 0.12.0
772
+
773
+ - Migrations and `naro db query` take Postgres's `CREATE POLICY`,
774
+ `DROP POLICY` and `COMMENT ON POLICY … IS 'naro:owner <column>'` next to
775
+ the tables they are for (see Row-level security). `naro db reset` clears
776
+ the policies with the schema and creates them again from your migration
777
+ files; before it drops anything it checks the files as
778
+ `naro db push --dry-run` does, and lists the policies your files do not
779
+ make, with the SQL to keep them.
780
+ - A failed `db push` keeps the server's `details.warnings` of the file that
781
+ failed (the policies a table rebuild took, with their SQL) and prints them
782
+ as `⚠` lines; the warnings of the files before it are
783
+ `details.appliedWarnings`.
784
+ - **Update the CLI before you run `naro db reset`.** `naro-cli` 0.11.0
785
+ (published on 2026-10-05 from main `98bf538b`, PR #82) and every older
786
+ version know nothing of policies in migrations. On a project whose backend
787
+ has named policies, the control plane now drops a table's policies with
788
+ the table, so their `db reset` takes every policy along — without listing
789
+ first the ones your files do not make again, and without reading the
790
+ warning that names them. A table left without a policy refuses every
791
+ Data API request until you create one again.
792
+ - **Check your clients before the backend updates.** The Data API now
793
+ refuses a string that SQLite would keep as text in a column declared with
794
+ INT, REAL, FLOA, DOUB, NUM, DEC, BOOL or BIT (a form's `"true"` for a
795
+ BOOLEAN, `"high"` for an INTEGER), even when no policy reads the column.
796
+ The backend worker takes this on at the project's next deploy or
797
+ `naro backend enable`, not when naro itself updates. Before that, look for
798
+ such rows: `naro db query "select count(*) from t where typeof(c) = 'text'"`
799
+ for each number or boolean column — a count above 0 means a client writes
800
+ strings there and needs fixing first.
801
+
478
802
  ## Machine-readable output (CI / AI agents)
479
803
 
480
804
  Every command supports `--json`: stdout carries exactly one JSON object,
@@ -497,6 +821,7 @@ Failures carry stable codes (`NOT_LOGGED_IN`, `PROJECT_NOT_LINKED`,
497
821
  `ENV_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `DOMAIN_NOT_READY`,
498
822
  `BACKEND_DISABLED`, `BACKEND_NOT_READY`, `BACKEND_BUSY`,
499
823
  `BACKEND_ROTATION_FAILED`, `BACKEND_PROVISION_FAILED`, `POLICY_NOT_FOUND`,
824
+ `POLICY_EXISTS`,
500
825
  `VALIDATION_FAILED`, `CONFIRMATION_REQUIRED`, `CANCELED`, …). A destructive
501
826
  command run non-interactively without `--yes` fails with
502
827
  `CONFIRMATION_REQUIRED` instead of silently doing nothing:
@@ -512,7 +837,8 @@ command run non-interactively without `--yes` fails with
512
837
 
513
838
  `--yes` (or `-y`) suppresses every prompt — destructive actions
514
839
  (production rollback/promote, env/domain removal, database reset, backend key
515
- rotation, policy removal) run unconfirmed, so use it deliberately in automation.
840
+ rotation, policy removal and drop) run unconfirmed, so use it deliberately in
841
+ automation.
516
842
 
517
843
  ## CI authentication
518
844
 
@@ -21,6 +21,11 @@ export interface DeploymentArtifact {
21
21
  assetsConfig?: {
22
22
  notFoundHandling: "single-page-application" | "404-page";
23
23
  };
24
+ /**
25
+ * Next.js routes ("/posts/[id]") for Speed Insights, read from the app's
26
+ * folders. Absent for other frameworks.
27
+ */
28
+ routes?: string[];
24
29
  }
25
30
  /** Mirrors the build log shape from the internal build package. */
26
31
  export interface BuildLog {