@binary1702/auth 0.3.0 → 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.
@@ -47,7 +47,8 @@ export function matchesPattern(pathname, pattern) {
47
47
  return pathname === pattern;
48
48
  }
49
49
  /**
50
- * Paths reachable without a session. The auth pages and Next internals
50
+ * Paths reachable without a session. The auth pages (the package's under
51
+ * /auth/, and the app's own named under `pages:`) and Next internals
51
52
  * always are. Then one of two modes from b17_auth.yaml:
52
53
  *
53
54
  * protected: /app/* /api/* only these need a session; everything else is open
@@ -61,6 +62,8 @@ export function isPublic(pathname, rules = {}) {
61
62
  const r = Array.isArray(rules) ? { public: rules } : rules;
62
63
  if (/^\/auth(\/|$)/.test(pathname))
63
64
  return true;
65
+ if (r.pages?.includes(pathname))
66
+ return true;
64
67
  if (pathname.startsWith("/_next/"))
65
68
  return true;
66
69
  if (r.protected?.length)
@@ -1,3 +1,4 @@
1
+ import { type Pages } from "./pages.ts";
1
2
  export declare const escape: (s: string) => string;
2
3
  /**
3
4
  * Headers every auth page is served with. Pure.
@@ -8,6 +9,7 @@ export declare const escape: (s: string) => string;
8
9
  */
9
10
  export declare function securityHeaders(scriptNonce?: string): Record<string, string>;
10
11
  export declare function setSiteName(name: string | undefined): void;
12
+ export declare function setPages(p: Pages | undefined): void;
11
13
  /** "Didn't get the email?" A second form, POSTing to /auth/verify/resend. */
12
14
  export declare function resendForm(email?: string): string;
