@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
|
@@ -13,12 +13,32 @@ import * as settings from "../../settings";
|
|
|
13
13
|
type DbRow = Record<string, unknown>;
|
|
14
14
|
|
|
15
15
|
export type LocalUserCreateData = {
|
|
16
|
-
email
|
|
16
|
+
/** Blank or missing stores NULL, which the email policy below allows only for full accounts. */
|
|
17
|
+
email?: string | null;
|
|
17
18
|
givenname?: string;
|
|
18
19
|
sn?: string;
|
|
19
20
|
displayName?: string;
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
/** Created and repaired by the emergency admin login; it never needs an email address. */
|
|
24
|
+
export const EMERGENCY_ADMIN_UID = "admin";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A local account may lack an email only as a full account while
|
|
28
|
+
* `user.local_email_optional` is on. The setting guards writes only, so
|
|
29
|
+
* existing accounts stay valid when it is turned off.
|
|
30
|
+
*/
|
|
31
|
+
const missingEmailError = async (profile: UserProfile, uid?: string): Promise<MutationResult<never> | null> => {
|
|
32
|
+
if (uid === EMERGENCY_ADMIN_UID) return null;
|
|
33
|
+
if (profile !== "user") return { ok: false, error: "Guest accounts need an email address.", status: 400 };
|
|
34
|
+
if (await settings.get<boolean>("user.local_email_optional")) return null;
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: "Local accounts need an email address. Administrators can allow accounts without one in the account settings.",
|
|
38
|
+
status: 400,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
22
42
|
const createLocalUid = async (): Promise<string> => {
|
|
23
43
|
const abbrLen = await settings.get<number>("user.abbr_length");
|
|
24
44
|
return generateUniqueAbbreviation(abbrLen);
|
|
@@ -31,7 +51,11 @@ export const create = async (params: {
|
|
|
31
51
|
admin?: boolean;
|
|
32
52
|
actor?: AuditActor;
|
|
33
53
|
}): Promise<MutationResult<{ id: string }>> => {
|
|
34
|
-
const email = normalizeAccountEmail(params.data.email);
|
|
54
|
+
const email = normalizeAccountEmail(params.data.email ?? "") || null;
|
|
55
|
+
if (!email) {
|
|
56
|
+
const missing = await missingEmailError(params.profile);
|
|
57
|
+
if (missing) return missing;
|
|
58
|
+
}
|
|
35
59
|
const uid = await createLocalUid();
|
|
36
60
|
const admin = resolveStoredAdminState({
|
|
37
61
|
provider: "local",
|
|
@@ -43,8 +67,10 @@ export const create = async (params: {
|
|
|
43
67
|
return await writeLocalAccount(async (tx) => {
|
|
44
68
|
if (!(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
|
|
45
69
|
return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
if (email) {
|
|
71
|
+
await lockAccountEmail(tx, email);
|
|
72
|
+
if ((await findAccountsByEmail(tx, email)).length > 0) return emailAlreadyUsed();
|
|
73
|
+
}
|
|
48
74
|
const rows = await tx<{ id: string }[]>`
|
|
49
75
|
INSERT INTO auth.users (
|
|
50
76
|
uid,
|
|
@@ -118,18 +144,23 @@ export const update = async (params: {
|
|
|
118
144
|
givenname?: string;
|
|
119
145
|
sn?: string;
|
|
120
146
|
displayName?: string;
|
|
121
|
-
|
|
147
|
+
/** `null` or blank removes the address when the email policy below allows it. */
|
|
148
|
+
mail?: string | null;
|
|
122
149
|
};
|
|
123
150
|
}): Promise<MutationResult<void>> => {
|
|
124
151
|
return sql.begin(async (tx) => {
|
|
125
|
-
|
|
152
|
+
const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail ?? "");
|
|
153
|
+
if (email) await lockAccountEmail(tx, email);
|
|
126
154
|
else await tx`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
|
|
127
|
-
const [existing] = await tx<{ mail: string | null }[]>`
|
|
128
|
-
SELECT mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
|
|
155
|
+
const [existing] = await tx<{ uid: string; profile: UserProfile; mail: string | null }[]>`
|
|
156
|
+
SELECT uid, profile, mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
|
|
129
157
|
`;
|
|
130
158
|
if (!existing) return { ok: false, error: "Local user not found", status: 404 };
|
|
131
|
-
const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail);
|
|
132
159
|
const changed = email !== undefined && email !== normalizeAccountEmail(existing.mail ?? "");
|
|
160
|
+
if (changed && !email) {
|
|
161
|
+
const missing = await missingEmailError(existing.profile, existing.uid);
|
|
162
|
+
if (missing) return missing;
|
|
163
|
+
}
|
|
133
164
|
if (changed && email && (await findAccountsByEmail(tx, email)).some((row) => row.id !== params.id)) return emailAlreadyUsed();
|
|
134
165
|
await tx`
|
|
135
166
|
UPDATE auth.users
|
|
@@ -150,8 +181,8 @@ export const setProfile = async (params: {
|
|
|
150
181
|
actor?: AuditActor;
|
|
151
182
|
}): Promise<MutationResult<void>> => {
|
|
152
183
|
const result = await writeLocalAccount(async (tx) => {
|
|
153
|
-
const rows = await tx<{ provider: string; profile: UserProfile; admin: boolean }[]>`
|
|
154
|
-
SELECT provider, profile, admin
|
|
184
|
+
const rows = await tx<{ provider: string; profile: UserProfile; admin: boolean; mail: string | null }[]>`
|
|
185
|
+
SELECT provider, profile, admin, mail
|
|
155
186
|
FROM auth.users
|
|
156
187
|
WHERE id = ${params.id}::uuid
|
|
157
188
|
`;
|
|
@@ -159,6 +190,8 @@ export const setProfile = async (params: {
|
|
|
159
190
|
if (rows[0]!.provider !== "local") {
|
|
160
191
|
return { ok: false, error: "Only local accounts can change profile locally", status: 400 };
|
|
161
192
|
}
|
|
193
|
+
if (params.profile === "guest" && !rows[0]!.mail?.trim())
|
|
194
|
+
return { ok: false, error: "Guest accounts need an email address.", status: 400 };
|
|
162
195
|
if (rows[0]!.profile !== params.profile && !(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
|
|
163
196
|
return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
|
|
164
197
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
4
4
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -6,6 +6,7 @@ import { env } from "../../config/env";
|
|
|
6
6
|
import type { User } from "../../contracts/shared";
|
|
7
7
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
8
8
|
import { isAccountExpired } from "../account-model";
|
|
9
|
+
import { audit } from "../audit";
|
|
9
10
|
import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "../identity/constants";
|
|
10
11
|
import { invalidateIdentitySignerCache, prepareIdentitySigner } from "../identity/key-ring";
|
|
11
12
|
import { getIdentityRuntimeConfig } from "../identity/runtime-config";
|
|
@@ -59,6 +60,17 @@ const requestCached = <T>(
|
|
|
59
60
|
const verifyForRequest = (c: Context, token: string): Promise<CloudSessionClaims | null> =>
|
|
60
61
|
requestCached(verificationByRequest, c, token, () => verifyCredential(token));
|
|
61
62
|
|
|
63
|
+
/** Moves the account's authentication epoch, which ends every session issued before it.
|
|
64
|
+
* Request-time expiry uses only this: device operations check expiry themselves and an
|
|
65
|
+
* administrator can lift it, so revoking devices would depend on which request came first. */
|
|
66
|
+
const endSessions = async (db: SQL, userId: string): Promise<void> => {
|
|
67
|
+
const [updated] = await db<Array<{ id: string }>>`
|
|
68
|
+
UPDATE auth.users SET auth_epoch = auth_epoch + 1
|
|
69
|
+
WHERE id = ${userId}::uuid RETURNING id
|
|
70
|
+
`;
|
|
71
|
+
if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
|
|
72
|
+
};
|
|
73
|
+
|
|
62
74
|
const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
|
|
63
75
|
const { groupsAdmin } = await getIdentityRuntimeConfig();
|
|
64
76
|
const user = await loadJwtSessionUser({
|
|
@@ -69,7 +81,7 @@ const authenticateVerified = async (credential: CloudSessionClaims): Promise<Aut
|
|
|
69
81
|
});
|
|
70
82
|
if (!user) return null;
|
|
71
83
|
if (isAccountExpired(user.accountExpires)) {
|
|
72
|
-
await
|
|
84
|
+
await endSessions(sql, user.id);
|
|
73
85
|
return null;
|
|
74
86
|
}
|
|
75
87
|
return {
|
|
@@ -94,7 +106,7 @@ const authenticateUserId = async (token: string): Promise<string | null> => {
|
|
|
94
106
|
const identity = await loadJwtSessionIdentity({ userId: credential.sub, sid: credential.sid, authEpoch: credential.auth_epoch });
|
|
95
107
|
if (!identity) return null;
|
|
96
108
|
if (isAccountExpired(identity.accountExpires)) {
|
|
97
|
-
await
|
|
109
|
+
await endSessions(sql, identity.userId);
|
|
98
110
|
return null;
|
|
99
111
|
}
|
|
100
112
|
return identity.userId;
|
|
@@ -224,12 +236,25 @@ export const session = {
|
|
|
224
236
|
deleteCookie(c, "session_token", { path: "/" });
|
|
225
237
|
},
|
|
226
238
|
|
|
239
|
+
/** Signs the account out everywhere: every session and every paired sign-in device, audited per device. */
|
|
227
240
|
revokeAllForUser: async (userId: string): Promise<void> => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
241
|
+
await sql.begin(async (tx) => {
|
|
242
|
+
await endSessions(tx, userId);
|
|
243
|
+
// A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
|
|
244
|
+
const devices = await tx<Array<{ id: string }>>`
|
|
245
|
+
UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
246
|
+
`;
|
|
247
|
+
for (const device of devices)
|
|
248
|
+
await audit.record(
|
|
249
|
+
{
|
|
250
|
+
action: "auth.app.device.revoke",
|
|
251
|
+
outcome: "allowed",
|
|
252
|
+
target: { type: "app_device", id: device.id },
|
|
253
|
+
metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
|
|
254
|
+
},
|
|
255
|
+
tx,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
233
258
|
},
|
|
234
259
|
|
|
235
260
|
authenticate: authenticateToken,
|
|
@@ -502,6 +502,13 @@ export const CORE_SETTINGS = {
|
|
|
502
502
|
default: false,
|
|
503
503
|
description: "Allow creating a local guest account automatically during first email sign-in when no local account exists yet.",
|
|
504
504
|
},
|
|
505
|
+
"user.local_email_optional": {
|
|
506
|
+
kind: "boolean",
|
|
507
|
+
label: "Allow local accounts without email",
|
|
508
|
+
default: false,
|
|
509
|
+
description:
|
|
510
|
+
"Administrators may create local full accounts without an email address or remove it later. These accounts sign in with a paired app or a passkey; email sign-in links, welcome emails and email notifications are unavailable to them. Guests and FreeIPA accounts still need an email address. Turning this off leaves existing accounts unchanged.",
|
|
511
|
+
},
|
|
505
512
|
"user.abbr_length": {
|
|
506
513
|
kind: "number",
|
|
507
514
|
label: "Username Abbreviation Length",
|
package/src/styles/global.css
CHANGED
|
@@ -278,6 +278,11 @@ solid-island {
|
|
|
278
278
|
@media (max-width: 1023px) {
|
|
279
279
|
.cloud-app-canvas {
|
|
280
280
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
281
|
+
}
|
|
282
|
+
/* The flat surface replaces the app gradient in both themes. The dark
|
|
283
|
+
selector matches the specificity of the dark gradient above. */
|
|
284
|
+
.cloud-app-canvas,
|
|
285
|
+
.dark .cloud-app-canvas {
|
|
281
286
|
background: var(--ui-surface);
|
|
282
287
|
}
|
|
283
288
|
/* Below lg the document is the page scroller; reserve its scrollbar gutter
|