@k2b/cloud 0.22.0 → 0.23.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.
Files changed (88) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/define-app.ts +81 -7
  7. package/src/_internal/page-responses.ts +19 -3
  8. package/src/_internal/registry-validation.ts +6 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/static-assets.ts +18 -3
  11. package/src/access/ResourceApiKeys.tsx +30 -30
  12. package/src/ai/capability-execution.ts +5 -0
  13. package/src/ai/client/controller.ts +17 -3
  14. package/src/ai/client/transport.ts +47 -3
  15. package/src/ai/live-routes.ts +2 -2
  16. package/src/ai/pdf-render.ts +2 -22
  17. package/src/ai/routes.ts +5 -4
  18. package/src/ai/stream.ts +89 -65
  19. package/src/api/app-approval.ts +5 -1
  20. package/src/api/index.ts +3 -0
  21. package/src/api/me.ts +19 -5
  22. package/src/api/pwa-phone.ts +161 -0
  23. package/src/api/pwa.ts +179 -0
  24. package/src/browser/app-session.ts +69 -0
  25. package/src/browser/live-connection.ts +211 -0
  26. package/src/browser/live-websocket.ts +31 -11
  27. package/src/browser/live.ts +2 -0
  28. package/src/contracts/app.ts +10 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/contracts/pwa-paths.ts +6 -0
  31. package/src/contracts/pwa.ts +156 -0
  32. package/src/contracts/registry.ts +7 -0
  33. package/src/contracts/shared.ts +7 -1
  34. package/src/events/index.ts +2 -0
  35. package/src/events/live-engine.ts +826 -0
  36. package/src/events/live-protocol.ts +31 -0
  37. package/src/events/live.ts +298 -0
  38. package/src/server/actor.ts +7 -0
  39. package/src/server/api-client.ts +23 -2
  40. package/src/server/index.ts +3 -1
  41. package/src/server/middleware/auth.ts +80 -8
  42. package/src/server/middleware/openapi.ts +2 -1
  43. package/src/server/services/access.ts +49 -0
  44. package/src/server/services/index.ts +1 -0
  45. package/src/services/app-approval.ts +6 -25
  46. package/src/services/audit/index.ts +2 -0
  47. package/src/services/branding/app-icon-source.ts +40 -0
  48. package/src/services/branding/app-icons.ts +112 -0
  49. package/src/services/branding/icon-render-worker.ts +69 -0
  50. package/src/services/branding/icon-svg.ts +22 -0
  51. package/src/services/identity/invocation-actor.ts +8 -5
  52. package/src/services/identity/invocation-authority.ts +1 -0
  53. package/src/services/identity/invocation-token.ts +6 -0
  54. package/src/services/index.ts +1 -0
  55. package/src/services/outbox.ts +150 -30
  56. package/src/services/pairing-secret.ts +16 -0
  57. package/src/services/pwa-devices.ts +574 -0
  58. package/src/services/session/index.ts +212 -57
  59. package/src/services/session/recent.ts +42 -0
  60. package/src/services/session/user.ts +15 -5
  61. package/src/shared/index.ts +1 -0
  62. package/src/{browser → shared}/locale-preference.ts +1 -1
  63. package/src/shared/markdown/extensions/info-blocks.ts +18 -47
  64. package/src/shared/markdown/index.ts +5 -14
  65. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  66. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  67. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  68. package/src/ssr/Layout.tsx +10 -12
  69. package/src/ssr/LayoutHeader.tsx +14 -2
  70. package/src/ssr/LayoutHelp.tsx +2 -4
  71. package/src/ssr/LayoutRail.tsx +13 -4
  72. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  73. package/src/ssr/MobileProfileActions.tsx +19 -13
  74. package/src/ssr/PageError.tsx +23 -1
  75. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  76. package/src/ssr/PwaLayout.tsx +69 -0
  77. package/src/ssr/PwaRuntime.island.tsx +105 -0
  78. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  79. package/src/ssr/app-navigation.ts +25 -10
  80. package/src/ssr/index.ts +8 -1
  81. package/src/ssr/layout-context.ts +1 -1
  82. package/src/ssr/preference-controller.ts +2 -2
  83. package/src/ssr/profile-actions.ts +3 -1
  84. package/src/ssr/profile-preferences-messages.ts +2 -0
  85. package/src/ssr/pwa-messages.ts +24 -0
  86. package/src/styles/global.css +6 -0
  87. package/src/styles/utilities-feedback.css +2 -2
  88. package/scripts/build-pdf-renderer.ts +0 -39
@@ -3,26 +3,40 @@ import type { Context } from "hono";
3
3
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4
4
  import { HTTPException } from "hono/http-exception";
