@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
@@ -1,4 +1,3 @@
1
- import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
1
  import { type SQL, sql } from "bun";
3
2
  import { lazySync } from "../_internal/process-sync";
4
3
  import { env } from "../config/env";
@@ -19,6 +18,8 @@ import { publicCloudOrigin } from "../shared/app-url";
19
18
  import { isAccountCategoryAllowed } from "./account-category-policy";
20
19
  import { audit } from "./audit";
21
20
  import { logger } from "./logging";
21
+ import { pairingSecret } from "./pairing-secret";
22
+ import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
22
23
  import { CORE_SETTINGS } from "./settings/core-settings";
23
24
  import { decryptValue } from "./settings/crypto";
24
25
 
@@ -33,10 +34,7 @@ export class AppApprovalError extends Error {
33
34
  const reject = (code: AppApprovalError["code"], status: AppApprovalError["status"]): never => {
34
35
  throw new AppApprovalError(code, status);
35
36
  };
36
- const secret = () => randomBytes(32).toString("base64url");
37
- const hash = (value: string) => createHash("sha256").update(value).digest("hex");
38
- const matches = (value: string, expected: string) => timingSafeEqual(Buffer.from(hash(value), "hex"), Buffer.from(expected, "hex"));
39
- const comparison = () => String(randomInt(0, 1_000_000)).padStart(6, "0");
37
+ const { create: secret, hash, matches, code: comparison } = pairingSecret;
40
38
  const iso = (value: Date | string) => new Date(value).toISOString();
41
39
  const future = (seconds: number) => new Date(Date.now() + seconds * 1000);
42
40
 
@@ -217,26 +215,9 @@ export const createAppApprovalService = (
217
215
  if (requireEnabled && (!value.enabled || !value.appOrigin)) return reject("UNAVAILABLE", 503);
218
216
  return value;
219
217
  };
220
- const eligible = async (tx: SQL, id: string): Promise<AccountRow> => {
221
- const [row] = await tx<
222
- AccountRow[]
223
- >`SELECT id, uid, provider, profile, auth_epoch, account_expires FROM auth.users WHERE id = ${id}::uuid`;
224
- if (
225
- !row ||
226
- (row.account_expires && new Date(row.account_expires).getTime() <= Date.now()) ||
227
- !(await isAccountCategoryAllowed(row, tx))
228
- )
229
- return reject("FORBIDDEN", 403);
230
- return row;
231
- };
232
- const fresh = async (tx: SQL, actor: AppApprovalActor) => {
233
- const [row] = await tx`SELECT sid FROM auth.session_families f JOIN auth.users u ON u.id = f.user_id
234
- WHERE f.sid = ${actor.sid}::uuid AND f.user_id = ${actor.userId}::uuid AND f.revoked_at IS NULL
235
- AND f.auth_epoch = u.auth_epoch AND f.expires_at > now()
236
- AND f.issued_at > now() - ${limits.recentSessionSeconds} * interval '1 second'`;
237
- if (!row) return reject("REAUTHENTICATE", 403);
238
- await eligible(tx, actor.userId);
239
- };
218
+ const forbidden = (code: "REAUTHENTICATE" | "FORBIDDEN") => reject(code, 403);
219
+ const eligible = (tx: SQL, id: string): Promise<AccountRow> => requireEligibleAccount(tx, id, forbidden);
220
+ const fresh = (tx: SQL, actor: AppApprovalActor) => requireRecentWebSession(tx, actor, limits.recentSessionSeconds, forbidden);
240
221
  const record = (tx: SQL, action: string, userId: string, id: string, metadata: Record<string, unknown> = {}) =>
241
222
  audit.record({ action: `auth.app.${action}`, outcome: "allowed", actor: { userId }, target: { type: "app_device", id }, metadata }, tx);
242
223
  const cleanup = async () => {
@@ -120,6 +120,8 @@ const SELF_SERVICE_ACTION_LABELS = {
120
120
  "webauthn_credential.create": "Passkey added",
121
121
  "webauthn_credential.delete": "Passkey removed",
122
122
  "webauthn_credential.authenticate": "Passkey used",
123
+ "auth.pwa.device.enroll": "App paired",
124
+ "auth.pwa.device.revoke": "App removed",
123
125
  } as const satisfies Record<string, string>;
124
126
  const SELF_SERVICE_ACTIONS = Object.keys(SELF_SERVICE_ACTION_LABELS);
125
127
 
@@ -0,0 +1,40 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CLOUD_LOGO_SVG } from "../../shared/branding";
3
+ import * as settings from "../settings";
4
+
5
+ /** Bumped when the drawing changes, so installed apps pick up the new icons. */
6
+ const RENDERER_VERSION = "1";
7
+ /** Larger logos fall back to the Cloud logo instead of reaching the decoder. */
8
+ export const APP_ICON_SOURCE_MAX_BYTES = 10 * 1024 * 1024;
9
+
10
+ export type AppIconSource = { data: Uint8Array; mime: string; version: string };
11
+
12
+ const DATA_URI = /^data:([^;,]+);base64,(.+)$/;
13
+
14
+ let latest: { logo: string; source: AppIconSource } | undefined;
15
+
16
+ const parse = (logo: string): AppIconSource => {
17
+ const match = DATA_URI.exec(logo);
18
+ const data = match ? Buffer.from(match[2]!, "base64") : null;
19
+ const source =
20
+ match && data && data.byteLength > 0 && data.byteLength <= APP_ICON_SOURCE_MAX_BYTES
21
+ ? { data: new Uint8Array(data), mime: match[1]!, key: logo }
22
+ : { data: new TextEncoder().encode(CLOUD_LOGO_SVG), mime: "image/svg+xml", key: CLOUD_LOGO_SVG };
23
+ const version = createHash("sha256").update(source.key).update(RENDERER_VERSION).digest("hex").slice(0, 12);
24
+ return { data: source.data, mime: source.mime, version };
25
+ };
26
+
27
+ /**
28
+ * The installation logo the app icons are drawn from: `app.logo` when it is a base64 data URI (as uploaded in the
29
+ * settings), otherwise the Cloud logo. `version` is the first 12 hex characters of a hash over the logo and the
30
+ * drawing, so it changes exactly when the icons do.
31
+ */
32
+ export const readAppIconSource = async (): Promise<AppIconSource> => {
33
+ const logo = (await settings.get<string>("app.logo")) || "";
34
+ // The manifest, the worker and the icons read it on every request; decode and hash a logo only once.
35
+ if (latest?.logo !== logo) latest = { logo, source: parse(logo) };
36
+ return latest.source;
37
+ };
38
+
39
+ /** The icons' version; the app's manifest carries it, so a new logo reaches installed apps. */
40
+ export const appIconVersion = async (): Promise<string> => (await readAppIconSource()).version;
@@ -0,0 +1,112 @@
1
+ import { z } from "zod";
2
+ import { spawnCanvasWorker } from "../../_internal/canvas-worker";
3
+ import { PWA_CANVAS_COLORS } from "../../contracts/pwa";
4
+ import { CLOUD_LOGO_SVG } from "../../shared/branding";
5
+ import { logger } from "../logging";
6
+ import { appIconVersion, readAppIconSource } from "./app-icon-source";
7
+
8
+ declare const __CLOUD_ICON_RENDER_WORKER__: string | undefined;
9
+
10
+ /**
11
+ * The icons of the installable mobile app. `any` icons are transparent; the maskable and the Apple icon are opaque,
12
+ * because launchers crop maskable icons to their own shape and iOS paints transparency black. The maskable logo stays
13
+ * inside the safe circle (40 % radius), so its square is at most 56 % of the icon.
14
+ */
15
+ export const APP_ICON_VARIANTS = {
16
+ "pwa-icon-192": { size: 192, background: null, box: 0.84 },
17
+ "pwa-icon-512": { size: 512, background: null, box: 0.84 },
18
+ "pwa-icon-maskable-512": { size: 512, background: PWA_CANVAS_COLORS.light, box: 0.56 },
19
+ "apple-touch-icon": { size: 180, background: PWA_CANVAS_COLORS.light, box: 0.7 },
20
+ } as const;
21
+ export type AppIconVariant = keyof typeof APP_ICON_VARIANTS;
22
+
23
+ const RENDER_TIMEOUT_MS = 10_000;
24
+ /** A failed run is kept this long, so failing requests cannot start a worker each; the icon routes ask to retry after it. */
25
+ export const APP_ICON_RETRY_SECONDS = 60;
26
+ /** Four PNGs of at most 512 px, in base64, plus the JSON envelope. */
27
+ const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
28
+ const RenderResult = z.object({
29
+ icons: z.record(z.string(), z.string()),
30
+ fallback: z.boolean(),
31
+ });
32
+
33
+ const log = logger("branding");
34
+
35
+ const renderAll = async (source: Awaited<ReturnType<typeof readAppIconSource>>): Promise<Map<AppIconVariant, Uint8Array<ArrayBuffer>>> => {
36
+ if (process.platform !== "linux") throw new Error("Icon rendering requires the Linux Cloud runtime with memory isolation.");
37
+ const worker =
38
+ typeof __CLOUD_ICON_RENDER_WORKER__ === "string"
39
+ ? new URL(__CLOUD_ICON_RENDER_WORKER__, import.meta.url)
40
+ : new URL("./icon-render-worker.ts", import.meta.url);
41
+ const signal = AbortSignal.timeout(RENDER_TIMEOUT_MS);
42
+ const child = spawnCanvasWorker(
43
+ worker,
44
+ "icon-renderer",
45
+ JSON.stringify({
46
+ source: Buffer.from(source.data).toString("base64"),
47
+ mime: source.mime,
48
+ fallback: CLOUD_LOGO_SVG,
49
+ variants: Object.entries(APP_ICON_VARIANTS).map(([id, variant]) => ({ id, ...variant })),
50
+ }),
51
+ );
52
+ const stop = () => child.kill("SIGKILL");
53
+ signal.addEventListener("abort", stop, { once: true });
54
+ try {
55
+ let length = 0;
56
+ const stdout = child.stdout.pipeThrough(
57
+ new TransformStream<Uint8Array, Uint8Array>({
58
+ transform(chunk, controller) {
59
+ length += chunk.byteLength;
60
+ if (length > MAX_OUTPUT_BYTES) throw new Error("Rendered icons exceed their budget.");
61
+ controller.enqueue(chunk);
62
+ },
63
+ }),
64
+ );
65
+ const text = await new Response(stdout).text();
66
+ signal.throwIfAborted();
67
+ if ((await child.exited) !== 0 || !text.trim()) throw new Error("Icon rendering failed.");
68
+ const output = RenderResult.parse(JSON.parse(text));
69
+ // Never log the logo or its data, only that it could not be used.
70
+ if (output.fallback) log.warn("The installation logo could not be decoded; the app icons use the Cloud logo");
71
+ const icons = new Map<AppIconVariant, Uint8Array<ArrayBuffer>>();
72
+ for (const variant of Object.keys(APP_ICON_VARIANTS) as AppIconVariant[]) {
73
+ const png = output.icons[variant];
74
+ if (!png) throw new Error("Icon rendering failed.");
75
+ icons.set(variant, new Uint8Array(Buffer.from(png, "base64")));
76
+ }
77
+ return icons;
78
+ } finally {
79
+ signal.removeEventListener("abort", stop);
80
+ stop();
81
+ await child.exited;
82
+ }
83
+ };
84
+
85
+ /**
86
+ * Renders the mobile app's icons from the installation logo. One run draws all variants in an isolated process; the
87
+ * result is kept for the current logo, concurrent requests share one run, a new logo is drawn on its next read, and a
88
+ * failed run is tried again after `APP_ICON_RETRY_SECONDS`.
89
+ */
90
+ export const createAppIcons = () => {
91
+ type Run = { version: string; icons: Promise<Map<AppIconVariant, Uint8Array<ArrayBuffer>>>; failedAt?: number };
92
+ let current: Run | undefined;
93
+ return {
94
+ version: appIconVersion,
95
+ render: async (variant: AppIconVariant): Promise<{ png: Uint8Array<ArrayBuffer>; etag: string }> => {
96
+ const source = await readAppIconSource();
97
+ let entry = current;
98
+ const retry = entry?.failedAt !== undefined && Date.now() - entry.failedAt >= APP_ICON_RETRY_SECONDS * 1000;
99
+ if (entry?.version !== source.version || retry) {
100
+ const created: Run = { version: source.version, icons: renderAll(source) };
101
+ created.icons.catch(() => {
102
+ created.failedAt = Date.now();
103
+ });
104
+ current = entry = created;
105
+ }
106
+ const icons = await entry.icons;
107
+ return { png: icons.get(variant)!, etag: `"${source.version}"` };
108
+ },
109
+ };
110
+ };
111
+
112
+ export const appIcons = createAppIcons();
@@ -0,0 +1,69 @@
1
+ // Standalone subprocess. No server imports or application credentials.
2
+
3
+ import { createCanvas, type Image, loadImage } from "@napi-rs/canvas";
4
+ import { z } from "zod";
5
+ import { sizedSvg } from "./icon-svg";
6
+
7
+ const MAX_SOURCE_BYTES = 10 * 1024 * 1024;
8
+ const inputSchema = z.object({
9
+ source: z.string().max(Math.ceil((MAX_SOURCE_BYTES * 4) / 3)),
10
+ mime: z.string().max(100),
11
+ fallback: z.string().max(64 * 1024),
12
+ variants: z
13
+ .array(
14
+ z.object({
15
+ id: z.string().regex(/^[a-z0-9-]+$/),
16
+ size: z.number().int().min(16).max(1024),
17
+ /** Opaque background, or none for a transparent icon. */
18
+ background: z
19
+ .string()
20
+ .regex(/^#[0-9a-f]{6}$/i)
21
+ .nullable(),
22
+ /** Side of the centred square the logo fits into, as a fraction of the icon. */
23
+ box: z.number().gt(0).max(1),
24
+ }),
25
+ )
26
+ .min(1)
27
+ .max(8),
28
+ });
29
+
30
+ const decode = async (data: Buffer, mime: string, size: number): Promise<Image> =>
31
+ loadImage(mime === "image/svg+xml" ? Buffer.from(sizedSvg(data.toString("utf8"), size)) : data);
32
+
33
+ try {
34
+ const input = inputSchema.parse(JSON.parse(await Bun.stdin.text()));
35
+ const source = Buffer.from(input.source, "base64");
36
+ if (source.byteLength > MAX_SOURCE_BYTES) throw new Error("The logo exceeds the 10 MiB limit.");
37
+ let fallback = false;
38
+ const icons: Record<string, string> = {};
39
+ for (const variant of input.variants) {
40
+ const box = Math.round(variant.size * variant.box);
41
+ let image: Image;
42
+ try {
43
+ if (fallback) throw new Error("fallback");
44
+ image = await decode(source, input.mime, box);
45
+ if (!(image.width > 0 && image.height > 0)) throw new Error("empty");
46
+ } catch {
47
+ fallback = true;
48
+ image = await decode(Buffer.from(input.fallback), "image/svg+xml", box);
49
+ }
50
+ const canvas = createCanvas(variant.size, variant.size);
51
+ const context = canvas.getContext("2d");
52
+ if (variant.background) {
53
+ context.fillStyle = variant.background;
54
+ context.fillRect(0, 0, variant.size, variant.size);
55
+ }
56
+ context.imageSmoothingEnabled = true;
57
+ context.imageSmoothingQuality = "high";
58
+ // Fit the logo into the centred box and keep its aspect ratio.
59
+ const scale = Math.min(box / image.width, box / image.height);
60
+ const width = image.width * scale;
61
+ const height = image.height * scale;
62
+ context.drawImage(image, (variant.size - width) / 2, (variant.size - height) / 2, width, height);
63
+ icons[variant.id] = Buffer.from(canvas.toBuffer("image/png")).toString("base64");
64
+ }
65
+ process.stdout.write(JSON.stringify({ icons, fallback }));
66
+ } catch (error) {
67
+ process.stdout.write(JSON.stringify({ error: error instanceof z.ZodError ? "Invalid icon request." : "Icon rendering failed." }));
68
+ process.exitCode = 1;
69
+ }
@@ -0,0 +1,22 @@
1
+ /** A root `width` or `height` in plain pixels; percentages and other units do not give the drawing's extent. */
2
+ const pixels = (attributes: string, name: "width" | "height"): number | undefined => {
3
+ const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attributes);
4
+ const value = (match?.[1] ?? match?.[2] ?? match?.[3])?.trim();
5
+ return value && /^\d+(?:\.\d+)?(?:px)?$/i.test(value) ? Number.parseFloat(value) : undefined;
6
+ };
7
+
8
+ /**
9
+ * Gives an SVG root the target size, so the vector renders sharp instead of being scaled as a bitmap. Without a
10
+ * viewBox, a new size would crop instead of scale, so the root gets one from its pixel size; a root with neither
11
+ * stays as it is.
12
+ */
13
+ export const sizedSvg = (svg: string, size: number): string =>
14
+ svg.replace(/<svg\b([^>]*)>/i, (tag, attributes: string) => {
15
+ const viewBox = /\sviewBox\s*=/i.test(attributes);
16
+ const width = pixels(attributes, "width");
17
+ const height = pixels(attributes, "height");
18
+ if (!viewBox && (width === undefined || height === undefined)) return tag;
19
+ let rest = attributes.replace(/\s(?:width|height)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
20
+ if (!viewBox) rest += ` viewBox="0 0 ${width} ${height}"`;
21
+ return `<svg${rest} width="${size}" height="${size}">`;
22
+ });
@@ -4,7 +4,7 @@ import type { AccessSubject } from "../../server/services/access";
4
4
  import { isAccountCategoryAllowed } from "../account-category-policy";
5
5
  import { isAccountExpired } from "../account-model";
6
6
  import type { ServiceAccount } from "../service-accounts";
7
- import { buildProjectedUser, loadCurrentUser, userProjectionJoin, userProjectionSql } from "../session/user";
7
+ import { buildProjectedUser, loadCurrentUser, userProjectionJoin, userProjectionSql, withoutAdminRole } from "../session/user";
8
8
  import type { CloudInvocationClaims } from "./invocation-token";
9
9
  import { getIdentityRuntimeConfig } from "./runtime-config";
10
10
 
@@ -66,11 +66,14 @@ export const resolveInvocationAuthority = async (
66
66
  const delegation = provenance(claims);
67
67
 
68
68
  if (claims.principal_type === "user") {
69
- const user = await loadCurrentUser({ userId: claims.sub, groupsAdmin }, query);
70
- if (!user || isAccountExpired(user.accountExpires)) return null;
71
- if (!(await isAccountCategoryAllowed(user, query))) return null;
69
+ const current = await loadCurrentUser({ userId: claims.sub, groupsAdmin }, query);
70
+ if (!current || isAccountExpired(current.accountExpires)) return null;
71
+ if (!(await isAccountCategoryAllowed(current, query))) return null;
72
+ // The mobile app's session never acts as an installation administrator, also not through another app.
73
+ const app = claims.session_kind === "app";
74
+ const user = app ? withoutAdminRole(current) : current;
72
75
  return {
73
- actor: { kind: "user", user, delegation },
76
+ actor: { kind: "user", user, delegation, ...(app ? { sessionKind: "app" as const } : {}) },
74
77
  accessSubject: { type: "user", userId: user.id },
75
78
  credentialKind: "invocation",
76
79
  scopes: [...claims.scopes],
@@ -17,6 +17,7 @@ export const invocationAuthorityFromRequest = (authority: RequestAuthority): Inv
17
17
  access_subject_type: "user",
18
18
  access_subject_id: authority.actor.user.id,
19
19
  credential_kind: authority.credentialKind,
20
+ ...(authority.credentialKind === "session" && authority.actor.sessionKind === "app" ? { session_kind: "app" as const } : {}),
20
21
  scopes: [...new Set(authority.scopes)].sort(),
21
22
  };
22
23
  }
@@ -56,6 +56,8 @@ const InvocationPayloadSchema = z
56
56
  delegated_user_id: z.string().uuid().optional(),
57
57
  act: z.object({ sub: z.string().regex(/^app:[a-z][a-z0-9-]{0,79}$/) }).strict(),
58
58
  credential_kind: z.enum(["session", "oauth", "api_key", "mandate"]),
59
+ /** Only for an app session of the mobile app, which never acts as an administrator. */
60
+ session_kind: z.literal("app").optional(),
59
61
  credential_id: BoundedIdentifierSchema.optional(),
60
62
  scopes: z
61
63
  .array(ScopeSchema)
@@ -97,6 +99,9 @@ const InvocationPayloadSchema = z
97
99
  if (claims.credential_kind === "session" && claims.principal_type !== "user") {
98
100
  context.addIssue({ code: "custom", message: "A session invocation must use a user principal" });
99
101
  }
102
+ if (claims.session_kind && claims.credential_kind !== "session") {
103
+ context.addIssue({ code: "custom", message: "Only a session invocation has a session kind" });
104
+ }
100
105
  if (claims.credential_kind === "session" && claims.scopes.length > 0) {
101
106
  context.addIssue({ code: "custom", message: "A session invocation cannot carry credential scopes" });
102
107
  }
@@ -120,6 +125,7 @@ export type InvocationAuthority = Pick<
120
125
  | "access_subject_id"
121
126
  | "delegated_user_id"
122
127
  | "credential_kind"
128
+ | "session_kind"
123
129
  | "credential_id"
124
130
  | "scopes"
125
131
  | "mandate_id"
@@ -197,6 +197,7 @@ export type {
197
197
  export { latestTopicCursor } from "./topic-cursor";
198
198
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
199
199
  export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
200
+ export { PwaError, pwaDevices, type PwaDeviceAdministrator } from "./pwa-devices";
200
201
  export { legalConsent } from "./legal-consent";
201
202
 
202
203
  /** Core-owned app bar administration; service methods enforce administrator access. */
@@ -10,9 +10,18 @@ import { logger } from "./logging";
10
10
  * delivered (or dead after `maxAttempts`, when the table has a `dead_at`
11
11
  * column), and delivered rows are pruned after a retention period.
12
12
  *
13
+ * With `sequence`, it works in batches: claims of one outbox (table and
14
+ * `where`) take turns, a claim takes rows in sequence order with several rows
15
+ * per ordering key, and the dispatcher publishes keys concurrently and the
16
+ * rows of one key in order, then completes the batch in one statement. A row
17
+ * that never failed is due at once. Claims stay proportional to the batch
18
+ * with an index on the `where` columns and `sequence`, and one on the `where`
19
+ * columns and `orderBy` limited to `claimed_until IS NOT NULL OR attempts > 0`.
20
+ *
13
21
  * Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
14
22
  * `claimed_until timestamptz`, `delivered_at timestamptz`, `last_error text`,
15
- * `created_at timestamptz`. `maxAttempts` additionally needs `dead_at`.
23
+ * `created_at timestamptz`. `maxAttempts` additionally needs `dead_at`;
24
+ * `onDelivered: "delete"` needs no `delivered_at`.
16
25
  */
17
26
 
18
27
  const DEFAULT_CLAIM_MS = 30_000;
@@ -27,7 +36,7 @@ export type OutboxRow = {
27
36
  };
28
37
 
29
38
  export type PgOutboxConfig<Row extends OutboxRow> = {
30
- /** Schema-qualified table, e.g. `mail.live_invalidation_outbox`. */
39
+ /** Schema-qualified table, e.g. `events.outbox`. */
31
40
  table: string;
32
41
  /** Log source. */
33
42
  name: string;
@@ -36,8 +45,16 @@ export type PgOutboxConfig<Row extends OutboxRow> = {
36
45
  /**
37
46
  * Column whose rows must be delivered in insertion order. A pending earlier
38
47
  * row with the same value blocks later ones until it is delivered or dead.
48
+ * With `sequence`, a value waits while any of its rows is claimed or waits
49
+ * for a retry.
39
50
  */
40
- orderBy?: string;
51
+ orderBy?: Extract<keyof Row, string>;
52
+ /** Fixed column values every claimed row matches, e.g. `{ kind: "live", app_id: "contacts" }`. */
53
+ where?: Readonly<Record<string, string>>;
54
+ /** Insertion-order column that orders claims instead of `(created_at, id)`, and enables batches (see above). */
55
+ sequence?: string;
56
+ /** Delete a published row instead of setting `delivered_at`; every row in the table is then pending. */
57
+ onDelivered?: "delete";
41
58
  /** Mark rows dead after this many failed attempts (requires `dead_at`). */
42
59
  maxAttempts?: number;
43
60
  claimMs?: number;
@@ -67,11 +84,73 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
67
84
  const claimMs = config.claimMs ?? DEFAULT_CLAIM_MS;
68
85
  const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
69
86
  const log = logger(config.name);
87
+ const sequence = config.sequence ? identifier(config.sequence, "sequence column") : null;
88
+ const deleteDelivered = config.onDelivered === "delete";
70
89
  const notDead = maxAttempts === null ? sql`` : sql`AND dead_at IS NULL`;
71
90
  const earlierNotDead = maxAttempts === null ? sql`` : sql`AND earlier.dead_at IS NULL`;
91
+ const pending = deleteDelivered ? sql`` : sql`AND delivered_at IS NULL`;
92
+ const earlierPending = deleteDelivered ? sql`` : sql`AND earlier.delivered_at IS NULL`;
93
+ const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
94
+ const matches = (alias: "current" | "earlier" | "busy") =>
95
+ filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
96
+ // Rows without an ordering column are independent: each is a key of its own.
97
+ const runKey = config.orderBy ?? "id";
98
+ const runColumn = identifier(runKey, "ordering column");
99
+ const claimLock = `${config.table}:${JSON.stringify(config.where ?? {})}`;
100
+
101
+ /**
102
+ * Claims rows in sequence order, several per key, and never a row of a key
103
+ * that has a claimed row or one waiting for a retry. `next_attempt_at` only
104
+ * delays retries: it is the writer's transaction start, so a fresh row that
105
+ * committed later can carry an earlier one, or one after this claim began.
106
+ */
107
+ const claimBatch = (cap: number, order: ReturnType<typeof sql.unsafe>) =>
108
+ sql.begin(async (tx) => {
109
+ // Claims take turns: a concurrent claim would skip this one's locked rows and take later rows of the same keys.
110
+ // A claim that outlives its claim period is worthless, so neither a slow statement nor a vanished client holds the turn longer.
111
+ await tx`
112
+ SELECT set_config('statement_timeout', ${String(claimMs)}, true),
113
+ set_config('idle_in_transaction_session_timeout', ${String(claimMs)}, true),
114
+ pg_advisory_xact_lock(hashtextextended(${claimLock}, 0))
115
+ `;
116
+ // The busy probe runs per candidate (OFFSET 0 keeps the planner from turning it into a join, which
117
+ // stale statistics can make quadratic), against an index of claimed and retried rows per key.
118
+ return tx<Row[]>`
119
+ WITH candidates AS MATERIALIZED (
120
+ SELECT current.id
121
+ FROM ${table} current
122
+ WHERE (current.attempts = 0 OR current.next_attempt_at <= now())
123
+ AND (current.claimed_until IS NULL OR current.claimed_until <= now())
124
+ ${pending}
125
+ ${notDead}
126
+ ${matches("current")}
127
+ AND NOT EXISTS (
128
+ SELECT
129
+ FROM ${table} busy
130
+ WHERE busy.${runColumn} = current.${runColumn}
131
+ AND (busy.claimed_until > now() OR (busy.attempts > 0 AND busy.next_attempt_at > now()))
132
+ ${pending}
133
+ ${notDead}
134
+ ${matches("busy")}
135
+ OFFSET 0
136
+ )
137
+ ORDER BY current.${order}
138
+ LIMIT ${cap}
139
+ FOR UPDATE SKIP LOCKED
140
+ ), claimed AS (
141
+ UPDATE ${table} outbox
142
+ SET claimed_until = now() + (${claimMs} * interval '1 millisecond')
143
+ FROM candidates
144
+ WHERE outbox.id = candidates.id
145
+ RETURNING outbox.*
146
+ )
147
+ SELECT * FROM claimed ORDER BY ${order}
148
+ `;
149
+ });
72
150
 
73
151
  const claim: PgOutbox<Row>["claim"] = async (limit = batchSize) => {
74
152
  const cap = Math.min(Math.max(limit, 1), batchSize);
153
+ if (sequence !== null) return claimBatch(cap, sequence);
75
154
  const ordered =
76
155
  orderBy === null
77
156
  ? sql``
@@ -80,8 +159,9 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
80
159
  SELECT 1
81
160
  FROM ${table} earlier
82
161
  WHERE earlier.${orderBy} = current.${orderBy}
83
- AND earlier.delivered_at IS NULL
162
+ ${earlierPending}
84
163
  ${earlierNotDead}
164
+ ${matches("earlier")}
85
165
  AND (earlier.created_at, earlier.id) < (current.created_at, current.id)
86
166
  )
87
167
  `;
@@ -90,10 +170,11 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
90
170
  WITH candidates AS MATERIALIZED (
91
171
  SELECT current.id
92
172
  FROM ${table} current
93
- WHERE current.delivered_at IS NULL
94
- ${notDead}
95
- AND current.next_attempt_at <= now()
173
+ WHERE current.next_attempt_at <= now()
96
174
  AND (current.claimed_until IS NULL OR current.claimed_until <= now())
175
+ ${pending}
176
+ ${notDead}
177
+ ${matches("current")}
97
178
  ${ordered}
98
179
  ORDER BY current.next_attempt_at, current.created_at, current.id
99
180
  LIMIT ${cap}
@@ -107,30 +188,66 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
107
188
  `;
108
189
  };
109
190
 
191
+ const retryLater = async (row: Row, error: unknown) => {
192
+ const attempts = row.attempts + 1;
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
195
+ const dead = maxAttempts === null ? sql`` : sql`dead_at = CASE WHEN ${attempts} >= ${maxAttempts} THEN now() ELSE dead_at END,`;
196
+ await sql`
197
+ UPDATE ${table}
198
+ SET attempts = ${attempts},
199
+ next_attempt_at = now() + (${delaySeconds} * interval '1 second'),
200
+ claimed_until = NULL,
201
+ ${dead}
202
+ last_error = ${message.slice(0, 1_000)}
203
+ WHERE id = ${row.id}::uuid ${pending} ${notDead} AND attempts = ${row.attempts}
204
+ `;
205
+ log.warn("Outbox delivery failed", { outboxId: row.id, attempts, error: message });
206
+ };
207
+
110
208
  const dispatch: PgOutbox<Row>["dispatch"] = async (row, publish = config.publish) => {
111
209
  try {
112
210
  await publish(row);
211
+ if (deleteDelivered) {
212
+ await sql`DELETE FROM ${table} WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}`;
213
+ return;
214
+ }
113
215
  await sql`
114
216
  UPDATE ${table}
115
217
  SET delivered_at = now(), claimed_until = NULL, last_error = NULL
116
- WHERE id = ${row.id}::uuid AND delivered_at IS NULL ${notDead} AND attempts = ${row.attempts}
218
+ WHERE id = ${row.id}::uuid ${pending} ${notDead} AND attempts = ${row.attempts}
117
219
  `;
118
220
  } catch (error) {
119
- const attempts = row.attempts + 1;
120
- const message = error instanceof Error ? error.message : String(error);
121
- const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
122
- const dead = maxAttempts === null ? sql`` : sql`dead_at = CASE WHEN ${attempts} >= ${maxAttempts} THEN now() ELSE dead_at END,`;
123
- await sql`
124
- UPDATE ${table}
125
- SET attempts = ${attempts},
126
- next_attempt_at = now() + (${delaySeconds} * interval '1 second'),
127
- claimed_until = NULL,
128
- ${dead}
129
- last_error = ${message.slice(0, 1_000)}
130
- WHERE id = ${row.id}::uuid AND delivered_at IS NULL ${notDead} AND attempts = ${row.attempts}
131
- `;
132
- log.warn("Outbox delivery failed", { outboxId: row.id, attempts, error: message });
221
+ await retryLater(row, error);
222
+ }
223
+ };
224
+
225
+ /** Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key. */
226
+ const dispatchBatch = async (rows: Row[]) => {
227
+ const delivered: string[] = [];
228
+ const runs = Map.groupBy(rows, (row) => row[runKey]);
229
+ const results = await Promise.allSettled(
230
+ [...runs.values()].map(async (run) => {
231
+ for (const [index, row] of run.entries()) {
232
+ try {
233
+ await config.publish(row);
234
+ } catch (error) {
235
+ await retryLater(row, error);
236
+ const rest = run.slice(index + 1).map((later) => later.id);
237
+ if (rest.length > 0) await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
238
+ return;
239
+ }
240
+ delivered.push(row.id);
241
+ }
242
+ }),
243
+ );
244
+ if (delivered.length > 0) {
245
+ const ids = sql.array(delivered, "uuid");
246
+ await (deleteDelivered
247
+ ? sql`DELETE FROM ${table} WHERE id = ANY(${ids})`
248
+ : sql`UPDATE ${table} SET delivered_at = now(), claimed_until = NULL, last_error = NULL WHERE id = ANY(${ids})`);
133
249
  }
250
+ for (const result of results) if (result.status === "rejected") throw result.reason;
134
251
  };
135
252
 
136
253
  let activeReconcile: Promise<number> | null = null;
@@ -140,19 +257,22 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
140
257
  reconcileRequested = true;
141
258
  if (activeReconcile) return activeReconcile;
142
259
  activeReconcile = (async () => {
143
- const deadExpired = maxAttempts === null ? sql`` : sql`OR dead_at < now() - ${DEAD_RETENTION}::interval`;
144
- await sql`
145
- DELETE FROM ${table}
146
- WHERE delivered_at < now() - ${DELIVERED_RETENTION}::interval
147
- ${deadExpired}
148
- `;
260
+ if (!deleteDelivered) {
261
+ const deadExpired = maxAttempts === null ? sql`` : sql`OR dead_at < now() - ${DEAD_RETENTION}::interval`;
262
+ await sql`
263
+ DELETE FROM ${table}
264
+ WHERE delivered_at < now() - ${DELIVERED_RETENTION}::interval
265
+ ${deadExpired}
266
+ `;
267
+ }
149
268
  let processed = 0;
150
269
  let rows: Row[];
151
270
  do {
152
271
  reconcileRequested = false;
153
272
  rows = await claim();
154
- // Sequential: a claimed batch may contain ordered rows of one key.
155
- for (const row of rows) await dispatch(row);
273
+ if (sequence !== null) await dispatchBatch(rows);
274
+ // Sequential: across keys, the claim order is the publish order.
275
+ else for (const row of rows) await dispatch(row);
156
276
  processed += rows.length;
157
277
  } while (reconcileRequested || rows.length > 0);
158
278
  return processed;