@binary1702/auth 0.2.5 → 0.4.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.
Files changed (53) hide show
  1. package/AGENTS.md +155 -0
  2. package/CHANGELOG.md +49 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +325 -36
  5. package/README.md +338 -53
  6. package/dist/cli.js +248 -17
  7. package/dist/core/admin-html.d.ts +32 -0
  8. package/dist/core/admin-html.js +350 -0
  9. package/dist/core/admin.d.ts +56 -0
  10. package/dist/core/admin.js +90 -0
  11. package/dist/core/config.d.ts +10 -3
  12. package/dist/core/config.js +22 -8
  13. package/dist/core/db.d.ts +155 -10
  14. package/dist/core/db.js +449 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +52 -0
  18. package/dist/core/guard.js +106 -0
  19. package/dist/core/html.d.ts +43 -0
  20. package/dist/core/html.js +122 -28
  21. package/dist/core/invite.d.ts +7 -0
  22. package/dist/core/invite.js +30 -0
  23. package/dist/core/options.d.ts +95 -0
  24. package/dist/core/options.js +377 -0
  25. package/dist/core/org.d.ts +43 -0
  26. package/dist/core/org.js +65 -0
  27. package/dist/core/pages.d.ts +34 -0
  28. package/dist/core/pages.js +73 -0
  29. package/dist/core/password.d.ts +16 -0
  30. package/dist/core/password.js +41 -12
  31. package/dist/core/rate.d.ts +26 -0
  32. package/dist/core/rate.js +70 -0
  33. package/dist/core/reset.d.ts +9 -0
  34. package/dist/core/reset.js +31 -0
  35. package/dist/core/session.d.ts +11 -1
  36. package/dist/core/session.js +27 -7
  37. package/dist/core/signup.d.ts +13 -6
  38. package/dist/core/signup.js +30 -15
  39. package/dist/core/tokens.d.ts +29 -0
  40. package/dist/core/tokens.js +64 -0
  41. package/dist/core/verify.d.ts +8 -0
  42. package/dist/core/verify.js +33 -0
  43. package/dist/core/yaml.d.ts +10 -0
  44. package/dist/core/yaml.js +85 -0
  45. package/dist/index.d.ts +18 -6
  46. package/dist/index.js +18 -6
  47. package/dist/next/index.d.ts +10 -3
  48. package/dist/next/index.js +649 -63
  49. package/dist/next/proxy.d.ts +2 -1
  50. package/dist/next/proxy.js +22 -10
  51. package/dist/react/index.d.ts +72 -0
  52. package/dist/react/index.js +107 -0
  53. package/package.json +18 -4
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // 3. AUTH_SECRET → generate, write .env.local
11
11
  // 4. app/auth/[...path]/route.ts
12
12
  // 5. proxy.ts
13
- // 6. create auth_users table
13
+ // 6. create users table
14
14
  // 7. create the first user
15
15
  //
16
16
  // Subcommands:
@@ -19,6 +19,10 @@
19
19
  // remove-user [email] delete a user (signs them out everywhere)
20
20
  // sign-out-everywhere [email] end every session of a user
21
21
  // list-users
22
+ // add-org <name> create an organization
23
+ // add-member <email> <org> [role] put a user in an org (role defaults to "member")
24
+ // remove-member <email> <org>
25
+ // list-orgs
22
26
  // ─────────────────────────────────────────────────────────────
23
27
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
24
28
  import { join, dirname } from "node:path";
@@ -77,7 +81,7 @@ function closePrompts() {
77
81
  }
78
82
  async function ask(question, opts = {}) {
79
83
  rl();
80
- const suffix = opts.default ? c.dim(` (${opts.default})`) : "";
84
+ const suffix = opts.default ? c.dim(` (${opts.default})`) : opts.optional ? c.dim(" (Enter to skip)") : "";
81
85
  stdout.write(` ${question}${suffix}: `);
82
86
  if (opts.hidden)
83
87
  muted = true;
@@ -90,8 +94,12 @@ async function ask(question, opts = {}) {
90
94
  muted = false;
91
95
  stdout.write("\n");
92
96
  }
93
- if (line === null)
97
+ if (line === null) {
98
+ // Piped input that stops short: an optional prompt is simply skipped
99
+ if (opts.optional)
100
+ return "";
94
101
  return fail("Input ended before all prompts were answered.");
102
+ }
95
103
  return opts.hidden ? line : line.trim() || opts.default || "";
96
104
  }