5
5
  import { env } from "../../config/env";
6
+ import { PWA_AUTH_PATH, PWA_COOKIES } from "../../contracts/pwa";
6
7
  import type { User } from "../../contracts/shared";
7
8
  import { isAccountCategoryAllowed } from "../account-category-policy";
8
9
  import { isAccountExpired } from "../account-model";
9
10
  import { audit } from "../audit";
10
11
  import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "../identity/constants";
11
- import { invalidateIdentitySignerCache, prepareIdentitySigner } from "../identity/key-ring";
12
+ import { invalidateIdentitySignerCache, type PreparedIdentitySigner, prepareIdentitySigner } from "../identity/key-ring";
12
13
  import { getIdentityRuntimeConfig } from "../identity/runtime-config";
13
14
  import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
14
15
  import * as settings from "../settings";
15
- import { loadJwtSessionIdentity, loadJwtSessionUser } from "./user";
16
+ import { loadJwtSession, loadJwtSessionIdentity } from "./user";
17
+
18
+ export type SessionKind = "web" | "app";
16
19
 
17
20
  export type SessionData = {
18
21
  userId: string;
19
22
  sid: string;
20
23
  authEpoch: number;
21
24
  expiresAt: string;
25
+ /** `"app"` for a session of a paired phone in the mobile app; derived from the family, never from the cookie. */
26
+ kind: SessionKind;
27
+ /** The paired phone of an app session. */
28
+ deviceId?: string;
22
29
  };
23
30
 
24
31
  export type AuthenticatedSession = { data: SessionData; user: User };
25
32
 
33
+ /** Every request below `/pwa/` except Core's `/pwa/_auth` belongs to the mobile app. */
34
+ export const isAppPagePath = (path: string): boolean =>
35
+ (path === "/pwa" || path.startsWith("/pwa/")) && path !== PWA_AUTH_PATH && !path.startsWith(`${PWA_AUTH_PATH}/`);
36
+
37
+ /** Documents, iframes and form posts. Only the web session navigates outside the app. */
38
+ export const isNavigationRequest = (c: Context): boolean => c.req.header("Sec-Fetch-Mode") === "navigate";
39
+
26
40
  const parseBearer = (header: string | undefined): string | null => {
27
41
  if (!header) return null;
28
42
  const match = header.match(/^Bearer\s+(\S+)$/i);
@@ -73,13 +87,14 @@ const endSessions = async (db: SQL, userId: string): Promise<void> => {
73
87
 
74
88
  const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
75
89
  const { groupsAdmin } = await getIdentityRuntimeConfig();
76
- const user = await loadJwtSessionUser({
90
+ const loaded = await loadJwtSession({
77
91
  userId: credential.sub,
78
92
  sid: credential.sid,
79
93
  authEpoch: credential.auth_epoch,
80
94
  groupsAdmin,
81
95
  });
82
- if (!user) return null;
96
+ if (!loaded) return null;
97
+ const { user, pwaDeviceId } = loaded;
83
98
  if (isAccountExpired(user.accountExpires)) {
84
99
  await endSessions(sql, user.id);
85
100
  return null;
@@ -91,6 +106,8 @@ const authenticateVerified = async (credential: CloudSessionClaims): Promise<Aut
91
106
  sid: credential.sid,
92
107
  authEpoch: credential.auth_epoch,
93
108
  expiresAt: new Date(credential.exp * 1_000).toISOString(),
109
+ kind: pwaDeviceId ? "app" : "web",
110
+ ...(pwaDeviceId ? { deviceId: pwaDeviceId } : {}),
94
111
  },
95
112
  };
96
113
  };
@@ -127,59 +144,84 @@ const revokeToken = async (token: string, requestId?: string | null): Promise<vo
127
144
  }
128
145
  };
129
146
 
