naro-js 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -243,6 +243,8 @@ const {
243
243
 
244
244
  // What came back: data.type is "oauth", "email_verification", "recovery"
245
245
  // or null, and a failure is the error (OAUTH_ACCESS_DENIED, TOKEN_EXPIRED…).
246
+ // A reset link also names whose password it resets, masked: data.email is
247
+ // "a***@example.com" — show it on the page.
246
248
  const { data, error } = await naro.auth.getSessionFromUrl();
247
249
 
248
250
  // The confirmation link only confirms the address. After you confirm, sign in.
@@ -258,8 +260,9 @@ await naro.auth.resendVerification(email, {
258
260
  await naro.auth.resetPasswordForEmail(email, {
259
261
  redirectTo: APP + "/auth/reset",
260
262
  });
261
- // On /auth/reset, after getSessionFromUrl() gave type "recovery" — within
262
- // 15 minutes, while nobody is signed in on this browser:
263
+ // On /auth/reset, after getSessionFromUrl() gave type "recovery" (and the
264
+ // page showed data.email) — within 15 minutes, while nobody is signed in
265
+ // on this browser:
263
266
  await naro.auth.updateUser({ password: newPassword });
264
267
  ```
265
268
 
@@ -306,16 +309,25 @@ await naro.auth.updateUser({ password: newPassword });
306
309
  (`OAUTH_ACCOUNT_NOT_LINKED`) — confirming clears that password
307
310
  (`PASSWORD_RESET_REQUIRED`): set one with `resetPasswordForEmail`.
308
311
  - `updateUser({ password })` sets a new password in the recovery flow only
309
- (`PASSWORD_RECOVERY_REQUIRED` otherwise, with no request). Like
310
- supabase-js's recovery session, reading a reset link signs out whoever
311
- was signed in on that browser, and `updateUser` acts on the link's
312
- account: it uses the token only while nobody is signed in, for 15 minutes
313
- after the link was opened, and `signIn`, `signUp`, `signInWithOAuth` and
314
- `signOut` forget it — a link left open on a shared computer is not the
315
- next person's to use. A reset ends every session of
316
- the account and unlinks the Google or GitHub sign-ins made before it
317
- (sign in with the provider again to link it back); sign in with the new
318
- password afterwards.
312
+ (`PASSWORD_RECOVERY_REQUIRED` otherwise, with no request). Reading a reset
313
+ link asks the backend whose token it is — the URL's own words cannot be
314
+ trusted, since anyone holding a reset link of their own can send its
315
+ landing URL to someone else — and answers the account's address masked
316
+ (`data.email`, `a***@example.com`): show it, so the person sees whose
317
+ password they are about to set. A link for another account than the one
318
+ signed in on this browser is not used: the session stays, and
319
+ `getSessionFromUrl()` answers `RECOVERY_FOR_ANOTHER_USER` (with the
320
+ link's account in `details.email`). Otherwise, like supabase-js's recovery
321
+ session, the link signs out whoever was signed in on that browser, and
322
+ `updateUser` acts on the link's account: it uses the token only while
323
+ nobody is signed in, for 15 minutes after the link was opened, and
324
+ `signIn`, `signUp`, `signInWithOAuth` and `signOut` forget it — a link
325
+ left open on a shared computer is not the next person's to use. A token
326
+ the backend does not know (expired, spent, forged) is `INVALID_TOKEN` as
327
+ the link is read, and nothing is kept. A reset ends every session of the
328
+ account and unlinks the Google or GitHub sign-ins made before it (sign in
329
+ with the provider again to link it back); sign in with the new password
330
+ afterwards.
319
331
  - `resetPasswordForEmail` and `resendVerification` answer the same whether or
320
332
  not the address has an account.
321
333
  - With email verification required, `signUp` answers the user without a
@@ -854,6 +866,8 @@ await naro.removeChannel(room);
854
866
  policies, so that table needs one too, or every private join is refused.
855
867
  Declare a type (`TEXT`, `INTEGER`, `REAL`) on every column a select policy
856
868
  reads.
869
+ - `.on("broadcast", { event: "*" }, …)` hears every broadcast event on the
870
+ channel (0.9.0 and later); a listener with a name hears that event only.
857
871
  - Nothing that changed while the connection was away comes again: read again
858
872
  when the channel says `SUBSCRIBED`. `BULK_CHANGE` (one request changed over
859
873
  1,000 rows or 8 MiB) and `EVENTS_DROPPED` reach `.on("system", {}, …)` with
@@ -959,8 +973,10 @@ await naro.removeChannel(room);
959
973
  (`This connection sent messages faster than a connection may.`) with
960
974
  `lost: true`: sent again, such a message may arrive twice. Keep such sends
961
975
  to 10 a second for all of a client's channels together.
962
- - Unlike supabase-js: broadcast and presence listeners match one event name
963
- exactly, case included — `{ event: "*" }` hears nothing; `DELETE` events
976
+ - Unlike supabase-js: a broadcast listener with a name and every presence
977
+ listener match that event name exactly, case included — presence has no
978
+ `*`, while broadcast's `{ event: "*" }` hears every event, as in
979
+ supabase-js (0.9.0 and later); `DELETE` events
964
980
  pass the select policies too; a subscription takes one filter (`eq`, `neq`,
965
981
  `lt`, `lte`, `gt`, `gte`, `in`); settings Naro does not take
966
982
  (`broadcast.replay`) are refused with `INVALID_CHANNEL_CONFIG`; `send()` and
@@ -968,9 +984,16 @@ await naro.removeChannel(room);
968
984
  `lost` too), not `'ok' | 'timed out' | 'error'`.
969
985
  - The service key receives every change of a published table, whatever the
970
986
  policies say: keep it on your server (`createClient` refuses it in a
971
- browser). A server that writes with `env.DB` broadcasts with
972
- `POST /_naro/v1/realtime/broadcast` and the API key: up to 100 messages and
973
- 1 MiB, all sent or none (202).
987
+ browser). On a server it goes in the `apikey` header of the connection,
988
+ never in its URL, where edge and worker logs would keep it: Node 22 or
989
+ later, Bun and `ws` take `{ headers }` on the WebSocket constructor, and
990
+ on Cloudflare Workers the connection is opened with `fetch` and
991
+ `Upgrade: websocket`. Deno's own WebSocket cannot send headers: there the
992
+ connection is refused (`WEBSOCKET_HEADERS_UNAVAILABLE`) until you pass
993
+ `ws` (`npm:ws`) as `realtime.WebSocket`. The anon key stays in the URL,
994
+ which a browser's WebSocket needs. A server that writes with `env.DB`
995
+ broadcasts with `POST /_naro/v1/realtime/broadcast` and the API key: up to
996
+ 100 messages and 1 MiB, all sent or none (202).
974
997
  - `createClient({ realtime: { WebSocket, heartbeatIntervalMs, timeoutMs } })`
975
998
  — a heartbeat every 25,000 ms and answers within 10,000 ms by default, each
976
999
  in whole milliseconds from 1 to 2,147,483,647 (the longest a timer waits).
@@ -101,11 +101,12 @@ export declare class AuthClient {
101
101
  * "(P1-2 리뷰 반영)".)
102
102
  *
103
103
  * Like supabase-js, it acts on the recovery link's account, which took the
104
- * place of any signed-in session when the link was read — so the token is
105
- * used only while no session is signed in on this browser, for 15 minutes
106
- * after the link was opened, and never after a sign-in, sign-up or
107
- * sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
108
- * with no request.
104
+ * place of any signed-in session when the link was read (a link for
105
+ * another account than the signed-in one is not read, `#recoveryAnswer`)
106
+ * — so the token is used only while no session is signed in on this
107
+ * browser, for 15 minutes after the link was opened, and never after a
108
+ * sign-in, sign-up or sign-out forgot it (PR #68 review 6). Otherwise
109
+ * PASSWORD_RECOVERY_REQUIRED, with no request.
109
110
  */
110
111
  updateUser(attributes: UserAttributes): Promise<AuthActionResponse>;
111
112
  /**
package/dist/errors.d.ts CHANGED
@@ -44,6 +44,13 @@ export declare function invalidSession(): NaroError;
44
44
  export declare function codeVerifierMissing(): NaroError;
45
45
  /** `updateUser({ password })` with no reset link opened. */
46
46
  export declare function passwordRecoveryRequired(): NaroError;
47
+ /**
48
+ * A reset link for one account read on a browser signed in as another
49
+ * (security audit 2026-11-14 F6): the link is not used and the session is
50
+ * kept, so a password typed here cannot land on an account the sender
51
+ * holds. `details.email` is the link's account, masked.
52
+ */
53
+ export declare function recoveryForAnotherUser(email: string | null): NaroError;
47
54
  /** `signInWithOAuth` with a provider the worker does not know. */
48
55
  export declare function invalidProvider(): NaroError;
49
56
  /**
@@ -58,6 +65,13 @@ export declare function cryptoUnavailable(): NaroError;
58
65
  export declare const SERVICE_KEY_OFF_SERVER_MESSAGE = "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.";
59
66
  /** No WebSocket in this runtime, and none passed to createClient. */
60
67
  export declare function websocketUnavailable(): NaroError;
68
+ /**
69
+ * The service key goes in the `apikey` header of the Realtime connection
70
+ * (security audit 2026-11-14 F7), which the runtime's own WebSocket cannot
71
+ * send — Deno's — and no WebSocket that can was passed. The key is never
72
+ * put in the URL instead.
73
+ */
74
+ export declare function websocketHeadersUnavailable(): NaroError;
61
75
  /**
62
76
  * The server did not answer a join, a request or the opening in `ms`. Whole
63
77
  * seconds, never fewer than one: a `timeoutMs` under 500 must not read
package/dist/index.js CHANGED
@@ -49,6 +49,13 @@ function passwordRecoveryRequired() {
49
49
  message: "updateUser({ password }) works in the password recovery flow: open the link resetPasswordForEmail sent, then call it on that page."
50
50
  };
51
51
  }
52
+ function recoveryForAnotherUser(email) {
53
+ return {
54
+ code: "RECOVERY_FOR_ANOTHER_USER",
55
+ 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.",
56
+ details: { email }
57
+ };
58
+ }
52
59
  function invalidProvider() {
53
60
  return {
54
61
  code: "VALIDATION_ERROR",
@@ -74,6 +81,12 @@ function websocketUnavailable() {
74
81
  message: "This runtime has no WebSocket. Pass one to createClient({ realtime: { WebSocket } })."
75
82
  };
76
83
  }
84
+ function websocketHeadersUnavailable() {
85
+ return {
86
+ code: "WEBSOCKET_HEADERS_UNAVAILABLE",
87
+ 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 } })."
88
+ };
89
+ }
77
90
  function realtimeTimedOut(ms) {
78
91
  const seconds = Math.max(1, Math.round(ms / 1e3));
79
92
  return {
@@ -751,8 +764,11 @@ function isBrowser(scope = globalThis) {
751
764
  const g = scope;
752
765
  return g.window !== void 0 && g.document !== void 0;
753
766
  }
754
- function isWorkerd(g) {
755
- return g.navigator?.userAgent === "Cloudflare-Workers";
767
+ function isWorkerd(scope = globalThis) {
768
+ return scope.navigator?.userAgent === "Cloudflare-Workers";
769
+ }
770
+ function isDeno(scope = globalThis) {
771
+ return typeof scope.Deno?.version?.deno === "string";
756
772
  }
757
773
  function hasServerSignal(g) {
758
774
  return isWorkerd(g) || // Vercel's edge runtime and Next.js's edge (middleware, edge routes).
@@ -1136,12 +1152,8 @@ var AuthClient = class {
1136
1152
  await this.#flow.verifiers.take(fragment.naro_flow);
1137
1153
  return { data: none, error: flowError(type, fragment.naro_error) };
1138
1154
  }
1139
- if (type === "recovery" && fragment.naro_token) {
1140
- await this.#http.tokens.clear();
1141
- await this.#http.accessJwt?.clear();
1142
- await this.#flow.recovery.keep(fragment.naro_token);
1143
- return { data: none, error: null };
1144
- }
1155
+ if (type === "recovery" && fragment.naro_token)
1156
+ return this.#recoveryAnswer(none, fragment.naro_token);
1145
1157
  if (fragment.naro_code) {
1146
1158
  const traded = await this.exchangeCodeForSession(
1147
1159
  fragment.naro_code,
@@ -1151,6 +1163,45 @@ var AuthClient = class {
1151
1163
  }
1152
1164
  return { data: none, error: null };
1153
1165
  }
1166
+ /**
1167
+ * A reset link's token, read on the page it opened (security audit
1168
+ * 2026-11-14 F6). The fragment is whoever sent the link's to write — one
1169
+ * who holds a reset token of their own account can send the landing URL
1170
+ * itself — so the worker is asked whose token it is: the answer names the
1171
+ * account (masked, `data.email`, for the page to show) and whether it is
1172
+ * the one signed in here.
1173
+ *
1174
+ * Signed in as another account, the link is not used: the session stays,
1175
+ * the token is not kept, and the answer is RECOVERY_FOR_ANOTHER_USER — a
1176
+ * password typed here cannot land on the sender's account. Otherwise,
1177
+ * like supabase-js's recovery session, the link takes the place of
1178
+ * whoever was signed in on this browser (the reset ends every session of
1179
+ * that account anyway, PR #68 review 6): `updateUser` then acts on the
1180
+ * link's account alone. A token the worker does not know is INVALID_TOKEN
1181
+ * here, and nothing is kept.
1182
+ */
1183
+ async #recoveryAnswer(none, token) {
1184
+ const result = await call(this.#http, {
1185
+ method: "POST",
1186
+ path: "/auth/recovery-owner",
1187
+ body: { token },
1188
+ withToken: true,
1189
+ // The page's queries wait on this; they must not wait forever.
1190
+ timeoutMs: EXCHANGE_TIMEOUT_MS
1191
+ });
1192
+ if (result.error !== null) return { data: none, error: result.error };
1193
+ const { body } = result.answer;
1194
+ const owner = isRecord(body) && isRecord(body.data) ? body.data : null;
1195
+ if (owner === null)
1196
+ return { data: none, error: invalidResponse(result.answer.status) };
1197
+ const email = typeof owner.email === "string" ? owner.email : null;
1198
+ if (owner.self === false)
1199
+ return { data: none, error: recoveryForAnotherUser(email) };
1200
+ await this.#http.tokens.clear();
1201
+ await this.#http.accessJwt?.clear();
1202
+ await this.#flow.recovery.keep(token);
1203
+ return { data: { ...none, email }, error: null };
1204
+ }
1154
1205
  /**
1155
1206
  * Emails a password reset link, when the address has an account. The
1156
1207
  * answer is the same either way — it does not say whether it had one.
@@ -1174,11 +1225,12 @@ var AuthClient = class {
1174
1225
  * "(P1-2 리뷰 반영)".)
1175
1226
  *
1176
1227
  * Like supabase-js, it acts on the recovery link's account, which took the
1177
- * place of any signed-in session when the link was read — so the token is
1178
- * used only while no session is signed in on this browser, for 15 minutes
1179
- * after the link was opened, and never after a sign-in, sign-up or
1180
- * sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
1181
- * with no request.
1228
+ * place of any signed-in session when the link was read (a link for
1229
+ * another account than the signed-in one is not read, `#recoveryAnswer`)
1230
+ * — so the token is used only while no session is signed in on this
1231
+ * browser, for 15 minutes after the link was opened, and never after a
1232
+ * sign-in, sign-up or sign-out forgot it (PR #68 review 6). Otherwise
1233
+ * PASSWORD_RECOVERY_REQUIRED, with no request.
1182
1234
  */
1183
1235
  async updateUser(attributes) {
1184
1236
  await this.#pendingUrl();
@@ -2901,9 +2953,79 @@ function channelNameProblem(name) {
2901
2953
  function socketUrl(baseUrl, apiKey) {
2902
2954
  const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
2903
2955
  url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
2904
- return `${url.href}?apikey=${encodeURIComponent(apiKey)}&vsn=${REALTIME_PROTOCOL_VERSION}`;
2956
+ const key = apiKey === null ? "" : `apikey=${encodeURIComponent(apiKey)}&`;
2957
+ return `${url.href}?${key}vsn=${REALTIME_PROTOCOL_VERSION}`;
2905
2958
  }
2906
2959
 
2960
+ // src/realtime/upgrade-socket.ts
2961
+ var UpgradeSocket = class {
2962
+ onopen = null;
2963
+ onmessage = null;
2964
+ onclose = null;
2965
+ onerror = null;
2966
+ #ws = null;
2967
+ /** `close()` was called, or the open failed: no event goes out after. */
2968
+ #closed = null;
2969
+ constructor(fetchLike, url, headers) {
2970
+ let pending;
2971
+ try {
2972
+ pending = fetchLike(url, {
2973
+ headers: { Upgrade: "websocket", ...headers }
2974
+ });
2975
+ } catch {
2976
+ pending = Promise.reject(new Error("fetch threw"));
2977
+ }
2978
+ pending.then(
2979
+ (response) => this.#arrived(response),
2980
+ () => this.#failed()
2981
+ );
2982
+ }
2983
+ #arrived(response) {
2984
+ const ws = response.webSocket ?? null;
2985
+ if (ws === null) {
2986
+ void response.body?.cancel?.().catch(() => void 0);
2987
+ this.#failed();
2988
+ return;
2989
+ }
2990
+ if (this.#closed !== null) {
2991
+ try {
2992
+ ws.accept();
2993
+ ws.close(this.#closed.code, this.#closed.reason);
2994
+ } catch {
2995
+ }
2996
+ return;
2997
+ }
2998
+ this.#ws = ws;
2999
+ ws.addEventListener("message", (event) => {
3000
+ this.onmessage?.({ data: event.data });
3001
+ });
3002
+ ws.addEventListener("close", (event) => {
3003
+ this.onclose?.({ code: event.code, reason: event.reason ?? "" });
3004
+ });
3005
+ ws.addEventListener("error", () => {
3006
+ this.onerror?.();
3007
+ });
3008
+ ws.accept();
3009
+ this.onopen?.();
3010
+ }
3011
+ /** No connection came of it: a close before the open. */
3012
+ #failed() {
3013
+ if (this.#closed !== null) return;
3014
+ this.#closed = { code: void 0, reason: void 0 };
3015
+ this.onclose?.({ code: 1006, reason: "" });
3016
+ }
3017
+ send(data) {
3018
+ if (this.#ws === null)
3019
+ throw new Error("The realtime connection is not open.");
3020
+ this.#ws.send(data);
3021
+ }
3022
+ close(code, reason) {
3023
+ if (this.#closed !== null) return;
3024
+ this.#closed = { code, reason };
3025
+ this.#ws?.close(code, reason);
3026
+ }
3027
+ };
3028
+
2907
3029
  // src/realtime/socket.ts
2908
3030
  var HEARTBEAT_MS = 25e3;
2909
3031
  var TIMEOUT_MS = 1e4;
@@ -3318,19 +3440,19 @@ var RealtimeSocket = class {
3318
3440
  }, this.#timeoutMs);
3319
3441
  }
3320
3442
  #open() {
3321
- const Impl = this.#config.options.WebSocket ?? globalThis.WebSocket;
3443
+ const passed = this.#config.options.WebSocket;
3444
+ const runtimes = globalThis.WebSocket;
3445
+ const Impl = passed ?? runtimes;
3322
3446
  if (typeof Impl !== "function")
3323
3447
  return this.#stop(websocketUnavailable(), true);
3324
3448
  if (this.#config.service && !isKnownServer())
3325
3449
  return this.#stop(serviceKeyOffServer(), true);
3326
- let ws;
3327
- try {
3328
- ws = new Impl(
3329
- socketUrl(this.#config.baseUrl, this.#config.apiKey)
3330
- );
3331
- } catch (error) {
3332
- return this.#stop(socketRefused(error), true);
3333
- }
3450
+ const made = this.#config.service ? this.#serviceSocket(
3451
+ Impl,
3452
+ passed === void 0 || passed === runtimes
3453
+ ) : this.#anonSocket(Impl);
3454
+ if ("error" in made) return this.#stop(made.error, true);
3455
+ const ws = made.ws;
3334
3456
  this.#generation += 1;
3335
3457
  this.#ws = ws;
3336
3458
  this.#state = "connecting";
@@ -3345,6 +3467,48 @@ var RealtimeSocket = class {
3345
3467
  this.#timeoutMs
3346
3468
  );
3347
3469
  }
3470
+ /**
3471
+ * The anon key's connection: the one place a key goes into a URL (design
3472
+ * §10 "3-2d") — a browser's WebSocket has no headers to put it in.
3473
+ */
3474
+ #anonSocket(Impl) {
3475
+ try {
3476
+ return {
3477
+ ws: new Impl(socketUrl(this.#config.baseUrl, this.#config.apiKey))
3478
+ };
3479
+ } catch (error) {
3480
+ return { error: socketRefused(error) };
3481
+ }
3482
+ }
3483
+ /**
3484
+ * The service key's connection (security audit 2026-11-14 F7): the key in
3485
+ * the `apikey` header and never in the URL, where edge and worker logs
3486
+ * would keep it. Node 22+, Bun and `ws` take `{ headers }` on the
3487
+ * constructor; workerd's constructor takes none, so there — unless the
3488
+ * app passed a WebSocket of its own, other than the runtime's — the
3489
+ * connection is a `fetch` upgrade (`UpgradeSocket`); Deno's takes none
3490
+ * either, and has no upgrade fetch, so the service key is refused there
3491
+ * until a WebSocket that takes headers (`ws`) is passed. `runtimes` says
3492
+ * the class is the runtime's own.
3493
+ */
3494
+ #serviceSocket(Impl, runtimes) {
3495
+ const headers = { apikey: this.#config.apiKey };
3496
+ const url = socketUrl(this.#config.baseUrl, null);
3497
+ if (runtimes && isWorkerd())
3498
+ return {
3499
+ ws: new UpgradeSocket(
3500
+ this.#config.fetch ?? globalThis.fetch,
3501
+ `http${url.slice(2)}`,
3502
+ headers
3503
+ )
3504
+ };
3505
+ if (runtimes && isDeno()) return { error: websocketHeadersUnavailable() };
3506
+ try {
3507
+ return { ws: new Impl(url, { headers }) };
3508
+ } catch (error) {
3509
+ return { error: socketRefused(error) };
3510
+ }
3511
+ }
3348
3512
  #opened(ws) {
3349
3513
  if (this.#ws !== ws || this.#state !== "connecting") return;
3350
3514
  clearTimeout(this.#openTimer);
@@ -4093,7 +4257,7 @@ var RealtimeChannel = class {
4093
4257
  payload: isRecord(payload.payload) ? payload.payload : {}
4094
4258
  };
4095
4259
  for (const binding of this.#bindings)
4096
- if (binding.type === "broadcast" && binding.event === event)
4260
+ if (binding.type === "broadcast" && (binding.event === event || binding.event === "*"))
4097
4261
  safely(() => binding.callback(message));
4098
4262
  }
4099
4263
  #system(payload) {
@@ -4534,6 +4698,7 @@ var RealtimeClient = class {
4534
4698
  apiKey: http.apiKey,
4535
4699
  service,
4536
4700
  options,
4701
+ fetch: http.fetch,
4537
4702
  events: {
4538
4703
  // Not awaited, and not live yet: onLive says when the hub took it.
4539
4704
  opened: () => {
@@ -72,7 +72,9 @@ export declare function clientFrame(topic: string, event: string, payload: Recor
72
72
  export declare function channelNameProblem(name: string): string | undefined;
73
73
  /**
74
74
  * The connection's URL (original spec §6.3): a browser's WebSocket cannot
75
- * send headers, so the key rides in the query, encoded. Nothing logs it;
76
- * an error never quotes it (socket.ts).
75
+ * send headers, so the anon key rides in the query, encoded. Nothing logs
76
+ * it; an error never quotes it (socket.ts). With `apiKey` null the URL
77
+ * carries no key: the service key goes in the `apikey` header instead
78
+ * (security audit 2026-11-14 F7), where edge and worker logs do not keep it.
77
79
  */
78
- export declare function socketUrl(baseUrl: string, apiKey: string): string;
80
+ export declare function socketUrl(baseUrl: string, apiKey: string | null): string;
@@ -1,4 +1,5 @@
1
1
  import { type NaroError } from "../errors.js";
2
+ import type { FetchLike } from "../http.js";
2
3
  import { type ServerFrame } from "./protocol.js";
3
4
  import type { RealtimeOptions } from "./types.js";
4
5
  export type Reply = {
@@ -27,6 +28,11 @@ export type SocketConfig = {
27
28
  service: boolean;
28
29
  options: RealtimeOptions;
29
30
  events: SocketEvents;
31
+ /**
32
+ * The client's `fetch`, for the service key's connection on workerd
33
+ * (`UpgradeSocket`); the runtime's when none was passed to createClient.
34
+ */
35
+ fetch?: FetchLike | undefined;
30
36
  };
31
37
  /**
32
38
  * Whose frame a request is, which sets its turn (`request()`): the app's
@@ -80,7 +80,13 @@ export type RealtimePresenceLeavePayload = {
80
80
  /** What the key holds after this leave; [] when it was the last. */
81
81
  currentPresences: RealtimePresenceEntry[];
82
82
  };
83
- /** The global WebSocket, or `ws`'s: any class built from a URL. */
83
+ /**
84
+ * The global WebSocket, or `ws`'s: any class built from a URL. On a server
85
+ * the service key's connection is built from the URL and a second argument,
86
+ * `{ headers }` (security audit 2026-11-14 F7), which Node's, Bun's and
87
+ * `ws`'s constructors take; the type names the first argument only, so a
88
+ * browser's `WebSocket` (typed with `protocols` second) still fits.
89
+ */
84
90
  export type WebSocketConstructor = new (url: string) => unknown;
85
91
  export type RealtimeOptions = {
86
92
  WebSocket?: WebSocketConstructor;
@@ -0,0 +1,19 @@
1
+ import type { FetchLike } from "../http.js";
2
+ type MessageEvent = {
3
+ data: unknown;
4
+ };
5
+ type CloseEvent = {
6
+ code: number;
7
+ reason?: string;
8
+ };
9
+ export declare class UpgradeSocket {
10
+ #private;
11
+ onopen: (() => void) | null;
12
+ onmessage: ((event: MessageEvent) => void) | null;
13
+ onclose: ((event: CloseEvent) => void) | null;
14
+ onerror: (() => void) | null;
15
+ constructor(fetchLike: FetchLike, url: string, headers: Record<string, string>);
16
+ send(data: string): void;
17
+ close(code?: number, reason?: string): void;
18
+ }
19
+ export {};
package/dist/runtime.d.ts CHANGED
@@ -4,6 +4,20 @@
4
4
  * service key is allowed is `isKnownServer`'s question, which is stricter.
5
5
  */
6
6
  export declare function isBrowser(scope?: object): boolean;
7
+ /**
8
+ * workerd sends this user agent whatever the compatibility date or flags
9
+ * (measured on workerd 1.20260815 at 2023-01-01 and 2026-08-19, with and
10
+ * without nodejs_compat). Its global scope is a ServiceWorkerGlobalScope, so
11
+ * the worker-scope signals below must not count against it.
12
+ */
13
+ export declare function isWorkerd(scope?: object): boolean;
14
+ /**
15
+ * Deno, whose own WebSocket takes no headers (the service key's `apikey`
16
+ * header on a Realtime connection, security audit 2026-11-14 F7): there the
17
+ * SDK asks for a WebSocket that does (`ws`) instead of putting the key in
18
+ * the URL.
19
+ */
20
+ export declare function isDeno(scope?: object): boolean;
7
21
  /**
8
22
  * Whether a service key may be used here (spec §7): only where the runtime
9
23
  * says it is a server and nothing says it is a user's device. Anything else —
package/dist/types.d.ts CHANGED
@@ -190,12 +190,19 @@ export type AuthFlowType = "oauth" | "email_verification" | "recovery";
190
190
  /**
191
191
  * `getSessionFromUrl()`: `type` null when the URL carried nothing of Naro's.
192
192
  * `recovery` holds no session — the page asks for a new password and calls
193
- * `updateUser({ password })`.
193
+ * `updateUser({ password })` — and names whose password that is.
194
194
  */
195
195
  export type UrlSessionResponse = AuthResponse<{
196
196
  session: Session | null;
197
197
  user: User | null;
198
198
  type: AuthFlowType | null;
199
+ /**
200
+ * Recovery only: the address of the account the reset link is for, as
201
+ * the worker knows it, masked to its first character (`a***@example.com`)
202
+ * — show it, so the person sees whose password they are about to set.
203
+ * Null for an account without an email.
204
+ */
205
+ email?: string | null;
199
206
  }>;
200
207
  export type EmailRedirectOptions = {
201
208
  /** A URL on an allowed origin of the project; omitted, its own origin. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-js",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Auth, data and files for your Naro project, from the browser or the server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -42,10 +42,10 @@
42
42
  "typescript": "7.0.2",
43
43
  "vitest": "3.2.7",
44
44
  "@next-deploy/backend-worker": "0.1.0",
45
+ "@next-deploy/config": "0.1.0",
45
46
  "@next-deploy/data-core": "0.1.0",
46
47
  "@next-deploy/functions-runtime": "0.1.0",
47
- "@next-deploy/sdk": "0.1.0",
48
- "@next-deploy/config": "0.1.0"
48
+ "@next-deploy/sdk": "0.1.0"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "tsup && pnpm build:types",