naro-js 0.7.1 → 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 +151 -22
- package/dist/auth/client.d.ts +6 -5
- package/dist/errors.d.ts +14 -0
- package/dist/http.d.ts +9 -0
- package/dist/index.js +199 -24
- package/dist/realtime/protocol.d.ts +5 -3
- package/dist/realtime/socket.d.ts +6 -0
- package/dist/realtime/types.d.ts +7 -1
- package/dist/realtime/upgrade-socket.d.ts +19 -0
- package/dist/runtime.d.ts +14 -0
- package/dist/ssr/cookies.d.ts +54 -0
- package/dist/ssr/storage.d.ts +37 -0
- package/dist/ssr.d.ts +39 -0
- package/dist/ssr.js +205 -0
- package/dist/types.d.ts +8 -1
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npm install naro-js
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
The backend is off until you turn it on, one project at a time: Turn on in
|
|
13
|
-
the Backend
|
|
13
|
+
the Backend section of the project's Settings page, or `naro backend enable`.
|
|
14
14
|
`naro backend status` prints the `url` and the anon key this client takes.
|
|
15
15
|
`naro backend disable` turns it off again: in up to about 90 seconds every call
|
|
16
16
|
answers `404 NOT_FOUND` ("There is no Naro backend at this host."). The data,
|
|
@@ -52,11 +52,11 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
52
52
|
`NARO_ANON_KEY`), saved for all environments with `naro env add` and in
|
|
53
53
|
`.env.local` for local development. Production and preview use the values
|
|
54
54
|
you saved. Server code reads `NARO_URL` and `NARO_ANON_KEY`, so save those
|
|
55
|
-
two for production and preview as well. The service key, which
|
|
55
|
+
two for production and preview as well. The service key, which Naro never
|
|
56
56
|
sets, goes in `NARO_SERVICE_KEY`: save one for each environment, because a
|
|
57
57
|
production service key saved for all environments is refused (401) by an
|
|
58
58
|
environment's backend. A named environment of a project with a backend has a backend of
|
|
59
|
-
its own, and
|
|
59
|
+
its own, and Naro sets those names to its address and anon key, so the same
|
|
60
60
|
code calls the right backend everywhere — an address written into the code
|
|
61
61
|
calls production's from every environment. On a custom domain, save
|
|
62
62
|
`NEXT_PUBLIC_NARO_URL=https://<your domain>` for production.
|
|
@@ -83,6 +83,8 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
83
83
|
- The session token lives in `localStorage` in a browser and in memory
|
|
84
84
|
elsewhere. On a server, create one client per request, or pass
|
|
85
85
|
`auth: { storage }` with your own `getItem` / `setItem` / `removeItem`.
|
|
86
|
+
To render pages as the signed-in user, keep it in cookies with
|
|
87
|
+
`naro-js/ssr` — see [Server rendering](#server-rendering).
|
|
86
88
|
- Queries and Storage calls carry a short-lived access token (15 minutes)
|
|
87
89
|
next to the session, which lasts days. The client renews it on its own
|
|
88
90
|
before it expires, and once more if the backend refuses it — nothing to
|
|
@@ -91,7 +93,7 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
91
93
|
and the client forgets both tokens: sign in again.
|
|
92
94
|
- Types: `createClient<Database>()` with the `Database` that
|
|
93
95
|
`naro gen types` writes — see [Types](#types).
|
|
94
|
-
- Sign-in is
|
|
96
|
+
- Sign-in is Naro Auth, the project's own (like Supabase Auth). Email and
|
|
95
97
|
password sign-up and sign-in need no setup: they work as soon as the
|
|
96
98
|
backend is on. Resend is needed only to send confirmation and password
|
|
97
99
|
reset emails.
|
|
@@ -241,6 +243,8 @@ const {
|
|
|
241
243
|
|
|
242
244
|
// What came back: data.type is "oauth", "email_verification", "recovery"
|
|
243
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.
|
|
244
248
|
const { data, error } = await naro.auth.getSessionFromUrl();
|
|
245
249
|
|
|
246
250
|
// The confirmation link only confirms the address. After you confirm, sign in.
|
|
@@ -256,8 +260,9 @@ await naro.auth.resendVerification(email, {
|
|
|
256
260
|
await naro.auth.resetPasswordForEmail(email, {
|
|
257
261
|
redirectTo: APP + "/auth/reset",
|
|
258
262
|
});
|
|
259
|
-
// On /auth/reset, after getSessionFromUrl() gave type "recovery"
|
|
260
|
-
// 15 minutes, while nobody is signed in
|
|
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:
|
|
261
266
|
await naro.auth.updateUser({ password: newPassword });
|
|
262
267
|
```
|
|
263
268
|
|
|
@@ -304,16 +309,25 @@ await naro.auth.updateUser({ password: newPassword });
|
|
|
304
309
|
(`OAUTH_ACCOUNT_NOT_LINKED`) — confirming clears that password
|
|
305
310
|
(`PASSWORD_RESET_REQUIRED`): set one with `resetPasswordForEmail`.
|
|
306
311
|
- `updateUser({ password })` sets a new password in the recovery flow only
|
|
307
|
-
(`PASSWORD_RECOVERY_REQUIRED` otherwise, with no request).
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
317
331
|
- `resetPasswordForEmail` and `resendVerification` answer the same whether or
|
|
318
332
|
not the address has an account.
|
|
319
333
|
- With email verification required, `signUp` answers the user without a
|
|
@@ -325,6 +339,110 @@ await naro.auth.updateUser({ password: newPassword });
|
|
|
325
339
|
`TIMEOUT` ("The Naro backend did not answer within 15 seconds. Check the
|
|
326
340
|
network and try again."), and queries go out as anon — sign in again.
|
|
327
341
|
|
|
342
|
+
## Server rendering
|
|
343
|
+
|
|
344
|
+
`naro-js/ssr` keeps the session in your app's own cookies instead of
|
|
345
|
+
`localStorage`, so a Server Component, a route handler, a server action or
|
|
346
|
+
`proxy.ts` knows who is signed in — the place `@supabase/ssr` has. The
|
|
347
|
+
backend still gets the anon key and a bearer token, never a cookie.
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
// lib/naro/client.ts — Client Components
|
|
351
|
+
import { createBrowserClient } from "naro-js/ssr";
|
|
352
|
+
|
|
353
|
+
export const naro = createBrowserClient<Database>({
|
|
354
|
+
url: process.env.NEXT_PUBLIC_NARO_URL!,
|
|
355
|
+
anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
// lib/naro/server.ts — Server Components, route handlers, server actions
|
|
361
|
+
import { cookies } from "next/headers";
|
|
362
|
+
import { createServerClient } from "naro-js/ssr";
|
|
363
|
+
|
|
364
|
+
export async function naroServer() {
|
|
365
|
+
const cookieStore = await cookies();
|
|
366
|
+
return createServerClient<Database>({
|
|
367
|
+
url: process.env.NEXT_PUBLIC_NARO_URL!,
|
|
368
|
+
anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
|
|
369
|
+
cookies: {
|
|
370
|
+
getAll: () => cookieStore.getAll(),
|
|
371
|
+
setAll(cookies) {
|
|
372
|
+
try {
|
|
373
|
+
for (const { name, value, options } of cookies)
|
|
374
|
+
cookieStore.set(name, value, options);
|
|
375
|
+
} catch {
|
|
376
|
+
// A Server Component cannot write cookies: proxy.ts does.
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// app/todos/page.tsx
|
|
384
|
+
const naro = await naroServer();
|
|
385
|
+
const { data: todos } = await naro.from("todos").select("id, title");
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
// src/proxy.ts — keeps the access token in the cookies fresh
|
|
390
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
391
|
+
import { createServerClient } from "naro-js/ssr";
|
|
392
|
+
|
|
393
|
+
export async function proxy(request: NextRequest) {
|
|
394
|
+
let response = NextResponse.next({ request });
|
|
395
|
+
const naro = createServerClient({
|
|
396
|
+
url: process.env.NEXT_PUBLIC_NARO_URL!,
|
|
397
|
+
anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
|
|
398
|
+
cookies: {
|
|
399
|
+
getAll: () => request.cookies.getAll(),
|
|
400
|
+
setAll(cookies) {
|
|
401
|
+
for (const { name, value } of cookies) request.cookies.set(name, value);
|
|
402
|
+
response = NextResponse.next({ request });
|
|
403
|
+
for (const { name, value, options } of cookies)
|
|
404
|
+
response.cookies.set(name, value, options);
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
await naro.auth.getUser();
|
|
409
|
+
return response;
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
- Sign in, sign up and sign out in the browser with `createBrowserClient`,
|
|
414
|
+
or in a server action with `naroServer()`: both write the cookies. The
|
|
415
|
+
next request renders as that user.
|
|
416
|
+
- `createBrowserClient` is one client per url and anon key on a page.
|
|
417
|
+
Where there is no page (a Client Component's server render) it keeps the
|
|
418
|
+
session in memory.
|
|
419
|
+
- `createServerClient` is one client per request. It never reads a sign-in
|
|
420
|
+
from the page URL, and takes the anon key only: a `serviceKey` throws
|
|
421
|
+
("naro-js/ssr keeps a signed-in user's session in cookies, so it takes the
|
|
422
|
+
anon key only. …"). Without `cookies.getAll` it throws too.
|
|
423
|
+
- The access token lasts 15 minutes. A Server Component that finds it about
|
|
424
|
+
to expire gets a new one for that request, but cannot write it back;
|
|
425
|
+
`getUser()` in `proxy.ts` writes it to the response, so the pages behind
|
|
426
|
+
it do not renew on every render. `getSession()` and `getUser()` renew it
|
|
427
|
+
within a minute of its expiry on every client.
|
|
428
|
+
- Google and GitHub sign-in come back to the page with the code in the URL
|
|
429
|
+
fragment, which never reaches the server: the browser client trades it
|
|
430
|
+
and writes the cookies, so the page they come back to renders signed out
|
|
431
|
+
once. Send them back to a client page (`redirectTo`) and refresh from
|
|
432
|
+
there (`router.refresh()`).
|
|
433
|
+
- One cookie per key, named for the backend's host:
|
|
434
|
+
`naro-auth-token.<host>` (the session), `naro-auth-jwt.<host>` (the access
|
|
435
|
+
token), and while a sign-in or reset link is under way
|
|
436
|
+
`naro-auth-code-verifiers.<host>` and `naro-auth-recovery.<host>`. They are
|
|
437
|
+
`Path=/`, `SameSite=Lax`, `Max-Age` 400 days (the session ends on the
|
|
438
|
+
backend, which then clears them), and `Secure` on https pages and from the
|
|
439
|
+
server. They are not `HttpOnly`: your page's JavaScript reads them, as it
|
|
440
|
+
reads `localStorage` — the same exposure to an injected script.
|
|
441
|
+
- `cookieOptions: { domain, path, sameSite, secure, maxAge }` changes them,
|
|
442
|
+
on both clients — `domain: "example.com"` to share the session with
|
|
443
|
+
subdomains, `secure: false` for a browser that refuses `Secure` cookies
|
|
444
|
+
from `http://localhost`.
|
|
445
|
+
|
|
328
446
|
## Types
|
|
329
447
|
|
|
330
448
|
Generate the types from the project's schema, and hand them to
|
|
@@ -748,6 +866,8 @@ await naro.removeChannel(room);
|
|
|
748
866
|
policies, so that table needs one too, or every private join is refused.
|
|
749
867
|
Declare a type (`TEXT`, `INTEGER`, `REAL`) on every column a select policy
|
|
750
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.
|
|
751
871
|
- Nothing that changed while the connection was away comes again: read again
|
|
752
872
|
when the channel says `SUBSCRIBED`. `BULK_CHANGE` (one request changed over
|
|
753
873
|
1,000 rows or 8 MiB) and `EVENTS_DROPPED` reach `.on("system", {}, …)` with
|
|
@@ -853,18 +973,27 @@ await naro.removeChannel(room);
|
|
|
853
973
|
(`This connection sent messages faster than a connection may.`) with
|
|
854
974
|
`lost: true`: sent again, such a message may arrive twice. Keep such sends
|
|
855
975
|
to 10 a second for all of a client's channels together.
|
|
856
|
-
- Unlike supabase-js: broadcast
|
|
857
|
-
exactly, case included —
|
|
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
|
|
858
980
|
pass the select policies too; a subscription takes one filter (`eq`, `neq`,
|
|
859
|
-
`lt`, `lte`, `gt`, `gte`, `in`); settings
|
|
981
|
+
`lt`, `lte`, `gt`, `gte`, `in`); settings Naro does not take
|
|
860
982
|
(`broadcast.replay`) are refused with `INVALID_CHANNEL_CONFIG`; `send()` and
|
|
861
983
|
the rest answer `{ data, error }` (`send()`, `track()` and `untrack()` with
|
|
862
984
|
`lost` too), not `'ok' | 'timed out' | 'error'`.
|
|
863
985
|
- The service key receives every change of a published table, whatever the
|
|
864
986
|
policies say: keep it on your server (`createClient` refuses it in a
|
|
865
|
-
browser).
|
|
866
|
-
|
|
867
|
-
|
|
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).
|
|
868
997
|
- `createClient({ realtime: { WebSocket, heartbeatIntervalMs, timeoutMs } })`
|
|
869
998
|
— a heartbeat every 25,000 ms and answers within 10,000 ms by default, each
|
|
870
999
|
in whole milliseconds from 1 to 2,147,483,647 (the longest a timer waits).
|
package/dist/auth/client.d.ts
CHANGED
|
@@ -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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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/http.d.ts
CHANGED
|
@@ -50,6 +50,15 @@ export declare const ACCESS_JWT_MARGIN_MS = 60000;
|
|
|
50
50
|
* INVALID_SESSION, if it comes, renews it (`call`, `send`).
|
|
51
51
|
*/
|
|
52
52
|
export declare function expiryOf(jwt: string): number | null;
|
|
53
|
+
/**
|
|
54
|
+
* Renews the stored access JWT when it is within `ACCESS_JWT_MARGIN_MS` of
|
|
55
|
+
* its `exp` (naro-js/ssr design 2026-11-04 decision 11): `getSession` calls
|
|
56
|
+
* it, so a server's proxy.ts gets a fresh JWT into the response cookies with
|
|
57
|
+
* one `getUser()`. A missing or unreadable JWT is left for the next query to
|
|
58
|
+
* mint; the service key and a client on an `accessToken` renew nothing.
|
|
59
|
+
* Never fails: a renewal that does not work leaves things as they were.
|
|
60
|
+
*/
|
|
61
|
+
export declare function freshAccessJwt(config: HttpConfig): Promise<void>;
|
|
53
62
|
export type CallOptions = {
|
|
54
63
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
55
64
|
/** Below `/_naro/v1`, e.g. `/data/query`. */
|
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 {
|
|
@@ -308,6 +321,15 @@ async function accessJwtFor(config, jwtStore) {
|
|
|
308
321
|
}
|
|
309
322
|
return { error: invalidSession() };
|
|
310
323
|
}
|
|
324
|
+
async function freshAccessJwt(config) {
|
|
325
|
+
const jwtStore = config.accessJwt;
|
|
326
|
+
if (jwtStore === void 0 || config.service) return;
|
|
327
|
+
const jwt = await jwtStore.get();
|
|
328
|
+
if (jwt === null) return;
|
|
329
|
+
const expiry = localExpiry(config, jwt);
|
|
330
|
+
if (expiry === null || expiry - ACCESS_JWT_MARGIN_MS > Date.now()) return;
|
|
331
|
+
await accessJwtFor(config, jwtStore);
|
|
332
|
+
}
|
|
311
333
|
async function bearerFor(config, path, withToken) {
|
|
312
334
|
if (!withToken) return { token: null, session: null };
|
|
313
335
|
const store = storeFor(config, path);
|
|
@@ -742,8 +764,11 @@ function isBrowser(scope = globalThis) {
|
|
|
742
764
|
const g = scope;
|
|
743
765
|
return g.window !== void 0 && g.document !== void 0;
|
|
744
766
|
}
|
|
745
|
-
function isWorkerd(
|
|
746
|
-
return
|
|
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";
|
|
747
772
|
}
|
|
748
773
|
function hasServerSignal(g) {
|
|
749
774
|
return isWorkerd(g) || // Vercel's edge runtime and Next.js's edge (middleware, edge routes).
|
|
@@ -1127,12 +1152,8 @@ var AuthClient = class {
|
|
|
1127
1152
|
await this.#flow.verifiers.take(fragment.naro_flow);
|
|
1128
1153
|
return { data: none, error: flowError(type, fragment.naro_error) };
|
|
1129
1154
|
}
|
|
1130
|
-
if (type === "recovery" && fragment.naro_token)
|
|
1131
|
-
|
|
1132
|
-
await this.#http.accessJwt?.clear();
|
|
1133
|
-
await this.#flow.recovery.keep(fragment.naro_token);
|
|
1134
|
-
return { data: none, error: null };
|
|
1135
|
-
}
|
|
1155
|
+
if (type === "recovery" && fragment.naro_token)
|
|
1156
|
+
return this.#recoveryAnswer(none, fragment.naro_token);
|
|
1136
1157
|
if (fragment.naro_code) {
|
|
1137
1158
|
const traded = await this.exchangeCodeForSession(
|
|
1138
1159
|
fragment.naro_code,
|
|
@@ -1142,6 +1163,45 @@ var AuthClient = class {
|
|
|
1142
1163
|
}
|
|
1143
1164
|
return { data: none, error: null };
|
|
1144
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
|
+
}
|
|
1145
1205
|
/**
|
|
1146
1206
|
* Emails a password reset link, when the address has an account. The
|
|
1147
1207
|
* answer is the same either way — it does not say whether it had one.
|
|
@@ -1165,11 +1225,12 @@ var AuthClient = class {
|
|
|
1165
1225
|
* "(P1-2 리뷰 반영)".)
|
|
1166
1226
|
*
|
|
1167
1227
|
* Like supabase-js, it acts on the recovery link's account, which took the
|
|
1168
|
-
* place of any signed-in session when the link was read
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
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.
|
|
1173
1234
|
*/
|
|
1174
1235
|
async updateUser(attributes) {
|
|
1175
1236
|
await this.#pendingUrl();
|
|
@@ -1254,6 +1315,7 @@ var AuthClient = class {
|
|
|
1254
1315
|
const renewed = tokenFrom(result.answer);
|
|
1255
1316
|
if (renewed !== null && token !== null && renewed !== token)
|
|
1256
1317
|
await tokens.replaceIf(token, renewed);
|
|
1318
|
+
await freshAccessJwt(this.#http);
|
|
1257
1319
|
return { data: { session: body.session, user }, error: null };
|
|
1258
1320
|
}
|
|
1259
1321
|
/** `{ data: { user } }`; `user` is null when signed out. */
|
|
@@ -2891,9 +2953,79 @@ function channelNameProblem(name) {
|
|
|
2891
2953
|
function socketUrl(baseUrl, apiKey) {
|
|
2892
2954
|
const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
|
|
2893
2955
|
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
2894
|
-
|
|
2956
|
+
const key = apiKey === null ? "" : `apikey=${encodeURIComponent(apiKey)}&`;
|
|
2957
|
+
return `${url.href}?${key}vsn=${REALTIME_PROTOCOL_VERSION}`;
|
|
2895
2958
|
}
|
|
2896
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
|
+
|
|
2897
3029
|
// src/realtime/socket.ts
|
|
2898
3030
|
var HEARTBEAT_MS = 25e3;
|
|
2899
3031
|
var TIMEOUT_MS = 1e4;
|
|
@@ -3308,19 +3440,19 @@ var RealtimeSocket = class {
|
|
|
3308
3440
|
}, this.#timeoutMs);
|
|
3309
3441
|
}
|
|
3310
3442
|
#open() {
|
|
3311
|
-
const
|
|
3443
|
+
const passed = this.#config.options.WebSocket;
|
|
3444
|
+
const runtimes = globalThis.WebSocket;
|
|
3445
|
+
const Impl = passed ?? runtimes;
|
|
3312
3446
|
if (typeof Impl !== "function")
|
|
3313
3447
|
return this.#stop(websocketUnavailable(), true);
|
|
3314
3448
|
if (this.#config.service && !isKnownServer())
|
|
3315
3449
|
return this.#stop(serviceKeyOffServer(), true);
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
return this.#stop(socketRefused(error), true);
|
|
3323
|
-
}
|
|
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;
|
|
3324
3456
|
this.#generation += 1;
|
|
3325
3457
|
this.#ws = ws;
|
|
3326
3458
|
this.#state = "connecting";
|
|
@@ -3335,6 +3467,48 @@ var RealtimeSocket = class {
|
|
|
3335
3467
|
this.#timeoutMs
|
|
3336
3468
|
);
|
|
3337
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
|
+
}
|
|
3338
3512
|
#opened(ws) {
|
|
3339
3513
|
if (this.#ws !== ws || this.#state !== "connecting") return;
|
|
3340
3514
|
clearTimeout(this.#openTimer);
|
|
@@ -4083,7 +4257,7 @@ var RealtimeChannel = class {
|
|
|
4083
4257
|
payload: isRecord(payload.payload) ? payload.payload : {}
|
|
4084
4258
|
};
|
|
4085
4259
|
for (const binding of this.#bindings)
|
|
4086
|
-
if (binding.type === "broadcast" && binding.event === event)
|
|
4260
|
+
if (binding.type === "broadcast" && (binding.event === event || binding.event === "*"))
|
|
4087
4261
|
safely(() => binding.callback(message));
|
|
4088
4262
|
}
|
|
4089
4263
|
#system(payload) {
|
|
@@ -4524,6 +4698,7 @@ var RealtimeClient = class {
|
|
|
4524
4698
|
apiKey: http.apiKey,
|
|
4525
4699
|
service,
|
|
4526
4700
|
options,
|
|
4701
|
+
fetch: http.fetch,
|
|
4527
4702
|
events: {
|
|
4528
4703
|
// Not awaited, and not live yet: onLive says when the hub took it.
|
|
4529
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
|
|
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
|
package/dist/realtime/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 —
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** What an app may change about the cookies. */
|
|
2
|
+
export type CookieOptions = {
|
|
3
|
+
domain?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
sameSite?: "lax" | "strict" | "none";
|
|
6
|
+
secure?: boolean;
|
|
7
|
+
/** Seconds. */
|
|
8
|
+
maxAge?: number;
|
|
9
|
+
};
|
|
10
|
+
export type ResolvedCookieOptions = {
|
|
11
|
+
domain?: string;
|
|
12
|
+
path: string;
|
|
13
|
+
sameSite: "lax" | "strict" | "none";
|
|
14
|
+
secure: boolean;
|
|
15
|
+
maxAge: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* 400 days, the most a browser keeps a cookie. Longer than the session's 7
|
|
19
|
+
* days on purpose (decision 6): the session slides while it is used but its
|
|
20
|
+
* token does not change, so the cookie is never written again — a 7-day
|
|
21
|
+
* cookie would outlive nothing. A session that ends is cleared by
|
|
22
|
+
* INVALID_SESSION instead.
|
|
23
|
+
*/
|
|
24
|
+
export declare const COOKIE_MAX_AGE = 34560000;
|
|
25
|
+
/**
|
|
26
|
+
* `naro-auth-token:https://abc.naro.sh` → `naro-auth-token.abc.naro.sh`: a
|
|
27
|
+
* cookie name holds no `:` or `/`. What else the host has (a port's `:`)
|
|
28
|
+
* becomes `_`; a key without a `:` is made safe the same way.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cookieName(key: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* `base64-` and the value's UTF-8 as base64url, as `@supabase/ssr` writes:
|
|
33
|
+
* a session token's signature has `+`, `/` and `=`, and a verifier list is
|
|
34
|
+
* JSON. Next's `cookies().set()` encodes a value into `Set-Cookie`, which the
|
|
35
|
+
* browser's `document.cookie` hands back as it is — so a value that any
|
|
36
|
+
* layer would encode again comes back changed. base64url is left alone by
|
|
37
|
+
* all of them.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeValue(value: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The value `encodeValue` wrote; one without the prefix as it is. Null for
|
|
42
|
+
* one that does not decode — it is read as no cookie.
|
|
43
|
+
*/
|
|
44
|
+
export declare function decodeValue(value: string): string | null;
|
|
45
|
+
export declare function resolveCookieOptions(options: CookieOptions | undefined, secure: boolean): ResolvedCookieOptions;
|
|
46
|
+
/** A `document.cookie` line; `value` is already encoded. */
|
|
47
|
+
export declare function serializeCookie(name: string, value: string, options: ResolvedCookieOptions): string;
|
|
48
|
+
/** `a=1; b=2` as pairs, in order; what is not a pair is skipped. */
|
|
49
|
+
export declare function parseCookies(header: string): {
|
|
50
|
+
name: string;
|
|
51
|
+
value: string;
|
|
52
|
+
}[];
|
|
53
|
+
/** One warning per cookie name (decision 7): it is written all the same. */
|
|
54
|
+
export declare function warnIfLarge(name: string, value: string): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AuthStorage } from "../auth/storage.js";
|
|
2
|
+
import { type ResolvedCookieOptions } from "./cookies.js";
|
|
3
|
+
export type CookieToSet = {
|
|
4
|
+
name: string;
|
|
5
|
+
/** Encoded; "" when the cookie is removed. */
|
|
6
|
+
value: string;
|
|
7
|
+
/** `maxAge` 0 removes it. */
|
|
8
|
+
options: ResolvedCookieOptions;
|
|
9
|
+
};
|
|
10
|
+
export type CookieMethods = {
|
|
11
|
+
/** The request's cookies — `cookieStore.getAll()`, `request.cookies.getAll()`. */
|
|
12
|
+
getAll(): {
|
|
13
|
+
name: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}[] | Promise<{
|
|
16
|
+
name: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Writes them to the response. Left out, or throwing (a Server Component
|
|
21
|
+
* cannot write cookies), the client keeps them for this request only.
|
|
22
|
+
*/
|
|
23
|
+
setAll?(cookies: CookieToSet[]): void | Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
/** What of `document` the browser storage uses. */
|
|
26
|
+
type CookieDocument = {
|
|
27
|
+
cookie: string;
|
|
28
|
+
};
|
|
29
|
+
/** `document.cookie`, read again on every get: another tab's sign-in shows. */
|
|
30
|
+
export declare function browserCookieStorage(options: ResolvedCookieOptions, doc: CookieDocument): AuthStorage;
|
|
31
|
+
/**
|
|
32
|
+
* The request's cookies, read once, under what this client wrote since
|
|
33
|
+
* (decision 10): a JWT renewed in a Server Component, whose `setAll`
|
|
34
|
+
* throws, is still the one the next query of the request sends.
|
|
35
|
+
*/
|
|
36
|
+
export declare function serverCookieStorage(methods: CookieMethods, options: ResolvedCookieOptions): AuthStorage;
|
|
37
|
+
export {};
|
package/dist/ssr.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DatabaseShape, type FetchLike, type NaroClient, type RealtimeOptions, type UntypedDatabase } from "./index.js";
|
|
2
|
+
import { type CookieOptions } from "./ssr/cookies.js";
|
|
3
|
+
import { type CookieMethods } from "./ssr/storage.js";
|
|
4
|
+
export type { CookieOptions } from "./ssr/cookies.js";
|
|
5
|
+
export type { CookieMethods, CookieToSet } from "./ssr/storage.js";
|
|
6
|
+
/** User-facing; the design's §4.1. */
|
|
7
|
+
export declare const SSR_MESSAGES: {
|
|
8
|
+
readonly serviceKey: "naro-js/ssr keeps a signed-in user's session in cookies, so it takes the anon key only. Make the service key's client with naro-js's createClient({ url, serviceKey }), on your server.";
|
|
9
|
+
readonly getAll: "createServerClient needs cookies.getAll: a function that returns the request's cookies, e.g. () => cookieStore.getAll().";
|
|
10
|
+
};
|
|
11
|
+
type SsrKeys = {
|
|
12
|
+
url: string;
|
|
13
|
+
anonKey: string;
|
|
14
|
+
serviceKey?: undefined;
|
|
15
|
+
};
|
|
16
|
+
export type BrowserClientOptions = SsrKeys & {
|
|
17
|
+
cookieOptions?: CookieOptions;
|
|
18
|
+
fetch?: FetchLike;
|
|
19
|
+
realtime?: RealtimeOptions;
|
|
20
|
+
};
|
|
21
|
+
export type ServerClientOptions = SsrKeys & {
|
|
22
|
+
cookies: CookieMethods;
|
|
23
|
+
cookieOptions?: CookieOptions;
|
|
24
|
+
fetch?: FetchLike;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The client for the browser: `createClient` with its session in
|
|
28
|
+
* `document.cookie`. On a page, the same url and key give the same client.
|
|
29
|
+
* Where there is no page (a Client Component's server render) it keeps the
|
|
30
|
+
* session in memory and is new each time.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBrowserClient<Database extends DatabaseShape<Database> = UntypedDatabase>(options: BrowserClientOptions): NaroClient<Database>;
|
|
33
|
+
/**
|
|
34
|
+
* The client for a request on the server — a Server Component, a route
|
|
35
|
+
* handler, a server action or proxy.ts: its session is the request's
|
|
36
|
+
* cookies, and what it writes goes to `cookies.setAll`. Make one per
|
|
37
|
+
* request. It never reads a session from a URL.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createServerClient<Database extends DatabaseShape<Database> = UntypedDatabase>(options: ServerClientOptions): NaroClient<Database>;
|
package/dist/ssr.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/ssr.ts
|
|
2
|
+
import {
|
|
3
|
+
createClient
|
|
4
|
+
} from "./index.js";
|
|
5
|
+
|
|
6
|
+
// src/runtime.ts
|
|
7
|
+
function isBrowser(scope = globalThis) {
|
|
8
|
+
const g = scope;
|
|
9
|
+
return g.window !== void 0 && g.document !== void 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/ssr/cookies.ts
|
|
13
|
+
var COOKIE_MAX_AGE = 3456e4;
|
|
14
|
+
var LARGE_COOKIE_BYTES = 4e3;
|
|
15
|
+
function cookieName(key) {
|
|
16
|
+
const colon = key.indexOf(":");
|
|
17
|
+
if (colon === -1) return safe(key);
|
|
18
|
+
const prefix = key.slice(0, colon);
|
|
19
|
+
const rest = key.slice(colon + 1);
|
|
20
|
+
let host = rest;
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(rest).host;
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
return `${safe(prefix)}.${safe(host)}`;
|
|
26
|
+
}
|
|
27
|
+
function safe(text) {
|
|
28
|
+
return text.replace(/[^A-Za-z0-9.-]/g, "_");
|
|
29
|
+
}
|
|
30
|
+
var BASE64_PREFIX = "base64-";
|
|
31
|
+
function encodeValue(value) {
|
|
32
|
+
let binary = "";
|
|
33
|
+
for (const byte of new TextEncoder().encode(value))
|
|
34
|
+
binary += String.fromCharCode(byte);
|
|
35
|
+
return BASE64_PREFIX + btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
36
|
+
}
|
|
37
|
+
function decodeValue(value) {
|
|
38
|
+
if (!value.startsWith(BASE64_PREFIX)) return value;
|
|
39
|
+
const base64 = value.slice(BASE64_PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
|
|
40
|
+
try {
|
|
41
|
+
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
|
|
42
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
43
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function resolveCookieOptions(options, secure) {
|
|
49
|
+
return {
|
|
50
|
+
...options?.domain !== void 0 ? { domain: options.domain } : {},
|
|
51
|
+
path: options?.path ?? "/",
|
|
52
|
+
sameSite: options?.sameSite ?? "lax",
|
|
53
|
+
secure: options?.secure ?? secure,
|
|
54
|
+
maxAge: options?.maxAge ?? COOKIE_MAX_AGE
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
var SAME_SITE = { lax: "Lax", strict: "Strict", none: "None" };
|
|
58
|
+
function serializeCookie(name, value, options) {
|
|
59
|
+
const parts = [
|
|
60
|
+
`${name}=${value}`,
|
|
61
|
+
`Max-Age=${options.maxAge}`,
|
|
62
|
+
`Path=${options.path}`
|
|
63
|
+
];
|
|
64
|
+
if (options.domain !== void 0) parts.push(`Domain=${options.domain}`);
|
|
65
|
+
parts.push(`SameSite=${SAME_SITE[options.sameSite]}`);
|
|
66
|
+
if (options.secure) parts.push("Secure");
|
|
67
|
+
return parts.join("; ");
|
|
68
|
+
}
|
|
69
|
+
function parseCookies(header) {
|
|
70
|
+
const pairs = [];
|
|
71
|
+
for (const part of header.split(";")) {
|
|
72
|
+
const at = part.indexOf("=");
|
|
73
|
+
if (at <= 0) continue;
|
|
74
|
+
const name = part.slice(0, at).trim();
|
|
75
|
+
if (name === "") continue;
|
|
76
|
+
pairs.push({ name, value: part.slice(at + 1).trim() });
|
|
77
|
+
}
|
|
78
|
+
return pairs;
|
|
79
|
+
}
|
|
80
|
+
var warned = /* @__PURE__ */ new Set();
|
|
81
|
+
function warnIfLarge(name, value) {
|
|
82
|
+
const bytes = new TextEncoder().encode(`${name}=${value}`).length;
|
|
83
|
+
if (bytes <= LARGE_COOKIE_BYTES || warned.has(name)) return;
|
|
84
|
+
warned.add(name);
|
|
85
|
+
console.warn(
|
|
86
|
+
`naro-js/ssr: the cookie ${name} is ${bytes} bytes. Browsers drop a cookie past 4096 bytes, and the session then lives only until the page closes.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/ssr/storage.ts
|
|
91
|
+
function browserCookieStorage(options, doc) {
|
|
92
|
+
return {
|
|
93
|
+
getItem(key) {
|
|
94
|
+
const name = cookieName(key);
|
|
95
|
+
const found = parseCookies(doc.cookie).find((c) => c.name === name);
|
|
96
|
+
return found === void 0 ? null : decodeValue(found.value);
|
|
97
|
+
},
|
|
98
|
+
setItem(key, value) {
|
|
99
|
+
const name = cookieName(key);
|
|
100
|
+
const encoded = encodeValue(value);
|
|
101
|
+
warnIfLarge(name, encoded);
|
|
102
|
+
doc.cookie = serializeCookie(name, encoded, options);
|
|
103
|
+
},
|
|
104
|
+
removeItem(key) {
|
|
105
|
+
doc.cookie = serializeCookie(cookieName(key), "", {
|
|
106
|
+
...options,
|
|
107
|
+
maxAge: 0
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function serverCookieStorage(methods, options) {
|
|
113
|
+
let request;
|
|
114
|
+
const written = /* @__PURE__ */ new Map();
|
|
115
|
+
const read = () => {
|
|
116
|
+
request ??= (async () => {
|
|
117
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const { name, value } of await methods.getAll())
|
|
119
|
+
if (!cookies.has(name)) cookies.set(name, value);
|
|
120
|
+
return cookies;
|
|
121
|
+
})();
|
|
122
|
+
return request;
|
|
123
|
+
};
|
|
124
|
+
const send = async (cookie) => {
|
|
125
|
+
try {
|
|
126
|
+
await methods.setAll?.([cookie]);
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
async getItem(key) {
|
|
132
|
+
const name = cookieName(key);
|
|
133
|
+
if (written.has(name)) return written.get(name) ?? null;
|
|
134
|
+
const value = (await read()).get(name);
|
|
135
|
+
return value === void 0 ? null : decodeValue(value);
|
|
136
|
+
},
|
|
137
|
+
async setItem(key, value) {
|
|
138
|
+
const name = cookieName(key);
|
|
139
|
+
written.set(name, value);
|
|
140
|
+
const encoded = encodeValue(value);
|
|
141
|
+
warnIfLarge(name, encoded);
|
|
142
|
+
await send({ name, value: encoded, options });
|
|
143
|
+
},
|
|
144
|
+
async removeItem(key) {
|
|
145
|
+
const name = cookieName(key);
|
|
146
|
+
written.set(name, null);
|
|
147
|
+
await send({ name, value: "", options: { ...options, maxAge: 0 } });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/ssr.ts
|
|
153
|
+
var SSR_MESSAGES = {
|
|
154
|
+
serviceKey: "naro-js/ssr keeps a signed-in user's session in cookies, so it takes the anon key only. Make the service key's client with naro-js's createClient({ url, serviceKey }), on your server.",
|
|
155
|
+
getAll: "createServerClient needs cookies.getAll: a function that returns the request's cookies, e.g. () => cookieStore.getAll()."
|
|
156
|
+
};
|
|
157
|
+
function refuseServiceKey(options) {
|
|
158
|
+
if (options.serviceKey !== void 0)
|
|
159
|
+
throw new Error(SSR_MESSAGES.serviceKey);
|
|
160
|
+
}
|
|
161
|
+
var pageClients = /* @__PURE__ */ new Map();
|
|
162
|
+
function createBrowserClient(options) {
|
|
163
|
+
refuseServiceKey(options);
|
|
164
|
+
const { url, anonKey, cookieOptions, fetch, realtime } = options;
|
|
165
|
+
const common = {
|
|
166
|
+
url,
|
|
167
|
+
anonKey,
|
|
168
|
+
...fetch !== void 0 ? { fetch } : {},
|
|
169
|
+
...realtime !== void 0 ? { realtime } : {}
|
|
170
|
+
};
|
|
171
|
+
if (!isBrowser()) return createClient(common);
|
|
172
|
+
const id = `${url}
|
|
173
|
+
${anonKey}`;
|
|
174
|
+
const kept = pageClients.get(id);
|
|
175
|
+
if (kept !== void 0) return kept;
|
|
176
|
+
const https = globalThis.location?.protocol === "https:";
|
|
177
|
+
const storage = browserCookieStorage(
|
|
178
|
+
resolveCookieOptions(cookieOptions, https),
|
|
179
|
+
globalThis.document
|
|
180
|
+
);
|
|
181
|
+
const client = createClient({ ...common, auth: { storage } });
|
|
182
|
+
pageClients.set(id, client);
|
|
183
|
+
return client;
|
|
184
|
+
}
|
|
185
|
+
function createServerClient(options) {
|
|
186
|
+
refuseServiceKey(options);
|
|
187
|
+
const { url, anonKey, cookies, cookieOptions, fetch } = options;
|
|
188
|
+
if (typeof cookies?.getAll !== "function")
|
|
189
|
+
throw new Error(SSR_MESSAGES.getAll);
|
|
190
|
+
const storage = serverCookieStorage(
|
|
191
|
+
cookies,
|
|
192
|
+
resolveCookieOptions(cookieOptions, true)
|
|
193
|
+
);
|
|
194
|
+
return createClient({
|
|
195
|
+
url,
|
|
196
|
+
anonKey,
|
|
197
|
+
...fetch !== void 0 ? { fetch } : {},
|
|
198
|
+
auth: { storage, detectSessionInUrl: false }
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export {
|
|
202
|
+
SSR_MESSAGES,
|
|
203
|
+
createBrowserClient,
|
|
204
|
+
createServerClient
|
|
205
|
+
};
|
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.
|
|
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,
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"*": {
|
|
13
13
|
"functions": [
|
|
14
14
|
"./dist/functions.d.ts"
|
|
15
|
+
],
|
|
16
|
+
"ssr": [
|
|
17
|
+
"./dist/ssr.d.ts"
|
|
15
18
|
]
|
|
16
19
|
}
|
|
17
20
|
},
|
|
@@ -23,6 +26,10 @@
|
|
|
23
26
|
"./functions": {
|
|
24
27
|
"types": "./dist/functions.d.ts",
|
|
25
28
|
"default": "./dist/functions.js"
|
|
29
|
+
},
|
|
30
|
+
"./ssr": {
|
|
31
|
+
"types": "./dist/ssr.d.ts",
|
|
32
|
+
"default": "./dist/ssr.js"
|
|
26
33
|
}
|
|
27
34
|
},
|
|
28
35
|
"publishConfig": {
|
|
@@ -36,9 +43,9 @@
|
|
|
36
43
|
"vitest": "3.2.7",
|
|
37
44
|
"@next-deploy/backend-worker": "0.1.0",
|
|
38
45
|
"@next-deploy/config": "0.1.0",
|
|
46
|
+
"@next-deploy/data-core": "0.1.0",
|
|
39
47
|
"@next-deploy/functions-runtime": "0.1.0",
|
|
40
|
-
"@next-deploy/sdk": "0.1.0"
|
|
41
|
-
"@next-deploy/data-core": "0.1.0"
|
|
48
|
+
"@next-deploy/sdk": "0.1.0"
|
|
42
49
|
},
|
|
43
50
|
"scripts": {
|
|
44
51
|
"build": "tsup && pnpm build:types",
|