97
105
  // ── env file ──────────────────────────────────────────────────
@@ -118,11 +126,14 @@ const PROXY_FILE = `import { proxy } from "${PKG}/next/proxy"
118
126
 
119
127
  export default proxy
120
128
 
121
- // Everything except the auth pages, Next internals, and static files.
129
+ // Everything except Next's build output. The proxy itself lets the
130
+ // auth pages and static files through.
122
131
  export const config = {
123
- matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"],
132
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
124
133
  }
125
134
  `;
135
+ // Written by 0.2.5 and earlier: skipped /authors, /api/export.csv, …
136
+ const OLD_MATCHER = "(?!auth|_next/static|_next/image|favicon.ico|.*";
126
137
  function writeIfMissing(rel, content) {
127
138
  const abs = join(cwd, rel);
128
139
  if (existsSync(abs)) {
@@ -156,7 +167,7 @@ function stepInstall() {
156
167
  fail(`npm install ${PKG} failed`);
157
168
  done(`${PKG} installed`);
158
169
  }
159
- async function stepEnv() {
170
+ async function stepEnv(opts = {}) {
160
171
  const env = readEnv();
161
172
  if (env.DATABASE_URL) {
162
173
  process.env.DATABASE_URL = env.DATABASE_URL;
@@ -178,12 +189,88 @@ async function stepEnv() {
178
189
  writeEnv("AUTH_SECRET", randomBytes(32).toString("base64url"));
179
190
  done("AUTH_SECRET generated → .env.local");
180
191
  }
192
+ // Email: verification and password-reset links. Optional. Without it,
193
+ // development prints the emails to the console and production refuses
194
+ // to send, so the install still works. See core/email.ts.
195
+ for (const k of ["AUTH_GMAIL_USER", "AUTH_GMAIL_CLIENT_EMAIL", "AUTH_GMAIL_PRIVATE_KEY", "AUTH_RESEND_API_KEY", "AUTH_EMAIL_FROM"]) {
196
+ if (env[k])
197
+ process.env[k] = env[k];
198
+ }
199
+ if (env.AUTH_GMAIL_USER && env.AUTH_GMAIL_CLIENT_EMAIL && env.AUTH_GMAIL_PRIVATE_KEY) {
200
+ if (opts.askEmail)
201
+ skip("email: Gmail API as " + env.AUTH_GMAIL_USER);
202
+ return;
203
+ }
204
+ if (env.AUTH_RESEND_API_KEY && env.AUTH_EMAIL_FROM) {
205
+ if (opts.askEmail)
206
+ skip("email: Resend from " + env.AUTH_EMAIL_FROM);
207
+ return;
208
+ }
209
+ if (!opts.askEmail)
210
+ return; // subcommands only need the database
211
+ // Once b17_auth.yaml exists, email is configured there (its comments show how); stop asking
212
+ if (existsSync(join(cwd, CONFIG_FILE)))
213
+ return;
214
+ console.log(c.dim(" Email for verification and password reset. gmail = Gmail API with a service account,"));
215
+ console.log(c.dim(" resend = resend.com API key. Skip it for now if you like."));
216
+ const which = (await ask("Email transport (gmail / resend)", { optional: true })).toLowerCase();
217
+ if (which === "gmail") {
218
+ const user = await ask("Mailbox to send as (AUTH_GMAIL_USER)", { default: env.AUTH_GMAIL_USER });
219
+ if (!user.includes("@"))
220
+ fail("That does not look like an email address.");
221
+ const path = await ask("Path to the service account's JSON key file");
222
+ const abs = path.startsWith("/") ? path : join(cwd, path);
223
+ if (!existsSync(abs))
224
+ fail(`No file at ${abs}`);
225
+ let sa;
226
+ try {
227
+ sa = JSON.parse(readFileSync(abs, "utf8"));
228
+ }
229
+ catch {
230
+ return fail("That file is not JSON.");
231
+ }
232
+ if (!sa.client_email || !sa.private_key)
233
+ fail("That JSON has no client_email / private_key: it is not a service account key.");
234
+ writeEnv("AUTH_GMAIL_USER", user);
235
+ writeEnv("AUTH_GMAIL_CLIENT_EMAIL", sa.client_email);
236
+ // One line: the PEM's newlines as the two characters \n, which the package restores
237
+ writeEnv("AUTH_GMAIL_PRIVATE_KEY", sa.private_key.replace(/\n/g, "\\n"));
238
+ done(`AUTH_GMAIL_USER, AUTH_GMAIL_CLIENT_EMAIL, AUTH_GMAIL_PRIVATE_KEY → .env.local (${sa.client_email})`);
239
+ console.log(c.dim(` In Google Admin, grant that service account domain-wide delegation for the scope:`));
240
+ console.log(c.dim(` https://www.googleapis.com/auth/gmail.send`));
241
+ return;
242
+ }
243
+ if (which === "resend") {
244
+ const key = env.AUTH_RESEND_API_KEY || (await ask("Resend API key (AUTH_RESEND_API_KEY)", { hidden: true }));
245
+ if (!key.trim())
246
+ fail("No key given.");
247
+ const from = env.AUTH_EMAIL_FROM || (await ask("From address (AUTH_EMAIL_FROM)", { default: "Auth <onboarding@resend.dev>" }));
248
+ if (!env.AUTH_RESEND_API_KEY)
249
+ writeEnv("AUTH_RESEND_API_KEY", key.trim());
250
+ if (!env.AUTH_EMAIL_FROM)
251
+ writeEnv("AUTH_EMAIL_FROM", from);
252
+ done("AUTH_RESEND_API_KEY, AUTH_EMAIL_FROM → .env.local");
253
+ return;
254
+ }
255
+ if (which)
256
+ fail(`Unknown transport "${which}". Use gmail, resend, or press Enter to skip.`);
257
+ skip("email not configured: dev prints emails to the console");
181
258
  }
182
259
  function stepFiles(appDir, src) {
183
260
  writeIfMissing(`${appDir}/auth/[...path]/route.ts`, ROUTE_FILE);
184
261
  const proxyPath = src ? "src/proxy.ts" : "proxy.ts";
185
262
  const legacy = [src ? "src/middleware.ts" : "middleware.ts", proxyPath].filter((p) => existsSync(join(cwd, p)));
186
263
  if (legacy.length) {
264
+ const text = readFileSync(join(cwd, legacy[0]), "utf8");
265
+ if (text.includes(`${PKG}/next/proxy`) && !text.includes(OLD_MATCHER)) {
266
+ skip(`${legacy[0]} exists`); // ours, current: nothing to say
267
+ return;
268
+ }
269
+ if (text.includes(OLD_MATCHER)) {
270
+ console.log(` ${c.red("!")} ${legacy[0]} has the old matcher, which leaves /auth* and dotted paths unguarded.`);
271
+ console.log(` Replace its matcher with: ${c.bold('["/((?!_next/static|_next/image|favicon.ico).*)"]')}`);
272
+ return;
273
+ }
187
274
  skip(`${legacy[0]} exists, not touched. Add to it:`);
188
275
  console.log(c.dim(PROXY_FILE.replace(/^/gm, " ")));
189
276
  return;
@@ -194,10 +281,109 @@ async function db() {
194
281
  // Imported lazily: needs DATABASE_URL in process.env first.
195
282
  return import("./core/db.js");
196
283
  }
284
+ // ── b17_auth.yaml ─────────────────────────────────────────────
285
+ const CONFIG_FILE = "b17_auth.yaml";
286
+ function appName() {
287
+ try {
288
+ const name = String(JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")).name ?? "");
289
+ return name.replace(/^@[^/]+\//, "").replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
290
+ }
291
+ catch {
292
+ return "My App";
293
+ }
294
+ }
295
+ /** The file the install writes once. Secrets are ${…} references into .env.local. */
296
+ function configTemplate(env) {
297
+ const gmail = env.AUTH_GMAIL_USER && env.AUTH_GMAIL_CLIENT_EMAIL && env.AUTH_GMAIL_PRIVATE_KEY;
298
+ const resend = env.AUTH_RESEND_API_KEY;
299
+ const from = env.AUTH_EMAIL_FROM || (gmail ? env.AUTH_GMAIL_USER : "");
300
+ return `# ${CONFIG_FILE} · @binary1702/auth
301
+ # One place for all of it. Secrets stay in .env.local and appear here as \${NAME}.
302
+ # Docs: https://www.npmjs.com/package/@binary1702/auth#b17_authyaml
303
+
304
+ name: ${appName()} # page titles, email subjects
305
+ signup: closed # open | closed | invite (invite also needs inviteCode)
306
+ # inviteCode: \${AUTH_INVITE_CODE}
307
+ # emailDomain: example.com # only these addresses may sign up
308
+ # requireVerified: true # no session until the email link is clicked
309
+ # protected: /app/* /api/* # only these need sign-in; everything else is open. * = anything below
310
+ # # (or the reverse: public: / /pricing = everything needs sign-in except these)
311
+ # admins: you@example.com # who may open /auth/admin (users, organizations, roles)
312
+ # home: /app # the signed-in home: after sign-in, and where a signed-in visitor to / goes
313
+ # pages: # your own sign-in / sign-up pages instead of the built-in ones;
314
+ # signIn: /sign-in # they call signIn() / signUp() from @binary1702/auth/react
315
+ # signUp: /sign-up
316
+
317
+ database: \${DATABASE_URL}
318
+ secret: \${AUTH_SECRET}
319
+
320
+ email:
321
+ ${from ? ` from: ${from}` : " # from: NoReply (My App) <no-reply@example.com>"}
322
+ # replyTo: support@example.com
323
+ ${gmail
324
+ ? ` gmail:
325
+ user: ${env.AUTH_GMAIL_USER} # the mailbox the service account acts as
326
+ clientEmail: \${AUTH_GMAIL_CLIENT_EMAIL}
327
+ privateKey: \${AUTH_GMAIL_PRIVATE_KEY}`
328
+ : resend
329
+ ? ` resend:
330
+ apiKey: \${AUTH_RESEND_API_KEY}`
331
+ : ` # gmail: # one of the two; see the README
332
+ # user: you@example.com
333
+ # clientEmail: \${AUTH_GMAIL_CLIENT_EMAIL}
334
+ # privateKey: \${AUTH_GMAIL_PRIVATE_KEY}
335
+ # resend:
336
+ # apiKey: \${AUTH_RESEND_API_KEY}`}
337
+
338
+ # permissions: # what the app gates: id: label. The rows on /auth/admin › Roles
339
+ # invoices.read: See invoices
340
+ # invoices.create: Create invoices
341
+
342
+ roles: # role: what it may do to start with. all = everything, x.* = a prefix
343
+ owner: all # (/auth/admin › Roles saves changes in the database)
344
+ admin: all
345
+ member:
346
+ `;
347
+ }
348
+ async function stepConfig() {
349
+ const abs = join(cwd, CONFIG_FILE);
350
+ if (existsSync(abs)) {
351
+ const { parseYaml } = await import("./core/yaml.js");
352
+ const { literalSecrets } = await import("./core/options.js");
353
+ let bad = [];
354
+ try {
355
+ bad = literalSecrets(parseYaml(readFileSync(abs, "utf8")));
356
+ }
357
+ catch (e) {
358
+ fail(`${CONFIG_FILE}: ${e instanceof Error ? e.message : String(e)}`);
359
+ }
360
+ if (bad.length) {
361
+ const ignored = spawnSync("git", ["check-ignore", "-q", CONFIG_FILE], { cwd }).status === 0;
362
+ if (!ignored) {
363
+ fail(`${CONFIG_FILE} holds a literal secret under ${bad.join(", ")}. Move it to .env.local and reference it as \${NAME}, or git-ignore the file.`);
364
+ }
365
+ }
366
+ skip(`${CONFIG_FILE} exists`);
367
+ return;
368
+ }
369
+ writeFileSync(abs, configTemplate(readEnv()));
370
+ done(`${CONFIG_FILE} written: app name, sign-up policy, email, permissions`);
371
+ }
372
+ /** The roles from b17_auth.yaml, written into the database once. A role the admin page already saved is left alone. */
373
+ async function stepRoles() {
374
+ const { seedRoles } = await db();
375
+ const { options, resetOptions } = await import("./core/options.js");
376
+ resetOptions(); // the file may have been written a step ago
377
+ const written = await seedRoles(options().roles);
378
+ if (written.length)
379
+ done(`roles written to the database: ${written.join(", ")} ${c.dim("(the starting point; /auth/admin › Roles decides from here)")}`);
380
+ else
381
+ skip("roles already in the database");
382
+ }
197
383
  async function stepSchema() {
198
- const { ensureSchema } = await db();
384
+ const { ensureSchema, MIGRATIONS } = await db();
199
385
  await ensureSchema().catch((e) => fail(`Could not reach the database: ${e.message}`));
200
- done("auth_users table ready");
386
+ done(`database schema ready (${MIGRATIONS.length} migration${MIGRATIONS.length === 1 ? "" : "s"})`);
201
387
  }
202
388
  async function createUserInteractive(presetEmail) {
203
389
  const { createUser, findUser } = await db();
@@ -213,7 +399,8 @@ async function createUserInteractive(presetEmail) {
213
399
  const again = await ask("Password again", { hidden: true });
214
400
  if (password !== again)
215
401
  fail("Passwords do not match.");
216
- await createUser(email, await hashPassword(password));
402
+ // Created by whoever runs the CLI: no verification email to wait for
403
+ await createUser(email, await hashPassword(password), { verified: true });
217
404
  done(`user "${email}" created`);
218
405
  }
219
406
  async function stepFirstUser() {
@@ -230,15 +417,15 @@ function finish() {
230
417
  console.log(`
231
418
  ${c.bold("Done.")} Start the dev server and open ${c.bold("/auth/sign-in")}.
232
419
 
233
- Get the user in any route or server component:
420
+ Get the session in any route, server component or action:
234
421
 
235
- import { requireUser } from "${PKG}/next"
236
- const user = await requireUser() // { id, email }
422
+ import { getSession, requirePermission } from "${PKG}/next"
423
+ const session = await getSession() // { user, organization, authorization } or null
237
424
 
238
- Add users later: npx ${PKG} add-user
239
- Or let people register themselves at /auth/sign-up:
240
- AUTH_SIGNUP=open in .env.local (anyone)
241
- AUTH_INVITE_CODE=<code> optional, the form then needs it
425
+ Settings live in ${c.bold(CONFIG_FILE)}: app name, who may sign up (signup: open | invite),
426
+ which paths need sign-in (protected:), email, roles → permissions. Secrets stay in .env.local.
427
+ Add users later: npx ${PKG} add-user Organizations: npx ${PKG} add-org <name>
428
+ Working with an AI agent in this app? Point it at node_modules/${PKG}/AGENTS.md
242
429
  `);
243
430
  }
244
431
  // ── main ──────────────────────────────────────────────────────
@@ -252,11 +439,53 @@ async function main() {
252
439
  npx ${PKG} remove-user [email] delete; also signs them out everywhere
253
440
  npx ${PKG} sign-out-everywhere [email] end every session of a user
254
441
  npx ${PKG} list-users
442
+
443
+ npx ${PKG} add-org <name> create an organization
444
+ npx ${PKG} add-member <email> <org> [role] add a user to an org (role: member, admin, owner, …)
445
+ npx ${PKG} remove-member <email> <org>
446
+ npx ${PKG} list-orgs
255
447
  `);
256
448
  return;
257
449
  }
258
450
  stepProject();
259
451
  const USER_CMDS = ["add-user", "set-password", "remove-user", "sign-out-everywhere", "list-users"];
452
+ const ORG_CMDS = ["add-org", "add-member", "remove-member", "list-orgs"];
453
+ if (ORG_CMDS.includes(cmd)) {
454
+ await stepEnv();
455
+ const d = await db();
456
+ const [, email, orgName, role] = process.argv.slice(2);
457
+ const org = async (name) => {
458
+ if (!name)
459
+ fail(`Usage: npx ${PKG} ${cmd} <email> <org>`);
460
+ return (await d.findOrg(name)) ?? fail(`No organization "${name}". Create it: npx ${PKG} add-org "${name}"`);
461
+ };
462
+ const user = async (e) => {
463
+ if (!e)
464
+ fail(`Usage: npx ${PKG} ${cmd} <email> <org>`);
465
+ return (await d.findUser(e)) ?? fail(`No user "${e}".`);
466
+ };
467
+ if (cmd === "add-org") {
468
+ if (!arg)
469
+ fail(`Usage: npx ${PKG} add-org <name>`);
470
+ const o = await d.createOrg(arg);
471
+ done(`organization "${o.name}" created ${c.dim(o.id)}`);
472
+ }
473
+ if (cmd === "list-orgs") {
474
+ for (const o of await d.listOrgs())
475
+ console.log(` ${o.name} ${c.dim(o.id)} ${o.members} member${o.members === 1 ? "" : "s"}`);
476
+ }
477
+ if (cmd === "add-member") {
478
+ const [u, o] = [await user(email), await org(orgName)];
479
+ await d.addMember(o.id, u.id, role || "member");
480
+ done(`"${u.email}" is ${role || "member"} of "${o.name}"`);
481
+ }
482
+ if (cmd === "remove-member") {
483
+ const [u, o] = [await user(email), await org(orgName)];
484
+ (await d.removeMember(o.id, u.id)) ? done(`"${u.email}" removed from "${o.name}"`) : fail(`"${u.email}" is not a member of "${o.name}".`);
485
+ }
486
+ console.log();
487
+ return;
488
+ }
260
489
  if (USER_CMDS.includes(cmd)) {
261
490
  await stepEnv();
262
491
  const d = await db();
@@ -295,9 +524,11 @@ async function main() {
295
524
  fail(`Unknown command "${cmd}". Try: npx ${PKG} help`);
296
525
  const { appDir, src } = stepProject();
297
526
  stepInstall();
298
- await stepEnv();
527
+ await stepEnv({ askEmail: true });
299
528
  stepFiles(appDir, src);
529
+ await stepConfig();
300
530
  await stepSchema();
531
+ await stepRoles();
301
532
  await stepFirstUser();
302
533
  finish();
303
534
  }
@@ -0,0 +1,32 @@
1
+ import type { AdminOrg, AdminUser, StoredRole } from "./db.ts";
2
+ import type { Options } from "./options.ts";
3
+ export type AdminView = {
4
+ tab: "orgs" | "users" | "roles" | "settings";
5
+ } | {
6
+ tab: "org";
7
+ orgId: string;
8
+ };
9
+ export type AdminPageData = {
10
+ self: string;
11
+ view: AdminView;
12
+ users: AdminUser[];
13
+ orgs: AdminOrg[];
14
+ roles: string[];
15
+ /** what the admin page saved for roles, all scopes */
16
+ stored: StoredRole[];
17
+ options: Options;
18
+ transport: string;
19
+ tokens: {
20
+ total: number;
21
+ live: number;
22
+ };
23
+ /** an organization's owner, not a system admin: only their organization is shown */
24
+ limited?: boolean;
25
+ notice?: string;
26
+ error?: string;
27
+ /** a one-time link (invite or verify) just issued, shown so the admin can hand it over */
28
+ link?: string;
29
+ /** per-render, for the one script the page carries; must match the CSP header */
30
+ nonce: string;
31
+ };
32
+ export declare function adminPage(d: AdminPageData): string;