@binary1702/auth 0.2.4 → 0.3.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 (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +30 -17
  4. package/PROMPT.md +325 -37
  5. package/README.md +293 -56
  6. package/dist/cli.js +255 -22
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +138 -7
  14. package/dist/core/db.js +406 -47
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +23 -2
  32. package/dist/core/session.js +45 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +11 -4
  44. package/dist/next/index.js +455 -51
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +16 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
package/dist/cli.js CHANGED
@@ -10,13 +10,19 @@
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:
17
- // add-user [name] create another user
18
- // set-password [name] change a password
17
+ // add-user [email] create another user
18
+ // set-password [email] change a password (signs them out everywhere)
19
+ // remove-user [email] delete a user (signs them out everywhere)
20
+ // sign-out-everywhere [email] end every session of a user
19
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
20
26
  // ─────────────────────────────────────────────────────────────
21
27
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
22
28
  import { join, dirname } from "node:path";
@@ -75,7 +81,7 @@ function closePrompts() {
75
81
  }
76
82
  async function ask(question, opts = {}) {
77
83
  rl();
78
- const suffix = opts.default ? c.dim(` (${opts.default})`) : "";
84
+ const suffix = opts.default ? c.dim(` (${opts.default})`) : opts.optional ? c.dim(" (Enter to skip)") : "";
79
85
  stdout.write(` ${question}${suffix}: `);
80
86
  if (opts.hidden)
81
87
  muted = true;
@@ -88,8 +94,12 @@ async function ask(question, opts = {}) {
88
94
  muted = false;
89
95
  stdout.write("\n");
90
96
  }
91
- 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 "";
92
101
  return fail("Input ended before all prompts were answered.");
102
+ }
93
103
  return opts.hidden ? line : line.trim() || opts.default || "";
94
104
  }
95
105
  // ── env file ──────────────────────────────────────────────────
@@ -116,11 +126,14 @@ const PROXY_FILE = `import { proxy } from "${PKG}/next/proxy"
116
126
 
117
127
  export default proxy
118
128
 
119
- // 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.
120
131
  export const config = {
121
- matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"],
132
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
122
133
  }
123
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|.*";
124
137
  function writeIfMissing(rel, content) {
125
138
  const abs = join(cwd, rel);
126
139
  if (existsSync(abs)) {
@@ -154,7 +167,7 @@ function stepInstall() {
154
167
  fail(`npm install ${PKG} failed`);
155
168
  done(`${PKG} installed`);
156
169
  }
157
- async function stepEnv() {
170
+ async function stepEnv(opts = {}) {
158
171
  const env = readEnv();
159
172
  if (env.DATABASE_URL) {
160
173
  process.env.DATABASE_URL = env.DATABASE_URL;
@@ -176,12 +189,88 @@ async function stepEnv() {
176
189
  writeEnv("AUTH_SECRET", randomBytes(32).toString("base64url"));
177
190
  done("AUTH_SECRET generated → .env.local");
178
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");
179
258
  }
180
259
  function stepFiles(appDir, src) {
181
260
  writeIfMissing(`${appDir}/auth/[...path]/route.ts`, ROUTE_FILE);
182
261
  const proxyPath = src ? "src/proxy.ts" : "proxy.ts";
183
262
  const legacy = [src ? "src/middleware.ts" : "middleware.ts", proxyPath].filter((p) => existsSync(join(cwd, p)));
184
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
+ }
185
274
  skip(`${legacy[0]} exists, not touched. Add to it:`);
186
275
  console.log(c.dim(PROXY_FILE.replace(/^/gm, " ")));
187
276
  return;
@@ -192,10 +281,94 @@ async function db() {
192
281
  // Imported lazily: needs DATABASE_URL in process.env first.
193
282
  return import("./core/db.js");
194
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
+
313
+ database: \${DATABASE_URL}
314
+ secret: \${AUTH_SECRET}
315
+
316
+ email:
317
+ ${from ? ` from: ${from}` : " # from: NoReply (My App) <no-reply@example.com>"}
318
+ # replyTo: support@example.com
319
+ ${gmail
320
+ ? ` gmail:
321
+ user: ${env.AUTH_GMAIL_USER} # the mailbox the service account acts as
322
+ clientEmail: \${AUTH_GMAIL_CLIENT_EMAIL}
323
+ privateKey: \${AUTH_GMAIL_PRIVATE_KEY}`
324
+ : resend
325
+ ? ` resend:
326
+ apiKey: \${AUTH_RESEND_API_KEY}`
327
+ : ` # gmail: # one of the two; see the README
328
+ # user: you@example.com
329
+ # clientEmail: \${AUTH_GMAIL_CLIENT_EMAIL}
330
+ # privateKey: \${AUTH_GMAIL_PRIVATE_KEY}
331
+ # resend:
332
+ # apiKey: \${AUTH_RESEND_API_KEY}`}
333
+
334
+ permissions: # role: what it may do. all = everything, x.* = a prefix
335
+ owner: all
336
+ admin: all
337
+ member:
338
+
339
+ # organizations: # overrides by organization name
340
+ # Acme:
341
+ # member: invoices.read
342
+ `;
343
+ }
344
+ async function stepConfig() {
345
+ const abs = join(cwd, CONFIG_FILE);
346
+ if (existsSync(abs)) {
347
+ const { parseYaml } = await import("./core/yaml.js");
348
+ const { literalSecrets } = await import("./core/options.js");
349
+ let bad = [];
350
+ try {
351
+ bad = literalSecrets(parseYaml(readFileSync(abs, "utf8")));
352
+ }
353
+ catch (e) {
354
+ fail(`${CONFIG_FILE}: ${e instanceof Error ? e.message : String(e)}`);
355
+ }
356
+ if (bad.length) {
357
+ const ignored = spawnSync("git", ["check-ignore", "-q", CONFIG_FILE], { cwd }).status === 0;
358
+ if (!ignored) {
359
+ 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.`);
360
+ }
361
+ }
362
+ skip(`${CONFIG_FILE} exists`);
363
+ return;
364
+ }
365
+ writeFileSync(abs, configTemplate(readEnv()));
366
+ done(`${CONFIG_FILE} written: app name, sign-up policy, email, permissions`);
367
+ }
195
368
  async function stepSchema() {
196
- const { ensureSchema } = await db();
369
+ const { ensureSchema, MIGRATIONS } = await db();
197
370
  await ensureSchema().catch((e) => fail(`Could not reach the database: ${e.message}`));
198
- done("auth_users table ready");
371
+ done(`database schema ready (${MIGRATIONS.length} migration${MIGRATIONS.length === 1 ? "" : "s"})`);
199
372
  }
