naro-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1733 @@
1
+ // src/errors.ts
2
+ var INVALID_QUERY_MESSAGE = "The query is not a valid Naro query.";
3
+ var INVALID_SESSION_MESSAGE = "The session is expired or not valid. Sign in again.";
4
+ function networkError(cause) {
5
+ return {
6
+ code: "NETWORK_ERROR",
7
+ message: "The request did not reach the Naro backend. Check the url and the network.",
8
+ details: { cause: describe(cause) }
9
+ };
10
+ }
11
+ function requestTimeout(ms) {
12
+ return {
13
+ code: "TIMEOUT",
14
+ message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
15
+ };
16
+ }
17
+ function invalidResponse(status) {
18
+ return {
19
+ code: "INVALID_RESPONSE",
20
+ message: `The server did not answer like a Naro backend (HTTP ${String(status)}).`,
21
+ details: { status }
22
+ };
23
+ }
24
+ function invalidQuery(path, reason) {
25
+ return {
26
+ code: "INVALID_QUERY",
27
+ message: INVALID_QUERY_MESSAGE,
28
+ details: { path, reason }
29
+ };
30
+ }
31
+ function invalidSession() {
32
+ return { code: "INVALID_SESSION", message: INVALID_SESSION_MESSAGE };
33
+ }
34
+ function codeVerifierMissing() {
35
+ return {
36
+ code: "CODE_VERIFIER_MISSING",
37
+ message: "This browser did not start this sign-in, so it cannot finish it. Sign in again here."
38
+ };
39
+ }
40
+ function passwordRecoveryRequired() {
41
+ return {
42
+ code: "PASSWORD_RECOVERY_REQUIRED",
43
+ message: "updateUser({ password }) works in the password recovery flow: open the link resetPasswordForEmail sent, then call it on that page."
44
+ };
45
+ }
46
+ function invalidProvider() {
47
+ return {
48
+ code: "VALIDATION_ERROR",
49
+ message: "provider must be google or github."
50
+ };
51
+ }
52
+ function cryptoUnavailable() {
53
+ return {
54
+ code: "CRYPTO_UNAVAILABLE",
55
+ message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
56
+ };
57
+ }
58
+ function describe(cause) {
59
+ if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
60
+ return String(cause);
61
+ }
62
+ function isRecord(value) {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+ function asError(value) {
66
+ if (!isRecord(value)) return null;
67
+ const { code, message } = value;
68
+ if (typeof code !== "string" || code === "") return null;
69
+ if (typeof message !== "string") return null;
70
+ const error = { code, message };
71
+ if (value.details !== void 0) error.details = value.details;
72
+ return error;
73
+ }
74
+ function errorFromBody(status, body) {
75
+ if (isRecord(body) && body.error !== void 0 && body.error !== null)
76
+ return asError(body.error) ?? invalidResponse(status);
77
+ if (status >= 200 && status < 300) return null;
78
+ return invalidResponse(status);
79
+ }
80
+
81
+ // src/http.ts
82
+ async function receive(config, options, init) {
83
+ const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
84
+ if (controller) init.signal = controller.signal;
85
+ const request = (async () => {
86
+ try {
87
+ const fetchImpl = config.fetch ?? globalThis.fetch;
88
+ const response = await fetchImpl(
89
+ `${config.baseUrl}/_naro/v1${options.path}`,
90
+ init
91
+ );
92
+ return {
93
+ status: response.status,
94
+ headers: response.headers,
95
+ text: await response.text()
96
+ };
97
+ } catch (error) {
98
+ return { error: networkError(error) };
99
+ }
100
+ })();
101
+ const { timeoutMs } = options;
102
+ if (timeoutMs === void 0) return request;
103
+ let timer;
104
+ const expired = new Promise((resolve) => {
105
+ timer = setTimeout(() => {
106
+ controller?.abort();
107
+ resolve({ error: requestTimeout(timeoutMs) });
108
+ }, timeoutMs);
109
+ });
110
+ try {
111
+ return await Promise.race([request, expired]);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ }
116
+ async function call(config, options) {
117
+ let payload;
118
+ if (options.body !== void 0) {
119
+ try {
120
+ payload = JSON.stringify(options.body);
121
+ } catch {
122
+ return { error: invalidQuery("", "is not JSON"), answer: null };
123
+ }
124
+ }
125
+ const token = options.withToken ? await config.tokens.get() : null;
126
+ const headers = { apikey: config.apiKey };
127
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
128
+ const init = {
129
+ method: options.method,
130
+ headers,
131
+ // Identity is the bearer token alone. Better Auth also sets a session
132
+ // cookie (spec §13); a request that carried it would let any site act
133
+ // as the user. Workers accept the field (measured on workerd 1.20260815).
134
+ credentials: "omit"
135
+ };
136
+ if (payload !== void 0) {
137
+ headers["Content-Type"] = "application/json";
138
+ init.body = payload;
139
+ }
140
+ const received = await receive(config, options, init);
141
+ if ("error" in received) return { error: received.error, answer: null };
142
+ const { status, headers: responseHeaders, text } = received;
143
+ let body;
144
+ if (text.trim() !== "") {
145
+ try {
146
+ body = JSON.parse(text);
147
+ } catch {
148
+ return { error: invalidResponse(status), answer: null };
149
+ }
150
+ }
151
+ const answer = { status, headers: responseHeaders, body, token };
152
+ const error = errorFromBody(status, body);
153
+ if (error === null) return { error: null, answer };
154
+ if (error.code === "INVALID_SESSION" && token !== null)
155
+ await config.tokens.clearIf(token);
156
+ if (RETRY_AFTER_CODES.has(error.code))
157
+ return { error: withRetryAfter(error, responseHeaders), answer };
158
+ return { error, answer };
159
+ }
160
+ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
161
+ "RATE_LIMITED",
162
+ "WRITE_QUOTA_EXCEEDED"
163
+ ]);
164
+ function withRetryAfter(error, headers) {
165
+ const header = headers.get("x-retry-after")?.trim() ?? "";
166
+ if (!/^\d{1,9}$/.test(header)) return error;
167
+ const { details } = error;
168
+ if (details !== void 0 && !isRecord(details)) return error;
169
+ return {
170
+ ...error,
171
+ details: { ...details, retryAfter: Number(header) }
172
+ };
173
+ }
174
+
175
+ // src/runtime.ts
176
+ function isBrowser(scope = globalThis) {
177
+ const g = scope;
178
+ return g.window !== void 0 && g.document !== void 0;
179
+ }
180
+ function isWorkerd(g) {
181
+ return g.navigator?.userAgent === "Cloudflare-Workers";
182
+ }
183
+ function hasServerSignal(g) {
184
+ return isWorkerd(g) || // Vercel's edge runtime and Next.js's edge (middleware, edge routes).
185
+ typeof g.EdgeRuntime === "string" || typeof g.Deno?.version?.deno === "string" || typeof g.Bun?.version === "string" || // Node, and so the Next.js server. Browser polyfills of `process` leave
186
+ // `versions.node` out.
187
+ typeof g.process?.versions?.node === "string";
188
+ }
189
+ function hasDeviceSignal(g) {
190
+ if (g.window !== void 0 || g.document !== void 0) return true;
191
+ if (g.navigator?.product === "ReactNative") return true;
192
+ return !isWorkerd(g) && (g.WorkerGlobalScope !== void 0 || g.ServiceWorkerGlobalScope !== void 0);
193
+ }
194
+ function isKnownServer(scope = globalThis) {
195
+ const g = scope;
196
+ return hasServerSignal(g) && !hasDeviceSignal(g);
197
+ }
198
+
199
+ // src/auth/flow-errors.ts
200
+ var CODE = /^[A-Z0-9_]{1,64}$/;
201
+ var INVALID_CONFIRMATION = "The confirmation link is not valid. Ask for a new one.";
202
+ var MESSAGES = {
203
+ INVALID_STATE: "This sign-in link is not valid or has expired. Start signing in again.",
204
+ OAUTH_BROWSER_MISMATCH: "This sign-in was started in another browser, or this browser did not keep its cookie. Start signing in again here.",
205
+ OAUTH_ACCESS_DENIED: "Signing in with the provider was cancelled.",
206
+ OAUTH_PROVIDER_EMAIL_NOT_VERIFIED: "Your email address is not verified with the provider. Verify it there, then sign in again.",
207
+ OAUTH_ACCOUNT_NOT_LINKED: "An account with this email already exists and is not linked to this provider. Sign in the way you signed up.",
208
+ PROVIDER_NOT_CONFIGURED: "Signing in with this provider is not set up for this project.",
209
+ RATE_LIMITED: "Too many sign-in attempts. Wait a few seconds and try again.",
210
+ INTERNAL_ERROR: "Something went wrong. Try again.",
211
+ PASSWORD_RESET_REQUIRED: "Your email is confirmed. Someone else also signed up with it before it was confirmed, so its password was cleared: choose a new one with a password reset.",
212
+ TOKEN_EXPIRED: "The confirmation link has expired. Ask for a new one.",
213
+ USER_NOT_FOUND: INVALID_CONFIRMATION
214
+ };
215
+ function flowError(type, raw) {
216
+ if (!CODE.test(raw))
217
+ return {
218
+ code: "AUTH_ERROR",
219
+ message: "Signing in did not finish. Try again."
220
+ };
221
+ if (raw === "INVALID_TOKEN")
222
+ return {
223
+ code: raw,
224
+ message: type === "recovery" ? "The password reset link is not valid, has expired or was already used. Ask for a new one." : INVALID_CONFIRMATION
225
+ };
226
+ const message = MESSAGES[raw];
227
+ if (message !== void 0) return { code: raw, message };
228
+ if (raw.startsWith("OAUTH_"))
229
+ return {
230
+ code: raw,
231
+ message: `Signing in with the provider did not finish (${raw}). Try again.`
232
+ };
233
+ return {
234
+ code: raw,
235
+ message: `Signing in did not finish (${raw}). Try again.`
236
+ };
237
+ }
238
+
239
+ // src/auth/pkce.ts
240
+ function base64url(bytes) {
241
+ let binary = "";
242
+ for (const byte of bytes) binary += String.fromCharCode(byte);
243
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
244
+ }
245
+ async function createPkcePair() {
246
+ const subtle = globalThis.crypto?.subtle;
247
+ if (subtle === void 0) return null;
248
+ const verifier = base64url(
249
+ globalThis.crypto.getRandomValues(new Uint8Array(32))
250
+ );
251
+ const digest = await subtle.digest(
252
+ "SHA-256",
253
+ new TextEncoder().encode(verifier)
254
+ );
255
+ return { verifier, challenge: base64url(new Uint8Array(digest)) };
256
+ }
257
+
258
+ // src/auth/client.ts
259
+ var TOKEN_HEADER = "set-auth-token";
260
+ var FRAGMENT_PREFIX = "naro_";
261
+ var OAUTH_PROVIDERS = ["google", "github"];
262
+ var FLOW_TYPES = [
263
+ "oauth",
264
+ "email_verification",
265
+ "recovery"
266
+ ];
267
+ function defaultName(email) {
268
+ if (typeof email !== "string") return "";
269
+ const at = email.lastIndexOf("@");
270
+ return at > 0 ? email.slice(0, at) : email;
271
+ }
272
+ function nonEmpty(value) {
273
+ return typeof value === "string" && value !== "" ? value : null;
274
+ }
275
+ function tokenFrom(answer) {
276
+ return nonEmpty(answer.headers.get(TOKEN_HEADER));
277
+ }
278
+ function userFrom(body) {
279
+ return isRecord(body) && isRecord(body.user) ? body.user : null;
280
+ }
281
+ function currentLocation() {
282
+ if (!isBrowser()) return null;
283
+ return globalThis.location ?? null;
284
+ }
285
+ function currentPage() {
286
+ const location = currentLocation();
287
+ if (!location) return void 0;
288
+ return `${location.origin}${location.pathname}${location.search}`;
289
+ }
290
+ function naroFragment() {
291
+ const hash = currentLocation()?.hash ?? "";
292
+ if (!hash.startsWith("#")) return null;
293
+ const params = new URLSearchParams(hash.slice(1));
294
+ const found = {};
295
+ for (const [key, value] of params)
296
+ if (key.startsWith(FRAGMENT_PREFIX)) found[key] = value;
297
+ return Object.keys(found).length > 0 ? found : null;
298
+ }
299
+ function clearFragment() {
300
+ const location = currentLocation();
301
+ const history = globalThis.history;
302
+ if (!location || !history?.replaceState) return;
303
+ try {
304
+ history.replaceState(
305
+ history.state,
306
+ "",
307
+ `${location.pathname}${location.search}`
308
+ );
309
+ } catch {
310
+ }
311
+ }
312
+ var EXCHANGE_TIMEOUT_MS = 15e3;
313
+ var inFlight = /* @__PURE__ */ new WeakMap();
314
+ function sharedInFlight(shared) {
315
+ return inFlight.get(shared.storage)?.get(shared.key) ?? null;
316
+ }
317
+ function share(shared, pending) {
318
+ let entries = inFlight.get(shared.storage);
319
+ if (!entries) {
320
+ entries = /* @__PURE__ */ new Map();
321
+ inFlight.set(shared.storage, entries);
322
+ }
323
+ entries.set(shared.key, pending);
324
+ void pending.finally(() => {
325
+ if (entries.get(shared.key) === pending) entries.delete(shared.key);
326
+ });
327
+ }
328
+ var AuthClient = class {
329
+ #http;
330
+ #flow;
331
+ /** The last `getSessionFromUrl()`, for callers after detection ran it. */
332
+ #fromUrl = null;
333
+ constructor(http, flow) {
334
+ this.#http = http;
335
+ this.#flow = flow;
336
+ if (flow.detectSessionInUrl) {
337
+ if (naroFragment() !== null) void this.getSessionFromUrl();
338
+ else this.#fromUrl = sharedInFlight(flow.shared);
339
+ }
340
+ }
341
+ /**
342
+ * Creates the account and signs in; the token is stored for later calls.
343
+ * When the project requires a confirmed email there is no session yet —
344
+ * `user` comes back, and after the confirmation link the user signs in.
345
+ */
346
+ async signUp(credentials) {
347
+ const { email, password } = credentials;
348
+ const name = credentials.name ?? defaultName(email);
349
+ const body = { email, password, name };
350
+ if (credentials.redirectTo !== void 0)
351
+ body.callbackURL = credentials.redirectTo;
352
+ return this.#signIn("/auth/sign-up/email", body, false);
353
+ }
354
+ async signIn(credentials) {
355
+ const { email, password } = credentials;
356
+ return this.#signIn("/auth/sign-in/email", { email, password }, true);
357
+ }
358
+ /**
359
+ * Starts a Google or GitHub sign-in: sends the browser, with a top-level
360
+ * navigation, to the worker's `GET /_naro/v1/auth/authorize/<provider>`,
361
+ * which checks `redirectTo`, binds the flow to this browser with a cookie
362
+ * and goes on to the provider (adversarial review I1). The provider sends
363
+ * the browser back to `redirectTo`, where `getSessionFromUrl()` (run by
364
+ * `createClient` on its own) finishes the sign-in. No request is made
365
+ * here: anything that goes wrong comes back in that page's URL.
366
+ */
367
+ async signInWithOAuth(options) {
368
+ const { provider } = options;
369
+ const failed = (error) => ({
370
+ data: { provider, url: null },
371
+ error
372
+ });
373
+ if (!OAUTH_PROVIDERS.includes(provider)) return failed(invalidProvider());
374
+ await this.#flow.recovery.forget();
375
+ const pair = await createPkcePair();
376
+ if (!pair) return failed(cryptoUnavailable());
377
+ await this.#flow.verifiers.add(pair.challenge, pair.verifier);
378
+ const redirectTo = options.redirectTo ?? currentPage();
379
+ const url = new URL(
380
+ `${this.#http.baseUrl}/_naro/v1/auth/authorize/${provider}`
381
+ );
382
+ if (redirectTo !== void 0)
383
+ url.searchParams.set("redirectTo", redirectTo);
384
+ url.searchParams.set("code_challenge", pair.challenge);
385
+ const location = currentLocation();
386
+ if (location && !options.skipBrowserRedirect) location.assign(url.href);
387
+ return { data: { provider, url: url.href }, error: null };
388
+ }
389
+ /**
390
+ * Trades the one-time code a sign-in redirect brought for the session,
391
+ * with the verifier this browser kept when it began that flow — `flow` is
392
+ * the redirect's `naro_flow`; without it, the newest flow's. Most apps
393
+ * never call it: `getSessionFromUrl()` does.
394
+ */
395
+ async exchangeCodeForSession(code, flow) {
396
+ const signedOut = { session: null, user: null };
397
+ const verifier = await this.#flow.verifiers.take(flow);
398
+ if (verifier === null)
399
+ return { data: signedOut, error: codeVerifierMissing() };
400
+ const result = await call(this.#http, {
401
+ method: "POST",
402
+ path: "/auth/exchange-code",
403
+ body: { code, codeVerifier: verifier },
404
+ withToken: false,
405
+ // The page's queries wait on this; they must not wait forever.
406
+ timeoutMs: EXCHANGE_TIMEOUT_MS
407
+ });
408
+ if (result.error !== null) return { data: signedOut, error: result.error };
409
+ const { body } = result.answer;
410
+ const token = tokenFrom(result.answer);
411
+ const user = userFrom(body);
412
+ if (token === null || user === null || !isRecord(body) || !isRecord(body.session))
413
+ return { data: signedOut, error: invalidResponse(result.answer.status) };
414
+ await this.#http.tokens.set(token);
415
+ await this.#flow.recovery.forget();
416
+ return { data: { session: body.session, user }, error: null };
417
+ }
418
+ /**
419
+ * Reads what a sign-in redirect left in this page's URL fragment, then
420
+ * clears it: a provider sign-in or a confirmation link hands over a
421
+ * session (traded here), a reset link a recovery token (kept for
422
+ * `updateUser`), and a failure its `code` and `message`. With nothing of
423
+ * Naro's in the URL, `type` is null — or, after `createClient` already
424
+ * read it, the answer it got.
425
+ */
426
+ getSessionFromUrl() {
427
+ const fragment = naroFragment();
428
+ if (fragment === null)
429
+ return this.#fromUrl ?? Promise.resolve({
430
+ data: { session: null, user: null, type: null },
431
+ error: null
432
+ });
433
+ clearFragment();
434
+ const answer = this.#answerOf(fragment);
435
+ this.#fromUrl = answer;
436
+ share(this.#flow.shared, answer);
437
+ return answer;
438
+ }
439
+ async #answerOf(fragment) {
440
+ const named = fragment.naro_type;
441
+ const type = named && FLOW_TYPES.includes(named) ? named : null;
442
+ const none = { session: null, user: null, type };
443
+ if (fragment.naro_error) {
444
+ if (fragment.naro_flow)
445
+ await this.#flow.verifiers.take(fragment.naro_flow);
446
+ return { data: none, error: flowError(type, fragment.naro_error) };
447
+ }
448
+ if (type === "recovery" && fragment.naro_token) {
449
+ await this.#http.tokens.clear();
450
+ await this.#flow.recovery.keep(fragment.naro_token);
451
+ return { data: none, error: null };
452
+ }
453
+ if (fragment.naro_code) {
454
+ const traded = await this.exchangeCodeForSession(
455
+ fragment.naro_code,
456
+ fragment.naro_flow
457
+ );
458
+ return { data: { ...traded.data, type }, error: traded.error };
459
+ }
460
+ return { data: none, error: null };
461
+ }
462
+ /**
463
+ * Emails a password reset link, when the address has an account. The
464
+ * answer is the same either way — it does not say whether it had one.
465
+ */
466
+ async resetPasswordForEmail(email, options = {}) {
467
+ const body = { email };
468
+ if (options.redirectTo !== void 0) body.redirectTo = options.redirectTo;
469
+ const result = await call(this.#http, {
470
+ method: "POST",
471
+ path: "/auth/request-password-reset",
472
+ body,
473
+ withToken: false
474
+ });
475
+ return { data: null, error: result.error };
476
+ }
477
+ /**
478
+ * Sets a new password in the recovery flow: on the page the reset link
479
+ * opened, after `getSessionFromUrl()` read its token. Signing in with the
480
+ * new password comes after — a reset ends every session of the account.
481
+ * (Changing the password of a signed-in user is not offered: spec §14
482
+ * "(P1-2 리뷰 반영)".)
483
+ *
484
+ * Like supabase-js, it acts on the recovery link's account, which took the
485
+ * place of any signed-in session when the link was read — so the token is
486
+ * used only while no session is signed in on this browser, for 15 minutes
487
+ * after the link was opened, and never after a sign-in, sign-up or
488
+ * sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
489
+ * with no request.
490
+ */
491
+ async updateUser(attributes) {
492
+ await this.#pendingUrl();
493
+ if (await this.#http.tokens.get() !== null)
494
+ return { data: null, error: passwordRecoveryRequired() };
495
+ const token = await this.#flow.recovery.current();
496
+ if (token === null)
497
+ return { data: null, error: passwordRecoveryRequired() };
498
+ const result = await call(this.#http, {
499
+ method: "POST",
500
+ path: "/auth/reset-password",
501
+ body: { newPassword: attributes.password, token },
502
+ withToken: false
503
+ });
504
+ if (result.error === null || result.error.code === "INVALID_TOKEN")
505
+ await this.#flow.recovery.forgetIf(token);
506
+ return { data: null, error: result.error };
507
+ }
508
+ /**
509
+ * Emails the confirmation link again, when the address has an account that
510
+ * is not confirmed yet. The answer does not say which.
511
+ */
512
+ async resendVerification(email, options = {}) {
513
+ const body = { email };
514
+ if (options.redirectTo !== void 0) body.callbackURL = options.redirectTo;
515
+ const result = await call(this.#http, {
516
+ method: "POST",
517
+ path: "/auth/send-verification-email",
518
+ body,
519
+ withToken: false
520
+ });
521
+ return { data: null, error: result.error };
522
+ }
523
+ /**
524
+ * Ends the session on the worker and forgets the token. The token is
525
+ * forgotten even when the worker cannot be reached — the error still comes
526
+ * back, since the session then lives on until it expires.
527
+ */
528
+ async signOut() {
529
+ await this.#pendingUrl();
530
+ await this.#flow.recovery.forget();
531
+ const tokens = this.#http.tokens;
532
+ const token = await tokens.get();
533
+ if (token === null) return { data: null, error: null };
534
+ const result = await call(this.#http, {
535
+ method: "POST",
536
+ path: "/auth/sign-out",
537
+ withToken: true
538
+ });
539
+ await tokens.clearIf(result.answer?.token ?? token);
540
+ return { data: null, error: result.error };
541
+ }
542
+ /**
543
+ * The signed-in session and user. Signed out: both null, no request. A
544
+ * stored token the worker no longer accepts is forgotten and reported as
545
+ * INVALID_SESSION once (spec §11.1: a session never quietly turns anon).
546
+ * Waits for a sign-in the page URL carried, if `createClient` found one.
547
+ */
548
+ async getSession() {
549
+ await this.#pendingUrl();
550
+ const signedOut = { session: null, user: null };
551
+ const tokens = this.#http.tokens;
552
+ if (await tokens.get() === null) return { data: signedOut, error: null };
553
+ const result = await call(this.#http, {
554
+ method: "GET",
555
+ path: "/auth/get-session",
556
+ withToken: true
557
+ });
558
+ if (result.error !== null) return { data: signedOut, error: result.error };
559
+ const { body, token } = result.answer;
560
+ if (body === null) {
561
+ if (token !== null) await tokens.clearIf(token);
562
+ return { data: signedOut, error: invalidSession() };
563
+ }
564
+ const user = userFrom(body);
565
+ if (!isRecord(body) || !isRecord(body.session) || user === null)
566
+ return { data: signedOut, error: invalidResponse(result.answer.status) };
567
+ const renewed = tokenFrom(result.answer);
568
+ if (renewed !== null && token !== null && renewed !== token)
569
+ await tokens.replaceIf(token, renewed);
570
+ return { data: { session: body.session, user }, error: null };
571
+ }
572
+ /** `{ data: { user } }`; `user` is null when signed out. */
573
+ async getUser() {
574
+ const { data, error } = await this.getSession();
575
+ return { data: { user: data.user }, error };
576
+ }
577
+ /**
578
+ * Resolves once a sign-in the page URL carried has been traded for its
579
+ * session (or failed), with its error. Queries wait for it, so the first
580
+ * one after a provider's redirect goes out signed in rather than as anon.
581
+ * The exchange gives up after 15 seconds (TIMEOUT, PR #68 review 10):
582
+ * then the queries go out as anon rather than wait forever.
583
+ */
584
+ async ready() {
585
+ if (!this.#fromUrl) return { error: null };
586
+ return { error: (await this.#fromUrl).error };
587
+ }
588
+ /** Waits for a URL sign-in in flight; its outcome is not this call's. */
589
+ async #pendingUrl() {
590
+ if (this.#fromUrl) await this.#fromUrl;
591
+ }
592
+ async #signIn(path, body, needsToken) {
593
+ await this.#flow.recovery.forget();
594
+ const result = await call(this.#http, {
595
+ method: "POST",
596
+ path,
597
+ body,
598
+ withToken: false
599
+ });
600
+ if (result.error !== null)
601
+ return { data: { user: null }, error: result.error };
602
+ const { body: answerBody } = result.answer;
603
+ const user = userFrom(answerBody);
604
+ const token = tokenFrom(result.answer);
605
+ const opened = isRecord(answerBody) && nonEmpty(answerBody.token) !== null;
606
+ if (user === null || (needsToken || opened) && token === null)
607
+ return {
608
+ data: { user: null },
609
+ error: invalidResponse(result.answer.status)
610
+ };
611
+ if (token !== null) await this.#http.tokens.set(token);
612
+ return { data: { user }, error: null };
613
+ }
614
+ };
615
+
616
+ // src/auth/flows.ts
617
+ var FLOW_TTL_MS = 10 * 6e4;
618
+ var MAX_FLOWS = 10;
619
+ function isEntry(value) {
620
+ return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
621
+ }
622
+ function live(text, now) {
623
+ if (text === null) return {};
624
+ let parsed;
625
+ try {
626
+ parsed = JSON.parse(text);
627
+ } catch {
628
+ return {};
629
+ }
630
+ if (typeof parsed !== "object" || parsed === null) return {};
631
+ const entries = {};
632
+ for (const [flow, entry] of Object.entries(parsed))
633
+ if (isEntry(entry) && entry.expiresAt > now) entries[flow] = entry;
634
+ return entries;
635
+ }
636
+ function pick(entries, oldest) {
637
+ let found;
638
+ for (const pair of Object.entries(entries))
639
+ if (found === void 0 || (oldest ? pair[1].expiresAt < found[1].expiresAt : pair[1].expiresAt > found[1].expiresAt))
640
+ found = pair;
641
+ return found?.[0];
642
+ }
643
+ function serialize(entries) {
644
+ return Object.keys(entries).length > 0 ? JSON.stringify(entries) : null;
645
+ }
646
+ var FlowVerifiers = class {
647
+ #store;
648
+ #now;
649
+ constructor(store, now = Date.now) {
650
+ this.#store = store;
651
+ this.#now = now;
652
+ }
653
+ /** Keeps `verifier` for the flow `flow`, for ten minutes. */
654
+ async add(flow, verifier) {
655
+ const now = this.#now();
656
+ await this.#store.update((text) => {
657
+ const entries = live(text, now);
658
+ entries[flow] = { verifier, expiresAt: now + FLOW_TTL_MS };
659
+ while (Object.keys(entries).length > MAX_FLOWS) {
660
+ const oldest = pick(entries, true);
661
+ if (oldest === void 0) break;
662
+ delete entries[oldest];
663
+ }
664
+ return serialize(entries);
665
+ });
666
+ }
667
+ /**
668
+ * The verifier of `flow`, taken — one flow, one use. Without a flow id
669
+ * (a code traded by hand), the newest one. Null when there is none, or it
670
+ * expired.
671
+ */
672
+ async take(flow) {
673
+ const now = this.#now();
674
+ let taken = null;
675
+ await this.#store.update((text) => {
676
+ const entries = live(text, now);
677
+ const id = flow ?? pick(entries, false);
678
+ if (id !== void 0 && Object.hasOwn(entries, id)) {
679
+ taken = entries[id]?.verifier ?? null;
680
+ delete entries[id];
681
+ }
682
+ return serialize(entries);
683
+ });
684
+ return taken;
685
+ }
686
+ };
687
+
688
+ // src/auth/recovery.ts
689
+ var RECOVERY_TTL_MS = 15 * 6e4;
690
+ function parse(text) {
691
+ if (text === null) return null;
692
+ try {
693
+ const value = JSON.parse(text);
694
+ if (typeof value === "object" && value !== null && typeof value.token === "string" && value.token !== "" && typeof value.expiresAt === "number")
695
+ return value;
696
+ } catch {
697
+ }
698
+ return null;
699
+ }
700
+ var RecoveryToken = class {
701
+ #store;
702
+ #now;
703
+ constructor(store, now = () => Date.now()) {
704
+ this.#store = store;
705
+ this.#now = now;
706
+ }
707
+ /** Keeps the token a recovery link brought, for 15 minutes. */
708
+ async keep(token) {
709
+ const kept = { token, expiresAt: this.#now() + RECOVERY_TTL_MS };
710
+ await this.#store.set(JSON.stringify(kept));
711
+ }
712
+ /** The token, while it lasts; an expired or unreadable one is cleared. */
713
+ async current() {
714
+ const now = this.#now();
715
+ let token = null;
716
+ await this.#store.update((text) => {
717
+ const kept = parse(text);
718
+ if (kept === null || kept.expiresAt <= now) return null;
719
+ token = kept.token;
720
+ return text;
721
+ });
722
+ return token;
723
+ }
724
+ /** Forgets it: a sign-in, a sign-up, a sign-out. */
725
+ async forget() {
726
+ await this.#store.clear();
727
+ }
728
+ /** Forgets it if it is still `token` — used, or refused as used. */
729
+ async forgetIf(token) {
730
+ await this.#store.update(
731
+ (text) => parse(text)?.token === token ? null : text
732
+ );
733
+ }
734
+ };
735
+
736
+ // src/auth/storage.ts
737
+ function memoryStorage() {
738
+ const items2 = /* @__PURE__ */ new Map();
739
+ return {
740
+ getItem: (key) => items2.get(key) ?? null,
741
+ setItem: (key, value) => {
742
+ items2.set(key, value);
743
+ },
744
+ removeItem: (key) => {
745
+ items2.delete(key);
746
+ }
747
+ };
748
+ }
749
+ function defaultStorage() {
750
+ if (!isBrowser()) return memoryStorage();
751
+ try {
752
+ const storage = globalThis.localStorage;
753
+ if (storage === void 0 || storage === null) return memoryStorage();
754
+ const probe = "naro-storage-probe";
755
+ storage.setItem(probe, probe);
756
+ storage.removeItem(probe);
757
+ return storage;
758
+ } catch {
759
+ return memoryStorage();
760
+ }
761
+ }
762
+ function storageKey(baseUrl) {
763
+ return `naro-auth-token:${baseUrl}`;
764
+ }
765
+ function verifierKey(baseUrl) {
766
+ return `naro-auth-code-verifiers:${baseUrl}`;
767
+ }
768
+ function recoveryKey(baseUrl) {
769
+ return `naro-auth-recovery:${baseUrl}`;
770
+ }
771
+ var TokenStore = class {
772
+ #storage;
773
+ #key;
774
+ #fallback = null;
775
+ /** Settles when the last queued operation has finished. */
776
+ #queue = Promise.resolve();
777
+ constructor(storage, key) {
778
+ this.#storage = storage;
779
+ this.#key = key;
780
+ }
781
+ get() {
782
+ return this.#serial(() => this.#get());
783
+ }
784
+ set(token) {
785
+ return this.#serial(() => this.#set(token));
786
+ }
787
+ clear() {
788
+ return this.#serial(() => this.#clear());
789
+ }
790
+ /**
791
+ * Clears only if the stored token is still the one that was rejected. A
792
+ * request that left with an old token can come back after the user signed
793
+ * in again; its INVALID_SESSION must not sign the new session out. The
794
+ * read and the removal are one operation: with an async storage, a sign-in
795
+ * that lands between them would otherwise be the token removed.
796
+ */
797
+ clearIf(token) {
798
+ return this.#serial(async () => {
799
+ if (await this.#get() === token) await this.#clear();
800
+ });
801
+ }
802
+ /**
803
+ * Reads the value, hands it to `transform` and stores what that returns
804
+ * (null clears it), as one operation — the per-flow verifiers
805
+ * (`flows.ts`) change one entry of a JSON object this way.
806
+ */
807
+ update(transform) {
808
+ return this.#serial(async () => {
809
+ const next = transform(await this.#get());
810
+ if (next === null) await this.#clear();
811
+ else await this.#set(next);
812
+ });
813
+ }
814
+ /** Stores `next` only if `expected` is still stored, as one operation. */
815
+ replaceIf(expected, next) {
816
+ return this.#serial(async () => {
817
+ if (await this.#get() === expected) await this.#set(next);
818
+ });
819
+ }
820
+ /**
821
+ * Runs `operation` once every earlier one has finished. The operations
822
+ * below never reject, and none of them calls a public method (that would
823
+ * wait on itself).
824
+ */
825
+ #serial(operation) {
826
+ const result = this.#queue.then(operation);
827
+ this.#queue = result.catch(() => void 0);
828
+ return result;
829
+ }
830
+ async #get() {
831
+ if (this.#fallback) return this.#fallback.token;
832
+ try {
833
+ const token = await this.#storage.getItem(this.#key);
834
+ return typeof token === "string" && token !== "" ? token : null;
835
+ } catch {
836
+ await this.#fallBack(null, true);
837
+ return null;
838
+ }
839
+ }
840
+ async #set(token) {
841
+ if (this.#fallback) {
842
+ this.#fallback.token = token;
843
+ return;
844
+ }
845
+ try {
846
+ await this.#storage.setItem(this.#key, token);
847
+ } catch {
848
+ await this.#fallBack(token, true);
849
+ }
850
+ }
851
+ async #clear() {
852
+ if (this.#fallback) {
853
+ this.#fallback.token = null;
854
+ return;
855
+ }
856
+ try {
857
+ await this.#storage.removeItem(this.#key);
858
+ } catch {
859
+ await this.#fallBack(null, false);
860
+ }
861
+ }
862
+ /**
863
+ * Stops using the storage, keeping `token` in memory. The token the storage
864
+ * still holds is removed once, if it lets us: left there it comes back on
865
+ * the next load — after a sign-out that could not read it, or over a
866
+ * sign-in whose write failed (the previous user's session).
867
+ */
868
+ async #fallBack(token, removeStored) {
869
+ this.#fallback = { token };
870
+ if (!removeStored) return;
871
+ try {
872
+ await this.#storage.removeItem(this.#key);
873
+ } catch {
874
+ }
875
+ }
876
+ };
877
+
878
+ // src/query/columns.ts
879
+ function isSpace(char) {
880
+ return char.trim() === "";
881
+ }
882
+ function tokenize(input) {
883
+ const tokens = [];
884
+ let index = 0;
885
+ const at = () => input[index] ?? "";
886
+ for (; ; ) {
887
+ while (index < input.length && isSpace(at())) index++;
888
+ let token;
889
+ if (at() === '"') {
890
+ index++;
891
+ let name = "";
892
+ for (; ; ) {
893
+ if (index >= input.length)
894
+ return `has an unclosed quote in column ${String(tokens.length + 1)}`;
895
+ const char = at();
896
+ index++;
897
+ if (char !== '"') name += char;
898
+ else if (at() === '"') {
899
+ name += '"';
900
+ index++;
901
+ } else break;
902
+ }
903
+ while (index < input.length && isSpace(at())) index++;
904
+ if (index < input.length && at() !== ",")
905
+ return `has text after the closing quote of column ${String(tokens.length + 1)}`;
906
+ token = { name, quoted: true };
907
+ } else {
908
+ let name = "";
909
+ while (index < input.length && at() !== ",") {
910
+ if (at() === '"')
911
+ return `has a quote inside column ${String(tokens.length + 1)}; quote the whole name`;
912
+ name += at();
913
+ index++;
914
+ }
915
+ token = { name: name.trim(), quoted: false };
916
+ }
917
+ tokens.push(token);
918
+ if (index >= input.length) return tokens;
919
+ index++;
920
+ }
921
+ }
922
+ function parseColumns(input) {
923
+ if (input === void 0) return { ok: true, columns: "*" };
924
+ if (typeof input !== "string")
925
+ return { ok: false, reason: "must be a string of column names" };
926
+ const tokens = tokenize(input);
927
+ if (typeof tokens === "string") return { ok: false, reason: tokens };
928
+ const star = tokens.findIndex((token) => !token.quoted && token.name === "*");
929
+ if (star >= 0) {
930
+ if (tokens.length === 1) return { ok: true, columns: "*" };
931
+ return { ok: false, reason: '"*" cannot be combined with column names' };
932
+ }
933
+ const empty = tokens.findIndex((token) => token.name === "");
934
+ if (empty >= 0)
935
+ return {
936
+ ok: false,
937
+ reason: tokens.length === 1 ? "must name at least one column" : `has an empty column name at position ${String(empty + 1)}`
938
+ };
939
+ return { ok: true, columns: tokens.map((token) => token.name) };
940
+ }
941
+
942
+ // src/query/filters.ts
943
+ var MAX_FILTER_DEPTH = 4;
944
+ var OPERATORS = {
945
+ eq: "eq",
946
+ neq: "neq",
947
+ gt: "gt",
948
+ gte: "gte",
949
+ lt: "lt",
950
+ lte: "lte",
951
+ in: "in",
952
+ is: "is",
953
+ like: "like",
954
+ ilike: "ilike",
955
+ contains: "contains",
956
+ containedBy: "containedBy",
957
+ overlaps: "overlaps",
958
+ cs: "contains",
959
+ cd: "containedBy",
960
+ ov: "overlaps"
961
+ };
962
+ function operatorOf(name) {
963
+ return typeof name === "string" && Object.hasOwn(OPERATORS, name) ? OPERATORS[name] : void 0;
964
+ }
965
+ var Refusal = class extends Error {
966
+ };
967
+ function refuse(reason) {
968
+ throw new Refusal(reason);
969
+ }
970
+ var LIST_HINT = {
971
+ in: ["(", ")", "needs a list in parentheses, like in.(a,b),"],
972
+ array: ["{", "}", "needs a list in braces, like cs.{a,b},"]
973
+ };
974
+ function isSpace2(char) {
975
+ return char !== "" && char.trim() === "";
976
+ }
977
+ function looseWord(text) {
978
+ return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "");
979
+ }
980
+ var Parser = class {
981
+ constructor(text, tokens = {}) {
982
+ this.text = text;
983
+ this.tokens = tokens;
984
+ }
985
+ index = 0;
986
+ /**
987
+ * A token at the next value, followed only by spaces and then one of
988
+ * `stops` or the end — `auth.uid()x` is text, not the token.
989
+ */
990
+ token(stops) {
991
+ this.skipSpaces();
992
+ const at = this.index;
993
+ for (const [word, value] of Object.entries(this.tokens)) {
994
+ if (!this.text.startsWith(word, at)) continue;
995
+ let end = at + word.length;
996
+ while (isSpace2(this.text[end] ?? "")) end++;
997
+ const next = this.text[end] ?? "";
998
+ if (next !== "" && !stops.includes(next)) continue;
999
+ this.index = end;
1000
+ return { value, word, at };
1001
+ }
1002
+ return void 0;
1003
+ }
1004
+ at() {
1005
+ return this.text[this.index] ?? "";
1006
+ }
1007
+ /**
1008
+ * An unquoted value that is one of the tokens misspelled — `auth.uid`,
1009
+ * `AUTH.UID` — is refused: read as text it would compare with the words
1010
+ * themselves (사용자 리뷰 반영). Quoted, it is the text.
1011
+ */
1012
+ notNearToken(value, at) {
1013
+ const loose = looseWord(value);
1014
+ for (const word of Object.keys(this.tokens))
1015
+ if (loose !== "" && loose === looseWord(word))
1016
+ refuse(
1017
+ `has ${JSON.stringify(value)} ${this.where(at)}: did you mean ${word}? Quote it to mean the text`
1018
+ );
1019
+ }
1020
+ /** `scalar`, with an unquoted value held to notNearToken. */
1021
+ bareOrQuoted(stops) {
1022
+ this.skipSpaces();
1023
+ const at = this.index;
1024
+ const quoted = this.at() === '"';
1025
+ const text = this.scalar(stops);
1026
+ if (!quoted) this.notNearToken(text, at);
1027
+ return { text, quoted };
1028
+ }
1029
+ where(index = this.index) {
1030
+ return `at character ${String(index + 1)}`;
1031
+ }
1032
+ skipSpaces() {
1033
+ while (isSpace2(this.at())) this.index++;
1034
+ }
1035
+ /** `item ("," item)*`, up to a `)` or the end, inside `level` groups. */
1036
+ list(level) {
1037
+ const nodes = [];
1038
+ for (; ; ) {
1039
+ nodes.push(this.item(level));
1040
+ this.skipSpaces();
1041
+ const char = this.at();
1042
+ if (char === ",") this.index++;
1043
+ else if (char === ")" || char === "") return nodes;
1044
+ else refuse(`has unexpected text ${this.where()}`);
1045
+ }
1046
+ }
1047
+ item(level) {
1048
+ this.skipSpaces();
1049
+ const start = this.index;
1050
+ const char = this.at();
1051
+ if (char === "" || char === "," || char === ")")
1052
+ refuse(`has an empty item ${this.where()}`);
1053
+ const group = /^(not\.)?(and|or)\(/.exec(this.text.slice(start));
1054
+ if (!group) return this.leaf(level);
1055
+ const negated = group[1] !== void 0;
1056
+ const inner = level + (negated ? 2 : 1);
1057
+ if (inner > MAX_FILTER_DEPTH)
1058
+ refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
1059
+ const open = start + group[0].length - 1;
1060
+ this.index = open + 1;
1061
+ const children = this.list(inner);
1062
+ if (this.at() !== ")") refuse(`has an unclosed "(" ${this.where(open)}`);
1063
+ this.index++;
1064
+ const node = group[2] === "or" ? { or: children } : { and: children };
1065
+ return negated ? { not: node } : node;
1066
+ }
1067
+ /** `column.[not.]operator.value`. */
1068
+ leaf(level) {
1069
+ const start = this.index;
1070
+ const column = this.at() === '"' ? this.quoted() : this.word().trim();
1071
+ if (column === "" || this.at() !== ".")
1072
+ refuse(`needs column.operator.value ${this.where(start)}`);
1073
+ this.index++;
1074
+ let nameAt = this.index;
1075
+ let name = this.word();
1076
+ let negated = false;
1077
+ if (name === "not" && this.at() === ".") {
1078
+ if (level + 1 > MAX_FILTER_DEPTH)
1079
+ refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
1080
+ negated = true;
1081
+ this.index++;
1082
+ nameAt = this.index;
1083
+ name = this.word();
1084
+ }
1085
+ const operator = operatorOf(name);
1086
+ if (this.at() !== ".") {
1087
+ if (name !== "" && operator === void 0)
1088
+ refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
1089
+ refuse(`needs column.operator.value ${this.where(start)}`);
1090
+ }
1091
+ if (operator === void 0)
1092
+ refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
1093
+ this.index++;
1094
+ const leaf = { operator, column, value: this.value(operator) };
1095
+ return negated ? { not: leaf } : leaf;
1096
+ }
1097
+ /** Up to the next `.`, `,`, `(`, `)` or the end. */
1098
+ word() {
1099
+ const start = this.index;
1100
+ while (this.index < this.text.length && !".,()".includes(this.at()))
1101
+ this.index++;
1102
+ return this.text.slice(start, this.index);
1103
+ }
1104
+ /** `"…"`, with `\` making the next character literal. */
1105
+ quoted() {
1106
+ const start = this.index;
1107
+ this.index++;
1108
+ let text = "";
1109
+ for (; ; ) {
1110
+ if (this.index >= this.text.length)
1111
+ refuse(`has an unclosed quote ${this.where(start)}`);
1112
+ const char = this.at();
1113
+ this.index++;
1114
+ if (char === '"') return text;
1115
+ if (char === "\\" && this.index < this.text.length) {
1116
+ text += this.at();
1117
+ this.index++;
1118
+ } else text += char;
1119
+ }
1120
+ }
1121
+ /** A quoted value, or everything up to `stops`, trimmed. */
1122
+ scalar(stops) {
1123
+ this.skipSpaces();
1124
+ if (this.at() === '"') {
1125
+ const text = this.quoted();
1126
+ this.skipSpaces();
1127
+ if (this.at() !== "" && !stops.includes(this.at()))
1128
+ refuse(`has text after a closing quote ${this.where()}`);
1129
+ return text;
1130
+ }
1131
+ const start = this.index;
1132
+ while (this.index < this.text.length && !stops.includes(this.at()))
1133
+ this.index++;
1134
+ return this.text.slice(start, this.index).trim();
1135
+ }
1136
+ /**
1137
+ * `(a,b)` for `in`: text values, except that an unquoted `true` or `false`
1138
+ * is a boolean (사용자 리뷰 반영, as in `eq`). `{a,b}` for the array
1139
+ * operators: text, except that an unquoted exact decimal is a number
1140
+ * (적대적 리뷰 반영, narrowed by 사용자 리뷰 반영) — json_each's values
1141
+ * have no type affinity to turn the text "1" into 1, so as text `cs.{1}`
1142
+ * matched no numeric array and `not.ov.{1}` matched every row. Quoted,
1143
+ * `"1"` and `"true"` stay text.
1144
+ */
1145
+ listValue(kind) {
1146
+ const [open, close, hint] = LIST_HINT[kind];
1147
+ this.skipSpaces();
1148
+ const openAt = this.index;
1149
+ if (this.at() !== open) refuse(`${hint} ${this.where()}`);
1150
+ this.index++;
1151
+ this.skipSpaces();
1152
+ const values = [];
1153
+ if (this.at() === close) {
1154
+ this.index++;
1155
+ return values;
1156
+ }
1157
+ for (; ; ) {
1158
+ this.skipSpaces();
1159
+ const itemAt = this.index;
1160
+ const token = this.token(`,${close}`);
1161
+ if (token) values.push(token.value);
1162
+ else {
1163
+ const { text: value, quoted } = this.bareOrQuoted(`,${close}`);
1164
+ if (!quoted && value === "") {
1165
+ if (this.index >= this.text.length)
1166
+ refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
1167
+ refuse(`has an empty item ${this.where(itemAt)}`);
1168
+ }
1169
+ values.push(
1170
+ quoted ? value : kind === "array" ? exactNumber(value) : truth(value)
1171
+ );
1172
+ }
1173
+ if (this.index >= this.text.length)
1174
+ refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
1175
+ const char = this.at();
1176
+ this.index++;
1177
+ if (char === close) return values;
1178
+ }
1179
+ }
1180
+ /** The value after `operator.`. */
1181
+ value(operator) {
1182
+ const start = this.index;
1183
+ switch (operator) {
1184
+ case "in":
1185
+ return this.listValue("in");
1186
+ case "contains":
1187
+ case "containedBy":
1188
+ case "overlaps":
1189
+ return this.listValue("array");
1190
+ case "is":
1191
+ return keyword(this.scalar(",)"), start);
1192
+ case "like":
1193
+ case "ilike": {
1194
+ const token = this.token(",)");
1195
+ if (token)
1196
+ refuse(
1197
+ `cannot use ${token.word} with ${operator} ${this.where(token.at)}`
1198
+ );
1199
+ return likePattern(this.bareOrQuoted(",)").text);
1200
+ }
1201
+ case "eq":
1202
+ case "neq": {
1203
+ const token = this.token(",)");
1204
+ if (token) return token.value;
1205
+ const { text, quoted } = this.bareOrQuoted(",)");
1206
+ return quoted ? text : truth(text);
1207
+ }
1208
+ default: {
1209
+ const token = this.token(",)");
1210
+ return token ? token.value : this.bareOrQuoted(",)").text;
1211
+ }
1212
+ }
1213
+ }
1214
+ };
1215
+ function likePattern(pattern) {
1216
+ return pattern.replaceAll("*", "%");
1217
+ }
1218
+ function truth(text) {
1219
+ const word = text.toLowerCase();
1220
+ if (word === "true") return true;
1221
+ if (word === "false") return false;
1222
+ return text;
1223
+ }
1224
+ var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
1225
+ function exactNumber(text) {
1226
+ if (!DECIMAL.test(text)) return text;
1227
+ const number = Number(text);
1228
+ if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
1229
+ const trimmed = text.replace(/\.?0+$/, "");
1230
+ return String(number) === trimmed ? number : text;
1231
+ }
1232
+ function quoteFilterValue(value) {
1233
+ const text = String(value);
1234
+ return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1235
+ }
1236
+ function keyword(text, at) {
1237
+ const word = text.toLowerCase();
1238
+ if (word === "null") return null;
1239
+ if (word === "true") return true;
1240
+ if (word === "false") return false;
1241
+ return refuse(
1242
+ `needs null, true or false after is at character ${String(at + 1)}`
1243
+ );
1244
+ }
1245
+ function attempt(parse2) {
1246
+ try {
1247
+ return { ok: true, node: parse2() };
1248
+ } catch (error) {
1249
+ if (error instanceof Refusal) return { ok: false, reason: error.message };
1250
+ throw error;
1251
+ }
1252
+ }
1253
+ function parseLogic(text, kind, options = {}) {
1254
+ if (typeof text !== "string")
1255
+ return { ok: false, reason: "must be a filter string or a function" };
1256
+ if (text.trim() === "") return { ok: false, reason: "must not be empty" };
1257
+ return attempt(() => {
1258
+ if (options.group === false) {
1259
+ const alone = items(text, options.tokens, 0);
1260
+ if (alone.length === 1 && alone[0]) return alone[0];
1261
+ }
1262
+ const nodes = items(text, options.tokens, 1);
1263
+ return kind === "or" ? { or: nodes } : { and: nodes };
1264
+ });
1265
+ }
1266
+ function items(text, tokens, level) {
1267
+ const parser = new Parser(text, tokens);
1268
+ const nodes = parser.list(level);
1269
+ if (parser.at() === ")") refuse(`has an unmatched ")" ${parser.where()}`);
1270
+ return nodes;
1271
+ }
1272
+ function conditionOf(column, name, value) {
1273
+ const operator = operatorOf(name);
1274
+ if (operator === void 0)
1275
+ return { ok: false, reason: `has an unknown operator "${String(name)}"` };
1276
+ if (typeof value !== "string")
1277
+ return {
1278
+ ok: true,
1279
+ node: {
1280
+ operator,
1281
+ column,
1282
+ value: Array.isArray(value) ? [...value] : value
1283
+ }
1284
+ };
1285
+ return attempt(() => {
1286
+ const parser = new Parser(value);
1287
+ let parsed = value;
1288
+ if (["in", "contains", "containedBy", "overlaps", "is"].includes(operator)) {
1289
+ parsed = parser.value(operator);
1290
+ parser.skipSpaces();
1291
+ if (parser.at() !== "") refuse(`has unexpected text ${parser.where()}`);
1292
+ } else if (operator === "like" || operator === "ilike")
1293
+ parsed = likePattern(value);
1294
+ else if (operator === "eq" || operator === "neq") parsed = truth(value);
1295
+ return { operator, column, value: parsed };
1296
+ });
1297
+ }
1298
+
1299
+ // src/query/builder.ts
1300
+ function patternOf(pattern) {
1301
+ return typeof pattern === "string" ? likePattern(pattern) : pattern;
1302
+ }
1303
+ var MAX_LIMIT = 1e3;
1304
+ var CAP = `one select reads at most ${String(MAX_LIMIT)} rows, by limit() or range()`;
1305
+ function initialState(table) {
1306
+ return { table, problem: null, filters: [], columns: "*", order: [] };
1307
+ }
1308
+ function copyRow(row) {
1309
+ return { ...row };
1310
+ }
1311
+ var Builder = class {
1312
+ run;
1313
+ state;
1314
+ constructor(run, state) {
1315
+ this.run = run;
1316
+ this.state = state;
1317
+ }
1318
+ /** A new builder of the same kind with `patch` applied. */
1319
+ next(patch) {
1320
+ const Next = this.constructor;
1321
+ return new Next(this.run, { ...this.state, ...patch });
1322
+ }
1323
+ fail(path, reason) {
1324
+ return this.failWith(invalidQuery(path, reason));
1325
+ }
1326
+ /** Keeps the first problem: it is the one the user has to fix first. */
1327
+ failWith(problem) {
1328
+ if (this.state.problem !== null) return this;
1329
+ return this.next({ problem });
1330
+ }
1331
+ /**
1332
+ * Runs the query. Every `await` sends a new request with the same AST.
1333
+ * Never rejects: failures resolve as `{ data: null, error }`.
1334
+ */
1335
+ // oxlint-disable-next-line unicorn/no-thenable -- awaitable builders are the API (spec §15)
1336
+ then(onfulfilled, onrejected) {
1337
+ return this.execute().then(onfulfilled, onrejected);
1338
+ }
1339
+ async execute() {
1340
+ if (this.state.problem !== null)
1341
+ return { data: null, error: this.state.problem };
1342
+ return await this.run(this.build());
1343
+ }
1344
+ /** A mutation's `.select()`: RETURNING these columns. */
1345
+ withReturning(columns) {
1346
+ const parsed = parseColumns(columns);
1347
+ if (!parsed.ok) return this.fail("returning", parsed.reason);
1348
+ return this.next({ returning: parsed.columns });
1349
+ }
1350
+ };
1351
+ function copyList(values) {
1352
+ return Array.isArray(values) ? [...values] : values;
1353
+ }
1354
+ var FilterBuilder = class _FilterBuilder extends Builder {
1355
+ /** One more entry in the top-level list, which is an AND. */
1356
+ add(node) {
1357
+ return this.next({ filters: [...this.state.filters, node] });
1358
+ }
1359
+ condition(operator, column, value) {
1360
+ return this.add({ operator, column, value });
1361
+ }
1362
+ eq(column, value) {
1363
+ return this.condition("eq", column, value);
1364
+ }
1365
+ neq(column, value) {
1366
+ return this.condition("neq", column, value);
1367
+ }
1368
+ gt(column, value) {
1369
+ return this.condition("gt", column, value);
1370
+ }
1371
+ gte(column, value) {
1372
+ return this.condition("gte", column, value);
1373
+ }
1374
+ lt(column, value) {
1375
+ return this.condition("lt", column, value);
1376
+ }
1377
+ lte(column, value) {
1378
+ return this.condition("lte", column, value);
1379
+ }
1380
+ /** An empty list matches nothing. */
1381
+ in(column, values) {
1382
+ return this.condition("in", column, copyList(values));
1383
+ }
1384
+ /** `IS NULL`, `IS TRUE` or `IS FALSE` — any non-zero number is true, NULL neither. */
1385
+ is(column, value) {
1386
+ return this.condition("is", column, value);
1387
+ }
1388
+ /**
1389
+ * Case-sensitive, `%` and `_` as in SQL; `*` is `%` too, as in PostgREST,
1390
+ * so a literal `*` cannot be matched.
1391
+ */
1392
+ like(column, pattern) {
1393
+ return this.condition("like", column, patternOf(pattern));
1394
+ }
1395
+ /** Case-insensitive for ASCII letters; `%`, `_` and `*` as in `like`. */
1396
+ ilike(column, pattern) {
1397
+ return this.condition("ilike", column, patternOf(pattern));
1398
+ }
1399
+ /**
1400
+ * The column — TEXT holding a JSON array — has every one of the values.
1401
+ * A row that is not a JSON array is no match; NULL is neither.
1402
+ */
1403
+ contains(column, values) {
1404
+ return this.condition("contains", column, copyList(values));
1405
+ }
1406
+ /** Every element of the column's JSON array is one of the values (`[]` is). */
1407
+ containedBy(column, values) {
1408
+ return this.condition("containedBy", column, copyList(values));
1409
+ }
1410
+ /** The column's JSON array has at least one of the values. */
1411
+ overlaps(column, values) {
1412
+ return this.condition("overlaps", column, copyList(values));
1413
+ }
1414
+ /** An `eq` for each key, in key order. */
1415
+ match(query) {
1416
+ const conditions = Object.entries(query).map(
1417
+ ([column, value]) => ({
1418
+ operator: "eq",
1419
+ column,
1420
+ value
1421
+ })
1422
+ );
1423
+ return this.next({ filters: [...this.state.filters, ...conditions] });
1424
+ }
1425
+ /**
1426
+ * Any operator by name, Supabase's escape hatch: `filter("id", "in",
1427
+ * "(1,2)")`, `filter("tags", "cs", "{a}")`, `filter("n", "not.gte", 10)`.
1428
+ * A string value is PostgREST syntax where the operator needs one, and text
1429
+ * otherwise.
1430
+ */
1431
+ filter(column, operator, value) {
1432
+ const name = operator;
1433
+ const negated = typeof name === "string" && name.startsWith("not.");
1434
+ const parsed = conditionOf(
1435
+ column,
1436
+ negated ? name.slice(4) : name,
1437
+ value
1438
+ );
1439
+ if (!parsed.ok) return this.fail("filter", parsed.reason);
1440
+ return this.add(negated ? { not: parsed.node } : parsed.node);
1441
+ }
1442
+ not(target, operator, value) {
1443
+ if (typeof target === "function") {
1444
+ const nodes = this.group("not", target);
1445
+ if (!Array.isArray(nodes)) return this.failWith(nodes);
1446
+ const [only] = nodes;
1447
+ return this.add({
1448
+ not: nodes.length === 1 && only ? only : { and: nodes }
1449
+ });
1450
+ }
1451
+ const parsed = conditionOf(target, operator, value);
1452
+ if (!parsed.ok) return this.fail("not", parsed.reason);
1453
+ return this.add({ not: parsed.node });
1454
+ }
1455
+ /**
1456
+ * Rows matching at least one of the conditions: a callback,
1457
+ * `or((q) => q.eq("status", "draft").lt("price", 10))`, or Supabase's
1458
+ * string, `or("status.eq.draft,price.lt.10")`, whose values are text.
1459
+ */
1460
+ or(filters) {
1461
+ return this.logic("or", filters);
1462
+ }
1463
+ /** Rows matching all of the conditions, as a group: for use inside `or`. */
1464
+ and(filters) {
1465
+ return this.logic("and", filters);
1466
+ }
1467
+ logic(kind, filters) {
1468
+ if (typeof filters === "function") {
1469
+ const nodes = this.group(kind, filters);
1470
+ if (!Array.isArray(nodes)) return this.failWith(nodes);
1471
+ return this.add(kind === "or" ? { or: nodes } : { and: nodes });
1472
+ }
1473
+ const parsed = parseLogic(filters, kind);
1474
+ if (!parsed.ok) return this.fail(kind, parsed.reason);
1475
+ return this.add(parsed.node);
1476
+ }
1477
+ /**
1478
+ * The conditions `build` adds, or why there are none to use. `build` gets
1479
+ * a builder of this kind with no filters; FilterGroup shows only its filter
1480
+ * methods, and only its conditions and first problem are read back.
1481
+ */
1482
+ group(path, build) {
1483
+ const built = build(this.next({ filters: [], problem: null }));
1484
+ if (!(built instanceof _FilterBuilder))
1485
+ return invalidQuery(path, "must return the filter builder it was given");
1486
+ const { filters, problem } = built.state;
1487
+ if (problem !== null) return problem;
1488
+ if (filters.length === 0) return invalidQuery(path, "must not be empty");
1489
+ return [...filters];
1490
+ }
1491
+ filtersPart() {
1492
+ return this.state.filters.length === 0 ? {} : { filters: [...this.state.filters] };
1493
+ }
1494
+ };
1495
+ var SelectBuilder = class extends FilterBuilder {
1496
+ /** Call again to sort by more columns, in call order. */
1497
+ order(column, options = {}) {
1498
+ const term = { column, ascending: options.ascending ?? true };
1499
+ if (options.nullsFirst !== void 0) term.nullsFirst = options.nullsFirst;
1500
+ return this.next({ order: [...this.state.order, term] });
1501
+ }
1502
+ /** At most `count` rows (1–1,000; a select without one reads up to 1,000). */
1503
+ limit(count) {
1504
+ if (!Number.isSafeInteger(count) || count < 1 || count > MAX_LIMIT)
1505
+ return this.fail(
1506
+ "limit",
1507
+ `must be an integer from 1 to ${String(MAX_LIMIT)}; ${CAP}`
1508
+ );
1509
+ return this.next({ limit: count });
1510
+ }
1511
+ offset(count) {
1512
+ return this.next({ offset: count });
1513
+ }
1514
+ /**
1515
+ * Rows `from` to `to`, both inclusive and counted from 0 — at most 1,000
1516
+ * of them. Sent as `offset` and `limit`, so it is checked here: past the
1517
+ * cap the worker would name `limit`, which the app never called.
1518
+ */
1519
+ range(from, to) {
1520
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 0 || to < from)
1521
+ return this.fail("range", "must be two integers with 0 <= from <= to");
1522
+ const rows = to - from + 1;
1523
+ if (rows > MAX_LIMIT)
1524
+ return this.fail(
1525
+ "range",
1526
+ `spans ${String(rows)} rows (to - from + 1); ${CAP}`
1527
+ );
1528
+ return this.next({ offset: from, limit: rows });
1529
+ }
1530
+ /** Exactly one row, or NOT_SINGLE_ROW. */
1531
+ single() {
1532
+ return this.next({ single: "one" });
1533
+ }
1534
+ /** One row or null; more than one is NOT_SINGLE_ROW. */
1535
+ maybeSingle() {
1536
+ return this.next({ single: "maybe" });
1537
+ }
1538
+ build() {
1539
+ const { table, columns, order, limit, offset, single } = this.state;
1540
+ return {
1541
+ version: 1,
1542
+ action: "select",
1543
+ table,
1544
+ columns: columns === "*" ? "*" : [...columns],
1545
+ ...this.filtersPart(),
1546
+ ...order.length === 0 ? {} : { order: order.map((term) => ({ ...term })) },
1547
+ ...limit === void 0 ? {} : { limit },
1548
+ ...offset === void 0 ? {} : { offset },
1549
+ ...single === void 0 ? {} : { single }
1550
+ };
1551
+ }
1552
+ };
1553
+ var InsertBuilder = class extends Builder {
1554
+ /** Return the inserted rows (all columns, or the ones named). */
1555
+ select(columns) {
1556
+ return this.withReturning(columns);
1557
+ }
1558
+ build() {
1559
+ const { table, rows = [], returning } = this.state;
1560
+ return {
1561
+ version: 1,
1562
+ action: "insert",
1563
+ table,
1564
+ rows: rows.map(copyRow),
1565
+ ...returning === void 0 ? {} : { returning }
1566
+ };
1567
+ }
1568
+ };
1569
+ var MutationBuilder = class extends FilterBuilder {
1570
+ /** Return the changed rows (all columns, or the ones named). */
1571
+ select(columns) {
1572
+ return this.withReturning(columns);
1573
+ }
1574
+ /** Apply to every row with no filter at all. Service key only. */
1575
+ all() {
1576
+ return this.next({ allowUnfiltered: true });
1577
+ }
1578
+ build() {
1579
+ const {
1580
+ table,
1581
+ action,
1582
+ values = {},
1583
+ returning,
1584
+ allowUnfiltered
1585
+ } = this.state;
1586
+ const tail = {
1587
+ ...this.filtersPart(),
1588
+ ...returning === void 0 ? {} : { returning },
1589
+ ...allowUnfiltered === void 0 ? {} : { allowUnfiltered }
1590
+ };
1591
+ return action === "delete" ? { version: 1, action: "delete", table, ...tail } : {
1592
+ version: 1,
1593
+ action: "update",
1594
+ table,
1595
+ values: copyRow(values),
1596
+ ...tail
1597
+ };
1598
+ }
1599
+ };
1600
+ var TableBuilder = class {
1601
+ #run;
1602
+ #table;
1603
+ constructor(run, table) {
1604
+ this.#run = run;
1605
+ this.#table = table;
1606
+ }
1607
+ /** `"*"` (the default) or `"id, title"`. */
1608
+ select(columns) {
1609
+ const state = initialState(this.#table);
1610
+ const parsed = parseColumns(columns);
1611
+ return new SelectBuilder(
1612
+ this.#run,
1613
+ parsed.ok ? { ...state, columns: parsed.columns } : { ...state, problem: invalidQuery("columns", parsed.reason) }
1614
+ );
1615
+ }
1616
+ /** One row or several; one request, one transaction. */
1617
+ insert(rows) {
1618
+ const list = Array.isArray(rows) ? rows : [rows];
1619
+ return new InsertBuilder(this.#run, {
1620
+ ...initialState(this.#table),
1621
+ rows: list.map(copyRow)
1622
+ });
1623
+ }
1624
+ update(values) {
1625
+ return new MutationBuilder(this.#run, {
1626
+ ...initialState(this.#table),
1627
+ action: "update",
1628
+ values: copyRow(values)
1629
+ });
1630
+ }
1631
+ delete() {
1632
+ return new MutationBuilder(this.#run, {
1633
+ ...initialState(this.#table),
1634
+ action: "delete"
1635
+ });
1636
+ }
1637
+ };
1638
+
1639
+ // src/client.ts
1640
+ var CONFIG_MESSAGES = {
1641
+ url: "createClient needs the project url, like https://my-app.naro.sh.",
1642
+ urlNotOrigin: "createClient needs the project url alone, like https://my-app.naro.sh: no path, no ? or #, no user:password@. Naro serves its API at /_naro/v1 on that origin.",
1643
+ key: "createClient needs an anonKey (or, on a server, a serviceKey).",
1644
+ bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
1645
+ serviceKeyOffServer: "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.",
1646
+ fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
1647
+ };
1648
+ var SERVICE_KEY_PREFIX = "naro_sk_";
1649
+ function baseUrlOf(url) {
1650
+ if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
1651
+ const trimmed = url.trim();
1652
+ let parsed;
1653
+ try {
1654
+ parsed = new URL(trimmed.replace(/\/+$/, ""));
1655
+ } catch {
1656
+ throw new Error(CONFIG_MESSAGES.url);
1657
+ }
1658
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
1659
+ throw new Error(CONFIG_MESSAGES.url);
1660
+ if (trimmed.includes("?") || trimmed.includes("#") || parsed.pathname !== "/" || parsed.username !== "" || parsed.password !== "")
1661
+ throw new Error(CONFIG_MESSAGES.urlNotOrigin);
1662
+ return parsed.origin;
1663
+ }
1664
+ function apiKeyOf(options) {
1665
+ const { anonKey, serviceKey } = options;
1666
+ const hasAnon = typeof anonKey === "string" && anonKey !== "";
1667
+ const hasService = typeof serviceKey === "string" && serviceKey !== "";
1668
+ if (hasAnon && hasService) throw new Error(CONFIG_MESSAGES.bothKeys);
1669
+ const key = hasService ? serviceKey : hasAnon ? anonKey : void 0;
1670
+ if (key === void 0) throw new Error(CONFIG_MESSAGES.key);
1671
+ if ((hasService || key.startsWith(SERVICE_KEY_PREFIX)) && !isKnownServer())
1672
+ throw new Error(CONFIG_MESSAGES.serviceKeyOffServer);
1673
+ return key;
1674
+ }
1675
+ function queryRunner(http, auth) {
1676
+ return async (query) => {
1677
+ await auth.ready();
1678
+ const result = await call(http, {
1679
+ method: "POST",
1680
+ path: "/data/query",
1681
+ body: query,
1682
+ withToken: true
1683
+ });
1684
+ if (result.error !== null) return { data: null, error: result.error };
1685
+ const { body, status } = result.answer;
1686
+ if (!isRecord(body) || !Object.hasOwn(body, "data"))
1687
+ return { data: null, error: invalidResponse(status) };
1688
+ return { data: body.data, error: null };
1689
+ };
1690
+ }
1691
+ var NaroClient = class {
1692
+ /** Sign-up, sign-in, sign-out and the current user (spec §13). */
1693
+ auth;
1694
+ #run;
1695
+ // naro.storage — Phase 3 (spec §18): R2 storage lands here, beside `auth`
1696
+ // and `from()`. Deliberately absent in Phase 1: not implemented, not typed.
1697
+ constructor(options) {
1698
+ const baseUrl = baseUrlOf(options.url);
1699
+ const apiKey = apiKeyOf(options);
1700
+ if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
1701
+ throw new Error(CONFIG_MESSAGES.fetch);
1702
+ const storage = options.auth?.storage ?? defaultStorage();
1703
+ const http = {
1704
+ baseUrl,
1705
+ apiKey,
1706
+ fetch: options.fetch,
1707
+ tokens: new TokenStore(storage, storageKey(baseUrl))
1708
+ };
1709
+ this.auth = new AuthClient(http, {
1710
+ verifiers: new FlowVerifiers(
1711
+ new TokenStore(storage, verifierKey(baseUrl))
1712
+ ),
1713
+ recovery: new RecoveryToken(
1714
+ new TokenStore(storage, recoveryKey(baseUrl))
1715
+ ),
1716
+ detectSessionInUrl: options.auth?.detectSessionInUrl ?? true,
1717
+ shared: { storage, key: storageKey(baseUrl) }
1718
+ });
1719
+ this.#run = queryRunner(http, this.auth);
1720
+ }
1721
+ /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
1722
+ from(table) {
1723
+ return new TableBuilder(this.#run, table);
1724
+ }
1725
+ };
1726
+ function createClient(options) {
1727
+ return new NaroClient(options);
1728
+ }
1729
+ export {
1730
+ NaroClient,
1731
+ createClient,
1732
+ quoteFilterValue
1733
+ };