@hyperfixation/auth 0.1.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/LICENSE +21 -0
- package/dist/bootstrap.d.ts +48 -0
- package/dist/bootstrap.js +109 -0
- package/dist/factory.d.ts +3372 -0
- package/dist/factory.js +81 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/policy.d.ts +65 -0
- package/dist/policy.js +68 -0
- package/dist/require-session.d.ts +34 -0
- package/dist/require-session.js +42 -0
- package/dist/reset-second-factor.d.ts +37 -0
- package/dist/reset-second-factor.js +60 -0
- package/dist/session.d.ts +34 -0
- package/dist/session.js +31 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Graham Lutz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
/** The designated address, when the deploy names one instead of relying on "first user". */
|
|
3
|
+
export declare const BOOTSTRAP_EMAIL_ENV = "HF_BOOTSTRAP_EMAIL";
|
|
4
|
+
/** One line when an app gets its first admin; it should happen exactly once per app. */
|
|
5
|
+
export declare const BOOTSTRAPPED_MARKER = "hf-auth: bootstrapped";
|
|
6
|
+
export type BootstrapRefusal = "admin-exists" | "not-designated" | "not-first-user" | "no-designation";
|
|
7
|
+
/** Refusals are by reason, because the CLI prints the reason and each has a different fix. */
|
|
8
|
+
export declare class BootstrapRefused extends Error {
|
|
9
|
+
readonly reason: BootstrapRefusal;
|
|
10
|
+
constructor(reason: BootstrapRefusal, message: string);
|
|
11
|
+
}
|
|
12
|
+
export interface BootstrapAdminOptions {
|
|
13
|
+
/**
|
|
14
|
+
* The address to bootstrap. Defaults to the designation in force — `designatedEmail`, else
|
|
15
|
+
* `HF_BOOTSTRAP_EMAIL` — so a deploy that names its owner need not name it twice. Neither is
|
|
16
|
+
* a `no-designation` refusal, not a crash.
|
|
17
|
+
*/
|
|
18
|
+
email?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Overrides `HF_BOOTSTRAP_EMAIL`. Pass `null` to run the first-user branch with the
|
|
22
|
+
* environment ignored.
|
|
23
|
+
*/
|
|
24
|
+
designatedEmail?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface BootstrapResult {
|
|
27
|
+
userId: string;
|
|
28
|
+
email: string;
|
|
29
|
+
/** False when the row already existed and only the role was granted. */
|
|
30
|
+
created: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The one way an app gets its first admin, and the answer to the chicken-and-egg: granting
|
|
34
|
+
* `admin` needs an admin, so this runs **outside the request path entirely** — `hf bootstrap`,
|
|
35
|
+
* on the box, against the database. It is not an endpoint, it is not reachable from the
|
|
36
|
+
* better-auth handler, and there is no self-serve path to it; `disableSignUp: true` means there
|
|
37
|
+
* is no self-serve path to a user at all.
|
|
38
|
+
*
|
|
39
|
+
* Its "one-time" is enforced, not documented: it refuses the moment any user holds `admin`,
|
|
40
|
+
* so a second run on a live app is a refusal rather than a second grant. After that, admins are
|
|
41
|
+
* made by admins.
|
|
42
|
+
*
|
|
43
|
+
* Two branches, both narrow. With `HF_BOOTSTRAP_EMAIL` set, only that address may be
|
|
44
|
+
* bootstrapped — a deploy designating its owner. With it unset, only the first user of an empty
|
|
45
|
+
* `hf_user` may be. Unset **and** a populated table is refused rather than guessed at: that is
|
|
46
|
+
* the state where "the first user" has no meaning and picking one would be inventing an admin.
|
|
47
|
+
*/
|
|
48
|
+
export declare function bootstrapAdmin(pool: Pool, options: BootstrapAdminOptions): Promise<BootstrapResult>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ADMIN_ROLE } from "./policy.js";
|
|
3
|
+
/** The designated address, when the deploy names one instead of relying on "first user". */
|
|
4
|
+
export const BOOTSTRAP_EMAIL_ENV = "HF_BOOTSTRAP_EMAIL";
|
|
5
|
+
/** One line when an app gets its first admin; it should happen exactly once per app. */
|
|
6
|
+
export const BOOTSTRAPPED_MARKER = "hf-auth: bootstrapped";
|
|
7
|
+
/**
|
|
8
|
+
* Serialises the whole check-then-write against another `hf bootstrap` running at the same
|
|
9
|
+
* time. The counts this reads are otherwise unlocked, so "no admin exists yet" would be true in
|
|
10
|
+
* two transactions at once and both would grant.
|
|
11
|
+
*/
|
|
12
|
+
const BOOTSTRAP_LOCK_STATEMENT = "SELECT pg_advisory_xact_lock(hashtext('hf-auth:bootstrap'))";
|
|
13
|
+
/** `hasRole(user, 'admin')` in SQL: the column is one comma-separated list, not one role. */
|
|
14
|
+
const ADMIN_EXISTS_STATEMENT = "SELECT id, email FROM hf_user WHERE role IS NOT NULL AND lower(role) ~ '(^|,)\\s*admin\\s*($|,)' LIMIT 1";
|
|
15
|
+
const USER_COUNT_STATEMENT = "SELECT count(*)::int AS count FROM hf_user";
|
|
16
|
+
const USER_BY_EMAIL_STATEMENT = "SELECT id, role FROM hf_user WHERE lower(email) = lower($1)";
|
|
17
|
+
const INSERT_USER_STATEMENT = "INSERT INTO hf_user (id, name, email, email_verified, role) VALUES ($1, $2, $3, false, $4)";
|
|
18
|
+
const GRANT_ROLE_STATEMENT = "UPDATE hf_user SET role = $2, updated_at = now() WHERE id = $1";
|
|
19
|
+
const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
|
|
20
|
+
"VALUES (NULL, 'auth.bootstrapped', 'user', $1, $2::jsonb)";
|
|
21
|
+
/** Refusals are by reason, because the CLI prints the reason and each has a different fix. */
|
|
22
|
+
export class BootstrapRefused extends Error {
|
|
23
|
+
reason;
|
|
24
|
+
constructor(reason, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "BootstrapRefused";
|
|
27
|
+
this.reason = reason;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The one way an app gets its first admin, and the answer to the chicken-and-egg: granting
|
|
32
|
+
* `admin` needs an admin, so this runs **outside the request path entirely** — `hf bootstrap`,
|
|
33
|
+
* on the box, against the database. It is not an endpoint, it is not reachable from the
|
|
34
|
+
* better-auth handler, and there is no self-serve path to it; `disableSignUp: true` means there
|
|
35
|
+
* is no self-serve path to a user at all.
|
|
36
|
+
*
|
|
37
|
+
* Its "one-time" is enforced, not documented: it refuses the moment any user holds `admin`,
|
|
38
|
+
* so a second run on a live app is a refusal rather than a second grant. After that, admins are
|
|
39
|
+
* made by admins.
|
|
40
|
+
*
|
|
41
|
+
* Two branches, both narrow. With `HF_BOOTSTRAP_EMAIL` set, only that address may be
|
|
42
|
+
* bootstrapped — a deploy designating its owner. With it unset, only the first user of an empty
|
|
43
|
+
* `hf_user` may be. Unset **and** a populated table is refused rather than guessed at: that is
|
|
44
|
+
* the state where "the first user" has no meaning and picking one would be inventing an admin.
|
|
45
|
+
*/
|
|
46
|
+
export async function bootstrapAdmin(pool, options) {
|
|
47
|
+
const designated = options.designatedEmail === undefined
|
|
48
|
+
? (process.env[BOOTSTRAP_EMAIL_ENV] ?? null)
|
|
49
|
+
: options.designatedEmail;
|
|
50
|
+
const email = trimmed(options.email) ?? trimmed(designated);
|
|
51
|
+
if (email === undefined) {
|
|
52
|
+
throw new BootstrapRefused("no-designation", `no address to bootstrap: pass an email or set ${BOOTSTRAP_EMAIL_ENV}`);
|
|
53
|
+
}
|
|
54
|
+
const client = await pool.connect();
|
|
55
|
+
try {
|
|
56
|
+
await client.query("BEGIN");
|
|
57
|
+
await client.query(BOOTSTRAP_LOCK_STATEMENT);
|
|
58
|
+
const result = await bootstrap(client, email, options.name ?? email, designated);
|
|
59
|
+
await client.query("COMMIT");
|
|
60
|
+
console.info(BOOTSTRAPPED_MARKER, JSON.stringify(result));
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
await client.query("ROLLBACK");
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
client.release();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function trimmed(value) {
|
|
72
|
+
const text = value?.trim();
|
|
73
|
+
return text === undefined || text === "" ? undefined : text;
|
|
74
|
+
}
|
|
75
|
+
async function bootstrap(client, email, name, designated) {
|
|
76
|
+
const existingAdmin = await client.query(ADMIN_EXISTS_STATEMENT);
|
|
77
|
+
if (existingAdmin.rows.length > 0) {
|
|
78
|
+
throw new BootstrapRefused("admin-exists", `${existingAdmin.rows[0].email} is already an admin; grant the role from the admin area instead`);
|
|
79
|
+
}
|
|
80
|
+
const user = await client.query(USER_BY_EMAIL_STATEMENT, [
|
|
81
|
+
email,
|
|
82
|
+
]);
|
|
83
|
+
if (designated !== null) {
|
|
84
|
+
if (designated.trim().toLowerCase() !== email.toLowerCase()) {
|
|
85
|
+
throw new BootstrapRefused("not-designated", `${BOOTSTRAP_EMAIL_ENV} designates ${designated.trim()}, not ${email}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const { rows } = await client.query(USER_COUNT_STATEMENT);
|
|
90
|
+
const users = rows[0].count;
|
|
91
|
+
if (users > 0 && user.rows.length === 0) {
|
|
92
|
+
throw new BootstrapRefused("not-first-user", `${users} user(s) already exist and none is an admin; set ${BOOTSTRAP_EMAIL_ENV} to name the one to promote`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const existing = user.rows[0];
|
|
96
|
+
const userId = existing?.id ?? randomUUID();
|
|
97
|
+
if (existing === undefined) {
|
|
98
|
+
await client.query(INSERT_USER_STATEMENT, [userId, name, email, ADMIN_ROLE]);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await client.query(GRANT_ROLE_STATEMENT, [userId, ADMIN_ROLE]);
|
|
102
|
+
}
|
|
103
|
+
const result = { userId, email, created: existing === undefined };
|
|
104
|
+
await client.query(AUDIT_STATEMENT, [
|
|
105
|
+
userId,
|
|
106
|
+
JSON.stringify({ email, created: result.created, designated: designated !== null }),
|
|
107
|
+
]);
|
|
108
|
+
return result;
|
|
109
|
+
}
|