naro-js 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,12 +71,94 @@ await naro.from("posts").delete().eq("id", 10);
71
71
  `auth: { storage }` with your own `getItem` / `setItem` / `removeItem`.
72
72
  - Types: `createClient<Database>()` with the `Database` that
73
73
  `naro gen types` writes — see [Types](#types).
74
+ - Sign-in is naro Auth, the project's own (like Supabase Auth). Email and
75
+ password sign-up and sign-in need no setup: they work as soon as the
76
+ backend is on. Resend is needed only to send confirmation and password
77
+ reset emails.
78
+ - Admin accounts, closed sign-ups, sign-in by username and the password
79
+ length — see [Admin accounts and closed sign-ups](#admin-accounts-and-closed-sign-ups).
74
80
  - Email and social sign-in — see [Sign-in flows](#sign-in-flows).
75
81
 
82
+ ## Admin accounts and closed sign-ups
83
+
84
+ An app that only its admins sign in to: close sign-ups, then create the
85
+ accounts yourself. With sign-ups closed, `authenticated` in a policy means
86
+ exactly the users you created.
87
+
88
+ ```bash
89
+ naro backend auth signups off
90
+ echo "$ADMIN_PASSWORD" | naro backend users create --username admin --password-stdin
91
+ ```
92
+
93
+ ```ts
94
+ // In the app: sign in by username, or by email.
95
+ await naro.auth.signIn({ username: "admin", password });
96
+ await naro.auth.signIn({ email, password });
97
+ ```
98
+
99
+ The same from your own server, with supabase-js's `auth.admin` names:
100
+
101
+ ```ts
102
+ // Server only: the service key skips every policy.
103
+ const admin = createClient({
104
+ url: "https://my-app.naro.sh",
105
+ serviceKey: process.env.NARO_SERVICE_KEY,
106
+ });
107
+
108
+ const { data, error } = await admin.auth.admin.createUser({
109
+ username: "admin", // and/or email
110
+ password,
111
+ // emailConfirm: false — only with an email; users are confirmed otherwise
112
+ });
113
+ const {
114
+ data: { users, total },
115
+ } = await admin.auth.admin.listUsers({ page: 1, perPage: 50 });
116
+ await admin.auth.admin.deleteUser(data.user.id);
117
+ ```
118
+
119
+ - `createUser` takes an email, a username or both. The user is confirmed
120
+ and signs in at once — while sign-ups are closed, and while sign-in waits
121
+ for a confirmed email — unless `emailConfirm: false` (which needs an
122
+ email). A user with only a username answers `email: null`, here, in
123
+ `signIn` and in `getUser()`, and signs in with its username. Answers
124
+ carry `id`, `email`, `username`, `emailVerified` and `createdAt`, never a
125
+ password.
126
+ - `listUsers` is newest first, 50 a page unless `perPage` says (at most
127
+ 1000), with the `total`. `deleteUser(id)` removes the user and its
128
+ sign-in methods and ends its sessions; an unknown id is
129
+ `404 USER_NOT_FOUND` ("No user has this id.").
130
+ - `auth.admin` works only on a client made with `serviceKey`. On an anon
131
+ client each call answers `403 PERMISSION_DENIED` ("Only the service key
132
+ can manage users.") without a request.
133
+ - A username is 3–30 letters, digits, `_` and `.`, and is matched in any
134
+ case (stored lowercased). `signUp({ email, password, username })` sets one
135
+ at sign-up.
136
+ - Closed sign-ups: every `signUp` answers `403 SIGNUPS_DISABLED` ("Sign-ups
137
+ are closed for this project."), and so does a first Google or GitHub
138
+ sign-in, in `getSessionFromUrl()`. Users that exist keep signing in, with
139
+ a password or a provider. `naro backend auth signups on` opens them again.
140
+ - While sign-ups are open, a sign-up with a username someone has answers
141
+ `409 USERNAME_TAKEN` ("That username is already taken.") — so anyone can
142
+ learn which usernames exist, as fast as sign-up's per-address rate limit
143
+ lets them. With sign-ups closed, every sign-up answers
144
+ `403 SIGNUPS_DISABLED` first. `createUser` answers `USERNAME_TAKEN` too,
145
+ and `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` for an email in use.
146
+ - Passwords take at least 6 characters unless
147
+ `naro backend auth password --min-length <n>` (6–128) says more. A
148
+ shorter one, on sign-up, `createUser` or a reset, answers
149
+ `400 PASSWORD_TOO_SHORT` naming it: "Use at least 10 characters." Sign-in
150
+ never checks the length, so raising it locks nobody out.
151
+ - Creating and deleting users are writes: past the project's daily write
152
+ limit they answer `429 WRITE_QUOTA_EXCEEDED`, like sign-up.
153
+ - A backend turned on before these existed gets them with its next upload:
154
+ run `naro backend enable` once. Until then `auth.admin` and
155
+ `signIn({ username })` answer `404 NOT_FOUND`.
156
+
76
157
  ## Sign-in flows
77
158
 
78
159
  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.
160
+ project has set them up (`naro backend auth`). Email and password sign-up
161
+ and sign-in need none of it. The names follow supabase-js.
80
162
 
81
163
  ```ts
82
164
  // Sends the browser to the project's /_naro/v1/auth/authorize/github, then
@@ -0,0 +1,23 @@
1
+ import { type HttpConfig } from "../http.js";
2
+ import type { AdminUserAttributes, AdminUserListResponse, AdminUserResponse, PageParams } from "../types.js";
3
+ export declare class AuthAdminApi {
4
+ #private;
5
+ constructor(http: HttpConfig, serviceKey: boolean);
6
+ /**
7
+ * Creates a user with a password: an email, a username or both. It is
8
+ * made while sign-ups are closed too, and is confirmed unless
9
+ * `emailConfirm: false`. A username-only user answers `email: null` and
10
+ * signs in with `signIn({ username, password })`. Refusals come as the
11
+ * worker says them: USERNAME_TAKEN (409),
12
+ * USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (422), PASSWORD_TOO_SHORT ("Use at
13
+ * least {n} characters."), VALIDATION_ERROR, WRITE_QUOTA_EXCEEDED (429).
14
+ */
15
+ createUser(attributes: AdminUserAttributes): Promise<AdminUserResponse>;
16
+ /** The project's users, newest first: page 1 and 50 a page unless given. */
17
+ listUsers(params?: PageParams): Promise<AdminUserListResponse>;
18
+ /**
19
+ * Deletes a user and its sign-in methods, and ends its sessions. Answers
20
+ * the user it was; USER_NOT_FOUND (404) for an id that is no user.
21
+ */
22
+ deleteUser(id: string): Promise<AdminUserResponse>;
23
+ }
@@ -1,9 +1,10 @@
1
1
  import { type NaroError } from "../errors.js";
2
2
  import { type HttpConfig } from "../http.js";
3
3
  import type { AuthActionResponse, EmailRedirectOptions, OAuthResponse, SessionResponse, SignInCredentials, SignInWithOAuthOptions, SignOutResponse, SignUpCredentials, UrlSessionResponse, UserAttributes, UserResponse } from "../types.js";
4
+ import { AuthAdminApi } from "./admin.js";
4
5
  import type { FlowVerifiers } from "./flows.js";
5
6
  import type { RecoveryToken } from "./recovery.js";
6
- /** Better Auth requires `name` on sign-up; the email's local part stands in. */
7
+ /** Sign-up needs a `name`; the email's local part stands in. */
7
8
  export declare function defaultName(email: string): string;
8
9
  export type AuthFlowStores = {
9
10
  /** The PKCE verifiers of the provider sign-ins this browser began. */
@@ -24,24 +25,42 @@ export type AuthFlowStores = {
24
25
  storage: object;
25
26
  key: string;
26
27
  };
28
+ /** Made with the service key: `admin` sends its requests. */
29
+ serviceKey: boolean;
27
30
  };
28
31
  /** How long the code exchange a sign-in redirect starts may take. */
29
32
  export declare const EXCHANGE_TIMEOUT_MS = 15000;
30
33
  /**
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.
34
+ * `naro.auth` — naro Auth, the project's own sign-in (like Supabase Auth):
35
+ * email or username and password, email confirmation, password reset and
36
+ * provider sign-in through the worker's `/_naro/v1/auth/*` routes (spec
37
+ * §8.1, §13; P2-6 §13.3; 2026-09-26 §2.3). Email and password work with no
38
+ * setup; email is needed only to send confirmation and reset links. Every
39
+ * method resolves; none throws.
35
40
  */
36
41
  export declare class AuthClient {
37
42
  #private;
43
+ /**
44
+ * The service key's user management — createUser, listUsers, deleteUser,
45
+ * as in supabase-js (spec 2026-09-26 §2.2). Only on a client made with
46
+ * `serviceKey`, on a server.
47
+ */
48
+ readonly admin: AuthAdminApi;
38
49
  constructor(http: HttpConfig, flow: AuthFlowStores);
39
50
  /**
40
51
  * Creates the account and signs in; the token is stored for later calls.
41
52
  * When the project requires a confirmed email there is no session yet —
42
53
  * `user` comes back, and after the confirmation link the user signs in.
54
+ * While the project's sign-ups are closed every sign-up answers 403
55
+ * SIGNUPS_DISABLED; while they are open, a taken `username` answers 409
56
+ * USERNAME_TAKEN.
43
57
  */
44
58
  signUp(credentials: SignUpCredentials): Promise<UserResponse>;
59
+ /**
60
+ * Signs in with an email and password, or a username and password (spec
61
+ * 2026-09-26 §2.3); the token is stored for later calls. A wrong password
62
+ * answers 401 INVALID_EMAIL_OR_PASSWORD, or INVALID_USERNAME_OR_PASSWORD.
63
+ */
45
64
  signIn(credentials: SignInCredentials): Promise<UserResponse>;
46
65
  /**
47
66
  * Starts a Google or GitHub sign-in: sends the browser, with a top-level
@@ -1,5 +1,5 @@
1
1
  import type { TokenStore } from "./storage.js";
2
- /** A flow's life: the worker's flow row and Better Auth's state. */
2
+ /** A flow's life: the worker's flow row and its sign-in state. */
3
3
  export declare const FLOW_TTL_MS: number;
4
4
  /** More in flight than anyone clicks; the oldest go first. */
5
5
  export declare const MAX_FLOWS = 10;
package/dist/client.d.ts CHANGED
@@ -46,7 +46,10 @@ export declare const CONFIG_MESSAGES: {
46
46
  };
47
47
  export declare class NaroClient<Database extends DatabaseShape<Database>> {
48
48
  #private;
49
- /** Sign-up, sign-in, sign-out and the current user (spec §13). */
49
+ /**
50
+ * Sign-up, sign-in, sign-out and the current user (spec §13); with the
51
+ * service key, `auth.admin` manages users.
52
+ */
50
53
  readonly auth: AuthClient;
51
54
  constructor(options: ClientOptions);
52
55
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
package/dist/errors.d.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import type { AuthErrorCode } from "./types.js";
1
2
  /**
2
3
  * Every failure the SDK hands back (spec §15). The Naro worker's errors
3
4
  * (spec §14) arrive as they are; the SDK makes its own only for what never
4
5
  * got a readable answer from the worker, and for queries it can tell are
5
6
  * malformed before sending them. The messages are a contract — spec §15's
6
7
  * "(P1-4 구현 결정)" block quotes them.
8
+ *
9
+ * `code` can be any string. The sign-in and user codes an app branches on
10
+ * (`AuthErrorCode`) are spelled out, so an editor completes them.
7
11
  */
8
12
  export type NaroError = {
9
- code: string;
13
+ code: AuthErrorCode | (string & {});
10
14
  message: string;
11
15
  details?: unknown;
12
16
  };
@@ -37,15 +41,21 @@ export declare function codeVerifierMissing(): NaroError;
37
41
  export declare function passwordRecoveryRequired(): NaroError;
38
42
  /** `signInWithOAuth` with a provider the worker does not know. */
39
43
  export declare function invalidProvider(): NaroError;
44
+ /**
45
+ * `naro.auth.admin` on a client made with the anon key (spec 2026-09-26
46
+ * admin users §2.2): the worker's own refusal, word for word, without the
47
+ * request — the answer would be the same.
48
+ */
49
+ export declare function serviceKeyRequired(): NaroError;
40
50
  /** No `crypto.subtle`: an http page that is not localhost. */
41
51
  export declare function cryptoUnavailable(): NaroError;
42
52
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
43
53
  /**
44
54
  * The error a response carries, or null when it carries none. Every error
45
55
  * under `/_naro/v1` comes as `{ data: null, error: { code, message,
46
- * details? } }` — the worker wraps Better Auth's too (spec §14, P1-2 리뷰
47
- * 반영). Better Auth's success bodies are not wrapped, so a 2xx without an
48
- * `error` is not an error; a failing status without the envelope is not a
49
- * Naro answer at all (a proxy's HTML page, the app's own 404).
56
+ * details? } }` — the sign-in routes' too (spec §14, P1-2 리뷰 반영). Their
57
+ * success bodies are not wrapped, so a 2xx without an `error` is not an
58
+ * error; a failing status without the envelope is not a Naro answer at all
59
+ * (a proxy's HTML page, the app's own 404).
50
60
  */
51
61
  export declare function errorFromBody(status: number, body: unknown): NaroError | null;
package/dist/http.d.ts CHANGED
@@ -15,7 +15,7 @@ export type HttpConfig = {
15
15
  tokens: TokenStore;
16
16
  };
17
17
  export type CallOptions = {
18
- method: "GET" | "POST";
18
+ method: "GET" | "POST" | "DELETE";
19
19
  /** Below `/_naro/v1`, e.g. `/data/query`. */
20
20
  path: string;
21
21
  /** Sent as JSON. */
@@ -54,8 +54,9 @@ export type CallResult = {
54
54
  */
55
55
  export declare function call(config: HttpConfig, options: CallOptions): Promise<CallResult>;
56
56
  /**
57
- * The worker's two 429s. `RATE_LIMITED` is Better Auth's limiter;
58
- * `WRITE_QUOTA_EXCEEDED` (PR C) is the project over its daily database write
59
- * limit — Data API writes and sign-up wait for 00:00 UTC, reads still work.
57
+ * The worker's two 429s. `RATE_LIMITED` is the sign-in routes' per-address
58
+ * limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the project over its daily
59
+ * database write limit — Data API writes, sign-up and creating or deleting
60
+ * users wait for 00:00 UTC, reads still work.
60
61
  */
61
62
  export declare const RETRY_AFTER_CODES: ReadonlySet<string>;
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { createClient, NaroClient, type ClientOptions } from "./client.js";
2
+ export type { AuthAdminApi } from "./auth/admin.js";
2
3
  export type { AuthClient } from "./auth/client.js";
3
4
  export type { AuthStorage } from "./auth/storage.js";
4
5
  export type { NaroError } from "./errors.js";
5
6
  export type { FetchLike } from "./http.js";
6
7
  export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
7
8
  export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
8
- export type { AuthActionResponse, AuthFlowType, AuthResponse, DatabaseShape, EmailRedirectOptions, OAuthProvider, OAuthResponse, SignInWithOAuthOptions, UrlSessionResponse, UserAttributes, GenericTable, GenericView, NaroResponse, OrderOptions, Session, SessionResponse, SignInCredentials, SignOutResponse, SignUpCredentials, UntypedDatabase, User, UserResponse, } from "./types.js";
9
+ export type { AdminUser, AdminUserAttributes, AdminUserListResponse, AdminUserResponse, AuthActionResponse, AuthErrorCode, AuthFlowType, AuthResponse, DatabaseShape, EmailRedirectOptions, OAuthProvider, OAuthResponse, SignInWithOAuthOptions, UrlSessionResponse, UserAttributes, GenericTable, GenericView, NaroResponse, OrderOptions, PageParams, Session, SessionResponse, SignInCredentials, SignOutResponse, SignUpCredentials, UntypedDatabase, User, UserResponse, } from "./types.js";
9
10
  export type { Filter, FilterNode, FilterOperator, Order, Query, Row, Value, } from "./wire.js";
package/dist/index.js CHANGED
@@ -49,6 +49,12 @@ function invalidProvider() {
49
49
  message: "provider must be google or github."
50
50
  };
51
51
  }
52
+ function serviceKeyRequired() {
53
+ return {
54
+ code: "PERMISSION_DENIED",
55
+ message: "Only the service key can manage users."
56
+ };
57
+ }
52
58
  function cryptoUnavailable() {
53
59
  return {
54
60
  code: "CRYPTO_UNAVAILABLE",
@@ -128,9 +134,10 @@ async function call(config, options) {
128
134
  const init = {
129
135
  method: options.method,
130
136
  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).
137
+ // Identity is the bearer token alone. The sign-in routes also set a
138
+ // session cookie (spec §13); a request that carried it would let any site
139
+ // act as the user. Workers accept the field (measured on workerd
140
+ // 1.20260815).
134
141
  credentials: "omit"
135
142
  };
136
143
  if (payload !== void 0) {
@@ -196,6 +203,90 @@ function isKnownServer(scope = globalThis) {
196
203
  return hasServerSignal(g) && !hasDeviceSignal(g);
197
204
  }
198
205
 
206
+ // src/auth/admin.ts
207
+ var USERS_PATH = "/auth/admin/users";
208
+ function dataOf(result) {
209
+ if (result.error !== null) return null;
210
+ const { body } = result.answer;
211
+ return isRecord(body) && isRecord(body.data) ? body.data : null;
212
+ }
213
+ function errorOf(result) {
214
+ if (result.error !== null) return result.error;
215
+ return invalidResponse(result.answer.status);
216
+ }
217
+ function userOf(value) {
218
+ return isRecord(value) && typeof value.id === "string" ? value : null;
219
+ }
220
+ var AuthAdminApi = class {
221
+ #http;
222
+ #serviceKey;
223
+ constructor(http, serviceKey) {
224
+ this.#http = http;
225
+ this.#serviceKey = serviceKey;
226
+ }
227
+ /**
228
+ * Creates a user with a password: an email, a username or both. It is
229
+ * made while sign-ups are closed too, and is confirmed unless
230
+ * `emailConfirm: false`. A username-only user answers `email: null` and
231
+ * signs in with `signIn({ username, password })`. Refusals come as the
232
+ * worker says them: USERNAME_TAKEN (409),
233
+ * USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (422), PASSWORD_TOO_SHORT ("Use at
234
+ * least {n} characters."), VALIDATION_ERROR, WRITE_QUOTA_EXCEEDED (429).
235
+ */
236
+ async createUser(attributes) {
237
+ if (!this.#serviceKey)
238
+ return { data: { user: null }, error: serviceKeyRequired() };
239
+ const result = await call(this.#http, {
240
+ method: "POST",
241
+ path: USERS_PATH,
242
+ body: attributes,
243
+ withToken: false
244
+ });
245
+ const user = userOf(dataOf(result)?.user);
246
+ if (user === null) return { data: { user: null }, error: errorOf(result) };
247
+ return { data: { user }, error: null };
248
+ }
249
+ /** The project's users, newest first: page 1 and 50 a page unless given. */
250
+ async listUsers(params = {}) {
251
+ const none = { users: [], total: 0 };
252
+ if (!this.#serviceKey) return { data: none, error: serviceKeyRequired() };
253
+ const query = new URLSearchParams();
254
+ if (params.page !== void 0) query.set("page", String(params.page));
255
+ if (params.perPage !== void 0)
256
+ query.set("perPage", String(params.perPage));
257
+ const search = query.toString();
258
+ const result = await call(this.#http, {
259
+ method: "GET",
260
+ path: search === "" ? USERS_PATH : `${USERS_PATH}?${search}`,
261
+ withToken: false
262
+ });
263
+ const data = dataOf(result);
264
+ const users = Array.isArray(data?.users) ? data.users.map(userOf) : null;
265
+ if (!data || users === null || users.includes(null) || typeof data.total !== "number")
266
+ return { data: none, error: errorOf(result) };
267
+ return {
268
+ data: { users, total: data.total },
269
+ error: null
270
+ };
271
+ }
272
+ /**
273
+ * Deletes a user and its sign-in methods, and ends its sessions. Answers
274
+ * the user it was; USER_NOT_FOUND (404) for an id that is no user.
275
+ */
276
+ async deleteUser(id) {
277
+ if (!this.#serviceKey)
278
+ return { data: { user: null }, error: serviceKeyRequired() };
279
+ const result = await call(this.#http, {
280
+ method: "DELETE",
281
+ path: `${USERS_PATH}/${encodeURIComponent(String(id))}`,
282
+ withToken: false
283
+ });
284
+ const user = userOf(dataOf(result)?.user);
285
+ if (user === null) return { data: { user: null }, error: errorOf(result) };
286
+ return { data: { user }, error: null };
287
+ }
288
+ };
289
+
199
290
  // src/auth/flow-errors.ts
200
291
  var CODE = /^[A-Z0-9_]{1,64}$/;
201
292
  var INVALID_CONFIRMATION = "The confirmation link is not valid. Ask for a new one.";
@@ -207,6 +298,9 @@ var MESSAGES = {
207
298
  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
299
  PROVIDER_NOT_CONFIGURED: "Signing in with this provider is not set up for this project.",
209
300
  RATE_LIMITED: "Too many sign-in attempts. Wait a few seconds and try again.",
301
+ // A first Google or GitHub sign-in while the project's sign-ups are
302
+ // closed (spec 2026-09-26 §2.1): the worker's own words.
303
+ SIGNUPS_DISABLED: "Sign-ups are closed for this project.",
210
304
  INTERNAL_ERROR: "Something went wrong. Try again.",
211
305
  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
306
  TOKEN_EXPIRED: "The confirmation link has expired. Ask for a new one.",
@@ -326,6 +420,12 @@ function share(shared, pending) {
326
420
  });
327
421
  }
328
422
  var AuthClient = class {
423
+ /**
424
+ * The service key's user management — createUser, listUsers, deleteUser,
425
+ * as in supabase-js (spec 2026-09-26 §2.2). Only on a client made with
426
+ * `serviceKey`, on a server.
427
+ */
428
+ admin;
329
429
  #http;
330
430
  #flow;
331
431
  /** The last `getSessionFromUrl()`, for callers after detection ran it. */
@@ -333,6 +433,7 @@ var AuthClient = class {
333
433
  constructor(http, flow) {
334
434
  this.#http = http;
335
435
  this.#flow = flow;
436
+ this.admin = new AuthAdminApi(http, flow.serviceKey);
336
437
  if (flow.detectSessionInUrl) {
337
438
  if (naroFragment() !== null) void this.getSessionFromUrl();
338
439
  else this.#fromUrl = sharedInFlight(flow.shared);
@@ -342,18 +443,37 @@ var AuthClient = class {
342
443
  * Creates the account and signs in; the token is stored for later calls.
343
444
  * When the project requires a confirmed email there is no session yet —
344
445
  * `user` comes back, and after the confirmation link the user signs in.
446
+ * While the project's sign-ups are closed every sign-up answers 403
447
+ * SIGNUPS_DISABLED; while they are open, a taken `username` answers 409
448
+ * USERNAME_TAKEN.
345
449
  */
346
450
  async signUp(credentials) {
347
- const { email, password } = credentials;
348
- const name = credentials.name ?? defaultName(email);
451
+ const { email, password, username } = credentials;
452
+ const name = credentials.name ?? username ?? defaultName(email);
349
453
  const body = { email, password, name };
454
+ if (username !== void 0) body.username = username;
350
455
  if (credentials.redirectTo !== void 0)
351
456
  body.callbackURL = credentials.redirectTo;
352
457
  return this.#signIn("/auth/sign-up/email", body, false);
353
458
  }
459
+ /**
460
+ * Signs in with an email and password, or a username and password (spec
461
+ * 2026-09-26 §2.3); the token is stored for later calls. A wrong password
462
+ * answers 401 INVALID_EMAIL_OR_PASSWORD, or INVALID_USERNAME_OR_PASSWORD.
463
+ */
354
464
  async signIn(credentials) {
355
- const { email, password } = credentials;
356
- return this.#signIn("/auth/sign-in/email", { email, password }, true);
465
+ const { password } = credentials;
466
+ if (credentials.username !== void 0)
467
+ return this.#signIn(
468
+ "/auth/sign-in/username",
469
+ { username: credentials.username, password },
470
+ true
471
+ );
472
+ return this.#signIn(
473
+ "/auth/sign-in/email",
474
+ { email: credentials.email, password },
475
+ true
476
+ );
357
477
  }
358
478
  /**
359
479
  * Starts a Google or GitHub sign-in: sends the browser, with a top-level
@@ -1668,9 +1788,10 @@ function apiKeyOf(options) {
1668
1788
  if (hasAnon && hasService) throw new Error(CONFIG_MESSAGES.bothKeys);
1669
1789
  const key = hasService ? serviceKey : hasAnon ? anonKey : void 0;
1670
1790
  if (key === void 0) throw new Error(CONFIG_MESSAGES.key);
1671
- if ((hasService || key.startsWith(SERVICE_KEY_PREFIX)) && !isKnownServer())
1791
+ const service = hasService || key.startsWith(SERVICE_KEY_PREFIX);
1792
+ if (service && !isKnownServer())
1672
1793
  throw new Error(CONFIG_MESSAGES.serviceKeyOffServer);
1673
- return key;
1794
+ return { key, service };
1674
1795
  }
1675
1796
  function queryRunner(http, auth) {
1676
1797
  return async (query) => {
@@ -1689,14 +1810,17 @@ function queryRunner(http, auth) {
1689
1810
  };
1690
1811
  }
1691
1812
  var NaroClient = class {
1692
- /** Sign-up, sign-in, sign-out and the current user (spec §13). */
1813
+ /**
1814
+ * Sign-up, sign-in, sign-out and the current user (spec §13); with the
1815
+ * service key, `auth.admin` manages users.
1816
+ */
1693
1817
  auth;
1694
1818
  #run;
1695
1819
  // naro.storage — Phase 3 (spec §18): R2 storage lands here, beside `auth`
1696
1820
  // and `from()`. Deliberately absent in Phase 1: not implemented, not typed.
1697
1821
  constructor(options) {
1698
1822
  const baseUrl = baseUrlOf(options.url);
1699
- const apiKey = apiKeyOf(options);
1823
+ const { key: apiKey, service } = apiKeyOf(options);
1700
1824
  if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
1701
1825
  throw new Error(CONFIG_MESSAGES.fetch);
1702
1826
  const storage = options.auth?.storage ?? defaultStorage();
@@ -1714,7 +1838,8 @@ var NaroClient = class {
1714
1838
  new TokenStore(storage, recoveryKey(baseUrl))
1715
1839
  ),
1716
1840
  detectSessionInUrl: options.auth?.detectSessionInUrl ?? true,
1717
- shared: { storage, key: storageKey(baseUrl) }
1841
+ shared: { storage, key: storageKey(baseUrl) },
1842
+ serviceKey: service
1718
1843
  });
1719
1844
  this.#run = queryRunner(http, this.auth);
1720
1845
  }
package/dist/types.d.ts CHANGED
@@ -81,10 +81,19 @@ export type OrderOptions = {
81
81
  /** Where NULLs go. Omitted, SQLite's default: first ascending, last descending. */
82
82
  nullsFirst?: boolean;
83
83
  };
84
- /** Better Auth's user (spec §13), as JSON: dates are ISO strings. */
84
+ /**
85
+ * The signed-in user, as naro Auth answers it (spec §13): dates are ISO
86
+ * strings. `email` is null for a user created with only a username
87
+ * (`auth.admin.createUser({ username, password })`).
88
+ */
85
89
  export type User = {
86
90
  id: string;
87
- email: string;
91
+ email: string | null;
92
+ /**
93
+ * Lowercased; null for a user without one. Absent from a backend whose
94
+ * Naro worker predates usernames (`naro backend enable` updates it).
95
+ */
96
+ username?: string | null;
88
97
  name: string;
89
98
  emailVerified: boolean;
90
99
  image?: string | null;
@@ -92,7 +101,7 @@ export type User = {
92
101
  updatedAt: string;
93
102
  [field: string]: unknown;
94
103
  };
95
- /** Better Auth's session, as JSON. */
104
+ /** The session, as naro Auth answers it. */
96
105
  export type Session = {
97
106
  id: string;
98
107
  userId: string;
@@ -124,7 +133,16 @@ export type SignOutResponse = {
124
133
  export type SignUpCredentials = {
125
134
  email: string;
126
135
  password: string;
127
- /** Better Auth requires a name; omitted, the email's local part is sent. */
136
+ /**
137
+ * A name to sign in by as well as the email (spec 2026-09-26 §2.3): 3–30
138
+ * letters, digits, `_` and `.`, stored lowercased. One that is taken
139
+ * answers 409 USERNAME_TAKEN.
140
+ */
141
+ username?: string;
142
+ /**
143
+ * Every user has a display name; omitted, the username is sent, or else
144
+ * the email's local part.
145
+ */
128
146
  name?: string;
129
147
  /**
130
148
  * Where the confirmation link sends the browser back to, when the project
@@ -133,8 +151,17 @@ export type SignUpCredentials = {
133
151
  */
134
152
  redirectTo?: string;
135
153
  };
154
+ /**
155
+ * An email and its password, or a username and its password (spec
156
+ * 2026-09-26 §2.3). A username is matched in any case.
157
+ */
136
158
  export type SignInCredentials = {
137
159
  email: string;
160
+ username?: undefined;
161
+ password: string;
162
+ } | {
163
+ username: string;
164
+ email?: undefined;
138
165
  password: string;
139
166
  };
140
167
  export type OAuthProvider = "google" | "github";
@@ -183,4 +210,59 @@ export type AuthActionResponse = {
183
210
  data: null;
184
211
  error: NaroError | null;
185
212
  };
213
+ /**
214
+ * A user as `naro.auth.admin` answers it — never a password or a hash.
215
+ * `email` is null for a user created with only a username.
216
+ */
217
+ export type AdminUser = {
218
+ id: string;
219
+ email: string | null;
220
+ /** Lowercased; null for a user without one. */
221
+ username: string | null;
222
+ emailVerified: boolean;
223
+ /** ISO 8601. */
224
+ createdAt: string;
225
+ };
226
+ /**
227
+ * `auth.admin.createUser()`: an email, a username or both, and a password
228
+ * of at least the project's minimum length (6 unless
229
+ * `naro backend auth password --min-length` changed it). The user is
230
+ * confirmed — signs in at once, even where sign-in waits for a confirmed
231
+ * email — unless `emailConfirm: false`, which needs an email. (Supabase's
232
+ * `email_confirm` is false by default; here confirmed is the default.)
233
+ */
234
+ export type AdminUserAttributes = {
235
+ email?: string;
236
+ username?: string;
237
+ password: string;
238
+ emailConfirm?: boolean;
239
+ };
240
+ /** `auth.admin.listUsers()`: page 1 and 50 a page unless given; at most 1000. */
241
+ export type PageParams = {
242
+ page?: number;
243
+ perPage?: number;
244
+ };
245
+ export type AdminUserResponse = AuthResponse<{
246
+ user: AdminUser | null;
247
+ }>;
248
+ /** A page of users, newest first, and how many there are in all. */
249
+ export type AdminUserListResponse = AuthResponse<{
250
+ users: AdminUser[];
251
+ total: number;
252
+ }>;
253
+ /**
254
+ * The codes sign-up, sign-in and `auth.admin` answer that an app branches
255
+ * on. `NaroError.code` can be any other string too.
256
+ */
257
+ export type AuthErrorCode =
258
+ /** Sign-ups are closed: only `auth.admin.createUser` makes users. */
259
+ "SIGNUPS_DISABLED"
260
+ /** Sign-up or `createUser` with a username someone has, in any case. */
261
+ | "USERNAME_TAKEN" | "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"
262
+ /** "Use at least {n} characters." — the project's minimum. */
263
+ | "PASSWORD_TOO_SHORT" | "PASSWORD_TOO_LONG" | "USERNAME_TOO_SHORT" | "USERNAME_TOO_LONG" | "INVALID_USERNAME" | "INVALID_EMAIL" | "INVALID_EMAIL_OR_PASSWORD" | "INVALID_USERNAME_OR_PASSWORD" | "EMAIL_NOT_VERIFIED" | "INVALID_SESSION"
264
+ /** `auth.admin` without the service key. */
265
+ | "PERMISSION_DENIED"
266
+ /** `auth.admin.deleteUser` with an id that is no user. */
267
+ | "USER_NOT_FOUND" | "RATE_LIMITED" | "WRITE_QUOTA_EXCEEDED";
186
268
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-js",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Auth and data for your Naro project, from the browser or the server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -25,8 +25,8 @@
25
25
  "vitest": "3.2.7",
26
26
  "@next-deploy/backend-worker": "0.1.0",
27
27
  "@next-deploy/config": "0.1.0",
28
- "@next-deploy/sdk": "0.1.0",
29
- "@next-deploy/data-core": "0.1.0"
28
+ "@next-deploy/data-core": "0.1.0",
29
+ "@next-deploy/sdk": "0.1.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsup && pnpm build:types",