naro-cli 0.9.1 → 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
@@ -124,7 +124,16 @@ naro backend keys --reveal Print the service key (a secret; needs databa
124
124
  naro backend keys rotate --service Replace a key (--anon or --service); the old one stops
125
125
  working at once (asks first)
126
126
  naro backend auth Sign-in settings: email, Google, GitHub, redirects,
127
- and the callback URL to register for each production host
127
+ the callback URL to register for each production host,
128
+ sign-ups and the minimum password length
129
+ naro backend auth signups off Nobody signs up on their own (on opens them again)
130
+ naro backend auth password --min-length 10
131
+ The fewest characters a new password takes (6–128)
132
+ naro backend users The project's users: id, username, email, verified, created
133
+ naro backend users create --username admin --password-stdin < password.txt
134
+ A confirmed account (--email too, or instead); works
135
+ while sign-ups are closed
136
+ naro backend users delete admin By id, username or email; ends its sessions (asks first)
128
137
  naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin < resend-key.txt
129
138
  Confirmation and password reset emails from your Resend account
130
139
  naro backend auth oauth set github --client-id Iv1.abc --client-secret-stdin < github-secret.txt
@@ -135,12 +144,19 @@ naro backend auth redirects remove https://app.example.com/auth/
135
144
  Exits 1, changing nothing, when it is not in the list
136
145
  naro backend auth require-verification on
137
146
  Refuse sign-in until the email is confirmed (needs email)
138
- 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)
139
155
  naro policy set todos --select owner --insert owner --update owner --delete owner --owner user_id
140
156
  naro policy set posts --select-when 'or(is_public.eq.true,user_id.eq.auth.uid())' --insert-when 'user_id.eq.auth.uid()'
141
157
  naro policy set posts --from-file policy.json
142
- The policy as JSON, conditions and all
143
- 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)
144
160
 
145
161
  naro promote <deployment-id> Publish a build to production
146
162
  naro rollback [deployment-id] Back to the previous production build
@@ -216,11 +232,28 @@ A column is `name:type[:modifier…]`:
216
232
  Every primary key but a single `integer` one is `NOT NULL`; a foreign key must
217
233
  point at its table's primary key or a unique column. `--owner user_id` adds
218
234
  `user_id TEXT NOT NULL` and, when the backend is on and ready, an owner policy for all
219
- four actions; any other new table is closed to the Data API until you run
220
- `naro policy set` (the command says so). Policies are kept by table name: a
221
- drop removes the table's policy whenever the project has a backend, on or off
222
- (the command says so), and a rename leaves the old one behind — the command
223
- 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.
224
257
 
225
258
  SQLite cannot add a PRIMARY KEY, UNIQUE or NOT-NULL-without-default column, or
226
259
  drop a primary key, a unique or indexed column, or a table another table
@@ -316,10 +349,12 @@ message that the worker may still expect the new key — run
316
349
  `naro backend enable`; the CLI prints that hint (JSON `hint`) and exits 1.
317
350
  For a few seconds during a rotation the worker can already refuse the old key.
318
351
 
319
- `naro backend auth` sets up email and social sign-in with the project's own
320
- accounts — Naro runs no shared sender. Email (confirmation and password reset
321
- links) needs a Resend API key and a `from` address; Google and GitHub need
322
- the project's own OAuth app. `naro backend auth` prints a callback URL for
352
+ Sign-in is naro Auth, the project's own (like Supabase Auth). Email and
353
+ password sign-up and sign-in work as soon as the backend is on, with nothing
354
+ to set up. `naro backend auth` adds the rest with the project's own
355
+ accounts — Naro runs no shared sender. Sending email (confirmation and
356
+ password reset links) needs a Resend API key and a `from` address; Google
357
+ and GitHub need the project's own OAuth app. `naro backend auth` prints a callback URL for
323
358
  each production host — `https://<project>.naro.sh/_naro/v1/auth/callback/<provider>`
324
359
  and the same path on each custom domain. Register, as the app's redirect URI,
325
360
  the one for the origin your app passes to `createClient({ url })`: the
@@ -347,25 +382,192 @@ code or reset token rides in the URL fragment, which any script on the page
347
382
  — or an open redirect like `/logout?next=…` — can pass on: allow a path of
