@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.
- package/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/define-app.ts +81 -7
- package/src/_internal/page-responses.ts +19 -3
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/client/controller.ts +17 -3
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/live-routes.ts +2 -2
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +161 -0
- package/src/api/pwa.ts +179 -0
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +826 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +150 -30
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pwa-devices.ts +574 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/extensions/info-blocks.ts +18 -47
- package/src/shared/markdown/index.ts +5 -14
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +69 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/utilities-feedback.css +2 -2
- 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 {
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/shared/index.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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,
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
|
|
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
|
|
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
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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",
|
|
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(
|
|
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
|
|
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 = ({
|
|
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:
|
|
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 {
|