naro-cli 0.12.0 → 0.14.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/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ANALYTICS_FILTER_KEYS,
3
4
  BACKEND_STEPS,
4
5
  BOOKMARK_FORMAT_MESSAGE,
5
6
  INDEX_SQL,
7
+ MAX_SINGLE_UPLOAD_BYTES,
6
8
  NAMED_POLICY_SQL,
7
9
  NAMED_POLICY_TABLE,
8
10
  NaroClient,
@@ -12,6 +14,10 @@ import {
12
14
  RESET_BACKEND_OUTDATED,
13
15
  RESTORE_OUTCOME_UNKNOWN_CODES,
14
16
  SCHEMA_SQL,
17
+ SPEED_INSIGHTS_FILTER_KEYS,
18
+ SPEED_INSIGHTS_METRICS,
19
+ SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
20
+ STORAGE_OBJECTS_TABLE,
15
21
  buildResetPlan,
16
22
  cellText,
17
23
  clearSession,
@@ -20,6 +26,7 @@ import {
20
26
  controlPlaneUrl,
21
27
  countLintedTables,
22
28
  createAuthClient,
29
+ dataError,
23
30
  deployDirectory,
24
31
  detectGitInfo,
25
32
  ensureGitignoreHasNaro,
@@ -40,6 +47,7 @@ import {
40
47
  migrationsPendingError,
41
48
  nextMigrationFileName,
42
49
  openBrowser,
50
+ parseCacheControl,
43
51
  parseGitHubRemote,
44
52
  parsePolicyNode,
45
53
  parsePolicyStatement,
@@ -75,7 +83,7 @@ import {
75
83
  toNaroError,
76
84
  unreadablePolicyComment,
77
85
  writeProjectLink
78
- } from "./chunk-GFCG5C7D.js";
86
+ } from "./chunk-U7AZSAFS.js";
79
87
  import "./chunk-K3NQKI34.js";
80
88
 
81
89
  // src/args.ts
@@ -108,7 +116,13 @@ var BOOLEAN_FLAGS = {
108
116
  "--allow-unindexed": "allowUnindexed",
109
117
  "--api-key-stdin": "apiKeyStdin",
110
118
  "--client-secret-stdin": "clientSecretStdin",
111
- "--password-stdin": "passwordStdin"
119
+ "--password-stdin": "passwordStdin",
120
+ "--public": "public",
121
+ "--private": "private",
122
+ "--empty": "empty",
123
+ "--recursive": "recursive",
124
+ "-r": "recursive",
125
+ "--upsert": "upsert"
112
126
  };
113
127
  var REPEATED_FLAGS = {
114
128
  "--column": "columns"
@@ -124,6 +138,15 @@ var STRING_FLAGS = {
124
138
  "--type": "type",
125
139
  "--env": "env",
126
140
  "--level": "level",
141
+ "--metric": "metric",
142
+ "--range": "range",
143
+ "--route": "route",
144
+ "--path": "path",
145
+ "--referrer": "referrer",
146
+ "--country": "country",
147
+ "--device": "device",
148
+ "--browser": "browser",
149
+ "--os": "os",
127
150
  "--file": "file",
128
151
  "--output": "output",
129
152
  "--lang": "lang",
@@ -149,7 +172,11 @@ var STRING_FLAGS = {
149
172
  "--username": "username",
150
173
  "--email": "email",
151
174
  "--password": "password",
152
- "--min-length": "minLength"
175
+ "--min-length": "minLength",
176
+ "--file-size-limit": "fileSizeLimit",
177
+ "--allowed-mime-types": "allowedMimeTypes",
178
+ "--content-type": "contentType",
179
+ "--cache-control": "cacheControl"
153
180
  };
154
181
  var SECRET_FLAGS = {
155
182
  "--api-key": "--api-key-stdin",
@@ -335,7 +362,7 @@ var TtyPrompter = class {
335
362
  });
336
363
  try {
337
364
  return await new Promise(
338
- (resolve5) => rl.question(question, resolve5)
365
+ (resolve6) => rl.question(question, resolve6)
339
366
  );
340
367
  } finally {
341
368
  rl.close();
@@ -356,7 +383,7 @@ var TtyPrompter = class {
356
383
  });
357
384
  try {
358
385
  const answer = new Promise(
359
- (resolve5) => rl.question(question, resolve5)
386
+ (resolve6) => rl.question(question, resolve6)
360
387
  );
361
388
  muted = true;
362
389
  const value = await answer;
@@ -438,10 +465,10 @@ function createContext(flags, positionals = []) {
438
465
  positionals,
439
466
  output,
440
467
  prompter,
441
- positionalOrThrow: (count, usage) => {
442
- if (positionals.length < count)
468
+ positionalOrThrow: (count2, usage) => {
469
+ if (positionals.length < count2)
443
470
  throw new NaroError("VALIDATION_FAILED", usage);
444
- return positionals.slice(0, count);
471
+ return positionals.slice(0, count2);
445
472
  },
446
473
  auth,
447
474
  client: async () => {
@@ -468,94 +495,20 @@ function createContext(flags, positionals = []) {
468
495
 
469
496
  // src/router.ts
470
497
  import { readFileSync as readFileSync5 } from "fs";
471
- import { dirname as dirname3, join as join6 } from "path";
498
+ import { dirname as dirname4, join as join7 } from "path";
472
499
  import { fileURLToPath } from "url";
473
500
 
474
- // src/commands/auth.ts
475
- async function runLogin(context) {
476
- const { output } = context;
477
- output.log("Opening your browser to sign in with GitHub...");
478
- const session = await login({
479
- openBrowser: (url) => {
480
- output.log(`If your browser did not open, visit:
481
- ${url}`);
482
- openBrowser(url);
483
- }
484
- });
485
- saveSession(session);
486
- output.result(
487
- {
488
- user: {
489
- id: session.user.id,
490
- email: session.user.email,
491
- github: session.user.github
492
- }
493
- },
494
- [`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
495
- );
496
- }
497
- async function runLogout(context) {
498
- const session = loadStoredSession();
499
- if (session) {
500
- try {
501
- const client = createAuthClient();
502
- await client.auth.setSession({
503
- access_token: session.access_token,
504
- refresh_token: session.refresh_token
505
- });
506
- await client.auth.signOut();
507
- } catch {
508
- }
509
- }
510
- clearSession();
511
- context.output.result({ loggedOut: true }, ["Logged out."]);
512
- }
513
- async function currentSession() {
514
- let session = loadStoredSession();
515
- if (!session)
516
- throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
517
- if (isExpired(session)) {
518
- try {
519
- session = await refreshStoredSession(session.refresh_token);
520
- saveSession(session);
521
- } catch {
522
- throw new NaroError(
523
- "NOT_LOGGED_IN",
524
- "Session expired. Run `naro login`."
525
- );
526
- }
527
- }
528
- return session;
529
- }
530
- async function runWhoami(context) {
531
- const session = await currentSession();
532
- const handle = session.user.github ? ` (${session.user.github})` : "";
533
- context.output.result(
534
- {
535
- user: {
536
- id: session.user.id,
537
- email: session.user.email,
538
- github: session.user.github
539
- }
540
- },
541
- [`Logged in as ${session.user.email ?? session.user.id}${handle}`]
542
- );
543
- }
544
- async function runToken(context) {
545
- const session = await currentSession();
546
- process.stderr.write(
547
- "\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
548
- );
549
- context.output.result(
550
- { token: session.access_token, expiresAt: session.expires_at },
551
- [session.access_token]
552
- );
553
- }
501
+ // src/commands/migration.ts
502
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
503
+ import { join, relative } from "path";
554
504
 
555
505
  // src/commands/confirm.ts
506
+ function cannotAsk(context) {
507
+ return context.flags.json || !isInteractive();
508
+ }
556
509
  async function confirmOrAbort(context, question) {
557
510
  if (context.flags.yes) return;
558
- if (context.flags.json || !isInteractive()) {
511
+ if (cannotAsk(context)) {
559
512
  throw new NaroError(
560
513
  "CONFIRMATION_REQUIRED",
561
514
  `${question} Re-run with --yes to confirm.`
@@ -566,8 +519,6 @@ async function confirmOrAbort(context, question) {
566
519
  }
567
520
 
568
521
  // src/commands/migration.ts
569
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
570
- import { join, relative } from "path";
571
522
  var processStdin = {
572
523
  stdinIsTty: () => process.stdin.isTTY === true,
573
524
  readStdin
@@ -591,14 +542,9 @@ async function appliedMigrations(client, projectId) {
591
542
  throw error;
592
543
  }
593
544
  }
545
+ var UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
594
546
  function printable(text) {
595
- let out = "";
596
- for (const char of text) {
597
- const code = char.charCodeAt(0);
598
- const control = code < 32 || code >= 127 && code <= 159;
599
- out += control ? "?" : char;
600
- }
601
- return out;
547
+ return text.replace(UNPRINTABLE, "?");
602
548
  }
603
549
  function isPrintable(text) {
604
550
  return printable(text) === text;
@@ -805,6 +751,381 @@ async function runMigrationUp(context) {
805
751
  ]);
806
752
  }
807
753
 
754
+ // src/commands/filters.ts
755
+ function filtersOf(flags, keys) {
756
+ const filters = {};
757
+ for (const key of keys) {
758
+ const value = flags[key];
759
+ if (value !== void 0) filters[key] = value;
760
+ }
761
+ return filters;
762
+ }
763
+ function givenFilterFlag(flags) {
764
+ return ANALYTICS_FILTER_KEYS.find((key) => {
765
+ const value = flags[key];
766
+ return value !== void 0 && value !== "";
767
+ });
768
+ }
769
+ function filteredBy(filters) {
770
+ const applied = ANALYTICS_FILTER_KEYS.flatMap((key) => {
771
+ const value = filters[key];
772
+ return value ? [`${key}=${printable(value)}`] : [];
773
+ });
774
+ return applied.length === 0 ? "" : ` \xB7 filtered by ${applied.join(", ")}`;
775
+ }
776
+
777
+ // src/commands/insights-format.ts
778
+ var OFF_LINE = "Speed Insights is off for this project. Turn it on: naro insights enable";
779
+ var TURNED_ON = "Speed Insights is on. Measuring starts within about a minute; values arrive when visitors leave a page.";
780
+ var TURNED_OFF = "Speed Insights is off. Measuring stops within about a minute; turning it back on shows past measurements again.";
781
+ var RANGE_TEXT = {
782
+ "24h": "last 24 hours",
783
+ "7d": "last 7 days",
784
+ "30d": "last 30 days"
785
+ };
786
+ var PART_TEXT = {
787
+ ttfb: "TTFB",
788
+ loadDelay: "load delay",
789
+ loadDuration: "load duration",
790
+ renderDelay: "render delay",
791
+ inputDelay: "input delay",
792
+ processing: "processing",
793
+ presentation: "presentation"
794
+ };
795
+ var PAGE_LOAD_PART_TEXT = {
796
+ wait: "wait",
797
+ dns: "DNS",
798
+ connect: "connect",
799
+ server: "server",
800
+ download: "download",
801
+ processing: "processing",
802
+ onload: "onload"
803
+ };
804
+ function rangeText(range) {
805
+ const known = RANGE_TEXT;
806
+ return known[range] ?? printable(range);
807
+ }
808
+ function partText(name) {
809
+ const known = PART_TEXT;
810
+ return known[name] ?? printable(name);
811
+ }
812
+ function pageLoadPartText(name) {
813
+ const known = PAGE_LOAD_PART_TEXT;
814
+ return known[name] ?? printable(name);
815
+ }
816
+ var ATTRIBUTED = /* @__PURE__ */ new Set([
817
+ "lcp",
818
+ "inp",
819
+ "cls"
820
+ ]);
821
+ var DEPLOYMENT_ROWS = 10;
822
+ var SUBJECT_LENGTH = 50;
823
+ function formatMs(ms) {
824
+ const rounded = Math.round(ms);
825
+ return rounded < 1e3 ? `${String(rounded)} ms` : `${(rounded / 1e3).toFixed(2)} s`;
826
+ }
827
+ function formatValue(metric, value) {
828
+ return metric === "cls" ? value.toFixed(2) : formatMs(value);
829
+ }
830
+ function formatChange(metric, change2) {
831
+ const size = formatValue(metric, Math.abs(change2));
832
+ if (Number.parseFloat(size) === 0) return "\xB10";
833
+ return change2 > 0 ? `\u25B2 +${size}` : `\u25BC \u2212${size}`;
834
+ }
835
+ function formatCount(count2) {
836
+ return Math.round(count2).toLocaleString("en-US");
837
+ }
838
+ function percent(part, total) {
839
+ return `${String(Math.round(part / total * 100))}%`;
840
+ }
841
+ function label(metric) {
842
+ return metric.toUpperCase();
843
+ }
844
+ function environmentSuffix(environment) {
845
+ return environment === "preview" ? ", preview" : "";
846
+ }
847
+ function environmentWord(environment) {
848
+ return environment === "preview" ? "preview " : "";
849
+ }
850
+ function table(rows) {
851
+ const widths = [];
852
+ for (const row of rows)
853
+ row.forEach((cell, i) => {
854
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
855
+ });
856
+ return rows.map(
857
+ (row) => ` ${row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.trimEnd()
858
+ );
859
+ }
860
+ function summaryLines(report) {
861
+ return table([
862
+ ["Metric", "p75", "Good", "Needs improvement", "Poor", "Visits"],
863
+ ...SPEED_INSIGHTS_METRICS.map((metric) => {
864
+ const summary = report.summary[metric];
865
+ if (summary.p75 === null || summary.samples <= 0)
866
+ return [label(metric), "\u2014", "\u2014", "\u2014", "\u2014", "0"];
867
+ return [
868
+ label(metric),
869
+ formatValue(metric, summary.p75),
870
+ percent(summary.good, summary.samples),
871
+ percent(summary.needsImprovement, summary.samples),
872
+ percent(summary.poor, summary.samples),
873
+ formatCount(summary.samples)
874
+ ];
875
+ })
876
+ ]);
877
+ }
878
+ function pageLoadLine(pageLoad) {
879
+ if (!pageLoad || pageLoad.samples <= 0 || pageLoad.p75 === null) return null;
880
+ const head = `Page load p75 ${formatMs(pageLoad.p75)}`;
881
+ if (pageLoad.parts.length === 0) return head;
882
+ const steps = pageLoad.parts.map(
883
+ (part) => `${pageLoadPartText(part.name)} ${formatMs(part.ms)}`
884
+ );
885
+ return `${head} \xB7 mean per step: ${steps.join(" \xB7 ")}`;
886
+ }
887
+ function breakdownTable(heading, metric, rows) {
888
+ if (rows.length === 0) return [" (none)"];
889
+ return table([
890
+ ["p75", "Visits", heading],
891
+ ...rows.map((row) => [
892
+ formatValue(metric, row.p75),
893
+ formatCount(row.samples),
894
+ printable(row.name)
895
+ ])
896
+ ]);
897
+ }
898
+ function elementLines(metric, elements) {
899
+ const lines = ["", `${label(metric)} elements behind slow visits`];
900
+ if (elements.length === 0) return [...lines, " (none)"];
901
+ for (const row of elements) {
902
+ lines.push(
903
+ ` ${printable(row.element)} on ${printable(row.route)}: ${formatCount(row.slow)} slow visits, average ${formatValue(metric, row.average)}`
904
+ );
905
+ if (row.parts && row.parts.length > 0)
906
+ lines.push(
907
+ ` ${row.parts.map((part) => `${partText(part.name)} ${formatMs(part.ms)}`).join(" \xB7 ")}`
908
+ );
909
+ if (row.url) lines.push(` image ${printable(row.url)}`);
910
+ }
911
+ return lines;
912
+ }
913
+ function deploymentName(row) {
914
+ if (row.source === "cli" || !row.commitSha) return row.id.slice(0, 8);
915
+ const sha = printable(row.commitSha.slice(0, 7));
916
+ const subject = [
917
+ ...printable(row.commitMessage?.split("\n")[0]?.trim() ?? "")
918
+ ];
919
+ if (subject.length === 0) return sha;
920
+ return subject.length > SUBJECT_LENGTH ? `${sha} ${subject.slice(0, SUBJECT_LENGTH - 1).join("")}\u2026` : `${sha} ${subject.join("")}`;
921
+ }
922
+ function changeText(metric, row) {
923
+ if (row.samples < SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES)
924
+ return "too few visits";
925
+ if (row.change === null) return "\u2013";
926
+ return formatChange(metric, row.change);
927
+ }
928
+ function deploymentLines(metric, deployments, environment) {
929
+ if (deployments === null) return [];
930
+ const lines = [
931
+ "",
932
+ `${label(metric)} by ${environmentWord(environment)}deployment, newest first`
933
+ ];
934
+ const measured = deployments.filter((row) => row.samples > 0).slice(0, DEPLOYMENT_ROWS);
935
+ if (measured.length === 0) return [...lines, " (none)"];
936
+ return [
937
+ ...lines,
938
+ ...table([
939
+ ["", "p75", "Visits", "Change", "Deployment"],
940
+ ...measured.map((row) => [
941
+ row.liveUntil === null ? "Live" : "",
942
+ row.p75 === null ? "\u2014" : formatValue(metric, row.p75),
943
+ formatCount(row.samples),
944
+ changeText(metric, row),
945
+ deploymentName(row)
946
+ ])
947
+ ])
948
+ ];
949
+ }
950
+ function reportLines(report, deployments, filters = {}) {
951
+ if (!report.enabled) return [OFF_LINE];
952
+ const { metric, range, environment } = report;
953
+ const filtered = filteredBy(filters);
954
+ const head = `Speed Insights \u2014 ${label(metric)}, ${rangeText(range)}${environmentSuffix(environment)}${filtered}`;
955
+ if (SPEED_INSIGHTS_METRICS.every((m) => report.summary[m].samples <= 0))
956
+ return [
957
+ head,
958
+ "",
959
+ // The dashboard's noFilteredSamples: waiting is the wrong advice when a
960
+ // filter is what emptied the report.
961
+ filtered === "" ? `No ${environmentWord(environment)}measurements in the ${rangeText(range)} yet. Values arrive when visitors leave a page.` : "No measurements match these filters. Drop a filter to see more."
962
+ ];
963
+ const pageLoad = pageLoadLine(report.pageLoad);
964
+ return [
965
+ head,
966
+ "",
967
+ ...summaryLines(report),
968
+ ...pageLoad === null ? [] : ["", pageLoad],
969
+ "",
970
+ `${label(metric)} by page`,
971
+ ...breakdownTable("Page", metric, report.paths),
972
+ "",
973
+ `${label(metric)} by device`,
974
+ ...breakdownTable("Device", metric, report.devices),
975
+ ...ATTRIBUTED.has(metric) ? elementLines(metric, report.elements) : [],
976
+ ...deploymentLines(metric, deployments, environment)
977
+ ];
978
+ }
979
+ function routePathsLines(result, filters = {}) {
980
+ return [
981
+ `${label(result.metric)} p75 under ${printable(result.route)}, ${rangeText(result.range)}${environmentSuffix(result.environment)}${filteredBy(filters)}`,
982
+ ...breakdownTable("Path", result.metric, result.paths)
983
+ ];
984
+ }
985
+
986
+ // src/commands/analytics-format.ts
987
+ var TABLES = [
988
+ { key: "pages", title: "Pages", column: "Page" },
989
+ { key: "referrers", title: "Referrers", column: "Referrer" },
990
+ { key: "countries", title: "Countries", column: "Country" },
991
+ { key: "devices", title: "Devices", column: "Device" },
992
+ { key: "browsers", title: "Browsers", column: "Browser" },
993
+ { key: "os", title: "Operating systems", column: "Operating system" }
994
+ ];
995
+ function breakdownLines({ title, column }, rows) {
996
+ if (rows.length === 0) return [title, " No data yet."];
997
+ return [
998
+ title,
999
+ ...table([
1000
+ ["Page views", column],
1001
+ // The name last: free text is never padded against anything after it.
1002
+ ...rows.map((row) => [formatCount(row.views), printable(row.name)])
1003
+ ])
1004
+ ];
1005
+ }
1006
+ function analyticsLines(report, range, filters) {
1007
+ const filtered = filteredBy(filters);
1008
+ const head = `Visitors ${formatCount(report.totals.visitors)} \xB7 Page views ${formatCount(report.totals.views)} \xB7 last ${printable(range)}${filtered}`;
1009
+ if (filtered !== "" && report.totals.views === 0)
1010
+ return [
1011
+ head,
1012
+ "",
1013
+ "No visits match these filters. Drop a filter to see more."
1014
+ ];
1015
+ return [
1016
+ head,
1017
+ ...TABLES.flatMap((spec) => [
1018
+ "",
1019
+ ...breakdownLines(spec, report[spec.key])
1020
+ ])
1021
+ ];
1022
+ }
1023
+
1024
+ // src/commands/analytics.ts
1025
+ function queryOf(flags) {
1026
+ const filters = filtersOf(flags, ANALYTICS_FILTER_KEYS);
1027
+ return {
1028
+ ...flags.range !== void 0 ? { range: flags.range } : {},
1029
+ ...Object.keys(filters).length > 0 ? { filters } : {}
1030
+ };
1031
+ }
1032
+ async function runAnalytics(context) {
1033
+ if (context.flags.env !== void 0)
1034
+ throw new NaroError(
1035
+ "VALIDATION_FAILED",
1036
+ "Analytics reads the production worker only; drop --env."
1037
+ );
1038
+ const client = await context.client();
1039
+ const projectId = await context.resolveProjectId();
1040
+ const query = queryOf(context.flags);
1041
+ const report = await client.analytics.report(projectId, query);
1042
+ context.output.result(
1043
+ { projectId, ...report },
1044
+ analyticsLines(report, report.range, query.filters ?? {})
1045
+ );
1046
+ }
1047
+
1048
+ // src/commands/auth.ts
1049
+ async function runLogin(context) {
1050
+ const { output } = context;
1051
+ output.log("Opening your browser to sign in with GitHub...");
1052
+ const session = await login({
1053
+ openBrowser: (url) => {
1054
+ output.log(`If your browser did not open, visit:
1055
+ ${url}`);
1056
+ openBrowser(url);
1057
+ }
1058
+ });
1059
+ saveSession(session);
1060
+ output.result(
1061
+ {
1062
+ user: {
1063
+ id: session.user.id,
1064
+ email: session.user.email,
1065
+ github: session.user.github
1066
+ }
1067
+ },
1068
+ [`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
1069
+ );
1070
+ }
1071
+ async function runLogout(context) {
1072
+ const session = loadStoredSession();
1073
+ if (session) {
1074
+ try {
1075
+ const client = createAuthClient();
1076
+ await client.auth.setSession({
1077
+ access_token: session.access_token,
1078
+ refresh_token: session.refresh_token
1079
+ });
1080
+ await client.auth.signOut();
1081
+ } catch {
1082
+ }
1083
+ }
1084
+ clearSession();
1085
+ context.output.result({ loggedOut: true }, ["Logged out."]);
1086
+ }
1087
+ async function currentSession() {
1088
+ let session = loadStoredSession();
1089
+ if (!session)
1090
+ throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
1091
+ if (isExpired(session)) {
1092
+ try {
1093
+ session = await refreshStoredSession(session.refresh_token);
1094
+ saveSession(session);
1095
+ } catch {
1096
+ throw new NaroError(
1097
+ "NOT_LOGGED_IN",
1098
+ "Session expired. Run `naro login`."
1099
+ );
1100
+ }
1101
+ }
1102
+ return session;
1103
+ }
1104
+ async function runWhoami(context) {
1105
+ const session = await currentSession();
1106
+ const handle = session.user.github ? ` (${session.user.github})` : "";
1107
+ context.output.result(
1108
+ {
1109
+ user: {
1110
+ id: session.user.id,
1111
+ email: session.user.email,
1112
+ github: session.user.github
1113
+ }
1114
+ },
1115
+ [`Logged in as ${session.user.email ?? session.user.id}${handle}`]
1116
+ );
1117
+ }
1118
+ async function runToken(context) {
1119
+ const session = await currentSession();
1120
+ process.stderr.write(
1121
+ "\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
1122
+ );
1123
+ context.output.result(
1124
+ { token: session.access_token, expiresAt: session.expires_at },
1125
+ [session.access_token]
1126
+ );
1127
+ }
1128
+
808
1129
  // src/commands/backend.ts
809
1130
  var SERVICE_KEY_WARNING = "\u26A0 The service key skips every policy. Keep it on a server (NARO_SERVICE_KEY) and never put it in browser code.";
810
1131
  var ROTATE_USAGE = "Usage: naro backend keys rotate --anon | --service";
@@ -1112,9 +1433,9 @@ function adviceLines2(auth) {
1112
1433
  "Redirect URIs to register with each provider \u2014 the one for the origin your app passes to createClient({ url }):"
1113
1434
  );
1114
1435
  for (const provider of ["google", "github"]) {
1115
- const label2 = ` ${LABELS[provider]}: `;
1436
+ const label3 = ` ${LABELS[provider]}: `;
1116
1437
  callbacksOf(auth, provider).forEach(
1117
- (url, index) => lines.push(`${index === 0 ? label2 : " ".repeat(label2.length)}${url}`)
1438
+ (url, index) => lines.push(`${index === 0 ? label3 : " ".repeat(label3.length)}${url}`)
1118
1439
  );
1119
1440
  }
1120
1441
  return lines;
@@ -1141,11 +1462,11 @@ async function readSecret(context, input, given, flag, what, usage) {
1141
1462
  throw new NaroError("VALIDATION_FAILED", `No ${what} came in on stdin.`);
1142
1463
  return secret;
1143
1464
  }
1144
- async function change(context, send, done, after) {
1465
+ async function change(context, send2, done, after) {
1145
1466
  const projectId = await context.resolveProjectId();
1146
1467
  let answer;
1147
1468
  try {
1148
- answer = await send(projectId);
1469
+ answer = await send2(projectId);
1149
1470
  } catch (error) {
1150
1471
  if (isNaroError(error) && error.code === "TIMEOUT")
1151
1472
  throw new NaroError(
@@ -1410,9 +1731,9 @@ var processStdin3 = {
1410
1731
  stdinIsTty: () => process.stdin.isTTY === true,
1411
1732
  readStdin
1412
1733
  };
1413
- async function usersCall(call) {
1734
+ async function usersCall(call2) {
1414
1735
  try {
1415
- return await call();
1736
+ return await call2();
1416
1737
  } catch (error) {
1417
1738
  if (isNaroError(error) && error.code === "BACKEND_OUTDATED")
1418
1739
  throw new NaroError("BACKEND_OUTDATED", OUTDATED_MESSAGE, {
@@ -1438,7 +1759,7 @@ async function allUsers(client, projectId) {
1438
1759
  function orDash(value) {
1439
1760
  return value === null ? "-" : printable(value);
1440
1761
  }
1441
- function label(user) {
1762
+ function label2(user) {
1442
1763
  return printable(user.username ?? user.email ?? user.id);
1443
1764
  }
1444
1765
  function userTable(users) {
@@ -1520,7 +1841,7 @@ async function runBackendUsersCreate(context, input = processStdin3) {
1520
1841
  );
1521
1842
  const signIn = user.username === null ? "naro.auth.signIn({ email, password })" : `naro.auth.signIn({ username: ${JSON.stringify(printable(user.username))}, password })`;
1522
1843
  context.output.result({ projectId, user }, [
1523
- `\u2705 Created user ${label(user)}. It signs in at once with ${signIn}.`,
1844
+ `\u2705 Created user ${label2(user)}. It signs in at once with ${signIn}.`,
1524
1845
  ...userLines(user)
1525
1846
  ]);
1526
1847
  }
@@ -1545,7 +1866,7 @@ async function runBackendUsersDelete(context) {
1545
1866
  "USER_NOT_FOUND",
1546
1867
  `No user has the id, username or email ${printable(given)}. \`naro backend users\` lists them.`
1547
1868
  );
1548
- const named = user.id === label(user) ? label(user) : `${label(user)} (${printable(user.id)})`;
1869
+ const named = user.id === label2(user) ? label2(user) : `${label2(user)} (${printable(user.id)})`;
1549
1870
  await confirmOrAbort(
1550
1871
  context,
1551
1872
  `Delete the user ${named}? Their sessions end at once and the account cannot be brought back.`
@@ -1969,7 +2290,7 @@ async function runDbRestore(context) {
1969
2290
  target = resolved.bookmark;
1970
2291
  at = resolved.timestamp ?? given.timestamp;
1971
2292
  }
1972
- const label2 = at === null ? `bookmark ${target}` : `${at} (bookmark ${target})`;
2293
+ const label3 = at === null ? `bookmark ${target}` : `${at} (bookmark ${target})`;
1973
2294
  if (context.flags.dryRun) {
1974
2295
  context.output.result(
1975
2296
  {
@@ -1977,13 +2298,13 @@ async function runDbRestore(context) {
1977
2298
  bookmark: target,
1978
2299
  ...at === null ? {} : { timestamp: at }
1979
2300
  },
1980
- [`Would restore to ${label2}. Nothing was changed.`]
2301
+ [`Would restore to ${label3}. Nothing was changed.`]
1981
2302
  );
1982
2303
  return;
1983
2304
  }
1984
2305
  await confirmOrAbort(
1985
2306
  context,
1986
- `Restore the project database to ${label2}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
2307
+ `Restore the project database to ${label3}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
1987
2308
  );
1988
2309
  const before = await bookmarkNow(context, client, projectId);
1989
2310
  let restored;
@@ -2083,11 +2404,12 @@ async function readPolicies(client, projectId, warn) {
2083
2404
  });
2084
2405
  if ((probe.statements[0]?.rows.length ?? 0) === 0) return void 0;
2085
2406
  const answer = await client.db.query(projectId, { sql: POLICY_READ_SQL });
2086
- const [stored, indexes, columns, definitions] = answer.statements;
2087
- if (stored === void 0 || indexes === void 0 || columns === void 0 || definitions === void 0)
2407
+ const [stored, indexes, ...schema] = answer.statements;
2408
+ const expected = SCHEMA_SQL.length + 2;
2409
+ if (stored === void 0 || indexes === void 0 || answer.statements.length !== expected)
2088
2410
  throw new NaroError(
2089
2411
  "DATABASE_QUERY_FAILED",
2090
- `The policies could not be read: the database answered with ${String(answer.statements.length)} result sets instead of 4.`
2412
+ `The policies could not be read: the database answered with ${String(answer.statements.length)} result sets instead of ${String(expected)}.`
2091
2413
  );
2092
2414
  if (answer.truncated) {
2093
2415
  warn(POLICIES_TRUNCATED_NOTICE);
@@ -2116,10 +2438,7 @@ async function readPolicies(client, projectId, warn) {
2116
2438
  owner: policy.owner
2117
2439
  });
2118
2440
  }
2119
- const snapshot = snapshotFromRows([
2120
- ...objectsOf(columns),
2121
- ...objectsOf(definitions)
2122
- ]);
2441
+ const snapshot = snapshotFromRows(schema.flatMap(objectsOf));
2123
2442
  return {
2124
2443
  tables: tableNames(snapshot),
2125
2444
  policies,
@@ -2155,17 +2474,17 @@ async function runDbLint(context) {
2155
2474
  const projectId = await context.resolveProjectId();
2156
2475
  const tables = await client.db.tables(projectId);
2157
2476
  const answer = await client.db.query(projectId, { sql: DIAGNOSTICS_SQL });
2158
- const [triggers, objects, quick, broken] = answer.statements;
2159
- if (triggers === void 0 || objects === void 0 || quick === void 0 || broken === void 0)
2477
+ const [triggers, objects2, quick, broken] = answer.statements;
2478
+ if (triggers === void 0 || objects2 === void 0 || quick === void 0 || broken === void 0)
2160
2479
  throw new NaroError(
2161
2480
  "DATABASE_QUERY_FAILED",
2162
2481
  `The diagnostics could not be read: the database answered with ${String(answer.statements.length)} result sets instead of 4.`
2163
2482
  );
2164
2483
  const virtualTables = [];
2165
2484
  const shadow = /* @__PURE__ */ new Set();
2166
- for (const row of objects.rows) {
2167
- const name = cellText(objects, row, "name");
2168
- if (cellText(objects, row, "type") === "shadow") shadow.add(name);
2485
+ for (const row of objects2.rows) {
2486
+ const name = cellText(objects2, row, "name");
2487
+ if (cellText(objects2, row, "type") === "shadow") shadow.add(name);
2169
2488
  else virtualTables.push(name);
2170
2489
  }
2171
2490
  const foreignKeyCheck = broken.rows.map((row) => ({
@@ -2179,7 +2498,7 @@ async function runDbLint(context) {
2179
2498
  (message) => context.output.warn(message)
2180
2499
  );
2181
2500
  const input = {
2182
- tables: tables.filter((table) => !shadow.has(table.name)),
2501
+ tables: tables.filter((table2) => !shadow.has(table2.name)),
2183
2502
  foreignKeyCheck,
2184
2503
  quickCheck: quick.rows.map(firstCell),
2185
2504
  triggers: triggers.rows.map((row) => cellText(triggers, row, "name")),
@@ -2207,15 +2526,15 @@ var SCHEMA_FILTER = `${systemTablePredicate("name")} AND ${systemTablePredicate(
2207
2526
  var SCHEMA_SQL2 = [
2208
2527
  `SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view', 'trigger') AND ${SCHEMA_FILTER} ORDER BY name;`,
2209
2528
  "SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'virtual';",
2210
- `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations', '${NAMED_POLICY_TABLE}');`
2529
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations', '${NAMED_POLICY_TABLE}', '${STORAGE_OBJECTS_TABLE}');`
2211
2530
  ].join("\n");
2212
2531
  function literal(value) {
2213
2532
  return `'${value.replace(/'/g, "''")}'`;
2214
2533
  }
2215
- function chunk(items2, size) {
2534
+ function chunk(items3, size) {
2216
2535
  const pieces = [];
2217
- for (let index = 0; index < items2.length; index += size)
2218
- pieces.push(items2.slice(index, index + size));
2536
+ for (let index = 0; index < items3.length; index += size)
2537
+ pieces.push(items3.slice(index, index + size));
2219
2538
  return pieces;
2220
2539
  }
2221
2540
  async function readSchema(client, projectId) {
@@ -2232,7 +2551,7 @@ async function readSchema(client, projectId) {
2232
2551
  const virtualNames = new Set(
2233
2552
  virtual.rows.map((row) => cellText(virtual, row, "name").toLowerCase())
2234
2553
  );
2235
- const objects = listed.rows.map((row) => {
2554
+ const objects2 = listed.rows.map((row) => {
2236
2555
  const name = cellText(listed, row, "name");
2237
2556
  const type = cellText(listed, row, "type");
2238
2557
  if (type === "view" || type === "trigger") return { type, name };
@@ -2241,7 +2560,7 @@ async function readSchema(client, projectId) {
2241
2560
  name
2242
2561
  };
2243
2562
  });
2244
- const lookups = objects.filter((object) => object.type === "table" || object.type === "virtual").map(
2563
+ const lookups = objects2.filter((object) => object.type === "table" || object.type === "virtual").map(
2245
2564
  (object) => `SELECT ${literal(object.name)} AS child, "table" AS parent FROM pragma_foreign_key_list(${literal(object.name)});`
2246
2565
  );
2247
2566
  const foreignKeys = [];
@@ -2260,13 +2579,14 @@ async function readSchema(client, projectId) {
2260
2579
  });
2261
2580
  }
2262
2581
  return {
2263
- objects,
2582
+ objects: objects2,
2264
2583
  foreignKeys,
2265
2584
  internal: {
2266
2585
  sqliteSequence: internalNames.has("sqlite_sequence"),
2267
2586
  ledger: internalNames.has("d1_migrations")
2268
2587
  },
2269
- policyTable: internalNames.has(NAMED_POLICY_TABLE)
2588
+ policyTable: internalNames.has(NAMED_POLICY_TABLE),
2589
+ storageTable: internalNames.has(STORAGE_OBJECTS_TABLE)
2270
2590
  };
2271
2591
  }
2272
2592
  async function hasBackend(client, projectId) {
@@ -2311,7 +2631,10 @@ async function policyPlan(client, projectId, schema, dir, files) {
2311
2631
  ...policiesLostByReset({
2312
2632
  current,
2313
2633
  tables: schema.tables,
2314
- files: files.map((name) => readFileSync3(join3(dir, name), "utf8"))
2634
+ files: files.map((name) => readFileSync3(join3(dir, name), "utf8")),
2635
+ // Storage's table stays; its policies are emptied with the rest (PR
2636
+ // #85 리뷰).
2637
+ storage: schema.storageTable
2315
2638
  })
2316
2639
  };
2317
2640
  }
@@ -2348,7 +2671,10 @@ ${problemLines.join("\n")}`,
2348
2671
  const client = await context.client();
2349
2672
  const projectId = await context.resolveProjectId();
2350
2673
  const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
2351
- const { policyTable: policyTable2, ...input } = await readSchema(client, projectId);
2674
+ const { policyTable: policyTable2, storageTable, ...input } = await readSchema(
2675
+ client,
2676
+ projectId
2677
+ );
2352
2678
  const plan = buildResetPlan(input, { clearPolicies: policyTable2 });
2353
2679
  const { tables, views, triggers } = plan.dropped;
2354
2680
  const counts = `${String(tables.length)} table(s), ${String(views.length)} view(s) and ${String(triggers.length)} trigger(s)`;
@@ -2356,7 +2682,7 @@ ${problemLines.join("\n")}`,
2356
2682
  const policies = await policyPlan(
2357
2683
  client,
2358
2684
  projectId,
2359
- { policyTable: policyTable2, tables },
2685
+ { policyTable: policyTable2, storageTable, tables },
2360
2686
  dir,
2361
2687
  files
2362
2688
  );
@@ -2789,9 +3115,9 @@ function keyword(text, at) {
2789
3115
  `needs null, true or false after is at character ${String(at + 1)}`
2790
3116
  );
2791
3117
  }
2792
- function attempt(parse) {
3118
+ function attempt(parse2) {
2793
3119
  try {
2794
- return { ok: true, node: parse() };
3120
+ return { ok: true, node: parse2() };
2795
3121
  } catch (error) {
2796
3122
  if (error instanceof Refusal) return { ok: false, reason: error.message };
2797
3123
  throw error;
@@ -2898,7 +3224,7 @@ function conditionsFrom(flags) {
2898
3224
  );
2899
3225
  return conditions;
2900
3226
  }
2901
- function patchFromFile(context, path, table) {
3227
+ function patchFromFile(context, path, table2) {
2902
3228
  let text;
2903
3229
  try {
2904
3230
  text = readFileSync4(resolve3(context.cwd, path), "utf8");
@@ -2920,9 +3246,9 @@ function patchFromFile(context, path, table) {
2920
3246
  if (Object.hasOwn(body, "table")) {
2921
3247
  if (typeof named !== "string")
2922
3248
  refuse2(`${path}: table must be a string, the table's name.`);
2923
- if (table !== void 0 && asciiLower(named) !== asciiLower(table))
3249
+ if (table2 !== void 0 && asciiLower(named) !== asciiLower(table2))
2924
3250
  refuse2(
2925
- `${path} is the policy of ${printable(named)}, not ${printable(table)}. Remove its "table" to set it on ${printable(table)}.`
3251
+ `${path} is the policy of ${printable(named)}, not ${printable(table2)}. Remove its "table" to set it on ${printable(table2)}.`
2926
3252
  );
2927
3253
  }
2928
3254
  const allowed = /* @__PURE__ */ new Set([...POLICY_ACTIONS, "ownerColumn"]);
@@ -2935,7 +3261,7 @@ function patchFromFile(context, path, table) {
2935
3261
  function asciiLower(text) {
2936
3262
  return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
2937
3263
  }
2938
- function policyPatch(flags, context, table) {
3264
+ function policyPatch(flags, context, table2) {
2939
3265
  if (flags.fromFile !== void 0) {
2940
3266
  if (OTHER_FLAGS.some(
2941
3267
  (key) => flags[key] !== void 0 && flags[key] !== false
@@ -2944,7 +3270,7 @@ function policyPatch(flags, context, table) {
2944
3270
  "--from-file takes the whole policy: leave out the other policy options."
2945
3271
  );
2946
3272
  if (!context) refuse2(SET_USAGE);
2947
- return patchFromFile(context, flags.fromFile, table);
3273
+ return patchFromFile(context, flags.fromFile, table2);
2948
3274
  }
2949
3275
  const patch = {};
2950
3276
  for (const action of POLICY_ACTIONS) {
@@ -3017,9 +3343,9 @@ function leafText(leaf, negated) {
3017
3343
  if (leaf.operator === "in" || short !== void 0) {
3018
3344
  if (!Array.isArray(leaf.value)) return void 0;
3019
3345
  const slot = short === void 0 ? "in" : "array";
3020
- const items2 = leaf.value.map((item) => valueText(item, slot));
3021
- if (items2.some((item) => item === void 0)) return void 0;
3022
- const list = items2.join(",");
3346
+ const items3 = leaf.value.map((item) => valueText(item, slot));
3347
+ if (items3.some((item) => item === void 0)) return void 0;
3348
+ const list = items3.join(",");
3023
3349
  return slot === "in" ? `${prefix}in.(${list})` : `${prefix}${String(short)}.{${list}}`;
3024
3350
  }
3025
3351
  if (Array.isArray(leaf.value)) return void 0;
@@ -3083,7 +3409,7 @@ function policyTable(policies) {
3083
3409
  const widths = header.map(
3084
3410
  (title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0))
3085
3411
  );
3086
- const table = [header, ...rows].map(
3412
+ const table2 = [header, ...rows].map(
3087
3413
  (cells) => cells.map(
3088
3414
  (cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)
3089
3415
  ).join(" ").trimEnd()
@@ -3100,10 +3426,10 @@ function policyTable(policies) {
3100
3426
  }
3101
3427
  )
3102
3428
  );
3103
- if (lines.length === 0) return table;
3429
+ if (lines.length === 0) return table2;
3104
3430
  const width = Math.max(...lines.map((line) => line.label.length));
3105
3431
  return [
3106
- ...table,
3432
+ ...table2,
3107
3433
  "",
3108
3434
  ...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
3109
3435
  ];
@@ -3120,39 +3446,39 @@ function statementLine(policy) {
3120
3446
  );
3121
3447
  return shownStatement(policy, policy.table, "naro policy list --json");
3122
3448
  }
3123
- function policyNamed(name, table) {
3124
- return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(table))}`;
3449
+ function policyNamed(name, table2) {
3450
+ return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(table2))}`;
3125
3451
  }
3126
- function shownStatement(statement, table, exact) {
3452
+ function shownStatement(statement, table2, exact) {
3127
3453
  if (isPrintable(statement.sql)) return statement.sql;
3128
3454
  return printable(
3129
- `-- ${policyNamed(statement.name, table)} ${UNSHOWN}, so it is not printed as CREATE POLICY: ${exact} has its exact text.`
3455
+ `-- ${policyNamed(statement.name, table2)} ${UNSHOWN}, so it is not printed as CREATE POLICY: ${exact} has its exact text.`
3130
3456
  );
3131
3457
  }
3132
3458
  function policyStatements(policies) {
3133
3459
  const lines = [];
3134
- let table;
3460
+ let table2;
3135
3461
  for (const policy of policies) {
3136
- if (table !== void 0 && policy.table !== table) lines.push("");
3137
- table = policy.table;
3462
+ if (table2 !== void 0 && policy.table !== table2) lines.push("");
3463
+ table2 = policy.table;
3138
3464
  lines.push(statementLine(policy));
3139
3465
  }
3140
3466
  return lines;
3141
3467
  }
3142
3468
  async function runPolicyList(context) {
3143
- const table = context.flags.table;
3469
+ const table2 = context.flags.table;
3144
3470
  const client = await context.client();
3145
3471
  const projectId = await context.resolveProjectId();
3146
3472
  const policies = await client.backend.rlsPolicies(
3147
3473
  projectId,
3148
- table === void 0 ? {} : { table }
3474
+ table2 === void 0 ? {} : { table: table2 }
3149
3475
  );
3150
3476
  let lines;
3151
3477
  if (policies.length > 0)
3152
3478
  lines = [...policyStatements(policies), "", DEFAULT_DENY];
3153
- else if (table !== void 0)
3479
+ else if (table2 !== void 0)
3154
3480
  lines = [
3155
- `No policies on ${printable(table)}: anon and signed-in requests to it are denied.`
3481
+ `No policies on ${printable(table2)}: anon and signed-in requests to it are denied.`
3156
3482
  ];
3157
3483
  else
3158
3484
  lines = [
@@ -3248,17 +3574,17 @@ async function runPolicyCreate(context) {
3248
3574
  }
3249
3575
  async function runPolicyDrop(context) {
3250
3576
  const [name] = context.positionalOrThrow(1, DROP_USAGE);
3251
- const table = context.flags.table;
3252
- if (table === void 0 || table === "") refuse2(DROP_USAGE);
3577
+ const table2 = context.flags.table;
3578
+ if (table2 === void 0 || table2 === "") refuse2(DROP_USAGE);
3253
3579
  await confirmOrAbort(
3254
3580
  context,
3255
- `Drop policy ${printable(name)} on ${printable(table)}? It stops applying within about 10 seconds.`
3581
+ `Drop policy ${printable(name)} on ${printable(table2)}? It stops applying within about 10 seconds.`
3256
3582
  );
3257
3583
  const client = await context.client();
3258
3584
  const projectId = await context.resolveProjectId();
3259
3585
  const dropped = await client.backend.dropRlsPolicy(
3260
3586
  projectId,
3261
- table,
3587
+ table2,
3262
3588
  name
3263
3589
  );
3264
3590
  context.output.result({ projectId, dropped }, [
@@ -3266,13 +3592,13 @@ async function runPolicyDrop(context) {
3266
3592
  ]);
3267
3593
  }
3268
3594
  async function runPolicySet(context) {
3269
- const [table] = context.positionalOrThrow(1, SET_USAGE);
3270
- const patch = policyPatch(context.flags, context, table);
3595
+ const [table2] = context.positionalOrThrow(1, SET_USAGE);
3596
+ const patch = policyPatch(context.flags, context, table2);
3271
3597
  const client = await context.client();
3272
3598
  const projectId = await context.resolveProjectId();
3273
3599
  const policy = await client.backend.setPolicy(
3274
3600
  projectId,
3275
- table,
3601
+ table2,
3276
3602
  patch
3277
3603
  );
3278
3604
  let named;
@@ -3293,14 +3619,14 @@ async function runPolicySet(context) {
3293
3619
  );
3294
3620
  }
3295
3621
  async function runPolicyRemove(context) {
3296
- const [table] = context.positionalOrThrow(1, REMOVE_USAGE);
3622
+ const [table2] = context.positionalOrThrow(1, REMOVE_USAGE);
3297
3623
  await confirmOrAbort(
3298
3624
  context,
3299
- `Remove every policy on ${printable(table)}? Anon and signed-in requests to it are denied from then on.`
3625
+ `Remove every policy on ${printable(table2)}? Anon and signed-in requests to it are denied from then on.`
3300
3626
  );
3301
3627
  const client = await context.client();
3302
3628
  const projectId = await context.resolveProjectId();
3303
- const policy = await client.backend.removePolicy(projectId, table);
3629
+ const policy = await client.backend.removePolicy(projectId, table2);
3304
3630
  const names = policy.policies ?? [];
3305
3631
  context.output.result({ projectId, removed: true, policy }, [
3306
3632
  names.length === 0 ? `\u2705 Removed the policy for ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.` : `\u2705 Removed ${names.length === 1 ? "policy" : "policies"} ${names.map(printable).join(", ")} on ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.`
@@ -3387,14 +3713,14 @@ function parseColumnSpec(spec) {
3387
3713
  column.default = defaultValue(value);
3388
3714
  else if (value !== void 0 && key === "references") {
3389
3715
  const dot = value.lastIndexOf(".");
3390
- const table = dot < 0 ? "" : value.slice(0, dot);
3716
+ const table2 = dot < 0 ? "" : value.slice(0, dot);
3391
3717
  const target = dot < 0 ? "" : value.slice(dot + 1);
3392
- if (!table || !target)
3718
+ if (!table2 || !target)
3393
3719
  throw new NaroError(
3394
3720
  "VALIDATION_FAILED",
3395
3721
  `${shown}: references needs <table>.<column>.`
3396
3722
  );
3397
- column.references = { table, column: target };
3723
+ column.references = { table: table2, column: target };
3398
3724
  } else if (value !== void 0 && key === "ondelete") {
3399
3725
  onDelete = ON_DELETE[value.toLowerCase()];
3400
3726
  if (!onDelete)
@@ -3573,16 +3899,16 @@ async function runDbIndexUsage() {
3573
3899
  throw new NaroError("VALIDATION_FAILED", USAGE2.index);
3574
3900
  }
3575
3901
  async function runDbTableCreate(context) {
3576
- const [table] = context.positionals;
3902
+ const [table2] = context.positionals;
3577
3903
  const specs = columnsFlag(context);
3578
- if (!table || specs.length === 0)
3904
+ if (!table2 || specs.length === 0)
3579
3905
  throw new NaroError("VALIDATION_FAILED", USAGE2.create);
3580
3906
  const columns = specs.map(parseColumnSpec);
3581
3907
  const owner = context.flags.ownerColumn;
3582
3908
  await runSchemaChange(context, [
3583
3909
  {
3584
3910
  op: "createTable",
3585
- table,
3911
+ table: table2,
3586
3912
  columns,
3587
3913
  ...context.flags.ifNotExists ? { ifNotExists: true } : {},
3588
3914
  ...owner === void 0 ? {} : { owner: { column: owner } }
@@ -3590,56 +3916,56 @@ async function runDbTableCreate(context) {
3590
3916
  ]);
3591
3917
  }
3592
3918
  async function runDbTableAddColumn(context) {
3593
- const [table] = context.positionals;
3919
+ const [table2] = context.positionals;
3594
3920
  const specs = columnsFlag(context);
3595
3921
  const [spec] = specs;
3596
- if (!table || spec === void 0 || specs.length !== 1)
3922
+ if (!table2 || spec === void 0 || specs.length !== 1)
3597
3923
  throw new NaroError("VALIDATION_FAILED", USAGE2.addColumn);
3598
3924
  await runSchemaChange(context, [
3599
- { op: "addColumn", table, column: parseColumnSpec(spec) }
3925
+ { op: "addColumn", table: table2, column: parseColumnSpec(spec) }
3600
3926
  ]);
3601
3927
  }
3602
3928
  async function runDbTableRenameColumn(context) {
3603
- const [table, column, to] = context.positionals;
3604
- if (!table || !column || !to)
3929
+ const [table2, column, to] = context.positionals;
3930
+ if (!table2 || !column || !to)
3605
3931
  throw new NaroError("VALIDATION_FAILED", USAGE2.renameColumn);
3606
- await runSchemaChange(context, [{ op: "renameColumn", table, column, to }]);
3932
+ await runSchemaChange(context, [{ op: "renameColumn", table: table2, column, to }]);
3607
3933
  }
3608
3934
  async function runDbTableRename(context) {
3609
- const [table, to] = context.positionals;
3610
- if (!table || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
3611
- await runSchemaChange(context, [{ op: "renameTable", table, to }]);
3935
+ const [table2, to] = context.positionals;
3936
+ if (!table2 || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
3937
+ await runSchemaChange(context, [{ op: "renameTable", table: table2, to }]);
3612
3938
  }
3613
3939
  async function runDbTableDropColumn(context) {
3614
- const [table, column] = context.positionals;
3615
- if (!table || !column)
3940
+ const [table2, column] = context.positionals;
3941
+ if (!table2 || !column)
3616
3942
  throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
3617
- const target = `${table}.${column}`;
3943
+ const target = `${table2}.${column}`;
3618
3944
  await runSchemaChange(
3619
3945
  context,
3620
- [{ op: "dropColumn", table, column, confirm: target }],
3946
+ [{ op: "dropColumn", table: table2, column, confirm: target }],
3621
3947
  `Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
3622
3948
  );
3623
3949
  }
3624
3950
  async function runDbTableDrop(context) {
3625
- const [table] = context.positionals;
3626
- if (!table) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
3951
+ const [table2] = context.positionals;
3952
+ if (!table2) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
3627
3953
  await runSchemaChange(
3628
3954
  context,
3629
- [{ op: "dropTable", table, confirm: table }],
3630
- `Drop table ${printable(table)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
3955
+ [{ op: "dropTable", table: table2, confirm: table2 }],
3956
+ `Drop table ${printable(table2)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
3631
3957
  );
3632
3958
  }
3633
3959
  async function runDbIndexCreate(context) {
3634
- const [table, list] = context.positionals;
3960
+ const [table2, list] = context.positionals;
3635
3961
  const columns = listFlag(list);
3636
- if (!table || columns.length === 0)
3962
+ if (!table2 || columns.length === 0)
3637
3963
  throw new NaroError("VALIDATION_FAILED", USAGE2.indexCreate);
3638
3964
  const name = context.flags.name;
3639
3965
  await runSchemaChange(context, [
3640
3966
  {
3641
3967
  op: "createIndex",
3642
- table,
3968
+ table: table2,
3643
3969
  columns,
3644
3970
  ...context.flags.unique ? { unique: true } : {},
3645
3971
  ...name === void 0 ? {} : { name }
@@ -3656,7 +3982,7 @@ async function runDbIndexDrop(context) {
3656
3982
  var defaults2 = {
3657
3983
  detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
3658
3984
  openBrowser,
3659
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
3985
+ sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
3660
3986
  };
3661
3987
  function connected(context, project) {
3662
3988
  const branch = project.defaultBranch;
@@ -3726,7 +4052,7 @@ async function waitForInstall(context, projectId, fullName, deps) {
3726
4052
  if (teamSlug) {
3727
4053
  const attempts = deps.pollAttempts ?? 60;
3728
4054
  const interval = deps.pollIntervalMs ?? 3e3;
3729
- for (let attempt2 = 0; attempt2 < attempts; attempt2 += 1) {
4055
+ for (let attempt3 = 0; attempt3 < attempts; attempt3 += 1) {
3730
4056
  await deps.sleep(interval);
3731
4057
  let reachable = false;
3732
4058
  try {
@@ -3853,9 +4179,9 @@ async function resolveTeam(context, teams, deps = {}) {
3853
4179
  );
3854
4180
  const account = deps.accountLabel ? await deps.accountLabel() : "this account";
3855
4181
  if (only) {
3856
- const label2 = only.isPersonal ? `${only.name} (personal)` : only.name;
4182
+ const label3 = only.isPersonal ? `${only.name} (personal)` : only.name;
3857
4183
  context.output.log(`
3858
- ${account} \u2192 ${label2}`);
4184
+ ${account} \u2192 ${label3}`);
3859
4185
  const ok = await context.prompter.confirm(
3860
4186
  "Deploy to this account and team?"
3861
4187
  );
@@ -4144,7 +4470,7 @@ async function runInspect(context) {
4144
4470
 
4145
4471
  // src/commands/domains.ts
4146
4472
  var defaults3 = {
4147
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
4473
+ sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)),
4148
4474
  now: () => Date.now()
4149
4475
  };
4150
4476
  var FIRST_DELAY_MS = 5e3;
@@ -4525,7 +4851,7 @@ async function runGenTypes(context) {
4525
4851
  read.policies === void 0 ? {} : { policies: read.policies }
4526
4852
  );
4527
4853
  const undescribed = tables.flatMap(
4528
- (table) => table.error === void 0 ? [] : [{ name: table.name, error: table.error }]
4854
+ (table2) => table2.error === void 0 ? [] : [{ name: table2.name, error: table2.error }]
4529
4855
  );
4530
4856
  for (const { name, error } of undescribed)
4531
4857
  context.output.warn(
@@ -4561,20 +4887,87 @@ async function runGenTypes(context) {
4561
4887
  ]);
4562
4888
  }
4563
4889
 
4564
- // src/commands/logs.ts
4565
- var FOLLOW_INTERVAL_MS = 3e3;
4566
- function sleep(ms) {
4567
- return new Promise((resolve5) => setTimeout(resolve5, ms));
4568
- }
4569
- function formatBuildLog(log) {
4570
- return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
4571
- }
4572
- function formatRuntimeLog(entry) {
4573
- const time = new Date(entry.ts).toISOString();
4574
- const request = entry.method && entry.path ? ` ${entry.method} ${entry.path}${entry.status ? ` ${String(entry.status)}` : ""}` : "";
4575
- return `${time} [${entry.level}]${request} ${entry.message}`.trimEnd();
4890
+ // src/commands/insights.ts
4891
+ function queryOf2(flags) {
4892
+ const filters = filtersOf(flags, SPEED_INSIGHTS_FILTER_KEYS);
4893
+ return {
4894
+ ...flags.range !== void 0 ? { range: flags.range } : {},
4895
+ ...flags.metric !== void 0 ? { metric: flags.metric } : {},
4896
+ ...flags.env !== void 0 ? { environment: flags.env } : {},
4897
+ ...Object.keys(filters).length > 0 ? { filters } : {}
4898
+ };
4576
4899
  }
4577
- async function latestProductionDeploymentId(client, projectId) {
4900
+ async function runInsights(context) {
4901
+ if (context.flags.referrer !== void 0 && context.flags.referrer !== "")
4902
+ throw new NaroError(
4903
+ "VALIDATION_FAILED",
4904
+ "Speed Insights has no referrer; drop --referrer."
4905
+ );
4906
+ const client = await context.client();
4907
+ const projectId = await context.resolveProjectId();
4908
+ const query = queryOf2(context.flags);
4909
+ const filters = query.filters ?? {};
4910
+ if (context.flags.route !== void 0) {
4911
+ const result = await client.insights.paths(projectId, {
4912
+ ...query,
4913
+ route: context.flags.route
4914
+ });
4915
+ context.output.result(
4916
+ { projectId, ...result },
4917
+ routePathsLines(result, filters)
4918
+ );
4919
+ return;
4920
+ }
4921
+ const overview = await client.insights.overview(projectId, query);
4922
+ if (overview.deploymentsError)
4923
+ context.output.warn(
4924
+ `Deployments are unavailable right now: ${printable(overview.deploymentsError.message)}`
4925
+ );
4926
+ context.output.result(
4927
+ { projectId, ...overview },
4928
+ reportLines(overview.report, overview.deployments, filters)
4929
+ );
4930
+ }
4931
+ async function setInsights(context, enabled) {
4932
+ if (context.flags.env !== void 0)
4933
+ throw new NaroError(
4934
+ "VALIDATION_FAILED",
4935
+ "Speed Insights has one switch for production and preview; drop --env."
4936
+ );
4937
+ const filter = givenFilterFlag(context.flags);
4938
+ if (filter !== void 0)
4939
+ throw new NaroError(
4940
+ "VALIDATION_FAILED",
4941
+ `Filters only narrow a report; drop --${filter}.`
4942
+ );
4943
+ const client = await context.client();
4944
+ const projectId = await context.resolveProjectId();
4945
+ const result = await client.insights.setEnabled(projectId, enabled);
4946
+ context.output.result({ projectId, enabled: result.enabled }, [
4947
+ result.enabled ? TURNED_ON : TURNED_OFF
4948
+ ]);
4949
+ }
4950
+ function runInsightsEnable(context) {
4951
+ return setInsights(context, true);
4952
+ }
4953
+ function runInsightsDisable(context) {
4954
+ return setInsights(context, false);
4955
+ }
4956
+
4957
+ // src/commands/logs.ts
4958
+ var FOLLOW_INTERVAL_MS = 3e3;
4959
+ function sleep(ms) {
4960
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
4961
+ }
4962
+ function formatBuildLog(log) {
4963
+ return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
4964
+ }
4965
+ function formatRuntimeLog(entry) {
4966
+ const time = new Date(entry.ts).toISOString();
4967
+ const request = entry.method && entry.path ? ` ${entry.method} ${entry.path}${entry.status ? ` ${String(entry.status)}` : ""}` : "";
4968
+ return `${time} [${entry.level}]${request} ${entry.message}`.trimEnd();
4969
+ }
4970
+ async function latestProductionDeploymentId(client, projectId) {
4578
4971
  const deployments = await client.deployments.list(projectId);
4579
4972
  const latest = deployments.find(
4580
4973
  (deployment) => deployment.status === "READY"
@@ -4656,160 +5049,3131 @@ async function runtimeLogs(context, client, projectId) {
4656
5049
  lastTs = entry.ts;
4657
5050
  boundary = /* @__PURE__ */ new Set();
4658
5051
  }
4659
- boundary.add(key);
4660
- context.output.log(formatRuntimeLog(entry));
5052
+ boundary.add(key);
5053
+ context.output.log(formatRuntimeLog(entry));
5054
+ }
5055
+ await sleep(FOLLOW_INTERVAL_MS);
5056
+ }
5057
+ }
5058
+ async function runLogs(context) {
5059
+ const client = await context.client();
5060
+ const [deploymentId] = context.positionals;
5061
+ const type = context.flags.type ?? (deploymentId !== void 0 ? "build" : "runtime");
5062
+ if (type !== "build" && type !== "runtime")
5063
+ throw new NaroError(
5064
+ "VALIDATION_FAILED",
5065
+ "--type must be build or runtime."
5066
+ );
5067
+ if (type === "runtime") {
5068
+ if (deploymentId)
5069
+ throw new NaroError(
5070
+ "VALIDATION_FAILED",
5071
+ "Runtime logs are per project, not per deployment. Drop the deployment id or use --type build."
5072
+ );
5073
+ const projectId = await context.resolveProjectId();
5074
+ await runtimeLogs(context, client, projectId);
5075
+ return;
5076
+ }
5077
+ const target = deploymentId ?? await latestProductionDeploymentId(
5078
+ client,
5079
+ await context.resolveProjectId()
5080
+ );
5081
+ await buildLogs(context, client, target);
5082
+ }
5083
+
5084
+ // src/commands/projects.ts
5085
+ function productionUrl(subdomain) {
5086
+ return `https://${subdomain}.${new URL(controlPlaneUrl()).hostname}`;
5087
+ }
5088
+ async function runProjects(context) {
5089
+ const client = await context.client();
5090
+ const teams = await client.teams.list();
5091
+ const team = await resolveTeam(context, teams, { intent: "read" });
5092
+ const projects = await client.projects.list(team.slug);
5093
+ context.output.result(
5094
+ {
5095
+ team: team.slug,
5096
+ projects: projects.map((project) => ({
5097
+ id: project.id,
5098
+ name: project.name,
5099
+ subdomain: project.subdomain,
5100
+ url: productionUrl(project.subdomain),
5101
+ defaultBranch: project.defaultBranch,
5102
+ updatedAt: project.updatedAt
5103
+ }))
5104
+ },
5105
+ [
5106
+ `Projects in ${team.slug} (${String(projects.length)}):`,
5107
+ ...projects.map(
5108
+ (project) => ` ${project.name.padEnd(24)} ${productionUrl(project.subdomain).padEnd(40)} ${project.id}`
5109
+ )
5110
+ ]
5111
+ );
5112
+ }
5113
+ async function runProjectInspect(context) {
5114
+ const client = await context.client();
5115
+ const projectId = await context.resolveProjectId();
5116
+ const project = await client.projects.get(projectId);
5117
+ const url = productionUrl(project.subdomain);
5118
+ context.output.result({ project: { ...project, url } }, [
5119
+ `${project.name}`,
5120
+ ` id: ${project.id}`,
5121
+ ` team: ${project.teamId}`,
5122
+ ` production URL: ${url}`,
5123
+ ` preview URL: https://${project.subdomain}-preview.${new URL(controlPlaneUrl()).hostname}`,
5124
+ ` default branch: ${project.defaultBranch}`,
5125
+ ` root directory: ${project.rootDirectory ?? "(repo root)"}`,
5126
+ ` repository: ${project.repositoryFullName ?? project.repositoryUrl}`
5127
+ ]);
5128
+ }
5129
+
5130
+ // src/commands/promote.ts
5131
+ async function promoteAndWait(context, client, project, sourceDeploymentId, action) {
5132
+ const promoted = await client.deployments.promote(
5133
+ project.id,
5134
+ sourceDeploymentId
5135
+ );
5136
+ context.output.log(
5137
+ `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}`
5138
+ );
5139
+ const detail = await client.deployments.wait(promoted.id, {
5140
+ timeoutMs: 5 * 6e4
5141
+ });
5142
+ if (detail.deployment.status !== "READY") {
5143
+ throw new NaroError(
5144
+ detail.deployment.errorCode ?? "DEPLOY_FAILED",
5145
+ detail.deployment.errorMessage ?? `The ${action} failed.`,
5146
+ { details: { deploymentId: promoted.id } }
5147
+ );
5148
+ }
5149
+ context.output.result(
5150
+ {
5151
+ deploymentId: promoted.id,
5152
+ sourceDeploymentId,
5153
+ projectId: project.id,
5154
+ status: detail.deployment.status,
5155
+ url: detail.deployment.url
5156
+ },
5157
+ [
5158
+ `\u2705 Production now serves ${sourceDeploymentId}`,
5159
+ "",
5160
+ detail.deployment.url ?? ""
5161
+ ]
5162
+ );
5163
+ }
5164
+ async function runPromote(context) {
5165
+ const [deploymentId] = context.positionalOrThrow(
5166
+ 1,
5167
+ "Usage: naro promote <deployment-id>"
5168
+ );
5169
+ const client = await context.client();
5170
+ const projectId = await context.resolveProjectId();
5171
+ const project = await client.projects.get(projectId);
5172
+ await confirmOrAbort(
5173
+ context,
5174
+ `Promote ${String(deploymentId)} to production (${project.subdomain})?`
5175
+ );
5176
+ await promoteAndWait(
5177
+ context,
5178
+ client,
5179
+ project,
5180
+ deploymentId,
5181
+ "promote"
5182
+ );
5183
+ }
5184
+ async function runRollback(context) {
5185
+ const client = await context.client();
5186
+ const projectId = await context.resolveProjectId();
5187
+ const project = await client.projects.get(projectId);
5188
+ let target = context.positionals[0];
5189
+ if (!target) {
5190
+ const { current, target: candidate } = await client.deployments.rollbackTarget(projectId, project.defaultBranch);
5191
+ if (!candidate)
5192
+ throw new NaroError(
5193
+ "DEPLOYMENT_NOT_FOUND",
5194
+ "No earlier production build with a different artifact to roll back to. Pass a deployment id explicitly."
5195
+ );
5196
+ context.output.log(
5197
+ `\u25B8 current production: ${current.id}${current.gitCommitSha ? ` (${current.gitCommitSha.slice(0, 7)})` : ""}`
5198
+ );
5199
+ target = candidate.id;
5200
+ }
5201
+ await confirmOrAbort(
5202
+ context,
5203
+ `Are you sure you want to rollback production to ${target}?`
5204
+ );
5205
+ await promoteAndWait(context, client, project, target, "rollback");
5206
+ }
5207
+
5208
+ // src/commands/protection.ts
5209
+ var DISABLE_QUESTION2 = "Open this project's preview to anyone with the link? Visitors no longer need to be a member to see it.";
5210
+ var REPLACE_QUESTION = "Replace the bypass secret? The current one stops working, and automation still sending it can no longer open the preview.";
5211
+ var REMOVE_QUESTION = "Remove the bypass secret? Automation that sends it can no longer open the preview.";
5212
+ var EDGE = "The edge picks the change up within about a minute.";
5213
+ function statusLine(state) {
5214
+ return `Preview protection: ${state.enabled ? "on" : "off"} \xB7 bypass secret: ${state.bypass ? "set" : "none"}`;
5215
+ }
5216
+ async function runProtection(context) {
5217
+ const client = await context.client();
5218
+ const projectId = await context.resolveProjectId();
5219
+ const state = await client.protection.get(projectId);
5220
+ context.output.result({ projectId, ...state }, [statusLine(state)]);
5221
+ }
5222
+ async function setProtection(context, enabled) {
5223
+ const client = await context.client();
5224
+ const projectId = await context.resolveProjectId();
5225
+ const state = await client.protection.set(projectId, enabled);
5226
+ context.output.result({ projectId, ...state }, [
5227
+ state.enabled ? `Preview protection is on: only members of this project can open the preview. ${EDGE}` : `Preview protection is off: anyone with the link can open the preview. ${EDGE}`
5228
+ ]);
5229
+ }
5230
+ function runProtectionEnable(context) {
5231
+ return setProtection(context, true);
5232
+ }
5233
+ async function runProtectionDisable(context) {
5234
+ await confirmOrAbort(context, DISABLE_QUESTION2);
5235
+ return setProtection(context, false);
5236
+ }
5237
+ async function runProtectionBypass(context) {
5238
+ const client = await context.client();
5239
+ const projectId = await context.resolveProjectId();
5240
+ if (!context.flags.yes && (await client.protection.get(projectId)).bypass)
5241
+ await confirmOrAbort(context, REPLACE_QUESTION);
5242
+ const result = await client.protection.createBypass(projectId);
5243
+ context.output.result({ projectId, ...result }, [
5244
+ `Bypass secret (shown once): ${result.secret}`,
5245
+ "Send it as the x-naro-protection-bypass header."
5246
+ ]);
5247
+ }
5248
+ async function runProtectionBypassRemove(context) {
5249
+ await confirmOrAbort(context, REMOVE_QUESTION);
5250
+ const client = await context.client();
5251
+ const projectId = await context.resolveProjectId();
5252
+ const state = await client.protection.removeBypass(projectId);
5253
+ context.output.result({ projectId, ...state }, ["Bypass secret removed."]);
5254
+ }
5255
+
5256
+ // src/commands/storage-units.ts
5257
+ var SIZE_MESSAGE = "--file-size-limit takes a whole number of bytes, or a size like 5MB (1000s) or 5MiB (1024s), or none.";
5258
+ var UNITS = {
5259
+ b: 1,
5260
+ kb: 1e3,
5261
+ mb: 1e6,
5262
+ gb: 1e9,
5263
+ kib: 1024,
5264
+ mib: 1048576,
5265
+ gib: 1073741824
5266
+ };
5267
+ function parseSize(text) {
5268
+ const match = /^(\d{1,16})\s*([a-z]+)?$/i.exec(text.trim());
5269
+ const unit = UNITS[(match?.[2] ?? "b").toLowerCase()];
5270
+ const bytes = match?.[1] !== void 0 && unit !== void 0 ? Number(match[1]) * unit : Number.NaN;
5271
+ if (!Number.isSafeInteger(bytes) || bytes < 1)
5272
+ throw new NaroError("VALIDATION_FAILED", SIZE_MESSAGE);
5273
+ return bytes;
5274
+ }
5275
+ function binarySize(bytes) {
5276
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
5277
+ let value = bytes;
5278
+ let unit = 0;
5279
+ while (value >= 1024 && unit < units.length - 1) {
5280
+ value /= 1024;
5281
+ unit += 1;
5282
+ }
5283
+ const shown = unit === 0 ? String(value) : String(Number(value.toFixed(1)));
5284
+ return `${shown} ${units[unit] ?? "B"}`;
5285
+ }
5286
+ function count(n, noun) {
5287
+ return `${n.toLocaleString("en-US")} ${noun}${n === 1 ? "" : "s"}`;
5288
+ }
5289
+
5290
+ // src/commands/storage.ts
5291
+ var CREATE_USAGE2 = "Usage: naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]";
5292
+ var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]";
5293
+ var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
5294
+ var REMOVE_USAGE2 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
5295
+ var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
5296
+ var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
5297
+ var IDLE_PAGES = 3;
5298
+ function bucketLine(bucket) {
5299
+ const name = printable(bucket.name);
5300
+ const cells = [
5301
+ name.padEnd(20),
5302
+ (bucket.public ? "public" : "private").padEnd(7),
5303
+ count(bucket.objects, "object").padEnd(12),
5304
+ binarySize(bucket.bytes).padEnd(10),
5305
+ bucket.allowedMimeTypes === null ? "any type" : printable(bucket.allowedMimeTypes.join(",")),
5306
+ bucket.fileSizeLimit === null ? "no size limit" : `up to ${binarySize(bucket.fileSizeLimit)}`
5307
+ ];
5308
+ const line = ` ${cells.join(" ")}`;
5309
+ return bucket.deletingAt === null ? line : `${line} (being removed: \`naro storage bucket remove ${name} --empty\` finishes it)`;
5310
+ }
5311
+ function storageLines(info) {
5312
+ if (!info.enabled)
5313
+ return [
5314
+ "Storage: off",
5315
+ "Run `naro storage enable` to turn it on (the project's backend must be on first)."
5316
+ ];
5317
+ const { limits, usage } = info;
5318
+ return [
5319
+ `Storage: on (R2 bucket ${printable(info.bucket ?? "unknown")})`,
5320
+ `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per upload`,
5321
+ usage === null ? "Used: not known yet: the backend is not ready (`naro backend` shows where it got to)" : `Used: ${binarySize(usage.bytes)} in ${count(usage.objects, "object")}`,
5322
+ info.buckets.length === 0 ? "Buckets: none yet (`naro storage bucket create <name>` makes one)" : `Buckets: ${String(info.buckets.length)}`,
5323
+ ...info.buckets.map(bucketLine)
5324
+ ];
5325
+ }
5326
+ async function enableStorage(client, projectId) {
5327
+ try {
5328
+ return await client.storage.enable(projectId);
5329
+ } catch (error) {
5330
+ if (isNaroError(error) && error.code === "TIMEOUT")
5331
+ throw new NaroError(
5332
+ "TIMEOUT",
5333
+ "Turning Storage on did not answer within 2 minutes. `naro storage` shows where it got to, and `naro storage enable` again picks up where it stopped.",
5334
+ { cause: error }
5335
+ );
5336
+ throw error;
5337
+ }
5338
+ }
5339
+ function bucketName(context, usage) {
5340
+ if (context.positionals.length !== 1)
5341
+ throw new NaroError("VALIDATION_FAILED", usage);
5342
+ return context.positionals[0];
5343
+ }
5344
+ function bucketFields(flags) {
5345
+ if (flags.public === true && flags.private === true)
5346
+ throw new NaroError(
5347
+ "VALIDATION_FAILED",
5348
+ "Pass --public or --private, not both."
5349
+ );
5350
+ const fields = {};
5351
+ if (flags.public === true) fields.public = true;
5352
+ if (flags.private === true) fields.public = false;
5353
+ if (flags.fileSizeLimit !== void 0)
5354
+ fields.fileSizeLimit = flags.fileSizeLimit.trim().toLowerCase() === "none" ? null : parseSize(flags.fileSizeLimit);
5355
+ if (flags.allowedMimeTypes !== void 0)
5356
+ fields.allowedMimeTypes = flags.allowedMimeTypes.trim().toLowerCase() === "any" ? null : listFlag(flags.allowedMimeTypes);
5357
+ return fields;
5358
+ }
5359
+ async function runStorageStatus(context) {
5360
+ const client = await context.client();
5361
+ const projectId = await context.resolveProjectId();
5362
+ const storage = await client.storage.info(projectId);
5363
+ context.output.result({ projectId, storage }, storageLines(storage));
5364
+ }
5365
+ async function runStorageEnable(context) {
5366
+ const client = await context.client();
5367
+ const projectId = await context.resolveProjectId();
5368
+ const storage = await enableStorage(client, projectId);
5369
+ context.output.result({ projectId, storage }, [
5370
+ `\u2705 Storage is on: R2 bucket ${printable(storage.bucket ?? "unknown")}.`,
5371
+ ...storage.usage === null ? ["\u23F3 The backend is still finishing; check `naro backend` in a minute."] : [],
5372
+ ...storageLines(storage)
5373
+ ]);
5374
+ }
5375
+ async function runStorageBucketList(context) {
5376
+ const client = await context.client();
5377
+ const projectId = await context.resolveProjectId();
5378
+ const buckets = await client.storage.listBuckets(projectId);
5379
+ context.output.result(
5380
+ { projectId, buckets },
5381
+ buckets.length === 0 ? [NO_BUCKETS] : buckets.map(bucketLine)
5382
+ );
5383
+ }
5384
+ async function runStorageBucketCreate(context) {
5385
+ const name = bucketName(context, CREATE_USAGE2);
5386
+ const input = { name, ...bucketFields(context.flags) };
5387
+ const client = await context.client();
5388
+ const projectId = await context.resolveProjectId();
5389
+ let bucket;
5390
+ try {
5391
+ bucket = await client.storage.createBucket(projectId, input);
5392
+ } catch (error) {
5393
+ if (!isNaroError(error) || error.code !== "STORAGE_NOT_ENABLED")
5394
+ throw error;
5395
+ context.output.log(ENABLE_FIRST);
5396
+ await enableStorage(client, projectId);
5397
+ bucket = await client.storage.createBucket(projectId, input);
5398
+ }
5399
+ context.output.result({ projectId, bucket }, [
5400
+ `\u2705 Created bucket ${printable(bucket.name)}.`,
5401
+ bucketLine(bucket)
5402
+ ]);
5403
+ }
5404
+ async function runStorageBucketUpdate(context) {
5405
+ const name = bucketName(context, UPDATE_USAGE);
5406
+ const patch = bucketFields(context.flags);
5407
+ if (patch.public === true)
5408
+ await confirmOrAbort(
5409
+ context,
5410
+ `Make bucket ${name} public? Anyone with an object's URL can download it.`
5411
+ );
5412
+ const client = await context.client();
5413
+ const projectId = await context.resolveProjectId();
5414
+ const bucket = await client.storage.updateBucket(projectId, name, patch);
5415
+ context.output.result({ projectId, bucket }, [
5416
+ `\u2705 Updated bucket ${printable(bucket.name)}. It takes effect within about 10 seconds.`,
5417
+ bucketLine(bucket)
5418
+ ]);
5419
+ }
5420
+ async function untilDone(context, bucket, page) {
5421
+ let deleted = 0;
5422
+ let idle = 0;
5423
+ for (; ; ) {
5424
+ const result = await page();
5425
+ deleted += result.deleted;
5426
+ if (result.done) return deleted;
5427
+ idle = result.deleted === 0 ? idle + 1 : 0;
5428
+ if (idle >= IDLE_PAGES)
5429
+ throw new NaroError(
5430
+ "BUCKET_EMPTY_STALLED",
5431
+ `Bucket ${bucket} is not empty yet, but the last calls deleted nothing. Run the same command again in a minute.`,
5432
+ { details: { deleted } }
5433
+ );
5434
+ context.output.log(`\u2026 ${count(deleted, "object")} deleted so far`);
5435
+ }
5436
+ }
5437
+ function objectsSentence(objects2) {
5438
+ return `Its ${count(objects2, "object")} ${objects2 === 1 ? "is" : "are"} deleted and cannot be restored.`;
5439
+ }
5440
+ async function runStorageBucketEmpty(context) {
5441
+ const name = bucketName(context, EMPTY_USAGE);
5442
+ const client = await context.client();
5443
+ const projectId = await context.resolveProjectId();
5444
+ const bucket = await client.storage.getBucket(projectId, name);
5445
+ await confirmOrAbort(
5446
+ context,
5447
+ `Empty bucket ${name}? ${objectsSentence(bucket.objects)}`
5448
+ );
5449
+ const deleted = await untilDone(
5450
+ context,
5451
+ name,
5452
+ () => client.storage.emptyBucket(projectId, name)
5453
+ );
5454
+ context.output.result({ projectId, bucket: name, deleted }, [
5455
+ `\u2705 Emptied ${name}: ${count(deleted, "object")} deleted.`
5456
+ ]);
5457
+ }
5458
+ async function runStorageBucketRemove(context) {
5459
+ const name = bucketName(context, REMOVE_USAGE2);
5460
+ const empty = context.flags.empty === true;
5461
+ const client = await context.client();
5462
+ const projectId = await context.resolveProjectId();
5463
+ let question = `Remove bucket ${name}?`;
5464
+ if (empty) {
5465
+ const bucket = await client.storage.getBucket(projectId, name);
5466
+ question += ` ${objectsSentence(bucket.objects)}`;
5467
+ }
5468
+ await confirmOrAbort(context, question);
5469
+ await untilDone(context, name, async () => {
5470
+ const removal = await client.storage.removeBucket(
5471
+ projectId,
5472
+ name,
5473
+ empty ? { empty: true } : {}
5474
+ );
5475
+ return "ok" in removal ? { deleted: 0, done: true } : removal;
5476
+ });
5477
+ context.output.result({ projectId, bucket: name, removed: true }, [
5478
+ `\u2705 Removed bucket ${name}.`
5479
+ ]);
5480
+ }
5481
+
5482
+ // src/commands/storage-objects.ts
5483
+ import { randomUUID } from "crypto";
5484
+ import { createWriteStream as createWriteStream2, openAsBlob, rmSync as rmSync2 } from "fs";
5485
+ import { rename, rm, stat } from "fs/promises";
5486
+ import { basename as basename2, dirname as dirname3, extname, join as join6, resolve as resolve5 } from "path";
5487
+ import { Readable } from "stream";
5488
+ import { pipeline as pipeline2 } from "stream/promises";
5489
+
5490
+ // ../../packages/naro-sdk/dist/index.js
5491
+ var INVALID_QUERY_MESSAGE = "The query is not a valid Naro query.";
5492
+ var INVALID_SESSION_MESSAGE = "The session is expired or not valid. Sign in again.";
5493
+ function networkError(cause) {
5494
+ return {
5495
+ code: "NETWORK_ERROR",
5496
+ message: "The request did not reach the Naro backend. Check the url and the network.",
5497
+ details: { cause: describe(cause) }
5498
+ };
5499
+ }
5500
+ function requestTimeout(ms) {
5501
+ return {
5502
+ code: "TIMEOUT",
5503
+ message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
5504
+ };
5505
+ }
5506
+ function invalidResponse(status) {
5507
+ return {
5508
+ code: "INVALID_RESPONSE",
5509
+ message: `The server did not answer like a Naro backend (HTTP ${String(status)}).`,
5510
+ details: { status }
5511
+ };
5512
+ }
5513
+ function invalidQuery(path, reason) {
5514
+ return {
5515
+ code: "INVALID_QUERY",
5516
+ message: INVALID_QUERY_MESSAGE,
5517
+ details: { path, reason }
5518
+ };
5519
+ }
5520
+ function invalidSession() {
5521
+ return { code: "INVALID_SESSION", message: INVALID_SESSION_MESSAGE };
5522
+ }
5523
+ function codeVerifierMissing() {
5524
+ return {
5525
+ code: "CODE_VERIFIER_MISSING",
5526
+ message: "This browser did not start this sign-in, so it cannot finish it. Sign in again here."
5527
+ };
5528
+ }
5529
+ function passwordRecoveryRequired() {
5530
+ return {
5531
+ code: "PASSWORD_RECOVERY_REQUIRED",
5532
+ message: "updateUser({ password }) works in the password recovery flow: open the link resetPasswordForEmail sent, then call it on that page."
5533
+ };
5534
+ }
5535
+ function invalidProvider() {
5536
+ return {
5537
+ code: "VALIDATION_ERROR",
5538
+ message: "provider must be google or github."
5539
+ };
5540
+ }
5541
+ function serviceKeyRequired() {
5542
+ return {
5543
+ code: "PERMISSION_DENIED",
5544
+ message: "Only the service key can manage users."
5545
+ };
5546
+ }
5547
+ function cryptoUnavailable() {
5548
+ return {
5549
+ code: "CRYPTO_UNAVAILABLE",
5550
+ message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
5551
+ };
5552
+ }
5553
+ function describe(cause) {
5554
+ if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
5555
+ return String(cause);
5556
+ }
5557
+ function isRecord(value) {
5558
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5559
+ }
5560
+ function asError(value) {
5561
+ if (!isRecord(value)) return null;
5562
+ const { code, message } = value;
5563
+ if (typeof code !== "string" || code === "") return null;
5564
+ if (typeof message !== "string") return null;
5565
+ const error = { code, message };
5566
+ if (value.details !== void 0) error.details = value.details;
5567
+ return error;
5568
+ }
5569
+ function errorFromBody(status, body) {
5570
+ if (isRecord(body) && body.error !== void 0 && body.error !== null)
5571
+ return asError(body.error) ?? invalidResponse(status);
5572
+ if (status >= 200 && status < 300) return null;
5573
+ return invalidResponse(status);
5574
+ }
5575
+ async function receive(config, options, init) {
5576
+ const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
5577
+ if (controller) init.signal = controller.signal;
5578
+ const request = (async () => {
5579
+ try {
5580
+ const fetchImpl = config.fetch ?? globalThis.fetch;
5581
+ const response = await fetchImpl(
5582
+ `${config.baseUrl}/_naro/v1${options.path}`,
5583
+ init
5584
+ );
5585
+ return {
5586
+ status: response.status,
5587
+ headers: response.headers,
5588
+ text: await response.text()
5589
+ };
5590
+ } catch (error) {
5591
+ return { error: networkError(error) };
5592
+ }
5593
+ })();
5594
+ const { timeoutMs } = options;
5595
+ if (timeoutMs === void 0) return request;
5596
+ let timer;
5597
+ const expired = new Promise((resolve6) => {
5598
+ timer = setTimeout(() => {
5599
+ controller?.abort();
5600
+ resolve6({ error: requestTimeout(timeoutMs) });
5601
+ }, timeoutMs);
5602
+ });
5603
+ try {
5604
+ return await Promise.race([request, expired]);
5605
+ } finally {
5606
+ clearTimeout(timer);
5607
+ }
5608
+ }
5609
+ async function call(config, options) {
5610
+ let payload;
5611
+ if (options.body !== void 0) {
5612
+ try {
5613
+ payload = JSON.stringify(options.body);
5614
+ } catch {
5615
+ return { error: invalidQuery("", "is not JSON"), answer: null };
5616
+ }
5617
+ }
5618
+ const token = options.withToken ? await config.tokens.get() : null;
5619
+ const headers = { apikey: config.apiKey };
5620
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
5621
+ const init = {
5622
+ method: options.method,
5623
+ headers,
5624
+ // Identity is the bearer token alone. The sign-in routes also set a
5625
+ // session cookie (spec §13); a request that carried it would let any site
5626
+ // act as the user. Workers accept the field (measured on workerd
5627
+ // 1.20260815).
5628
+ credentials: "omit"
5629
+ };
5630
+ if (payload !== void 0) {
5631
+ headers["Content-Type"] = "application/json";
5632
+ init.body = payload;
5633
+ }
5634
+ const received = await receive(config, options, init);
5635
+ if ("error" in received) return { error: received.error, answer: null };
5636
+ const { status, headers: responseHeaders, text } = received;
5637
+ let body;
5638
+ if (text.trim() !== "") {
5639
+ try {
5640
+ body = JSON.parse(text);
5641
+ } catch {
5642
+ return { error: invalidResponse(status), answer: null };
5643
+ }
5644
+ }
5645
+ const answer = { status, headers: responseHeaders, body, token };
5646
+ const error = errorFromBody(status, body);
5647
+ if (error === null) return { error: null, answer };
5648
+ return {
5649
+ error: await settled(config, error, responseHeaders, token),
5650
+ answer
5651
+ };
5652
+ }
5653
+ async function settled(config, error, headers, token) {
5654
+ if (error.code === "INVALID_SESSION" && token !== null)
5655
+ await config.tokens.clearIf(token);
5656
+ return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
5657
+ }
5658
+ async function send(config, options) {
5659
+ const token = options.withToken ? await config.tokens.get() : null;
5660
+ const headers = {
5661
+ ...options.headers,
5662
+ apikey: config.apiKey
5663
+ };
5664
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
5665
+ const init = {
5666
+ method: options.method,
5667
+ headers,
5668
+ credentials: "omit"
5669
+ };
5670
+ if (options.body !== void 0) init.body = options.body;
5671
+ let response;
5672
+ try {
5673
+ const fetchImpl = config.fetch ?? globalThis.fetch;
5674
+ response = await fetchImpl(
5675
+ `${config.baseUrl}/_naro/v1${options.path}`,
5676
+ init
5677
+ );
5678
+ } catch (error2) {
5679
+ return { error: networkError(error2) };
5680
+ }
5681
+ if (response.status >= 200 && response.status < 300)
5682
+ return { error: null, response };
5683
+ let text;
5684
+ try {
5685
+ text = await response.text();
5686
+ } catch (error2) {
5687
+ return { error: networkError(error2) };
5688
+ }
5689
+ let body;
5690
+ if (text.trim() !== "") {
5691
+ try {
5692
+ body = JSON.parse(text);
5693
+ } catch {
5694
+ return { error: invalidResponse(response.status) };
5695
+ }
5696
+ }
5697
+ const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
5698
+ return { error: await settled(config, error, response.headers, token) };
5699
+ }
5700
+ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
5701
+ "RATE_LIMITED",
5702
+ "WRITE_QUOTA_EXCEEDED",
5703
+ // Storage (original spec §13): one object written twice within a second
5704
+ // (`x-retry-after: 1`), and the daily operation limit (3-1d).
5705
+ "OBJECT_RATE_LIMITED",
5706
+ "STORAGE_OPS_QUOTA_EXCEEDED"
5707
+ ]);
5708
+ function withRetryAfter(error, headers) {
5709
+ const header = headers.get("x-retry-after")?.trim() ?? "";
5710
+ if (!/^\d{1,9}$/.test(header)) return error;
5711
+ const { details } = error;
5712
+ if (details !== void 0 && !isRecord(details)) return error;
5713
+ return {
5714
+ ...error,
5715
+ details: { ...details, retryAfter: Number(header) }
5716
+ };
5717
+ }
5718
+ function isBrowser(scope = globalThis) {
5719
+ const g = scope;
5720
+ return g.window !== void 0 && g.document !== void 0;
5721
+ }
5722
+ function isWorkerd(g) {
5723
+ return g.navigator?.userAgent === "Cloudflare-Workers";
5724
+ }
5725
+ function hasServerSignal(g) {
5726
+ return isWorkerd(g) || // Vercel's edge runtime and Next.js's edge (middleware, edge routes).
5727
+ typeof g.EdgeRuntime === "string" || typeof g.Deno?.version?.deno === "string" || typeof g.Bun?.version === "string" || // Node, and so the Next.js server. Browser polyfills of `process` leave
5728
+ // `versions.node` out.
5729
+ typeof g.process?.versions?.node === "string";
5730
+ }
5731
+ function hasDeviceSignal(g) {
5732
+ if (g.window !== void 0 || g.document !== void 0) return true;
5733
+ if (g.navigator?.product === "ReactNative") return true;
5734
+ return !isWorkerd(g) && (g.WorkerGlobalScope !== void 0 || g.ServiceWorkerGlobalScope !== void 0);
5735
+ }
5736
+ function isKnownServer(scope = globalThis) {
5737
+ const g = scope;
5738
+ return hasServerSignal(g) && !hasDeviceSignal(g);
5739
+ }
5740
+ var USERS_PATH = "/auth/admin/users";
5741
+ function dataOf(result) {
5742
+ if (result.error !== null) return null;
5743
+ const { body } = result.answer;
5744
+ return isRecord(body) && isRecord(body.data) ? body.data : null;
5745
+ }
5746
+ function errorOf(result) {
5747
+ if (result.error !== null) return result.error;
5748
+ return invalidResponse(result.answer.status);
5749
+ }
5750
+ function userOf(value) {
5751
+ return isRecord(value) && typeof value.id === "string" ? value : null;
5752
+ }
5753
+ var AuthAdminApi = class {
5754
+ #http;
5755
+ #serviceKey;
5756
+ constructor(http, serviceKey) {
5757
+ this.#http = http;
5758
+ this.#serviceKey = serviceKey;
5759
+ }
5760
+ /**
5761
+ * Creates a user with a password: an email, a username or both. It is
5762
+ * made while sign-ups are closed too, and is confirmed unless
5763
+ * `emailConfirm: false`. A username-only user answers `email: null` and
5764
+ * signs in with `signIn({ username, password })`. Refusals come as the
5765
+ * worker says them: USERNAME_TAKEN (409),
5766
+ * USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (422), PASSWORD_TOO_SHORT ("Use at
5767
+ * least {n} characters."), VALIDATION_ERROR, WRITE_QUOTA_EXCEEDED (429).
5768
+ */
5769
+ async createUser(attributes) {
5770
+ if (!this.#serviceKey)
5771
+ return { data: { user: null }, error: serviceKeyRequired() };
5772
+ const result = await call(this.#http, {
5773
+ method: "POST",
5774
+ path: USERS_PATH,
5775
+ body: attributes,
5776
+ withToken: false
5777
+ });
5778
+ const user = userOf(dataOf(result)?.user);
5779
+ if (user === null) return { data: { user: null }, error: errorOf(result) };
5780
+ return { data: { user }, error: null };
5781
+ }
5782
+ /** The project's users, newest first: page 1 and 50 a page unless given. */
5783
+ async listUsers(params = {}) {
5784
+ const none = { users: [], total: 0 };
5785
+ if (!this.#serviceKey) return { data: none, error: serviceKeyRequired() };
5786
+ const query = new URLSearchParams();
5787
+ if (params.page !== void 0) query.set("page", String(params.page));
5788
+ if (params.perPage !== void 0)
5789
+ query.set("perPage", String(params.perPage));
5790
+ const search = query.toString();
5791
+ const result = await call(this.#http, {
5792
+ method: "GET",
5793
+ path: search === "" ? USERS_PATH : `${USERS_PATH}?${search}`,
5794
+ withToken: false
5795
+ });
5796
+ const data = dataOf(result);
5797
+ const users = Array.isArray(data?.users) ? data.users.map(userOf) : null;
5798
+ if (!data || users === null || users.includes(null) || typeof data.total !== "number")
5799
+ return { data: none, error: errorOf(result) };
5800
+ return {
5801
+ data: { users, total: data.total },
5802
+ error: null
5803
+ };
5804
+ }
5805
+ /**
5806
+ * Deletes a user and its sign-in methods, and ends its sessions. Answers
5807
+ * the user it was; USER_NOT_FOUND (404) for an id that is no user.
5808
+ */
5809
+ async deleteUser(id) {
5810
+ if (!this.#serviceKey)
5811
+ return { data: { user: null }, error: serviceKeyRequired() };
5812
+ const result = await call(this.#http, {
5813
+ method: "DELETE",
5814
+ path: `${USERS_PATH}/${encodeURIComponent(String(id))}`,
5815
+ withToken: false
5816
+ });
5817
+ const user = userOf(dataOf(result)?.user);
5818
+ if (user === null) return { data: { user: null }, error: errorOf(result) };
5819
+ return { data: { user }, error: null };
5820
+ }
5821
+ };
5822
+ var CODE = /^[A-Z0-9_]{1,64}$/;
5823
+ var INVALID_CONFIRMATION = "The confirmation link is not valid. Ask for a new one.";
5824
+ var MESSAGES = {
5825
+ INVALID_STATE: "This sign-in link is not valid or has expired. Start signing in again.",
5826
+ OAUTH_BROWSER_MISMATCH: "This sign-in was started in another browser, or this browser did not keep its cookie. Start signing in again here.",
5827
+ OAUTH_ACCESS_DENIED: "Signing in with the provider was cancelled.",
5828
+ OAUTH_PROVIDER_EMAIL_NOT_VERIFIED: "Your email address is not verified with the provider. Verify it there, then sign in again.",
5829
+ OAUTH_ACCOUNT_NOT_LINKED: "An account with this email already exists and is not linked to this provider. Sign in the way you signed up.",
5830
+ PROVIDER_NOT_CONFIGURED: "Signing in with this provider is not set up for this project.",
5831
+ RATE_LIMITED: "Too many sign-in attempts. Wait a few seconds and try again.",
5832
+ // A first Google or GitHub sign-in while the project's sign-ups are
5833
+ // closed (spec 2026-09-26 §2.1): the worker's own words.
5834
+ SIGNUPS_DISABLED: "Sign-ups are closed for this project.",
5835
+ INTERNAL_ERROR: "Something went wrong. Try again.",
5836
+ PASSWORD_RESET_REQUIRED: "Your email is confirmed. Someone else also signed up with it before it was confirmed, so its password was cleared: choose a new one with a password reset.",
5837
+ TOKEN_EXPIRED: "The confirmation link has expired. Ask for a new one.",
5838
+ USER_NOT_FOUND: INVALID_CONFIRMATION
5839
+ };
5840
+ function flowError(type, raw) {
5841
+ if (!CODE.test(raw))
5842
+ return {
5843
+ code: "AUTH_ERROR",
5844
+ message: "Signing in did not finish. Try again."
5845
+ };
5846
+ if (raw === "INVALID_TOKEN")
5847
+ return {
5848
+ code: raw,
5849
+ message: type === "recovery" ? "The password reset link is not valid, has expired or was already used. Ask for a new one." : INVALID_CONFIRMATION
5850
+ };
5851
+ const message = MESSAGES[raw];
5852
+ if (message !== void 0) return { code: raw, message };
5853
+ if (raw.startsWith("OAUTH_"))
5854
+ return {
5855
+ code: raw,
5856
+ message: `Signing in with the provider did not finish (${raw}). Try again.`
5857
+ };
5858
+ return {
5859
+ code: raw,
5860
+ message: `Signing in did not finish (${raw}). Try again.`
5861
+ };
5862
+ }
5863
+ function base64url(bytes) {
5864
+ let binary = "";
5865
+ for (const byte of bytes) binary += String.fromCharCode(byte);
5866
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
5867
+ }
5868
+ async function createPkcePair() {
5869
+ const subtle = globalThis.crypto?.subtle;
5870
+ if (subtle === void 0) return null;
5871
+ const verifier = base64url(
5872
+ globalThis.crypto.getRandomValues(new Uint8Array(32))
5873
+ );
5874
+ const digest = await subtle.digest(
5875
+ "SHA-256",
5876
+ new TextEncoder().encode(verifier)
5877
+ );
5878
+ return { verifier, challenge: base64url(new Uint8Array(digest)) };
5879
+ }
5880
+ var TOKEN_HEADER = "set-auth-token";
5881
+ var FRAGMENT_PREFIX = "naro_";
5882
+ var OAUTH_PROVIDERS = ["google", "github"];
5883
+ var FLOW_TYPES = [
5884
+ "oauth",
5885
+ "email_verification",
5886
+ "recovery"
5887
+ ];
5888
+ function defaultName(email) {
5889
+ if (typeof email !== "string") return "";
5890
+ const at = email.lastIndexOf("@");
5891
+ return at > 0 ? email.slice(0, at) : email;
5892
+ }
5893
+ function nonEmpty(value) {
5894
+ return typeof value === "string" && value !== "" ? value : null;
5895
+ }
5896
+ function tokenFrom(answer) {
5897
+ return nonEmpty(answer.headers.get(TOKEN_HEADER));
5898
+ }
5899
+ function userFrom(body) {
5900
+ return isRecord(body) && isRecord(body.user) ? body.user : null;
5901
+ }
5902
+ function currentLocation() {
5903
+ if (!isBrowser()) return null;
5904
+ return globalThis.location ?? null;
5905
+ }
5906
+ function currentPage() {
5907
+ const location = currentLocation();
5908
+ if (!location) return void 0;
5909
+ return `${location.origin}${location.pathname}${location.search}`;
5910
+ }
5911
+ function naroFragment() {
5912
+ const hash = currentLocation()?.hash ?? "";
5913
+ if (!hash.startsWith("#")) return null;
5914
+ const params = new URLSearchParams(hash.slice(1));
5915
+ const found = {};
5916
+ for (const [key, value] of params)
5917
+ if (key.startsWith(FRAGMENT_PREFIX)) found[key] = value;
5918
+ return Object.keys(found).length > 0 ? found : null;
5919
+ }
5920
+ function clearFragment() {
5921
+ const location = currentLocation();
5922
+ const history = globalThis.history;
5923
+ if (!location || !history?.replaceState) return;
5924
+ try {
5925
+ history.replaceState(
5926
+ history.state,
5927
+ "",
5928
+ `${location.pathname}${location.search}`
5929
+ );
5930
+ } catch {
5931
+ }
5932
+ }
5933
+ var EXCHANGE_TIMEOUT_MS = 15e3;
5934
+ var inFlight = /* @__PURE__ */ new WeakMap();
5935
+ function sharedInFlight(shared) {
5936
+ return inFlight.get(shared.storage)?.get(shared.key) ?? null;
5937
+ }
5938
+ function share(shared, pending) {
5939
+ let entries = inFlight.get(shared.storage);
5940
+ if (!entries) {
5941
+ entries = /* @__PURE__ */ new Map();
5942
+ inFlight.set(shared.storage, entries);
5943
+ }
5944
+ entries.set(shared.key, pending);
5945
+ void pending.finally(() => {
5946
+ if (entries.get(shared.key) === pending) entries.delete(shared.key);
5947
+ });
5948
+ }
5949
+ var AuthClient = class {
5950
+ /**
5951
+ * The service key's user management — createUser, listUsers, deleteUser,
5952
+ * as in supabase-js (spec 2026-09-26 §2.2). Only on a client made with
5953
+ * `serviceKey`, on a server.
5954
+ */
5955
+ admin;
5956
+ #http;
5957
+ #flow;
5958
+ /** The last `getSessionFromUrl()`, for callers after detection ran it. */
5959
+ #fromUrl = null;
5960
+ constructor(http, flow) {
5961
+ this.#http = http;
5962
+ this.#flow = flow;
5963
+ this.admin = new AuthAdminApi(http, flow.serviceKey);
5964
+ if (flow.detectSessionInUrl) {
5965
+ if (naroFragment() !== null) void this.getSessionFromUrl();
5966
+ else this.#fromUrl = sharedInFlight(flow.shared);
5967
+ }
5968
+ }
5969
+ /**
5970
+ * Creates the account and signs in; the token is stored for later calls.
5971
+ * When the project requires a confirmed email there is no session yet —
5972
+ * `user` comes back, and after the confirmation link the user signs in.
5973
+ * While the project's sign-ups are closed every sign-up answers 403
5974
+ * SIGNUPS_DISABLED; while they are open, a taken `username` answers 409
5975
+ * USERNAME_TAKEN.
5976
+ */
5977
+ async signUp(credentials) {
5978
+ const { email, password, username } = credentials;
5979
+ const name = credentials.name ?? username ?? defaultName(email);
5980
+ const body = { email, password, name };
5981
+ if (username !== void 0) body.username = username;
5982
+ if (credentials.redirectTo !== void 0)
5983
+ body.callbackURL = credentials.redirectTo;
5984
+ return this.#signIn("/auth/sign-up/email", body, false);
5985
+ }
5986
+ /**
5987
+ * Signs in with an email and password, or a username and password (spec
5988
+ * 2026-09-26 §2.3); the token is stored for later calls. A wrong password
5989
+ * answers 401 INVALID_EMAIL_OR_PASSWORD, or INVALID_USERNAME_OR_PASSWORD.
5990
+ */
5991
+ async signIn(credentials) {
5992
+ const { password } = credentials;
5993
+ if (credentials.username !== void 0)
5994
+ return this.#signIn(
5995
+ "/auth/sign-in/username",
5996
+ { username: credentials.username, password },
5997
+ true
5998
+ );
5999
+ return this.#signIn(
6000
+ "/auth/sign-in/email",
6001
+ { email: credentials.email, password },
6002
+ true
6003
+ );
6004
+ }
6005
+ /**
6006
+ * Starts a Google or GitHub sign-in: sends the browser, with a top-level
6007
+ * navigation, to the worker's `GET /_naro/v1/auth/authorize/<provider>`,
6008
+ * which checks `redirectTo`, binds the flow to this browser with a cookie
6009
+ * and goes on to the provider (adversarial review I1). The provider sends
6010
+ * the browser back to `redirectTo`, where `getSessionFromUrl()` (run by
6011
+ * `createClient` on its own) finishes the sign-in. No request is made
6012
+ * here: anything that goes wrong comes back in that page's URL.
6013
+ */
6014
+ async signInWithOAuth(options) {
6015
+ const { provider } = options;
6016
+ const failed = (error) => ({
6017
+ data: { provider, url: null },
6018
+ error
6019
+ });
6020
+ if (!OAUTH_PROVIDERS.includes(provider)) return failed(invalidProvider());
6021
+ await this.#flow.recovery.forget();
6022
+ const pair = await createPkcePair();
6023
+ if (!pair) return failed(cryptoUnavailable());
6024
+ await this.#flow.verifiers.add(pair.challenge, pair.verifier);
6025
+ const redirectTo = options.redirectTo ?? currentPage();
6026
+ const url = new URL(
6027
+ `${this.#http.baseUrl}/_naro/v1/auth/authorize/${provider}`
6028
+ );
6029
+ if (redirectTo !== void 0)
6030
+ url.searchParams.set("redirectTo", redirectTo);
6031
+ url.searchParams.set("code_challenge", pair.challenge);
6032
+ const location = currentLocation();
6033
+ if (location && !options.skipBrowserRedirect) location.assign(url.href);
6034
+ return { data: { provider, url: url.href }, error: null };
6035
+ }
6036
+ /**
6037
+ * Trades the one-time code a sign-in redirect brought for the session,
6038
+ * with the verifier this browser kept when it began that flow — `flow` is
6039
+ * the redirect's `naro_flow`; without it, the newest flow's. Most apps
6040
+ * never call it: `getSessionFromUrl()` does.
6041
+ */
6042
+ async exchangeCodeForSession(code, flow) {
6043
+ const signedOut = { session: null, user: null };
6044
+ const verifier = await this.#flow.verifiers.take(flow);
6045
+ if (verifier === null)
6046
+ return { data: signedOut, error: codeVerifierMissing() };
6047
+ const result = await call(this.#http, {
6048
+ method: "POST",
6049
+ path: "/auth/exchange-code",
6050
+ body: { code, codeVerifier: verifier },
6051
+ withToken: false,
6052
+ // The page's queries wait on this; they must not wait forever.
6053
+ timeoutMs: EXCHANGE_TIMEOUT_MS
6054
+ });
6055
+ if (result.error !== null) return { data: signedOut, error: result.error };
6056
+ const { body } = result.answer;
6057
+ const token = tokenFrom(result.answer);
6058
+ const user = userFrom(body);
6059
+ if (token === null || user === null || !isRecord(body) || !isRecord(body.session))
6060
+ return { data: signedOut, error: invalidResponse(result.answer.status) };
6061
+ await this.#http.tokens.set(token);
6062
+ await this.#flow.recovery.forget();
6063
+ return { data: { session: body.session, user }, error: null };
6064
+ }
6065
+ /**
6066
+ * Reads what a sign-in redirect left in this page's URL fragment, then
6067
+ * clears it: a provider sign-in or a confirmation link hands over a
6068
+ * session (traded here), a reset link a recovery token (kept for
6069
+ * `updateUser`), and a failure its `code` and `message`. With nothing of
6070
+ * Naro's in the URL, `type` is null — or, after `createClient` already
6071
+ * read it, the answer it got.
6072
+ */
6073
+ getSessionFromUrl() {
6074
+ const fragment = naroFragment();
6075
+ if (fragment === null)
6076
+ return this.#fromUrl ?? Promise.resolve({
6077
+ data: { session: null, user: null, type: null },
6078
+ error: null
6079
+ });
6080
+ clearFragment();
6081
+ const answer = this.#answerOf(fragment);
6082
+ this.#fromUrl = answer;
6083
+ share(this.#flow.shared, answer);
6084
+ return answer;
6085
+ }
6086
+ async #answerOf(fragment) {
6087
+ const named = fragment.naro_type;
6088
+ const type = named && FLOW_TYPES.includes(named) ? named : null;
6089
+ const none = { session: null, user: null, type };
6090
+ if (fragment.naro_error) {
6091
+ if (fragment.naro_flow)
6092
+ await this.#flow.verifiers.take(fragment.naro_flow);
6093
+ return { data: none, error: flowError(type, fragment.naro_error) };
6094
+ }
6095
+ if (type === "recovery" && fragment.naro_token) {
6096
+ await this.#http.tokens.clear();
6097
+ await this.#flow.recovery.keep(fragment.naro_token);
6098
+ return { data: none, error: null };
6099
+ }
6100
+ if (fragment.naro_code) {
6101
+ const traded = await this.exchangeCodeForSession(
6102
+ fragment.naro_code,
6103
+ fragment.naro_flow
6104
+ );
6105
+ return { data: { ...traded.data, type }, error: traded.error };
6106
+ }
6107
+ return { data: none, error: null };
6108
+ }
6109
+ /**
6110
+ * Emails a password reset link, when the address has an account. The
6111
+ * answer is the same either way — it does not say whether it had one.
6112
+ */
6113
+ async resetPasswordForEmail(email, options = {}) {
6114
+ const body = { email };
6115
+ if (options.redirectTo !== void 0) body.redirectTo = options.redirectTo;
6116
+ const result = await call(this.#http, {
6117
+ method: "POST",
6118
+ path: "/auth/request-password-reset",
6119
+ body,
6120
+ withToken: false
6121
+ });
6122
+ return { data: null, error: result.error };
6123
+ }
6124
+ /**
6125
+ * Sets a new password in the recovery flow: on the page the reset link
6126
+ * opened, after `getSessionFromUrl()` read its token. Signing in with the
6127
+ * new password comes after — a reset ends every session of the account.
6128
+ * (Changing the password of a signed-in user is not offered: spec §14
6129
+ * "(P1-2 리뷰 반영)".)
6130
+ *
6131
+ * Like supabase-js, it acts on the recovery link's account, which took the
6132
+ * place of any signed-in session when the link was read — so the token is
6133
+ * used only while no session is signed in on this browser, for 15 minutes
6134
+ * after the link was opened, and never after a sign-in, sign-up or
6135
+ * sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
6136
+ * with no request.
6137
+ */
6138
+ async updateUser(attributes) {
6139
+ await this.#pendingUrl();
6140
+ if (await this.#http.tokens.get() !== null)
6141
+ return { data: null, error: passwordRecoveryRequired() };
6142
+ const token = await this.#flow.recovery.current();
6143
+ if (token === null)
6144
+ return { data: null, error: passwordRecoveryRequired() };
6145
+ const result = await call(this.#http, {
6146
+ method: "POST",
6147
+ path: "/auth/reset-password",
6148
+ body: { newPassword: attributes.password, token },
6149
+ withToken: false
6150
+ });
6151
+ if (result.error === null || result.error.code === "INVALID_TOKEN")
6152
+ await this.#flow.recovery.forgetIf(token);
6153
+ return { data: null, error: result.error };
6154
+ }
6155
+ /**
6156
+ * Emails the confirmation link again, when the address has an account that
6157
+ * is not confirmed yet. The answer does not say which.
6158
+ */
6159
+ async resendVerification(email, options = {}) {
6160
+ const body = { email };
6161
+ if (options.redirectTo !== void 0) body.callbackURL = options.redirectTo;
6162
+ const result = await call(this.#http, {
6163
+ method: "POST",
6164
+ path: "/auth/send-verification-email",
6165
+ body,
6166
+ withToken: false
6167
+ });
6168
+ return { data: null, error: result.error };
6169
+ }
6170
+ /**
6171
+ * Ends the session on the worker and forgets the token. The token is
6172
+ * forgotten even when the worker cannot be reached — the error still comes
6173
+ * back, since the session then lives on until it expires.
6174
+ */
6175
+ async signOut() {
6176
+ await this.#pendingUrl();
6177
+ await this.#flow.recovery.forget();
6178
+ const tokens = this.#http.tokens;
6179
+ const token = await tokens.get();
6180
+ if (token === null) return { data: null, error: null };
6181
+ const result = await call(this.#http, {
6182
+ method: "POST",
6183
+ path: "/auth/sign-out",
6184
+ withToken: true
6185
+ });
6186
+ await tokens.clearIf(result.answer?.token ?? token);
6187
+ return { data: null, error: result.error };
6188
+ }
6189
+ /**
6190
+ * The signed-in session and user. Signed out: both null, no request. A
6191
+ * stored token the worker no longer accepts is forgotten and reported as
6192
+ * INVALID_SESSION once (spec §11.1: a session never quietly turns anon).
6193
+ * Waits for a sign-in the page URL carried, if `createClient` found one.
6194
+ */
6195
+ async getSession() {
6196
+ await this.#pendingUrl();
6197
+ const signedOut = { session: null, user: null };
6198
+ const tokens = this.#http.tokens;
6199
+ if (await tokens.get() === null) return { data: signedOut, error: null };
6200
+ const result = await call(this.#http, {
6201
+ method: "GET",
6202
+ path: "/auth/get-session",
6203
+ withToken: true
6204
+ });
6205
+ if (result.error !== null) return { data: signedOut, error: result.error };
6206
+ const { body, token } = result.answer;
6207
+ if (body === null) {
6208
+ if (token !== null) await tokens.clearIf(token);
6209
+ return { data: signedOut, error: invalidSession() };
6210
+ }
6211
+ const user = userFrom(body);
6212
+ if (!isRecord(body) || !isRecord(body.session) || user === null)
6213
+ return { data: signedOut, error: invalidResponse(result.answer.status) };
6214
+ const renewed = tokenFrom(result.answer);
6215
+ if (renewed !== null && token !== null && renewed !== token)
6216
+ await tokens.replaceIf(token, renewed);
6217
+ return { data: { session: body.session, user }, error: null };
6218
+ }
6219
+ /** `{ data: { user } }`; `user` is null when signed out. */
6220
+ async getUser() {
6221
+ const { data, error } = await this.getSession();
6222
+ return { data: { user: data.user }, error };
6223
+ }
6224
+ /**
6225
+ * Resolves once a sign-in the page URL carried has been traded for its
6226
+ * session (or failed), with its error. Queries wait for it, so the first
6227
+ * one after a provider's redirect goes out signed in rather than as anon.
6228
+ * The exchange gives up after 15 seconds (TIMEOUT, PR #68 review 10):
6229
+ * then the queries go out as anon rather than wait forever.
6230
+ */
6231
+ async ready() {
6232
+ if (!this.#fromUrl) return { error: null };
6233
+ return { error: (await this.#fromUrl).error };
6234
+ }
6235
+ /** Waits for a URL sign-in in flight; its outcome is not this call's. */
6236
+ async #pendingUrl() {
6237
+ if (this.#fromUrl) await this.#fromUrl;
6238
+ }
6239
+ async #signIn(path, body, needsToken) {
6240
+ await this.#flow.recovery.forget();
6241
+ const result = await call(this.#http, {
6242
+ method: "POST",
6243
+ path,
6244
+ body,
6245
+ withToken: false
6246
+ });
6247
+ if (result.error !== null)
6248
+ return { data: { user: null }, error: result.error };
6249
+ const { body: answerBody } = result.answer;
6250
+ const user = userFrom(answerBody);
6251
+ const token = tokenFrom(result.answer);
6252
+ const opened = isRecord(answerBody) && nonEmpty(answerBody.token) !== null;
6253
+ if (user === null || (needsToken || opened) && token === null)
6254
+ return {
6255
+ data: { user: null },
6256
+ error: invalidResponse(result.answer.status)
6257
+ };
6258
+ if (token !== null) await this.#http.tokens.set(token);
6259
+ return { data: { user }, error: null };
6260
+ }
6261
+ };
6262
+ var FLOW_TTL_MS = 10 * 6e4;
6263
+ var MAX_FLOWS = 10;
6264
+ function isEntry(value) {
6265
+ return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
6266
+ }
6267
+ function live(text, now) {
6268
+ if (text === null) return {};
6269
+ let parsed;
6270
+ try {
6271
+ parsed = JSON.parse(text);
6272
+ } catch {
6273
+ return {};
6274
+ }
6275
+ if (typeof parsed !== "object" || parsed === null) return {};
6276
+ const entries = {};
6277
+ for (const [flow, entry] of Object.entries(parsed))
6278
+ if (isEntry(entry) && entry.expiresAt > now) entries[flow] = entry;
6279
+ return entries;
6280
+ }
6281
+ function pick(entries, oldest) {
6282
+ let found;
6283
+ for (const pair of Object.entries(entries))
6284
+ if (found === void 0 || (oldest ? pair[1].expiresAt < found[1].expiresAt : pair[1].expiresAt > found[1].expiresAt))
6285
+ found = pair;
6286
+ return found?.[0];
6287
+ }
6288
+ function serialize(entries) {
6289
+ return Object.keys(entries).length > 0 ? JSON.stringify(entries) : null;
6290
+ }
6291
+ var FlowVerifiers = class {
6292
+ #store;
6293
+ #now;
6294
+ constructor(store, now = Date.now) {
6295
+ this.#store = store;
6296
+ this.#now = now;
6297
+ }
6298
+ /** Keeps `verifier` for the flow `flow`, for ten minutes. */
6299
+ async add(flow, verifier) {
6300
+ const now = this.#now();
6301
+ await this.#store.update((text) => {
6302
+ const entries = live(text, now);
6303
+ entries[flow] = { verifier, expiresAt: now + FLOW_TTL_MS };
6304
+ while (Object.keys(entries).length > MAX_FLOWS) {
6305
+ const oldest = pick(entries, true);
6306
+ if (oldest === void 0) break;
6307
+ delete entries[oldest];
6308
+ }
6309
+ return serialize(entries);
6310
+ });
6311
+ }
6312
+ /**
6313
+ * The verifier of `flow`, taken — one flow, one use. Without a flow id
6314
+ * (a code traded by hand), the newest one. Null when there is none, or it
6315
+ * expired.
6316
+ */
6317
+ async take(flow) {
6318
+ const now = this.#now();
6319
+ let taken = null;
6320
+ await this.#store.update((text) => {
6321
+ const entries = live(text, now);
6322
+ const id = flow ?? pick(entries, false);
6323
+ if (id !== void 0 && Object.hasOwn(entries, id)) {
6324
+ taken = entries[id]?.verifier ?? null;
6325
+ delete entries[id];
6326
+ }
6327
+ return serialize(entries);
6328
+ });
6329
+ return taken;
6330
+ }
6331
+ };
6332
+ var RECOVERY_TTL_MS = 15 * 6e4;
6333
+ function parse(text) {
6334
+ if (text === null) return null;
6335
+ try {
6336
+ const value = JSON.parse(text);
6337
+ if (typeof value === "object" && value !== null && typeof value.token === "string" && value.token !== "" && typeof value.expiresAt === "number")
6338
+ return value;
6339
+ } catch {
6340
+ }
6341
+ return null;
6342
+ }
6343
+ var RecoveryToken = class {
6344
+ #store;
6345
+ #now;
6346
+ constructor(store, now = () => Date.now()) {
6347
+ this.#store = store;
6348
+ this.#now = now;
6349
+ }
6350
+ /** Keeps the token a recovery link brought, for 15 minutes. */
6351
+ async keep(token) {
6352
+ const kept = { token, expiresAt: this.#now() + RECOVERY_TTL_MS };
6353
+ await this.#store.set(JSON.stringify(kept));
6354
+ }
6355
+ /** The token, while it lasts; an expired or unreadable one is cleared. */
6356
+ async current() {
6357
+ const now = this.#now();
6358
+ let token = null;
6359
+ await this.#store.update((text) => {
6360
+ const kept = parse(text);
6361
+ if (kept === null || kept.expiresAt <= now) return null;
6362
+ token = kept.token;
6363
+ return text;
6364
+ });
6365
+ return token;
6366
+ }
6367
+ /** Forgets it: a sign-in, a sign-up, a sign-out. */
6368
+ async forget() {
6369
+ await this.#store.clear();
6370
+ }
6371
+ /** Forgets it if it is still `token` — used, or refused as used. */
6372
+ async forgetIf(token) {
6373
+ await this.#store.update(
6374
+ (text) => parse(text)?.token === token ? null : text
6375
+ );
6376
+ }
6377
+ };
6378
+ function memoryStorage() {
6379
+ const items22 = /* @__PURE__ */ new Map();
6380
+ return {
6381
+ getItem: (key) => items22.get(key) ?? null,
6382
+ setItem: (key, value) => {
6383
+ items22.set(key, value);
6384
+ },
6385
+ removeItem: (key) => {
6386
+ items22.delete(key);
6387
+ }
6388
+ };
6389
+ }
6390
+ function defaultStorage() {
6391
+ if (!isBrowser()) return memoryStorage();
6392
+ try {
6393
+ const storage = globalThis.localStorage;
6394
+ if (storage === void 0 || storage === null) return memoryStorage();
6395
+ const probe = "naro-storage-probe";
6396
+ storage.setItem(probe, probe);
6397
+ storage.removeItem(probe);
6398
+ return storage;
6399
+ } catch {
6400
+ return memoryStorage();
6401
+ }
6402
+ }
6403
+ function storageKey(baseUrl) {
6404
+ return `naro-auth-token:${baseUrl}`;
6405
+ }
6406
+ function verifierKey(baseUrl) {
6407
+ return `naro-auth-code-verifiers:${baseUrl}`;
6408
+ }
6409
+ function recoveryKey(baseUrl) {
6410
+ return `naro-auth-recovery:${baseUrl}`;
6411
+ }
6412
+ var TokenStore = class {
6413
+ #storage;
6414
+ #key;
6415
+ #fallback = null;
6416
+ /** Settles when the last queued operation has finished. */
6417
+ #queue = Promise.resolve();
6418
+ constructor(storage, key) {
6419
+ this.#storage = storage;
6420
+ this.#key = key;
6421
+ }
6422
+ get() {
6423
+ return this.#serial(() => this.#get());
6424
+ }
6425
+ set(token) {
6426
+ return this.#serial(() => this.#set(token));
6427
+ }
6428
+ clear() {
6429
+ return this.#serial(() => this.#clear());
6430
+ }
6431
+ /**
6432
+ * Clears only if the stored token is still the one that was rejected. A
6433
+ * request that left with an old token can come back after the user signed
6434
+ * in again; its INVALID_SESSION must not sign the new session out. The
6435
+ * read and the removal are one operation: with an async storage, a sign-in
6436
+ * that lands between them would otherwise be the token removed.
6437
+ */
6438
+ clearIf(token) {
6439
+ return this.#serial(async () => {
6440
+ if (await this.#get() === token) await this.#clear();
6441
+ });
6442
+ }
6443
+ /**
6444
+ * Reads the value, hands it to `transform` and stores what that returns
6445
+ * (null clears it), as one operation — the per-flow verifiers
6446
+ * (`flows.ts`) change one entry of a JSON object this way.
6447
+ */
6448
+ update(transform) {
6449
+ return this.#serial(async () => {
6450
+ const next = transform(await this.#get());
6451
+ if (next === null) await this.#clear();
6452
+ else await this.#set(next);
6453
+ });
6454
+ }
6455
+ /** Stores `next` only if `expected` is still stored, as one operation. */
6456
+ replaceIf(expected, next) {
6457
+ return this.#serial(async () => {
6458
+ if (await this.#get() === expected) await this.#set(next);
6459
+ });
6460
+ }
6461
+ /**
6462
+ * Runs `operation` once every earlier one has finished. The operations
6463
+ * below never reject, and none of them calls a public method (that would
6464
+ * wait on itself).
6465
+ */
6466
+ #serial(operation) {
6467
+ const result = this.#queue.then(operation);
6468
+ this.#queue = result.catch(() => void 0);
6469
+ return result;
6470
+ }
6471
+ async #get() {
6472
+ if (this.#fallback) return this.#fallback.token;
6473
+ try {
6474
+ const token = await this.#storage.getItem(this.#key);
6475
+ return typeof token === "string" && token !== "" ? token : null;
6476
+ } catch {
6477
+ await this.#fallBack(null, true);
6478
+ return null;
6479
+ }
6480
+ }
6481
+ async #set(token) {
6482
+ if (this.#fallback) {
6483
+ this.#fallback.token = token;
6484
+ return;
6485
+ }
6486
+ try {
6487
+ await this.#storage.setItem(this.#key, token);
6488
+ } catch {
6489
+ await this.#fallBack(token, true);
6490
+ }
6491
+ }
6492
+ async #clear() {
6493
+ if (this.#fallback) {
6494
+ this.#fallback.token = null;
6495
+ return;
6496
+ }
6497
+ try {
6498
+ await this.#storage.removeItem(this.#key);
6499
+ } catch {
6500
+ await this.#fallBack(null, false);
6501
+ }
6502
+ }
6503
+ /**
6504
+ * Stops using the storage, keeping `token` in memory. The token the storage
6505
+ * still holds is removed once, if it lets us: left there it comes back on
6506
+ * the next load — after a sign-out that could not read it, or over a
6507
+ * sign-in whose write failed (the previous user's session).
6508
+ */
6509
+ async #fallBack(token, removeStored) {
6510
+ this.#fallback = { token };
6511
+ if (!removeStored) return;
6512
+ try {
6513
+ await this.#storage.removeItem(this.#key);
6514
+ } catch {
6515
+ }
6516
+ }
6517
+ };
6518
+ function isSpace2(char) {
6519
+ return char.trim() === "";
6520
+ }
6521
+ function tokenize(input) {
6522
+ const tokens = [];
6523
+ let index = 0;
6524
+ const at = () => input[index] ?? "";
6525
+ for (; ; ) {
6526
+ while (index < input.length && isSpace2(at())) index++;
6527
+ let token;
6528
+ if (at() === '"') {
6529
+ index++;
6530
+ let name = "";
6531
+ for (; ; ) {
6532
+ if (index >= input.length)
6533
+ return `has an unclosed quote in column ${String(tokens.length + 1)}`;
6534
+ const char = at();
6535
+ index++;
6536
+ if (char !== '"') name += char;
6537
+ else if (at() === '"') {
6538
+ name += '"';
6539
+ index++;
6540
+ } else break;
6541
+ }
6542
+ while (index < input.length && isSpace2(at())) index++;
6543
+ if (index < input.length && at() !== ",")
6544
+ return `has text after the closing quote of column ${String(tokens.length + 1)}`;
6545
+ token = { name, quoted: true };
6546
+ } else {
6547
+ let name = "";
6548
+ while (index < input.length && at() !== ",") {
6549
+ if (at() === '"')
6550
+ return `has a quote inside column ${String(tokens.length + 1)}; quote the whole name`;
6551
+ name += at();
6552
+ index++;
6553
+ }
6554
+ token = { name: name.trim(), quoted: false };
6555
+ }
6556
+ tokens.push(token);
6557
+ if (index >= input.length) return tokens;
6558
+ index++;
6559
+ }
6560
+ }
6561
+ function parseColumns(input) {
6562
+ if (input === void 0) return { ok: true, columns: "*" };
6563
+ if (typeof input !== "string")
6564
+ return { ok: false, reason: "must be a string of column names" };
6565
+ const tokens = tokenize(input);
6566
+ if (typeof tokens === "string") return { ok: false, reason: tokens };
6567
+ const star = tokens.findIndex((token) => !token.quoted && token.name === "*");
6568
+ if (star >= 0) {
6569
+ if (tokens.length === 1) return { ok: true, columns: "*" };
6570
+ return { ok: false, reason: '"*" cannot be combined with column names' };
6571
+ }
6572
+ const empty = tokens.findIndex((token) => token.name === "");
6573
+ if (empty >= 0)
6574
+ return {
6575
+ ok: false,
6576
+ reason: tokens.length === 1 ? "must name at least one column" : `has an empty column name at position ${String(empty + 1)}`
6577
+ };
6578
+ return { ok: true, columns: tokens.map((token) => token.name) };
6579
+ }
6580
+ var MAX_FILTER_DEPTH2 = 4;
6581
+ var OPERATORS2 = {
6582
+ eq: "eq",
6583
+ neq: "neq",
6584
+ gt: "gt",
6585
+ gte: "gte",
6586
+ lt: "lt",
6587
+ lte: "lte",
6588
+ in: "in",
6589
+ is: "is",
6590
+ like: "like",
6591
+ ilike: "ilike",
6592
+ contains: "contains",
6593
+ containedBy: "containedBy",
6594
+ overlaps: "overlaps",
6595
+ cs: "contains",
6596
+ cd: "containedBy",
6597
+ ov: "overlaps"
6598
+ };
6599
+ function operatorOf2(name) {
6600
+ return typeof name === "string" && Object.hasOwn(OPERATORS2, name) ? OPERATORS2[name] : void 0;
6601
+ }
6602
+ var Refusal2 = class extends Error {
6603
+ };
6604
+ function refuse3(reason) {
6605
+ throw new Refusal2(reason);
6606
+ }
6607
+ var LIST_HINT2 = {
6608
+ in: ["(", ")", "needs a list in parentheses, like in.(a,b),"],
6609
+ array: ["{", "}", "needs a list in braces, like cs.{a,b},"]
6610
+ };
6611
+ function isSpace22(char) {
6612
+ return char !== "" && char.trim() === "";
6613
+ }
6614
+ function looseWord2(text) {
6615
+ return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "");
6616
+ }
6617
+ var Parser2 = class {
6618
+ constructor(text, tokens = {}) {
6619
+ this.text = text;
6620
+ this.tokens = tokens;
6621
+ }
6622
+ index = 0;
6623
+ /**
6624
+ * A token at the next value, followed only by spaces and then one of
6625
+ * `stops` or the end — `auth.uid()x` is text, not the token.
6626
+ */
6627
+ token(stops) {
6628
+ this.skipSpaces();
6629
+ const at = this.index;
6630
+ for (const [word, value] of Object.entries(this.tokens)) {
6631
+ if (!this.text.startsWith(word, at)) continue;
6632
+ let end = at + word.length;
6633
+ while (isSpace22(this.text[end] ?? "")) end++;
6634
+ const next = this.text[end] ?? "";
6635
+ if (next !== "" && !stops.includes(next)) continue;
6636
+ this.index = end;
6637
+ return { value, word, at };
6638
+ }
6639
+ return void 0;
6640
+ }
6641
+ at() {
6642
+ return this.text[this.index] ?? "";
6643
+ }
6644
+ /**
6645
+ * An unquoted value that is one of the tokens misspelled — `auth.uid`,
6646
+ * `AUTH.UID` — is refused: read as text it would compare with the words
6647
+ * themselves (사용자 리뷰 반영). Quoted, it is the text.
6648
+ */
6649
+ notNearToken(value, at) {
6650
+ const loose = looseWord2(value);
6651
+ for (const word of Object.keys(this.tokens))
6652
+ if (loose !== "" && loose === looseWord2(word))
6653
+ refuse3(
6654
+ `has ${JSON.stringify(value)} ${this.where(at)}: did you mean ${word}? Quote it to mean the text`
6655
+ );
6656
+ }
6657
+ /** `scalar`, with an unquoted value held to notNearToken. */
6658
+ bareOrQuoted(stops) {
6659
+ this.skipSpaces();
6660
+ const at = this.index;
6661
+ const quoted = this.at() === '"';
6662
+ const text = this.scalar(stops);
6663
+ if (!quoted) this.notNearToken(text, at);
6664
+ return { text, quoted };
6665
+ }
6666
+ where(index = this.index) {
6667
+ return `at character ${String(index + 1)}`;
6668
+ }
6669
+ skipSpaces() {
6670
+ while (isSpace22(this.at())) this.index++;
6671
+ }
6672
+ /** `item ("," item)*`, up to a `)` or the end, inside `level` groups. */
6673
+ list(level) {
6674
+ const nodes = [];
6675
+ for (; ; ) {
6676
+ nodes.push(this.item(level));
6677
+ this.skipSpaces();
6678
+ const char = this.at();
6679
+ if (char === ",") this.index++;
6680
+ else if (char === ")" || char === "") return nodes;
6681
+ else refuse3(`has unexpected text ${this.where()}`);
6682
+ }
6683
+ }
6684
+ item(level) {
6685
+ this.skipSpaces();
6686
+ const start = this.index;
6687
+ const char = this.at();
6688
+ if (char === "" || char === "," || char === ")")
6689
+ refuse3(`has an empty item ${this.where()}`);
6690
+ const group = /^(not\.)?(and|or)\(/.exec(this.text.slice(start));
6691
+ if (!group) return this.leaf(level);
6692
+ const negated = group[1] !== void 0;
6693
+ const inner = level + (negated ? 2 : 1);
6694
+ if (inner > MAX_FILTER_DEPTH2)
6695
+ refuse3(`nests deeper than ${String(MAX_FILTER_DEPTH2)} groups`);
6696
+ const open = start + group[0].length - 1;
6697
+ this.index = open + 1;
6698
+ const children = this.list(inner);
6699
+ if (this.at() !== ")") refuse3(`has an unclosed "(" ${this.where(open)}`);
6700
+ this.index++;
6701
+ const node = group[2] === "or" ? { or: children } : { and: children };
6702
+ return negated ? { not: node } : node;
6703
+ }
6704
+ /** `column.[not.]operator.value`. */
6705
+ leaf(level) {
6706
+ const start = this.index;
6707
+ const column = this.at() === '"' ? this.quoted() : this.word().trim();
6708
+ if (column === "" || this.at() !== ".")
6709
+ refuse3(`needs column.operator.value ${this.where(start)}`);
6710
+ this.index++;
6711
+ let nameAt = this.index;
6712
+ let name = this.word();
6713
+ let negated = false;
6714
+ if (name === "not" && this.at() === ".") {
6715
+ if (level + 1 > MAX_FILTER_DEPTH2)
6716
+ refuse3(`nests deeper than ${String(MAX_FILTER_DEPTH2)} groups`);
6717
+ negated = true;
6718
+ this.index++;
6719
+ nameAt = this.index;
6720
+ name = this.word();
6721
+ }
6722
+ const operator = operatorOf2(name);
6723
+ if (this.at() !== ".") {
6724
+ if (name !== "" && operator === void 0)
6725
+ refuse3(`has an unknown operator "${name}" ${this.where(nameAt)}`);
6726
+ refuse3(`needs column.operator.value ${this.where(start)}`);
6727
+ }
6728
+ if (operator === void 0)
6729
+ refuse3(`has an unknown operator "${name}" ${this.where(nameAt)}`);
6730
+ this.index++;
6731
+ const leaf = { operator, column, value: this.value(operator) };
6732
+ return negated ? { not: leaf } : leaf;
6733
+ }
6734
+ /** Up to the next `.`, `,`, `(`, `)` or the end. */
6735
+ word() {
6736
+ const start = this.index;
6737
+ while (this.index < this.text.length && !".,()".includes(this.at()))
6738
+ this.index++;
6739
+ return this.text.slice(start, this.index);
6740
+ }
6741
+ /** `"…"`, with `\` making the next character literal. */
6742
+ quoted() {
6743
+ const start = this.index;
6744
+ this.index++;
6745
+ let text = "";
6746
+ for (; ; ) {
6747
+ if (this.index >= this.text.length)
6748
+ refuse3(`has an unclosed quote ${this.where(start)}`);
6749
+ const char = this.at();
6750
+ this.index++;
6751
+ if (char === '"') return text;
6752
+ if (char === "\\" && this.index < this.text.length) {
6753
+ text += this.at();
6754
+ this.index++;
6755
+ } else text += char;
6756
+ }
6757
+ }
6758
+ /** A quoted value, or everything up to `stops`, trimmed. */
6759
+ scalar(stops) {
6760
+ this.skipSpaces();
6761
+ if (this.at() === '"') {
6762
+ const text = this.quoted();
6763
+ this.skipSpaces();
6764
+ if (this.at() !== "" && !stops.includes(this.at()))
6765
+ refuse3(`has text after a closing quote ${this.where()}`);
6766
+ return text;
6767
+ }
6768
+ const start = this.index;
6769
+ while (this.index < this.text.length && !stops.includes(this.at()))
6770
+ this.index++;
6771
+ return this.text.slice(start, this.index).trim();
6772
+ }
6773
+ /**
6774
+ * `(a,b)` for `in`: text values, except that an unquoted `true` or `false`
6775
+ * is a boolean (사용자 리뷰 반영, as in `eq`). `{a,b}` for the array
6776
+ * operators: text, except that an unquoted exact decimal is a number
6777
+ * (적대적 리뷰 반영, narrowed by 사용자 리뷰 반영) — json_each's values
6778
+ * have no type affinity to turn the text "1" into 1, so as text `cs.{1}`
6779
+ * matched no numeric array and `not.ov.{1}` matched every row. Quoted,
6780
+ * `"1"` and `"true"` stay text.
6781
+ */
6782
+ listValue(kind) {
6783
+ const [open, close, hint] = LIST_HINT2[kind];
6784
+ this.skipSpaces();
6785
+ const openAt = this.index;
6786
+ if (this.at() !== open) refuse3(`${hint} ${this.where()}`);
6787
+ this.index++;
6788
+ this.skipSpaces();
6789
+ const values = [];
6790
+ if (this.at() === close) {
6791
+ this.index++;
6792
+ return values;
6793
+ }
6794
+ for (; ; ) {
6795
+ this.skipSpaces();
6796
+ const itemAt = this.index;
6797
+ const token = this.token(`,${close}`);
6798
+ if (token) values.push(token.value);
6799
+ else {
6800
+ const { text: value, quoted } = this.bareOrQuoted(`,${close}`);
6801
+ if (!quoted && value === "") {
6802
+ if (this.index >= this.text.length)
6803
+ refuse3(`has an unclosed "${open}" ${this.where(openAt)}`);
6804
+ refuse3(`has an empty item ${this.where(itemAt)}`);
6805
+ }
6806
+ values.push(
6807
+ quoted ? value : kind === "array" ? exactNumber2(value) : truth2(value)
6808
+ );
6809
+ }
6810
+ if (this.index >= this.text.length)
6811
+ refuse3(`has an unclosed "${open}" ${this.where(openAt)}`);
6812
+ const char = this.at();
6813
+ this.index++;
6814
+ if (char === close) return values;
6815
+ }
6816
+ }
6817
+ /** The value after `operator.`. */
6818
+ value(operator) {
6819
+ const start = this.index;
6820
+ switch (operator) {
6821
+ case "in":
6822
+ return this.listValue("in");
6823
+ case "contains":
6824
+ case "containedBy":
6825
+ case "overlaps":
6826
+ return this.listValue("array");
6827
+ case "is":
6828
+ return keyword2(this.scalar(",)"), start);
6829
+ case "like":
6830
+ case "ilike": {
6831
+ const token = this.token(",)");
6832
+ if (token)
6833
+ refuse3(
6834
+ `cannot use ${token.word} with ${operator} ${this.where(token.at)}`
6835
+ );
6836
+ return likePattern2(this.bareOrQuoted(",)").text);
6837
+ }
6838
+ case "eq":
6839
+ case "neq": {
6840
+ const token = this.token(",)");
6841
+ if (token) return token.value;
6842
+ const { text, quoted } = this.bareOrQuoted(",)");
6843
+ return quoted ? text : truth2(text);
6844
+ }
6845
+ default: {
6846
+ const token = this.token(",)");
6847
+ return token ? token.value : this.bareOrQuoted(",)").text;
6848
+ }
6849
+ }
6850
+ }
6851
+ };
6852
+ function likePattern2(pattern) {
6853
+ return pattern.replaceAll("*", "%");
6854
+ }
6855
+ function truth2(text) {
6856
+ const word = text.toLowerCase();
6857
+ if (word === "true") return true;
6858
+ if (word === "false") return false;
6859
+ return text;
6860
+ }
6861
+ var DECIMAL3 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
6862
+ function exactNumber2(text) {
6863
+ if (!DECIMAL3.test(text)) return text;
6864
+ const number = Number(text);
6865
+ if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
6866
+ const trimmed = text.replace(/\.?0+$/, "");
6867
+ return String(number) === trimmed ? number : text;
6868
+ }
6869
+ function keyword2(text, at) {
6870
+ const word = text.toLowerCase();
6871
+ if (word === "null") return null;
6872
+ if (word === "true") return true;
6873
+ if (word === "false") return false;
6874
+ return refuse3(
6875
+ `needs null, true or false after is at character ${String(at + 1)}`
6876
+ );
6877
+ }
6878
+ function attempt2(parse2) {
6879
+ try {
6880
+ return { ok: true, node: parse2() };
6881
+ } catch (error) {
6882
+ if (error instanceof Refusal2) return { ok: false, reason: error.message };
6883
+ throw error;
6884
+ }
6885
+ }
6886
+ function parseLogic2(text, kind, options = {}) {
6887
+ if (typeof text !== "string")
6888
+ return { ok: false, reason: "must be a filter string or a function" };
6889
+ if (text.trim() === "") return { ok: false, reason: "must not be empty" };
6890
+ return attempt2(() => {
6891
+ if (options.group === false) {
6892
+ const alone = items2(text, options.tokens, 0);
6893
+ if (alone.length === 1 && alone[0]) return alone[0];
6894
+ }
6895
+ const nodes = items2(text, options.tokens, 1);
6896
+ return kind === "or" ? { or: nodes } : { and: nodes };
6897
+ });
6898
+ }
6899
+ function items2(text, tokens, level) {
6900
+ const parser = new Parser2(text, tokens);
6901
+ const nodes = parser.list(level);
6902
+ if (parser.at() === ")") refuse3(`has an unmatched ")" ${parser.where()}`);
6903
+ return nodes;
6904
+ }
6905
+ function conditionOf(column, name, value) {
6906
+ const operator = operatorOf2(name);
6907
+ if (operator === void 0)
6908
+ return { ok: false, reason: `has an unknown operator "${String(name)}"` };
6909
+ if (typeof value !== "string")
6910
+ return {
6911
+ ok: true,
6912
+ node: {
6913
+ operator,
6914
+ column,
6915
+ value: Array.isArray(value) ? [...value] : value
6916
+ }
6917
+ };
6918
+ return attempt2(() => {
6919
+ const parser = new Parser2(value);
6920
+ let parsed = value;
6921
+ if (["in", "contains", "containedBy", "overlaps", "is"].includes(operator)) {
6922
+ parsed = parser.value(operator);
6923
+ parser.skipSpaces();
6924
+ if (parser.at() !== "") refuse3(`has unexpected text ${parser.where()}`);
6925
+ } else if (operator === "like" || operator === "ilike")
6926
+ parsed = likePattern2(value);
6927
+ else if (operator === "eq" || operator === "neq") parsed = truth2(value);
6928
+ return { operator, column, value: parsed };
6929
+ });
6930
+ }
6931
+ function patternOf(pattern) {
6932
+ return typeof pattern === "string" ? likePattern2(pattern) : pattern;
6933
+ }
6934
+ var MAX_LIMIT = 1e3;
6935
+ var CAP = `one select reads at most ${String(MAX_LIMIT)} rows, by limit() or range()`;
6936
+ function initialState(table2) {
6937
+ return { table: table2, problem: null, filters: [], columns: "*", order: [] };
6938
+ }
6939
+ function copyRow(row) {
6940
+ return { ...row };
6941
+ }
6942
+ var Builder = class {
6943
+ run;
6944
+ state;
6945
+ constructor(run, state) {
6946
+ this.run = run;
6947
+ this.state = state;
6948
+ }
6949
+ /** A new builder of the same kind with `patch` applied. */
6950
+ next(patch) {
6951
+ const Next = this.constructor;
6952
+ return new Next(this.run, { ...this.state, ...patch });
6953
+ }
6954
+ fail(path, reason) {
6955
+ return this.failWith(invalidQuery(path, reason));
6956
+ }
6957
+ /** Keeps the first problem: it is the one the user has to fix first. */
6958
+ failWith(problem) {
6959
+ if (this.state.problem !== null) return this;
6960
+ return this.next({ problem });
6961
+ }
6962
+ /**
6963
+ * Runs the query. Every `await` sends a new request with the same AST.
6964
+ * Never rejects: failures resolve as `{ data: null, error }`.
6965
+ */
6966
+ // oxlint-disable-next-line unicorn/no-thenable -- awaitable builders are the API (spec §15)
6967
+ then(onfulfilled, onrejected) {
6968
+ return this.execute().then(onfulfilled, onrejected);
6969
+ }
6970
+ async execute() {
6971
+ if (this.state.problem !== null)
6972
+ return { data: null, error: this.state.problem };
6973
+ return await this.run(this.build());
6974
+ }
6975
+ /** A mutation's `.select()`: RETURNING these columns. */
6976
+ withReturning(columns) {
6977
+ const parsed = parseColumns(columns);
6978
+ if (!parsed.ok) return this.fail("returning", parsed.reason);
6979
+ return this.next({ returning: parsed.columns });
6980
+ }
6981
+ };
6982
+ function copyList(values) {
6983
+ return Array.isArray(values) ? [...values] : values;
6984
+ }
6985
+ var FilterBuilder = class _FilterBuilder extends Builder {
6986
+ /** One more entry in the top-level list, which is an AND. */
6987
+ add(node) {
6988
+ return this.next({ filters: [...this.state.filters, node] });
6989
+ }
6990
+ condition(operator, column, value) {
6991
+ return this.add({ operator, column, value });
6992
+ }
6993
+ eq(column, value) {
6994
+ return this.condition("eq", column, value);
6995
+ }
6996
+ neq(column, value) {
6997
+ return this.condition("neq", column, value);
6998
+ }
6999
+ gt(column, value) {
7000
+ return this.condition("gt", column, value);
7001
+ }
7002
+ gte(column, value) {
7003
+ return this.condition("gte", column, value);
7004
+ }
7005
+ lt(column, value) {
7006
+ return this.condition("lt", column, value);
7007
+ }
7008
+ lte(column, value) {
7009
+ return this.condition("lte", column, value);
7010
+ }
7011
+ /** An empty list matches nothing. */
7012
+ in(column, values) {
7013
+ return this.condition("in", column, copyList(values));
7014
+ }
7015
+ /** `IS NULL`, `IS TRUE` or `IS FALSE` — any non-zero number is true, NULL neither. */
7016
+ is(column, value) {
7017
+ return this.condition("is", column, value);
7018
+ }
7019
+ /**
7020
+ * Case-sensitive, `%` and `_` as in SQL; `*` is `%` too, as in PostgREST,
7021
+ * so a literal `*` cannot be matched.
7022
+ */
7023
+ like(column, pattern) {
7024
+ return this.condition("like", column, patternOf(pattern));
7025
+ }
7026
+ /** Case-insensitive for ASCII letters; `%`, `_` and `*` as in `like`. */
7027
+ ilike(column, pattern) {
7028
+ return this.condition("ilike", column, patternOf(pattern));
7029
+ }
7030
+ /**
7031
+ * The column — TEXT holding a JSON array — has every one of the values.
7032
+ * A row that is not a JSON array is no match; NULL is neither.
7033
+ */
7034
+ contains(column, values) {
7035
+ return this.condition("contains", column, copyList(values));
7036
+ }
7037
+ /** Every element of the column's JSON array is one of the values (`[]` is). */
7038
+ containedBy(column, values) {
7039
+ return this.condition("containedBy", column, copyList(values));
7040
+ }
7041
+ /** The column's JSON array has at least one of the values. */
7042
+ overlaps(column, values) {
7043
+ return this.condition("overlaps", column, copyList(values));
7044
+ }
7045
+ /** An `eq` for each key, in key order. */
7046
+ match(query) {
7047
+ const conditions = Object.entries(query).map(
7048
+ ([column, value]) => ({
7049
+ operator: "eq",
7050
+ column,
7051
+ value
7052
+ })
7053
+ );
7054
+ return this.next({ filters: [...this.state.filters, ...conditions] });
7055
+ }
7056
+ /**
7057
+ * Any operator by name, Supabase's escape hatch: `filter("id", "in",
7058
+ * "(1,2)")`, `filter("tags", "cs", "{a}")`, `filter("n", "not.gte", 10)`.
7059
+ * A string value is PostgREST syntax where the operator needs one, and text
7060
+ * otherwise.
7061
+ */
7062
+ filter(column, operator, value) {
7063
+ const name = operator;
7064
+ const negated = typeof name === "string" && name.startsWith("not.");
7065
+ const parsed = conditionOf(
7066
+ column,
7067
+ negated ? name.slice(4) : name,
7068
+ value
7069
+ );
7070
+ if (!parsed.ok) return this.fail("filter", parsed.reason);
7071
+ return this.add(negated ? { not: parsed.node } : parsed.node);
7072
+ }
7073
+ not(target, operator, value) {
7074
+ if (typeof target === "function") {
7075
+ const nodes = this.group("not", target);
7076
+ if (!Array.isArray(nodes)) return this.failWith(nodes);
7077
+ const [only] = nodes;
7078
+ return this.add({
7079
+ not: nodes.length === 1 && only ? only : { and: nodes }
7080
+ });
7081
+ }
7082
+ const parsed = conditionOf(target, operator, value);
7083
+ if (!parsed.ok) return this.fail("not", parsed.reason);
7084
+ return this.add({ not: parsed.node });
7085
+ }
7086
+ /**
7087
+ * Rows matching at least one of the conditions: a callback,
7088
+ * `or((q) => q.eq("status", "draft").lt("price", 10))`, or Supabase's
7089
+ * string, `or("status.eq.draft,price.lt.10")`, whose values are text.
7090
+ */
7091
+ or(filters) {
7092
+ return this.logic("or", filters);
7093
+ }
7094
+ /** Rows matching all of the conditions, as a group: for use inside `or`. */
7095
+ and(filters) {
7096
+ return this.logic("and", filters);
7097
+ }
7098
+ logic(kind, filters) {
7099
+ if (typeof filters === "function") {
7100
+ const nodes = this.group(kind, filters);
7101
+ if (!Array.isArray(nodes)) return this.failWith(nodes);
7102
+ return this.add(kind === "or" ? { or: nodes } : { and: nodes });
7103
+ }
7104
+ const parsed = parseLogic2(filters, kind);
7105
+ if (!parsed.ok) return this.fail(kind, parsed.reason);
7106
+ return this.add(parsed.node);
7107
+ }
7108
+ /**
7109
+ * The conditions `build` adds, or why there are none to use. `build` gets
7110
+ * a builder of this kind with no filters; FilterGroup shows only its filter
7111
+ * methods, and only its conditions and first problem are read back.
7112
+ */
7113
+ group(path, build) {
7114
+ const built = build(this.next({ filters: [], problem: null }));
7115
+ if (!(built instanceof _FilterBuilder))
7116
+ return invalidQuery(path, "must return the filter builder it was given");
7117
+ const { filters, problem } = built.state;
7118
+ if (problem !== null) return problem;
7119
+ if (filters.length === 0) return invalidQuery(path, "must not be empty");
7120
+ return [...filters];
7121
+ }
7122
+ filtersPart() {
7123
+ return this.state.filters.length === 0 ? {} : { filters: [...this.state.filters] };
7124
+ }
7125
+ };
7126
+ var SelectBuilder = class extends FilterBuilder {
7127
+ /** Call again to sort by more columns, in call order. */
7128
+ order(column, options = {}) {
7129
+ const term = { column, ascending: options.ascending ?? true };
7130
+ if (options.nullsFirst !== void 0) term.nullsFirst = options.nullsFirst;
7131
+ return this.next({ order: [...this.state.order, term] });
7132
+ }
7133
+ /** At most `count` rows (1–1,000; a select without one reads up to 1,000). */
7134
+ limit(count2) {
7135
+ if (!Number.isSafeInteger(count2) || count2 < 1 || count2 > MAX_LIMIT)
7136
+ return this.fail(
7137
+ "limit",
7138
+ `must be an integer from 1 to ${String(MAX_LIMIT)}; ${CAP}`
7139
+ );
7140
+ return this.next({ limit: count2 });
7141
+ }
7142
+ offset(count2) {
7143
+ return this.next({ offset: count2 });
7144
+ }
7145
+ /**
7146
+ * Rows `from` to `to`, both inclusive and counted from 0 — at most 1,000
7147
+ * of them. Sent as `offset` and `limit`, so it is checked here: past the
7148
+ * cap the worker would name `limit`, which the app never called.
7149
+ */
7150
+ range(from, to) {
7151
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 0 || to < from)
7152
+ return this.fail("range", "must be two integers with 0 <= from <= to");
7153
+ const rows = to - from + 1;
7154
+ if (rows > MAX_LIMIT)
7155
+ return this.fail(
7156
+ "range",
7157
+ `spans ${String(rows)} rows (to - from + 1); ${CAP}`
7158
+ );
7159
+ return this.next({ offset: from, limit: rows });
7160
+ }
7161
+ /** Exactly one row, or NOT_SINGLE_ROW. */
7162
+ single() {
7163
+ return this.next({ single: "one" });
7164
+ }
7165
+ /** One row or null; more than one is NOT_SINGLE_ROW. */
7166
+ maybeSingle() {
7167
+ return this.next({ single: "maybe" });
7168
+ }
7169
+ build() {
7170
+ const { table: table2, columns, order, limit, offset, single } = this.state;
7171
+ return {
7172
+ version: 1,
7173
+ action: "select",
7174
+ table: table2,
7175
+ columns: columns === "*" ? "*" : [...columns],
7176
+ ...this.filtersPart(),
7177
+ ...order.length === 0 ? {} : { order: order.map((term) => ({ ...term })) },
7178
+ ...limit === void 0 ? {} : { limit },
7179
+ ...offset === void 0 ? {} : { offset },
7180
+ ...single === void 0 ? {} : { single }
7181
+ };
7182
+ }
7183
+ };
7184
+ var InsertBuilder = class extends Builder {
7185
+ /** Return the inserted rows (all columns, or the ones named). */
7186
+ select(columns) {
7187
+ return this.withReturning(columns);
7188
+ }
7189
+ build() {
7190
+ const { table: table2, rows = [], returning } = this.state;
7191
+ return {
7192
+ version: 1,
7193
+ action: "insert",
7194
+ table: table2,
7195
+ rows: rows.map(copyRow),
7196
+ ...returning === void 0 ? {} : { returning }
7197
+ };
7198
+ }
7199
+ };
7200
+ var MutationBuilder = class extends FilterBuilder {
7201
+ /** Return the changed rows (all columns, or the ones named). */
7202
+ select(columns) {
7203
+ return this.withReturning(columns);
7204
+ }
7205
+ /** Apply to every row with no filter at all. Service key only. */
7206
+ all() {
7207
+ return this.next({ allowUnfiltered: true });
7208
+ }
7209
+ build() {
7210
+ const {
7211
+ table: table2,
7212
+ action,
7213
+ values = {},
7214
+ returning,
7215
+ allowUnfiltered
7216
+ } = this.state;
7217
+ const tail = {
7218
+ ...this.filtersPart(),
7219
+ ...returning === void 0 ? {} : { returning },
7220
+ ...allowUnfiltered === void 0 ? {} : { allowUnfiltered }
7221
+ };
7222
+ return action === "delete" ? { version: 1, action: "delete", table: table2, ...tail } : {
7223
+ version: 1,
7224
+ action: "update",
7225
+ table: table2,
7226
+ values: copyRow(values),
7227
+ ...tail
7228
+ };
7229
+ }
7230
+ };
7231
+ var TableBuilder = class {
7232
+ #run;
7233
+ #table;
7234
+ constructor(run, table2) {
7235
+ this.#run = run;
7236
+ this.#table = table2;
7237
+ }
7238
+ /** `"*"` (the default) or `"id, title"`. */
7239
+ select(columns) {
7240
+ const state = initialState(this.#table);
7241
+ const parsed = parseColumns(columns);
7242
+ return new SelectBuilder(
7243
+ this.#run,
7244
+ parsed.ok ? { ...state, columns: parsed.columns } : { ...state, problem: invalidQuery("columns", parsed.reason) }
7245
+ );
7246
+ }
7247
+ /** One row or several; one request, one transaction. */
7248
+ insert(rows) {
7249
+ const list = Array.isArray(rows) ? rows : [rows];
7250
+ return new InsertBuilder(this.#run, {
7251
+ ...initialState(this.#table),
7252
+ rows: list.map(copyRow)
7253
+ });
7254
+ }
7255
+ update(values) {
7256
+ return new MutationBuilder(this.#run, {
7257
+ ...initialState(this.#table),
7258
+ action: "update",
7259
+ values: copyRow(values)
7260
+ });
7261
+ }
7262
+ delete() {
7263
+ return new MutationBuilder(this.#run, {
7264
+ ...initialState(this.#table),
7265
+ action: "delete"
7266
+ });
7267
+ }
7268
+ };
7269
+ var MAX_SINGLE_UPLOAD_BYTES2 = 52428800;
7270
+ var STORAGE_MESSAGES = {
7271
+ bucket: "The bucket name is not valid.",
7272
+ tooLarge: "Uploads over 50 MiB are not supported yet.",
7273
+ request: "The storage request is not valid.",
7274
+ body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
7275
+ };
7276
+ var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
7277
+ "authenticated",
7278
+ "copy",
7279
+ "info",
7280
+ "list",
7281
+ "move",
7282
+ "public",
7283
+ "render",
7284
+ "sign",
7285
+ "upload"
7286
+ ]);
7287
+ var DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
7288
+ var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
7289
+ var OCTET_STREAM = "application/octet-stream";
7290
+ var UTF8_TEXT = "text/plain;charset=utf-8";
7291
+ function invalidPath(reason) {
7292
+ return {
7293
+ code: "INVALID_PATH",
7294
+ message: `The object path is not valid: it ${reason}.`
7295
+ };
7296
+ }
7297
+ function objectTooLarge() {
7298
+ return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
7299
+ }
7300
+ function encodePath(path) {
7301
+ if (typeof path !== "string" || path === "")
7302
+ return { ok: false, error: invalidPath("is empty") };
7303
+ if (LONE_SURROGATE.test(path))
7304
+ return { ok: false, error: invalidPath("is not valid UTF-8") };
7305
+ if (path.startsWith("/") || path.endsWith("/"))
7306
+ return { ok: false, error: invalidPath("starts or ends with /") };
7307
+ if (path.includes("//"))
7308
+ return { ok: false, error: invalidPath("has an empty segment (//)") };
7309
+ const segments2 = path.split("/");
7310
+ if (segments2.some((segment) => segment === "." || segment === ".."))
7311
+ return { ok: false, error: invalidPath("has a . or .. segment") };
7312
+ return { ok: true, encoded: segments2.map(encodeURIComponent).join("/") };
7313
+ }
7314
+ function downloadQuery(download2) {
7315
+ if (download2 === true) return { ok: true, encoded: "?download" };
7316
+ if (typeof download2 !== "string") return { ok: true, encoded: "" };
7317
+ if (LONE_SURROGATE.test(download2))
7318
+ return {
7319
+ ok: false,
7320
+ error: {
7321
+ code: "INVALID_REQUEST",
7322
+ message: STORAGE_MESSAGES.request,
7323
+ details: { path: "download", reason: DOWNLOAD_NAME_REASON }
7324
+ }
7325
+ };
7326
+ return { ok: true, encoded: `?download=${encodeURIComponent(download2)}` };
7327
+ }
7328
+ function encodeBucket(bucket) {
7329
+ if (typeof bucket !== "string" || bucket === "" || bucket === "." || bucket === ".." || LONE_SURROGATE.test(bucket) || RESERVED_BUCKETS.has(bucket))
7330
+ return {
7331
+ ok: false,
7332
+ error: { code: "INVALID_PATH", message: STORAGE_MESSAGES.bucket }
7333
+ };
7334
+ return { ok: true, encoded: encodeURIComponent(bucket) };
7335
+ }
7336
+ var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
7337
+ function isDetached(buffer) {
7338
+ if (!isArrayBuffer(buffer)) return false;
7339
+ const flag = buffer.detached;
7340
+ if (typeof flag === "boolean") return flag;
7341
+ if (buffer.byteLength > 0) return false;
7342
+ try {
7343
+ buffer.slice(0);
7344
+ return false;
7345
+ } catch {
7346
+ return true;
7347
+ }
7348
+ }
7349
+ var CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
7350
+ var MAX_TYPE_IN_MESSAGE = 256;
7351
+ function unsendable(value) {
7352
+ return /[\0\r\n]|[^\0-\xff]/.test(
7353
+ value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "")
7354
+ );
7355
+ }
7356
+ function headerProblem(contentType, cacheControl) {
7357
+ if (typeof contentType === "string" && unsendable(contentType)) {
7358
+ let shown = contentType;
7359
+ if (shown.length > MAX_TYPE_IN_MESSAGE) {
7360
+ shown = shown.slice(0, MAX_TYPE_IN_MESSAGE);
7361
+ if (/[\ud800-\udbff]$/.test(shown)) shown = shown.slice(0, -1);
7362
+ }
7363
+ return {
7364
+ code: "INVALID_CONTENT_TYPE",
7365
+ message: `Content-Type ${shown} is not a media type like image/png.`
7366
+ };
7367
+ }
7368
+ if (unsendable(cacheControl))
7369
+ return {
7370
+ code: "INVALID_REQUEST",
7371
+ message: STORAGE_MESSAGES.request,
7372
+ details: { path: "cache-control", reason: CACHE_CONTROL_REASON }
7373
+ };
7374
+ return null;
7375
+ }
7376
+ function payloadOf(body, contentType) {
7377
+ if (typeof body === "string") {
7378
+ const bytes = new TextEncoder().encode(body);
7379
+ return {
7380
+ ok: true,
7381
+ body: bytes,
7382
+ size: bytes.byteLength,
7383
+ type: contentType ?? UTF8_TEXT
7384
+ };
7385
+ }
7386
+ if (typeof Blob === "function" && body instanceof Blob)
7387
+ return {
7388
+ ok: true,
7389
+ body,
7390
+ size: body.size,
7391
+ type: contentType ?? (body.type === "" ? OCTET_STREAM : body.type)
7392
+ };
7393
+ if (isArrayBuffer(body) && !isDetached(body))
7394
+ return {
7395
+ ok: true,
7396
+ body,
7397
+ size: body.byteLength,
7398
+ type: contentType ?? OCTET_STREAM
7399
+ };
7400
+ if (ArrayBuffer.isView(body) && !isDetached(body.buffer)) {
7401
+ const { buffer, byteOffset, byteLength } = body;
7402
+ const bytes = isArrayBuffer(buffer) ? new Uint8Array(buffer, byteOffset, byteLength) : Uint8Array.from(new Uint8Array(buffer, byteOffset, byteLength));
7403
+ return {
7404
+ ok: true,
7405
+ body: bytes,
7406
+ size: byteLength,
7407
+ type: contentType ?? OCTET_STREAM
7408
+ };
7409
+ }
7410
+ return {
7411
+ ok: false,
7412
+ error: { code: "INVALID_BODY", message: STORAGE_MESSAGES.body }
7413
+ };
7414
+ }
7415
+ var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
7416
+ function fail(error) {
7417
+ return { data: null, error };
7418
+ }
7419
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7420
+ var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
7421
+ var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
7422
+ var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
7423
+ function listOf(guard) {
7424
+ return (value) => Array.isArray(value) && value.every(guard);
7425
+ }
7426
+ function dataOf2(status, body, guard) {
7427
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7428
+ return { data: body.data, error: null };
7429
+ return fail(invalidResponse(status));
7430
+ }
7431
+ async function readJson(response, guard) {
7432
+ let text;
7433
+ try {
7434
+ text = await response.text();
7435
+ } catch (error) {
7436
+ return fail(networkError(error));
7437
+ }
7438
+ let body;
7439
+ try {
7440
+ body = JSON.parse(text);
7441
+ } catch {
7442
+ return fail(invalidResponse(response.status));
7443
+ }
7444
+ return dataOf2(response.status, body, guard);
7445
+ }
7446
+ var StorageFileApi = class {
7447
+ #http;
7448
+ #auth;
7449
+ #bucket;
7450
+ constructor(http, auth, bucket) {
7451
+ this.#http = http;
7452
+ this.#auth = auth;
7453
+ this.#bucket = bucket;
7454
+ }
7455
+ /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
7456
+ #objectPath(route2, path) {
7457
+ const bucket = encodeBucket(this.#bucket);
7458
+ if (!bucket.ok) return bucket;
7459
+ const name = encodePath(path);
7460
+ if (!name.ok) return name;
7461
+ return {
7462
+ ok: true,
7463
+ path: `/storage/object/${route2}${bucket.encoded}/${name.encoded}`
7464
+ };
7465
+ }
7466
+ /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
7467
+ #bucketPath(route2) {
7468
+ const bucket = encodeBucket(this.#bucket);
7469
+ if (!bucket.ok) return bucket;
7470
+ return { ok: true, path: `/storage/object/${route2}${bucket.encoded}` };
7471
+ }
7472
+ async #json(request, guard) {
7473
+ await this.#auth.ready();
7474
+ const result = await call(this.#http, { ...request, withToken: true });
7475
+ if (result.error !== null) return fail(result.error);
7476
+ return dataOf2(result.answer.status, result.answer.body, guard);
7477
+ }
7478
+ async #write(method, path, body, options, upsert) {
7479
+ const target = this.#objectPath("", path);
7480
+ if (!target.ok) return fail(target.error);
7481
+ const payload = payloadOf(body, options.contentType);
7482
+ if (!payload.ok) return fail(payload.error);
7483
+ const cache = `max-age=${options.cacheControl ?? "3600"}`;
7484
+ const unreadable = headerProblem(options.contentType, cache);
7485
+ if (unreadable !== null) return fail(unreadable);
7486
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES2) return fail(objectTooLarge());
7487
+ await this.#auth.ready();
7488
+ const headers = {
7489
+ "content-type": payload.type,
7490
+ "cache-control": cache
7491
+ };
7492
+ if (upsert) headers["x-upsert"] = "true";
7493
+ const sent = await send(this.#http, {
7494
+ method,
7495
+ path: target.path,
7496
+ headers,
7497
+ body: payload.body,
7498
+ withToken: true
7499
+ });
7500
+ if (sent.error !== null) return fail(sent.error);
7501
+ return readJson(sent.response, isUploadResult);
7502
+ }
7503
+ /**
7504
+ * A new object; with `upsert: true`, one of the same name is replaced.
7505
+ * `null` options, as a JS caller may pass, are no options.
7506
+ */
7507
+ upload(path, body, options = {}) {
7508
+ const given = options ?? {};
7509
+ return this.#write("POST", path, body, given, given.upsert === true);
7510
+ }
7511
+ /** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
7512
+ update(path, body, options = {}) {
7513
+ return this.#write("PUT", path, body, options ?? {}, false);
7514
+ }
7515
+ /**
7516
+ * The object's bytes, as a Blob of the type the worker serves it under:
7517
+ * the stored type for the images, audio, video, PDF, plain text, CSV,
7518
+ * JSON and SVG it serves as themselves, `application/octet-stream` for
7519
+ * anything else (HTML, scripts, archives, unknown types — `info` and
7520
+ * `list` hold the stored type).
7521
+ */
7522
+ async download(path) {
7523
+ const target = this.#objectPath("", path);
7524
+ if (!target.ok) return fail(target.error);
7525
+ await this.#auth.ready();
7526
+ const sent = await send(this.#http, {
7527
+ method: "GET",
7528
+ path: target.path,
7529
+ withToken: true
7530
+ });
7531
+ if (sent.error !== null) return fail(sent.error);
7532
+ try {
7533
+ return { data: await sent.response.blob(), error: null };
7534
+ } catch (error) {
7535
+ return fail(networkError(error));
7536
+ }
7537
+ }
7538
+ async info(path) {
7539
+ const target = this.#objectPath("info/", path);
7540
+ if (!target.ok) return fail(target.error);
7541
+ return this.#json({ method: "GET", path: target.path }, isFileObject);
7542
+ }
7543
+ /**
7544
+ * Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
7545
+ * every other error comes back as it is (plan C1 — asked with `info`,
7546
+ * not HEAD, whose answer has no body to tell a missing bucket from a
7547
+ * missing object).
7548
+ */
7549
+ async exists(path) {
7550
+ const found = await this.info(path);
7551
+ if (found.error === null) return { data: true, error: null };
7552
+ if (found.error.code === "OBJECT_NOT_FOUND")
7553
+ return { data: false, error: null };
7554
+ return fail(found.error);
7555
+ }
7556
+ /**
7557
+ * What is directly below `prefix` (the whole bucket by default): folders
7558
+ * first, then files. The options go to the worker as they are given.
7559
+ */
7560
+ async list(prefix, options = {}) {
7561
+ const target = this.#bucketPath("list/");
7562
+ if (!target.ok) return fail(target.error);
7563
+ return this.#json(
7564
+ {
7565
+ method: "POST",
7566
+ path: target.path,
7567
+ body: { ...prefix === void 0 ? {} : { prefix }, ...options }
7568
+ },
7569
+ listOf(isEntry2)
7570
+ );
7571
+ }
7572
+ /** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
7573
+ async remove(paths) {
7574
+ const target = this.#bucketPath("");
7575
+ if (!target.ok) return fail(target.error);
7576
+ return this.#json(
7577
+ { method: "DELETE", path: target.path, body: { prefixes: paths } },
7578
+ listOf(isFileObject)
7579
+ );
7580
+ }
7581
+ /**
7582
+ * A link to the object for `expiresIn` seconds (1–604,800). It stops
7583
+ * working once the object is replaced or removed.
7584
+ */
7585
+ async createSignedUrl(path, expiresIn, options = {}) {
7586
+ const target = this.#objectPath("sign/", path);
7587
+ if (!target.ok) return fail(target.error);
7588
+ const { download: download2 } = options ?? {};
7589
+ const signed = await this.#json(
7590
+ {
7591
+ method: "POST",
7592
+ path: target.path,
7593
+ body: {
7594
+ expiresIn,
7595
+ ...download2 === void 0 || download2 === false ? {} : { download: download2 }
7596
+ }
7597
+ },
7598
+ isSigned
7599
+ );
7600
+ if (signed.error !== null) return signed;
7601
+ return {
7602
+ data: { signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}` },
7603
+ error: null
7604
+ };
7605
+ }
7606
+ /**
7607
+ * A public bucket's URL for the object. No request is made, so whether
7608
+ * the bucket is public is not checked (as in supabase-js); a path no URL
7609
+ * can carry is INVALID_PATH (plan C3), and a download name none can carry
7610
+ * is the worker's INVALID_REQUEST for it.
7611
+ */
7612
+ getPublicUrl(path, options = {}) {
7613
+ const target = this.#objectPath("public/", path);
7614
+ if (!target.ok) return fail(target.error);
7615
+ const { download: download2 } = options ?? {};
7616
+ const query = downloadQuery(download2);
7617
+ if (!query.ok) return fail(query.error);
7618
+ return {
7619
+ data: {
7620
+ publicUrl: `${this.#http.baseUrl}/_naro/v1${target.path}${query.encoded}`
7621
+ },
7622
+ error: null
7623
+ };
7624
+ }
7625
+ };
7626
+ var StorageClient = class {
7627
+ #http;
7628
+ #auth;
7629
+ constructor(http, auth) {
7630
+ this.#http = http;
7631
+ this.#auth = auth;
7632
+ }
7633
+ /** The objects of one bucket. */
7634
+ from(bucket) {
7635
+ return new StorageFileApi(this.#http, this.#auth, bucket);
7636
+ }
7637
+ };
7638
+ var CONFIG_MESSAGES = {
7639
+ url: "createClient needs the project url, like https://my-app.naro.sh.",
7640
+ urlNotOrigin: "createClient needs the project url alone, like https://my-app.naro.sh: no path, no ? or #, no user:password@. Naro serves its API at /_naro/v1 on that origin.",
7641
+ key: "createClient needs an anonKey (or, on a server, a serviceKey).",
7642
+ bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
7643
+ serviceKeyOffServer: "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.",
7644
+ fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
7645
+ };
7646
+ var SERVICE_KEY_PREFIX = "naro_sk_";
7647
+ function baseUrlOf(url) {
7648
+ if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
7649
+ const trimmed = url.trim();
7650
+ let parsed;
7651
+ try {
7652
+ parsed = new URL(trimmed.replace(/\/+$/, ""));
7653
+ } catch {
7654
+ throw new Error(CONFIG_MESSAGES.url);
7655
+ }
7656
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
7657
+ throw new Error(CONFIG_MESSAGES.url);
7658
+ if (trimmed.includes("?") || trimmed.includes("#") || parsed.pathname !== "/" || parsed.username !== "" || parsed.password !== "")
7659
+ throw new Error(CONFIG_MESSAGES.urlNotOrigin);
7660
+ return parsed.origin;
7661
+ }
7662
+ function apiKeyOf(options) {
7663
+ const { anonKey, serviceKey } = options;
7664
+ const hasAnon = typeof anonKey === "string" && anonKey !== "";
7665
+ const hasService = typeof serviceKey === "string" && serviceKey !== "";
7666
+ if (hasAnon && hasService) throw new Error(CONFIG_MESSAGES.bothKeys);
7667
+ const key = hasService ? serviceKey : hasAnon ? anonKey : void 0;
7668
+ if (key === void 0) throw new Error(CONFIG_MESSAGES.key);
7669
+ const service = hasService || key.startsWith(SERVICE_KEY_PREFIX);
7670
+ if (service && !isKnownServer())
7671
+ throw new Error(CONFIG_MESSAGES.serviceKeyOffServer);
7672
+ return { key, service };
7673
+ }
7674
+ function queryRunner(http, auth) {
7675
+ return async (query) => {
7676
+ await auth.ready();
7677
+ const result = await call(http, {
7678
+ method: "POST",
7679
+ path: "/data/query",
7680
+ body: query,
7681
+ withToken: true
7682
+ });
7683
+ if (result.error !== null) return { data: null, error: result.error };
7684
+ const { body, status } = result.answer;
7685
+ if (!isRecord(body) || !Object.hasOwn(body, "data"))
7686
+ return { data: null, error: invalidResponse(status) };
7687
+ return { data: body.data, error: null };
7688
+ };
7689
+ }
7690
+ var NaroClient2 = class {
7691
+ /**
7692
+ * Sign-up, sign-in, sign-out and the current user (spec §13); with the
7693
+ * service key, `auth.admin` manages users.
7694
+ */
7695
+ auth;
7696
+ /**
7697
+ * Files in the project's Naro Storage (first-release design 2026-10-06
7698
+ * §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
7699
+ * them, as supabase-js does.
7700
+ */
7701
+ storage;
7702
+ #run;
7703
+ constructor(options) {
7704
+ const baseUrl = baseUrlOf(options.url);
7705
+ const { key: apiKey, service } = apiKeyOf(options);
7706
+ if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
7707
+ throw new Error(CONFIG_MESSAGES.fetch);
7708
+ const storage = options.auth?.storage ?? defaultStorage();
7709
+ const http = {
7710
+ baseUrl,
7711
+ apiKey,
7712
+ fetch: options.fetch,
7713
+ tokens: new TokenStore(storage, storageKey(baseUrl))
7714
+ };
7715
+ this.auth = new AuthClient(http, {
7716
+ verifiers: new FlowVerifiers(
7717
+ new TokenStore(storage, verifierKey(baseUrl))
7718
+ ),
7719
+ recovery: new RecoveryToken(
7720
+ new TokenStore(storage, recoveryKey(baseUrl))
7721
+ ),
7722
+ detectSessionInUrl: options.auth?.detectSessionInUrl ?? true,
7723
+ shared: { storage, key: storageKey(baseUrl) },
7724
+ serviceKey: service
7725
+ });
7726
+ this.#run = queryRunner(http, this.auth);
7727
+ this.storage = new StorageClient(http, this.auth);
7728
+ }
7729
+ /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
7730
+ from(table2) {
7731
+ return new TableBuilder(this.#run, table2);
7732
+ }
7733
+ };
7734
+ function createClient(options) {
7735
+ return new NaroClient2(options);
7736
+ }
7737
+
7738
+ // src/commands/storage-objects.ts
7739
+ var NARO_SCHEME = "naro://";
7740
+ var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<path>, the other a local file)";
7741
+ var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
7742
+ var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]";
7743
+ var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
7744
+ var PAGE = 1e3;
7745
+ var MAX_OFFSET = 1e5;
7746
+ var TRUNCATED_WARNING = `\u26A0 A folder's listing stopped at ${(MAX_OFFSET + PAGE).toLocaleString("en-US")} entries, as far as the API's offset reaches; there may be more.`;
7747
+ var REMOVE_BATCH = 1e3;
7748
+ var CACHE_CONTROL_RULE = "--cache-control takes whole seconds from 0 to 31536000 (a year), like 3600.";
7749
+ function isMaxAge(text) {
7750
+ return parseCacheControl(`max-age=${text}`).ok;
7751
+ }
7752
+ var TYPES = {
7753
+ ".png": "image/png",
7754
+ ".jpg": "image/jpeg",
7755
+ ".jpeg": "image/jpeg",
7756
+ ".gif": "image/gif",
7757
+ ".webp": "image/webp",
7758
+ ".avif": "image/avif",
7759
+ ".svg": "image/svg+xml",
7760
+ ".ico": "image/x-icon",
7761
+ ".bmp": "image/bmp",
7762
+ ".pdf": "application/pdf",
7763
+ ".json": "application/json",
7764
+ ".txt": "text/plain",
7765
+ ".csv": "text/csv",
7766
+ ".md": "text/markdown",
7767
+ ".html": "text/html",
7768
+ ".htm": "text/html",
7769
+ ".css": "text/css",
7770
+ ".js": "text/javascript",
7771
+ ".mjs": "text/javascript",
7772
+ ".xml": "application/xml",
7773
+ ".zip": "application/zip",
7774
+ ".gz": "application/gzip",
7775
+ ".mp3": "audio/mpeg",
7776
+ ".wav": "audio/wav",
7777
+ ".ogg": "audio/ogg",
7778
+ ".m4a": "audio/mp4",
7779
+ ".mp4": "video/mp4",
7780
+ ".webm": "video/webm",
7781
+ ".mov": "video/quicktime",
7782
+ ".woff": "font/woff",
7783
+ ".woff2": "font/woff2",
7784
+ ".ttf": "font/ttf"
7785
+ };
7786
+ function parseStorageUrl(text) {
7787
+ if (!text.startsWith(NARO_SCHEME)) return null;
7788
+ const rest = text.slice(NARO_SCHEME.length);
7789
+ const slash = rest.indexOf("/");
7790
+ const bucket = slash < 0 ? rest : rest.slice(0, slash);
7791
+ if (bucket === "")
7792
+ throw new NaroError(
7793
+ "VALIDATION_FAILED",
7794
+ "A naro:// path needs a bucket: naro://<bucket>/<path>."
7795
+ );
7796
+ return { bucket, path: slash < 0 ? "" : rest.slice(slash + 1) };
7797
+ }
7798
+ function typeFor(file) {
7799
+ return TYPES[extname(file).toLowerCase()];
7800
+ }
7801
+ function unwrap(result) {
7802
+ if (result.error === null) return result.data;
7803
+ const { code, message, details } = result.error;
7804
+ let shown = typeof details === "object" && details !== null && !Array.isArray(details) ? details : void 0;
7805
+ if (shown && code === "NETWORK_ERROR") {
7806
+ const { cause: _cause, ...rest } = shown;
7807
+ shown = Object.keys(rest).length > 0 ? rest : void 0;
7808
+ }
7809
+ throw new NaroError(code, message, shown ? { details: shown } : {});
7810
+ }
7811
+ var ONE_KEY = /^[\x21-\x7e]+$/;
7812
+ async function objects(context) {
7813
+ const client = await context.client();
7814
+ const projectId = await context.resolveProjectId();
7815
+ const backend = await client.backend.info(projectId);
7816
+ if (backend.status === "none")
7817
+ throw new NaroError(
7818
+ "BACKEND_NOT_READY",
7819
+ "This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
7820
+ );
7821
+ if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
7822
+ let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
7823
+ if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
7824
+ throw new NaroError(
7825
+ "VALIDATION_FAILED",
7826
+ "NARO_SERVICE_KEY is not a single key: set it to the service key alone."
7827
+ );
7828
+ if (serviceKey === "") {
7829
+ serviceKey = await client.backend.revealServiceKey(projectId);
7830
+ context.output.warn(SERVICE_KEY_WARNING);
7831
+ }
7832
+ let naro;
7833
+ try {
7834
+ naro = createClient({
7835
+ url: backend.url,
7836
+ serviceKey,
7837
+ auth: { detectSessionInUrl: false }
7838
+ });
7839
+ } catch (error) {
7840
+ throw new NaroError(
7841
+ "BACKEND_NOT_READY",
7842
+ `The project's backend url cannot be used (${printable(backend.url)}). Run \`naro backend\` to see where the backend got to.`,
7843
+ { cause: error }
7844
+ );
7845
+ }
7846
+ return { projectId, storage: naro.storage };
7847
+ }
7848
+ function isFolder(entry) {
7849
+ return entry.id === null;
7850
+ }
7851
+ function fileEntry(path, object) {
7852
+ const { size, mimetype } = object.metadata;
7853
+ return {
7854
+ path,
7855
+ kind: "file",
7856
+ size: typeof size === "number" ? size : null,
7857
+ contentType: typeof mimetype === "string" ? mimetype : null,
7858
+ updatedAt: object.updated_at
7859
+ };
7860
+ }
7861
+ async function listAll(storage, bucket, prefix, recursive) {
7862
+ const entries = [];
7863
+ let truncated = false;
7864
+ const folders = [prefix];
7865
+ for (let folder = folders.shift(); folder !== void 0; folder = folders.shift()) {
7866
+ for (let offset = 0; ; offset += PAGE) {
7867
+ if (offset > MAX_OFFSET) {
7868
+ truncated = true;
7869
+ break;
7870
+ }
7871
+ const page = unwrap(
7872
+ await storage.from(bucket).list(folder, { limit: PAGE, offset })
7873
+ );
7874
+ for (const entry of page) {
7875
+ const path = folder === "" ? entry.name : `${folder}/${entry.name}`;
7876
+ if (isFolder(entry)) {
7877
+ if (recursive) folders.push(path);
7878
+ else
7879
+ entries.push({
7880
+ path: `${path}/`,
7881
+ kind: "folder",
7882
+ size: null,
7883
+ contentType: null,
7884
+ updatedAt: null
7885
+ });
7886
+ continue;
7887
+ }
7888
+ entries.push(fileEntry(path, entry));
7889
+ }
7890
+ if (page.length < PAGE) break;
4661
7891
  }
4662
- await sleep(FOLLOW_INTERVAL_MS);
4663
7892
  }
7893
+ return { entries, truncated };
4664
7894
  }
4665
- async function runLogs(context) {
4666
- const client = await context.client();
4667
- const [deploymentId] = context.positionals;
4668
- const type = context.flags.type ?? (deploymentId !== void 0 ? "build" : "runtime");
4669
- if (type !== "build" && type !== "runtime")
7895
+ function entryLine(bucket, entry) {
7896
+ const where = `${NARO_SCHEME}${bucket}/${printable(entry.path)}`;
7897
+ if (entry.kind === "folder") return `${" ".repeat(38)}${where}`;
7898
+ return `${(entry.size === null ? "?" : binarySize(entry.size)).padStart(10)} ${printable(entry.updatedAt ?? "")} ${where}`;
7899
+ }
7900
+ async function storageLs(context) {
7901
+ const [target, ...extra] = context.positionals;
7902
+ if (extra.length > 0) throw new NaroError("VALIDATION_FAILED", LS_USAGE);
7903
+ if (target === void 0) {
7904
+ const client = await context.client();
7905
+ const projectId2 = await context.resolveProjectId();
7906
+ const buckets = await client.storage.listBuckets(projectId2);
7907
+ context.output.result(
7908
+ { projectId: projectId2, buckets },
7909
+ buckets.length === 0 ? [NO_BUCKETS] : buckets.map((bucket) => `${NARO_SCHEME}${printable(bucket.name)}/`)
7910
+ );
7911
+ return;
7912
+ }
7913
+ const url = parseStorageUrl(target);
7914
+ if (url === null)
4670
7915
  throw new NaroError(
4671
7916
  "VALIDATION_FAILED",
4672
- "--type must be build or runtime."
7917
+ `${printable(target)} is not a naro:// path. ${LS_USAGE}`
4673
7918
  );
4674
- if (type === "runtime") {
4675
- if (deploymentId)
4676
- throw new NaroError(
4677
- "VALIDATION_FAILED",
4678
- "Runtime logs are per project, not per deployment. Drop the deployment id or use --type build."
4679
- );
4680
- const projectId = await context.resolveProjectId();
4681
- await runtimeLogs(context, client, projectId);
4682
- return;
7919
+ const prefix = url.path.replace(/\/+$/, "");
7920
+ const { projectId, storage } = await objects(context);
7921
+ const { entries, truncated } = await listAll(
7922
+ storage,
7923
+ url.bucket,
7924
+ prefix,
7925
+ context.flags.recursive === true
7926
+ );
7927
+ if (truncated) context.output.warn(TRUNCATED_WARNING);
7928
+ if (entries.length === 0 && prefix !== "" && !url.path.endsWith("/")) {
7929
+ const found = await storage.from(url.bucket).info(prefix);
7930
+ if (found.error === null) entries.push(fileEntry(prefix, found.data));
7931
+ else if (found.error.code !== "OBJECT_NOT_FOUND") unwrap(found);
4683
7932
  }
4684
- const target = deploymentId ?? await latestProductionDeploymentId(
4685
- client,
4686
- await context.resolveProjectId()
7933
+ context.output.result(
7934
+ { projectId, bucket: url.bucket, prefix, entries, truncated },
7935
+ entries.length === 0 ? [`Nothing under ${NARO_SCHEME}${url.bucket}/${printable(prefix)}.`] : entries.map((entry) => entryLine(url.bucket, entry))
4687
7936
  );
4688
- await buildLogs(context, client, target);
4689
7937
  }
4690
-
4691
- // src/commands/projects.ts
4692
- function productionUrl(subdomain) {
4693
- return `https://${subdomain}.${new URL(controlPlaneUrl()).hostname}`;
7938
+ var INTERRUPTS = ["SIGINT", "SIGTERM", "SIGHUP"];
7939
+ function removeOnInterrupt(partial) {
7940
+ const stop = () => {
7941
+ for (const signal of INTERRUPTS) process.off(signal, onSignal);
7942
+ };
7943
+ function onSignal(signal) {
7944
+ stop();
7945
+ try {
7946
+ rmSync2(partial, { force: true });
7947
+ } catch {
7948
+ }
7949
+ try {
7950
+ process.kill(process.pid, signal);
7951
+ } catch {
7952
+ process.exit(1);
7953
+ }
7954
+ }
7955
+ for (const signal of INTERRUPTS) process.on(signal, onSignal);
7956
+ return stop;
4694
7957
  }
4695
- async function runProjects(context) {
4696
- const client = await context.client();
4697
- const teams = await client.teams.list();
4698
- const team = await resolveTeam(context, teams, { intent: "read" });
4699
- const projects = await client.projects.list(team.slug);
7958
+ async function writeAtomically(blob, destination) {
7959
+ const partial = join6(dirname3(destination), `.${randomUUID()}.naro-partial`);
7960
+ const stopListening = removeOnInterrupt(partial);
7961
+ try {
7962
+ await pipeline2(
7963
+ Readable.fromWeb(blob.stream()),
7964
+ createWriteStream2(partial, { flags: "wx" })
7965
+ );
7966
+ await rename(partial, destination);
7967
+ } catch (error) {
7968
+ await rm(partial, { force: true }).catch(() => void 0);
7969
+ throw error;
7970
+ } finally {
7971
+ stopListening();
7972
+ }
7973
+ }
7974
+ async function upload(context, local, target) {
7975
+ const file = resolve5(context.cwd, local);
7976
+ const info = await stat(file).catch(() => null);
7977
+ if (info === null)
7978
+ throw new NaroError(
7979
+ "VALIDATION_FAILED",
7980
+ `No such file: ${printable(local)}.`
7981
+ );
7982
+ if (info.isDirectory())
7983
+ throw new NaroError(
7984
+ "VALIDATION_FAILED",
7985
+ `${printable(local)} is a directory. naro storage cp copies one file at a time.`
7986
+ );
7987
+ if (!info.isFile())
7988
+ throw new NaroError(
7989
+ "VALIDATION_FAILED",
7990
+ `${printable(local)} is not a regular file.`
7991
+ );
7992
+ if (info.size > MAX_SINGLE_UPLOAD_BYTES)
7993
+ throw new NaroError(
7994
+ "OBJECT_TOO_LARGE",
7995
+ dataError("OBJECT_TOO_LARGE", { single: true }).message
7996
+ );
7997
+ const { cacheControl } = context.flags;
7998
+ if (cacheControl !== void 0 && !isMaxAge(cacheControl))
7999
+ throw new NaroError("VALIDATION_FAILED", CACHE_CONTROL_RULE);
8000
+ const path = target.path === "" || target.path.endsWith("/") ? `${target.path}${basename2(file)}` : target.path;
8001
+ const { projectId, storage } = await objects(context);
8002
+ const body = await openAsBlob(file);
8003
+ const contentType = context.flags.contentType ?? typeFor(file);
8004
+ const result = unwrap(
8005
+ await storage.from(target.bucket).upload(path, body, {
8006
+ ...contentType === void 0 ? {} : { contentType },
8007
+ ...cacheControl === void 0 ? {} : { cacheControl },
8008
+ upsert: context.flags.upsert === true
8009
+ })
8010
+ );
8011
+ const to = `${NARO_SCHEME}${target.bucket}/${path}`;
4700
8012
  context.output.result(
4701
- {
4702
- team: team.slug,
4703
- projects: projects.map((project) => ({
4704
- id: project.id,
4705
- name: project.name,
4706
- subdomain: project.subdomain,
4707
- url: productionUrl(project.subdomain),
4708
- defaultBranch: project.defaultBranch,
4709
- updatedAt: project.updatedAt
4710
- }))
4711
- },
8013
+ { projectId, from: local, to, bytes: body.size, id: result.id },
4712
8014
  [
4713
- `Projects in ${team.slug} (${String(projects.length)}):`,
4714
- ...projects.map(
4715
- (project) => ` ${project.name.padEnd(24)} ${productionUrl(project.subdomain).padEnd(40)} ${project.id}`
4716
- )
8015
+ `\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
4717
8016
  ]
4718
8017
  );
4719
8018
  }
4720
- async function runProjectInspect(context) {
4721
- const client = await context.client();
4722
- const projectId = await context.resolveProjectId();
4723
- const project = await client.projects.get(projectId);
4724
- const url = productionUrl(project.subdomain);
4725
- context.output.result({ project: { ...project, url } }, [
4726
- `${project.name}`,
4727
- ` id: ${project.id}`,
4728
- ` team: ${project.teamId}`,
4729
- ` production URL: ${url}`,
4730
- ` preview URL: https://${project.subdomain}-preview.${new URL(controlPlaneUrl()).hostname}`,
4731
- ` default branch: ${project.defaultBranch}`,
4732
- ` root directory: ${project.rootDirectory ?? "(repo root)"}`,
4733
- ` repository: ${project.repositoryFullName ?? project.repositoryUrl}`
4734
- ]);
8019
+ var WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
8020
+ var MAX_FILE_NAME_BYTES = 255;
8021
+ function savedAs(path) {
8022
+ const name = path.slice(path.lastIndexOf("/") + 1);
8023
+ if (name === "." || name === ".." || name.includes("\\") || name.includes(":") || name.includes("\0") || WINDOWS_DEVICE.test(name) || Buffer.byteLength(name, "utf8") > MAX_FILE_NAME_BYTES)
8024
+ throw new NaroError(
8025
+ "VALIDATION_FAILED",
8026
+ `The object's name (${printable(name)}) cannot be used as a file name. Name the file: naro storage cp naro://<bucket>/<path> <file>.`
8027
+ );
8028
+ return name;
4735
8029
  }
4736
-
4737
- // src/commands/promote.ts
4738
- async function promoteAndWait(context, client, project, sourceDeploymentId, action) {
4739
- const promoted = await client.deployments.promote(
4740
- project.id,
4741
- sourceDeploymentId
4742
- );
4743
- context.output.log(
4744
- `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}`
4745
- );
4746
- const detail = await client.deployments.wait(promoted.id, {
4747
- timeoutMs: 5 * 6e4
4748
- });
4749
- if (detail.deployment.status !== "READY") {
8030
+ async function download(context, source, local) {
8031
+ if (source.path === "" || source.path.endsWith("/"))
4750
8032
  throw new NaroError(
4751
- detail.deployment.errorCode ?? "DEPLOY_FAILED",
4752
- detail.deployment.errorMessage ?? `The ${action} failed.`,
4753
- { details: { deploymentId: promoted.id } }
8033
+ "VALIDATION_FAILED",
8034
+ "naro storage cp downloads one object: name it, naro://<bucket>/<path>."
4754
8035
  );
4755
- }
8036
+ const resolved = resolve5(context.cwd, local);
8037
+ const isDirectory = local.endsWith("/") || (await stat(resolved).catch(() => null))?.isDirectory() === true;
8038
+ const destination = isDirectory ? join6(resolved, savedAs(source.path)) : resolved;
8039
+ const folder = await stat(dirname3(destination)).catch(() => null);
8040
+ if (folder === null || !folder.isDirectory())
8041
+ throw new NaroError(
8042
+ "VALIDATION_FAILED",
8043
+ `Cannot save to ${printable(local)}: its folder does not exist.`
8044
+ );
8045
+ const { projectId, storage } = await objects(context);
8046
+ const blob = unwrap(await storage.from(source.bucket).download(source.path));
8047
+ await writeAtomically(blob, destination);
8048
+ const from = `${NARO_SCHEME}${source.bucket}/${source.path}`;
4756
8049
  context.output.result(
4757
- {
4758
- deploymentId: promoted.id,
4759
- sourceDeploymentId,
4760
- projectId: project.id,
4761
- status: detail.deployment.status,
4762
- url: detail.deployment.url
4763
- },
8050
+ { projectId, from, to: destination, bytes: blob.size },
4764
8051
  [
4765
- `\u2705 Production now serves ${sourceDeploymentId}`,
4766
- "",
4767
- detail.deployment.url ?? ""
8052
+ `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(blob.size)})`
4768
8053
  ]
4769
8054
  );
4770
8055
  }
4771
- async function runPromote(context) {
4772
- const [deploymentId] = context.positionalOrThrow(
4773
- 1,
4774
- "Usage: naro promote <deployment-id>"
4775
- );
4776
- const client = await context.client();
4777
- const projectId = await context.resolveProjectId();
4778
- const project = await client.projects.get(projectId);
4779
- await confirmOrAbort(
4780
- context,
4781
- `Promote ${String(deploymentId)} to production (${project.subdomain})?`
4782
- );
4783
- await promoteAndWait(
4784
- context,
4785
- client,
4786
- project,
4787
- deploymentId,
4788
- "promote"
4789
- );
4790
- }
4791
- async function runRollback(context) {
4792
- const client = await context.client();
4793
- const projectId = await context.resolveProjectId();
4794
- const project = await client.projects.get(projectId);
4795
- let target = context.positionals[0];
4796
- if (!target) {
4797
- const { current, target: candidate } = await client.deployments.rollbackTarget(projectId, project.defaultBranch);
4798
- if (!candidate)
8056
+ async function storageCp(context) {
8057
+ const [from, to, ...extra] = context.positionals;
8058
+ if (from === void 0 || to === void 0 || extra.length > 0)
8059
+ throw new NaroError("VALIDATION_FAILED", CP_USAGE);
8060
+ const source = parseStorageUrl(from);
8061
+ const target = parseStorageUrl(to);
8062
+ if (source !== null && target !== null)
8063
+ throw new NaroError(
8064
+ "VALIDATION_FAILED",
8065
+ "naro storage cp copies between this machine and Storage: one side is naro://<bucket>/<path>, the other a local file. Copying inside Storage is not supported yet."
8066
+ );
8067
+ if (target !== null) return upload(context, from, target);
8068
+ if (source !== null) return download(context, source, to);
8069
+ throw new NaroError("VALIDATION_FAILED", CP_USAGE);
8070
+ }
8071
+ async function namesUnder(storage, bucket, prefixes) {
8072
+ const names = /* @__PURE__ */ new Set();
8073
+ let truncated = false;
8074
+ for (const written of prefixes) {
8075
+ const prefix = written.replace(/\/+$/, "");
8076
+ if (prefix !== "" && !written.endsWith("/") && unwrap(await storage.from(bucket).exists(prefix)))
8077
+ names.add(prefix);
8078
+ const found = await listAll(storage, bucket, prefix, true);
8079
+ for (const entry of found.entries) names.add(entry.path);
8080
+ truncated ||= found.truncated;
8081
+ }
8082
+ return { names: [...names], truncated };
8083
+ }
8084
+ async function storageRm(context) {
8085
+ if (context.positionals.length === 0)
8086
+ throw new NaroError("VALIDATION_FAILED", RM_USAGE);
8087
+ const recursive = context.flags.recursive === true;
8088
+ const byBucket = /* @__PURE__ */ new Map();
8089
+ for (const arg of context.positionals) {
8090
+ const url = parseStorageUrl(arg);
8091
+ if (url === null)
4799
8092
  throw new NaroError(
4800
- "DEPLOYMENT_NOT_FOUND",
4801
- "No earlier production build with a different artifact to roll back to. Pass a deployment id explicitly."
8093
+ "VALIDATION_FAILED",
8094
+ `${printable(arg)} is not a naro:// path. ${RM_USAGE}`
8095
+ );
8096
+ if (!recursive && (url.path === "" || url.path.endsWith("/")))
8097
+ throw new NaroError(
8098
+ "VALIDATION_FAILED",
8099
+ `${printable(arg)} is a folder: pass --recursive to remove what is under it.`
8100
+ );
8101
+ byBucket.set(url.bucket, [...byBucket.get(url.bucket) ?? [], url.path]);
8102
+ }
8103
+ if (!recursive)
8104
+ for (const [bucket, paths] of byBucket)
8105
+ await confirmOrAbort(
8106
+ context,
8107
+ `Remove ${count(new Set(paths).size, "object")} from ${bucket}?`
8108
+ );
8109
+ else if (!context.flags.yes && cannotAsk(context))
8110
+ throw new NaroError(
8111
+ "CONFIRMATION_REQUIRED",
8112
+ `Remove everything under ${context.positionals.map((arg) => printable(arg)).join(", ")}? Re-run with --yes to confirm.`
8113
+ );
8114
+ const { projectId, storage } = await objects(context);
8115
+ const results = [];
8116
+ for (const [bucket, paths] of byBucket) {
8117
+ let names;
8118
+ if (recursive) {
8119
+ const found = await namesUnder(storage, bucket, paths);
8120
+ names = found.names;
8121
+ if (found.truncated) context.output.warn(TRUNCATED_WARNING);
8122
+ } else names = [...new Set(paths)];
8123
+ if (names.length === 0) {
8124
+ context.output.log(`Nothing to remove in ${bucket}.`);
8125
+ results.push({ bucket, requested: 0, removed: [] });
8126
+ continue;
8127
+ }
8128
+ if (recursive)
8129
+ await confirmOrAbort(
8130
+ context,
8131
+ `Remove ${count(names.length, "object")} from ${bucket}?`
8132
+ );
8133
+ const removed = [];
8134
+ try {
8135
+ for (let at = 0; at < names.length; at += REMOVE_BATCH) {
8136
+ const page = unwrap(
8137
+ await storage.from(bucket).remove(names.slice(at, at + REMOVE_BATCH))
8138
+ );
8139
+ removed.push(...page.map((object) => object.name));
8140
+ }
8141
+ } catch (error) {
8142
+ if (removed.length > 0)
8143
+ context.output.warn(
8144
+ `\u26A0 Removed ${removed.length.toLocaleString("en-US")} of ${count(names.length, "object")} from ${bucket} before the error.`
8145
+ );
8146
+ throw error;
8147
+ }
8148
+ if (removed.length < names.length)
8149
+ context.output.warn(
8150
+ `\u26A0 ${String(names.length - removed.length)} of ${String(names.length)} were not removed: they were not there, or another request was changing them.`
4802
8151
  );
4803
8152
  context.output.log(
4804
- `\u25B8 current production: ${current.id}${current.gitCommitSha ? ` (${current.gitCommitSha.slice(0, 7)})` : ""}`
8153
+ removed.length === 0 ? `Nothing was removed from ${bucket}.` : `\u2705 Removed ${count(removed.length, "object")} from ${bucket}.`
4805
8154
  );
4806
- target = candidate.id;
8155
+ results.push({ bucket, requested: names.length, removed });
8156
+ }
8157
+ context.output.result({ projectId, removed: results });
8158
+ }
8159
+ var ENV_KEY_REFUSED = "\u26A0 The service key came from NARO_SERVICE_KEY, which is used in every project: it may be another project's. Unset it to use this project's key.";
8160
+ async function explainingKey(context, run) {
8161
+ try {
8162
+ await run(context);
8163
+ } catch (error) {
8164
+ if (isNaroError(error) && error.code === "INVALID_API_KEY" && (process.env.NARO_SERVICE_KEY?.trim() ?? "") !== "")
8165
+ context.output.warn(ENV_KEY_REFUSED);
8166
+ throw error;
4807
8167
  }
4808
- await confirmOrAbort(
4809
- context,
4810
- `Are you sure you want to rollback production to ${target}?`
4811
- );
4812
- await promoteAndWait(context, client, project, target, "rollback");
8168
+ }
8169
+ function runStorageLs(context) {
8170
+ return explainingKey(context, storageLs);
8171
+ }
8172
+ function runStorageCp(context) {
8173
+ return explainingKey(context, storageCp);
8174
+ }
8175
+ function runStorageRm(context) {
8176
+ return explainingKey(context, storageRm);
4813
8177
  }
4814
8178
 
4815
8179
  // src/router.ts
@@ -4836,6 +8200,21 @@ Usage:
4836
8200
  naro logs [deployment-id] Runtime logs (or build logs of a deployment)
4837
8201
  [--type build|runtime] [--env production|preview] [--level error] [--follow]
4838
8202
 
8203
+ naro analytics [--range 7d] [--path p] [--referrer host] [--country KR] [--device mobile] [--browser safari] [--os ios]
8204
+ Web Analytics: visitors and page views, then the top pages,
8205
+ referrers, countries, devices, browsers and operating systems
8206
+ naro insights [--metric lcp] [--range 7d] [--env preview] [--path p] [--country KR] [--device mobile] [--browser safari] [--os ios]
8207
+ Speed Insights: Web Vitals p75 by page, device, the elements
8208
+ behind slow visits and deployment, and the page load: its p75
8209
+ and the mean of each step (production by default)
8210
+ naro insights --route '/posts/[id]' The paths opened under one route
8211
+ naro insights enable | disable Turn measuring on or off (production pages get a
8212
+ same-origin script, preview pages too; past measurements are kept)
8213
+
8214
+ naro protection Preview protection: on or off, and whether a bypass secret is set
8215
+ naro protection enable | disable Protect the preview URL (members only) or open it to anyone with the link (disable asks first; --yes in CI)
8216
+ naro protection bypass [remove] Create (shown once) or remove the secret automation sends as x-naro-protection-bypass (replacing or removing asks first; --yes in CI)
8217
+
4839
8218
  naro env list List env vars (secrets masked)
4840
8219
  naro env add KEY [environment] Add/update (value from prompt or stdin)
4841
8220
  naro env remove KEY [environment] Remove
@@ -4949,6 +8328,27 @@ Usage:
4949
8328
  naro_insert, naro_update, naro_delete (shorthand for CREATE POLICY)
4950
8329
  naro policy remove <table> Drop every policy on it: back to deny (asks first)
4951
8330
 
8331
+ naro storage [status] Naro Storage: on or off, limits, what is stored, the buckets
8332
+ naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
8333
+ (safe to re-run; applies a plan change's limits)
8334
+ naro storage bucket list The buckets: public or private, objects, bytes, limits
8335
+ naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]
8336
+ <size>: bytes, 5MB (1000s) or 5MiB (1024s); turns Storage on first if needed
8337
+ naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]
8338
+ --public asks first: anyone with an object's URL can then download it
8339
+ naro storage bucket empty <name> Delete every object in it and keep the bucket (asks first)
8340
+ naro storage bucket remove <name> [--empty]
8341
+ Remove an empty bucket; --empty deletes its objects first (asks first)
8342
+ naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]
8343
+ The buckets, or the folders and objects under a prefix
8344
+ naro storage cp <from> <to> [--upsert] [--content-type <t>] [--cache-control <seconds>]
8345
+ One file between this machine and naro://<bucket>/<path>, 50 MiB at most,
8346
+ with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
8347
+ A download replaces a local file of the same name
8348
+ naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
8349
+ Remove objects; --recursive removes what is under a folder (asks first):
8350
+ <path>/ is the folder alone, <path> also an object of that name
8351
+
4952
8352
  naro promote <deployment-id> Publish a build to production
4953
8353
  naro rollback [deployment-id] Roll production back to the previous build
4954
8354
 
@@ -4963,6 +8363,16 @@ Global options:
4963
8363
  --cwd <dir> Run as if started in <dir>
4964
8364
  --prod Deploy/target production
4965
8365
  --wait Poll until the domain goes live (naro domains verify)
8366
+ --metric <m> lcp, inp, cls, fcp or ttfb; lcp by default (naro insights)
8367
+ --range <r> 24h, 7d or 30d; 7d by default (naro analytics, naro insights)
8368
+ --route <pattern> A route from the pages table, e.g. '/posts/[id]': the paths under it (naro insights)
8369
+ --path <p> Only this page, starting with /, e.g. /pricing (naro analytics, naro insights)
8370
+ --referrer <host> Only visits that came from this lowercase host, e.g. t.co (naro analytics only: Speed Insights has no referrer)
8371
+ --country <cc> Only this country, an uppercase two-letter code like KR (naro analytics, naro insights)
8372
+ --device <d> Only this device: desktop, mobile or tablet (naro analytics, naro insights)
8373
+ --browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
8374
+ --os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
8375
+ --env <e> production or preview (naro logs, naro insights: production by default; naro env pull: preview by default)
4966
8376
  --no-www Register the apex alone (naro domains add)
4967
8377
  --file <path> SQL file to run (naro db query, naro policy create)
4968
8378
  --dry-run Show what would change without changing it (naro db push, db restore, db reset, db table, db index)
@@ -4989,6 +8399,14 @@ Global options:
4989
8399
  --password-stdin Read the new user's password from stdin (a pipe, or a hidden prompt)
4990
8400
  --password <p> The password in argv: works, but lands in shell history (naro backend users create)
4991
8401
  --min-length <n> Fewest characters of a new password, 6-128 (naro backend auth password)
8402
+ --public | --private Who may download a bucket's objects by URL (naro storage bucket create, update)
8403
+ --file-size-limit <size> A bucket's largest object: bytes, 5MB or 5MiB; none clears it
8404
+ --allowed-mime-types <t,\u2026> The types a bucket takes, like image/png,image/*; any clears it
8405
+ --empty Delete the bucket's objects first (naro storage bucket remove)
8406
+ --recursive, -r Everything under a folder (naro storage ls, naro storage rm)
8407
+ --upsert Replace an object that is there (naro storage cp)
8408
+ --content-type <t> The object's type; by default from the file's extension (naro storage cp)
8409
+ --cache-control <seconds> How long a browser may keep it; 3600 by default (naro storage cp)
4992
8410
  --select <rule> Rule for reads; --insert, --update, --delete likewise (naro policy set)
4993
8411
  --owner <column> Column that holds the signed-in user's id, for the owner rule (naro policy set, db table create)
4994
8412
  --select-when <filter> Condition for reads, e.g. 'user_id.eq.auth.uid()'; --insert-when (what a new
@@ -5082,6 +8500,56 @@ function routeBackendUsers(words) {
5082
8500
  return null;
5083
8501
  }
5084
8502
  }
8503
+ function routeStorage(words) {
8504
+ const [sub, action] = words;
8505
+ switch (sub) {
8506
+ case void 0:
8507
+ case "status":
8508
+ return words.length <= 1 ? { handler: runStorageStatus, positionals: [] } : null;
8509
+ case "enable":
8510
+ return words.length === 1 ? { handler: runStorageEnable, positionals: [] } : null;
8511
+ case "bucket":
8512
+ case "buckets":
8513
+ switch (action) {
8514
+ case void 0:
8515
+ case "list":
8516
+ case "ls":
8517
+ return words.length <= 2 ? { handler: runStorageBucketList, positionals: [] } : null;
8518
+ case "create":
8519
+ return {
8520
+ handler: runStorageBucketCreate,
8521
+ positionals: words.slice(2)
8522
+ };
8523
+ case "update":
8524
+ return {
8525
+ handler: runStorageBucketUpdate,
8526
+ positionals: words.slice(2)
8527
+ };
8528
+ case "empty":
8529
+ return {
8530
+ handler: runStorageBucketEmpty,
8531
+ positionals: words.slice(2)
8532
+ };
8533
+ case "remove":
8534
+ case "rm":
8535
+ case "delete":
8536
+ return {
8537
+ handler: runStorageBucketRemove,
8538
+ positionals: words.slice(2)
8539
+ };
8540
+ default:
8541
+ return null;
8542
+ }
8543
+ case "ls":
8544
+ return { handler: runStorageLs, positionals: words.slice(1) };
8545
+ case "cp":
8546
+ return { handler: runStorageCp, positionals: words.slice(1) };
8547
+ case "rm":
8548
+ return { handler: runStorageRm, positionals: words.slice(1) };
8549
+ default:
8550
+ return null;
8551
+ }
8552
+ }
5085
8553
  function route(parsed) {
5086
8554
  const { command, positionals } = parsed;
5087
8555
  const sub = positionals[0];
@@ -5119,6 +8587,42 @@ function route(parsed) {
5119
8587
  return { handler: runCancel, positionals };
5120
8588
  case "logs":
5121
8589
  return { handler: runLogs, positionals };
8590
+ // Spec 2026-10-08-analytics-cli-pageload §5.
8591
+ case "analytics":
8592
+ return positionals.length === 0 ? { handler: runAnalytics, positionals: [] } : null;
8593
+ // Spec 2026-10-08 §5.
8594
+ case "insights": {
8595
+ if (positionals.length > 1) return null;
8596
+ switch (sub) {
8597
+ case void 0:
8598
+ return { handler: runInsights, positionals: [] };
8599
+ case "enable":
8600
+ return { handler: runInsightsEnable, positionals: [] };
8601
+ case "disable":
8602
+ return { handler: runInsightsDisable, positionals: [] };
8603
+ default:
8604
+ return null;
8605
+ }
8606
+ }
8607
+ // Spec 2026-10-08-preview-protection §9.
8608
+ case "protection": {
8609
+ const [, second, third] = positionals;
8610
+ if (third !== void 0) return null;
8611
+ switch (sub) {
8612
+ case void 0:
8613
+ return { handler: runProtection, positionals: [] };
8614
+ case "enable":
8615
+ return second === void 0 ? { handler: runProtectionEnable, positionals: [] } : null;
8616
+ case "disable":
8617
+ return second === void 0 ? { handler: runProtectionDisable, positionals: [] } : null;
8618
+ case "bypass":
8619
+ if (second === void 0)
8620
+ return { handler: runProtectionBypass, positionals: [] };
8621
+ return second === "remove" ? { handler: runProtectionBypassRemove, positionals: [] } : null;
8622
+ default:
8623
+ return null;
8624
+ }
8625
+ }
5122
8626
  case "env":
5123
8627
  switch (sub) {
5124
8628
  case void 0:
@@ -5252,6 +8756,8 @@ function route(parsed) {
5252
8756
  default:
5253
8757
  return null;
5254
8758
  }
8759
+ case "storage":
8760
+ return routeStorage(positionals);
5255
8761
  case "policy":
5256
8762
  switch (sub) {
5257
8763
  case void 0:
@@ -5299,7 +8805,7 @@ function version() {
5299
8805
  try {
5300
8806
  const pkg = JSON.parse(
5301
8807
  readFileSync5(
5302
- join6(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
8808
+ join7(dirname4(fileURLToPath(import.meta.url)), "..", "package.json"),
5303
8809
  "utf8"
5304
8810
  )
5305
8811
  );