naro-cli 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/deploy.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  slugify,
15
15
  userAdapterUnreadable,
16
16
  workingTreeNotices
17
- } from "./chunk-UJGDKNDM.js";
17
+ } from "./chunk-ITTYPT3A.js";
18
18
  import "./chunk-K3NQKI34.js";
19
19
  export {
20
20
  assertFunctionsNode,
@@ -1,5 +1,11 @@
1
1
  import { bootCheck, bundleFunction } from "@next-deploy/functions-bundler";
2
2
  import { type FunctionUploadTarget, type NaroClient } from "@next-deploy/sdk";
3
+ /**
4
+ * A function's own failures while its version is made: said, and the run
5
+ * goes on with the next. FUNCTION_BUSY is not one — the lease is the
6
+ * project's worker's (design 2026-11-08 G5), so the next function would
7
+ * meet it too; it is retried, then stops the run.
8
+ */
3
9
  export declare const PER_FUNCTION_CODES: ReadonlySet<string>;
4
10
  export interface DeploySteps {
5
11
  bundle: typeof bundleFunction;
@@ -67,4 +73,5 @@ export declare function assertFunctionsNode(version?: string): void;
67
73
  */
68
74
  export declare function uploadTimeoutMs(upload: FunctionUploadTarget, now?: number): number;
69
75
  export declare const deadlineReached: (name: string, deadlineMs: number) => string;
76
+ export declare const batchDeadlineReached: (count: number, deadlineMs: number) => string;
70
77
  export declare function deployFunctions(options: DeployFunctionsOptions): Promise<DeployFunctionsResult>;
@@ -6,7 +6,22 @@ export declare const FUNCTION_RATE_LIMIT_MAX_SECONDS = 300;
6
6
  export declare function defaultSleep(ms: number): Promise<void>;
7
7
  /** `details.retryAfter` rounded up, at most 300; 60 when it is missing. */
8
8
  export declare function retryAfterSeconds(error: NaroError): number;
9
- export declare function withFunctionRetries<T>(op: () => Promise<T>, name: string, io: {
9
+ /** What a retry says, for one function or for a batch of them. */
10
+ export interface RetryWords {
11
+ rateLimited(seconds: number, attempt: number, of: number): string;
12
+ busy(attempt: number, of: number): string;
13
+ }
14
+ /**
15
+ * Several functions put live in one call (design 2026-11-08 G4): the lease
16
+ * the server waits on is the project's worker, not one function's.
17
+ */
18
+ export declare const batchRetryWords: (count: number) => RetryWords;
19
+ /**
20
+ * `op` again after FUNCTIONS_RATE_LIMITED and FUNCTION_BUSY, judged by the
21
+ * code alone (the words are the server's to change). `name` is the
22
+ * function's, or the words of a batch.
23
+ */
24
+ export declare function withFunctionRetries<T>(op: () => Promise<T>, name: string | RetryWords, io: {
10
25
  sleep: (ms: number) => Promise<void>;
11
26
  log: (line: string) => void;
12
27
  /** Tries after FUNCTIONS_RATE_LIMITED: 3 by default; naro-mcp passes 0. */
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  appliedMigrations,
29
29
  applyMigrationFiles,
30
30
  assertFunctionsNode,
31
+ assertProjectLinkWritable,
31
32
  buildResetPlan,
32
33
  cannotAsk,
33
34
  cellText,
@@ -66,6 +67,7 @@ import {
66
67
  isPrintable,
67
68
  isReservedEnvTarget,
68
69
  isStalledBackend,
70
+ isSymbolicLink,
69
71
  isTerminalStatus,
70
72
  listMigrationFiles,
71
73
  loadStoredSession,
@@ -136,7 +138,7 @@ import {
136
138
  warningLines,
137
139
  withFunctionRetries,
138
140
  writeProjectLink
139
- } from "./chunk-UJGDKNDM.js";
141
+ } from "./chunk-ITTYPT3A.js";
140
142
  import "./chunk-K3NQKI34.js";
141
143
 
142
144
  // src/args.ts
@@ -1704,12 +1706,12 @@ async function allUsers(client, projectId) {
1704
1706
  const users = [];
1705
1707
  let total = 0;
1706
1708
  for (let page = 1; ; page++) {
1707
- const listed = await usersCall(
1709
+ const listed2 = await usersCall(
1708
1710
  () => client.backend.listUsers(projectId, { page, perPage: PAGE_SIZE })
1709
1711
  );
1710
- total = listed.total;
1711
- users.push(...listed.users);
1712
- if (listed.users.length < PAGE_SIZE || users.length >= total)
1712
+ total = listed2.total;
1713
+ users.push(...listed2.users);
1714
+ if (listed2.users.length < PAGE_SIZE || users.length >= total)
1713
1715
  return { users, total };
1714
1716
  }
1715
1717
  }
@@ -2365,8 +2367,8 @@ function chunk(items3, size) {
2365
2367
  }
2366
2368
  async function readSchema(client, projectId) {
2367
2369
  const schema = await client.db.query(projectId, { sql: SCHEMA_SQL });
2368
- const [listed, virtual, internal] = schema.statements;
2369
- if (listed === void 0 || virtual === void 0 || internal === void 0)
2370
+ const [listed2, virtual, internal] = schema.statements;
2371
+ if (listed2 === void 0 || virtual === void 0 || internal === void 0)
2370
2372
  throw new NaroError(
2371
2373
  "DATABASE_QUERY_FAILED",
2372
2374
  `The schema could not be read: the database answered with ${String(schema.statements.length)} result sets instead of 3.`
@@ -2377,9 +2379,9 @@ async function readSchema(client, projectId) {
2377
2379
  const virtualNames = new Set(
2378
2380
  virtual.rows.map((row) => cellText(virtual, row, "name").toLowerCase())
2379
2381
  );
2380
- const objects2 = listed.rows.map((row) => {
2381
- const name = cellText(listed, row, "name");
2382
- const type = cellText(listed, row, "type");
2382
+ const objects2 = listed2.rows.map((row) => {
2383
+ const name = cellText(listed2, row, "name");
2384
+ const type = cellText(listed2, row, "type");
2383
2385
  if (type === "view" || type === "trigger") return { type, name };
2384
2386
  return {
2385
2387
  type: virtualNames.has(name.toLowerCase()) ? "virtual" : "table",
@@ -3247,6 +3249,72 @@ function cellOf(value) {
3247
3249
  if (typeof value === "string") return value;
3248
3250
  return isInvalid(value) ? "invalid" : "condition";
3249
3251
  }
3252
+ var SET_POLICY_NAMES = new Set(
3253
+ POLICY_ACTIONS.map((action) => `naro_${action}`)
3254
+ );
3255
+ function customPolicies(named) {
3256
+ const byTable = /* @__PURE__ */ new Map();
3257
+ for (const policy of named) {
3258
+ if (SET_POLICY_NAMES.has(policy.name)) continue;
3259
+ const invalid = policy.invalid !== null;
3260
+ if (!invalid && policy.as !== "permissive") continue;
3261
+ const key = asciiLower(policy.table);
3262
+ const list = byTable.get(key) ?? [];
3263
+ list.push({ name: policy.name, command: policy.command, invalid });
3264
+ byTable.set(key, list);
3265
+ }
3266
+ return byTable;
3267
+ }
3268
+ function covers(policy, action) {
3269
+ return policy.command === "all" || policy.command === action;
3270
+ }
3271
+ var CUSTOM_NOTE = "custom: a policy written as CREATE POLICY allows it. naro policy list shows it.";
3272
+ var INVALID_NOTE = "invalid: a policy that cannot be read covers it, so the Data API refuses it (500 POLICY_INVALID). naro policy list shows why.";
3273
+ var NAMES_ONLY_NOTE = "`none` may not mean denied on those tables: naro policy list shows their policies.";
3274
+ function listed(entries) {
3275
+ return entries.map((entry) => `${printable(entry.name)} (${entry.command})`).toSorted().join(", ");
3276
+ }
3277
+ function customLines(policies, custom) {
3278
+ const lines = [];
3279
+ let allows = false;
3280
+ let unreadable2 = false;
3281
+ for (const policy of policies) {
3282
+ const table3 = printable(policy.table);
3283
+ if (custom) {
3284
+ const list = custom.get(asciiLower(policy.table)) ?? [];
3285
+ const valid = list.filter((entry) => !entry.invalid);
3286
+ const invalid = list.filter((entry) => entry.invalid);
3287
+ if (valid.length > 0) {
3288
+ allows = true;
3289
+ lines.push(`${table3} custom: ${listed(valid)}`);
3290
+ }
3291
+ if (invalid.length > 0) {
3292
+ unreadable2 = true;
3293
+ lines.push(`${table3} invalid: ${listed(invalid)}`);
3294
+ }
3295
+ continue;
3296
+ }
3297
+ const names = (policy.policies ?? []).filter(
3298
+ (name) => !SET_POLICY_NAMES.has(name)
3299
+ );
3300
+ if (names.length === 0) continue;
3301
+ lines.push(
3302
+ `${table3} also has policies written as SQL: ${names.map(printable).join(", ")}`
3303
+ );
3304
+ }
3305
+ if (lines.length === 0) return lines;
3306
+ if (!custom) return [...lines, NAMES_ONLY_NOTE];
3307
+ return [
3308
+ ...lines,
3309
+ ...allows ? [CUSTOM_NOTE] : [],
3310
+ ...unreadable2 ? [INVALID_NOTE] : []
3311
+ ];
3312
+ }
3313
+ var POLICY_TABLE_HINTS = /* @__PURE__ */ new Set([
3314
+ CUSTOM_NOTE,
3315
+ INVALID_NOTE,
3316
+ NAMES_ONLY_NOTE
3317
+ ]);
3250
3318
  function conditionText(action, value) {
3251
3319
  if (typeof value === "string") return value;
3252
3320
  if (isInvalid(value))
@@ -3262,11 +3330,19 @@ function conditionText(action, value) {
3262
3330
  const text = parts.join("; ");
3263
3331
  return value.role === "authenticated" ? `${text} (signed-in users only)` : text;
3264
3332
  }
3265
- function policyTable(policies) {
3333
+ function policyTable(policies, custom) {
3266
3334
  const header = ["Table", ...POLICY_ACTIONS, "Owner column"];
3335
+ const shown = (policy, action) => {
3336
+ const value = policy[action];
3337
+ const sql2 = (custom?.get(asciiLower(policy.table)) ?? []).filter(
3338
+ (entry) => covers(entry, action)
3339
+ );
3340
+ if (sql2.some((entry) => entry.invalid)) return "invalid";
3341
+ return value === "none" && sql2.length > 0 ? "custom" : cellOf(value);
3342
+ };
3267
3343
  const rows = policies.map((policy) => [
3268
3344
  printable(policy.table),
3269
- ...POLICY_ACTIONS.map((action) => cellOf(policy[action])),
3345
+ ...POLICY_ACTIONS.map((action) => shown(policy, action)),
3270
3346
  policy.ownerColumn === null ? "-" : printable(policy.ownerColumn)
3271
3347
  ]);
3272
3348
  const widths = header.map(
@@ -3289,19 +3365,22 @@ function policyTable(policies) {
3289
3365
  }
3290
3366
  )
3291
3367
  );
3292
- if (lines.length === 0) return table3;
3368
+ const sql = customLines(policies, custom);
3369
+ const below = sql.length > 0 ? ["", ...sql] : [];
3370
+ if (lines.length === 0) return [...table3, ...below];
3293
3371
  const width = Math.max(...lines.map((line) => line.label.length));
3294
3372
  return [
3295
3373
  ...table3,
3296
3374
  "",
3297
- ...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
3375
+ ...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`),
3376
+ ...below
3298
3377
  ];
3299
3378
  }
3300
3379
  var UNSHOWN = "has characters that cannot be shown here";
3301
3380
  function hintsFor(lines, environment) {
3302
3381
  if (environment === null) return lines;
3303
3382
  return lines.map(
3304
- (line) => line.includes(UNSHOWN) || line.startsWith("Add one with ") ? forEnvironment(line, environment) : line
3383
+ (line) => line.includes(UNSHOWN) || line.startsWith("Add one with ") || POLICY_TABLE_HINTS.has(line) ? forEnvironment(line, environment) : line
3305
3384
  );
3306
3385
  }
3307
3386
  function statementLine(policy) {
@@ -3364,9 +3443,13 @@ async function runPolicyRules(context) {
3364
3443
  const client = await dataClient(context);
3365
3444
  const projectId = await context.resolveProjectId();
3366
3445
  const all = await client.backend.policies(projectId);
3367
- const wanted = table3?.toLowerCase();
3368
- const policies = wanted === void 0 ? all : all.filter((policy) => policy.table.toLowerCase() === wanted);
3369
- const lines = policies.length > 0 ? [...policyTable(policies), "", DEFAULT_DENY] : [
3446
+ const wanted = table3 === void 0 ? void 0 : asciiLower(table3);
3447
+ const policies = wanted === void 0 ? all : all.filter((policy) => asciiLower(policy.table) === wanted);
3448
+ const custom = context.flags.json || policies.length === 0 ? void 0 : await client.backend.rlsPolicies(projectId, table3 === void 0 ? {} : { table: table3 }).then(customPolicies, (error) => {
3449
+ if (!isNaroError(error)) throw error;
3450
+ return null;
3451
+ });
3452
+ const lines = policies.length > 0 ? [...policyTable(policies, custom), "", DEFAULT_DENY] : [
3370
3453
  table3 === void 0 ? "No tables yet." : `No table named ${printable(table3)}.`
3371
3454
  ];
3372
3455
  context.output.result(
@@ -3501,7 +3584,10 @@ async function runPolicySet(context) {
3501
3584
  hintsFor(
3502
3585
  [
3503
3586
  `\u2705 Saved the policy for ${printable(policy.table)}. ${TAKES_EFFECT}`,
3504
- ...policyTable([policy]),
3587
+ ...policyTable(
3588
+ [policy],
3589
+ named === void 0 ? void 0 : customPolicies(named)
3590
+ ),
3505
3591
  ...named === void 0 || named.length === 0 ? [] : ["", ...policyStatements(named)]
3506
3592
  ],
3507
3593
  dataEnvironment(context.flags)
@@ -4217,6 +4303,7 @@ async function runDeploy(context, deps = { deploy: deployDirectory, offerAutoDep
4217
4303
  const target = deployTarget(flags);
4218
4304
  const flaggedAdapter = flaggedUserAdapter(flags);
4219
4305
  const link = flags.project ? { projectId: flags.project, teamSlug: void 0 } : readProjectLink(context.cwd);
4306
+ assertProjectLinkWritable(context.cwd, "deploying");
4220
4307
  if (target.production && !flags.yes && !flags.json && isInteractive()) {
4221
4308
  const confirmed = await context.prompter.confirm("Deploy to production?");
4222
4309
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
@@ -4670,9 +4757,9 @@ async function verifyAll(client, projectId, hostname, auto = false) {
4670
4757
  return [
4671
4758
  await client.domains.verifyByHostname(projectId, hostname, { auto })
4672
4759
  ];
4673
- const listed = await client.domains.list(projectId);
4760
+ const listed2 = await client.domains.list(projectId);
4674
4761
  const verified = [];
4675
- for (const domain of listed)
4762
+ for (const domain of listed2)
4676
4763
  verified.push(await client.domains.verify(projectId, domain.id, { auto }));
4677
4764
  return verified;
4678
4765
  }
@@ -5070,6 +5157,13 @@ function passwordRecoveryRequired() {
5070
5157
  message: "updateUser({ password }) works in the password recovery flow: open the link resetPasswordForEmail sent, then call it on that page."
5071
5158
  };
5072
5159
  }
5160
+ function recoveryForAnotherUser(email) {
5161
+ return {
5162
+ code: "RECOVERY_FOR_ANOTHER_USER",
5163
+ message: "This password reset link is for another account than the one signed in on this browser, so it was not used. To reset that account's password, sign out first. If you did not ask for it, ignore it.",
5164
+ details: { email }
5165
+ };
5166
+ }
5073
5167
  function invalidProvider() {
5074
5168
  return {
5075
5169
  code: "VALIDATION_ERROR",
@@ -5095,6 +5189,12 @@ function websocketUnavailable() {
5095
5189
  message: "This runtime has no WebSocket. Pass one to createClient({ realtime: { WebSocket } })."
5096
5190
  };
5097
5191
  }
5192
+ function websocketHeadersUnavailable() {
5193
+ return {
5194
+ code: "WEBSOCKET_HEADERS_UNAVAILABLE",
5195
+ message: "The service key goes in the apikey header of the realtime connection, which this runtime's WebSocket cannot send. Pass one that takes { headers } \u2014 ws (npm:ws) \u2014 as createClient({ realtime: { WebSocket } })."
5196
+ };
5197
+ }
5098
5198
  function realtimeTimedOut(ms) {
5099
5199
  const seconds = Math.max(1, Math.round(ms / 1e3));
5100
5200
  return {
@@ -5768,8 +5868,11 @@ function isBrowser(scope = globalThis) {
5768
5868
  const g = scope;
5769
5869
  return g.window !== void 0 && g.document !== void 0;
5770
5870
  }
5771
- function isWorkerd(g) {
5772
- return g.navigator?.userAgent === "Cloudflare-Workers";
5871
+ function isWorkerd(scope = globalThis) {
5872
+ return scope.navigator?.userAgent === "Cloudflare-Workers";
5873
+ }
5874
+ function isDeno(scope = globalThis) {
5875
+ return typeof scope.Deno?.version?.deno === "string";
5773
5876
  }
5774
5877
  function hasServerSignal(g) {
5775
5878
  return isWorkerd(g) || // Vercel's edge runtime and Next.js's edge (middleware, edge routes).
@@ -6145,12 +6248,8 @@ var AuthClient = class {
6145
6248
  await this.#flow.verifiers.take(fragment.naro_flow);
6146
6249
  return { data: none, error: flowError(type, fragment.naro_error) };
6147
6250
  }
6148
- if (type === "recovery" && fragment.naro_token) {
6149
- await this.#http.tokens.clear();
6150
- await this.#http.accessJwt?.clear();
6151
- await this.#flow.recovery.keep(fragment.naro_token);
6152
- return { data: none, error: null };
6153
- }
6251
+ if (type === "recovery" && fragment.naro_token)
6252
+ return this.#recoveryAnswer(none, fragment.naro_token);
6154
6253
  if (fragment.naro_code) {
6155
6254
  const traded = await this.exchangeCodeForSession(
6156
6255
  fragment.naro_code,
@@ -6160,6 +6259,45 @@ var AuthClient = class {
6160
6259
  }
6161
6260
  return { data: none, error: null };
6162
6261
  }
6262
+ /**
6263
+ * A reset link's token, read on the page it opened (security audit
6264
+ * 2026-11-14 F6). The fragment is whoever sent the link's to write — one
6265
+ * who holds a reset token of their own account can send the landing URL
6266
+ * itself — so the worker is asked whose token it is: the answer names the
6267
+ * account (masked, `data.email`, for the page to show) and whether it is
6268
+ * the one signed in here.
6269
+ *
6270
+ * Signed in as another account, the link is not used: the session stays,
6271
+ * the token is not kept, and the answer is RECOVERY_FOR_ANOTHER_USER — a
6272
+ * password typed here cannot land on the sender's account. Otherwise,
6273
+ * like supabase-js's recovery session, the link takes the place of
6274
+ * whoever was signed in on this browser (the reset ends every session of
6275
+ * that account anyway, PR #68 review 6): `updateUser` then acts on the
6276
+ * link's account alone. A token the worker does not know is INVALID_TOKEN
6277
+ * here, and nothing is kept.
6278
+ */
6279
+ async #recoveryAnswer(none, token) {
6280
+ const result = await call(this.#http, {
6281
+ method: "POST",
6282
+ path: "/auth/recovery-owner",
6283
+ body: { token },
6284
+ withToken: true,
6285
+ // The page's queries wait on this; they must not wait forever.
6286
+ timeoutMs: EXCHANGE_TIMEOUT_MS
6287
+ });
6288
+ if (result.error !== null) return { data: none, error: result.error };
6289
+ const { body } = result.answer;
6290
+ const owner = isRecord(body) && isRecord(body.data) ? body.data : null;
6291
+ if (owner === null)
6292
+ return { data: none, error: invalidResponse(result.answer.status) };
6293
+ const email = typeof owner.email === "string" ? owner.email : null;
6294
+ if (owner.self === false)
6295
+ return { data: none, error: recoveryForAnotherUser(email) };
6296
+ await this.#http.tokens.clear();
6297
+ await this.#http.accessJwt?.clear();
6298
+ await this.#flow.recovery.keep(token);
6299
+ return { data: { ...none, email }, error: null };
6300
+ }
6163
6301
  /**
6164
6302
  * Emails a password reset link, when the address has an account. The
6165
6303
  * answer is the same either way — it does not say whether it had one.
@@ -6183,11 +6321,12 @@ var AuthClient = class {
6183
6321
  * "(P1-2 리뷰 반영)".)
6184
6322
  *
6185
6323
  * Like supabase-js, it acts on the recovery link's account, which took the
6186
- * place of any signed-in session when the link was read — so the token is
6187
- * used only while no session is signed in on this browser, for 15 minutes
6188
- * after the link was opened, and never after a sign-in, sign-up or
6189
- * sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
6190
- * with no request.
6324
+ * place of any signed-in session when the link was read (a link for
6325
+ * another account than the signed-in one is not read, `#recoveryAnswer`)
6326
+ * — so the token is used only while no session is signed in on this
6327
+ * browser, for 15 minutes after the link was opened, and never after a
6328
+ * sign-in, sign-up or sign-out forgot it (PR #68 review 6). Otherwise
6329
+ * PASSWORD_RECOVERY_REQUIRED, with no request.
6191
6330
  */
6192
6331
  async updateUser(attributes) {
6193
6332
  await this.#pendingUrl();
@@ -7882,8 +8021,76 @@ function channelNameProblem(name) {
7882
8021
  function socketUrl(baseUrl, apiKey) {
7883
8022
  const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
7884
8023
  url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
7885
- return `${url.href}?apikey=${encodeURIComponent(apiKey)}&vsn=${REALTIME_PROTOCOL_VERSION}`;
7886
- }
8024
+ const key = apiKey === null ? "" : `apikey=${encodeURIComponent(apiKey)}&`;
8025
+ return `${url.href}?${key}vsn=${REALTIME_PROTOCOL_VERSION}`;
8026
+ }
8027
+ var UpgradeSocket = class {
8028
+ onopen = null;
8029
+ onmessage = null;
8030
+ onclose = null;
8031
+ onerror = null;
8032
+ #ws = null;
8033
+ /** `close()` was called, or the open failed: no event goes out after. */
8034
+ #closed = null;
8035
+ constructor(fetchLike, url, headers) {
8036
+ let pending;
8037
+ try {
8038
+ pending = fetchLike(url, {
8039
+ headers: { Upgrade: "websocket", ...headers }
8040
+ });
8041
+ } catch {
8042
+ pending = Promise.reject(new Error("fetch threw"));
8043
+ }
8044
+ pending.then(
8045
+ (response) => this.#arrived(response),
8046
+ () => this.#failed()
8047
+ );
8048
+ }
8049
+ #arrived(response) {
8050
+ const ws = response.webSocket ?? null;
8051
+ if (ws === null) {
8052
+ void response.body?.cancel?.().catch(() => void 0);
8053
+ this.#failed();
8054
+ return;
8055
+ }
8056
+ if (this.#closed !== null) {
8057
+ try {
8058
+ ws.accept();
8059
+ ws.close(this.#closed.code, this.#closed.reason);
8060
+ } catch {
8061
+ }
8062
+ return;
8063
+ }
8064
+ this.#ws = ws;
8065
+ ws.addEventListener("message", (event) => {
8066
+ this.onmessage?.({ data: event.data });
8067
+ });
8068
+ ws.addEventListener("close", (event) => {
8069
+ this.onclose?.({ code: event.code, reason: event.reason ?? "" });
8070
+ });
8071
+ ws.addEventListener("error", () => {
8072
+ this.onerror?.();
8073
+ });
8074
+ ws.accept();
8075
+ this.onopen?.();
8076
+ }
8077
+ /** No connection came of it: a close before the open. */
8078
+ #failed() {
8079
+ if (this.#closed !== null) return;
8080
+ this.#closed = { code: void 0, reason: void 0 };
8081
+ this.onclose?.({ code: 1006, reason: "" });
8082
+ }
8083
+ send(data) {
8084
+ if (this.#ws === null)
8085
+ throw new Error("The realtime connection is not open.");
8086
+ this.#ws.send(data);
8087
+ }
8088
+ close(code, reason) {
8089
+ if (this.#closed !== null) return;
8090
+ this.#closed = { code, reason };
8091
+ this.#ws?.close(code, reason);
8092
+ }
8093
+ };
7887
8094
  var HEARTBEAT_MS = 25e3;
7888
8095
  var TIMEOUT_MS = 1e4;
7889
8096
  var PACED_BURST = CONNECTION_FRAME_BURST - 20;
@@ -8297,19 +8504,19 @@ var RealtimeSocket = class {
8297
8504
  }, this.#timeoutMs);
8298
8505
  }
8299
8506
  #open() {
8300
- const Impl = this.#config.options.WebSocket ?? globalThis.WebSocket;
8507
+ const passed = this.#config.options.WebSocket;
8508
+ const runtimes = globalThis.WebSocket;
8509
+ const Impl = passed ?? runtimes;
8301
8510
  if (typeof Impl !== "function")
8302
8511
  return this.#stop(websocketUnavailable(), true);
8303
8512
  if (this.#config.service && !isKnownServer())
8304
8513
  return this.#stop(serviceKeyOffServer(), true);
8305
- let ws;
8306
- try {
8307
- ws = new Impl(
8308
- socketUrl(this.#config.baseUrl, this.#config.apiKey)
8309
- );
8310
- } catch (error) {
8311
- return this.#stop(socketRefused(error), true);
8312
- }
8514
+ const made = this.#config.service ? this.#serviceSocket(
8515
+ Impl,
8516
+ passed === void 0 || passed === runtimes
8517
+ ) : this.#anonSocket(Impl);
8518
+ if ("error" in made) return this.#stop(made.error, true);
8519
+ const ws = made.ws;
8313
8520
  this.#generation += 1;
8314
8521
  this.#ws = ws;
8315
8522
  this.#state = "connecting";
@@ -8324,6 +8531,48 @@ var RealtimeSocket = class {
8324
8531
  this.#timeoutMs
8325
8532
  );
8326
8533
  }
8534
+ /**
8535
+ * The anon key's connection: the one place a key goes into a URL (design
8536
+ * §10 "3-2d") — a browser's WebSocket has no headers to put it in.
8537
+ */
8538
+ #anonSocket(Impl) {
8539
+ try {
8540
+ return {
8541
+ ws: new Impl(socketUrl(this.#config.baseUrl, this.#config.apiKey))
8542
+ };
8543
+ } catch (error) {
8544
+ return { error: socketRefused(error) };
8545
+ }
8546
+ }
8547
+ /**
8548
+ * The service key's connection (security audit 2026-11-14 F7): the key in
8549
+ * the `apikey` header and never in the URL, where edge and worker logs
8550
+ * would keep it. Node 22+, Bun and `ws` take `{ headers }` on the
8551
+ * constructor; workerd's constructor takes none, so there — unless the
8552
+ * app passed a WebSocket of its own, other than the runtime's — the
8553
+ * connection is a `fetch` upgrade (`UpgradeSocket`); Deno's takes none
8554
+ * either, and has no upgrade fetch, so the service key is refused there
8555
+ * until a WebSocket that takes headers (`ws`) is passed. `runtimes` says
8556
+ * the class is the runtime's own.
8557
+ */
8558
+ #serviceSocket(Impl, runtimes) {
8559
+ const headers = { apikey: this.#config.apiKey };
8560
+ const url = socketUrl(this.#config.baseUrl, null);
8561
+ if (runtimes && isWorkerd())
8562
+ return {
8563
+ ws: new UpgradeSocket(
8564
+ this.#config.fetch ?? globalThis.fetch,
8565
+ `http${url.slice(2)}`,
8566
+ headers
8567
+ )
8568
+ };
8569
+ if (runtimes && isDeno()) return { error: websocketHeadersUnavailable() };
8570
+ try {
8571
+ return { ws: new Impl(url, { headers }) };
8572
+ } catch (error) {
8573
+ return { error: socketRefused(error) };
8574
+ }
8575
+ }
8327
8576
  #opened(ws) {
8328
8577
  if (this.#ws !== ws || this.#state !== "connecting") return;
8329
8578
  clearTimeout(this.#openTimer);
@@ -9507,6 +9756,7 @@ var RealtimeClient = class {
9507
9756
  apiKey: http.apiKey,
9508
9757
  service,
9509
9758
  options,
9759
+ fetch: http.fetch,
9510
9760
  events: {
9511
9761
  // Not awaited, and not live yet: onLive says when the hub took it.
9512
9762
  opened: () => {
@@ -11211,14 +11461,14 @@ async function listAll(storage, bucket, prefix, recursive, search) {
11211
11461
  truncated = true;
11212
11462
  break;
11213
11463
  }
11214
- const listed = await storage.from(bucket).list(folder, {
11464
+ const listed2 = await storage.from(bucket).list(folder, {
11215
11465
  limit: PAGE,
11216
11466
  offset,
11217
11467
  ...search === "" ? {} : { search }
11218
11468
  });
11219
- if (isOldSearchRefusal(listed.error))
11469
+ if (isOldSearchRefusal(listed2.error))
11220
11470
  throw new NaroError("BACKEND_OUTDATED", STORAGE_OUTDATED_MESSAGE);
11221
- const page = unwrap(listed);
11471
+ const page = unwrap(listed2);
11222
11472
  for (const entry of page) {
11223
11473
  const path = folder === "" ? entry.name : `${folder}/${entry.name}`;
11224
11474
  if (isFolder(entry)) {
@@ -12604,6 +12854,12 @@ function parsePullSurface(value) {
12604
12854
  async function runEnvPull(context) {
12605
12855
  const file = context.positionals[0] ?? ".env.local";
12606
12856
  const surface = parsePullSurface(context.flags.env);
12857
+ const path = resolve5(context.cwd, file);
12858
+ if (isSymbolicLink(path))
12859
+ throw new NaroError(
12860
+ "VALIDATION_FAILED",
12861
+ `${file} is a symbolic link. Remove it, or pass another file name.`
12862
+ );
12607
12863
  const client = await context.client();
12608
12864
  const projectId = await context.resolveProjectId();
12609
12865
  if (!isReservedEnvTarget(surface))
@@ -12613,7 +12869,6 @@ async function runEnvPull(context) {
12613
12869
  const count2 = Object.keys(vars).length;
12614
12870
  const text = `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
12615
12871
  ${serializeDotenv(vars)}`;
12616
- const path = resolve5(context.cwd, file);
12617
12872
  if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
12618
12873
  const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
12619
12874
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
@@ -13014,7 +13269,7 @@ function functionName(context, usage) {
13014
13269
  }
13015
13270
  function statusWord(fn) {
13016
13271
  if (fn.status !== "error") return fn.status;
13017
- return `error at ${fn.step ?? "upload"}: ${printable(fn.error ?? "unknown error")}`;
13272
+ return `error: ${printable(fn.error ?? "unknown error")}`;
13018
13273
  }
13019
13274
  var cpuHours = (ms) => (ms / 36e5).toLocaleString("en-US", { maximumFractionDigits: 1 });
13020
13275
  function functionLines(list) {
@@ -13035,8 +13290,13 @@ function functionLines(list) {
13035
13290
  return `${fn.name.padEnd(width)} ${version2.padEnd(versionWidth)} ${verify} ${statusWord(fn)} ${fn.url}${stale}`;
13036
13291
  });
13037
13292
  lines.push(
13038
- `Up to ${list.limits.functions.toLocaleString("en-US")} functions per ${list.limits.functionsScope} on this plan.`
13293
+ `Up to ${list.limits.functions.toLocaleString("en-US")} functions per project.`
13039
13294
  );
13295
+ const today = list.today;
13296
+ if (today)
13297
+ lines.push(
13298
+ `Functions today (UTC, the project's): ${today.invocations.toLocaleString("en-US")} calls, ${today.cpuMs.toLocaleString("en-US")} ms of CPU.`
13299
+ );
13040
13300
  const usage = list.usage;
13041
13301
  if (usage !== null) {
13042
13302
  const percent2 = usage.cpuMsLimit > 0 ? Math.floor(usage.cpuMs / usage.cpuMsLimit * 100) : 0;
@@ -14067,6 +14327,8 @@ function meterRow(meter, limitsKnown) {
14067
14327
  const label3 = METRIC_LABELS[meter.metric];
14068
14328
  const used = formatCount(meter.used);
14069
14329
  if (!limitsKnown) return [label3, used, "\u2014", "\u2014"];
14330
+ if (typeof meter.perProject === "number")
14331
+ return [label3, used, `${formatCount(meter.perProject)} per project`, "\u2014"];
14070
14332
  if (meter.limit === null) return [label3, used, "No limit", "\u2014"];
14071
14333
  if (meter.limit === 0) return [label3, used, "Not included", "\u2014"];
14072
14334
  return [label3, used, formatCount(meter.limit), usedPercent(meter)];
@@ -14408,7 +14670,7 @@ Usage:
14408
14670
  naro backend users delete <id|username|email>
14409
14671
  Delete a user and end its sessions (asks first; --yes to skip)
14410
14672
  naro policy list [--table <t>] Row-level security policies as CREATE POLICY (no policy = deny)
14411
- naro policy rules [--table <t>] Each table's policies as rules (public, authenticated, owner, none) and conditions
14673
+ naro policy rules [--table <t>] Each table's policies as rules (public, authenticated, owner, none, custom, invalid) and conditions
14412
14674
  naro policy create "CREATE POLICY <name> ON <table> \u2026" | --file <policies.sql>
14413
14675
  Postgres's CREATE POLICY: AS PERMISSIVE|RESTRICTIVE,
14414
14676
  FOR ALL|SELECT|INSERT|UPDATE|DELETE, TO anon|authenticated|public,
@@ -14469,7 +14731,8 @@ Usage:
14469
14731
  naro functions new <name> Write naro/functions/<name>/index.ts, a handler to start from
14470
14732
  naro functions deploy [name\u2026] [--no-verify-jwt] [--import-map <file>] [--skip-check]
14471
14733
  Bundle on this machine (Node.js 22 or later), check that it starts, upload;
14472
- every function in the folder when no name is given
14734
+ every function in the folder when no name is given;
14735
+ several go live together in one upload
14473
14736
  naro functions versions <name> Its stored versions, newest first
14474
14737
  naro functions rollback <name> [<version>]
14475
14738
  Put an earlier version live again (the one before the live one by default)