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/README.md +334 -0
- package/dist/auth/client.d.ts +122 -0
- package/dist/auth/flow-errors.d.ts +4 -0
- package/dist/auth/flows.d.ts +17 -0
- package/dist/auth/pkce.d.ts +10 -0
- package/dist/auth/recovery.d.ts +15 -0
- package/dist/auth/storage.d.ts +61 -0
- package/dist/client.d.ts +61 -0
- package/dist/errors.d.ts +51 -0
- package/dist/http.d.ts +61 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1733 -0
- package/dist/query/builder.d.ts +211 -0
- package/dist/query/columns.d.ts +15 -0
- package/dist/query/filters.d.ts +63 -0
- package/dist/query/selected.d.ts +35 -0
- package/dist/runtime.d.ts +13 -0
- package/dist/types.d.ts +186 -0
- package/dist/wire.d.ts +61 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# naro-js
|
|
2
|
+
|
|
3
|
+
Auth and data for your [Naro](https://naro.sh) project, from the browser or
|
|
4
|
+
the server. A project's backend brings its own database; this client signs
|
|
5
|
+
users in and reads and writes it through the project's policies. No SQL, no
|
|
6
|
+
database credentials, no dependencies.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install naro-js
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The backend is off until you turn it on, one project at a time: Turn on in
|
|
13
|
+
the Backend card of the project's Database page, or `naro backend enable`.
|
|
14
|
+
`naro backend status` prints the `url` and the anon key this client takes.
|
|
15
|
+
`naro backend disable` turns it off again: in up to about 90 seconds every call
|
|
16
|
+
answers `404 NOT_FOUND` ("There is no Naro backend at this host."). The data,
|
|
17
|
+
the users and the keys are kept, and turning it back on brings them back
|
|
18
|
+
with the same keys — nothing to change in your app.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createClient } from "naro-js";
|
|
22
|
+
|
|
23
|
+
const naro = createClient({
|
|
24
|
+
url: "https://my-app.naro.sh",
|
|
25
|
+
anonKey: "naro_pk_…",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await naro.auth.signIn({ email, password });
|
|
29
|
+
const {
|
|
30
|
+
data: { user },
|
|
31
|
+
} = await naro.auth.getUser();
|
|
32
|
+
|
|
33
|
+
const { data, error } = await naro
|
|
34
|
+
.from("posts")
|
|
35
|
+
.select("*")
|
|
36
|
+
.eq("user_id", user.id)
|
|
37
|
+
.order("created_at", { ascending: false })
|
|
38
|
+
.limit(20);
|
|
39
|
+
|
|
40
|
+
await naro.from("posts").insert({ title: "Hello", content: "World" });
|
|
41
|
+
await naro.from("posts").update({ title: "Updated" }).eq("id", 10);
|
|
42
|
+
await naro.from("posts").delete().eq("id", 10);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Every call resolves to `{ data, error }` and never throws. `createClient`
|
|
46
|
+
throws only for a configuration mistake.
|
|
47
|
+
- `url` is the project's origin alone (`https://my-app.naro.sh`): no path,
|
|
48
|
+
no `?` or `#`.
|
|
49
|
+
- Queries: `.select("id, title")`, `.eq` `.neq` `.gt` `.gte` `.lt` `.lte`
|
|
50
|
+
`.in` `.is` `.like` `.ilike`, `.or` `.and` `.not` `.filter` `.match`
|
|
51
|
+
`.contains` `.containedBy` `.overlaps` (see [Filters](#filters)),
|
|
52
|
+
`.order()`, `.limit()`, `.offset()`,
|
|
53
|
+
`.range(from, to)`, `.single()`, `.maybeSingle()`. One select reads at
|
|
54
|
+
most 1,000 rows, by `.limit()` or `.range()`. Add `.select()` to an
|
|
55
|
+
insert, update or delete to get the rows back.
|
|
56
|
+
- The anon key is safe in a browser; what it may do is decided by the
|
|
57
|
+
project's policies. The service key (`naro_sk_…`, as `serviceKey`) skips
|
|
58
|
+
them — use it on a server only. `createClient` accepts it only in Node,
|
|
59
|
+
Deno, Bun, Cloudflare Workers or an edge runtime, and refuses it in a
|
|
60
|
+
browser, a web worker or a React Native app.
|
|
61
|
+
- A `RATE_LIMITED` error carries `details.retryAfter`, in seconds, when the
|
|
62
|
+
backend says how long to wait. So does `WRITE_QUOTA_EXCEEDED`: the project
|
|
63
|
+
wrote more database rows today (UTC) than its daily limit, so inserts,
|
|
64
|
+
updates, deletes and sign-ups are refused until 00:00 UTC. Reads and
|
|
65
|
+
sign-in still work.
|
|
66
|
+
- `DATABASE_FULL` (507) means the project's database reached D1's 10 GB
|
|
67
|
+
limit, which cannot be raised: writes fail until rows are deleted. Reads
|
|
68
|
+
still work, and no retry helps.
|
|
69
|
+
- The session token lives in `localStorage` in a browser and in memory
|
|
70
|
+
elsewhere. On a server, create one client per request, or pass
|
|
71
|
+
`auth: { storage }` with your own `getItem` / `setItem` / `removeItem`.
|
|
72
|
+
- Types: `createClient<Database>()` with the `Database` that
|
|
73
|
+
`naro gen types` writes — see [Types](#types).
|
|
74
|
+
- Email and social sign-in — see [Sign-in flows](#sign-in-flows).
|
|
75
|
+
|
|
76
|
+
## Sign-in flows
|
|
77
|
+
|
|
78
|
+
Google and GitHub sign-in, email confirmation and password reset, once the
|
|
79
|
+
project has set them up (`naro backend auth`). The names follow supabase-js.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// Sends the browser to the project's /_naro/v1/auth/authorize/github, then
|
|
83
|
+
// to GitHub; it comes back to redirectTo.
|
|
84
|
+
await naro.auth.signInWithOAuth({
|
|
85
|
+
provider: "github", // or "google"
|
|
86
|
+
redirectTo: "https://app.example.com/auth/done", // default: this page
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// On the page it comes back to, createClient finishes the sign-in itself
|
|
90
|
+
// (auth: { detectSessionInUrl: false } to do it yourself). getSession and
|
|
91
|
+
// every naro.from() query wait for it, so none goes out as anon first — for
|
|
92
|
+
// at most 15 seconds: then naro.auth.ready() resolves { error: TIMEOUT } and
|
|
93
|
+
// they go out as anon. A client built twice on the page (React StrictMode)
|
|
94
|
+
// waits on the same exchange.
|
|
95
|
+
const {
|
|
96
|
+
data: { user },
|
|
97
|
+
} = await naro.auth.getUser();
|
|
98
|
+
|
|
99
|
+
// What came back: data.type is "oauth", "email_verification", "recovery"
|
|
100
|
+
// or null, and a failure is the error (OAUTH_ACCESS_DENIED, TOKEN_EXPIRED…).
|
|
101
|
+
const { data, error } = await naro.auth.getSessionFromUrl();
|
|
102
|
+
|
|
103
|
+
// The confirmation link only confirms the address. After you confirm, sign in.
|
|
104
|
+
await naro.auth.signUp({
|
|
105
|
+
email,
|
|
106
|
+
password,
|
|
107
|
+
redirectTo: APP + "/auth/confirmed",
|
|
108
|
+
});
|
|
109
|
+
await naro.auth.resendVerification(email, {
|
|
110
|
+
redirectTo: APP + "/auth/confirmed",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await naro.auth.resetPasswordForEmail(email, {
|
|
114
|
+
redirectTo: APP + "/auth/reset",
|
|
115
|
+
});
|
|
116
|
+
// On /auth/reset, after getSessionFromUrl() gave type "recovery" — within
|
|
117
|
+
// 15 minutes, while nobody is signed in on this browser:
|
|
118
|
+
await naro.auth.updateUser({ password: newPassword });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `redirectTo` must be on one of the project's production hosts — its
|
|
122
|
+
`https://<project>.naro.sh` address and its custom domains — or an entry
|
|
123
|
+
it allowed with `naro backend auth redirects add` — an origin, or an
|
|
124
|
+
origin and a path (`https://app.example.com/auth/`: only the pages under
|
|
125
|
+
it). Otherwise the worker answers `403 INVALID_REDIRECT_URL`
|
|
126
|
+
(`INVALID_CALLBACK_URL` from `signUp` and `resendVerification`). The
|
|
127
|
+
preview host (`<project>-preview.naro.sh`) runs any pushed branch against
|
|
128
|
+
the same accounts: it is allowed only once added, and emailed links always
|
|
129
|
+
point at a production host. Email flows without `redirectTo` come back to
|
|
130
|
+
that host's root.
|
|
131
|
+
- Pass `createClient` the origin your app signs in on: the provider sends
|
|
132
|
+
the browser back to that host's `/_naro/v1/auth/callback/<provider>`, so
|
|
133
|
+
that is the redirect URI to register with the provider
|
|
134
|
+
(`naro backend auth` prints one per production host).
|
|
135
|
+
- `signInWithOAuth` makes no request: it navigates. The worker's
|
|
136
|
+
`/authorize` sets a ten-minute `__Host-` cookie for that one sign-in on the
|
|
137
|
+
host it runs on — no other naro.sh app can set or overwrite it, and two
|
|
138
|
+
tabs keep their own — and only the browser that holds it can finish
|
|
139
|
+
(`OAUTH_BROWSER_MISMATCH` elsewhere);
|
|
140
|
+
anything that goes wrong — `PROVIDER_NOT_CONFIGURED`, `RATE_LIMITED`… —
|
|
141
|
+
comes back in the page's URL, as `getSessionFromUrl()`'s error.
|
|
142
|
+
`skipBrowserRedirect: true` answers the URL without going there.
|
|
143
|
+
- The answer comes back in the URL fragment as a one-time code: it lives
|
|
144
|
+
60 seconds, works once, and only with the PKCE verifier this browser kept
|
|
145
|
+
for that sign-in (one per sign-in, for ten minutes, in the same storage as
|
|
146
|
+
the session token). Without it: `CODE_VERIFIER_MISSING` — sign in again.
|
|
147
|
+
- The fragment can be read by any script on the page it lands on, and an
|
|
148
|
+
open redirect (`/logout?next=…`) passes it on. Keep sign-in on pages of
|
|
149
|
+
its own, allow only their path, and keep third-party scripts and open
|
|
150
|
+
redirects off them. `getSessionFromUrl()` never shows text from the URL:
|
|
151
|
+
its errors carry the SDK's own messages.
|
|
152
|
+
- A provider email the provider has not verified is refused
|
|
153
|
+
(`OAUTH_PROVIDER_EMAIL_NOT_VERIFIED`): verify it with the provider first.
|
|
154
|
+
An account with the same email is linked only when both sides have it
|
|
155
|
+
confirmed (`OAUTH_ACCOUNT_NOT_LINKED` otherwise).
|
|
156
|
+
- A confirmation link opens no session. If someone else signed up with the
|
|
157
|
+
address before its owner confirmed it — or the owner's Google or GitHub
|
|
158
|
+
sign-in was refused for that unconfirmed account
|
|
159
|
+
(`OAUTH_ACCOUNT_NOT_LINKED`) — confirming clears that password
|
|
160
|
+
(`PASSWORD_RESET_REQUIRED`): set one with `resetPasswordForEmail`.
|
|
161
|
+
- `updateUser({ password })` sets a new password in the recovery flow only
|
|
162
|
+
(`PASSWORD_RECOVERY_REQUIRED` otherwise, with no request). Like
|
|
163
|
+
supabase-js's recovery session, reading a reset link signs out whoever
|
|
164
|
+
was signed in on that browser, and `updateUser` acts on the link's
|
|
165
|
+
account: it uses the token only while nobody is signed in, for 15 minutes
|
|
166
|
+
after the link was opened, and `signIn`, `signUp`, `signInWithOAuth` and
|
|
167
|
+
`signOut` forget it — a link left open on a shared computer is not the
|
|
168
|
+
next person's to use. A reset ends every session of
|
|
169
|
+
the account and unlinks the Google or GitHub sign-ins made before it
|
|
170
|
+
(sign in with the provider again to link it back); sign in with the new
|
|
171
|
+
password afterwards.
|
|
172
|
+
- `resetPasswordForEmail` and `resendVerification` answer the same whether or
|
|
173
|
+
not the address has an account.
|
|
174
|
+
- With email verification required, `signUp` answers the user without a
|
|
175
|
+
session, and `signIn` before confirming answers `403 EMAIL_NOT_VERIFIED`.
|
|
176
|
+
- Signing in with a provider needs `crypto.subtle` (https, or localhost);
|
|
177
|
+
without it `CRYPTO_UNAVAILABLE`.
|
|
178
|
+
- The code exchange a page runs on load gives up after 15 seconds: the
|
|
179
|
+
request is called off, `getSessionFromUrl()` and `auth.ready()` answer
|
|
180
|
+
`TIMEOUT` ("The Naro backend did not answer within 15 seconds. Check the
|
|
181
|
+
network and try again."), and queries go out as anon — sign in again.
|
|
182
|
+
|
|
183
|
+
## Types
|
|
184
|
+
|
|
185
|
+
Generate the types from the project's schema, and hand them to
|
|
186
|
+
`createClient`:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
naro gen types --output src/database.types.ts
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { createClient } from "naro-js";
|
|
194
|
+
import type { Database, Tables } from "./database.types";
|
|
195
|
+
|
|
196
|
+
const naro = createClient<Database>({
|
|
197
|
+
url: "https://my-app.naro.sh",
|
|
198
|
+
anonKey: "naro_pk_…",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// { id: number; title: string }[] | null
|
|
202
|
+
const { data } = await naro.from("posts").select("id, title");
|
|
203
|
+
|
|
204
|
+
await naro.from("posts").insert({ title: "Hello", user_id: user.id });
|
|
205
|
+
const post: Tables<"posts"> | null = (
|
|
206
|
+
await naro.from("posts").select().eq("id", 1).maybeSingle()
|
|
207
|
+
).data;
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- `from()` takes the tables of `Database`. `select("id, title")` returns
|
|
211
|
+
those columns, `select()` or `select("*")` the whole row, and `.single()`
|
|
212
|
+
/ `.maybeSingle()` follow. A column the table lacks is a type error where
|
|
213
|
+
you write it: `Argument of type '"id, nope"' is not assignable to
|
|
214
|
+
parameter of type '"Unknown column 'nope'"'`. A list the types cannot read
|
|
215
|
+
— a quoted name, or a `string` variable — returns the whole row.
|
|
216
|
+
- `insert()` takes the table's `Insert`: a column is optional when it has a
|
|
217
|
+
default, allows NULL, is the `INTEGER PRIMARY KEY`, or is the owner column
|
|
218
|
+
of a table whose insert rule is `owner` — the backend fills it, and
|
|
219
|
+
`naro gen types` reads the policies to know (run it again after
|
|
220
|
+
`naro policy set`). Generated columns are left out. `update()` takes
|
|
221
|
+
`Update`, every column optional.
|
|
222
|
+
- **Arrays and objects go straight into a TEXT column.**
|
|
223
|
+
`insert({ tags: ["a", "b"] })` stores their JSON text — exactly what
|
|
224
|
+
`JSON.stringify` gives — which is what `.contains()` reads. It comes back
|
|
225
|
+
as that string: `JSON.parse(row.tags)`. A value typed with an `interface`
|
|
226
|
+
has no index signature and does not fit `Json`; spread it
|
|
227
|
+
(`{ ...value }`) or declare it with `type`.
|
|
228
|
+
- Filters, `order()` and `.match()` take the table's column names; their
|
|
229
|
+
values stay loose (a column with a type converts `"10"` to 10, as SQLite
|
|
230
|
+
does). Filter strings like `or("status.eq.draft")` are not checked.
|
|
231
|
+
- What a column holds, from its declared type: INTEGER, REAL → `number`;
|
|
232
|
+
TEXT → `string` (and writes take `Json` too, above); BOOLEAN → `number`
|
|
233
|
+
(0 or 1; insert `true` or `false`); NUMERIC, DATE, DATETIME →
|
|
234
|
+
`number | string` (`CURRENT_TIMESTAMP` is text); BLOB → `number[]`, and
|
|
235
|
+
never writable through the Data API; no type, or a STRICT table's `ANY` →
|
|
236
|
+
`number | string | number[]`. NULL allowed → `| null`.
|
|
237
|
+
- Views are in `Database["views"]` for `Tables<"view">`, but `from()` does
|
|
238
|
+
not take them: the Data API reads no view (`404 TABLE_NOT_FOUND`).
|
|
239
|
+
- Without a type argument everything stays untyped: any table, any column,
|
|
240
|
+
rows of `Record<string, unknown>`.
|
|
241
|
+
|
|
242
|
+
## Filters
|
|
243
|
+
|
|
244
|
+
Chained filters are ANDed. For anything else, group them:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// or / and / not, with a callback…
|
|
248
|
+
await naro
|
|
249
|
+
.from("posts")
|
|
250
|
+
.select()
|
|
251
|
+
.or((q) => q.eq("status", "draft").lt("price", 10))
|
|
252
|
+
.not("deleted_at", "is", null);
|
|
253
|
+
|
|
254
|
+
// …or with Supabase's filter string, pasted as it is.
|
|
255
|
+
await naro
|
|
256
|
+
.from("posts")
|
|
257
|
+
.select()
|
|
258
|
+
.or("status.eq.draft,and(price.lt.10,tags.cs.{sale})");
|
|
259
|
+
|
|
260
|
+
// A TEXT column holding a JSON array, like `["a","b"]`.
|
|
261
|
+
await naro.from("posts").select().contains("tags", ["a", "b"]);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `.or()` and `.and()` take a callback or a string; `.not()` takes a
|
|
265
|
+
callback (its conditions ANDed) or `column, operator, value`.
|
|
266
|
+
`.filter(column, operator, value)` takes any operator by name
|
|
267
|
+
(`"not.gte"` too), and `.match({ a: 1, b: "x" })` is an `.eq()` per key.
|
|
268
|
+
- `.contains(column, values)`: the array has every value.
|
|
269
|
+
`.containedBy(column, values)`: every element is one of the values (`[]`
|
|
270
|
+
is). `.overlaps(column, values)`: at least one in common. Values are
|
|
271
|
+
strings and numbers, 1 to 100 of them, and one request holds at most 4 of
|
|
272
|
+
these three conditions. A row that is not a JSON array — other text, a
|
|
273
|
+
JSON object, a number — is no match, never an error. A value longer than
|
|
274
|
+
65,536 characters is not looked into: like NULL, it matches neither the
|
|
275
|
+
condition nor its `not`. The column must hold JSON text; a JSONB value is
|
|
276
|
+
no match. JSON `true` and `false` inside an array compare as 1 and 0, and
|
|
277
|
+
an element that is itself an array or object compares as its JSON text.
|
|
278
|
+
- **NULL is neither.** A condition on a NULL column is unknown, and so is its
|
|
279
|
+
`not`: `.not("status", "eq", "draft")` does not return rows whose status
|
|
280
|
+
is NULL — as in SQL and in Supabase. Add
|
|
281
|
+
`.or("status.neq.draft,status.is.null")` to include them.
|
|
282
|
+
- **String values are text.** In a filter string, and in a string value of
|
|
283
|
+
`.filter()` / `.not()`, `id.eq.10` sends `"10"`. A column declared with a
|
|
284
|
+
type (INTEGER, REAL…) converts it back to a number when comparing. A column
|
|
285
|
+
without a type does not: there `"10"` never equals `10`, and because
|
|
286
|
+
SQLite orders every number before any text, `.or("n.lt.10")` matches every
|
|
287
|
+
number in `n` — 5, 50 and 500 alike — and `.or("n.gt.100")` matches none.
|
|
288
|
+
Declare the column's type, or use the callback form or `.lt("n", 10)`,
|
|
289
|
+
whose values keep their type. The exceptions, for unquoted values only:
|
|
290
|
+
- `is.null`, `is.true` and `is.false` are those values.
|
|
291
|
+
- `true` and `false` after `eq` or `neq`, and in an `in.(…)` list, are
|
|
292
|
+
booleans — `done.eq.true` and `.filter("done", "eq", "true")` send
|
|
293
|
+
`true`, which the backend compares as 1, so it matches a BOOLEAN or
|
|
294
|
+
INTEGER column holding 1, and `archived.neq.true` matches only the 0
|
|
295
|
+
rows. Quoted, `done.eq."true"` compares with the text `true`. Other
|
|
296
|
+
items of an `in.(1,2)` list stay text.
|
|
297
|
+
- In a `cs` / `cd` / `ov` list, a plain decimal that goes out exactly as
|
|
298
|
+
written is a number: `tags.cs.{1,2.5}` sends `[1, 2.5]`. Integers must
|
|
299
|
+
be within ±9,007,199,254,740,991, and there is no exponent or `+` —
|
|
300
|
+
`{12345678901234567890}` and `{1e5}` stay text, as does `{"1"}`. Store
|
|
301
|
+
ids larger than that as strings in the array.
|
|
302
|
+
- In a filter string, quote a value that holds `,` `.` `(` `)`, or leading
|
|
303
|
+
or trailing spaces: `title.eq."a, b"`, with `\` before a `"` or `\` inside.
|
|
304
|
+
`quoteFilterValue(text)` does exactly this. A string the SDK cannot read —
|
|
305
|
+
an unknown operator, unbalanced parentheses, an empty item — is answered
|
|
306
|
+
with `INVALID_QUERY` without being sent.
|
|
307
|
+
- **`*` is `%` in every `like` and `ilike`** — `.ilike("name", "*kim*")`,
|
|
308
|
+
`name.ilike.*kim*` and `.filter("name", "ilike", "*kim*")` alike — as in
|
|
309
|
+
PostgREST, so code ported from supabase-js keeps matching. The price, the
|
|
310
|
+
same as in PostgREST: a literal `*` cannot be matched.
|
|
311
|
+
- **Filtering on user input?** Prefer the methods that take a value —
|
|
312
|
+
`.eq()`, `.ilike()`, the callback form — or `.filter()`, whose value
|
|
313
|
+
cannot add a condition. If you must build a filter string, pass every
|
|
314
|
+
value through `quoteFilterValue()`:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { quoteFilterValue } from "naro-js";
|
|
318
|
+
|
|
319
|
+
await naro
|
|
320
|
+
.from("posts")
|
|
321
|
+
.select()
|
|
322
|
+
.or(`title.eq.${quoteFilterValue(q)},body.eq.${quoteFilterValue(q)}`);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Pasted in unquoted (or quoted by escaping only `"`), input like
|
|
326
|
+
`x\",id.gte.0,title.eq.\"y` adds its own conditions. Filters only narrow
|
|
327
|
+
within what the project's policies allow — but inside that boundary an
|
|
328
|
+
injected condition can widen a query all the way to it: a `.delete().or(…)`
|
|
329
|
+
built that way can delete every row the caller is allowed to delete.
|
|
330
|
+
|
|
331
|
+
- Limits: groups nest 4 deep (`not` counts); one request holds at most 100
|
|
332
|
+
conditions, 4 of them `contains` / `containedBy` / `overlaps`; one
|
|
333
|
+
statement binds at most 100 values (a select's limit and offset are two of
|
|
334
|
+
them) and compiles to at most 100,000 bytes of SQL.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type NaroError } from "../errors.js";
|
|
2
|
+
import { type HttpConfig } from "../http.js";
|
|
3
|
+
import type { AuthActionResponse, EmailRedirectOptions, OAuthResponse, SessionResponse, SignInCredentials, SignInWithOAuthOptions, SignOutResponse, SignUpCredentials, UrlSessionResponse, UserAttributes, UserResponse } from "../types.js";
|
|
4
|
+
import type { FlowVerifiers } from "./flows.js";
|
|
5
|
+
import type { RecoveryToken } from "./recovery.js";
|
|
6
|
+
/** Better Auth requires `name` on sign-up; the email's local part stands in. */
|
|
7
|
+
export declare function defaultName(email: string): string;
|
|
8
|
+
export type AuthFlowStores = {
|
|
9
|
+
/** The PKCE verifiers of the provider sign-ins this browser began. */
|
|
10
|
+
verifiers: FlowVerifiers;
|
|
11
|
+
/** The password reset token a recovery link brought (15 minutes). */
|
|
12
|
+
recovery: RecoveryToken;
|
|
13
|
+
/**
|
|
14
|
+
* Read a sign-in's answer from the page URL when the client is created
|
|
15
|
+
* (`getSessionFromUrl`). Default true in a browser.
|
|
16
|
+
*/
|
|
17
|
+
detectSessionInUrl: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Where the session is kept: the storage and its key. Clients on one page
|
|
20
|
+
* that keep it in the same place share the URL sign-in one of them began
|
|
21
|
+
* (`inFlight`).
|
|
22
|
+
*/
|
|
23
|
+
shared: {
|
|
24
|
+
storage: object;
|
|
25
|
+
key: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** How long the code exchange a sign-in redirect starts may take. */
|
|
29
|
+
export declare const EXCHANGE_TIMEOUT_MS = 15000;
|
|
30
|
+
/**
|
|
31
|
+
* `naro.auth` — email and password, email confirmation, password reset and
|
|
32
|
+
* provider sign-in through the worker's Better Auth routes
|
|
33
|
+
* (`/_naro/v1/auth/*`, spec §8.1, §13; P2-6 §13.3). Every method resolves;
|
|
34
|
+
* none throws.
|
|
35
|
+
*/
|
|
36
|
+
export declare class AuthClient {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(http: HttpConfig, flow: AuthFlowStores);
|
|
39
|
+
/**
|
|
40
|
+
* Creates the account and signs in; the token is stored for later calls.
|
|
41
|
+
* When the project requires a confirmed email there is no session yet —
|
|
42
|
+
* `user` comes back, and after the confirmation link the user signs in.
|
|
43
|
+
*/
|
|
44
|
+
signUp(credentials: SignUpCredentials): Promise<UserResponse>;
|
|
45
|
+
signIn(credentials: SignInCredentials): Promise<UserResponse>;
|
|
46
|
+
/**
|
|
47
|
+
* Starts a Google or GitHub sign-in: sends the browser, with a top-level
|
|
48
|
+
* navigation, to the worker's `GET /_naro/v1/auth/authorize/<provider>`,
|
|
49
|
+
* which checks `redirectTo`, binds the flow to this browser with a cookie
|
|
50
|
+
* and goes on to the provider (adversarial review I1). The provider sends
|
|
51
|
+
* the browser back to `redirectTo`, where `getSessionFromUrl()` (run by
|
|
52
|
+
* `createClient` on its own) finishes the sign-in. No request is made
|
|
53
|
+
* here: anything that goes wrong comes back in that page's URL.
|
|
54
|
+
*/
|
|
55
|
+
signInWithOAuth(options: SignInWithOAuthOptions): Promise<OAuthResponse>;
|
|
56
|
+
/**
|
|
57
|
+
* Trades the one-time code a sign-in redirect brought for the session,
|
|
58
|
+
* with the verifier this browser kept when it began that flow — `flow` is
|
|
59
|
+
* the redirect's `naro_flow`; without it, the newest flow's. Most apps
|
|
60
|
+
* never call it: `getSessionFromUrl()` does.
|
|
61
|
+
*/
|
|
62
|
+
exchangeCodeForSession(code: string, flow?: string): Promise<SessionResponse>;
|
|
63
|
+
/**
|
|
64
|
+
* Reads what a sign-in redirect left in this page's URL fragment, then
|
|
65
|
+
* clears it: a provider sign-in or a confirmation link hands over a
|
|
66
|
+
* session (traded here), a reset link a recovery token (kept for
|
|
67
|
+
* `updateUser`), and a failure its `code` and `message`. With nothing of
|
|
68
|
+
* Naro's in the URL, `type` is null — or, after `createClient` already
|
|
69
|
+
* read it, the answer it got.
|
|
70
|
+
*/
|
|
71
|
+
getSessionFromUrl(): Promise<UrlSessionResponse>;
|
|
72
|
+
/**
|
|
73
|
+
* Emails a password reset link, when the address has an account. The
|
|
74
|
+
* answer is the same either way — it does not say whether it had one.
|
|
75
|
+
*/
|
|
76
|
+
resetPasswordForEmail(email: string, options?: EmailRedirectOptions): Promise<AuthActionResponse>;
|
|
77
|
+
/**
|
|
78
|
+
* Sets a new password in the recovery flow: on the page the reset link
|
|
79
|
+
* opened, after `getSessionFromUrl()` read its token. Signing in with the
|
|
80
|
+
* new password comes after — a reset ends every session of the account.
|
|
81
|
+
* (Changing the password of a signed-in user is not offered: spec §14
|
|
82
|
+
* "(P1-2 리뷰 반영)".)
|
|
83
|
+
*
|
|
84
|
+
* Like supabase-js, it acts on the recovery link's account, which took the
|
|
85
|
+
* place of any signed-in session when the link was read — so the token is
|
|
86
|
+
* used only while no session is signed in on this browser, for 15 minutes
|
|
87
|
+
* after the link was opened, and never after a sign-in, sign-up or
|
|
88
|
+
* sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
|
|
89
|
+
* with no request.
|
|
90
|
+
*/
|
|
91
|
+
updateUser(attributes: UserAttributes): Promise<AuthActionResponse>;
|
|
92
|
+
/**
|
|
93
|
+
* Emails the confirmation link again, when the address has an account that
|
|
94
|
+
* is not confirmed yet. The answer does not say which.
|
|
95
|
+
*/
|
|
96
|
+
resendVerification(email: string, options?: EmailRedirectOptions): Promise<AuthActionResponse>;
|
|
97
|
+
/**
|
|
98
|
+
* Ends the session on the worker and forgets the token. The token is
|
|
99
|
+
* forgotten even when the worker cannot be reached — the error still comes
|
|
100
|
+
* back, since the session then lives on until it expires.
|
|
101
|
+
*/
|
|
102
|
+
signOut(): Promise<SignOutResponse>;
|
|
103
|
+
/**
|
|
104
|
+
* The signed-in session and user. Signed out: both null, no request. A
|
|
105
|
+
* stored token the worker no longer accepts is forgotten and reported as
|
|
106
|
+
* INVALID_SESSION once (spec §11.1: a session never quietly turns anon).
|
|
107
|
+
* Waits for a sign-in the page URL carried, if `createClient` found one.
|
|
108
|
+
*/
|
|
109
|
+
getSession(): Promise<SessionResponse>;
|
|
110
|
+
/** `{ data: { user } }`; `user` is null when signed out. */
|
|
111
|
+
getUser(): Promise<UserResponse>;
|
|
112
|
+
/**
|
|
113
|
+
* Resolves once a sign-in the page URL carried has been traded for its
|
|
114
|
+
* session (or failed), with its error. Queries wait for it, so the first
|
|
115
|
+
* one after a provider's redirect goes out signed in rather than as anon.
|
|
116
|
+
* The exchange gives up after 15 seconds (TIMEOUT, PR #68 review 10):
|
|
117
|
+
* then the queries go out as anon rather than wait forever.
|
|
118
|
+
*/
|
|
119
|
+
ready(): Promise<{
|
|
120
|
+
error: NaroError | null;
|
|
121
|
+
}>;
|
|
122
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TokenStore } from "./storage.js";
|
|
2
|
+
/** A flow's life: the worker's flow row and Better Auth's state. */
|
|
3
|
+
export declare const FLOW_TTL_MS: number;
|
|
4
|
+
/** More in flight than anyone clicks; the oldest go first. */
|
|
5
|
+
export declare const MAX_FLOWS = 10;
|
|
6
|
+
export declare class FlowVerifiers {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(store: TokenStore, now?: () => number);
|
|
9
|
+
/** Keeps `verifier` for the flow `flow`, for ten minutes. */
|
|
10
|
+
add(flow: string, verifier: string): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* The verifier of `flow`, taken — one flow, one use. Without a flow id
|
|
13
|
+
* (a code traded by hand), the newest one. Null when there is none, or it
|
|
14
|
+
* expired.
|
|
15
|
+
*/
|
|
16
|
+
take(flow: string | undefined): Promise<string | null>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type PkcePair = {
|
|
2
|
+
verifier: string;
|
|
3
|
+
challenge: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* A fresh verifier (32 random bytes, 43 base64url characters, RFC 7636 §4.1)
|
|
7
|
+
* and its S256 challenge; null where the runtime has no `crypto.subtle` — an
|
|
8
|
+
* http page that is not localhost, for one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createPkcePair(): Promise<PkcePair | null>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TokenStore } from "./storage.js";
|
|
2
|
+
/** How long the token is kept after the link was opened. */
|
|
3
|
+
export declare const RECOVERY_TTL_MS: number;
|
|
4
|
+
export declare class RecoveryToken {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(store: TokenStore, now?: () => number);
|
|
7
|
+
/** Keeps the token a recovery link brought, for 15 minutes. */
|
|
8
|
+
keep(token: string): Promise<void>;
|
|
9
|
+
/** The token, while it lasts; an expired or unreadable one is cleared. */
|
|
10
|
+
current(): Promise<string | null>;
|
|
11
|
+
/** Forgets it: a sign-in, a sign-up, a sign-out. */
|
|
12
|
+
forget(): Promise<void>;
|
|
13
|
+
/** Forgets it if it is still `token` — used, or refused as used. */
|
|
14
|
+
forgetIf(token: string): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the session token lives (spec §13). `localStorage` fits as it is; so
|
|
3
|
+
* does any async store (a cookie jar on the server, React Native's
|
|
4
|
+
* AsyncStorage) — `createClient({ auth: { storage } })`.
|
|
5
|
+
*/
|
|
6
|
+
export type AuthStorage = {
|
|
7
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
8
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
9
|
+
removeItem(key: string): void | Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function memoryStorage(): AuthStorage;
|
|
12
|
+
/**
|
|
13
|
+
* The browser's `localStorage` when it works, memory otherwise. Merely
|
|
14
|
+
* reading `localStorage` throws in a sandboxed iframe or with storage
|
|
15
|
+
* blocked, and Safari's private mode used to throw on the first write, so
|
|
16
|
+
* the probe writes too.
|
|
17
|
+
*/
|
|
18
|
+
export declare function defaultStorage(): AuthStorage;
|
|
19
|
+
/** One token per project URL, so two clients on one page do not collide. */
|
|
20
|
+
export declare function storageKey(baseUrl: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* The PKCE verifiers of the provider sign-ins this browser began, one per
|
|
23
|
+
* flow (P2-6 §6, `flows.ts`), kept where the token is.
|
|
24
|
+
*/
|
|
25
|
+
export declare function verifierKey(baseUrl: string): string;
|
|
26
|
+
/** The password reset token a recovery link brought, until it is used. */
|
|
27
|
+
export declare function recoveryKey(baseUrl: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* The client's view of its token. Nothing here throws: the storage is the
|
|
30
|
+
* app's code, and a query must still answer `{ data, error }` when it fails.
|
|
31
|
+
* The first time the storage throws or rejects, the store stops using it and
|
|
32
|
+
* keeps the token in memory for the rest of the client's life — a store that
|
|
33
|
+
* failed once would otherwise hand back tokens it no longer has.
|
|
34
|
+
*
|
|
35
|
+
* Operations run one at a time, in call order, so a compare-and-clear cannot
|
|
36
|
+
* interleave with a sign-in on an async storage (see `#serial`). That holds
|
|
37
|
+
* per client; two clients on one storage key, or two tabs, can still race.
|
|
38
|
+
*/
|
|
39
|
+
export declare class TokenStore {
|
|
40
|
+
#private;
|
|
41
|
+
constructor(storage: AuthStorage, key: string);
|
|
42
|
+
get(): Promise<string | null>;
|
|
43
|
+
set(token: string): Promise<void>;
|
|
44
|
+
clear(): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Clears only if the stored token is still the one that was rejected. A
|
|
47
|
+
* request that left with an old token can come back after the user signed
|
|
48
|
+
* in again; its INVALID_SESSION must not sign the new session out. The
|
|
49
|
+
* read and the removal are one operation: with an async storage, a sign-in
|
|
50
|
+
* that lands between them would otherwise be the token removed.
|
|
51
|
+
*/
|
|
52
|
+
clearIf(token: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Reads the value, hands it to `transform` and stores what that returns
|
|
55
|
+
* (null clears it), as one operation — the per-flow verifiers
|
|
56
|
+
* (`flows.ts`) change one entry of a JSON object this way.
|
|
57
|
+
*/
|
|
58
|
+
update(transform: (current: string | null) => string | null): Promise<void>;
|
|
59
|
+
/** Stores `next` only if `expected` is still stored, as one operation. */
|
|
60
|
+
replaceIf(expected: string, next: string): Promise<void>;
|
|
61
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AuthClient } from "./auth/client.js";
|
|
2
|
+
import { type AuthStorage } from "./auth/storage.js";
|
|
3
|
+
import { type FetchLike } from "./http.js";
|
|
4
|
+
import { TableBuilder } from "./query/builder.js";
|
|
5
|
+
import type { DatabaseShape, InsertOf, RowOf, TableName, TablesOf, UntypedDatabase, UpdateOf } from "./types.js";
|
|
6
|
+
type CommonOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The project's Naro URL, e.g. `https://my-app.naro.sh`: an origin only,
|
|
9
|
+
* since the worker serves `/_naro/v1/…` at the root of its host.
|
|
10
|
+
*/
|
|
11
|
+
url: string;
|
|
12
|
+
auth?: {
|
|
13
|
+
/** Where the session token is kept. Default: localStorage in a browser, memory elsewhere. */
|
|
14
|
+
storage?: AuthStorage;
|
|
15
|
+
/**
|
|
16
|
+
* Finish a sign-in the page URL carries back — a provider's callback, a
|
|
17
|
+
* confirmation or reset link (P2-6) — when the client is created, and
|
|
18
|
+
* clear it from the address bar. Default true; off, call
|
|
19
|
+
* `naro.auth.getSessionFromUrl()` yourself.
|
|
20
|
+
*/
|
|
21
|
+
detectSessionInUrl?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** Replaces the global fetch (tests, custom runtimes). */
|
|
24
|
+
fetch?: FetchLike;
|
|
25
|
+
};
|
|
26
|
+
export type ClientOptions = CommonOptions & ({
|
|
27
|
+
anonKey: string;
|
|
28
|
+
serviceKey?: undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Server only: skips every policy (spec §7). Refused unless the runtime
|
|
32
|
+
* is one the SDK knows as a server (Node, Deno, Bun, Workers, edge).
|
|
33
|
+
*/
|
|
34
|
+
| {
|
|
35
|
+
serviceKey: string;
|
|
36
|
+
anonKey?: undefined;
|
|
37
|
+
});
|
|
38
|
+
/** User-facing; quoted in spec §15's "(P1-4 구현 결정)". */
|
|
39
|
+
export declare const CONFIG_MESSAGES: {
|
|
40
|
+
readonly url: "createClient needs the project url, like https://my-app.naro.sh.";
|
|
41
|
+
readonly 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.";
|
|
42
|
+
readonly key: "createClient needs an anonKey (or, on a server, a serviceKey).";
|
|
43
|
+
readonly bothKeys: "Pass createClient an anonKey or a serviceKey, not both.";
|
|
44
|
+
readonly serviceKeyOffServer: "A Naro service key (naro_sk_…) 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_…) here and keep the service key on your server.";
|
|
45
|
+
readonly fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).";
|
|
46
|
+
};
|
|
47
|
+
export declare class NaroClient<Database extends DatabaseShape<Database>> {
|
|
48
|
+
#private;
|
|
49
|
+
/** Sign-up, sign-in, sign-out and the current user (spec §13). */
|
|
50
|
+
readonly auth: AuthClient;
|
|
51
|
+
constructor(options: ClientOptions);
|
|
52
|
+
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
53
|
+
from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The one entry point (spec §15). Throws only for a configuration mistake —
|
|
57
|
+
* no url or not an origin, no key, or a service key off a server; every call
|
|
58
|
+
* on the client resolves to `{ data, error }` instead.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createClient<Database extends DatabaseShape<Database> = UntypedDatabase>(options: ClientOptions): NaroClient<Database>;
|
|
61
|
+
export {};
|