@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.
@@ -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: string;
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
- await lockAccountEmail(tx, email);
47
- if ((await findAccountsByEmail(tx, email)).length > 0) return emailAlreadyUsed();
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
- mail?: string;
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
- if (params.data.mail !== undefined) await lockAccountEmail(tx, params.data.mail);
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 session.revokeAllForUser(user.id);
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 session.revokeAllForUser(identity.userId);
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
- const [updated] = await sql<Array<{ id: string }>>`
229
- UPDATE auth.users SET auth_epoch = auth_epoch + 1
230
- WHERE id = ${userId}::uuid RETURNING id
231
- `;
232
- if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
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",
@@ -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