200
373
  async function createUserInteractive(presetEmail) {
201
374
  const { createUser, findUser } = await db();
@@ -211,7 +384,8 @@ async function createUserInteractive(presetEmail) {
211
384
  const again = await ask("Password again", { hidden: true });
212
385
  if (password !== again)
213
386
  fail("Passwords do not match.");
214
- await createUser(email, await hashPassword(password));
387
+ // Created by whoever runs the CLI: no verification email to wait for
388
+ await createUser(email, await hashPassword(password), { verified: true });
215
389
  done(`user "${email}" created`);
216
390
  }
217
391
  async function stepFirstUser() {
@@ -228,15 +402,15 @@ function finish() {
228
402
  console.log(`
229
403
  ${c.bold("Done.")} Start the dev server and open ${c.bold("/auth/sign-in")}.
230
404
 
231
- Get the user in any route or server component:
405
+ Get the session in any route, server component or action:
232
406
 
233
- import { requireUser } from "${PKG}/next"
234
- const user = await requireUser() // { id, email }
407
+ import { getSession, requirePermission } from "${PKG}/next"
408
+ const session = await getSession() // { user, organization, authorization } or null
235
409
 
236
- Add users later: npx ${PKG} add-user
237
- Or let people register themselves at /auth/sign-up:
238
- AUTH_SIGNUP=open in .env.local (anyone)
239
- AUTH_INVITE_CODE=<code> optional, the form then needs it
410
+ Settings live in ${c.bold(CONFIG_FILE)}: app name, who may sign up (signup: open | invite),
411
+ which paths need sign-in (protected:), email, roles → permissions. Secrets stay in .env.local.
412
+ Add users later: npx ${PKG} add-user Organizations: npx ${PKG} add-org <name>
413
+ Working with an AI agent in this app? Point it at node_modules/${PKG}/AGENTS.md
240
414
  `);
241
415
  }
242
416
  // ── main ──────────────────────────────────────────────────────
@@ -246,13 +420,58 @@ async function main() {
246
420
  if (cmd === "help" || cmd === "--help" || cmd === "-h") {
247
421
  console.log(` npx ${PKG} set up auth in this project (safe to re-run)
248
422
  npx ${PKG} add-user [email] create a user
249
- npx ${PKG} set-password [email]
423
+ npx ${PKG} set-password [email] also signs them out everywhere
424
+ npx ${PKG} remove-user [email] delete; also signs them out everywhere
425
+ npx ${PKG} sign-out-everywhere [email] end every session of a user
250
426
  npx ${PKG} list-users
427
+
428
+ npx ${PKG} add-org <name> create an organization
429
+ npx ${PKG} add-member <email> <org> [role] add a user to an org (role: member, admin, owner, …)
430
+ npx ${PKG} remove-member <email> <org>
431
+ npx ${PKG} list-orgs
251
432
  `);
252
433
  return;
253
434
  }
254
435
  stepProject();
255
- if (cmd === "add-user" || cmd === "set-password" || cmd === "list-users") {
436
+ const USER_CMDS = ["add-user", "set-password", "remove-user", "sign-out-everywhere", "list-users"];
437
+ const ORG_CMDS = ["add-org", "add-member", "remove-member", "list-orgs"];
438
+ if (ORG_CMDS.includes(cmd)) {
439
+ await stepEnv();
440
+ const d = await db();
441
+ const [, email, orgName, role] = process.argv.slice(2);
442
+ const org = async (name) => {
443
+ if (!name)
444
+ fail(`Usage: npx ${PKG} ${cmd} <email> <org>`);
445
+ return (await d.findOrg(name)) ?? fail(`No organization "${name}". Create it: npx ${PKG} add-org "${name}"`);
446
+ };
447
+ const user = async (e) => {
448
+ if (!e)
449
+ fail(`Usage: npx ${PKG} ${cmd} <email> <org>`);
450
+ return (await d.findUser(e)) ?? fail(`No user "${e}".`);
451
+ };
452
+ if (cmd === "add-org") {
453
+ if (!arg)
454
+ fail(`Usage: npx ${PKG} add-org <name>`);
455
+ const o = await d.createOrg(arg);
456
+ done(`organization "${o.name}" created ${c.dim(o.id)}`);
457
+ }
458
+ if (cmd === "list-orgs") {
459
+ for (const o of await d.listOrgs())
460
+ console.log(` ${o.name} ${c.dim(o.id)} ${o.members} member${o.members === 1 ? "" : "s"}`);
461
+ }
462
+ if (cmd === "add-member") {
463
+ const [u, o] = [await user(email), await org(orgName)];
464
+ await d.addMember(o.id, u.id, role || "member");
465
+ done(`"${u.email}" is ${role || "member"} of "${o.name}"`);
466
+ }
467
+ if (cmd === "remove-member") {
468
+ const [u, o] = [await user(email), await org(orgName)];
469
+ (await d.removeMember(o.id, u.id)) ? done(`"${u.email}" removed from "${o.name}"`) : fail(`"${u.email}" is not a member of "${o.name}".`);
470
+ }
471
+ console.log();
472
+ return;
473
+ }
474
+ if (USER_CMDS.includes(cmd)) {
256
475
  await stepEnv();
257
476
  const d = await db();
258
477
  if (cmd === "add-user")
@@ -268,7 +487,20 @@ async function main() {
268
487
  fail("Password must be at least 8 characters.");
269
488
  const { hashPassword } = await import("./core/password.js");
270
489
  const ok = await d.setPassword(email, await hashPassword(password));
271
- ok ? done(`password updated for "${email}"`) : fail(`No user "${email}".`);
490
+ ok ? done(`password updated for "${email}", old sessions ended`) : fail(`No user "${email}".`);
491
+ }
492
+ if (cmd === "remove-user" || cmd === "sign-out-everywhere") {
493
+ const email = (arg ?? (await ask("Email"))).trim().toLowerCase();
494
+ if (cmd === "remove-user") {
495
+ const sure = await ask(`Delete "${email}"? This cannot be undone. Type the email again`);
496
+ if (sure.trim().toLowerCase() !== email)
497
+ fail("Not deleted.");
498
+ (await d.removeUser(email)) ? done(`user "${email}" deleted, sessions ended`) : fail(`No user "${email}".`);
499
+ }
500
+ else {
501
+ ;
502
+ (await d.revokeSessions(email)) ? done(`every session of "${email}" ended`) : fail(`No user "${email}".`);
503
+ }
272
504
  }
273
505
  console.log();
274
506
  return;
@@ -277,8 +509,9 @@ async function main() {
277
509
  fail(`Unknown command "${cmd}". Try: npx ${PKG} help`);
278
510
  const { appDir, src } = stepProject();
279
511
  stepInstall();
280
- await stepEnv();
512
+ await stepEnv({ askEmail: true });
281
513
  stepFiles(appDir, src);
514
+ await stepConfig();
282
515
  await stepSchema();
283
516
  await stepFirstUser();
284
517
  finish();
@@ -0,0 +1,26 @@
1
+ import type { AdminOrg, AdminUser } from "./db.ts";
2
+ import type { Options } from "./options.ts";
3
+ export type AdminView = {
4
+ tab: "orgs" | "users" | "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
+ options: Options;
16
+ transport: string;
17
+ tokens: {
18
+ total: number;
19
+ live: number;
20
+ };
21
+ notice?: string;
22
+ error?: string;
23
+ /** per-render, for the one script the page carries; must match the CSP header */
24
+ nonce: string;
25
+ };
26
+ export declare function adminPage(d: AdminPageData): string;