@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.
- package/AGENTS.md +29 -13
- package/CHANGELOG.md +18 -0
- package/PROMPT.md +4 -3
- package/README.md +78 -21
- package/dist/cli.js +22 -6
- package/dist/core/admin-html.d.ts +8 -2
- package/dist/core/admin-html.js +133 -35
- package/dist/core/admin.d.ts +35 -5
- package/dist/core/admin.js +49 -7
- package/dist/core/config.d.ts +2 -2
- package/dist/core/config.js +2 -2
- package/dist/core/db.d.ts +25 -1
- package/dist/core/db.js +62 -1
- package/dist/core/guard.d.ts +4 -1
- package/dist/core/guard.js +4 -1
- package/dist/core/html.d.ts +8 -1
- package/dist/core/html.js +26 -13
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +24 -5
- package/dist/core/options.js +89 -22
- package/dist/core/org.d.ts +14 -5
- package/dist/core/org.js +27 -17
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/signup.d.ts +4 -0
- package/dist/core/signup.js +7 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/next/index.js +250 -56
- package/dist/next/proxy.js +14 -6
- package/dist/react/index.d.ts +55 -1
- package/dist/react/index.js +54 -2
- package/package.json +3 -2
package/dist/core/guard.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/core/html.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/core/options.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
40
|
-
permissions: Record<string, string
|
|
41
|
-
/**
|
|
42
|
-
|
|
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,
|
|
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
|
package/dist/core/options.js
CHANGED
|
@@ -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
|
|
27
|
-
permissions: {
|
|
28
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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", "
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
o.
|
|
247
|
-
for (const [
|
|
248
|
-
if (
|
|
249
|
-
throw new OptionsError(`
|
|
250
|
-
|
|
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,
|
|
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: {},
|
|
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 === "
|
|
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;
|
package/dist/core/org.d.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
|
25
|
-
export
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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;
|