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/README.md +106 -1
- package/dist/{chunk-GFCG5C7D.js → chunk-U7AZSAFS.js} +1629 -195
- package/dist/deploy.js +1 -1
- package/dist/index.js +3847 -341
- package/package.json +5 -5
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-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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: (
|
|
442
|
-
if (positionals.length <
|
|
468
|
+
positionalOrThrow: (count2, usage) => {
|
|
469
|
+
if (positionals.length < count2)
|
|
443
470
|
throw new NaroError("VALIDATION_FAILED", usage);
|
|
444
|
-
return positionals.slice(0,
|
|
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
|
|
498
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
472
499
|
import { fileURLToPath } from "url";
|
|
473
500
|
|
|
474
|
-
// src/commands/
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1436
|
+
const label3 = ` ${LABELS[provider]}: `;
|
|
1116
1437
|
callbacksOf(auth, provider).forEach(
|
|
1117
|
-
(url, index) => lines.push(`${index === 0 ?
|
|
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,
|
|
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
|
|
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(
|
|
1734
|
+
async function usersCall(call2) {
|
|
1414
1735
|
try {
|
|
1415
|
-
return await
|
|
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
|
|
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 ${
|
|
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 ===
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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,
|
|
2087
|
-
|
|
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
|
|
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,
|
|
2159
|
-
if (triggers === 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
|
|
2167
|
-
const name = cellText(
|
|
2168
|
-
if (cellText(
|
|
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((
|
|
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(
|
|
2534
|
+
function chunk(items3, size) {
|
|
2216
2535
|
const pieces = [];
|
|
2217
|
-
for (let index = 0; index <
|
|
2218
|
-
pieces.push(
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
3118
|
+
function attempt(parse2) {
|
|
2793
3119
|
try {
|
|
2794
|
-
return { ok: true, node:
|
|
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,
|
|
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 (
|
|
3249
|
+
if (table2 !== void 0 && asciiLower(named) !== asciiLower(table2))
|
|
2924
3250
|
refuse2(
|
|
2925
|
-
`${path} is the policy of ${printable(named)}, not ${printable(
|
|
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,
|
|
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,
|
|
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
|
|
3021
|
-
if (
|
|
3022
|
-
const list =
|
|
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
|
|
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
|
|
3429
|
+
if (lines.length === 0) return table2;
|
|
3104
3430
|
const width = Math.max(...lines.map((line) => line.label.length));
|
|
3105
3431
|
return [
|
|
3106
|
-
...
|
|
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,
|
|
3124
|
-
return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(
|
|
3449
|
+
function policyNamed(name, table2) {
|
|
3450
|
+
return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(table2))}`;
|
|
3125
3451
|
}
|
|
3126
|
-
function shownStatement(statement,
|
|
3452
|
+
function shownStatement(statement, table2, exact) {
|
|
3127
3453
|
if (isPrintable(statement.sql)) return statement.sql;
|
|
3128
3454
|
return printable(
|
|
3129
|
-
`-- ${policyNamed(statement.name,
|
|
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
|
|
3460
|
+
let table2;
|
|
3135
3461
|
for (const policy of policies) {
|
|
3136
|
-
if (
|
|
3137
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
3479
|
+
else if (table2 !== void 0)
|
|
3154
3480
|
lines = [
|
|
3155
|
-
`No policies on ${printable(
|
|
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
|
|
3252
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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 [
|
|
3270
|
-
const patch = policyPatch(context.flags, context,
|
|
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
|
-
|
|
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 [
|
|
3622
|
+
const [table2] = context.positionalOrThrow(1, REMOVE_USAGE);
|
|
3297
3623
|
await confirmOrAbort(
|
|
3298
3624
|
context,
|
|
3299
|
-
`Remove every policy on ${printable(
|
|
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,
|
|
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
|
|
3716
|
+
const table2 = dot < 0 ? "" : value.slice(0, dot);
|
|
3391
3717
|
const target = dot < 0 ? "" : value.slice(dot + 1);
|
|
3392
|
-
if (!
|
|
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 [
|
|
3902
|
+
const [table2] = context.positionals;
|
|
3577
3903
|
const specs = columnsFlag(context);
|
|
3578
|
-
if (!
|
|
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 [
|
|
3919
|
+
const [table2] = context.positionals;
|
|
3594
3920
|
const specs = columnsFlag(context);
|
|
3595
3921
|
const [spec] = specs;
|
|
3596
|
-
if (!
|
|
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 [
|
|
3604
|
-
if (!
|
|
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 [
|
|
3610
|
-
if (!
|
|
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 [
|
|
3615
|
-
if (!
|
|
3940
|
+
const [table2, column] = context.positionals;
|
|
3941
|
+
if (!table2 || !column)
|
|
3616
3942
|
throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
|
|
3617
|
-
const target = `${
|
|
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 [
|
|
3626
|
-
if (!
|
|
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:
|
|
3630
|
-
`Drop table ${printable(
|
|
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 [
|
|
3960
|
+
const [table2, list] = context.positionals;
|
|
3635
3961
|
const columns = listFlag(list);
|
|
3636
|
-
if (!
|
|
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((
|
|
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
|
|
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
|
|
4182
|
+
const label3 = only.isPersonal ? `${only.name} (personal)` : only.name;
|
|
3857
4183
|
context.output.log(`
|
|
3858
|
-
${account} \u2192 ${
|
|
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((
|
|
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
|
-
(
|
|
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/
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
return
|
|
4568
|
-
}
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
}
|
|
4572
|
-
|
|
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
|
|
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
|
-
|
|
4666
|
-
const
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
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
|
-
|
|
7917
|
+
`${printable(target)} is not a naro:// path. ${LS_USAGE}`
|
|
4673
7918
|
);
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
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
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
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
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
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
|
|
4696
|
-
const
|
|
4697
|
-
const
|
|
4698
|
-
|
|
4699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
const
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4752
|
-
|
|
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
|
|
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
|
|
4772
|
-
const [
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
);
|
|
4776
|
-
const
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
);
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
)
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
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
|
-
"
|
|
4801
|
-
|
|
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
|
-
|
|
8153
|
+
removed.length === 0 ? `Nothing was removed from ${bucket}.` : `\u2705 Removed ${count(removed.length, "object")} from ${bucket}.`
|
|
4805
8154
|
);
|
|
4806
|
-
|
|
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
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
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
|
-
|
|
8808
|
+
join7(dirname4(fileURLToPath(import.meta.url)), "..", "package.json"),
|
|
5303
8809
|
"utf8"
|
|
5304
8810
|
)
|
|
5305
8811
|
);
|