naro-cli 0.10.0 → 0.11.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
@@ -144,12 +144,19 @@ naro backend auth redirects remove https://app.example.com/auth/
144
144
  Exits 1, changing nothing, when it is not in the list
145
145
  naro backend auth require-verification on
146
146
  Refuse sign-in until the email is confirmed (needs email)
147
- naro policy list Per-table rules and conditions of the Data API
147
+ naro policy list [--table todos] Row-level security policies, as CREATE POLICY
148
+ naro policy create 'CREATE POLICY "own todos" ON todos TO authenticated USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())'
149
+ naro policy create --file policies.sql
150
+ CREATE POLICY and DROP POLICY statements, run in order;
151
+ --allow-unindexed accepts a subquery on the outer row
152
+ that no index serves (refused otherwise)
153
+ naro policy drop "own todos" --table todos
154
+ DROP POLICY (asks first)
148
155
  naro policy set todos --select owner --insert owner --update owner --delete owner --owner user_id
149
156
  naro policy set posts --select-when 'or(is_public.eq.true,user_id.eq.auth.uid())' --insert-when 'user_id.eq.auth.uid()'
150
157
  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)
158
+ Shorthand: writes the policies naro_select … naro_delete
159
+ naro policy remove todos Drop every policy on the table: back to deny (asks first)
153
160
 
154
161
  naro promote <deployment-id> Publish a build to production
155
162
  naro rollback [deployment-id] Back to the previous production build
@@ -225,11 +232,28 @@ A column is `name:type[:modifier…]`:
225
232
  Every primary key but a single `integer` one is `NOT NULL`; a foreign key must
226
233
  point at its table's primary key or a unique column. `--owner user_id` adds