13
15
  export declare function signInPage(opts?: {
@@ -25,10 +27,15 @@ export declare function forgotPage(opts?: {
25
27
  error?: string;
26
28
  message?: string;
27
29
  }): string;
28
- /** The form behind the emailed link: a new password, the token hidden. */
30
+ /**
31
+ * The form behind an emailed link: a new password, the token hidden. A
32
+ * reset link, or (`invite`) the welcome link an added user gets: same
33
+ * form, posted to /auth/invite, worded for a first password.
34
+ */
29
35
  export declare function resetPage(opts: {
30
36
  token: string;
31
37
  error?: string;
38
+ invite?: boolean;
32
39
  }): string;
33
40
  export declare function signUpPage(opts?: {
34
41
  error?: string;
package/dist/core/html.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // consumer never builds a form or a page. Every untrusted value goes
4
4
  // through escape().
5
5
  // ─────────────────────────────────────────────────────────────
6
- import { SIGN_IN_PATH, SIGN_UP_PATH } from "./config.js";
6
+ import { pagePath } from "./pages.js";
7
7
  import { VERIFY_PATH } from "./verify.js";
8
8
  import { FORGOT_PATH, RESET_PATH } from "./reset.js";
9
+ import { INVITE_PATH } from "./invite.js";
9
10
  import { PASSWORD_MAX, PASSWORD_MIN } from "./signup.js";
10
11
  export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11
12
  /**
@@ -50,6 +51,13 @@ let siteName = "";
50
51
  export function setSiteName(name) {
51
52
  siteName = name ?? "";
52
53
  }
54
+ /** Where links to sign-in / sign-up point: the app's own pages when b17_auth.yaml names them */
55
+ let pages = {};
56
+ export function setPages(p) {
57
+ pages = p ?? {};
58
+ }
59
+ const SIGN_IN_PATH = () => pagePath(pages, "signIn");
60
+ const SIGN_UP_PATH = () => pagePath(pages, "signUp");
53
61
  function shell(title, body) {
54
62
  return `<!doctype html>
55
63
  <html lang="en">
@@ -92,7 +100,7 @@ export function resendForm(email) {
92
100
  export function signInPage(opts = {}) {
93
101
  return page({
94
102
  title: opts.title ?? "Sign in",
95
- action: SIGN_IN_PATH,
103
+ action: SIGN_IN_PATH(),
96
104
  error: opts.error,
97
105
  notice: opts.notice,
98
106
  next: opts.next,
@@ -100,7 +108,7 @@ export function signInPage(opts = {}) {
100
108
  fields: `
101
109
  <label>Email <input name="email" type="email" autocomplete="email" autofocus required value="${escape(opts.email ?? "")}"></label>
102
110
  <label>Password <input name="password" type="password" autocomplete="current-password" required></label>`,
103
- alt: `<p class="alt"><a href="${FORGOT_PATH}">Forgot password?</a>${opts.canSignUp ? ` · No account? <a href="${SIGN_UP_PATH}${nextQuery(opts.next)}">Create one</a>` : ""}</p>`,
111
+ alt: `<p class="alt"><a href="${FORGOT_PATH}">Forgot password?</a>${opts.canSignUp ? ` · No account? <a href="${SIGN_UP_PATH()}${nextQuery(opts.next)}">Create one</a>` : ""}</p>`,
104
112
  after: opts.resend ? resendForm(opts.email) : undefined,
105
113
  });
106
114
  }
@@ -110,7 +118,7 @@ export function forgotPage(opts = {}) {
110
118
  return messagePage({
111
119
  title: "Check your email",
112
120
  message: opts.message ?? "",
113
- link: { href: SIGN_IN_PATH, text: "Back to sign in" },
121
+ link: { href: SIGN_IN_PATH(), text: "Back to sign in" },
114
122
  });
115
123
  }
116
124
  return page({
@@ -120,33 +128,38 @@ export function forgotPage(opts = {}) {
120
128
  button: "Send reset link",
121
129
  fields: `
122
130
  <label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>`,
123
- alt: `<p class="alt"><a href="${SIGN_IN_PATH}">Back to sign in</a></p>`,
131
+ alt: `<p class="alt"><a href="${SIGN_IN_PATH()}">Back to sign in</a></p>`,
124
132
  });
125
133
  }
126
- /** The form behind the emailed link: a new password, the token hidden. */
134
+ /**
135
+ * The form behind an emailed link: a new password, the token hidden. A
136
+ * reset link, or (`invite`) the welcome link an added user gets: same
137
+ * form, posted to /auth/invite, worded for a first password.
138
+ */
127
139
  export function resetPage(opts) {
128
140
  return page({
129
- title: "Choose a new password",
130
- action: RESET_PATH,
141
+ title: opts.invite ? `Welcome${siteName ? ` to ${siteName}` : ""}` : "Choose a new password",
142
+ action: opts.invite ? INVITE_PATH : RESET_PATH,
131
143
  error: opts.error,
132
- button: "Set password",
144
+ notice: opts.invite ? "Choose a password to finish setting up your account." : undefined,
145
+ button: opts.invite ? "Set password and sign in" : "Set password",
133
146
  hidden: `\n <input type="hidden" name="token" value="${escape(opts.token)}">`,
134
147
  fields: `
135
- <label>New password <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" autofocus required></label>`,
136
- alt: `<p class="alt"><a href="${FORGOT_PATH}">Request a new link</a></p>`,
148
+ <label>${opts.invite ? "Password" : "New password"} <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" autofocus required></label>`,
149
+ alt: opts.invite ? `<p class="alt">Already set it? <a href="${SIGN_IN_PATH()}">Sign in</a></p>` : `<p class="alt"><a href="${FORGOT_PATH}">Request a new link</a></p>`,
137
150
  });
138
151
  }
139
152
  export function signUpPage(opts = {}) {
140
153
  return page({
141
154
  title: opts.title ?? (opts.first ? "Create the first account" : "Create account"),
142
- action: SIGN_UP_PATH,
155
+ action: SIGN_UP_PATH(),
143
156
  error: opts.error,
144
157
  next: opts.next,
145
158
  button: "Create account",
146
159
  fields: `
147
160
  <label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
148
161
  <label>Password <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" required></label>${opts.inviteRequired ? `\n <label>Invite code <input name="invite" autocomplete="off" required></label>` : ""}`,
149
- alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH}${nextQuery(opts.next)}">Sign in</a></p>`,
162
+ alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH()}${nextQuery(opts.next)}">Sign in</a></p>`,
150
163
  });
151
164
  }
