@k2b/cloud 0.17.0 → 0.18.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/package.json +3 -2
- package/src/ai/chat/builtin-tools.tsx +10 -4
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/code-mode-skill.ts +3 -3
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/html-pdf-tool.ts +180 -0
- package/src/ai/markdown-pdf-tool.ts +14 -6
- package/src/ai/pdf-conversions.ts +21 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/api/auth.ts +5 -10
- package/src/browser/app-approval.ts +6 -0
- package/src/contracts/app-approval.ts +11 -0
- package/src/services/accounts/app.ts +17 -4
- package/src/services/accounts/users.ts +26 -12
- package/src/services/app-approval.ts +57 -10
- package/src/services/auth-flows/deferred.ts +41 -0
- package/src/services/auth-flows/magic-link.ts +57 -28
- package/src/services/auth-flows/password-reset.ts +25 -9
- package/src/services/index.ts +1 -1
- package/src/services/pdf/gotenberg.ts +4 -3
- package/src/services/pdf/offline-html.ts +74 -0
- package/src/services/providers/local/auth.ts +18 -9
- package/src/services/providers/local/users.ts +44 -11
- package/src/services/session/index.ts +33 -8
- package/src/services/settings/core-settings.ts +7 -0
- package/src/styles/global.css +5 -0
|
@@ -34,7 +34,8 @@ type CreateUserData = {
|
|
|
34
34
|
provider: UserProvider;
|
|
35
35
|
profile: UserProfile;
|
|
36
36
|
admin?: boolean;
|
|
37
|
-
|
|
37
|
+
/** Optional only for local full accounts while `user.local_email_optional` is on. */
|
|
38
|
+
email?: string | null;
|
|
38
39
|
givenname: string;
|
|
39
40
|
sn: string;
|
|
40
41
|
displayName?: string;
|
|
@@ -47,7 +48,8 @@ type UpdateUserData = {
|
|
|
47
48
|
givenname?: string;
|
|
48
49
|
sn?: string;
|
|
49
50
|
displayName?: string;
|
|
50
|
-
|
|
51
|
+
/** `null` removes the address of a local account when its policy allows that. */
|
|
52
|
+
mail?: string | null;
|
|
51
53
|
ipa?: {
|
|
52
54
|
phone?: string;
|
|
53
55
|
address?: {
|
|
@@ -456,6 +458,8 @@ export const create = async (params: {
|
|
|
456
458
|
return { ok: true, data: { user } };
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
const email = params.data.email?.trim();
|
|
462
|
+
if (!email) return { ok: false, error: "FreeIPA accounts need an email address.", status: 400 };
|
|
459
463
|
const serviceSession = await getServiceIpaSession();
|
|
460
464
|
if (!serviceSession.ok) return serviceSession;
|
|
461
465
|
|
|
@@ -464,7 +468,7 @@ export const create = async (params: {
|
|
|
464
468
|
profile: params.data.profile,
|
|
465
469
|
accountExpires,
|
|
466
470
|
data: {
|
|
467
|
-
email
|
|
471
|
+
email,
|
|
468
472
|
givenname: params.data.givenname,
|
|
469
473
|
sn: params.data.sn,
|
|
470
474
|
displayName: params.data.displayName,
|
|
@@ -489,12 +493,14 @@ export const update = async (params: { id: string; data: UpdateUserData }): Prom
|
|
|
489
493
|
if (!user) return { ok: false, error: "User not found", status: 404 };
|
|
490
494
|
|
|
491
495
|
if (user.provider === "ipa") {
|
|
496
|
+
const { mail, ...data } = params.data;
|
|
497
|
+
if (mail === null) return { ok: false, error: "FreeIPA accounts need an email address.", status: 400 };
|
|
492
498
|
const serviceSession = await getServiceIpaSession();
|
|
493
499
|
if (!serviceSession.ok) return serviceSession;
|
|
494
500
|
return providers.ipa.users.update({
|
|
495
501
|
ipaSession: serviceSession.data,
|
|
496
502
|
id: params.id,
|
|
497
|
-
data:
|
|
503
|
+
data: mail === undefined ? data : { ...data, mail },
|
|
498
504
|
});
|
|
499
505
|
}
|
|
500
506
|
|
|
@@ -578,6 +584,16 @@ export const setExpiry = async (params: {
|
|
|
578
584
|
});
|
|
579
585
|
};
|
|
580
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Administrators never receive sign-in tokens for the shared emergency
|
|
589
|
+
* `admin` identity. Break-glass access uses `ADMIN_LOGIN_TOKEN`, so everyday
|
|
590
|
+
* actions stay attributed to the administrator who performs them.
|
|
591
|
+
*/
|
|
592
|
+
const emergencyAdminSignInError = (user: { uid: string }): MutationResult<never> | null =>
|
|
593
|
+
user.uid === providers.local.users.EMERGENCY_ADMIN_UID
|
|
594
|
+
? { ok: false, error: "The emergency admin account has no login tokens or links. Use the admin login token instead.", status: 403 }
|
|
595
|
+
: null;
|
|
596
|
+
|
|
581
597
|
export const sendLoginLink = async (params: {
|
|
582
598
|
id: string;
|
|
583
599
|
notificationSender: AccountsNotificationSender;
|
|
@@ -588,9 +604,11 @@ export const sendLoginLink = async (params: {
|
|
|
588
604
|
if (user.provider !== "local") {
|
|
589
605
|
return { ok: false, error: "Login links are only available for local accounts", status: 400 };
|
|
590
606
|
}
|
|
607
|
+
const emergencyError = emergencyAdminSignInError(user);
|
|
608
|
+
if (emergencyError) return emergencyError;
|
|
591
609
|
if (!user.mail) return { ok: false, error: "A local account requires an email address to receive a login link", status: 400 };
|
|
592
610
|
|
|
593
|
-
const token = await providers.local.auth.
|
|
611
|
+
const token = await providers.local.auth.createAccountLoginToken({ userId: user.id, ttlSeconds: 300 });
|
|
594
612
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
595
613
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
596
614
|
try {
|
|
@@ -615,15 +633,11 @@ export const createLoginToken = async (params: {
|
|
|
615
633
|
if (user.provider !== "local") {
|
|
616
634
|
return { ok: false, error: "Login tokens are only available for local accounts", status: 400 };
|
|
617
635
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
636
|
+
const emergencyError = emergencyAdminSignInError(user);
|
|
637
|
+
if (emergencyError) return emergencyError;
|
|
621
638
|
|
|
622
639
|
const expiresInSeconds = 300;
|
|
623
|
-
const token = await providers.local.auth.
|
|
624
|
-
email: user.mail,
|
|
625
|
-
ttlSeconds: expiresInSeconds,
|
|
626
|
-
});
|
|
640
|
+
const token = await providers.local.auth.createAccountLoginToken({ userId: user.id, ttlSeconds: expiresInSeconds });
|
|
627
641
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
628
642
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
629
643
|
|
|
@@ -115,6 +115,8 @@ type PairingRow = {
|
|
|
115
115
|
initiated_by: string;
|
|
116
116
|
initiator_sid: string;
|
|
117
117
|
assisted: boolean;
|
|
118
|
+
/** The target account's epoch at start; a sign-out everywhere moves it and voids the pairing. */
|
|
119
|
+
auth_epoch: string | null;
|
|
118
120
|
secret_hash: string;
|
|
119
121
|
state: string;
|
|
120
122
|
expires_at: Date;
|
|
@@ -137,6 +139,8 @@ type LoginRow = {
|
|
|
137
139
|
expires_at: Date;
|
|
138
140
|
};
|
|
139
141
|
export type AppApprovalActor = { userId: string; sid: string; admin: boolean };
|
|
142
|
+
/** Administration of another account's devices needs current admin authority, not a recent session. */
|
|
143
|
+
export type AppDeviceAdministrator = Pick<AppApprovalActor, "userId" | "admin">;
|
|
140
144
|
export type AppDeviceEnrollmentNotice = { deviceId: string; userId: string; name: string; assisted: boolean };
|
|
141
145
|
/** Best-effort wake-ups for browsers waiting on a login decision. Postgres stays authoritative. */
|
|
142
146
|
export type AppLoginDecisionHints = {
|
|
@@ -250,7 +254,7 @@ export const createAppApprovalService = (
|
|
|
250
254
|
(pairing.assisted && (!actor.admin || !cfg.adminPairing))
|
|
251
255
|
)
|
|
252
256
|
return reject("FORBIDDEN", 403);
|
|
253
|
-
|
|
257
|
+
return eligible(tx, pairing.user_id);
|
|
254
258
|
};
|
|
255
259
|
const activeDevice = async (tx: SQL, id: string, issuer: string): Promise<DeviceRow> => {
|
|
256
260
|
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices WHERE id = ${id}::uuid AND issuer = ${issuer} FOR UPDATE`;
|
|
@@ -282,14 +286,17 @@ export const createAppApprovalService = (
|
|
|
282
286
|
cleanup,
|
|
283
287
|
maintain: async (notify: (notice: AppDeviceEnrollmentNotice) => Promise<unknown>, signal?: AbortSignal) => {
|
|
284
288
|
await cleanup();
|
|
285
|
-
const rows = await db<
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
const rows = await db<(DeviceRow & { has_email: boolean })[]>`
|
|
290
|
+
SELECT d.*, COALESCE(btrim(u.mail), '') <> '' AS has_email
|
|
291
|
+
FROM auth.app_devices d JOIN auth.users u ON u.id = d.user_id
|
|
292
|
+
WHERE d.notified_at IS NULL ORDER BY d.created_at LIMIT ${limits.pageSize}`;
|
|
288
293
|
for (const row of rows) {
|
|
289
294
|
signal?.throwIfAborted();
|
|
290
295
|
// The notification owner deduplicates by deviceId. A crash after send
|
|
291
296
|
// but before this marker is safe; no external effect precedes enrollment.
|
|
292
|
-
|
|
297
|
+
// The notice is email-only, so an account without email relies on the
|
|
298
|
+
// enrollment audit entry instead of a delivery that can only fail.
|
|
299
|
+
if (row.has_email) await notify({ deviceId: row.id, userId: row.user_id, name: row.name, assisted: row.assisted });
|
|
293
300
|
await db`UPDATE auth.app_devices SET notified_at=now() WHERE id=${row.id}::uuid AND notified_at IS NULL`;
|
|
294
301
|
}
|
|
295
302
|
},
|
|
@@ -314,13 +321,13 @@ export const createAppApprovalService = (
|
|
|
314
321
|
await fresh(tx, actor);
|
|
315
322
|
if (targetId !== actor.userId && (!actor.admin || !cfg.adminPairing)) return reject("FORBIDDEN", 403);
|
|
316
323
|
await tx`SELECT id FROM auth.users WHERE id = ${targetId}::uuid FOR UPDATE`;
|
|
317
|
-
await eligible(tx, targetId);
|
|
324
|
+
const target = await eligible(tx, targetId);
|
|
318
325
|
const [count] = await tx<
|
|
319
326
|
{ count: number }[]
|
|
320
327
|
>`SELECT count(*)::int AS count FROM auth.app_pairings WHERE issuer = ${cfg.issuer} AND user_id = ${targetId}::uuid AND expires_at > now() AND state IN ('pending','claimed')`;
|
|
321
328
|
if ((count?.count ?? 0) >= limits.pendingPerAccount) return reject("LIMIT_REACHED", 429);
|
|
322
|
-
await tx`INSERT INTO auth.app_pairings(id, issuer, user_id, initiated_by, initiator_sid, assisted, secret_hash, expires_at)
|
|
323
|
-
VALUES (${id}::uuid,${cfg.issuer},${targetId}::uuid,${actor.userId}::uuid,${actor.sid}::uuid,${targetId !== actor.userId},${hash(token)},${expiresAt})`;
|
|
329
|
+
await tx`INSERT INTO auth.app_pairings(id, issuer, user_id, initiated_by, initiator_sid, assisted, auth_epoch, secret_hash, expires_at)
|
|
330
|
+
VALUES (${id}::uuid,${cfg.issuer},${targetId}::uuid,${actor.userId}::uuid,${actor.sid}::uuid,${targetId !== actor.userId},${target.auth_epoch},${hash(token)},${expiresAt})`;
|
|
324
331
|
await record(tx, "pairing.start", actor.userId, id, { targetUserId: targetId, assisted: targetId !== actor.userId });
|
|
325
332
|
return { protocol: APP_APPROVAL_PROTOCOL, issuer: cfg.issuer, pairingId: id, secret: token, expiresAt: iso(expiresAt) };
|
|
326
333
|
});
|
|
@@ -356,7 +363,8 @@ export const createAppApprovalService = (
|
|
|
356
363
|
return db.begin(async (tx) => {
|
|
357
364
|
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
358
365
|
if (!p || new Date(p.expires_at).getTime() <= Date.now()) return reject("UNAVAILABLE", 404);
|
|
359
|
-
|
|
366
|
+
// A pairing from before the account was signed out everywhere reads as expired.
|
|
367
|
+
if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
|
|
360
368
|
return { state: p.state, name: p.name, comparison: p.comparison, deviceId: p.device_id, userId: p.user_id };
|
|
361
369
|
});
|
|
362
370
|
},
|
|
@@ -373,8 +381,11 @@ export const createAppApprovalService = (
|
|
|
373
381
|
!p.name
|
|
374
382
|
)
|
|
375
383
|
return reject("CONFLICT", 409);
|
|
376
|
-
|
|
384
|
+
// Lock the account before checking the session and epoch: a concurrent sign-out
|
|
385
|
+
// everywhere either revokes this device after it exists, or fails these checks.
|
|
386
|
+
// The epoch also voids assisted pairings, whose administrator session outlives it.
|
|
377
387
|
await tx`SELECT id FROM auth.users WHERE id = ${p.user_id}::uuid FOR UPDATE`;
|
|
388
|
+
if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
|
|
378
389
|
const [count] = await tx<
|
|
379
390
|
{ count: number }[]
|
|
380
391
|
>`SELECT count(*)::int AS count FROM auth.app_devices WHERE issuer = ${cfg.issuer} AND user_id = ${p.user_id}::uuid AND revoked_at IS NULL`;
|
|
@@ -423,6 +434,31 @@ export const createAppApprovalService = (
|
|
|
423
434
|
await record(tx, name === undefined ? "device.revoke" : "device.rename", actor.userId, id);
|
|
424
435
|
});
|
|
425
436
|
},
|
|
437
|
+
/** Active devices of any account, for administrators. Bounded by the active-device limit. */
|
|
438
|
+
listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
|
|
439
|
+
if (!actor.admin) return reject("FORBIDDEN", 403);
|
|
440
|
+
const cfg = await config(false);
|
|
441
|
+
const rows = await db<
|
|
442
|
+
DeviceRow[]
|
|
443
|
+
>`SELECT * FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL ORDER BY created_at, id LIMIT ${limits.devicesPerAccount}`;
|
|
444
|
+
return rows.map(view);
|
|
445
|
+
},
|
|
446
|
+
/** Idempotent: `revoked` is true only for the call that revoked the device. Works while the account is
|
|
447
|
+
* expired or app sign-in is disabled, because revocation only removes access. */
|
|
448
|
+
revokeUserDevice: async (actor: AppDeviceAdministrator, userId: string, id: string) => {
|
|
449
|
+
if (!actor.admin) return reject("FORBIDDEN", 403);
|
|
450
|
+
const cfg = await config(false);
|
|
451
|
+
return db.begin(async (tx) => {
|
|
452
|
+
const [device] = await tx<
|
|
453
|
+
DeviceRow[]
|
|
454
|
+
>`SELECT * FROM auth.app_devices WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${userId}::uuid FOR UPDATE`;
|
|
455
|
+
if (!device) return reject("UNAVAILABLE", 404);
|
|
456
|
+
if (device.revoked_at) return { device: view(device), revoked: false };
|
|
457
|
+
const [row] = await tx<DeviceRow[]>`UPDATE auth.app_devices SET revoked_at=now() WHERE id=${id}::uuid RETURNING *`;
|
|
458
|
+
await record(tx, "device.revoke", actor.userId, id, { targetUserId: userId });
|
|
459
|
+
return { device: view(row!), revoked: true };
|
|
460
|
+
});
|
|
461
|
+
},
|
|
426
462
|
startLogin: async (identifier: string, category: AccountCategory) => {
|
|
427
463
|
const cfg = await config();
|
|
428
464
|
await cleanup();
|
|
@@ -488,6 +524,17 @@ export const createAppApprovalService = (
|
|
|
488
524
|
await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
|
|
489
525
|
return { state: "updated" as const };
|
|
490
526
|
}
|
|
527
|
+
if (command.operation === "account") {
|
|
528
|
+
// Only the account this device signs in; activeDevice already checked it exists and may use app approval.
|
|
529
|
+
const [user] = await tx<
|
|
530
|
+
{ uid: string; display_name: string; mail: string | null }[]
|
|
531
|
+
>`SELECT uid, display_name, mail FROM auth.users WHERE id=${device.user_id}::uuid`;
|
|
532
|
+
if (!user) return reject("FORBIDDEN", 403);
|
|
533
|
+
return {
|
|
534
|
+
account: { uid: user.uid, displayName: user.display_name, mail: user.mail || null },
|
|
535
|
+
device: { name: device.name, createdAt: iso(device.created_at) },
|
|
536
|
+
};
|
|
537
|
+
}
|
|
491
538
|
if (command.operation === "revoke") {
|
|
492
539
|
await tx`UPDATE auth.app_devices SET revoked_at=now() WHERE id=${device.id}::uuid`;
|
|
493
540
|
await record(tx, "device.revoke", device.user_id, device.id);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { logger } from "../logging";
|
|
2
|
+
|
|
3
|
+
const log = logger("auth:deferred");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Enumeration-safe requests answer before their lookup and delivery run. That
|
|
7
|
+
* work takes a few Postgres and Valkey round trips, so the backlog stays near
|
|
8
|
+
* zero; it reaches this limit only while a backend stalls. Further requests
|
|
9
|
+
* are then dropped until the backlog shrinks. Their answer is the same either
|
|
10
|
+
* way, so dropping reveals nothing.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_DEFERRED_REQUESTS = 100;
|
|
13
|
+
|
|
14
|
+
const running = new Set<Promise<void>>();
|
|
15
|
+
let dropped = 0;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Starts `work` without holding up the caller's answer. The returned promise
|
|
19
|
+
* settles when the work has finished or was dropped; it never rejects.
|
|
20
|
+
*/
|
|
21
|
+
export const run = (label: string, work: () => Promise<void>): Promise<void> => {
|
|
22
|
+
if (running.size >= MAX_DEFERRED_REQUESTS) {
|
|
23
|
+
// One entry per overload episode; a log row per dropped request would add load.
|
|
24
|
+
if (dropped++ === 0) log.warn("Dropping sign-in requests while the backlog is full", { limit: MAX_DEFERRED_REQUESTS });
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
}
|
|
27
|
+
if (dropped > 0) {
|
|
28
|
+
log.warn("Accepting sign-in requests again", { dropped });
|
|
29
|
+
dropped = 0;
|
|
30
|
+
}
|
|
31
|
+
const task: Promise<void> = work()
|
|
32
|
+
.catch((error) => log.error(`${label} failed`, { error: error instanceof Error ? error.message : String(error) }))
|
|
33
|
+
.finally(() => running.delete(task));
|
|
34
|
+
running.add(task);
|
|
35
|
+
return task;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Waits for accepted work so a graceful shutdown does not lose it. */
|
|
39
|
+
export const drain = async (): Promise<void> => {
|
|
40
|
+
await Promise.all(running);
|
|
41
|
+
};
|
|
@@ -7,6 +7,7 @@ import { accounts } from "../accounts";
|
|
|
7
7
|
import { logger } from "../logging";
|
|
8
8
|
import { providers } from "../providers";
|
|
9
9
|
import * as settings from "../settings";
|
|
10
|
+
import * as deferred from "./deferred";
|
|
10
11
|
import type { AuthNotificationSender } from "./notification-sender";
|
|
11
12
|
|
|
12
13
|
const log = logger("auth:magic-link");
|
|
@@ -60,13 +61,24 @@ const resolveEmailForUsername = async (username: string): Promise<string | null>
|
|
|
60
61
|
return mail ? normalizeEmail(mail) : null;
|
|
61
62
|
};
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
type SignInLinkRequest = { email: string; redirectTo?: string; locale?: string; category?: "guest" | "login" };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Accepts a sign-in link request without revealing whether an account exists.
|
|
68
|
+
* The lookup and delivery run after this returns, so existing, unknown and
|
|
69
|
+
* mail-less accounts get the same response in the same time. `settled`
|
|
70
|
+
* resolves when that bounded background work has finished or was dropped
|
|
71
|
+
* under overload; it never rejects.
|
|
72
|
+
*/
|
|
73
|
+
export const request = (params: SignInLinkRequest, notificationSender: AuthNotificationSender): { ok: true; settled: Promise<void> } => ({
|
|
74
|
+
ok: true,
|
|
75
|
+
settled: deferred.run("Sign-in link request", () => deliverSignInLink(params, notificationSender)),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const deliverSignInLink = async (params: SignInLinkRequest, notificationSender: AuthNotificationSender): Promise<void> => {
|
|
67
79
|
const identifier = params.email.trim();
|
|
68
80
|
const email = identifier.includes("@") ? normalizeEmail(identifier) : await resolveEmailForUsername(identifier);
|
|
69
|
-
if (!email) return
|
|
81
|
+
if (!email) return;
|
|
70
82
|
const hasIpaUser = await hasIpaAccountForEmail(email);
|
|
71
83
|
const userRows = hasIpaUser
|
|
72
84
|
? []
|
|
@@ -77,25 +89,25 @@ export const request = async (
|
|
|
77
89
|
const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
|
|
78
90
|
|
|
79
91
|
if (hasIpaUser) {
|
|
80
|
-
if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return
|
|
92
|
+
if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return;
|
|
81
93
|
if (await claimIpaHintCooldown(email)) {
|
|
82
|
-
|
|
94
|
+
await sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo, locale: params.locale }, notificationSender).catch((error) => {
|
|
83
95
|
log.warn("Failed to send FreeIPA email-login hint", {
|
|
84
96
|
email,
|
|
85
97
|
error: error instanceof Error ? error.message : String(error),
|
|
86
98
|
});
|
|
87
99
|
});
|
|
88
100
|
}
|
|
89
|
-
return
|
|
101
|
+
return;
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
if (localUser && ((params.category && accountCategory(localUser) !== params.category) || !(await isAccountCategoryAllowed(localUser))))
|
|
93
|
-
return
|
|
105
|
+
return;
|
|
94
106
|
if (
|
|
95
107
|
!localUser &&
|
|
96
108
|
(!allowSelfRegistration || params.category === "login" || !(await isAccountCategoryAllowed({ provider: "local", profile: "guest" })))
|
|
97
109
|
) {
|
|
98
|
-
return
|
|
110
|
+
return;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
const token = await providers.local.auth.createMagicLinkToken({ email, category: params.category, ttlSeconds: 300 });
|
|
@@ -106,27 +118,51 @@ export const request = async (
|
|
|
106
118
|
const result = await notificationSender.sendMagicLink({ email, token, magicLink, locale: params.locale });
|
|
107
119
|
if (result.status === "error") log.error("Magic link delivery failed", { notificationId: result.id });
|
|
108
120
|
} catch (error) {
|
|
109
|
-
//
|
|
110
|
-
// sender records accepted delivery failures; pre-persistence failures land here.
|
|
121
|
+
// The durable sender records accepted delivery failures; pre-persistence failures land here.
|
|
111
122
|
log.error("Magic link notification could not be accepted", {
|
|
112
123
|
error: error instanceof Error ? error.message : String(error),
|
|
113
124
|
});
|
|
114
125
|
}
|
|
115
|
-
|
|
116
|
-
return { ok: true };
|
|
117
126
|
};
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}): Promise<
|
|
122
|
-
| { ok: true; userId: string; user: User; email: string; createdGuest: boolean }
|
|
128
|
+
type VerifyResult =
|
|
129
|
+
| { ok: true; userId: string; user: User; email: string | null; createdGuest: boolean }
|
|
123
130
|
| { ok: false; status: 401; message: string }
|
|
124
|
-
| { ok: false; status: number; message: string }
|
|
125
|
-
|
|
131
|
+
| { ok: false; status: number; message: string };
|
|
132
|
+
|
|
133
|
+
const signIn = async (params: {
|
|
134
|
+
userId: string;
|
|
135
|
+
email?: string;
|
|
136
|
+
createdGuest?: boolean;
|
|
137
|
+
category?: "guest" | "login";
|
|
138
|
+
}): Promise<VerifyResult> => {
|
|
139
|
+
const user = await accounts.users.get({ id: params.userId });
|
|
140
|
+
if (!user) {
|
|
141
|
+
return { ok: false, status: 401, message: "User not found" };
|
|
142
|
+
}
|
|
143
|
+
if (!(await isAccountCategoryAllowed(user)) || (params.category && accountCategory(user) !== params.category))
|
|
144
|
+
return { ok: false, status: 403, message: "This account cannot sign in through this category. Contact an administrator." };
|
|
145
|
+
return { ok: true, userId: params.userId, user, email: params.email ?? user.mail, createdGuest: params.createdGuest ?? false };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Administrator-issued tokens name the account itself, so they work without an email address. */
|
|
149
|
+
const verifyAccountToken = async (userId: string): Promise<VerifyResult> => {
|
|
150
|
+
const [row] = await sql<{ expired: boolean }[]>`
|
|
151
|
+
SELECT account_expires IS NOT NULL AND account_expires <= now() AS expired
|
|
152
|
+
FROM auth.users
|
|
153
|
+
WHERE id = ${userId}::uuid AND provider = 'local'
|
|
154
|
+
`;
|
|
155
|
+
if (!row) return { ok: false, status: 401, message: "Invalid or expired token" };
|
|
156
|
+
if (row.expired) return { ok: false, status: 403, message: "Your account has expired. Contact an administrator." };
|
|
157
|
+
return signIn({ userId });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const verify = async (params: { token: string }): Promise<VerifyResult> => {
|
|
126
161
|
const payload = await providers.local.auth.consumeMagicLinkToken(params.token);
|
|
127
162
|
if (!payload) {
|
|
128
163
|
return { ok: false, status: 401, message: "Invalid or expired token" };
|
|
129
164
|
}
|
|
165
|
+
if ("userId" in payload) return verifyAccountToken(payload.userId);
|
|
130
166
|
|
|
131
167
|
const { email, category } = payload;
|
|
132
168
|
const normalizedEmail = normalizeEmail(email);
|
|
@@ -186,12 +222,5 @@ export const verify = async (params: {
|
|
|
186
222
|
createdGuest = true;
|
|
187
223
|
}
|
|
188
224
|
|
|
189
|
-
|
|
190
|
-
if (!user) {
|
|
191
|
-
return { ok: false, status: 401, message: "User not found" };
|
|
192
|
-
}
|
|
193
|
-
if (!(await isAccountCategoryAllowed(user)) || (category && accountCategory(user) !== category))
|
|
194
|
-
return { ok: false, status: 403, message: "This account cannot sign in through this category. Contact an administrator." };
|
|
195
|
-
|
|
196
|
-
return { ok: true, userId, user, email, createdGuest };
|
|
225
|
+
return signIn({ userId, email, createdGuest, category });
|
|
197
226
|
};
|
|
@@ -8,6 +8,7 @@ import { logger } from "../logging";
|
|
|
8
8
|
import { providers } from "../providers";
|
|
9
9
|
import { session } from "../session";
|
|
10
10
|
import * as settings from "../settings";
|
|
11
|
+
import * as deferred from "./deferred";
|
|
11
12
|
import * as ipaFlow from "./ipa";
|
|
12
13
|
import type { AuthNotificationDeliveryResult, AuthNotificationSender } from "./notification-sender";
|
|
13
14
|
|
|
@@ -15,7 +16,8 @@ const log = logger("auth:password-reset");
|
|
|
15
16
|
|
|
16
17
|
const REQUEST_TTL_SECONDS = 900;
|
|
17
18
|
const REQUEST_COOLDOWN_SECONDS = 60;
|
|
18
|
-
const GENERIC_MESSAGE =
|
|
19
|
+
const GENERIC_MESSAGE =
|
|
20
|
+
"If this account can reset a password, a reset link has been sent. If no message arrives, contact an administrator.";
|
|
19
21
|
|
|
20
22
|
type ResetTarget = {
|
|
21
23
|
userId: string;
|
|
@@ -161,27 +163,42 @@ const changeTemporaryPassword = async (params: {
|
|
|
161
163
|
};
|
|
162
164
|
};
|
|
163
165
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
type ResetRequest = { email: string; redirectTo?: string; locale?: string };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Accepts a reset request without revealing whether an eligible account
|
|
170
|
+
* exists. The lookup and delivery run after this returns, so every address
|
|
171
|
+
* gets the same message in the same time. `settled` resolves when that
|
|
172
|
+
* bounded background work has finished or was dropped under overload; it
|
|
173
|
+
* never rejects.
|
|
174
|
+
*/
|
|
175
|
+
export const request = (
|
|
176
|
+
params: ResetRequest,
|
|
166
177
|
notificationSender: AuthNotificationSender,
|
|
167
|
-
):
|
|
168
|
-
|
|
178
|
+
): { ok: true; message: string; settled: Promise<void> } => ({
|
|
179
|
+
ok: true,
|
|
180
|
+
message: GENERIC_MESSAGE,
|
|
181
|
+
settled: deferred.run("Password reset request", () => deliverResetLink(params, notificationSender)),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const deliverResetLink = async (params: ResetRequest, notificationSender: AuthNotificationSender): Promise<void> => {
|
|
185
|
+
if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return;
|
|
169
186
|
const email = normalizeEmail(params.email);
|
|
170
187
|
if (await isInCooldown(email)) {
|
|
171
188
|
log.info("Password reset request ignored during cooldown");
|
|
172
|
-
return
|
|
189
|
+
return;
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
const freeIpaConfig = await getFreeIpaConfig();
|
|
176
193
|
if (!freeIpaConfig.enabled || !freeIpaConfig.configured) {
|
|
177
194
|
log.info("Password reset request accepted while FreeIPA is unavailable");
|
|
178
|
-
return
|
|
195
|
+
return;
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
const target = await resolveResetTarget(email);
|
|
182
199
|
if (!target) {
|
|
183
200
|
log.info("Password reset request accepted without eligible target");
|
|
184
|
-
return
|
|
201
|
+
return;
|
|
185
202
|
}
|
|
186
203
|
|
|
187
204
|
try {
|
|
@@ -194,7 +211,6 @@ export const request = async (
|
|
|
194
211
|
error: error instanceof Error ? error.message : String(error),
|
|
195
212
|
});
|
|
196
213
|
}
|
|
197
|
-
return { ok: true, message: GENERIC_MESSAGE };
|
|
198
214
|
};
|
|
199
215
|
|
|
200
216
|
export const complete = async (params: { token?: string; newPassword: string }): Promise<ResetAttemptSuccess | ResetAttemptFailure> => {
|
package/src/services/index.ts
CHANGED
|
@@ -194,7 +194,7 @@ export type {
|
|
|
194
194
|
|
|
195
195
|
export { latestTopicCursor } from "./topic-cursor";
|
|
196
196
|
export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
|
|
197
|
-
export { appApproval, type AppDeviceEnrollmentNotice } from "./app-approval";
|
|
197
|
+
export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
|
|
198
198
|
export { legalConsent } from "./legal-consent";
|
|
199
199
|
|
|
200
200
|
/** Core-owned app bar administration; service methods enforce administrator access. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as settings from "../settings";
|
|
2
|
+
import { offlineHtml } from "./offline-html";
|
|
2
3
|
|
|
3
4
|
export type GotenbergRenderErrorCode =
|
|
4
5
|
| "bad_input"
|
|
@@ -131,9 +132,9 @@ function htmlForm(input: RenderHtmlToPdfInput, config: GotenbergConfig): FormDat
|
|
|
131
132
|
(input.assets ?? []).reduce((sum, asset) => sum + asset.data.size, 0);
|
|
132
133
|
if (total > config.maxHtmlBytes) throw new GotenbergRenderError("html_too_large", "HTML and assets exceed the configured input budget.");
|
|
133
134
|
const form = new FormData();
|
|
134
|
-
form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
|
|
135
|
-
if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
|
|
136
|
-
if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
|
|
135
|
+
form.append("files", new Blob([offlineHtml(input.html)], { type: "text/html" }), "index.html");
|
|
136
|
+
if (input.headerHtml?.trim()) form.append("files", new Blob([offlineHtml(input.headerHtml)], { type: "text/html" }), "header.html");
|
|
137
|
+
if (input.footerHtml?.trim()) form.append("files", new Blob([offlineHtml(input.footerHtml)], { type: "text/html" }), "footer.html");
|
|
137
138
|
const names = new Set(["index.html", "header.html", "footer.html", "factur-x.xml"]);
|
|
138
139
|
for (const asset of input.assets ?? []) {
|
|
139
140
|
const name = fileName(asset.name);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline mode for every HTML document Cloud sends to Gotenberg.
|
|
3
|
+
*
|
|
4
|
+
* Chromium in Gotenberg would otherwise run scripts and load every URL the
|
|
5
|
+
* HTML names. The prepended policy allows only inline styles plus local files
|
|
6
|
+
* and data URLs for styles, images, and fonts. Local files are the request's
|
|
7
|
+
* named assets; Gotenberg's default deny list keeps file access inside its
|
|
8
|
+
* working directory under `/tmp`. Removing elements that run code, navigate,
|
|
9
|
+
* embed documents, or open connections is a second layer that does not rely
|
|
10
|
+
* on the policy, which matters for header and footer templates that Chromium
|
|
11
|
+
* renders apart from the document.
|
|
12
|
+
*
|
|
13
|
+
* MathML (`math`) and the SVG elements that hold HTML (`foreignObject`,
|
|
14
|
+
* `desc`) go as well; they have no print use in Cloud documents. SVG shapes,
|
|
15
|
+
* images, `title`, and links stay, so graphics still render.
|
|
16
|
+
*/
|
|
17
|
+
const POLICY =
|
|
18
|
+
"default-src 'none'; style-src 'unsafe-inline' file: data:; img-src file: data:; font-src file: data:; base-uri 'none'; form-action 'none'";
|
|
19
|
+
const OFFLINE_META = `<meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${POLICY}">`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The caller's doctype when it leads the document after whitespace, comments,
|
|
23
|
+
* and an XML declaration. Repeating it ahead of the policy keeps the caller's
|
|
24
|
+
* rendering mode: HTML without a doctype still renders in quirks mode. Comments
|
|
25
|
+
* end where HTML ends them, including `<!-->` and `<!--->`; `<?…>` ends at the
|
|
26
|
+
* first `>`. A single forward scan keeps the time linear in the input size.
|
|
27
|
+
*/
|
|
28
|
+
const leadingDoctype = (html: string): string => {
|
|
29
|
+
let at = 0;
|
|
30
|
+
for (;;) {
|
|
31
|
+
while (/\s/.test(html.charAt(at))) at += 1;
|
|
32
|
+
if (html.startsWith("<!--", at)) {
|
|
33
|
+
if (html.startsWith(">", at + 4)) at += 5;
|
|
34
|
+
else if (html.startsWith("->", at + 4)) at += 6;
|
|
35
|
+
else {
|
|
36
|
+
const dashes = html.indexOf("-->", at + 4);
|
|
37
|
+
const bang = html.indexOf("--!>", at + 4);
|
|
38
|
+
if (dashes < 0 && bang < 0) return "";
|
|
39
|
+
at = dashes >= 0 && (bang < 0 || dashes < bang) ? dashes + 3 : bang + 4;
|
|
40
|
+
}
|
|
41
|
+
} else if (html.startsWith("<?", at)) {
|
|
42
|
+
const end = html.indexOf(">", at + 2);
|
|
43
|
+
if (end < 0) return "";
|
|
44
|
+
at = end + 1;
|
|
45
|
+
} else {
|
|
46
|
+
if (html.slice(at, at + 9).toLowerCase() !== "<!doctype") return "";
|
|
47
|
+
const end = html.indexOf(">", at + 9);
|
|
48
|
+
return end < 0 ? "" : html.slice(at, end + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** An empty comment stands in for a removed element, so the text on either side stays separate. */
|
|
54
|
+
const REMOVED = "<!---->";
|
|
55
|
+
|
|
56
|
+
export const offlineHtml = (html: string): string =>
|
|
57
|
+
leadingDoctype(html) +
|
|
58
|
+
OFFLINE_META +
|
|
59
|
+
new HTMLRewriter()
|
|
60
|
+
// Chromium parses noscript content as markup when JavaScript is disabled.
|
|
61
|
+
// math, foreignObject, and desc: see the module comment.
|
|
62
|
+
.on("script, noscript, meta, base, iframe, frame, frameset, object, embed, math, foreignObject, desc", {
|
|
63
|
+
element(element) {
|
|
64
|
+
element.replace(REMOVED, { html: true });
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
.on("link", {
|
|
68
|
+
element(element) {
|
|
69
|
+
// The policy limits a stylesheet to named assets and data URLs. Other
|
|
70
|
+
// link types can preload, prefetch, or connect outside of it.
|
|
71
|
+
if (element.getAttribute("rel")?.trim().toLowerCase() !== "stylesheet") element.replace(REMOVED, { html: true });
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
.transform(html);
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { redis } from "bun";
|
|
2
2
|
|
|
3
|
+
/** Requested by email or username on the login page; resolves the account through its email at use. */
|
|
4
|
+
export type EmailLoginTokenPayload = { email: string; category?: "guest" | "login" };
|
|
5
|
+
/** Issued by an administrator for one account; works without an email address. */
|
|
6
|
+
export type AccountLoginTokenPayload = { userId: string };
|
|
7
|
+
|
|
8
|
+
const loginTokenKey = (token: string) => `email-login:${token}`;
|
|
9
|
+
|
|
3
10
|
export const createMagicLinkToken = async (params: {
|
|
4
11
|
email: string;
|
|
5
12
|
category?: "guest" | "login";
|
|
6
13
|
ttlSeconds?: number;
|
|
7
14
|
}): Promise<string> => {
|
|
8
15
|
const token = crypto.randomUUID();
|
|
9
|
-
await redis.set(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
await redis.set(loginTokenKey(token), JSON.stringify({ email: params.email, category: params.category }), "EX", params.ttlSeconds ?? 300);
|
|
17
|
+
return token;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Shares the email-link key space, so both token kinds use the same link and verify endpoint. */
|
|
21
|
+
export const createAccountLoginToken = async (params: { userId: string; ttlSeconds?: number }): Promise<string> => {
|
|
22
|
+
const token = crypto.randomUUID();
|
|
23
|
+
await redis.set(loginTokenKey(token), JSON.stringify({ userId: params.userId }), "EX", params.ttlSeconds ?? 300);
|
|
15
24
|
return token;
|
|
16
25
|
};
|
|
17
26
|
|
|
18
|
-
export const consumeMagicLinkToken = async (token: string): Promise<
|
|
19
|
-
const raw = await redis.getdel(
|
|
27
|
+
export const consumeMagicLinkToken = async (token: string): Promise<EmailLoginTokenPayload | AccountLoginTokenPayload | null> => {
|
|
28
|
+
const raw = await redis.getdel(loginTokenKey(token));
|
|
20
29
|
if (!raw) return null;
|
|
21
|
-
return JSON.parse(raw) as
|
|
30
|
+
return JSON.parse(raw) as EmailLoginTokenPayload | AccountLoginTokenPayload;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
type PasswordResetPayload = {
|