@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
|
@@ -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
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
70
|
-
if (!
|
|
71
|
-
if (!(await isAccountCategoryAllowed(
|
|
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"
|
package/src/services/index.ts
CHANGED
|
@@ -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. */
|
package/src/services/outbox.ts
CHANGED
|
@@ -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. `
|
|
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
|
-
|
|
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.
|
|
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
|
|
218
|
+
WHERE id = ${row.id}::uuid ${pending} ${notDead} AND attempts = ${row.attempts}
|
|
117
219
|
`;
|
|
118
220
|
} catch (error) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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;
|