create-safest-tools 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -11
- package/package.json +1 -1
- package/src/cli.mjs +12 -33
- package/src/config.mjs +4 -16
- package/template/README.md +9 -8
- package/template/console/auth/AccountJourney.tsx +2 -0
- package/template/console/auth/OperatorLogin.tsx +1 -13
- package/template/console/auth/OwnerSetup.tsx +110 -0
- package/template/console/auth/account-route.ts +3 -1
- package/template/console/shell/types.ts +1 -1
- package/template/migrations/0042_owner_setup_tokens.sql +29 -0
- package/template/package.json +4 -3
- package/template/public/console/auth-shell.js +3118 -2972
- package/template/reports.config.example.json +2 -5
- package/template/reports.schema.json +6 -13
- package/template/scripts/reports-deploy.mjs +5 -3
- package/template/scripts/reports-owner-setup.mjs +132 -0
- package/template/scripts/reports-plan.mjs +16 -28
- package/template/scripts/reports-uninstall-plan.mjs +2 -3
- package/template/src/index.ts +52 -15
- package/template/src/report-auth.ts +2 -93
- package/template/src/report-better-auth.ts +149 -1
- package/template/src/report-operator-accounts.ts +126 -39
- package/template/src/report-types.ts +1 -1
- package/template/worker-configuration.d.ts +2 -4
|
@@ -5,6 +5,9 @@ import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
|
|
|
5
5
|
|
|
6
6
|
const INVITATION_COOKIE = "safest_invitation";
|
|
7
7
|
const INVITATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
|
8
|
+
const OWNER_SETUP_COOKIE = "safest_owner_setup";
|
|
9
|
+
const OWNER_SETUP_RATE_LIMIT = 12;
|
|
10
|
+
const OWNER_SETUP_WINDOW_SECONDS = 10 * 60;
|
|
8
11
|
|
|
9
12
|
type AuthEmailBinding = {
|
|
10
13
|
send(message: {
|
|
@@ -52,6 +55,20 @@ type InvitationRow = {
|
|
|
52
55
|
expires_at: string;
|
|
53
56
|
};
|
|
54
57
|
|
|
58
|
+
export type OwnerSetupContext = {
|
|
59
|
+
id: string;
|
|
60
|
+
email: string;
|
|
61
|
+
purpose: "bootstrap" | "recovery";
|
|
62
|
+
expiresAt: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type OwnerSetupRow = {
|
|
66
|
+
id: string;
|
|
67
|
+
email: string;
|
|
68
|
+
purpose: "bootstrap" | "recovery";
|
|
69
|
+
expires_at: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
55
72
|
const encoder = new TextEncoder();
|
|
56
73
|
|
|
57
74
|
function bytesToBase64Url(bytes: Uint8Array): string {
|
|
@@ -116,6 +133,46 @@ async function invitationCookieValue(id: string, env: BetterAuthEnv): Promise<st
|
|
|
116
133
|
return `${id}.${await hmac(`${INVITATION_COOKIE}:${id}`, authSecret(env))}`;
|
|
117
134
|
}
|
|
118
135
|
|
|
136
|
+
async function ownerSetupCookieValue(id: string, env: BetterAuthEnv): Promise<string> {
|
|
137
|
+
return `${id}.${await hmac(`${OWNER_SETUP_COOKIE}:${id}`, authSecret(env))}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function signedCookieId(value: string | undefined): { id: string; signature: string } | null {
|
|
141
|
+
if (!value) return null;
|
|
142
|
+
const separator = value.lastIndexOf(".");
|
|
143
|
+
if (separator <= 0) return null;
|
|
144
|
+
const id = value.slice(0, separator);
|
|
145
|
+
const signature = value.slice(separator + 1);
|
|
146
|
+
return /^[0-9a-f-]{36}$/iu.test(id) ? { id, signature } : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function ownerSetupContextFromRequest(
|
|
150
|
+
request: Request | undefined,
|
|
151
|
+
env: BetterAuthEnv,
|
|
152
|
+
): Promise<OwnerSetupContext | null> {
|
|
153
|
+
const signed = signedCookieId(parseCookies(request?.headers.get("cookie") ?? null).get(OWNER_SETUP_COOKIE));
|
|
154
|
+
if (!signed) return null;
|
|
155
|
+
const expected = await hmac(`${OWNER_SETUP_COOKIE}:${signed.id}`, authSecret(env));
|
|
156
|
+
if (!constantTimeEqual(signed.signature, expected)) return null;
|
|
157
|
+
const row = await env.DB.prepare(`
|
|
158
|
+
SELECT id, email, purpose, expires_at
|
|
159
|
+
FROM owner_setup_tokens
|
|
160
|
+
WHERE id = ?1 AND status = 'pending' AND expires_at > ?2
|
|
161
|
+
LIMIT 1
|
|
162
|
+
`).bind(signed.id, new Date().toISOString()).first<OwnerSetupRow>();
|
|
163
|
+
return row ? { id: row.id, email: row.email, purpose: row.purpose, expiresAt: row.expires_at } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function ownerSetupAllowsEmail(
|
|
167
|
+
request: Request | undefined,
|
|
168
|
+
env: BetterAuthEnv,
|
|
169
|
+
emailValue: unknown,
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
const email = typeof emailValue === "string" ? emailValue.trim().toLowerCase() : "";
|
|
172
|
+
const setup = await ownerSetupContextFromRequest(request, env);
|
|
173
|
+
return setup?.purpose === "bootstrap" && setup.email.trim().toLowerCase() === email;
|
|
174
|
+
}
|
|
175
|
+
|
|
119
176
|
export async function invitationIdFromRequest(
|
|
120
177
|
request: Request | undefined,
|
|
121
178
|
env: BetterAuthEnv,
|
|
@@ -179,6 +236,7 @@ async function identityMayCreateUser(
|
|
|
179
236
|
"SELECT id FROM operator_users WHERE email = ?1 AND status = 'active' LIMIT 1",
|
|
180
237
|
).bind(email).first();
|
|
181
238
|
if (existing) return;
|
|
239
|
+
if (await ownerSetupAllowsEmail(request, env, email)) return;
|
|
182
240
|
const invitationId = await invitationIdFromRequest(request, env);
|
|
183
241
|
const invitation = invitationId ? await findPendingInvitationById(env, invitationId) : null;
|
|
184
242
|
if (!invitation) {
|
|
@@ -202,6 +260,7 @@ async function identityMaySignIn(
|
|
|
202
260
|
"SELECT status FROM operator_users WHERE email = ?1 LIMIT 1",
|
|
203
261
|
).bind(email).first<{ status: string }>() : null;
|
|
204
262
|
if (operator?.status === "active") return;
|
|
263
|
+
if (request && await ownerSetupAllowsEmail(request, env, email)) return;
|
|
205
264
|
if (request && await invitationAllowsEmail(request, env, email)) return;
|
|
206
265
|
return { error: "account_unavailable", errorDescription: "This Safest Resolve account is unavailable." };
|
|
207
266
|
}
|
|
@@ -393,7 +452,8 @@ function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
|
|
|
393
452
|
sendOnSignIn: !development,
|
|
394
453
|
autoSignInAfterVerification: true,
|
|
395
454
|
expiresIn: 24 * 60 * 60,
|
|
396
|
-
async sendVerificationEmail({ user, url }) {
|
|
455
|
+
async sendVerificationEmail({ user, url }, request) {
|
|
456
|
+
if (await ownerSetupAllowsEmail(request, env, user.email)) return;
|
|
397
457
|
await deliverAuthEmail(env, ctx, {
|
|
398
458
|
to: user.email,
|
|
399
459
|
subject: "Verify your Safest Resolve email",
|
|
@@ -543,6 +603,10 @@ export function appendBetterAuthCsrfCookie(
|
|
|
543
603
|
"set-cookie",
|
|
544
604
|
`${INVITATION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`,
|
|
545
605
|
);
|
|
606
|
+
headers.append(
|
|
607
|
+
"set-cookie",
|
|
608
|
+
`${OWNER_SETUP_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`,
|
|
609
|
+
);
|
|
546
610
|
return new Response(response.body, {
|
|
547
611
|
status: response.status,
|
|
548
612
|
statusText: response.statusText,
|
|
@@ -550,6 +614,90 @@ export function appendBetterAuthCsrfCookie(
|
|
|
550
614
|
});
|
|
551
615
|
}
|
|
552
616
|
|
|
617
|
+
export function clearOwnerSetupCookie(response: Response, request: Request): Response {
|
|
618
|
+
const secure = new URL(request.url).protocol === "https:";
|
|
619
|
+
const headers = new Headers(response.headers);
|
|
620
|
+
headers.append(
|
|
621
|
+
"set-cookie",
|
|
622
|
+
`${OWNER_SETUP_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`,
|
|
623
|
+
);
|
|
624
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function enforceOwnerSetupRateLimit(request: Request, env: BetterAuthEnv): Promise<boolean> {
|
|
628
|
+
const pepper = env.RATE_LIMIT_PEPPER;
|
|
629
|
+
if (typeof pepper !== "string" || new TextEncoder().encode(pepper).byteLength < 32) {
|
|
630
|
+
throw new Error("RATE_LIMIT_PEPPER must be configured for owner setup.");
|
|
631
|
+
}
|
|
632
|
+
const now = new Date();
|
|
633
|
+
const windowStart = new Date(Math.floor(now.valueOf() / (OWNER_SETUP_WINDOW_SECONDS * 1_000))
|
|
634
|
+
* OWNER_SETUP_WINDOW_SECONDS * 1_000).toISOString();
|
|
635
|
+
const network = (request.headers.get("cf-connecting-ip") ?? "unknown").slice(0, 200);
|
|
636
|
+
const bucketKey = await sha256(`${pepper}:owner-setup:${windowStart}:${network}`);
|
|
637
|
+
const row = await env.DB.prepare(`
|
|
638
|
+
INSERT INTO owner_setup_rate_limits (bucket_key, window_start, request_count, updated_at)
|
|
639
|
+
VALUES (?1, ?2, 1, ?3)
|
|
640
|
+
ON CONFLICT(bucket_key) DO UPDATE SET
|
|
641
|
+
request_count = owner_setup_rate_limits.request_count + 1,
|
|
642
|
+
updated_at = excluded.updated_at
|
|
643
|
+
WHERE owner_setup_rate_limits.request_count < ?4
|
|
644
|
+
RETURNING request_count
|
|
645
|
+
`).bind(bucketKey, windowStart, now.toISOString(), OWNER_SETUP_RATE_LIMIT).first<{ request_count: number }>();
|
|
646
|
+
await env.DB.prepare(`DELETE FROM owner_setup_rate_limits WHERE updated_at < ?1`)
|
|
647
|
+
.bind(new Date(now.valueOf() - 60 * 60_000).toISOString()).run();
|
|
648
|
+
return Boolean(row);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function ownerSetupError(status = 400): Response {
|
|
652
|
+
return Response.json({
|
|
653
|
+
error: { code: "invalid_owner_setup", message: "This owner setup link is invalid or expired." },
|
|
654
|
+
}, { status, headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" } });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export async function inspectOwnerSetup(request: Request, env: BetterAuthEnv): Promise<Response> {
|
|
658
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { allow: "POST" } });
|
|
659
|
+
if (!await enforceOwnerSetupRateLimit(request, env)) {
|
|
660
|
+
return Response.json({
|
|
661
|
+
error: { code: "owner_setup_rate_limited", message: "Too many setup attempts. Try again in ten minutes." },
|
|
662
|
+
}, { status: 429, headers: { "cache-control": "no-store", "retry-after": String(OWNER_SETUP_WINDOW_SECONDS) } });
|
|
663
|
+
}
|
|
664
|
+
const body = await request.json().catch(() => null) as { token?: unknown } | null;
|
|
665
|
+
const token = typeof body?.token === "string" ? body.token : "";
|
|
666
|
+
if (!/^[A-Za-z0-9_-]{43,200}$/u.test(token)) return ownerSetupError();
|
|
667
|
+
const now = new Date().toISOString();
|
|
668
|
+
const setup = await env.DB.prepare(`
|
|
669
|
+
SELECT id, email, purpose, expires_at
|
|
670
|
+
FROM owner_setup_tokens
|
|
671
|
+
WHERE token_hash = ?1 AND status = 'pending' AND expires_at > ?2
|
|
672
|
+
LIMIT 1
|
|
673
|
+
`).bind(await sha256(token), now).first<OwnerSetupRow>();
|
|
674
|
+
if (!setup) return ownerSetupError();
|
|
675
|
+
const eligible = setup.purpose === "bootstrap"
|
|
676
|
+
? !await env.DB.prepare(`
|
|
677
|
+
SELECT id FROM operator_users
|
|
678
|
+
WHERE status = 'active' AND roles_json LIKE '%"owner"%'
|
|
679
|
+
LIMIT 1
|
|
680
|
+
`).first()
|
|
681
|
+
: Boolean(await env.DB.prepare(`
|
|
682
|
+
SELECT id FROM operator_users
|
|
683
|
+
WHERE email = ?1 AND status = 'active' AND roles_json LIKE '%"owner"%'
|
|
684
|
+
LIMIT 1
|
|
685
|
+
`).bind(setup.email).first());
|
|
686
|
+
if (!eligible) return ownerSetupError();
|
|
687
|
+
const secure = new URL(request.url).protocol === "https:";
|
|
688
|
+
const maxAge = Math.max(0, Math.floor((Date.parse(setup.expires_at) - Date.now()) / 1_000));
|
|
689
|
+
const headers = new Headers({ "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
690
|
+
headers.append(
|
|
691
|
+
"set-cookie",
|
|
692
|
+
`${OWNER_SETUP_COOKIE}=${await ownerSetupCookieValue(setup.id, env)}; Path=/; Max-Age=${maxAge}; ` +
|
|
693
|
+
`HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`,
|
|
694
|
+
);
|
|
695
|
+
return Response.json({
|
|
696
|
+
schema_version: "1",
|
|
697
|
+
setup: { email: setup.email, purpose: setup.purpose, expires_at: setup.expires_at },
|
|
698
|
+
}, { headers });
|
|
699
|
+
}
|
|
700
|
+
|
|
553
701
|
export async function inspectInvitation(
|
|
554
702
|
request: Request,
|
|
555
703
|
env: BetterAuthEnv,
|
|
@@ -202,7 +202,7 @@ function operatorAvatarUrl(user: OperatorUserRow): string | undefined {
|
|
|
202
202
|
function operatorSession(
|
|
203
203
|
user: OperatorUserRow,
|
|
204
204
|
authorization?: { roles: OperatorRole[]; queueScope: string[] | null; fieldScope: Record<string, string[]> | null },
|
|
205
|
-
authMethod: OperatorSession["authMethod"] = "
|
|
205
|
+
authMethod: OperatorSession["authMethod"] = "owner_setup",
|
|
206
206
|
): OperatorSession {
|
|
207
207
|
const roles = authorization?.roles ?? parseRoles(user.roles_json);
|
|
208
208
|
const avatarUrl = operatorAvatarUrl(user);
|
|
@@ -275,10 +275,19 @@ export async function resolveBetterAuthOperator(
|
|
|
275
275
|
db: D1Database,
|
|
276
276
|
identity: { email: string; name: string; image?: string | null },
|
|
277
277
|
invitationId: string | null,
|
|
278
|
+
ownerSetupId: string | null = null,
|
|
278
279
|
): Promise<OperatorSession | null> {
|
|
279
280
|
const email = cleanEmail(identity.email);
|
|
280
281
|
let user = await db.prepare(`SELECT * FROM operator_users WHERE email = ?1 LIMIT 1`)
|
|
281
282
|
.bind(email).first<OperatorUserRow>();
|
|
283
|
+
if (ownerSetupId) {
|
|
284
|
+
const owner = await claimInitialOwner(db, ownerSetupId, {
|
|
285
|
+
email,
|
|
286
|
+
displayName: identity.name,
|
|
287
|
+
avatarUrl: identity.image,
|
|
288
|
+
});
|
|
289
|
+
if (owner) return owner;
|
|
290
|
+
}
|
|
282
291
|
if (user) {
|
|
283
292
|
if (user.status !== "active") return null;
|
|
284
293
|
return operatorSession(user, await operatorAuthorization(db, user), "better_auth");
|
|
@@ -385,18 +394,6 @@ export function clearOperatorSessionCookies(response: Response, request: Request
|
|
|
385
394
|
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
386
395
|
}
|
|
387
396
|
|
|
388
|
-
async function issueSession(db: D1Database, user: OperatorUserRow): Promise<IssuedOperatorSession> {
|
|
389
|
-
const createdAt = new Date().toISOString();
|
|
390
|
-
const expiresAt = new Date(Date.parse(createdAt) + SESSION_HOURS * 3_600_000).toISOString();
|
|
391
|
-
const sessionToken = randomToken();
|
|
392
|
-
const csrfToken = randomToken();
|
|
393
|
-
await db.prepare(`
|
|
394
|
-
INSERT INTO infrastructure_owner_sessions (id, user_id, token_hash, csrf_hash, expires_at, last_seen_at, created_at)
|
|
395
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
|
396
|
-
`).bind(crypto.randomUUID(), user.id, await sha256(sessionToken), await sha256(csrfToken), expiresAt, createdAt).run();
|
|
397
|
-
return { session: operatorSession(user), sessionToken, csrfToken, expiresAt };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
397
|
export async function resolveOperatorAccountSession(
|
|
401
398
|
request: Request,
|
|
402
399
|
db: D1Database,
|
|
@@ -427,37 +424,127 @@ export async function verifyOperatorCsrf(request: Request, csrfHash: string): Pr
|
|
|
427
424
|
)) throw new ApiError(403, "csrf_validation_failed", "Refresh the page and try again.");
|
|
428
425
|
}
|
|
429
426
|
|
|
430
|
-
export async function
|
|
427
|
+
export async function claimInitialOwner(
|
|
431
428
|
db: D1Database,
|
|
432
|
-
|
|
433
|
-
|
|
429
|
+
setupId: string,
|
|
430
|
+
identity: { email: string; displayName: string; avatarUrl?: string | null },
|
|
431
|
+
): Promise<OperatorSession | null> {
|
|
434
432
|
const email = cleanEmail(identity.email);
|
|
435
433
|
const displayName = cleanDisplayName(identity.displayName, email.split("@", 1)[0]);
|
|
436
434
|
const avatarUrl = cleanAvatarUrl(identity.avatarUrl);
|
|
437
435
|
const timestamp = new Date().toISOString();
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
roles_json
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
436
|
+
const existing = await db.prepare(`SELECT * FROM operator_users WHERE email = ?1 LIMIT 1`)
|
|
437
|
+
.bind(email).first<OperatorUserRow>();
|
|
438
|
+
const userId = existing?.id ?? crypto.randomUUID();
|
|
439
|
+
const tokenPredicate = `
|
|
440
|
+
EXISTS (
|
|
441
|
+
SELECT 1 FROM owner_setup_tokens
|
|
442
|
+
WHERE id = ?1 AND email = ?2 AND purpose = 'bootstrap'
|
|
443
|
+
AND status = 'pending' AND expires_at > ?3
|
|
444
|
+
)
|
|
445
|
+
AND NOT EXISTS (
|
|
446
|
+
SELECT 1 FROM operator_users
|
|
447
|
+
WHERE status = 'active' AND roles_json LIKE '%"owner"%'
|
|
448
|
+
)
|
|
449
|
+
`;
|
|
450
|
+
const userStatement = existing
|
|
451
|
+
? db.prepare(`
|
|
452
|
+
UPDATE operator_users
|
|
453
|
+
SET display_name = ?4, avatar_url = ?5, roles_json = '["owner"]', status = 'active',
|
|
454
|
+
accepted_at = COALESCE(accepted_at, ?3), last_login_at = ?3, updated_at = ?3
|
|
455
|
+
WHERE id = ?6 AND ${tokenPredicate}
|
|
456
|
+
`).bind(setupId, email, timestamp, displayName, avatarUrl, userId)
|
|
457
|
+
: db.prepare(`
|
|
458
|
+
INSERT INTO operator_users (
|
|
459
|
+
id, email, display_name, avatar_url, roles_json, status,
|
|
460
|
+
accepted_at, last_login_at, created_at, updated_at
|
|
461
|
+
)
|
|
462
|
+
SELECT ?6, ?2, ?4, ?5, '["owner"]', 'active', ?3, ?3, ?3, ?3
|
|
463
|
+
WHERE ${tokenPredicate}
|
|
464
|
+
`).bind(setupId, email, timestamp, displayName, avatarUrl, userId);
|
|
465
|
+
const auditId = crypto.randomUUID();
|
|
466
|
+
const results = await db.batch([
|
|
467
|
+
userStatement,
|
|
468
|
+
db.prepare(`
|
|
469
|
+
INSERT INTO audit_events (
|
|
470
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
471
|
+
target_type, target_id, details_json, created_at
|
|
472
|
+
)
|
|
473
|
+
SELECT ?4, ?4, 'operator.owner_bootstrap', 'system', ?1,
|
|
474
|
+
'operator_user', ?5, ?6, ?3
|
|
475
|
+
FROM owner_setup_tokens
|
|
476
|
+
WHERE id = ?1 AND email = ?2 AND purpose = 'bootstrap'
|
|
477
|
+
AND status = 'pending' AND expires_at > ?3
|
|
478
|
+
`).bind(setupId, email, timestamp, auditId, userId, canonicalJson({ email, method: "one_time_setup" })),
|
|
479
|
+
db.prepare(`
|
|
480
|
+
UPDATE owner_setup_tokens
|
|
481
|
+
SET status = 'consumed', consumed_at = ?3
|
|
482
|
+
WHERE id = ?1 AND email = ?2 AND purpose = 'bootstrap'
|
|
483
|
+
AND status = 'pending' AND expires_at > ?3
|
|
484
|
+
`).bind(setupId, email, timestamp),
|
|
485
|
+
]);
|
|
486
|
+
if (!results[0]?.meta.changes || !results[2]?.meta.changes) return null;
|
|
487
|
+
const user = await db.prepare(`SELECT * FROM operator_users WHERE id = ?1 LIMIT 1`)
|
|
488
|
+
.bind(userId).first<OperatorUserRow>();
|
|
489
|
+
return user ? operatorSession(user, undefined, "better_auth") : null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function recoverOwnerWithSetup(
|
|
493
|
+
db: D1Database,
|
|
494
|
+
setupId: string,
|
|
495
|
+
emailValue: string,
|
|
496
|
+
): Promise<IssuedOperatorSession | null> {
|
|
497
|
+
const email = cleanEmail(emailValue);
|
|
498
|
+
const timestamp = new Date().toISOString();
|
|
499
|
+
const user = await db.prepare(`
|
|
500
|
+
SELECT * FROM operator_users
|
|
501
|
+
WHERE email = ?1 AND status = 'active' AND roles_json LIKE '%"owner"%'
|
|
502
|
+
LIMIT 1
|
|
503
|
+
`).bind(email).first<OperatorUserRow>();
|
|
504
|
+
if (!user) return null;
|
|
505
|
+
const expiresAt = new Date(Date.parse(timestamp) + SESSION_HOURS * 3_600_000).toISOString();
|
|
506
|
+
const sessionToken = randomToken();
|
|
507
|
+
const csrfToken = randomToken();
|
|
508
|
+
const sessionId = crypto.randomUUID();
|
|
509
|
+
const auditId = crypto.randomUUID();
|
|
510
|
+
const results = await db.batch([
|
|
511
|
+
db.prepare(`
|
|
512
|
+
INSERT INTO infrastructure_owner_sessions (
|
|
513
|
+
id, user_id, token_hash, csrf_hash, expires_at, last_seen_at, created_at
|
|
514
|
+
)
|
|
515
|
+
SELECT ?4, ?5, ?6, ?7, ?8, ?3, ?3
|
|
516
|
+
FROM owner_setup_tokens
|
|
517
|
+
WHERE id = ?1 AND email = ?2 AND purpose = 'recovery'
|
|
518
|
+
AND status = 'pending' AND expires_at > ?3
|
|
519
|
+
`).bind(
|
|
520
|
+
setupId, email, timestamp, sessionId, user.id,
|
|
521
|
+
await sha256(sessionToken), await sha256(csrfToken), expiresAt,
|
|
522
|
+
),
|
|
523
|
+
db.prepare(`
|
|
524
|
+
INSERT INTO audit_events (
|
|
525
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
526
|
+
target_type, target_id, details_json, created_at
|
|
527
|
+
)
|
|
528
|
+
SELECT ?4, ?4, 'operator.owner_recovered', 'system', ?1,
|
|
529
|
+
'operator_user', ?5, ?6, ?3
|
|
530
|
+
FROM owner_setup_tokens
|
|
531
|
+
WHERE id = ?1 AND email = ?2 AND purpose = 'recovery'
|
|
532
|
+
AND status = 'pending' AND expires_at > ?3
|
|
533
|
+
`).bind(setupId, email, timestamp, auditId, user.id, canonicalJson({ email, method: "one_time_setup" })),
|
|
534
|
+
db.prepare(`
|
|
535
|
+
UPDATE owner_setup_tokens
|
|
536
|
+
SET status = 'consumed', consumed_at = ?3
|
|
537
|
+
WHERE id = ?1 AND email = ?2 AND purpose = 'recovery'
|
|
538
|
+
AND status = 'pending' AND expires_at > ?3
|
|
539
|
+
`).bind(setupId, email, timestamp),
|
|
540
|
+
]);
|
|
541
|
+
if (!results[0]?.meta.changes || !results[2]?.meta.changes) return null;
|
|
542
|
+
return {
|
|
543
|
+
session: operatorSession(user, await operatorAuthorization(db, user), "owner_setup"),
|
|
544
|
+
sessionToken,
|
|
545
|
+
csrfToken,
|
|
546
|
+
expiresAt,
|
|
547
|
+
};
|
|
461
548
|
}
|
|
462
549
|
|
|
463
550
|
function auditEvent(
|
|
@@ -65,7 +65,7 @@ export interface OperatorPermissions {
|
|
|
65
65
|
|
|
66
66
|
export interface OperatorSession {
|
|
67
67
|
actor: { type: "cloudflare_access" | "admin_key" | "operator_user"; id: string };
|
|
68
|
-
authMethod: "cloudflare_access" | "admin_key" | "better_auth";
|
|
68
|
+
authMethod: "cloudflare_access" | "owner_setup" | "admin_key" | "better_auth";
|
|
69
69
|
verified: boolean;
|
|
70
70
|
displayName: string;
|
|
71
71
|
email?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
|
-
// Generated by Wrangler by running `wrangler types --env-file=config/dev.vars.example --include-runtime=false --strict-vars=false` (hash:
|
|
2
|
+
// Generated by Wrangler by running `wrangler types --env-file=config/dev.vars.example --include-runtime=false --strict-vars=false` (hash: 2cca359aa7fa3dcf738a197c47478066)
|
|
3
3
|
interface __BaseEnv_Env {
|
|
4
4
|
PROFILE_MEDIA: R2Bucket;
|
|
5
5
|
WORKFLOW_ARTIFACTS: R2Bucket;
|
|
@@ -34,8 +34,6 @@ interface __BaseEnv_Env {
|
|
|
34
34
|
CREDENTIAL_ENCRYPTION_KEY: string;
|
|
35
35
|
RATE_LIMIT_PEPPER: string;
|
|
36
36
|
INTEGRATION_API_KEYS_JSON: string;
|
|
37
|
-
ACCESS_AUD: string;
|
|
38
|
-
INFRASTRUCTURE_OWNER_EMAILS: string;
|
|
39
37
|
TURNSTILE_SITE_KEY: string;
|
|
40
38
|
TURNSTILE_SECRET_KEY: string;
|
|
41
39
|
ACTION_WEBHOOK_URL: string;
|
|
@@ -61,5 +59,5 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|
|
61
59
|
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
|
62
60
|
};
|
|
63
61
|
declare namespace NodeJS {
|
|
64
|
-
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "PUBLIC_BASE_URL" | "ALLOWED_ORIGINS" | "CONTEXT_TOKEN_TTL_SECONDS" | "CLAIM_TTL_MINUTES" | "REPORT_RETENTION_DAYS" | "MESSAGE_RETENTION_DAYS" | "APPEAL_WINDOW_DAYS" | "ALLOWED_ACTION_CODES" | "OPERATIONS_DLQ_NAME" | "PUBLIC_INTEGRATION_ID" | "PUBLIC_REPORT_LIMIT_PER_10_MINUTES" | "PUBLIC_REPORT_SURGE_PER_5_MINUTES" | "OPERATOR_NOTIFICATION_WEBHOOK_URL" | "EMAIL_FROM_ADDRESS" | "EMAIL_SUBJECT_PREFIX" | "ADMIN_API_KEY" | "BETTER_AUTH_SECRET" | "AUTH_EMAIL_FROM" | "CONTEXT_SIGNING_SECRET" | "CREDENTIAL_ENCRYPTION_KEY" | "RATE_LIMIT_PEPPER" | "INTEGRATION_API_KEYS_JSON" | "
|
|
62
|
+
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "PUBLIC_BASE_URL" | "ALLOWED_ORIGINS" | "CONTEXT_TOKEN_TTL_SECONDS" | "CLAIM_TTL_MINUTES" | "REPORT_RETENTION_DAYS" | "MESSAGE_RETENTION_DAYS" | "APPEAL_WINDOW_DAYS" | "ALLOWED_ACTION_CODES" | "OPERATIONS_DLQ_NAME" | "PUBLIC_INTEGRATION_ID" | "PUBLIC_REPORT_LIMIT_PER_10_MINUTES" | "PUBLIC_REPORT_SURGE_PER_5_MINUTES" | "OPERATOR_NOTIFICATION_WEBHOOK_URL" | "EMAIL_FROM_ADDRESS" | "EMAIL_SUBJECT_PREFIX" | "ADMIN_API_KEY" | "BETTER_AUTH_SECRET" | "AUTH_EMAIL_FROM" | "CONTEXT_SIGNING_SECRET" | "CREDENTIAL_ENCRYPTION_KEY" | "RATE_LIMIT_PEPPER" | "INTEGRATION_API_KEYS_JSON" | "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET_KEY" | "ACTION_WEBHOOK_URL" | "ACTION_WEBHOOK_SECRET" | "NOTIFICATION_WEBHOOK_URL" | "NOTIFICATION_WEBHOOK_SECRET" | "OPERATOR_NOTIFICATION_WEBHOOK_SECRET" | "EXAMPLE_ENRICHMENT_SECRET" | "AI_LOCAL_FIXTURE_ENABLED" | "AI_LOCAL_FIXTURE_JSON">> {}
|
|
65
63
|
}
|