@k2b/cloud 0.6.0-rc.0 → 0.7.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/README.md +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
|
@@ -15,7 +15,6 @@ import { encryptSecret } from "../secrets";
|
|
|
15
15
|
import { ensureNotificationDefinition } from "./catalog";
|
|
16
16
|
import { getNotificationChannel, type NotificationDestination, type ResolvedNotificationRecipient } from "./channels";
|
|
17
17
|
import { processNotificationDelivery } from "./dispatcher";
|
|
18
|
-
import { notificationLive } from "./live";
|
|
19
18
|
import { enqueueNotificationDeliveries, enqueueNotificationDelivery } from "./runtime";
|
|
20
19
|
|
|
21
20
|
export type TypedNotificationDeliveryStatus = "deferred" | "pending" | "sending" | "delivered" | "suppressed" | "failed";
|
|
@@ -406,9 +405,6 @@ export const sendTypedNotification = async <
|
|
|
406
405
|
});
|
|
407
406
|
|
|
408
407
|
if (persisted.prepared) {
|
|
409
|
-
if (resolved.recipient.userId && (requiredChannelSet.has("browser") || preferred.includes("browser"))) {
|
|
410
|
-
await notificationLive.publish({ userId: resolved.recipient.userId, eventId: identity.id, presentation });
|
|
411
|
-
}
|
|
412
408
|
for (const deliveryId of persisted.requiredIds) {
|
|
413
409
|
const result = await processNotificationDelivery(deliveryId);
|
|
414
410
|
if (result.status === "retry") await enqueueNotificationDelivery(deliveryId, result.retryAfterMs);
|
|
@@ -6,7 +6,7 @@ import { isAccountExpired } from "./account-model";
|
|
|
6
6
|
import { IDENTITY_JWKS_MAX_AGE_SECONDS } from "./identity/constants";
|
|
7
7
|
import { getIdentityRuntimeConfig } from "./identity/runtime-config";
|
|
8
8
|
import type { ServiceAccount } from "./service-accounts";
|
|
9
|
-
import { buildProjectedUser, userProjectionSql } from "./session/user";
|
|
9
|
+
import { buildProjectedUser, userProjectionJoin, userProjectionSql } from "./session/user";
|
|
10
10
|
|
|
11
11
|
type ServiceAccountActorRow = Record<string, unknown> & {
|
|
12
12
|
service_account_id: string;
|
|
@@ -116,7 +116,7 @@ export const resolveOAuthTokenActor = async (
|
|
|
116
116
|
FROM oauth.clients c
|
|
117
117
|
JOIN auth.service_accounts sa ON sa.id = ${serviceAccountId}::uuid AND sa.status = 'active'
|
|
118
118
|
LEFT JOIN auth.users u ON u.id = sa.delegated_user_id
|
|
119
|
-
|
|
119
|
+
${userProjectionJoin()}
|
|
120
120
|
WHERE c.client_id = ${clientId}
|
|
121
121
|
`;
|
|
122
122
|
const row = rows[0];
|
|
@@ -139,7 +139,7 @@ export const resolveOAuthTokenActor = async (
|
|
|
139
139
|
SELECT ${userProjectionSql(groupsAdmin)}
|
|
140
140
|
FROM oauth.clients c
|
|
141
141
|
JOIN auth.users u ON ${predicate}
|
|
142
|
-
|
|
142
|
+
${userProjectionJoin()}
|
|
143
143
|
WHERE c.client_id = ${clientId}
|
|
144
144
|
`;
|
|
145
145
|
const user = rows[0] ? buildProjectedUser(rows[0]) : null;
|
|
@@ -34,6 +34,18 @@ export type RenderHtmlToPdfInput = {
|
|
|
34
34
|
headerHtml?: string | null;
|
|
35
35
|
footerHtml?: string | null;
|
|
36
36
|
filename?: string;
|
|
37
|
+
assets?: Array<{ name: string; data: Blob }>;
|
|
38
|
+
page?: {
|
|
39
|
+
format?: "A4" | "A3" | "A5" | "Letter" | "Legal";
|
|
40
|
+
landscape?: boolean;
|
|
41
|
+
margin?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
42
|
+
};
|
|
43
|
+
tagged?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AttachPdfFilesInput = {
|
|
47
|
+
document: Blob;
|
|
48
|
+
attachments: Array<{ name: string; data: Blob; relationship?: "Source" | "Data" | "Alternative" | "Supplement" | "Unspecified" }>;
|
|
37
49
|
};
|
|
38
50
|
|
|
39
51
|
export type RenderFacturXHtmlToPdfInput = RenderHtmlToPdfInput & {
|
|
@@ -59,6 +71,7 @@ export type GotenbergFetch = (input: string | URL | Request, init?: RequestInit)
|
|
|
59
71
|
|
|
60
72
|
export type RenderHtmlToPdfOptions = {
|
|
61
73
|
fetch?: GotenbergFetch;
|
|
74
|
+
signal?: AbortSignal;
|
|
62
75
|
};
|
|
63
76
|
|
|
64
77
|
const DEFAULT_PDF_CONTENT_TYPE = "application/pdf";
|
|
@@ -105,111 +118,118 @@ const sanitizeFetchError = (error: unknown): GotenbergRenderError => {
|
|
|
105
118
|
return new GotenbergRenderError("request_failed", "Gotenberg request failed.");
|
|
106
119
|
};
|
|
107
120
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (htmlBytes > config.maxHtmlBytes) {
|
|
115
|
-
throw new GotenbergRenderError("html_too_large", `HTML input is too large (${htmlBytes} bytes, limit ${config.maxHtmlBytes} bytes).`);
|
|
116
|
-
}
|
|
121
|
+
const dimensions = { A4: [210, 297], A3: [297, 420], A5: [148, 210], Letter: [215.9, 279.4], Legal: [215.9, 355.6] } as const;
|
|
122
|
+
const fileName = (name: string) => {
|
|
123
|
+
if (!name || name.length > 180 || /[\\/\x00-\x1f]/.test(name) || name === "." || name === "..")
|
|
124
|
+
throw new GotenbergRenderError("bad_input", "Use a plain filename without directories.");
|
|
125
|
+
return name;
|
|
126
|
+
};
|
|
117
127
|
|
|
118
|
-
|
|
128
|
+
function htmlForm(input: RenderHtmlToPdfInput, config: GotenbergConfig): FormData {
|
|
129
|
+
const total = [input.html, input.headerHtml ?? "", input.footerHtml ?? ""].reduce((sum, value) => sum + byteLength(value), 0)
|
|
130
|
+
+ (input.assets ?? []).reduce((sum, asset) => sum + asset.data.size, 0);
|
|
131
|
+
if (total > config.maxHtmlBytes) throw new GotenbergRenderError("html_too_large", "HTML and assets exceed the configured input budget.");
|
|
119
132
|
const form = new FormData();
|
|
120
133
|
form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
|
|
121
134
|
if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
|
|
122
135
|
if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
|
|
123
|
-
|
|
136
|
+
const names = new Set(["index.html", "header.html", "footer.html", "factur-x.xml"]);
|
|
137
|
+
for (const asset of input.assets ?? []) {
|
|
138
|
+
const name = fileName(asset.name);
|
|
139
|
+
if (names.has(name.toLowerCase())) throw new GotenbergRenderError("bad_input", "Asset filenames must be unique and cannot replace HTML documents.");
|
|
140
|
+
names.add(name.toLowerCase());
|
|
141
|
+
form.append("files", asset.data, name);
|
|
142
|
+
}
|
|
143
|
+
// Preserve the existing CSS page-size contract unless a caller supplies page options.
|
|
144
|
+
form.append("preferCssPageSize", String(!input.page));
|
|
124
145
|
form.append("printBackground", "true");
|
|
146
|
+
if (input.tagged !== undefined) form.append("generateTaggedPdf", String(input.tagged));
|
|
147
|
+
if (input.page) {
|
|
148
|
+
const [width, height] = dimensions[input.page.format ?? "A4"];
|
|
149
|
+
form.append("paperWidth", String(width / 25.4));
|
|
150
|
+
form.append("paperHeight", String(height / 25.4));
|
|
151
|
+
form.append("landscape", String(input.page.landscape ?? false));
|
|
152
|
+
for (const side of ["top", "right", "bottom", "left"] as const) {
|
|
153
|
+
const value = input.page.margin?.[side] ?? 15;
|
|
154
|
+
if (!Number.isFinite(value) || value < 0) throw new GotenbergRenderError("bad_input", "Page margins must be nonnegative millimeters.");
|
|
155
|
+
form.append(`margin${side[0]!.toUpperCase()}${side.slice(1)}`, String(value / 25.4));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return form;
|
|
159
|
+
}
|
|
125
160
|
|
|
161
|
+
/** Bounded transport shared by HTML conversion and attachment embedding. */
|
|
162
|
+
async function requestPdf(route: string, form: FormData, config: GotenbergConfig, options: RenderHtmlToPdfOptions): Promise<RenderHtmlToPdfResult> {
|
|
126
163
|
const headers = new Headers();
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let response: Response;
|
|
164
|
+
const auth = basicAuthHeader(config);
|
|
165
|
+
if (auth) headers.set("Authorization", auth);
|
|
166
|
+
const signal = options.signal ? AbortSignal.any([options.signal, abortSignal(config.timeoutMs)]) : abortSignal(config.timeoutMs);
|
|
167
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
132
168
|
try {
|
|
133
|
-
response = await
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
169
|
+
const response = await (options.fetch ?? fetch)(`${normalizeBaseUrl(config.url)}${route}`, { method: "POST", headers, body: form, signal });
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
await response.body?.cancel();
|
|
172
|
+
throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
|
|
173
|
+
}
|
|
174
|
+
const contentType = response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE;
|
|
175
|
+
if (contentType.split(";")[0] !== DEFAULT_PDF_CONTENT_TYPE) {
|
|
176
|
+
await response.body?.cancel();
|
|
177
|
+
throw new GotenbergRenderError("bad_response", "Gotenberg did not return a PDF.");
|
|
178
|
+
}
|
|
179
|
+
if (!response.body) throw new GotenbergRenderError("bad_response", "Gotenberg returned an empty response.");
|
|
180
|
+
reader = response.body.getReader();
|
|
181
|
+
const chunks: Uint8Array[] = [];
|
|
182
|
+
let size = 0;
|
|
183
|
+
while (true) {
|
|
184
|
+
signal.throwIfAborted();
|
|
185
|
+
const item = await reader.read();
|
|
186
|
+
if (item.done) break;
|
|
187
|
+
size += item.value.byteLength;
|
|
188
|
+
if (size > config.maxPdfBytes) throw new GotenbergRenderError("pdf_too_large", "PDF output exceeds the configured budget.");
|
|
189
|
+
chunks.push(item.value);
|
|
190
|
+
}
|
|
191
|
+
const pdf = new Uint8Array(size);
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const chunk of chunks) { pdf.set(chunk, offset); offset += chunk.byteLength; }
|
|
194
|
+
return { pdf, contentType };
|
|
139
195
|
} catch (error) {
|
|
196
|
+
await reader?.cancel().catch(() => {});
|
|
140
197
|
throw sanitizeFetchError(error);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (!response.ok) {
|
|
144
|
-
throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const buffer = await response.arrayBuffer();
|
|
148
|
-
if (buffer.byteLength > config.maxPdfBytes) {
|
|
149
|
-
throw new GotenbergRenderError(
|
|
150
|
-
"pdf_too_large",
|
|
151
|
-
`PDF output is too large (${buffer.byteLength} bytes, limit ${config.maxPdfBytes} bytes).`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
198
|
+
} finally { reader?.releaseLock(); }
|
|
199
|
+
}
|
|
154
200
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE,
|
|
158
|
-
};
|
|
159
|
-
};
|
|
201
|
+
export const renderHtmlToPdfWithConfig = async (input: RenderHtmlToPdfInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> =>
|
|
202
|
+
requestPdf("/forms/chromium/convert/html", htmlForm(input, config), config, options);
|
|
160
203
|
|
|
161
|
-
export const renderFacturXHtmlToPdfWithConfig = async (
|
|
162
|
-
input: RenderFacturXHtmlToPdfInput,
|
|
163
|
-
config: GotenbergConfig,
|
|
164
|
-
options: RenderHtmlToPdfOptions = {},
|
|
165
|
-
): Promise<RenderHtmlToPdfResult> => {
|
|
166
|
-
const htmlBytes = byteLength(input.html);
|
|
204
|
+
export const renderFacturXHtmlToPdfWithConfig = async (input: RenderFacturXHtmlToPdfInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> => {
|
|
167
205
|
const xmlBytes = byteLength(input.xml);
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
if (xmlBytes === 0 || xmlBytes > config.maxHtmlBytes) {
|
|
172
|
-
throw new GotenbergRenderError(
|
|
173
|
-
xmlBytes === 0 ? "bad_input" : "html_too_large",
|
|
174
|
-
xmlBytes === 0
|
|
175
|
-
? "Factur-X XML must not be empty."
|
|
176
|
-
: `Factur-X XML input is too large (${xmlBytes} bytes, limit ${config.maxHtmlBytes} bytes).`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const baseUrl = normalizeBaseUrl(config.url);
|
|
181
|
-
const form = new FormData();
|
|
182
|
-
form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
|
|
183
|
-
if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
|
|
184
|
-
if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
|
|
206
|
+
if (!xmlBytes || xmlBytes > config.maxHtmlBytes) throw new GotenbergRenderError(xmlBytes ? "html_too_large" : "bad_input", "Invoice XML is empty or exceeds the configured input budget.");
|
|
207
|
+
const form = htmlForm(input, { ...config, maxHtmlBytes: config.maxHtmlBytes - xmlBytes });
|
|
185
208
|
form.append("facturxXml", new Blob([input.xml], { type: "application/xml" }), "factur-x.xml");
|
|
186
209
|
form.append("facturxConformanceLevel", input.conformanceLevel ?? "EN 16931");
|
|
187
210
|
form.append("facturxDocumentType", input.documentType ?? "INVOICE");
|
|
188
211
|
form.append("facturxVersion", input.facturXVersion ?? "1.0");
|
|
189
212
|
form.append("pdfa", "PDF/A-3b");
|
|
190
|
-
|
|
191
|
-
|
|
213
|
+
return requestPdf("/forms/chromium/convert/html", form, config, options);
|
|
214
|
+
};
|
|
192
215
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!response.ok) throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
|
|
208
|
-
const buffer = await response.arrayBuffer();
|
|
209
|
-
if (buffer.byteLength > config.maxPdfBytes) {
|
|
210
|
-
throw new GotenbergRenderError("pdf_too_large", `PDF output is too large (${buffer.byteLength} bytes, limit ${config.maxPdfBytes} bytes).`);
|
|
216
|
+
export const attachPdfFilesWithConfig = async (input: AttachPdfFilesInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> => {
|
|
217
|
+
if (!input.attachments.length) throw new GotenbergRenderError("bad_input", "At least one attachment is required.");
|
|
218
|
+
if (input.document.size + input.attachments.reduce((sum, file) => sum + file.data.size, 0) > config.maxPdfBytes)
|
|
219
|
+
throw new GotenbergRenderError("pdf_too_large", "PDF and attachments exceed the configured input budget.");
|
|
220
|
+
const form = new FormData();
|
|
221
|
+
form.append("files", input.document, "document.pdf");
|
|
222
|
+
const metadata: Record<string, { mimeType: string; relationship: string }> = Object.create(null);
|
|
223
|
+
const names = new Set<string>();
|
|
224
|
+
for (const attachment of input.attachments) {
|
|
225
|
+
const name = fileName(attachment.name);
|
|
226
|
+
if (names.has(name.toLowerCase())) throw new GotenbergRenderError("bad_input", "Attachment filenames must be unique.");
|
|
227
|
+
names.add(name.toLowerCase());
|
|
228
|
+
form.append("embeds", attachment.data, name);
|
|
229
|
+
metadata[name] = { mimeType: attachment.data.type || "application/octet-stream", relationship: attachment.relationship ?? "Unspecified" };
|
|
211
230
|
}
|
|
212
|
-
|
|
231
|
+
form.append("embedsMetadata", JSON.stringify(metadata));
|
|
232
|
+
return requestPdf("/forms/pdfengines/embed", form, config, options);
|
|
213
233
|
};
|
|
214
234
|
|
|
215
235
|
export const mergePdfsWithConfig = async (
|
|
@@ -288,3 +308,6 @@ export const testGotenberg = async (options: RenderHtmlToPdfOptions = {}): Promi
|
|
|
288
308
|
const result = await renderHtmlToPdf({ html: TEST_HTML, filename: "index.html" }, options);
|
|
289
309
|
return { bytes: result.pdf.byteLength, contentType: result.contentType };
|
|
290
310
|
};
|
|
311
|
+
|
|
312
|
+
export const attachPdfFiles = async (input: AttachPdfFilesInput, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> =>
|
|
313
|
+
attachPdfFilesWithConfig(input, await getGotenbergConfig(), options);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
GotenbergConfig,
|
|
3
|
+
AttachPdfFilesInput,
|
|
3
4
|
GotenbergRenderErrorCode,
|
|
4
5
|
MergePdfsInput,
|
|
5
6
|
RenderFacturXHtmlToPdfInput,
|
|
@@ -10,6 +11,8 @@ export type {
|
|
|
10
11
|
export {
|
|
11
12
|
GotenbergRenderError,
|
|
12
13
|
getGotenbergConfig,
|
|
14
|
+
attachPdfFiles,
|
|
15
|
+
attachPdfFilesWithConfig,
|
|
13
16
|
mergePdfs,
|
|
14
17
|
mergePdfsWithConfig,
|
|
15
18
|
renderFacturXHtmlToPdf,
|
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { MutationResult, UserProfile } from "../../../contracts/shared";
|
|
3
3
|
import { isAccountCategoryAllowed } from "../../account-category-policy";
|
|
4
4
|
import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
|
|
5
|
+
import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../../accounts/email-write";
|
|
5
6
|
import { resolveStoredAdminState } from "../../accounts/model";
|
|
6
7
|
import { writeLocalAccount } from "../../accounts/posix";
|
|
7
8
|
import type { AuditActor } from "../../audit";
|
|
@@ -31,6 +32,7 @@ export const create = async (params: {
|
|
|
31
32
|
admin?: boolean;
|
|
32
33
|
actor?: AuditActor;
|
|
33
34
|
}): Promise<MutationResult<{ id: string }>> => {
|
|
35
|
+
const email = normalizeAccountEmail(params.data.email);
|
|
34
36
|
const uid = await createLocalUid();
|
|
35
37
|
const admin = resolveStoredAdminState({
|
|
36
38
|
provider: "local",
|
|
@@ -42,6 +44,8 @@ export const create = async (params: {
|
|
|
42
44
|
return await writeLocalAccount(async (tx) => {
|
|
43
45
|
if (!(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
|
|
44
46
|
return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
|
|
47
|
+
await lockAccountEmail(tx, email);
|
|
48
|
+
if ((await findAccountsByEmail(tx, email)).length > 0) return emailAlreadyUsed();
|
|
45
49
|
const rows = await tx<{ id: string }[]>`
|
|
46
50
|
INSERT INTO auth.users (
|
|
47
51
|
uid,
|
|
@@ -58,7 +62,7 @@ export const create = async (params: {
|
|
|
58
62
|
${uid},
|
|
59
63
|
'local',
|
|
60
64
|
${params.profile},
|
|
61
|
-
${
|
|
65
|
+
${email},
|
|
62
66
|
${params.data.givenname ?? ""},
|
|
63
67
|
${params.data.sn ?? ""},
|
|
64
68
|
${params.data.displayName ?? ""},
|
|
@@ -118,21 +122,26 @@ export const update = async (params: {
|
|
|
118
122
|
mail?: string;
|
|
119
123
|
};
|
|
120
124
|
}): Promise<MutationResult<void>> => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
125
|
+
return sql.begin(async (tx) => {
|
|
126
|
+
if (params.data.mail !== undefined) await lockAccountEmail(tx, params.data.mail);
|
|
127
|
+
else await tx`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
|
|
128
|
+
const [existing] = await tx<{ mail: string | null }[]>`
|
|
129
|
+
SELECT mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
|
|
130
|
+
`;
|
|
131
|
+
if (!existing) return { ok: false, error: "Local user not found", status: 404 };
|
|
132
|
+
const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail);
|
|
133
|
+
const changed = email !== undefined && email !== normalizeAccountEmail(existing.mail ?? "");
|
|
134
|
+
if (changed && email && (await findAccountsByEmail(tx, email)).some((row) => row.id !== params.id)) return emailAlreadyUsed();
|
|
135
|
+
await tx`
|
|
136
|
+
UPDATE auth.users
|
|
137
|
+
SET given_name = CASE WHEN ${params.data.givenname !== undefined} THEN ${params.data.givenname ?? ""} ELSE given_name END,
|
|
138
|
+
sn = CASE WHEN ${params.data.sn !== undefined} THEN ${params.data.sn ?? ""} ELSE sn END,
|
|
139
|
+
display_name = CASE WHEN ${params.data.displayName !== undefined} THEN ${params.data.displayName ?? ""} ELSE display_name END,
|
|
140
|
+
mail = CASE WHEN ${changed} THEN ${email || null} ELSE mail END
|
|
141
|
+
WHERE id = ${params.id}::uuid
|
|
142
|
+
`;
|
|
143
|
+
return { ok: true, data: undefined };
|
|
144
|
+
});
|
|
136
145
|
};
|
|
137
146
|
|
|
138
147
|
export const setProfile = async (params: {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { request, type RequestOptions } from "node:https";
|
|
2
|
+
import type { ClientRequest, IncomingMessage } from "node:http";
|
|
3
|
+
import { resolvePublicNetworkAddresses } from "./network-security";
|
|
4
|
+
|
|
5
|
+
export type PublicHttpInput = {
|
|
6
|
+
url: string;
|
|
7
|
+
method: string;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
body?: Uint8Array;
|
|
10
|
+
maxBytes: number;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** One public HTTPS request, pinned to a validated address. Never redirects or retries.
|
|
15
|
+
* Callers own credential authorization, request budgets and response disclosure. */
|
|
16
|
+
export async function requestPublicHttps(
|
|
17
|
+
input: PublicHttpInput,
|
|
18
|
+
dependencies: {
|
|
19
|
+
resolve: typeof resolvePublicNetworkAddresses;
|
|
20
|
+
request: (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
|
21
|
+
} = { resolve: resolvePublicNetworkAddresses, request },
|
|
22
|
+
) {
|
|
23
|
+
if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 0) throw new Error("INVALID_BYTE_LIMIT");
|
|
24
|
+
const url = new URL(input.url);
|
|
25
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("HTTPS_REQUIRED");
|
|
26
|
+
input.signal.throwIfAborted();
|
|
27
|
+
let cancelLookup = () => {};
|
|
28
|
+
const addresses = await Promise.race([
|
|
29
|
+
dependencies.resolve(url.hostname),
|
|
30
|
+
new Promise<never>((_, reject) => {
|
|
31
|
+
cancelLookup = () => reject(new Error("HTTP_CANCELLED"));
|
|
32
|
+
input.signal.addEventListener("abort", cancelLookup, { once: true });
|
|
33
|
+
if (input.signal.aborted) cancelLookup();
|
|
34
|
+
}),
|
|
35
|
+
]).finally(() => input.signal.removeEventListener("abort", cancelLookup));
|
|
36
|
+
input.signal.throwIfAborted();
|
|
37
|
+
const target = addresses[0]!;
|
|
38
|
+
return new Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>((resolve, reject) => {
|
|
39
|
+
let settled = false;
|
|
40
|
+
const fail = () => finish(undefined);
|
|
41
|
+
const finish = (value?: { status: number; headers: Record<string, string>; body: Uint8Array }) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
settled = true;
|
|
44
|
+
input.signal.removeEventListener("abort", abort);
|
|
45
|
+
if (value) resolve(value);
|
|
46
|
+
else reject(new Error("HTTP_FAILED"));
|
|
47
|
+
};
|
|
48
|
+
const outgoing = dependencies.request(
|
|
49
|
+
{
|
|
50
|
+
hostname: target.address,
|
|
51
|
+
family: target.family,
|
|
52
|
+
port: url.port || 443,
|
|
53
|
+
servername: url.hostname.replace(/^\[|\]$/g, ""),
|
|
54
|
+
rejectUnauthorized: true,
|
|
55
|
+
path: url.pathname + url.search,
|
|
56
|
+
method: input.method,
|
|
57
|
+
headers: { ...input.headers, host: url.host, "accept-encoding": "identity" },
|
|
58
|
+
},
|
|
59
|
+
(incoming) => {
|
|
60
|
+
const status = incoming.statusCode ?? 0;
|
|
61
|
+
const headers: Record<string, string> = {};
|
|
62
|
+
for (const name of ["content-type", "retry-after", "etag", "last-modified"]) {
|
|
63
|
+
const value = incoming.headers[name];
|
|
64
|
+
if (typeof value === "string") headers[name] = value;
|
|
65
|
+
}
|
|
66
|
+
// A redirect never receives a second request or exposes a Location token.
|
|
67
|
+
if (status >= 300 && status < 400 && status !== 304) {
|
|
68
|
+
incoming.destroy();
|
|
69
|
+
finish({ status, headers, body: new Uint8Array() });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
|
|
73
|
+
incoming.destroy();
|
|
74
|
+
fail();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const chunks: Uint8Array[] = [];
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
incoming.on("data", (chunk: Buffer) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
bytes += chunk.byteLength;
|
|
82
|
+
if (bytes > input.maxBytes) {
|
|
83
|
+
incoming.destroy();
|
|
84
|
+
fail();
|
|
85
|
+
} else chunks.push(chunk);
|
|
86
|
+
});
|
|
87
|
+
incoming.on("end", () => finish({ status, headers, body: Buffer.concat(chunks, bytes) }));
|
|
88
|
+
incoming.on("error", fail);
|
|
89
|
+
incoming.on("aborted", fail);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
const abort = () => {
|
|
93
|
+
outgoing.destroy();
|
|
94
|
+
fail();
|
|
95
|
+
};
|
|
96
|
+
outgoing.on("error", fail); // Never expose errors containing injected headers.
|
|
97
|
+
input.signal.addEventListener("abort", abort, { once: true });
|
|
98
|
+
if (input.signal.aborted) abort();
|
|
99
|
+
else outgoing.end(input.body);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { RailAdminSchema, RailAdminInputSchema, type RailAdminInput, type RailAdminState } from "../contracts/rail-admin";
|
|
3
|
+
import { RailShortcutSchema } from "../contracts/rail-preferences";
|
|
4
|
+
import { hasRole, type User } from "../contracts/shared";
|
|
5
|
+
import { buildAccessPrincipalCondition, createAccess } from "../server/services/access";
|
|
6
|
+
|
|
7
|
+
export class RailAdminError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public status: 400 | 403 | 409,
|
|
10
|
+
message: string,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const requireAdmin = (actor: User | undefined) => {
|
|
16
|
+
if (!actor || !hasRole(actor, "admin")) throw new RailAdminError(403, "Administrator access required");
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const createRailShortcutsService = (db: SQL = sql) => {
|
|
20
|
+
const list = async (query: SQL = db): Promise<RailAdminState> => {
|
|
21
|
+
const [state] = await query<{ revision: number }[]>`SELECT revision FROM auth.rail_state WHERE singleton`;
|
|
22
|
+
const rows = await query<{ shortcut: unknown; access: unknown }[]>`
|
|
23
|
+
SELECT r.shortcut, COALESCE((
|
|
24
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
25
|
+
'id', a.id, 'permission', a.permission, 'createdAt', a.created_at,
|
|
26
|
+
'principal', CASE WHEN a.user_id IS NOT NULL THEN jsonb_build_object('type', 'user', 'userId', a.user_id)
|
|
27
|
+
WHEN a.group_id IS NOT NULL THEN jsonb_build_object('type', 'group', 'groupId', a.group_id)
|
|
28
|
+
ELSE jsonb_build_object('type', 'authenticated') END,
|
|
29
|
+
'displayName', COALESCE(u.display_name, g.name, '')
|
|
30
|
+
) ORDER BY a.created_at, a.id)
|
|
31
|
+
FROM auth.rail_shortcut_access ra JOIN auth.access a ON a.id = ra.access_id
|
|
32
|
+
LEFT JOIN auth.users u ON u.id = a.user_id LEFT JOIN auth.groups g ON g.id = a.group_id
|
|
33
|
+
WHERE ra.shortcut_id = r.id
|
|
34
|
+
), '[]'::jsonb) AS access
|
|
35
|
+
FROM auth.rail_shortcuts r ORDER BY r.position, r.id
|
|
36
|
+
`;
|
|
37
|
+
return RailAdminSchema.parse({ revision: state?.revision ?? 0, entries: rows });
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
async invalidateCache(actor: User | undefined) {
|
|
41
|
+
requireAdmin(actor);
|
|
42
|
+
// One generation switch invalidates every user, including concurrent old refills.
|
|
43
|
+
// Keep the configuration revision unchanged so open editors remain valid.
|
|
44
|
+
await db`UPDATE auth.rail_state SET cache_version = gen_random_uuid() WHERE singleton`;
|
|
45
|
+
},
|
|
46
|
+
async list(actor: User | undefined) {
|
|
47
|
+
requireAdmin(actor);
|
|
48
|
+
return list();
|
|
49
|
+
},
|
|
50
|
+
async save(actor: User | undefined, input: RailAdminInput) {
|
|
51
|
+
requireAdmin(actor);
|
|
52
|
+
const value = RailAdminInputSchema.parse(input);
|
|
53
|
+
return db.begin(async (tx) => {
|
|
54
|
+
const [state] = await tx<{ revision: number }[]>`SELECT revision FROM auth.rail_state WHERE singleton FOR UPDATE`;
|
|
55
|
+
if (state?.revision !== value.revision) throw new RailAdminError(409, "App bar configuration changed. Reload before saving.");
|
|
56
|
+
await tx`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM auth.rail_shortcut_access)`;
|
|
57
|
+
await tx`DELETE FROM auth.rail_shortcuts`;
|
|
58
|
+
for (const [position, entry] of value.entries.entries()) {
|
|
59
|
+
await tx`INSERT INTO auth.rail_shortcuts(id, position, shortcut)
|
|
60
|
+
VALUES (${entry.shortcut.id}, ${position}, (${JSON.stringify(entry.shortcut)}::text)::jsonb)`;
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
for (const access of entry.access) {
|
|
63
|
+
const key = JSON.stringify(access.principal);
|
|
64
|
+
if (seen.has(key)) continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
const created = await createAccess({ principal: access.principal, permission: "read" }, tx);
|
|
67
|
+
if (!created.ok) throw new RailAdminError(400, "A selected user or group no longer exists.");
|
|
68
|
+
await tx`INSERT INTO auth.rail_shortcut_access(shortcut_id, access_id) VALUES (${entry.shortcut.id}, ${created.data.id}::uuid)`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await tx`UPDATE auth.rail_state SET revision = revision + 1 WHERE singleton`;
|
|
72
|
+
return list(tx);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async forUser(userId: string) {
|
|
76
|
+
const match = buildAccessPrincipalCondition({
|
|
77
|
+
subject: { type: "user", userId },
|
|
78
|
+
columns: {
|
|
79
|
+
userId: db`a.user_id`,
|
|
80
|
+
groupId: db`a.group_id`,
|
|
81
|
+
serviceAccountId: db`a.service_account_id`,
|
|
82
|
+
authenticatedOnly: db`a.authenticated_only`,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const rows = await db<{ shortcut: unknown }[]>`
|
|
86
|
+
SELECT r.shortcut FROM auth.rail_shortcuts r WHERE EXISTS (
|
|
87
|
+
SELECT 1 FROM auth.rail_shortcut_access ra JOIN auth.access a ON a.id = ra.access_id
|
|
88
|
+
WHERE ra.shortcut_id = r.id AND a.permission = 'read' AND ${match}
|
|
89
|
+
) ORDER BY r.position, r.id
|
|
90
|
+
`;
|
|
91
|
+
return rows.map((row) => RailShortcutSchema.parse(row.shortcut));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
export const railShortcuts = createRailShortcutsService();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { requestCacheRedis } from "./request-cache-redis";
|
|
2
|
+
import { type RailSnapshot, RailSnapshotSchema } from "../contracts/rail-preferences";
|
|
3
|
+
import type { User } from "../contracts/shared";
|
|
4
|
+
import { railPreferences } from "./rail-preferences";
|
|
5
|
+
import { railShortcuts } from "./rail-shortcuts";
|
|
6
|
+
|
|
7
|
+
// Private request metadata: never serialized into the public User contract.
|
|
8
|
+
const versions = new WeakMap<User, string>();
|
|
9
|
+
export const setRailCacheVersion = (user: User, version: unknown) => {
|
|
10
|
+
if (typeof version === "string") versions.set(user, version);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** TTL only bounds unused generations; freshness comes from the current identity query. */
|
|
14
|
+
const CACHE_TTL_SECONDS = 300;
|
|
15
|
+
export const createRailSnapshotReader =
|
|
16
|
+
(
|
|
17
|
+
load: (userId: string) => Promise<RailSnapshot>,
|
|
18
|
+
cache: { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<unknown> },
|
|
19
|
+
) =>
|
|
20
|
+
async (user: User): Promise<RailSnapshot> => {
|
|
21
|
+
const version = versions.get(user);
|
|
22
|
+
if (!version) return load(user.id);
|
|
23
|
+
const key = `appglobalcache:user:${user.id}:rail:v1:${version}`;
|
|
24
|
+
try {
|
|
25
|
+
const cached = await cache.get(key);
|
|
26
|
+
if (cached !== null) {
|
|
27
|
+
const parsed = RailSnapshotSchema.safeParse(JSON.parse(cached));
|
|
28
|
+
if (parsed.success) return parsed.data;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
/* Valkey is an optimization; authoritative reads still work. */
|
|
32
|
+
}
|
|
33
|
+
const value = await load(user.id);
|
|
34
|
+
// A concurrent mutation changes the generation. A late refill remains under
|
|
35
|
+
// this request's old key and cannot poison a subsequent request's snapshot.
|
|
36
|
+
try {
|
|
37
|
+
await cache.set(key, JSON.stringify(value));
|
|
38
|
+
} catch {
|
|
39
|
+
/* Retry on a later read. */
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const readRailSnapshot = createRailSnapshotReader(
|
|
45
|
+
async (userId) => {
|
|
46
|
+
const [personal, managedShortcuts] = await Promise.all([railPreferences.get(userId), railShortcuts.forUser(userId)]);
|
|
47
|
+
return { ...personal, managedShortcuts };
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
get: async (key) => (await requestCacheRedis()).get(key),
|
|
51
|
+
set: async (key, value) => (await requestCacheRedis()).set(key, value, "EX", CACHE_TTL_SECONDS),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RedisClient } from "bun";
|
|
2
|
+
|
|
3
|
+
// Cache commands must not queue across a disconnect: delayed fills/invalidations
|
|
4
|
+
// are not useful, and reads must be able to fall back to Postgres. Keep this
|
|
5
|
+
// connection separate from sessions, rate limits and other Redis consumers.
|
|
6
|
+
let client: RedisClient | undefined;
|
|
7
|
+
let initialConnection: Promise<void> | undefined;
|
|
8
|
+
let reconnecting: Promise<void> | undefined;
|
|
9
|
+
|
|
10
|
+
export const requestCacheRedis = async (): Promise<RedisClient> => {
|
|
11
|
+
if (!client) {
|
|
12
|
+
client = new RedisClient(process.env.VALKEY_URL || process.env.REDIS_URL, { enableOfflineQueue: false });
|
|
13
|
+
// The first operation waits for normal connection establishment (Bun's
|
|
14
|
+
// connection timeout applies). Later disconnected operations fail promptly.
|
|
15
|
+
initialConnection = client.connect().catch(() => {});
|
|
16
|
+
}
|
|
17
|
+
await initialConnection;
|
|
18
|
+
if (!client.connected && !reconnecting) {
|
|
19
|
+
// Bun reconnects automatically; also allow recovery after its retry limit.
|
|
20
|
+
reconnecting = client
|
|
21
|
+
.connect()
|
|
22
|
+
.catch(() => {})
|
|
23
|
+
.finally(() => {
|
|
24
|
+
reconnecting = undefined;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return client;
|
|
28
|
+
};
|