@k2b/cloud 0.22.0 → 0.24.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/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- 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/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- 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/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -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 +830 -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/help.ts +3 -3
- 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 +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -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/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- 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 +70 -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/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
|
@@ -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, type PwaDevicePairedNotice } 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
|
@@ -5,20 +5,25 @@ import { logger } from "./logging";
|
|
|
5
5
|
* PostgreSQL transactional-outbox dispatcher.
|
|
6
6
|
*
|
|
7
7
|
* Domain writers insert a row in their own transaction; this dispatcher claims
|
|
8
|
-
* pending rows (`FOR UPDATE SKIP LOCKED`), publishes them, and
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* pending rows (`FOR UPDATE SKIP LOCKED`), publishes them, and deletes a
|
|
9
|
+
* published row or schedules an exponential-backoff retry. Every row in the
|
|
10
|
+
* table is pending: rows stay until they are published.
|
|
11
|
+
*
|
|
12
|
+
* It works in batches: claims of one outbox (table and `where`) take turns, a
|
|
13
|
+
* claim takes rows in `sequence` order with several rows per ordering key, and
|
|
14
|
+
* the dispatcher publishes keys concurrently and the rows of one key in order,
|
|
15
|
+
* then completes the batch in one statement. A row that never failed is due at
|
|
16
|
+
* once. Claims stay proportional to the batch with an index on the `where`
|
|
17
|
+
* columns and `sequence`, and one on the `where` columns and `orderBy` limited
|
|
18
|
+
* to `claimed_until IS NOT NULL OR attempts > 0`.
|
|
12
19
|
*
|
|
13
20
|
* Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
|
|
14
|
-
* `claimed_until timestamptz`, `
|
|
15
|
-
* `
|
|
21
|
+
* `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
|
|
22
|
+
* `sequence` columns.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
const DEFAULT_CLAIM_MS = 30_000;
|
|
19
26
|
const DEFAULT_BATCH_SIZE = 100;
|
|
20
|
-
const DELIVERED_RETENTION = "7 days";
|
|
21
|
-
const DEAD_RETENTION = "30 days";
|
|
22
27
|
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
23
28
|
|
|
24
29
|
export type OutboxRow = {
|
|
@@ -27,19 +32,21 @@ export type OutboxRow = {
|
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export type PgOutboxConfig<Row extends OutboxRow> = {
|
|
30
|
-
/** Schema-qualified table, e.g. `
|
|
35
|
+
/** Schema-qualified table, e.g. `events.outbox`. */
|
|
31
36
|
table: string;
|
|
32
37
|
/** Log source. */
|
|
33
38
|
name: string;
|
|
34
39
|
publish: (row: Row) => Promise<unknown>;
|
|
35
40
|
reconcileIntervalMs: number;
|
|
36
41
|
/**
|
|
37
|
-
* Column whose rows
|
|
38
|
-
*
|
|
42
|
+
* Column whose rows are published in `sequence` order. A value waits while
|
|
43
|
+
* any of its rows is claimed or waits for a retry.
|
|
39
44
|
*/
|
|
40
|
-
orderBy
|
|
41
|
-
/**
|
|
42
|
-
|
|
45
|
+
orderBy: Extract<keyof Row, string>;
|
|
46
|
+
/** Insertion-order column that orders claims. */
|
|
47
|
+
sequence: string;
|
|
48
|
+
/** Fixed column values every claimed row matches, e.g. `{ kind: "live", app_id: "contacts" }`. */
|
|
49
|
+
where?: Readonly<Record<string, string>>;
|
|
43
50
|
claimMs?: number;
|
|
44
51
|
batchSize?: number;
|
|
45
52
|
};
|
|
@@ -62,77 +69,111 @@ const identifier = (name: string, label: string): ReturnType<typeof sql.unsafe>
|
|
|
62
69
|
|
|
63
70
|
export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row>): PgOutbox<Row> => {
|
|
64
71
|
const table = identifier(config.table, "table");
|
|
65
|
-
const
|
|
66
|
-
const
|
|
72
|
+
const runKey = config.orderBy;
|
|
73
|
+
const runColumn = identifier(runKey, "ordering column");
|
|
74
|
+
const sequence = identifier(config.sequence, "sequence column");
|
|
67
75
|
const claimMs = config.claimMs ?? DEFAULT_CLAIM_MS;
|
|
68
76
|
const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
69
77
|
const log = logger(config.name);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
78
|
+
const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
|
|
79
|
+
const matches = (alias: "current" | "busy") =>
|
|
80
|
+
filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
|
|
81
|
+
const claimLock = `${config.table}:${JSON.stringify(config.where ?? {})}`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Claims rows in sequence order, several per key, and never a row of a key
|
|
85
|
+
* that has a claimed row or one waiting for a retry. `next_attempt_at` only
|
|
86
|
+
* delays retries: it is the writer's transaction start, so a fresh row that
|
|
87
|
+
* committed later can carry an earlier one, or one after this claim began.
|
|
88
|
+
*/
|
|
89
|
+
const claim: PgOutbox<Row>["claim"] = (limit = batchSize) =>
|
|
90
|
+
sql.begin(async (tx) => {
|
|
91
|
+
const cap = Math.min(Math.max(limit, 1), batchSize);
|
|
92
|
+
// Claims take turns: a concurrent claim would skip this one's locked rows and take later rows of the same keys.
|
|
93
|
+
// A claim that outlives its claim period is worthless, so neither a slow statement nor a vanished client holds the turn longer.
|
|
94
|
+
await tx`
|
|
95
|
+
SELECT set_config('statement_timeout', ${String(claimMs)}, true),
|
|
96
|
+
set_config('idle_in_transaction_session_timeout', ${String(claimMs)}, true),
|
|
97
|
+
pg_advisory_xact_lock(hashtextextended(${claimLock}, 0))
|
|
98
|
+
`;
|
|
99
|
+
// The busy probe runs per candidate (OFFSET 0 keeps the planner from turning it into a join, which
|
|
100
|
+
// stale statistics can make quadratic), against an index of claimed and retried rows per key.
|
|
101
|
+
return tx<Row[]>`
|
|
102
|
+
WITH candidates AS MATERIALIZED (
|
|
103
|
+
SELECT current.id
|
|
104
|
+
FROM ${table} current
|
|
105
|
+
WHERE (current.attempts = 0 OR current.next_attempt_at <= now())
|
|
106
|
+
AND (current.claimed_until IS NULL OR current.claimed_until <= now())
|
|
107
|
+
${matches("current")}
|
|
108
|
+
AND NOT EXISTS (
|
|
109
|
+
SELECT
|
|
110
|
+
FROM ${table} busy
|
|
111
|
+
WHERE busy.${runColumn} = current.${runColumn}
|
|
112
|
+
AND (busy.claimed_until > now() OR (busy.attempts > 0 AND busy.next_attempt_at > now()))
|
|
113
|
+
${matches("busy")}
|
|
114
|
+
OFFSET 0
|
|
115
|
+
)
|
|
116
|
+
ORDER BY current.${sequence}
|
|
117
|
+
LIMIT ${cap}
|
|
118
|
+
FOR UPDATE SKIP LOCKED
|
|
119
|
+
), claimed AS (
|
|
120
|
+
UPDATE ${table} outbox
|
|
121
|
+
SET claimed_until = now() + (${claimMs} * interval '1 millisecond')
|
|
122
|
+
FROM candidates
|
|
123
|
+
WHERE outbox.id = candidates.id
|
|
124
|
+
RETURNING outbox.*
|
|
125
|
+
)
|
|
126
|
+
SELECT * FROM claimed ORDER BY ${sequence}
|
|
127
|
+
`;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const retryLater = async (row: Row, error: unknown) => {
|
|
131
|
+
const attempts = row.attempts + 1;
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
|
134
|
+
await sql`
|
|
135
|
+
UPDATE ${table}
|
|
136
|
+
SET attempts = ${attempts},
|
|
137
|
+
next_attempt_at = now() + (${delaySeconds} * interval '1 second'),
|
|
138
|
+
claimed_until = NULL,
|
|
139
|
+
last_error = ${message.slice(0, 1_000)}
|
|
140
|
+
WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}
|
|
107
141
|
`;
|
|
142
|
+
log.warn("Outbox delivery failed", { outboxId: row.id, attempts, error: message });
|
|
108
143
|
};
|
|
109
144
|
|
|
110
145
|
const dispatch: PgOutbox<Row>["dispatch"] = async (row, publish = config.publish) => {
|
|
111
146
|
try {
|
|
112
147
|
await publish(row);
|
|
113
|
-
await sql`
|
|
114
|
-
UPDATE ${table}
|
|
115
|
-
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}
|
|
117
|
-
`;
|
|
148
|
+
await sql`DELETE FROM ${table} WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}`;
|
|
118
149
|
} catch (error) {
|
|
119
|
-
|
|
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 });
|
|
150
|
+
await retryLater(row, error);
|
|
133
151
|
}
|
|
134
152
|
};
|
|
135
153
|
|
|
154
|
+
/** Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key. */
|
|
155
|
+
const dispatchBatch = async (rows: Row[]) => {
|
|
156
|
+
const delivered: string[] = [];
|
|
157
|
+
const runs = Map.groupBy(rows, (row) => row[runKey]);
|
|
158
|
+
const results = await Promise.allSettled(
|
|
159
|
+
[...runs.values()].map(async (run) => {
|
|
160
|
+
for (const [index, row] of run.entries()) {
|
|
161
|
+
try {
|
|
162
|
+
await config.publish(row);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await retryLater(row, error);
|
|
165
|
+
const rest = run.slice(index + 1).map((later) => later.id);
|
|
166
|
+
if (rest.length > 0) await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
delivered.push(row.id);
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
if (delivered.length > 0) await sql`DELETE FROM ${table} WHERE id = ANY(${sql.array(delivered, "uuid")})`;
|
|
174
|
+
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
175
|
+
};
|
|
176
|
+
|
|
136
177
|
let activeReconcile: Promise<number> | null = null;
|
|
137
178
|
let reconcileRequested = false;
|
|
138
179
|
|
|
@@ -140,19 +181,12 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
140
181
|
reconcileRequested = true;
|
|
141
182
|
if (activeReconcile) return activeReconcile;
|
|
142
183
|
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
|
-
`;
|
|
149
184
|
let processed = 0;
|
|
150
185
|
let rows: Row[];
|
|
151
186
|
do {
|
|
152
187
|
reconcileRequested = false;
|
|
153
188
|
rows = await claim();
|
|
154
|
-
|
|
155
|
-
for (const row of rows) await dispatch(row);
|
|
189
|
+
await dispatchBatch(rows);
|
|
156
190
|
processed += rows.length;
|
|
157
191
|
} while (reconcileRequested || rows.length > 0);
|
|
158
192
|
return processed;
|