227
234
  `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.
235
+ four actions; any other new table is closed to the Data API until you give it
236
+ a policy (`naro policy create` or `naro policy set`; the command says so).
237
+ Policies are kept by table name: a drop removes the table's policies whenever
238
+ the project has a backend, on or off (the command says so). A rename leaves
239
+ them under the old name, where they apply to no table — the command prints
240
+ what to run to move them. Move them then: the next time `naro db table`
241
+ creates a table as that name or renames one to it, renaming the table back
242
+ included, the policies stored under the name are removed first, so a new
243
+ table never inherits an earlier one's (a table made by `naro db push` or
244
+ `naro db query` gets them as they are). Whatever a command removes it prints
245
+ so it can be made again: as `CREATE POLICY` statements, which
246
+ `naro policy create` takes back, and — for owner rules, whose owner column
247
+ `CREATE POLICY` cannot say — as the `naro policy set <table> … --owner <column>`
248
+ command that sets them again. A statement that cannot be shown as it is (a
249
+ control character, or a line break inside a string) prints as a `--` comment
250
+ in its place; `--json` has the exact text.
251
+
252
+ A policy reads columns and, in a subquery, other tables. When a rename or a
253
+ drop takes one of them away, the Data API answers `500 POLICY_INVALID` to the
254
+ requests that policy applies to — on another table, when the policy of `docs`
255
+ reads `members`. The command names each policy the change breaks and why;
256
+ `--dry-run` names them before anything is applied.
233
257
 
234
258
  SQLite cannot add a PRIMARY KEY, UNIQUE or NOT-NULL-without-default column, or
235
259
  drop a primary key, a unique or indexed column, or a table another table
@@ -411,21 +435,139 @@ A backend turned on before user management answers the `users` commands
411
435
  `409 BACKEND_OUTDATED`: run `naro backend enable` once to update this
412
436
  project's backend, then run the command again.
413
437
 
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
438
+ ### Row-level security
439
+
440
+ Policies are Postgres's row-level security, as in Supabase. A table without a
441
+ policy refuses every anon and signed-in request; the service key skips
442
+ policies. Write them as `CREATE POLICY`, a change is live within about 10
443
+ seconds, and `naro policy list` prints them back as SQL:
444
+
445
+ ```sql
446
+ -- Each signed-in user reads and changes their own todos.
447
+ CREATE POLICY "own todos" ON todos TO authenticated
448
+ USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
449
+
450
+ -- Anyone reads published posts.
451
+ CREATE POLICY "published posts" ON posts FOR SELECT USING (published);
452
+
453
+ -- Members read their team's docs. The subquery reads members through
454
+ -- members' own select policies, so members needs one.
455
+ CREATE POLICY "own memberships" ON members FOR SELECT TO authenticated
456
+ USING (user_id = auth.uid());
457
+ CREATE POLICY "team docs" ON docs FOR SELECT TO authenticated
458
+ USING (EXISTS (SELECT 1 FROM members m
459
+ WHERE m.team_id = docs.team_id AND m.user_id = auth.uid()));
460
+
461
+ -- Deleted docs stay hidden, whatever else lets them through.
462
+ CREATE POLICY "hide deleted" ON docs AS RESTRICTIVE FOR SELECT
463
+ USING (deleted_at IS NULL);
464
+ ```
465
+
466
+ A request passes when at least one permissive policy for its command and role
467
+ passes and every restrictive one does. `FOR ALL` (the default) is every
468
+ command; `TO public` (the default) is anon and signed-in users. As in
469
+ Postgres:
470
+
471
+ - select reaches the rows the select policies pass;
472
+ - insert writes a row only when it passes `WITH CHECK`, and with `.select()`
473
+ the select policies too;
474
+ - update reaches the rows its `USING` and the select policies pass, and each
475
+ changed row must pass `WITH CHECK` (`USING` when there is none) and the
476
+ select policies — so an update that moves a row out of what select shows,
477
+ like a soft delete, is refused;
478
+ - delete reaches the rows its `USING` and the select policies pass.
479
+
480
+ A row outside `USING` is skipped without a word. A written row that misses a
481
+ check writes nothing and answers `403 PERMISSION_DENIED` "New row violates
482
+ row-level security policy for table todos." (with the name of a restrictive
483
+ policy it missed). With no permissive policy for the command, the request is
484
+ refused — `403 PERMISSION_DENIED` "This request is not allowed by the update
485
+ policy of todos." — where Postgres would answer no rows.
486
+
487
+ Expressions: columns, literals, comparisons, `AND`/`OR`/`NOT`, `IS [NOT] NULL`,
488
+ `IS [NOT] DISTINCT FROM`, `[NOT] IN (…)`, `[NOT] LIKE`/`ILIKE`, `BETWEEN`,
489
+ `EXISTS (SELECT …)` and `IN (SELECT …)`, `auth.uid()`, `auth.role()`,
490
+ `auth.jwt() ->> 'sub' | 'role' | 'email' | 'email_verified'`, and `lower`,
491
+ `upper`, `coalesce`, `length`, `now()`. Supabase's `(select auth.uid())` reads
492
+ as `auth.uid()`. A subquery's table is read through its own select policies;
493
+ policies that would expand themselves are refused ("Infinite recursion
494
+ detected in policy for relation members."). A statement's policies bind at
495
+ most 50 values and write at most 50,000 bytes of SQL, leaving the rest of
496
+ D1's 100 values and 100,000 bytes to the request. A `LIKE` or `ILIKE` pattern
497
+ is at most 50 bytes of UTF-8, which is D1's limit; `LIKE` counts each `*`,
498
+ `?` and `[` as 3.
499
+
500
+ A subquery that reads the outer row (`m.team_id = docs.team_id`) runs once for
501
+ every row the statement looks at, so it needs an index to find its rows: the
502
+ control plane refuses such a policy until a column its WHERE compares with `=`
503
+ (or `IN`) to a value from outside it starts an index, and names the index to
504
+ create — `naro db index create members team_id`. `--allow-unindexed` accepts
505
+ it anyway; every run then reads the whole table.
506
+
507
+ For anon, `auth.uid()` is NULL and `auth.jwt()` holds only `role` `'anon'`, as
508
+ in Supabase, and NULL logic is Postgres's: `auth.uid() IS NULL` is true for
509
+ anon, `user_id = auth.uid()` and its `NOT` match no row, and a `NOT EXISTS`
510
+ over another table's policy reads that policy the same way. Target anon with
511
+ `TO anon` or `auth.role() = 'anon'`. `auth.jwt() ->> 'email'` is the user's
512
+ address only once it is verified, and NULL before — sign-up signs a user in
513
+ before the address is confirmed, so an unverified one is only what was typed;
514
+ `email_verified` says which. It is the text `'true'` or `'false'`: write
515
+ `(auth.jwt() ->> 'email_verified') = 'true'`. `IS TRUE`, `IS FALSE`, `NOT x`
516
+ and a value alone read a number or boolean column; on a claim, `auth.uid()`,
517
+ a string, a function or a TEXT column they are refused, with the comparison
518
+ to write — as text, `'true'` would read as false. So is comparing text with
519
+ `TRUE` or `FALSE` — `<> false`, `= true`, `IN (true)`, `BETWEEN`,
520
+ `IS DISTINCT FROM`, also through `coalesce` — since the text `'true'` is not
521
+ `TRUE`: `<> false` would hold for everyone.
522
+
523
+ Where naro differs from Postgres: no policy for a command is a 403, not zero
524
+ rows; row-level security is always on; roles are only `anon` and
525
+ `authenticated`, with no `security definer` functions; comparisons follow
526
+ SQLite (column affinity and collation), and `lower`, `upper` and `ILIKE` fold
527
+ ASCII only; `now()` is UTC text as `CURRENT_TIMESTAMP` stores it.
528
+
529
+ `naro policy create --file` parses every statement first, so a syntax error
530
+ sends none; then it runs them in order and stops at the first one refused,
531
+ keeping those before it. `naro policy list` prints what `naro policy create`
532
+ takes back, except the owner comment below. A policy whose name, table or
533
+ string holds a control character (a line break, an escape) is not printed as
534
+ a statement — with the character replaced it would create a different policy
535
+ — but as a `--` comment that says so; `naro policy list --json` has its exact
536
+ text.
537
+
538
+ `naro policy drop <name>` takes the name as you type it: the policy of exactly
539
+ that name, or else the one of its ASCII lower case. `CREATE POLICY MyPolicy`
540
+ stores `mypolicy`, as in Postgres, and `naro policy drop MyPolicy --table
541
+ todos` drops it; a policy created as `"MyPolicy"` is the one dropped when both
542
+ exist.
543
+
544
+ `naro policy set` is shorthand: it writes the policies `naro_select`,
545
+ `naro_insert`, `naro_update` and `naro_delete` of a table and leaves its other
546
+ policies alone. Each of `--select`, `--insert`, `--update` and `--delete`
547
+ takes `none` (drops that policy), `public`, `authenticated` or `owner`;
548
+ `owner` lets a signed-in user reach only rows whose `--owner` column holds
549
+ their user id, and fills that column in on insert — a Naro extension SQL
550
+ cannot say, so `naro policy list` prints it as a `-- naro: owner` comment.
551
+ What a `policy set` leaves out keeps its stored value. `naro gen types` reads
552
+ the policies too: with `--insert owner --owner user_id`, `user_id` is optional
553
+ in the generated `Insert` even when it is `NOT NULL` with no default — run it
422
554
  again after changing a policy.
423
555
 
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:
556
+ A backend turned on before named policies answers the commands that write a
557
+ policy — `naro policy create`, `naro policy drop` and `naro policy set` — with
558
+ `409 BACKEND_OUTDATED`:
559
+ ``The project backend is older than named policies. Run `naro backend enable` to update it, then try again.``
560
+ `naro backend enable` uploads the current backend and turns the stored rules
561
+ into named policies; it also turns a backend that is off back on, so run
562
+ `naro backend disable` again afterwards if it should stay off. `naro policy
563
+ list` and `naro policy remove` work in the meantime.
564
+
565
+ `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.
566
+
567
+ Instead of a rule, an action can hold a condition on the row, written in the
568
+ filter syntax of `naro-js`'s `.or()` (commas are AND) with `auth.uid()` for
569
+ the signed-in user's id — unquoted; `"auth.uid()"` is the text, and
570
+ `auth.uid` without `()` is refused rather than read as text:
429
571
 
430
572
  - `--select-when` and `--delete-when`: the rows reads and deletes reach.
431
573
  - `--insert-when`: what every new row must meet.
@@ -437,10 +579,9 @@ rather than read as text:
437
579
  so `not(and(is_public.eq.1,user_id.eq.auth.uid()))` matches every row that is
438
580
  not public, anon included — write what a condition lets in.
439
581
 
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:
582
+ A check — `WITH CHECK`, and the select policies an update's new row must
583
+ pass — reads a row as it will be stored and compares it with the column's
584
+ collation (`COLLATE NOCASE`, `RTRIM`). So:
444
585
 
445
586
  - a number column it reads takes numbers, booleans and null, not strings, and
446
587
  cannot be compared with `like` or `ilike`;
@@ -451,28 +592,21 @@ and compares it with the column's collation (`COLLATE NOCASE`, `RTRIM`). So:
451
592
  another expression must be sent;
452
593
  - it cannot read a generated column or a virtual table's column.
453
594
 
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`
595
+ The 50 values count an update's check once more for each column it reads, and
596
+ a write with a check binds the table's schema too (48 left), to make sure it
597
+ checks against the table as it is now. `--from-file` takes the PUT body, or a
598
+ policy object from `--json`
459
599
  (`naro policy set posts --json | jq .policy > policy.json`), where
460
600
  `auth.uid()` is `{"auth":"uid"}`; its `table`, if it has one, must name the
461
601
  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
602
+ `naro policy set` prints the table's rules — each condition in filter syntax
603
+ that reads back as the same condition, or as JSON where filter syntax cannot
604
+ say it exactly (`deleted_at.eq.null` would be the text `"null"`, and `= NULL`
605
+ is not `IS NULL`) — and then its policies as `CREATE POLICY`.
606
+
607
+ An `--update-check` you give replaces the default: without
608
+ `user_id.eq.auth.uid()` in it, a user can give a row away by changing its
609
+ `user_id`. Write
476
610
  `--update-when 'user_id.eq.auth.uid()' --update-check 'user_id.eq.auth.uid(),status.eq.draft'`.
477
611
 
478
612
  ## Machine-readable output (CI / AI agents)
@@ -497,6 +631,7 @@ Failures carry stable codes (`NOT_LOGGED_IN`, `PROJECT_NOT_LINKED`,
497
631
  `ENV_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `DOMAIN_NOT_READY`,
498
632
  `BACKEND_DISABLED`, `BACKEND_NOT_READY`, `BACKEND_BUSY`,
499
633
  `BACKEND_ROTATION_FAILED`, `BACKEND_PROVISION_FAILED`, `POLICY_NOT_FOUND`,
634
+ `POLICY_EXISTS`,
500
635
  `VALIDATION_FAILED`, `CONFIRMATION_REQUIRED`, `CANCELED`, …). A destructive
501
636
  command run non-interactively without `--yes` fails with
502
637
  `CONFIRMATION_REQUIRED` instead of silently doing nothing:
@@ -512,7 +647,8 @@ command run non-interactively without `--yes` fails with
512
647
 
513
648
  `--yes` (or `-y`) suppresses every prompt — destructive actions
514
649
  (production rollback/promote, env/domain removal, database reset, backend key
515
- rotation, policy removal) run unconfirmed, so use it deliberately in automation.
650
+ rotation, policy removal and drop) run unconfirmed, so use it deliberately in
651
+ automation.
516
652
 
517
653
  ## CI authentication
518
654
 
@@ -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 {