152
165
  /** A page with no form: a result, an error, a "check your email". */
@@ -0,0 +1,7 @@
1
+ import type { EmailMessage } from "./email.ts";
2
+ export declare const INVITE_PATH = "/auth/invite";
3
+ export declare const INVITE_TTL_SECONDS: number;
4
+ export declare const INVITE_LINK_INVALID = "This invitation link is invalid, already used, or older than seven days. Ask for a new one.";
5
+ /** The absolute link that goes in the email */
6
+ export declare function inviteLink(base: string, rawToken: string): string;
7
+ export declare function inviteEmail(to: string, link: string, site: string): EmailMessage;
@@ -0,0 +1,30 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Invitations. Pure: the link, the email, the constants.
3
+ //
4
+ // An admin adds a user by email alone (no password). The account is
5
+ // created with a random password nobody knows, unverified, and the
6
+ // person gets one link: /auth/invite?token=… It is the reset flow with
7
+ // a longer life and different words: POST /auth/invite consumes the
8
+ // `invite` token, sets the password they chose (which marks the email
9
+ // verified, since the link proved it), and signs them in.
10
+ //
11
+ // The same link can be sent again later ("invite" on the admin page):
12
+ // a new token, the old one still dies when either is used or expires.
13
+ // ─────────────────────────────────────────────────────────────
14
+ export const INVITE_PATH = "/auth/invite";
15
+ export const INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
16
+ export const INVITE_LINK_INVALID = "This invitation link is invalid, already used, or older than seven days. Ask for a new one.";
17
+ /** The absolute link that goes in the email */
18
+ export function inviteLink(base, rawToken) {
19
+ const url = new URL(INVITE_PATH, base);
20
+ url.searchParams.set("token", rawToken);
21
+ return url.toString();
22
+ }
23
+ export function inviteEmail(to, link, site) {
24
+ return {
25
+ to,
26
+ subject: `Your ${site} account: choose a password`,
27
+ text: `An account on ${site} was created for this address. Open the link below to choose your password and sign in. It works once and expires in seven days.\n\n${link}\n\nIf you were not expecting this, ignore this email: nothing happens until the link is used.`,
28
+ html: `<p>An account on ${site} was created for this address. Open the link below to choose your password and sign in. It works once and expires in seven days.</p><p><a href="${link}">${link}</a></p><p>If you were not expecting this, ignore this email: nothing happens until the link is used.</p>`,
29
+ };
30
+ }
@@ -1,4 +1,5 @@
1
1
  import { type YamlTree } from "./yaml.ts";
2
+ import { type Pages } from "./pages.ts";
2
3
  export declare const CONFIG_FILE = "b17_auth.yaml";
3
4
  export type SignupMode = "open" | "closed" | "invite";
