@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.
- package/AGENTS.md +155 -0
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +27 -16
- package/PROMPT.md +325 -36
- package/README.md +338 -53
- package/dist/cli.js +248 -17
- package/dist/core/admin-html.d.ts +32 -0
- package/dist/core/admin-html.js +350 -0
- package/dist/core/admin.d.ts +56 -0
- package/dist/core/admin.js +90 -0
- package/dist/core/config.d.ts +10 -3
- package/dist/core/config.js +22 -8
- package/dist/core/db.d.ts +155 -10
- package/dist/core/db.js +449 -69
- package/dist/core/email.d.ts +49 -0
- package/dist/core/email.js +213 -0
- package/dist/core/guard.d.ts +52 -0
- package/dist/core/guard.js +106 -0
- package/dist/core/html.d.ts +43 -0
- package/dist/core/html.js +122 -28
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +95 -0
- package/dist/core/options.js +377 -0
- package/dist/core/org.d.ts +43 -0
- package/dist/core/org.js +65 -0
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/password.d.ts +16 -0
- package/dist/core/password.js +41 -12
- package/dist/core/rate.d.ts +26 -0
- package/dist/core/rate.js +70 -0
- package/dist/core/reset.d.ts +9 -0
- package/dist/core/reset.js +31 -0
- package/dist/core/session.d.ts +11 -1
- package/dist/core/session.js +27 -7
- package/dist/core/signup.d.ts +13 -6
- package/dist/core/signup.js +30 -15
- package/dist/core/tokens.d.ts +29 -0
- package/dist/core/tokens.js +64 -0
- package/dist/core/verify.d.ts +8 -0
- package/dist/core/verify.js +33 -0
- package/dist/core/yaml.d.ts +10 -0
- package/dist/core/yaml.js +85 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +18 -6
- package/dist/next/index.d.ts +10 -3
- package/dist/next/index.js +649 -63
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +22 -10
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +107 -0
- package/package.json +18 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type YamlTree } from "./yaml.ts";
|
|
2
|
+
import { type Pages } from "./pages.ts";
|
|
3
|
+
export declare const CONFIG_FILE = "b17_auth.yaml";
|
|
4
|
+
export type SignupMode = "open" | "closed" | "invite";
|
|
5
|
+
export type EmailOptions = {
|
|
6
|
+
from?: string;
|
|
7
|
+
replyTo?: string;
|
|
8
|
+
gmail?: {
|
|
9
|
+
/** the mailbox to send as */
|
|
10
|
+
user: string;
|
|
11
|
+
/** service account (Workspace, domain-wide delegation): its email and private key */
|
|
12
|
+
clientEmail?: string;
|
|
13
|
+
privateKey?: string;
|
|
14
|
+
/** or an OAuth client with a refresh token (a plain Gmail account) */
|
|
15
|
+
clientId?: string;
|
|
16
|
+
clientSecret?: string;
|
|
17
|
+
refreshToken?: string;
|
|
18
|
+
};
|
|
19
|
+
resend?: {
|
|
20
|
+
apiKey: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type Options = {
|
|
24
|
+
/** App name: page titles and email subjects. Falls back to the request's host. */
|
|
25
|
+
name?: string;
|
|
26
|
+
signup: SignupMode;
|
|
27
|
+
inviteCode?: string;
|
|
28
|
+
emailDomain?: string;
|
|
29
|
+
requireVerified: boolean;
|
|
30
|
+
sessionDays: number;
|
|
31
|
+
/** Paths that need a session, exact or `prefix/*`; everything else is open. Use this OR `public`. */
|
|
32
|
+
protected: string[];
|
|
33
|
+
/** The reverse: everything needs a session except these (and static files). */
|
|
34
|
+
public: string[];
|
|
35
|
+
/** Emails that may open /auth/admin. Lowercased. */
|
|
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;
|
|
41
|
+
database?: string;
|
|
42
|
+
secret?: string;
|
|
43
|
+
email: EmailOptions;
|
|
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[]>;
|
|
53
|
+
production: boolean;
|
|
54
|
+
};
|
|
55
|
+
export declare const DEFAULTS: Options;
|
|
56
|
+
/** Keys whose value must be a ${…} reference in the file, never a literal */
|
|
57
|
+
export declare const SECRET_KEYS: string[];
|
|
58
|
+
export type Env = Record<string, string | undefined>;
|
|
59
|
+
/** The env vars as they were before the file existed. Pure. */
|
|
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;
|
|
70
|
+
/** "all" is the file's word for "*"; duplicates dropped. */
|
|
71
|
+
export declare function normalizePermissions(list: string[]): string[];
|
|
72
|
+
export declare class OptionsError extends Error {
|
|
73
|
+
constructor(message: string);
|
|
74
|
+
}
|
|
75
|
+
/** Replace every ${NAME} in a value from env. A missing variable is an error naming both. */
|
|
76
|
+
export declare function interpolate(value: string, env: Env, key: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* Literal secrets in a committed file are how keys leak. Returns the keys
|
|
79
|
+
* that hold a literal where a ${…} reference belongs. Pure.
|
|
80
|
+
*/
|
|
81
|
+
export declare function literalSecrets(tree: YamlTree): string[];
|
|
82
|
+
/** The file's contents as options, ${…} resolved. Pure. */
|
|
83
|
+
export declare function fromYaml(tree: YamlTree, env: Env): Partial<Options>;
|
|
84
|
+
/** defaults ← file ← env. Maps (email, pages, permissions, roles) merge key by key. Pure. */
|
|
85
|
+
export declare function resolveOptions(file: YamlTree | null, env: Env): Options;
|
|
86
|
+
/**
|
|
87
|
+
* Read b17_auth.yaml from the project root (the directory the server was
|
|
88
|
+
* started in), or from AUTH_CONFIG when that is set, and merge with env.
|
|
89
|
+
* Memoized; re-read in development when the file changes.
|
|
90
|
+
*/
|
|
91
|
+
export declare function loadOptions(cwd?: string, env?: Env): Options;
|
|
92
|
+
/** The current options. What every handler reads. */
|
|
93
|
+
export declare function options(): Options;
|
|
94
|
+
/** Drop the memo (tests, CLI after writing the file). */
|
|
95
|
+
export declare function resetOptions(): void;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// One typed object for everything configurable, built from three
|
|
3
|
+
// layers, each overriding the one before:
|
|
4
|
+
//
|
|
5
|
+
// DEFAULTS what the package does with no configuration
|
|
6
|
+
// b17_auth.yaml the app's choices, with ${NAME} pulled from env
|
|
7
|
+
// env vars the deploy-time override, and the only home of secrets
|
|
8
|
+
//
|
|
9
|
+
// No file → the env vars work exactly as they always did. The pure
|
|
10
|
+
// part is resolveOptions(); loadOptions() adds the file read and a
|
|
11
|
+
// per-process memo (re-read in development when the file changes).
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { parseYaml, words } from "./yaml.js";
|
|
16
|
+
import { PAGE_KEYS, validateAppPath, validatePagePath } from "./pages.js";
|
|
17
|
+
export const CONFIG_FILE = "b17_auth.yaml";
|
|
18
|
+
export const DEFAULTS = {
|
|
19
|
+
signup: "closed",
|
|
20
|
+
requireVerified: false,
|
|
21
|
+
sessionDays: 7,
|
|
22
|
+
protected: [],
|
|
23
|
+
public: [],
|
|
24
|
+
admins: [],
|
|
25
|
+
pages: {},
|
|
26
|
+
email: {},
|
|
27
|
+
// An owner can do everything unless a file says otherwise. A sole
|
|
28
|
+
// developer never has to write a roles section at all.
|
|
29
|
+
permissions: {},
|
|
30
|
+
roles: { owner: ["*"] },
|
|
31
|
+
production: false,
|
|
32
|
+
};
|
|
33
|
+
/** Keys whose value must be a ${…} reference in the file, never a literal */
|
|
34
|
+
export const SECRET_KEYS = ["database", "secret", "inviteCode", "email.gmail.privateKey", "email.gmail.clientSecret", "email.gmail.refreshToken", "email.resend.apiKey"];
|
|
35
|
+
// ── env layer ─────────────────────────────────────────────────
|
|
36
|
+
const truthy = (v) => /^(true|1|yes)$/i.test(v ?? "");
|
|
37
|
+
/** The env vars as they were before the file existed. Pure. */
|
|
38
|
+
export function fromEnv(env) {
|
|
39
|
+
const o = {};
|
|
40
|
+
if (env.AUTH_SIGNUP === "open")
|
|
41
|
+
o.signup = env.AUTH_INVITE_CODE ? "invite" : "open";
|
|
42
|
+
if (env.AUTH_INVITE_CODE)
|
|
43
|
+
o.inviteCode = env.AUTH_INVITE_CODE;
|
|
44
|
+
if (env.AUTH_EMAIL_DOMAIN)
|
|
45
|
+
o.emailDomain = env.AUTH_EMAIL_DOMAIN;
|
|
46
|
+
if (env.AUTH_REQUIRE_VERIFIED !== undefined)
|
|
47
|
+
o.requireVerified = truthy(env.AUTH_REQUIRE_VERIFIED);
|
|
48
|
+
if (env.AUTH_PROTECTED)
|
|
49
|
+
o.protected = words(env.AUTH_PROTECTED);
|
|
50
|
+
if (env.AUTH_PUBLIC)
|
|
51
|
+
o.public = words(env.AUTH_PUBLIC);
|
|
52
|
+
if (env.AUTH_ADMINS)
|
|
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
|
+
}
|
|
72
|
+
if (env.DATABASE_URL)
|
|
73
|
+
o.database = env.DATABASE_URL;
|
|
74
|
+
if (env.AUTH_SECRET)
|
|
75
|
+
o.secret = env.AUTH_SECRET;
|
|
76
|
+
const email = {};
|
|
77
|
+
if (env.AUTH_EMAIL_FROM)
|
|
78
|
+
email.from = env.AUTH_EMAIL_FROM;
|
|
79
|
+
if (env.AUTH_EMAIL_REPLY_TO)
|
|
80
|
+
email.replyTo = env.AUTH_EMAIL_REPLY_TO;
|
|
81
|
+
if (env.AUTH_GMAIL_USER) {
|
|
82
|
+
email.gmail = {
|
|
83
|
+
user: env.AUTH_GMAIL_USER,
|
|
84
|
+
clientEmail: env.AUTH_GMAIL_CLIENT_EMAIL,
|
|
85
|
+
privateKey: env.AUTH_GMAIL_PRIVATE_KEY,
|
|
86
|
+
clientId: env.AUTH_GMAIL_CLIENT_ID,
|
|
87
|
+
clientSecret: env.AUTH_GMAIL_CLIENT_SECRET,
|
|
88
|
+
refreshToken: env.AUTH_GMAIL_REFRESH_TOKEN,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (env.AUTH_RESEND_API_KEY)
|
|
92
|
+
email.resend = { apiKey: env.AUTH_RESEND_API_KEY };
|
|
93
|
+
if (Object.keys(email).length)
|
|
94
|
+
o.email = email;
|
|
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");
|
|
99
|
+
o.production = env.NODE_ENV === "production";
|
|
100
|
+
return o;
|
|
101
|
+
}
|
|
102
|
+
function rolesFromJson(raw, name) {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error(`${name} is not valid JSON. Expected: {"admin":["invoices.view"],"member":[]}`);
|
|
109
|
+
}
|
|
110
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
111
|
+
throw new Error(`${name} must be a JSON object mapping role → list of permissions`);
|
|
112
|
+
}
|
|
113
|
+
const map = {};
|
|
114
|
+
for (const [role, perms] of Object.entries(parsed)) {
|
|
115
|
+
if (!Array.isArray(perms) || !perms.every((p) => typeof p === "string")) {
|
|
116
|
+
throw new Error(`${name}: role "${role}" must map to a list of strings`);
|
|
117
|
+
}
|
|
118
|
+
map[role] = normalizePermissions(perms);
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
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
|
+
}
|
|
137
|
+
/** "all" is the file's word for "*"; duplicates dropped. */
|
|
138
|
+
export function normalizePermissions(list) {
|
|
139
|
+
return [...new Set(list.map((p) => (p === "all" ? "*" : p)))];
|
|
140
|
+
}
|
|
141
|
+
// ── file layer ────────────────────────────────────────────────
|
|
142
|
+
export class OptionsError extends Error {
|
|
143
|
+
constructor(message) {
|
|
144
|
+
super(`${CONFIG_FILE}: ${message}`);
|
|
145
|
+
this.name = "OptionsError";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Replace every ${NAME} in a value from env. A missing variable is an error naming both. */
|
|
149
|
+
export function interpolate(value, env, key) {
|
|
150
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
|
|
151
|
+
const v = env[name];
|
|
152
|
+
if (v === undefined || v === "")
|
|
153
|
+
throw new OptionsError(`${key} refers to \${${name}}, which is not set in the environment`);
|
|
154
|
+
return v;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const isRef = (v) => typeof v === "string" && /^\$\{[A-Z0-9_]+\}$/i.test(v.trim());
|
|
158
|
+
/**
|
|
159
|
+
* Literal secrets in a committed file are how keys leak. Returns the keys
|
|
160
|
+
* that hold a literal where a ${…} reference belongs. Pure.
|
|
161
|
+
*/
|
|
162
|
+
export function literalSecrets(tree) {
|
|
163
|
+
const get = (path) => path.split(".").reduce((n, k) => (n && typeof n === "object" ? n[k] : undefined), tree);
|
|
164
|
+
return SECRET_KEYS.filter((k) => {
|
|
165
|
+
const v = get(k);
|
|
166
|
+
return typeof v === "string" && v.trim() !== "" && !isRef(v);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** `key:` with nothing after it and nothing under it: the same as leaving the key out */
|
|
170
|
+
const isEmpty = (v) => typeof v === "object" && v !== null && Object.keys(v).length === 0;
|
|
171
|
+
function str(tree, key, env) {
|
|
172
|
+
const v = tree[key];
|
|
173
|
+
if (v === undefined || isEmpty(v))
|
|
174
|
+
return undefined;
|
|
175
|
+
if (typeof v !== "string")
|
|
176
|
+
throw new OptionsError(`${key} must be a value, not a section`);
|
|
177
|
+
return interpolate(v, env, key);
|
|
178
|
+
}
|
|
179
|
+
function section(tree, key) {
|
|
180
|
+
const v = tree[key];
|
|
181
|
+
if (v === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
if (typeof v !== "object")
|
|
184
|
+
throw new OptionsError(`${key} must be a section with indented keys under it`);
|
|
185
|
+
return v;
|
|
186
|
+
}
|
|
187
|
+
/** The file's contents as options, ${…} resolved. Pure. */
|
|
188
|
+
export function fromYaml(tree, env) {
|
|
189
|
+
const o = {};
|
|
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
|
+
}
|
|
194
|
+
for (const k of Object.keys(tree))
|
|
195
|
+
if (!known.has(k))
|
|
196
|
+
throw new OptionsError(`unknown key "${k}"`);
|
|
197
|
+
const name = str(tree, "name", env);
|
|
198
|
+
if (name)
|
|
199
|
+
o.name = name;
|
|
200
|
+
const signup = str(tree, "signup", env);
|
|
201
|
+
if (signup !== undefined) {
|
|
202
|
+
if (!["open", "closed", "invite"].includes(signup))
|
|
203
|
+
throw new OptionsError(`signup must be open, closed or invite, not "${signup}"`);
|
|
204
|
+
o.signup = signup;
|
|
205
|
+
}
|
|
206
|
+
const invite = str(tree, "inviteCode", env);
|
|
207
|
+
if (invite)
|
|
208
|
+
o.inviteCode = invite;
|
|
209
|
+
const domain = str(tree, "emailDomain", env);
|
|
210
|
+
if (domain)
|
|
211
|
+
o.emailDomain = domain;
|
|
212
|
+
const rv = str(tree, "requireVerified", env);
|
|
213
|
+
if (rv !== undefined) {
|
|
214
|
+
if (!/^(true|false)$/i.test(rv))
|
|
215
|
+
throw new OptionsError(`requireVerified must be true or false`);
|
|
216
|
+
o.requireVerified = truthy(rv);
|
|
217
|
+
}
|
|
218
|
+
const days = str(tree, "sessionDays", env);
|
|
219
|
+
if (days !== undefined) {
|
|
220
|
+
if (!/^\d+$/.test(days) || Number(days) < 1)
|
|
221
|
+
throw new OptionsError(`sessionDays must be a whole number of days`);
|
|
222
|
+
o.sessionDays = Number(days);
|
|
223
|
+
}
|
|
224
|
+
for (const key of ["protected", "public"]) {
|
|
225
|
+
const v = str(tree, key, env);
|
|
226
|
+
if (v === undefined)
|
|
227
|
+
continue;
|
|
228
|
+
o[key] = words(v);
|
|
229
|
+
for (const p of o[key])
|
|
230
|
+
if (!p.startsWith("/"))
|
|
231
|
+
throw new OptionsError(`${key} paths start with /, not "${p}"`);
|
|
232
|
+
}
|
|
233
|
+
if (o.protected?.length && o.public?.length) {
|
|
234
|
+
throw new OptionsError("use either protected: (only these need sign-in) or public: (everything but these), not both");
|
|
235
|
+
}
|
|
236
|
+
const admins = str(tree, "admins", env);
|
|
237
|
+
if (admins !== undefined) {
|
|
238
|
+
o.admins = words(admins).map((e) => e.toLowerCase());
|
|
239
|
+
for (const a of o.admins)
|
|
240
|
+
if (!a.includes("@"))
|
|
241
|
+
throw new OptionsError(`admins are email addresses, not "${a}"`);
|
|
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
|
+
}
|
|
265
|
+
const database = str(tree, "database", env);
|
|
266
|
+
if (database)
|
|
267
|
+
o.database = database;
|
|
268
|
+
const secret = str(tree, "secret", env);
|
|
269
|
+
if (secret)
|
|
270
|
+
o.secret = secret;
|
|
271
|
+
const email = section(tree, "email");
|
|
272
|
+
if (email) {
|
|
273
|
+
const e = {};
|
|
274
|
+
const from = str(email, "from", env);
|
|
275
|
+
if (from)
|
|
276
|
+
e.from = from;
|
|
277
|
+
const replyTo = str(email, "replyTo", env);
|
|
278
|
+
if (replyTo)
|
|
279
|
+
e.replyTo = replyTo;
|
|
280
|
+
const gmail = section(email, "gmail");
|
|
281
|
+
if (gmail) {
|
|
282
|
+
const user = str(gmail, "user", env);
|
|
283
|
+
if (!user)
|
|
284
|
+
throw new OptionsError("email.gmail needs `user: <mailbox to send as>`");
|
|
285
|
+
e.gmail = {
|
|
286
|
+
user,
|
|
287
|
+
clientEmail: str(gmail, "clientEmail", env),
|
|
288
|
+
privateKey: str(gmail, "privateKey", env),
|
|
289
|
+
clientId: str(gmail, "clientId", env),
|
|
290
|
+
clientSecret: str(gmail, "clientSecret", env),
|
|
291
|
+
refreshToken: str(gmail, "refreshToken", env),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const resend = section(email, "resend");
|
|
295
|
+
if (resend) {
|
|
296
|
+
const apiKey = str(resend, "apiKey", env);
|
|
297
|
+
if (!apiKey)
|
|
298
|
+
throw new OptionsError("email.resend needs `apiKey: ${AUTH_RESEND_API_KEY}`");
|
|
299
|
+
e.resend = { apiKey };
|
|
300
|
+
}
|
|
301
|
+
o.email = e;
|
|
302
|
+
}
|
|
303
|
+
const perms = section(tree, "permissions");
|
|
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;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const roles = section(tree, "roles");
|
|
318
|
+
if (roles)
|
|
319
|
+
o.roles = roleMap(roles, env, "roles");
|
|
320
|
+
return o;
|
|
321
|
+
}
|
|
322
|
+
function roleMap(tree, env, where) {
|
|
323
|
+
const map = {};
|
|
324
|
+
for (const [role, v] of Object.entries(tree)) {
|
|
325
|
+
if (isEmpty(v)) {
|
|
326
|
+
map[role] = []; // `member:` with nothing: a role that exists and may do nothing yet
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (typeof v !== "string")
|
|
330
|
+
throw new OptionsError(`${where}.${role} must be a list of permissions separated by spaces`);
|
|
331
|
+
map[role] = normalizePermissions(words(interpolate(v, env, `${where}.${role}`)));
|
|
332
|
+
}
|
|
333
|
+
return map;
|
|
334
|
+
}
|
|
335
|
+
// ── merge ─────────────────────────────────────────────────────
|
|
336
|
+
/** defaults ← file ← env. Maps (email, pages, permissions, roles) merge key by key. Pure. */
|
|
337
|
+
export function resolveOptions(file, env) {
|
|
338
|
+
const layers = [DEFAULTS, file ? fromYaml(file, env) : {}, fromEnv(env)];
|
|
339
|
+
const out = { ...DEFAULTS, email: {}, pages: {}, permissions: {}, roles: {} };
|
|
340
|
+
for (const layer of layers) {
|
|
341
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
342
|
+
if (v === undefined)
|
|
343
|
+
continue;
|
|
344
|
+
if (k === "email" || k === "pages" || k === "permissions" || k === "roles")
|
|
345
|
+
Object.assign(out[k], v);
|
|
346
|
+
else
|
|
347
|
+
out[k] = v;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
// ── loading ───────────────────────────────────────────────────
|
|
353
|
+
let cache = null;
|
|
354
|
+
/**
|
|
355
|
+
* Read b17_auth.yaml from the project root (the directory the server was
|
|
356
|
+
* started in), or from AUTH_CONFIG when that is set, and merge with env.
|
|
357
|
+
* Memoized; re-read in development when the file changes.
|
|
358
|
+
*/
|
|
359
|
+
export function loadOptions(cwd = process.cwd(), env = process.env) {
|
|
360
|
+
const path = env.AUTH_CONFIG ? (env.AUTH_CONFIG.startsWith("/") ? env.AUTH_CONFIG : join(cwd, env.AUTH_CONFIG)) : join(cwd, CONFIG_FILE);
|
|
361
|
+
const exists = existsSync(path);
|
|
362
|
+
const mtime = exists ? statSync(path).mtimeMs : 0;
|
|
363
|
+
if (cache && cache.path === path && (cache.options.production || cache.mtime === mtime))
|
|
364
|
+
return cache.options;
|
|
365
|
+
const file = exists ? parseYaml(readFileSync(path, "utf8")) : null;
|
|
366
|
+
const options = resolveOptions(file, env);
|
|
367
|
+
cache = { options, path, mtime };
|
|
368
|
+
return options;
|
|
369
|
+
}
|
|
370
|
+
/** The current options. What every handler reads. */
|
|
371
|
+
export function options() {
|
|
372
|
+
return loadOptions();
|
|
373
|
+
}
|
|
374
|
+
/** Drop the memo (tests, CLI after writing the file). */
|
|
375
|
+
export function resetOptions() {
|
|
376
|
+
cache = null;
|
|
377
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { User } from "./session.ts";
|
|
2
|
+
import type { SessionRow } from "./db.ts";
|
|
3
|
+
export type Organization = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
};
|
|
7
|
+
export type Authorization = {
|
|
8
|
+
role: string | null;
|
|
9
|
+
permissions: string[];
|
|
10
|
+
};
|
|
11
|
+
export type Session = {
|
|
12
|
+
user: User;
|
|
13
|
+
organization: Organization | null;
|
|
14
|
+
authorization: Authorization;
|
|
15
|
+
};
|
|
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[];
|
|
29
|
+
/** Build the contract from the store's row. Pure. */
|
|
30
|
+
export declare function buildSession(row: SessionRow, opts?: PermissionSource): Session;
|
|
31
|
+
/**
|
|
32
|
+
* The check. Pure. A granted entry matches when it is
|
|
33
|
+
* "*" everything
|
|
34
|
+
* "invoices.*" any permission under that prefix ("invoices.view", "invoices.a.b")
|
|
35
|
+
* exact the permission itself
|
|
36
|
+
*/
|
|
37
|
+
export declare function hasPermission(session: Pick<Session, "authorization">, permission: string): boolean;
|
|
38
|
+
/** What a signed-out client sees from the React side. The HTTP endpoint answers 401 instead. */
|
|
39
|
+
export declare const NO_SESSION: {
|
|
40
|
+
user: null;
|
|
41
|
+
organization: null;
|
|
42
|
+
authorization: Authorization;
|
|
43
|
+
};
|
package/dist/core/org.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// The session contract: one JSON shape for "who is this, where are
|
|
3
|
+
// they, what may they do". Served by GET /auth/session and returned
|
|
4
|
+
// by getSession() on the server. Three keys, nothing else.
|
|
5
|
+
//
|
|
6
|
+
// user { id, email }
|
|
7
|
+
// organization { id, name } or null when the user is in none
|
|
8
|
+
// authorization { role, permissions }
|
|
9
|
+
//
|
|
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
|
+
//
|
|
14
|
+
// scope = the organization's id that organization's own rule for the role
|
|
15
|
+
// scope = "" the rule for every organization without its own
|
|
16
|
+
//
|
|
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.
|
|
28
|
+
// ─────────────────────────────────────────────────────────────
|
|
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 = {}) {
|
|
33
|
+
if (!role)
|
|
34
|
+
return [];
|
|
35
|
+
if (role === OWNER)
|
|
36
|
+
return ["*"];
|
|
37
|
+
return stored.org ?? stored.all ?? [];
|
|
38
|
+
}
|
|
39
|
+
/** Build the contract from the store's row. Pure. */
|
|
40
|
+
export function buildSession(row, opts = {}) {
|
|
41
|
+
const organization = row.org_id && row.org_name !== null ? { id: row.org_id, name: row.org_name } : null;
|
|
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 });
|
|
45
|
+
return {
|
|
46
|
+
user: { id: row.id, email: row.email },
|
|
47
|
+
organization,
|
|
48
|
+
authorization: { role, permissions },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The check. Pure. A granted entry matches when it is
|
|
53
|
+
* "*" everything
|
|
54
|
+
* "invoices.*" any permission under that prefix ("invoices.view", "invoices.a.b")
|
|
55
|
+
* exact the permission itself
|
|
56
|
+
*/
|
|
57
|
+
export function hasPermission(session, permission) {
|
|
58
|
+
return session.authorization.permissions.some((granted) => granted === "*" || granted === permission || (granted.endsWith(".*") && permission.startsWith(granted.slice(0, -1))));
|
|
59
|
+
}
|
|
60
|
+
/** What a signed-out client sees from the React side. The HTTP endpoint answers 401 instead. */
|
|
61
|
+
export const NO_SESSION = {
|
|
62
|
+
user: null,
|
|
63
|
+
organization: null,
|
|
64
|
+
authorization: { role: null, permissions: [] },
|
|
65
|
+
};
|
|
@@ -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;
|