348
383
  sign-in pages rather than a whole origin. Each change uploads
349
384
  the Naro worker again; when that upload stops, the change is saved all the
350
- same and the command exits 1 with `BACKEND_PROVISION_FAILED` — run
351
- `naro backend enable` to finish. `require-verification on` needs email set
385
+ same and the command exits 1 with `BACKEND_PROVISION_FAILED`, saying
386
+ whether the worker runs it yet — run `naro backend enable` to finish. Until
387
+ then `naro backend auth` shows the saved settings with a warning that the
388
+ worker may still run older ones (`applied: false` with `--json`), so a
389
+ `signups off` that did not upload never reads back as simply closed.
390
+ `require-verification on` needs email set
352
391
  up (`VERIFICATION_NEEDS_EMAIL`), and email cannot be removed while it is on.
353
392
 
354
- Policies are default deny: a table without one refuses every anon and
355
- signed-in request. Each of `--select`, `--insert`, `--update` and `--delete`
356
- takes `none`, `public`, `authenticated` or `owner`; `owner` lets a signed-in
357
- user reach only rows whose `--owner` column holds their user id, and fills
358
- that column in on insert. What a `policy set` leaves out keeps its stored
359
- value. A change is live within about 10 seconds. `naro gen types` reads the
360
- policies too: with `--insert owner --owner user_id`, `user_id` is optional in
361
- the generated `Insert` even when it is `NOT NULL` with no default — run it
393
+ For an app only its admins sign in to, close sign-ups and create the
394
+ accounts yourself — with sign-ups closed, `authenticated` in a policy means
395
+ exactly the users you made:
396
+
397
+ ```bash
398
+ naro backend auth signups off
399
+ naro backend users create --username admin --password-stdin < password.txt
400
+ ```
401
+
402
+ `signups off` makes every sign-up answer `403 SIGNUPS_DISABLED` ("Sign-ups
403
+ are closed for this project."), and a first Google or GitHub sign-in too;
404
+ users that exist keep signing in. `naro backend users create` takes
405
+ `--username`, `--email` or both, and the password from stdin
406
+ (`--password-stdin`: a pipe, or a hidden prompt in a terminal). `--password
407
+ <p>` works too, with a warning on stderr: it lands in shell history. The
408
+ account is confirmed and signs in at once — by username with
409
+ `naro.auth.signIn({ username, password })` — also while sign-ups are closed
410
+ and while sign-in waits for a confirmed email; a username-only account has
411
+ `email: null`. The output never carries the password. `naro backend users`
412
+ lists every user (id, username, email, verified, created; `--json` for all
413
+ fields), and `naro backend users delete <id|username|email>` removes one and
414
+ ends its sessions — it asks first, and without a terminal needs `--yes`
415
+ (`CONFIRMATION_REQUIRED` otherwise, exit 1, nothing deleted). A username is
416
+ 3–30 letters, digits, `_` and `.`, in any case. The backend's refusals pass
417
+ through: `409 USERNAME_TAKEN` "That username is already taken.",
418
+ `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`, `400 PASSWORD_TOO_SHORT`
419
+ "Use at least {n} characters.", `429 WRITE_QUOTA_EXCEEDED`.
420
+
421
+ While sign-ups are open, a sign-up with a username someone has answers
422
+ `409 USERNAME_TAKEN`, so anyone can learn which usernames exist, as fast as
423
+ sign-up's per-address rate limit allows. With sign-ups closed, every
424
+ sign-up answers `403 SIGNUPS_DISABLED` first.
425
+
426
+ `naro backend auth password --min-length <n>` sets the fewest characters a
427
+ new password takes, 6 to 128 (6 by default). Sign-up, password reset and
428
+ `users create` refuse a shorter one with "Use at least {n} characters.";
429
+ sign-in never checks it, so passwords set before keep working. Both
430
+ settings re-upload the Naro worker, like the other `naro backend auth`
431
+ changes, and `naro backend auth` shows them (`Sign-ups:`, `Minimum password
432
+ length:`).
433
+
434
+ A backend turned on before user management answers the `users` commands
435
+ `409 BACKEND_OUTDATED`: run `naro backend enable` once to update this
436
+ project's backend, then run the command again.
437
+
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
362
554
  again after changing a policy.
363
555
 
364
- Instead of a rule, an action can hold a condition on the row, like a Supabase
365
- row-level security policy, written in the filter syntax of `naro-js`'s
366
- `.or()` (commas are AND) with `auth.uid()` for the signed-in user's id —
367
- unquoted; `"auth.uid()"` is the text, and `auth.uid` without `()` is refused
368
- 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:
369
571
 
370
572
  - `--select-when` and `--delete-when`: the rows reads and deletes reach.
371
573
  - `--insert-when`: what every new row must meet.
@@ -377,10 +579,9 @@ rather than read as text:
377
579
  so `not(and(is_public.eq.1,user_id.eq.auth.uid()))` matches every row that is
378
580
  not public, anon included — write what a condition lets in.
379
581
 
380
- When any row of an insert or an update misses its condition, nothing is
381
- written and the Data API answers `403 PERMISSION_DENIED` ("The new row does not
382
- meet the insert policy of posts."). A check reads a row as it will be stored
383
- 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:
384
585
 
385
586
  - a number column it reads takes numbers, booleans and null, not strings, and
386
587
  cannot be compared with `like` or `ilike`;
@@ -391,28 +592,21 @@ and compares it with the column's collation (`COLLATE NOCASE`, `RTRIM`). So:
391
592
  another expression must be sent;
392
593
  - it cannot read a generated column or a virtual table's column.
393
594
 
394
- One policy's conditions use at most 50 values together (an update's check
395
- counts each column it reads once more), leaving 50 of D1's 100 per statement
396
- for the request — 48 for a write with a check, which also binds the table's
397
- schema to make sure it checks against the table as it is now. `--from-file`
398
- 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`
399
599
  (`naro policy set posts --json | jq .policy > policy.json`), where
400
600
  `auth.uid()` is `{"auth":"uid"}`; its `table`, if it has one, must name the
401
601
  table being set. It cannot be combined with the other policy options.
402
- `naro policy list` prints each condition in filter syntax that reads back as
403
- the same condition, or as JSON where filter syntax cannot say it exactly
404
- (`deleted_at.eq.null` would be the text `"null"`, and `= NULL` is not
405
- `IS NULL`); `--json` gives the exact JSON.
406
-
407
- Two things a condition does not do for you. An update or delete is held to its
408
- own condition only, not to select's, unless it asks for rows back with
409
- `.select()`. With an update condition broader than select's, a caller can tell
410
- rows it cannot select apart without `.select()`: an update that sets a value
411
- the check refuses answers 403 when a hidden row matches its filters and 200
412
- when none does, so filter by filter it learns what those rows hold. Keep
413
- update and delete conditions no broader than select's. And an `--update-check`
414
- you give replaces the default: without `user_id.eq.auth.uid()` in it, a user
415
- 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
416
610
  `--update-when 'user_id.eq.auth.uid()' --update-check 'user_id.eq.auth.uid(),status.eq.draft'`.
417
611
 
418
612
  ## Machine-readable output (CI / AI agents)
@@ -437,6 +631,7 @@ Failures carry stable codes (`NOT_LOGGED_IN`, `PROJECT_NOT_LINKED`,
437
631
  `ENV_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `DOMAIN_NOT_READY`,
438
632
  `BACKEND_DISABLED`, `BACKEND_NOT_READY`, `BACKEND_BUSY`,
439
633
  `BACKEND_ROTATION_FAILED`, `BACKEND_PROVISION_FAILED`, `POLICY_NOT_FOUND`,
634
+ `POLICY_EXISTS`,
440
635
  `VALIDATION_FAILED`, `CONFIRMATION_REQUIRED`, `CANCELED`, …). A destructive
441
636
  command run non-interactively without `--yes` fails with
442
637
  `CONFIRMATION_REQUIRED` instead of silently doing nothing:
@@ -452,7 +647,8 @@ command run non-interactively without `--yes` fails with
452
647
 
453
648
  `--yes` (or `-y`) suppresses every prompt — destructive actions
454
649
  (production rollback/promote, env/domain removal, database reset, backend key
455
- 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.
456
652
 
457
653
  ## CI authentication
458
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 {