4
5
  export type EmailOptions = {
@@ -33,13 +34,22 @@ export type Options = {
33
34
  public: string[];
34
35
  /** Emails that may open /auth/admin. Lowercased. */
35
36
  admins: string[];
37
+ /** The app's own sign-in / sign-up pages, by path. Missing: the package's pages under /auth/. */
38
+ pages: Pages;
39
+ /** The signed-in home (e.g. /app): after sign-in, the admin page's app link, and where a signed-in visitor to / is sent. Missing: the site root. */
40
+ home?: string;
36
41
  database?: string;
37
42
  secret?: string;
38
43
  email: EmailOptions;
39
- /** role → permissions. "all" (or "*") is everything, "blue.*" a prefix */
40
- permissions: Record<string, string[]>;
41
- /** organization name → role → permissions, overriding `permissions` there */
42
- organizations: Record<string, Record<string, string[]>>;
44
+ /** What the app gates: permission id → label. The admin page's rows. */
45
+ permissions: Record<string, string>;
46
+ /**
47
+ * role → permissions, written into the database once at install as the
48
+ * starting point ("all" or "*" is everything, "blue.*" a prefix). After
49
+ * that the database decides; "Reset to defaults" on /auth/admin brings a
50
+ * role back to this.
51
+ */
52
+ roles: Record<string, string[]>;
43
53
  production: boolean;
44
54
  };
45
55
  export declare const DEFAULTS: Options;
@@ -48,6 +58,15 @@ export declare const SECRET_KEYS: string[];
48
58
  export type Env = Record<string, string | undefined>;
49
59
  /** The env vars as they were before the file existed. Pure. */
50
60
  export declare function fromEnv(env: Env): Partial<Options>;
61
+ /** A permission id: words joined by dots, `area.verb`; a grant may end in `.*` */
62
+ export declare const PERMISSION_ID: RegExp;
63
+ /**
64
+ * Before 0.4.0, `permissions:` held role → list. Now it is the catalogue
65
+ * (id → label) and the map is `roles:`. A value that reads as a list of
66
+ * grants ("all", "x.*", dotted ids only) under the old key is the old
67
+ * shape: say so, instead of turning role names into permission ids. Pure.
68
+ */
69
+ export declare function looksLikeRoleMap(values: unknown[]): boolean;
51
70
  /** "all" is the file's word for "*"; duplicates dropped. */
52
71
  export declare function normalizePermissions(list: string[]): string[];
53
72
  export declare class OptionsError extends Error {
@@ -62,7 +81,7 @@ export declare function interpolate(value: string, env: Env, key: string): strin
62
81
  export declare function literalSecrets(tree: YamlTree): string[];
63
82
  /** The file's contents as options, ${…} resolved. Pure. */
64
83
  export declare function fromYaml(tree: YamlTree, env: Env): Partial<Options>;
65
- /** defaults ← file ← env. Maps (email, permissions, organizations) merge key by key. Pure. */
84
+ /** defaults ← file ← env. Maps (email, pages, permissions, roles) merge key by key. Pure. */
66
85
  export declare function resolveOptions(file: YamlTree | null, env: Env): Options;
67
86
  /**
68
87
  * Read b17_auth.yaml from the project root (the directory the server was
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readFileSync, statSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { parseYaml, words } from "./yaml.js";
16
+ import { PAGE_KEYS, validateAppPath, validatePagePath } from "./pages.js";
16
17
  export const CONFIG_FILE = "b17_auth.yaml";
17
18
  export const DEFAULTS = {
18
19
  signup: "closed",
@@ -21,11 +22,12 @@ export const DEFAULTS = {
21
22
  protected: [],
22
23
  public: [],
23
24
  admins: [],
25
+ pages: {},
24
26
  email: {},
25
27
  // An owner can do everything unless a file says otherwise. A sole
26
- // developer never has to write a permissions section at all.
27
- permissions: { owner: ["*"] },
28
- organizations: {},
28
+ // developer never has to write a roles section at all.
29
+ permissions: {},
30
+ roles: { owner: ["*"] },
29
31
  production: false,
30
32
  };
31
33
  /** Keys whose value must be a ${…} reference in the file, never a literal */
@@ -49,6 +51,24 @@ export function fromEnv(env) {
49
51
  o.public = words(env.AUTH_PUBLIC);
50
52
  if (env.AUTH_ADMINS)
51
53
  o.admins = words(env.AUTH_ADMINS).map((e) => e.toLowerCase());
54
+ const pages = {};
55
+ if (env.AUTH_SIGN_IN_PAGE)
56
+ pages.signIn = env.AUTH_SIGN_IN_PAGE;
57
+ if (env.AUTH_SIGN_UP_PAGE)
58
+ pages.signUp = env.AUTH_SIGN_UP_PAGE;
59
+ for (const [k, v] of Object.entries(pages)) {
60
+ const bad = validatePagePath(k, v);
61
+ if (bad)
62
+ throw new Error(`AUTH_${k === "signIn" ? "SIGN_IN" : "SIGN_UP"}_PAGE: ${bad}`);
63
+ }
64
+ if (Object.keys(pages).length)
65
+ o.pages = pages;
66
+ if (env.AUTH_HOME) {
67
+ const bad = validateAppPath("home", env.AUTH_HOME);
68
+ if (bad)
69
+ throw new Error(`AUTH_HOME: ${bad}`);
70
+ o.home = env.AUTH_HOME;
71
+ }
52
72
  if (env.DATABASE_URL)
53
73
  o.database = env.DATABASE_URL;
54
74
  if (env.AUTH_SECRET)
@@ -72,31 +92,48 @@ export function fromEnv(env) {
72
92
  email.resend = { apiKey: env.AUTH_RESEND_API_KEY };
73
93
  if (Object.keys(email).length)
74
94
  o.email = email;
75
- if (env.AUTH_PERMISSIONS)
76
- o.permissions = permissionsFromJson(env.AUTH_PERMISSIONS);
95
+ // AUTH_ROLES is the name since 0.4.0; AUTH_PERMISSIONS meant the same map before and still works
96
+ const rolesJson = env.AUTH_ROLES ?? env.AUTH_PERMISSIONS;
97
+ if (rolesJson)
98
+ o.roles = rolesFromJson(rolesJson, env.AUTH_ROLES ? "AUTH_ROLES" : "AUTH_PERMISSIONS");
77
99
  o.production = env.NODE_ENV === "production";
78
100
  return o;
79
101
  }
80
- function permissionsFromJson(raw) {
102
+ function rolesFromJson(raw, name) {
81
103
  let parsed;
82
104
  try {
83
105
  parsed = JSON.parse(raw);
84
106
  }
85
107
  catch {
86
- throw new Error('AUTH_PERMISSIONS is not valid JSON. Expected: {"admin":["invoices.view"],"member":[]}');
108
+ throw new Error(`${name} is not valid JSON. Expected: {"admin":["invoices.view"],"member":[]}`);
87
109
  }
88
110
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
- throw new Error("AUTH_PERMISSIONS must be a JSON object mapping role → list of permissions");
111
+ throw new Error(`${name} must be a JSON object mapping role → list of permissions`);
90
112
  }
91
113
  const map = {};
92
114
  for (const [role, perms] of Object.entries(parsed)) {
93
115
  if (!Array.isArray(perms) || !perms.every((p) => typeof p === "string")) {
94
- throw new Error(`AUTH_PERMISSIONS: role "${role}" must map to a list of strings`);
116
+ throw new Error(`${name}: role "${role}" must map to a list of strings`);
95
117
  }
96
118
  map[role] = normalizePermissions(perms);
97
119
  }
98
120
  return map;
99
121
  }
122
+ /** A permission id: words joined by dots, `area.verb`; a grant may end in `.*` */
123
+ export const PERMISSION_ID = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/i;
124
+ const GRANT = /^([a-z0-9_-]+\.)*[a-z0-9_-]+(\.\*)?$/i;
125
+ /**
126
+ * Before 0.4.0, `permissions:` held role → list. Now it is the catalogue
127
+ * (id → label) and the map is `roles:`. A value that reads as a list of
128
+ * grants ("all", "x.*", dotted ids only) under the old key is the old
129
+ * shape: say so, instead of turning role names into permission ids. Pure.
130
+ */
131
+ export function looksLikeRoleMap(values) {
132
+ const strings = values.filter((v) => typeof v === "string" && v.trim() !== "");
133
+ if (!strings.length)
134
+ return false;
135
+ return strings.every((v) => words(v).every((w) => w === "all" || w === "*" || (GRANT.test(w) && w.includes("."))));
136
+ }
100
137
  /** "all" is the file's word for "*"; duplicates dropped. */
101
138
  export function normalizePermissions(list) {
102
139
  return [...new Set(list.map((p) => (p === "all" ? "*" : p)))];
@@ -150,7 +187,10 @@ function section(tree, key) {
150
187
  /** The file's contents as options, ${…} resolved. Pure. */
151
188
  export function fromYaml(tree, env) {
152
189
  const o = {};
153
- const known = new Set(["name", "signup", "inviteCode", "emailDomain", "requireVerified", "sessionDays", "protected", "public", "admins", "database", "secret", "email", "permissions", "organizations"]);
190
+ const known = new Set(["name", "signup", "inviteCode", "emailDomain", "requireVerified", "sessionDays", "protected", "public", "admins", "pages", "home", "database", "secret", "email", "permissions", "roles"]);
191
+ if (tree.organizations !== undefined) {
192
+ throw new OptionsError("organizations: is gone; an organization's own roles are set on /auth/admin (open the organization) and saved in the database");
193
+ }
154
194
  for (const k of Object.keys(tree))
155
195
  if (!known.has(k))
156
196
  throw new OptionsError(`unknown key "${k}"`);
@@ -200,6 +240,28 @@ export function fromYaml(tree, env) {
200
240
  if (!a.includes("@"))
201
241
  throw new OptionsError(`admins are email addresses, not "${a}"`);
202
242
  }
243
+ const pages = section(tree, "pages");
244
+ if (pages) {
245
+ o.pages = {};
246
+ for (const key of Object.keys(pages)) {
247
+ if (!PAGE_KEYS.includes(key))
248
+ throw new OptionsError(`pages.${key}: the pages are ${PAGE_KEYS.join(" and ")}`);
249
+ const path = str(pages, key, env);
250
+ if (path === undefined)
251
+ continue;
252
+ const bad = validatePagePath(key, path);
253
+ if (bad)
254
+ throw new OptionsError(bad);
255
+ o.pages[key] = path;
256
+ }
257
+ }
258
+ const home = str(tree, "home", env);
259
+ if (home !== undefined) {
260
+ const bad = validateAppPath("home", home);
261
+ if (bad)
262
+ throw new OptionsError(bad);
263
+ o.home = home;
264
+ }
203
265
  const database = str(tree, "database", env);
204
266
  if (database)
205
267
  o.database = database;
@@ -239,17 +301,22 @@ export function fromYaml(tree, env) {
239
301
  o.email = e;
240
302
  }
241
303
  const perms = section(tree, "permissions");
242
- if (perms)
243
- o.permissions = roleMap(perms, env, "permissions");
244
- const orgs = section(tree, "organizations");
245
- if (orgs) {
246
- o.organizations = {};
247
- for (const [org, roles] of Object.entries(orgs)) {
248
- if (typeof roles !== "object")
249
- throw new OptionsError(`organizations.${org} must be a section of role: permissions`);
250
- o.organizations[org] = roleMap(roles, env, `organizations.${org}`);
304
+ if (perms) {
305
+ if (tree.roles === undefined && looksLikeRoleMap(Object.values(perms))) {
306
+ throw new OptionsError("permissions: now lists what the app gates, one `id: label` per line; the role → permissions map moved to roles:");
307
+ }
308
+ o.permissions = {};
309
+ for (const [id, v] of Object.entries(perms)) {
310
+ if (!PERMISSION_ID.test(id))
311
+ throw new OptionsError(`permissions.${id}: an id is words joined by dots, like invoices.create`);
312
+ if (typeof v === "object" && !isEmpty(v))
313
+ throw new OptionsError(`permissions.${id} must be a label, not a section`);
314
+ o.permissions[id] = typeof v === "string" && v.trim() ? interpolate(v, env, `permissions.${id}`) : id;
251
315
  }
252
316
  }
317
+ const roles = section(tree, "roles");
318
+ if (roles)
319
+ o.roles = roleMap(roles, env, "roles");
253
320
  return o;
254
321
  }
255
322
  function roleMap(tree, env, where) {
@@ -266,15 +333,15 @@ function roleMap(tree, env, where) {
266
333
  return map;
267
334
  }
268
335
  // ── merge ─────────────────────────────────────────────────────
269
- /** defaults ← file ← env. Maps (email, permissions, organizations) merge key by key. Pure. */
336
+ /** defaults ← file ← env. Maps (email, pages, permissions, roles) merge key by key. Pure. */
270
337
  export function resolveOptions(file, env) {
271
338
  const layers = [DEFAULTS, file ? fromYaml(file, env) : {}, fromEnv(env)];
272
- const out = { ...DEFAULTS, email: {}, permissions: {}, organizations: {} };
339
+ const out = { ...DEFAULTS, email: {}, pages: {}, permissions: {}, roles: {} };
273
340
  for (const layer of layers) {
274
341
  for (const [k, v] of Object.entries(layer)) {
275
342
  if (v === undefined)
276
343
  continue;
277
- if (k === "email" || k === "permissions" || k === "organizations")
344
+ if (k === "email" || k === "pages" || k === "permissions" || k === "roles")
278
345
  Object.assign(out[k], v);
279
346
  else
280
347
  out[k] = v;
@@ -1,6 +1,5 @@
1
1
  import type { User } from "./session.ts";
2
2
  import type { SessionRow } from "./db.ts";
3
- import type { Options } from "./options.ts";
4
3
  export type Organization = {
5
4
  id: string;
6
5
  name: string;
@@ -14,11 +13,21 @@ export type Session = {
14
13
  organization: Organization | null;
15
14
  authorization: Authorization;
16
15
  };
17
- export type PermissionSource = Pick<Options, "permissions" | "organizations">;
18
- /** The effective permissions of a role, in an organization. The organization's own map wins. Pure. */
19
- export declare function permissionsFor(role: string | null, org: string | null, opts: PermissionSource): string[];
16
+ /** The role whose permissions cannot be taken away */
17
+ export declare const OWNER = "owner";
18
+ /** What the database holds for a role: for this organization, and for every organization. Null = nothing saved there. */
19
+ export type StoredGrants = {
20
+ org?: string[] | null;
21
+ all?: string[] | null;
22
+ };
23
+ /** What the session builder needs besides the row */
24
+ export type PermissionSource = {
25
+ admins?: readonly string[];
26
+ };
27
+ /** The effective permissions of a role. This organization's rule, else the rule for all, else nothing. Owner: everything. Pure. */
28
+ export declare function permissionsFor(role: string | null, stored?: StoredGrants): string[];
20
29
  /** Build the contract from the store's row. Pure. */
21
- export declare function buildSession(row: SessionRow, opts: PermissionSource): Session;
30
+ export declare function buildSession(row: SessionRow, opts?: PermissionSource): Session;
22
31
  /**
23
32
  * The check. Pure. A granted entry matches when it is
24
33
  * "*" everything
package/dist/core/org.js CHANGED
@@ -7,35 +7,45 @@
7
7
  // organization { id, name } or null when the user is in none
8
8
  // authorization { role, permissions }
9
9
  //
10
- // Role comes from the membership row (members.role, free text;
11
- // the CLI defaults to "member"). Permissions are the app's business,
12
- // so they come from b17_auth.yaml (or AUTH_PERMISSIONS):
10
+ // Role comes from the membership row (members.role, free text; the CLI
11
+ // defaults to "member"). What a role may do comes from the database,
12
+ // the `roles` table, as set on /auth/admin:
13
13
  //
14
- // permissions: role → what it may do
15
- // owner: all "all" (or "*") is everything
16
- // member: blue.* green.read
17
- // organizations: overrides, by organization name
18
- // Acme:
19
- // member: blue.read
14
+ // scope = the organization's id that organization's own rule for the role
15
+ // scope = "" the rule for every organization without its own
20
16
  //
21
- // Unknown role, no role, or no map: no permissions. All pure; the
22
- // store feeds it one row.
17
+ // b17_auth.yaml never decides: `permissions:` declares what exists (the
18
+ // catalogue), `roles:` is written into the database once, at install,
19
+ // as the starting point. After that the database is the truth.
20
+ //
21
+ // Two roles are fixed: `owner` always holds everything (so an
22
+ // organization can never lock out its own owner), and an email under
23
+ // `admins:` holds everything (they can grant themselves anything on
24
+ // /auth/admin anyway, and the first account has no organization yet).
25
+ //
26
+ // Unknown role, no role, or nothing saved: no permissions. All pure;
27
+ // the store feeds it one row.
23
28
  // ─────────────────────────────────────────────────────────────
24
- /** The effective permissions of a role, in an organization. The organization's own map wins. Pure. */
25
- export function permissionsFor(role, org, opts) {
29
+ /** The role whose permissions cannot be taken away */
30
+ export const OWNER = "owner";
31
+ /** The effective permissions of a role. This organization's rule, else the rule for all, else nothing. Owner: everything. Pure. */
32
+ export function permissionsFor(role, stored = {}) {
26
33
  if (!role)
27
34
  return [];
28
- const own = org ? opts.organizations[org]?.[role] : undefined;
29
- return own ?? opts.permissions[role] ?? [];
35
+ if (role === OWNER)
36
+ return ["*"];
37
+ return stored.org ?? stored.all ?? [];
30
38
  }
31
39
  /** Build the contract from the store's row. Pure. */
32
- export function buildSession(row, opts) {
40
+ export function buildSession(row, opts = {}) {
33
41
  const organization = row.org_id && row.org_name !== null ? { id: row.org_id, name: row.org_name } : null;
34
42
  const role = organization ? row.role : null;
43
+ const admin = (opts.admins ?? []).includes(row.email.toLowerCase());
44
+ const permissions = admin ? ["*"] : permissionsFor(role, { org: row.stored_org, all: row.stored_all });
35
45
  return {
36
46
  user: { id: row.id, email: row.email },
37
47
  organization,
38
- authorization: { role, permissions: permissionsFor(role, organization?.name ?? null, opts) },
48
+ authorization: { role, permissions },
39
49
  };
40
50
  }
41
51
  /**
@@ -0,0 +1,34 @@
1
+ export declare const SIGN_IN_PATH = "/auth/sign-in";
2
+ export declare const SIGN_UP_PATH = "/auth/sign-up";
3
+ export declare const PAGE_KEYS: readonly ["signIn", "signUp"];
4
+ export type PageKey = (typeof PAGE_KEYS)[number];
5
+ /** App paths per page; a missing key means the package's own page */
6
+ export type Pages = Partial<Record<PageKey, string>>;
7
+ /** The app's path for a page, or undefined when the package serves it */
8
+ export declare function pageOverride(pages: Pages | undefined, key: PageKey): string | undefined;
9
+ /** Where a browser is sent for a page: the app's path if named, else the package's */
10
+ export declare function pagePath(pages: Pages | undefined, key: PageKey): string;
11
+ /** The app's pages, for the proxy: they must open without a session */
12
+ export declare function appPages(pages: Pages | undefined): string[];
13
+ /**
14
+ * An app path is an absolute path on this site, outside /auth/ (which the
15
+ * package serves), with no pattern, query or fragment. Error text or null.
16
+ */
17
+ export declare function validateAppPath(key: string, path: string): string | null;
18
+ export declare function validatePagePath(key: string, path: string): string | null;
19
+ /**
20
+ * Where a signed-in visitor lands when nothing says otherwise: `home:` from
21
+ * b17_auth.yaml (the app's signed-in home, say /app), else the site root.
22
+ * after sign-in with no `next`, or `next` reduced to "/" by safeNext
23
+ * the admin page's "app" link
24
+ * a signed-in visitor opening "/" (the proxy sends them home: the root is
25
+ * the public site, the app is where they were going)
26
+ */
27
+ export declare function landing(next: string | null | undefined, home: string | undefined): string;
28
+ /** Should the proxy send this signed-in request home? Only the root, only when home is elsewhere. */
29
+ export declare function sendsHome(pathname: string, home: string | undefined): boolean;
30
+ /**
31
+ * The same URL on the app's page: GET /auth/sign-in?next=/x&error=1 becomes
32
+ * /sign-in?next=/x&error=1, so a bookmark or an old link keeps working.
33
+ */
34
+ export declare function movedTo(path: string, search: string): string;