130
- const issueJwtSession = async (c: Context, userId: string, ttlSeconds: number, attempt = 0): Promise<string> => {
147
+ export type SessionIssueOptions = {
148
+ ttlSeconds: number;
149
+ /** Pre-chosen family id, for callers that record it in the same transaction. */
150
+ sid?: string;
151
+ /** Binds the family to a paired phone of the mobile app; the device must be active. */
152
+ pwaDeviceId?: string | null;
153
+ /** Web sign-ins update `last_login_local`; app renewals track use per device instead. */
154
+ recordLogin?: boolean;
155
+ };
156
+
157
+ type PreparedFamily = {
158
+ userId: string;
159
+ sid: string;
160
+ authEpoch: number;
161
+ issuedAt: Date;
162
+ expiresAt: Date;
163
+ signer: PreparedIdentitySigner;
164
+ options: SessionIssueOptions;
165
+ };
166
+
167
+ const SIGNER_INACTIVE = "Prepared Cloud session signer is no longer active";
168
+ const isSignerInactive = (error: unknown) => error instanceof Error && error.message === SIGNER_INACTIVE;
169
+
170
+ /** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows. */
171
+ const insertFamily = async (
172
+ tx: SQL,
173
+ c: Context,
174
+ userId: string,
175
+ signer: PreparedIdentitySigner,
176
+ options: SessionIssueOptions,
177
+ ): Promise<PreparedFamily> => {
131
178
  const issuedAt = new Date();
132
- const expiresAt = new Date(issuedAt.getTime() + ttlSeconds * 1_000);
133
- const sid = crypto.randomUUID();
134
- const signer = await prepareIdentitySigner("session");
179
+ const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
180
+ const sid = options.sid ?? crypto.randomUUID();
135
181
  const requestId = c.req.header("x-request-id")?.slice(0, 64) ?? null;
136
182
  const userAgent = c.req.header("user-agent")?.slice(0, 256) ?? null;
137
-
138
- let authEpoch: number;
139
- try {
140
- authEpoch = await sql.begin(async (tx) => {
141
- const [user] = await tx<Array<{ auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>>`
142
- SELECT auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR UPDATE
143
- `;
144
- if (!user) throw new Error("Cannot create a session for an unknown user");
145
- if (!(await isAccountCategoryAllowed(user, tx)))
146
- throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
147
- const epoch = Number(user.auth_epoch);
148
- if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
149
- const [activeKey] = await tx<Array<{ kid: string }>>`
150
- UPDATE auth.signing_keys
151
- SET verify_until = GREATEST(verify_until, ${new Date(
152
- expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS,
153
- )})
154
- WHERE kid = ${signer.kid} AND purpose = 'session' AND state = 'active' AND sign_until > now()
155
- RETURNING kid
156
- `;
157
- if (!activeKey) throw new Error("Prepared Cloud session signer is no longer active");
158
- await tx`
159
- INSERT INTO auth.session_families (
160
- sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending
161
- ) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
162
- NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid))
163
- `;
164
- await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
165
- return epoch;
166
- });
167
- } catch (error) {
168
- if (attempt === 0 && error instanceof Error && error.message === "Prepared Cloud session signer is no longer active") {
169
- invalidateIdentitySignerCache("session", signer.kid);
170
- return issueJwtSession(c, userId, ttlSeconds, 1);
171
- }
172
- throw error;
183
+ const [user] = await tx<Array<{ auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>>`
184
+ SELECT auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR UPDATE
185
+ `;
186
+ if (!user) throw new Error("Cannot create a session for an unknown user");
187
+ if (!(await isAccountCategoryAllowed(user, tx)))
188
+ throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
189
+ const epoch = Number(user.auth_epoch);
190
+ if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
191
+ if (options.pwaDeviceId) {
192
+ // Device revocation locks the same user row first, so a revoked device never gets a new family.
193
+ const [device] = await tx`
194
+ SELECT id FROM auth.pwa_devices WHERE id = ${options.pwaDeviceId}::uuid AND user_id = ${userId}::uuid AND revoked_at IS NULL FOR UPDATE
195
+ `;
196
+ if (!device) throw new Error("Cannot create an app session for an inactive device");
173
197
  }
198
+ const [activeKey] = await tx<Array<{ kid: string }>>`
199
+ UPDATE auth.signing_keys
200
+ SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
201
+ WHERE kid = ${signer.kid} AND purpose = 'session' AND state = 'active' AND sign_until > now()
202
+ RETURNING kid
203
+ `;
204
+ if (!activeKey) throw new Error(SIGNER_INACTIVE);
205
+ await tx`
206
+ INSERT INTO auth.session_families (
207
+ sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id
208
+ ) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
209
+ NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null})
210
+ `;
211
+ if (options.recordLogin !== false) await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
212
+ return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options };
213
+ };
174
214
 
215
+ /** Signs a committed family. Null when the signer was revoked meanwhile; the family is revoked then. */
216
+ const signFamily = async (family: PreparedFamily): Promise<string | null> => {
175
217
  let token: string;
176
218
  try {
177
- token = (await signSessionToken({ userId, sid, authEpoch, issuedAt, expiresAt, signer })).token;
219
+ token = (await signSessionToken(family)).token;
178
220
  } catch (error) {
179
221
  await sql`
180
222
  UPDATE auth.session_families
181
223
  SET revoked_at = now(), revocation_reason = 'issuance_failed'
182
- WHERE sid = ${sid}::uuid AND revoked_at IS NULL
224
+ WHERE sid = ${family.sid}::uuid AND revoked_at IS NULL
183
225
  `;
184
226
  throw error;
185
227
  }
@@ -187,7 +229,7 @@ const issueJwtSession = async (c: Context, userId: string, ttlSeconds: number, a
187
229
  const [stillActive] = await sql<Array<{ active: boolean }>>`
188
230
  SELECT EXISTS(
189
231
  SELECT 1 FROM auth.signing_keys
190
- WHERE kid = ${signer.kid} AND purpose = 'session' AND state = 'active'
232
+ WHERE kid = ${family.signer.kid} AND purpose = 'session' AND state = 'active'
191
233
  ) AS active
192
234
  `;
193
235
  if (stillActive?.active) return token;
@@ -195,28 +237,123 @@ const issueJwtSession = async (c: Context, userId: string, ttlSeconds: number, a
195
237
  await sql`
196
238
  UPDATE auth.session_families
197
239
  SET revoked_at = now(), revocation_reason = 'signer_revoked_during_issuance'
198
- WHERE sid = ${sid}::uuid AND revoked_at IS NULL
240
+ WHERE sid = ${family.sid}::uuid AND revoked_at IS NULL
199
241
  `;
200
- invalidateIdentitySignerCache("session", signer.kid);
201
- if (attempt === 0) return issueJwtSession(c, userId, ttlSeconds, 1);
242
+ invalidateIdentitySignerCache("session", family.signer.kid);
243
+ return null;
244
+ };
245
+
246
+ /** One issuance attempt is retried once when the signing key changes underneath it. */
247
+ const issueJwtSession = async (c: Context, userId: string, options: SessionIssueOptions, attempt = 0): Promise<string> => {
248
+ const signer = await prepareIdentitySigner("session");
249
+ let family: PreparedFamily;
250
+ try {
251
+ family = await sql.begin((tx) => insertFamily(tx, c, userId, signer, options));
252
+ } catch (error) {
253
+ if (attempt === 0 && isSignerInactive(error)) {
254
+ invalidateIdentitySignerCache("session", signer.kid);
255
+ return issueJwtSession(c, userId, options, 1);
256
+ }
257
+ throw error;
258
+ }
259
+ const token = await signFamily(family);
260
+ if (token) return token;
261
+ if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1);
202
262
  throw new Error("Cloud session signer changed repeatedly during issuance");
203
263
  };
204
264
 
265
+ /**
266
+ * Runs `work` in one transaction in which `issue` may insert a single session family next to
267
+ * the caller's own rows, then signs that family after the commit. `work` must lock the user
268
+ * row before any pairing or device row (`issue` locks it too). The transaction is retried
269
+ * once when the signing key changed before the commit; a signer revoked after the commit
270
+ * gets a replacement family in its own transaction.
271
+ */
272
+ const issueInTransaction = async <T>(
273
+ c: Context,
274
+ work: (tx: SQL, issue: (userId: string, options: SessionIssueOptions) => Promise<string>) => Promise<T>,
275
+ ): Promise<{ value: T; token: string | null }> => {
276
+ for (let attempt = 0; ; attempt += 1) {
277
+ const signer = await prepareIdentitySigner("session");
278
+ let family: PreparedFamily | undefined;
279
+ let value: T;
280
+ try {
281
+ value = await sql.begin((tx) =>
282
+ work(tx, async (userId, options) => {
283
+ if (family) throw new Error("Only one session family per transaction");
284
+ family = await insertFamily(tx, c, userId, signer, options);
285
+ return family.sid;
286
+ }),
287
+ );
288
+ } catch (error) {
289
+ if (attempt === 0 && isSignerInactive(error)) {
290
+ invalidateIdentitySignerCache("session", signer.kid);
291
+ continue;
292
+ }
293
+ throw error;
294
+ }
295
+ if (!family) return { value, token: null };
296
+ const token = await signFamily(family);
297
+ return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)) };
298
+ }
299
+ };
300
+
301
+ /** Today's web credential: an explicit non-API bearer, otherwise the `session_token` cookie. */
302
+ const getWebOrBearerToken = (c: Context): string | null => {
303
+ const authorization = c.req.header("Authorization");
304
+ const bearer = parseBearer(authorization);
305
+ if (/^Bearer(?:\s|$)/i.test(authorization ?? "")) return isCloudApiToken(bearer) ? null : bearer;
306
+ return getCookie(c, "session_token") ?? null;
307
+ };
308
+
205
309
  export const session = {
310
+ /**
311
+ * The credential of this request. Below `/pwa/` (except `/pwa/_auth`) only the app session
312
+ * counts. Navigations elsewhere use the web session only. Other requests prefer the web
313
+ * session and fall back to the app session.
314
+ */
206
315
  getToken: (c: Context): string | null => {
207
- const authorization = c.req.header("Authorization");
208
- const bearer = parseBearer(authorization);
209
- if (/^Bearer(?:\s|$)/i.test(authorization ?? "")) return isCloudApiToken(bearer) ? null : bearer;
210
- return getCookie(c, "session_token") ?? null;
316
+ if (isAppPagePath(c.req.path)) return session.getAppToken(c);
317
+ const token = getWebOrBearerToken(c);
318
+ if (token || c.req.header("Authorization") || isNavigationRequest(c)) return token;
319
+ return session.getAppToken(c);
320
+ },
321
+
322
+ /**
323
+ * The app session to try when `token` (from `getToken`) did not authenticate: only for the web
324
+ * cookie of a fetch outside `/pwa/` without an `Authorization` header. A copied, expired or revoked
325
+ * web cookie must not hide a valid app session of the mobile app.
326
+ */
327
+ appSessionFallback: (c: Context, token: string | null): string | null => {
328
+ const appToken = session.getAppToken(c);
329
+ if (!appToken || !token || token === appToken || isAppPagePath(c.req.path)) return null;
330
+ if (token !== session.getWebToken(c) || c.req.header("Authorization") || isNavigationRequest(c)) return null;
331
+ return appToken;
211
332
  },
212
333
 
334
+ /**
335
+ * `getToken` with the validity fallback of the auth middleware, for handlers that authenticate
336
+ * the token themselves (WebSockets, streams). Costs one extra check only while the request also
337
+ * carries an app session.
338
+ */
339
+ resolveToken: async (c: Context): Promise<string | null> => {
340
+ const token = session.getToken(c);
341
+ const fallback = session.appSessionFallback(c, token);
342
+ if (!token || !fallback) return token;
343
+ return (await session.authenticateRequest(c, token)) ? token : fallback;
344
+ },
345
+
346
+ getWebToken: (c: Context): string | null => getCookie(c, "session_token") ?? null,
347
+
348
+ getAppToken: (c: Context): string | null => getCookie(c, PWA_COOKIES.session) ?? null,
349
+
213
350
  getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
214
351
 
215
352
  create: async (c: Context, userId: string): Promise<string> => {
216
353
  const expiryHours = await settings.get<number>("user.session.expiry_hours");
217
354
  if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
218
355
  const ttlSeconds = Math.floor(expiryHours * 60 * 60);
219
- const clientToken = await issueJwtSession(c, userId, ttlSeconds);
356
+ const clientToken = await issueJwtSession(c, userId, { ttlSeconds });
220
357
 
221
358
  setCookie(c, "session_token", clientToken, {
222
359
  httpOnly: true,
@@ -230,16 +367,34 @@ export const session = {
230
367
 
231
368
  revoke: revokeToken,
232
369
 
370
+ /** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
233
371
  delete: async (c: Context): Promise<void> => {
234
- const token = session.getToken(c);
372
+ const token = getWebOrBearerToken(c);
235
373
  if (token) await revokeToken(token, c.req.header("x-request-id"));
236
374
  deleteCookie(c, "session_token", { path: "/" });
237
375
  },
238
376
 
239
- /** Signs the account out everywhere: every session and every paired sign-in device, audited per device. */
377
+ issueInTransaction,
378
+
379
+ /** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device. */
240
380
  revokeAllForUser: async (userId: string): Promise<void> => {
241
381
  await sql.begin(async (tx) => {
242
382
  await endSessions(tx, userId);
383
+ // The epoch change already ends the phones; revoking them keeps lists and the audit log truthful.
384
+ const phones = await tx<Array<{ id: string }>>`
385
+ UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = 'sign_out_everywhere'
386
+ WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
387
+ `;
388
+ for (const phone of phones)
389
+ await audit.record(
390
+ {
391
+ action: "auth.pwa.device.revoke",
392
+ outcome: "allowed",
393
+ target: { type: "pwa_device", id: phone.id },
394
+ metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
395
+ },
396
+ tx,
397
+ );
243
398
  // A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
244
399
  const devices = await tx<Array<{ id: string }>>`
245
400
  UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
@@ -0,0 +1,42 @@
1
+ import type { SQL } from "bun";
2
+ import { isAccountCategoryAllowed } from "../account-category-policy";
3
+
4
+ export type EligibleAccount = {
5
+ id: string;
6
+ uid: string;
7
+ display_name: string | null;
8
+ provider: "local" | "ipa";
9
+ profile: "guest" | "user";
10
+ auth_epoch: string;
11
+ account_expires: Date | null;
12
+ };
13
+
14
+ type Reject = (code: "REAUTHENTICATE" | "FORBIDDEN") => never;
15
+
16
+ /** The account exists, has not expired and its category is enabled. */
17
+ export const requireEligibleAccount = async (tx: SQL, userId: string, reject: Reject): Promise<EligibleAccount> => {
18
+ const [row] = await tx<EligibleAccount[]>`
19
+ SELECT id, uid, display_name, provider, profile, auth_epoch, account_expires FROM auth.users WHERE id = ${userId}::uuid
20
+ `;
21
+ if (!row || (row.account_expires && new Date(row.account_expires).getTime() <= Date.now()) || !(await isAccountCategoryAllowed(row, tx)))
22
+ return reject("FORBIDDEN");
23
+ return row;
24
+ };
25
+
26
+ /**
27
+ * The web session family `sid` is valid and was issued within `seconds`. App sessions of the
28
+ * mobile app never count: they are renewed daily without a sign-in.
29
+ */
30
+ export const requireRecentWebSession = async (
31
+ tx: SQL,
32
+ actor: { userId: string; sid: string },
33
+ seconds: number,
34
+ reject: Reject,
35
+ ): Promise<EligibleAccount> => {
36
+ const [row] = await tx`SELECT sid FROM auth.session_families f JOIN auth.users u ON u.id = f.user_id
37
+ WHERE f.sid = ${actor.sid}::uuid AND f.user_id = ${actor.userId}::uuid AND f.revoked_at IS NULL
38
+ AND f.auth_epoch = u.auth_epoch AND f.expires_at > now() AND f.pwa_device_id IS NULL
39
+ AND f.issued_at > now() - ${seconds} * interval '1 second'`;
40
+ if (!row) return reject("REAUTHENTICATE");
41
+ return requireEligibleAccount(tx, actor.userId, reject);
42
+ };
@@ -46,6 +46,9 @@ const stringArray = (value: unknown): string[] => {
46
46
  return [];
47
47
  };
48
48
 
49
+ /** The user as the mobile app's session sees it: never an installation administrator. */
50
+ export const withoutAdminRole = (user: User): User => ({ ...user, roles: user.roles.filter((role) => role !== "admin") });
51
+
49
52
  export const buildProjectedUser = (row: DbRow): User => {
50
53
  const { provider, profile } = resolveProviderProfile(row);
51
54
  const mail = (row.mail as string | null | undefined) ?? null;
@@ -57,7 +60,8 @@ export const buildProjectedUser = (row: DbRow): User => {
57
60
  const common = {
58
61
  id: row.id as string,
59
62
  uid: row.uid as string,
60
- roles: buildRoles({ provider, profile, memberofGroup, manages, admin: Boolean(row.effective_admin) }),
63
+ // An app session of the mobile app never acts as an installation administrator.
64
+ roles: buildRoles({ provider, profile, memberofGroup, manages, admin: Boolean(row.effective_admin) && !row.pwa_device_id }),
61
65
  profile,
62
66
  givenname: (row.given_name as string | null | undefined) ?? "",
63
67
  sn: (row.sn as string | null | undefined) ?? "",
@@ -123,7 +127,7 @@ type SessionUserParams = {
123
127
  // Both projections use the same live validity checks. Neither caches authorization.
124
128
  const loadSessionRow = async (params: SessionUserParams, projection: SQLQuery, join: SQLQuery, query: typeof sql) => {
125
129
  const [row] = await query<DbRow[]>`
126
- SELECT ${projection}, (
130
+ SELECT ${projection}, sf.pwa_device_id AS pwa_device_id, (
127
131
  SELECT value FROM settings.entries WHERE key = 'user.category.' ||
128
132
  CASE WHEN u.provider = 'ipa' THEN 'freeipa' WHEN u.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
129
133
  ) AS category_enabled
@@ -144,14 +148,20 @@ const loadSessionRow = async (params: SessionUserParams, projection: SQLQuery, j
144
148
  return row && (await decodeAccountCategoryEnabled(row.category_enabled)) ? row : null;
145
149
  };
146
150
 
147
- export const loadJwtSessionUser = async (
151
+ /** The user of a valid session family and, for an app session of the mobile app, its device. */
152
+ export const loadJwtSession = async (
148
153
  params: SessionUserParams & { groupsAdmin: string[] },
149
154
  query: typeof sql = sql,
150
- ): Promise<User | null> => {
155
+ ): Promise<{ user: User; pwaDeviceId: string | null } | null> => {
151
156
  const row = await loadSessionRow(params, userProjectionSql(params.groupsAdmin), userProjectionJoin(), query);
152
- return row ? buildProjectedUser(row) : null;
157
+ return row ? { user: buildProjectedUser(row), pwaDeviceId: row.pwa_device_id ? String(row.pwa_device_id) : null } : null;
153
158
  };
154
159
 
160
+ export const loadJwtSessionUser = async (
161
+ params: SessionUserParams & { groupsAdmin: string[] },
162
+ query: typeof sql = sql,
163
+ ): Promise<User | null> => (await loadJwtSession(params, query))?.user ?? null;
164
+
155
165
  /** Identity-only consumers must not pay for group, IPA, role and rail projections. */
156
166
  export const loadJwtSessionIdentity = async (params: SessionUserParams, query: typeof sql = sql) => {
157
167
  const row = await loadSessionRow(params, sql`u.id, u.account_expires`, sql``, query);
@@ -36,6 +36,7 @@ export * from "./help";
36
36
  export type * from "./icons";
37
37
  export { icons } from "./icons";
38
38
  export * from "./locale";
39
+ export * from "./locale-preference";
39
40
  export * from "./login-method";
40
41
  export { markdown } from "./markdown";
41
42
  export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
@@ -1,5 +1,5 @@
1
1
  import { cookies } from "@k2b/stdlib/browser";
2
- import { canonicalLocale, LOCALE_COOKIE } from "../shared/locale";
2
+ import { canonicalLocale, LOCALE_COOKIE } from "./locale";
3
3
 
4
4
  export const PROFILE_PREFERENCE_LOCALES = ["en", "de"] as const;
5
5
  export type ProfilePreferenceLocale = (typeof PROFILE_PREFERENCE_LOCALES)[number];
@@ -12,38 +12,25 @@
12
12
  *
13
13
  * Supported types: note, info, success, warning, danger
14
14
  *
15
- * `notices: "minimal"` renders only the tone colour: no icon and no automatic
16
- * label. The type name stays available to screen readers, and an explicit
17
- * title is still shown.
15
+ * Every block renders as the calm NoticeCard: the tone tints the surface, the
16
+ * text stays neutral, and there is no icon. An explicit title is the visible
17
+ * heading and names the block, as on a NoticeCard; without one the type name
18
+ * is announced to screen readers only. Email drops the visually hidden class,
19
+ * so a titled block must not carry the type name as well.
18
20
  */
19
21
 
20
- import { NOTICE_CARD_CLASSES, NOTICE_CARD_ICONS, type NoticeTone } from "@k2b/ui";
22
+ import { NOTICE_CARD_CLASSES, type NoticeTone } from "@k2b/ui";
21
23
  import type { MarkedExtension, Tokens } from "marked";
22
24
  import { escapeHtml } from "../shared";
23
25
 
24
26
  type BlockType = "note" | "info" | "success" | "warning" | "danger";
25
27
 
26
28
  const blockConfig: Record<BlockType, { label: string; tone: NoticeTone }> = {
27
- note: {
28
- label: "Note",
29
- tone: "neutral",
30
- },
31
- info: {
32
- label: "Info",
33
- tone: "info",
34
- },
35
- success: {
36
- label: "Success",
37
- tone: "success",
38
- },
39
- warning: {
40
- label: "Warning",
41
- tone: "warning",
42
- },
43
- danger: {
44
- label: "Danger",
45
- tone: "danger",
46
- },
29
+ note: { label: "Note", tone: "neutral" },
30
+ info: { label: "Info", tone: "info" },
31
+ success: { label: "Success", tone: "success" },
32
+ warning: { label: "Warning", tone: "warning" },
33
+ danger: { label: "Danger", tone: "danger" },
47
34
  };
48
35
 
49
36
  const renderInlineContent = (content: string): string => {
@@ -54,9 +41,7 @@ const renderInlineContent = (content: string): string => {
54
41
  .replace(/\n/g, "<br>");
55
42
  };
56
43
 
57
- export type NoticeStyle = "card" | "minimal";
58
-
59
- export function infoBlocksExtension(notices: NoticeStyle = "card"): MarkedExtension {
44
+ export function infoBlocksExtension(): MarkedExtension {
60
45
  return {
61
46
  extensions: [
62
47
  {
@@ -81,26 +66,12 @@ export function infoBlocksExtension(notices: NoticeStyle = "card"): MarkedExtens
81
66
  };
82
67
  },
83
68
  renderer(token: Tokens.Generic) {
84
- const blockType = token.blockType as BlockType;
85
- const config = blockConfig[blockType];
86
- const content = escapeHtml(token.content as string);
87
- const renderedContent = renderInlineContent(content);
88
-
89
- if (notices === "minimal") {
90
- const title = token.title ? `<p class="${NOTICE_CARD_CLASSES.title}">${escapeHtml(token.title as string)}</p>` : "";
91
- return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}" role="note"><span class="sr-only">${config.label}: </span>${title}<div class="${NOTICE_CARD_CLASSES.body}">${renderedContent}</div></aside>`;
92
- }
93
-
94
- const title = escapeHtml((token.title as string | undefined) ?? config.label);
95
- return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}">
96
- <div class="${NOTICE_CARD_CLASSES.inner}">
97
- <i class="${NOTICE_CARD_ICONS[config.tone]} ${NOTICE_CARD_CLASSES.icon}" aria-hidden="true"></i>
98
- <div class="${NOTICE_CARD_CLASSES.content}">
99
- <p class="${NOTICE_CARD_CLASSES.title}">${title}</p>
100
- <div class="${NOTICE_CARD_CLASSES.body}">${renderedContent}</div>
101
- </div>
102
- </div>
103
- </aside>`;
69
+ const config = blockConfig[token.blockType as BlockType];
70
+ const body = renderInlineContent(escapeHtml(token.content as string));
71
+ const name = token.title
72
+ ? `<p class="${NOTICE_CARD_CLASSES.title}">${escapeHtml(token.title as string)}</p>`
73
+ : `<span class="sr-only">${config.label}: </span>`;
74
+ return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}" role="note">${name}<div class="${NOTICE_CARD_CLASSES.body}">${body}</div></aside>`;
104
75
  },
105
76
  },
106
77
  ],
@@ -11,7 +11,7 @@ import { markdownClient } from "./client";
11
11
  import { codeExtension } from "./extensions/code";
12
12
  import { guidedHelpExtension } from "./extensions/guided-help";
13
13
  import { imagesExtension } from "./extensions/images";
14
- import { infoBlocksExtension, type NoticeStyle } from "./extensions/info-blocks";
14
+ import { infoBlocksExtension } from "./extensions/info-blocks";
15
15
  import { katexExtension } from "./extensions/katex";
16
16
  import { linksExtension } from "./extensions/links";
17
17
  import { markExtension } from "./extensions/mark";
@@ -23,7 +23,7 @@ import { taskListExtension } from "./extensions/task-list";
23
23
  type MarkdownProfile = "content" | "help";
24
24
  type LinkStyle = "widget" | "plain";
25
25
 
26
- const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card", links: LinkStyle = "widget") => {
26
+ const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "widget") => {
27
27
  const marked = new Marked();
28
28
 
29
29
  marked.use({
@@ -33,7 +33,7 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
33
33
 
34
34
  // Apply extensions in order
35
35
  // Note: katexExtension must come before codeExtension to handle ```math blocks
36
- marked.use(infoBlocksExtension(notices));
36
+ marked.use(infoBlocksExtension());
37
37
  marked.use(taskListExtension());
38
38
  marked.use(tablesExtension());
39
39
  // Plain links keep marked's own renderer: an anchor around the link text.
@@ -51,23 +51,14 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
51
51
 
52
52
  const marked = createMarked();
53
53
  const helpMarked = createMarked("help");
54
- const contentMarked = new Map<`${NoticeStyle}:${LinkStyle}`, Marked>([["card:widget", marked]]);
54
+ const plainLinksMarked = createMarked("content", "plain");
55
55
 
56
56
  export type MarkdownRenderOptions = {
57
- /** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
58
- notices?: NoticeStyle;
59
57
  /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
60
58
  links?: LinkStyle;
61
59
  };
62
60
 
63
- const markedFor = ({ notices = "card", links = "widget" }: MarkdownRenderOptions): Marked => {
64
- const key = `${notices}:${links}` as const;
65
- const cached = contentMarked.get(key);
66
- if (cached) return cached;
67
- const created = createMarked("content", notices, links);
68
- contentMarked.set(key, created);
69
- return created;
70
- };
61
+ const markedFor = ({ links = "widget" }: MarkdownRenderOptions): Marked => (links === "plain" ? plainLinksMarked : marked);
71
62
 
72
63
  const sanitizeRenderedHtml = (html: string): string =>
73
64
  sanitizeHtml(html, {
@@ -27,7 +27,14 @@ const readEmbeddedContext = (): AppLaunchpadContext | undefined => {
27
27
  if (!Array.isArray(parsed.apps)) return undefined;
28
28
  return {
29
29
  apps: parsed.apps,
30
- profile: parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light") ? parsed.profile : undefined,
30
+ profile:
31
+ parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light")
32
+ ? {
33
+ name: parsed.profile.name,
34
+ theme: parsed.profile.theme,
35
+ ...(parsed.profile.appHref === "/me/app" ? { appHref: parsed.profile.appHref } : {}),
36
+ }
37
+ : undefined,
31
38
  legalLinks: Array.isArray(parsed.legalLinks) ? parsed.legalLinks : [],
32
39
  };
33
40
  } catch {