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 +377 -51
- package/dist/artifact-types.d.ts +5 -0
- package/dist/{chunk-5U6YPVZS.js → chunk-GFCG5C7D.js} +6022 -380
- package/dist/deploy.js +1 -1
- package/dist/git-info.d.ts +14 -1
- package/dist/index.js +2353 -1844
- package/package.json +4 -4
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,
|
|
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;
|
|
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
|
|
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
|
-
|
|
152
|
-
naro policy remove todos
|
|
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.
|
|
192
|
-
|
|
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.
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
463
|
-
the same condition, or as JSON where filter syntax cannot
|
|
464
|
-
(`deleted_at.eq.null` would be the text `"null"`, and `= NULL`
|
|
465
|
-
`IS NULL`)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
`.
|
|
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
|
|
840
|
+
rotation, policy removal and drop) run unconfirmed, so use it deliberately in
|
|
841
|
+
automation.
|
|
516
842
|
|
|
517
843
|
## CI authentication
|
|
518
844
|
|
package/dist/artifact-types.d.ts
CHANGED
|
@@ -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 {
|