@k2b/cloud 0.40.0 → 0.42.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 +9 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +158 -92
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/setup-lock.ts +103 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +76 -4
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +214 -23
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +64 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/http.ts +1 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +28 -0
- package/src/ai/open-questions.ts +168 -0
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +119 -35
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +184 -31
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +25 -4
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +73 -41
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/me.ts +79 -3
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +30 -66
- package/src/approvals/approval-card.ts +68 -0
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +149 -55
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +3 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +5 -3
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/accounts/users.ts +12 -1
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +15 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +72 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +11 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/session/index.ts +203 -29
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +185 -542
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +7 -5
- package/src/ssr/index.ts +2 -1
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-idle.ts +9 -0
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +15 -0
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -5,6 +5,7 @@ import type { CapabilityActionPolicy } from "../../contracts/capabilities";
|
|
|
5
5
|
import { CapabilityAppIdSchema } from "../../contracts/capabilities";
|
|
6
6
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
7
7
|
import { audit } from "../audit";
|
|
8
|
+
import { requireRegisteredAppAccess, resolveIssuanceAuthority } from "../identity/issue-invocation";
|
|
8
9
|
import { logger } from "../logging";
|
|
9
10
|
import { parsePgJsonValue } from "../postgres";
|
|
10
11
|
import { isMandatePolicyNarrowing, type MandatePolicyV1, MandatePolicyV1Schema, mandatePolicyAllows, parseMandatePolicy } from "./policy";
|
|
@@ -213,6 +214,33 @@ const validSubject = async (subject: MandateSubject, db: SQL): Promise<boolean>
|
|
|
213
214
|
return row?.active === true;
|
|
214
215
|
};
|
|
215
216
|
|
|
217
|
+
const subjectAppAccess = async (
|
|
218
|
+
subject: MandateSubject,
|
|
219
|
+
appIds: readonly string[],
|
|
220
|
+
db: SQL,
|
|
221
|
+
groupsAdmin?: readonly string[],
|
|
222
|
+
): Promise<boolean> => {
|
|
223
|
+
try {
|
|
224
|
+
const authority = await resolveIssuanceAuthority(
|
|
225
|
+
{
|
|
226
|
+
sub: subject.id,
|
|
227
|
+
principal_type: subject.type,
|
|
228
|
+
access_subject_type: subject.type,
|
|
229
|
+
access_subject_id: subject.id,
|
|
230
|
+
credential_kind: "mandate",
|
|
231
|
+
scopes: [],
|
|
232
|
+
},
|
|
233
|
+
"core",
|
|
234
|
+
db,
|
|
235
|
+
groupsAdmin,
|
|
236
|
+
);
|
|
237
|
+
for (const appId of new Set(appIds)) await requireRegisteredAppAccess(authority, appId, { db });
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
216
244
|
const canActForSubject = (authority: MandateInteractiveAuthority | MandateAdminAuthority, subject: MandateSubject): boolean =>
|
|
217
245
|
authority.kind === "admin" || (subject.type === "user" && subject.id === authority.userId);
|
|
218
246
|
|
|
@@ -548,6 +576,12 @@ const setMandateState = async (
|
|
|
548
576
|
return changedRow(input.mandateId, input.expectedRevision, input.authority, db, async (current) => {
|
|
549
577
|
if (state === "active" && input.authority.kind === "workload") return fail(err.forbidden("Workloads cannot resume mandates"));
|
|
550
578
|
if (state === "active" && !(await validSubject(current.subject, db))) return ok(null);
|
|
579
|
+
if (
|
|
580
|
+
state === "active" &&
|
|
581
|
+
!(await subjectAppAccess(current.subject, [current.ownerAppId, ...(current.policy.apps === "*" ? [] : current.policy.apps)], db))
|
|
582
|
+
) {
|
|
583
|
+
return fail(err.forbidden("App access was denied. Ask someone who manages access before resuming this mandate."));
|
|
584
|
+
}
|
|
551
585
|
if (current.state === state) return ok(current);
|
|
552
586
|
if (state === "paused" && current.state !== "active") return ok(null);
|
|
553
587
|
if (state === "active" && current.state !== "paused") return ok(null);
|
|
@@ -632,7 +666,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
632
666
|
expectedRevision?: number;
|
|
633
667
|
requestId?: string;
|
|
634
668
|
},
|
|
635
|
-
options: { db?: SQL } = {},
|
|
669
|
+
options: { db?: SQL; groupsAdmin?: readonly string[] } = {},
|
|
636
670
|
): Promise<Result<MandateIssueAuthority>> => {
|
|
637
671
|
if (!UUID.safeParse(input.mandateId).success) {
|
|
638
672
|
return denyMandateIssue(err.forbidden("Mandate identifier is invalid"));
|
|
@@ -695,6 +729,9 @@ export const validateMandateIssueAuthority = async (
|
|
|
695
729
|
return denyMandateIssue(err.forbidden(MANDATE_POLICY_DENIED_MESSAGE));
|
|
696
730
|
}
|
|
697
731
|
const mandate = mapMandate(row);
|
|
732
|
+
if (!(await subjectAppAccess(mandate.subject, [mandate.ownerAppId, target.data], db, options.groupsAdmin))) {
|
|
733
|
+
return denyMandateIssue(err.forbidden("App access was denied. Ask someone who manages access before running this mandate."));
|
|
734
|
+
}
|
|
698
735
|
return ok({
|
|
699
736
|
mandateId: mandate.id,
|
|
700
737
|
mandateRevision: mandate.revision,
|
|
@@ -759,10 +796,10 @@ const issueAuditParams = <T>(
|
|
|
759
796
|
export const withMandateIssueAuthority = <T>(
|
|
760
797
|
input: Parameters<typeof validateMandateIssueAuthority>[0],
|
|
761
798
|
use: (authority: MandateIssueAuthority) => Promise<T>,
|
|
762
|
-
options: { db?: SQL } = {},
|
|
799
|
+
options: { db?: SQL; groupsAdmin?: readonly string[] } = {},
|
|
763
800
|
): Promise<Result<T>> => {
|
|
764
801
|
const run = async (db: SQL): Promise<Result<T>> => {
|
|
765
|
-
const authority = await validateMandateIssueAuthority(input, { db });
|
|
802
|
+
const authority = await validateMandateIssueAuthority(input, { db, groupsAdmin: options.groupsAdmin });
|
|
766
803
|
if (!authority.ok) {
|
|
767
804
|
mandateMetrics.increment(metricForIssueDenial(authority.error));
|
|
768
805
|
return audit.recordResult(issueAuditParams(input, authority, input.expectedRevision ?? null, db));
|
|
@@ -27,9 +27,10 @@ export const resolveAllowedNetworkAddresses = async (
|
|
|
27
27
|
rawHostname: string,
|
|
28
28
|
allowlist: NetworkAllowlist,
|
|
29
29
|
lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
|
|
30
|
+
port?: number,
|
|
30
31
|
): Promise<PublicNetworkAddress[]> => {
|
|
31
32
|
const hostname = normalizeNetworkHostname(rawHostname);
|
|
32
|
-
const trustedHost = allowlist.hosts.has(hostname);
|
|
33
|
+
const trustedHost = allowlist.hosts.has(hostname) || (port !== undefined && allowlist.hostPorts?.get(hostname)?.has(port) === true);
|
|
33
34
|
if (!trustedHost && isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
|
|
34
35
|
|
|
35
36
|
const literal = parseNetworkAddress(rawHostname);
|
|
@@ -45,7 +46,7 @@ export const resolveAllowedNetworkAddresses = async (
|
|
|
45
46
|
});
|
|
46
47
|
if (
|
|
47
48
|
!trustedHost &&
|
|
48
|
-
normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address))
|
|
49
|
+
normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address, port))
|
|
49
50
|
) {
|
|
50
51
|
throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
|
|
51
52
|
}
|
|
@@ -59,15 +60,17 @@ export const isNetworkTargetBlocked = async (
|
|
|
59
60
|
{
|
|
60
61
|
resolve = resolveAllowedNetworkAddresses,
|
|
61
62
|
timeoutMs = 5000,
|
|
63
|
+
port,
|
|
62
64
|
}: {
|
|
63
65
|
resolve?: typeof resolveAllowedNetworkAddresses;
|
|
64
66
|
timeoutMs?: number;
|
|
67
|
+
port?: number;
|
|
65
68
|
} = {},
|
|
66
69
|
): Promise<boolean> => {
|
|
67
70
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
71
|
try {
|
|
69
72
|
await Promise.race([
|
|
70
|
-
resolve(hostname, allowlist),
|
|
73
|
+
resolve(hostname, allowlist, undefined, port),
|
|
71
74
|
new Promise<void>((resolve) => {
|
|
72
75
|
timer = setTimeout(resolve, timeoutMs);
|
|
73
76
|
}),
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ClientRequest, IncomingMessage } from "node:http";
|
|
2
|
+
import type { RequestOptions } from "node:https";
|
|
3
|
+
|
|
4
|
+
/** Stop waiting even when a resolver cannot cancel its underlying DNS work. */
|
|
5
|
+
export const awaitNetworkOperation = <T>(work: Promise<T>, signal: AbortSignal): Promise<T> =>
|
|
6
|
+
new Promise<T>((resolve, reject) => {
|
|
7
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
8
|
+
const abort = () => {
|
|
9
|
+
cleanup();
|
|
10
|
+
reject(signal.reason);
|
|
11
|
+
};
|
|
12
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
13
|
+
work.then(
|
|
14
|
+
(value) => {
|
|
15
|
+
cleanup();
|
|
16
|
+
if (signal.aborted) reject(signal.reason);
|
|
17
|
+
else resolve(value);
|
|
18
|
+
},
|
|
19
|
+
(error) => {
|
|
20
|
+
cleanup();
|
|
21
|
+
reject(error);
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
if (signal.aborted) abort();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** One caller-owned budget, shared by lookup, address attempts, redirects and body reads. */
|
|
28
|
+
export const withNetworkDeadline = async <T>(
|
|
29
|
+
timeoutMs: number,
|
|
30
|
+
callerSignal: AbortSignal | undefined,
|
|
31
|
+
timeoutError: Error,
|
|
32
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
33
|
+
): Promise<T> => {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const abort = () => controller.abort(callerSignal?.reason);
|
|
36
|
+
callerSignal?.addEventListener("abort", abort, { once: true });
|
|
37
|
+
if (callerSignal?.aborted) abort();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(timeoutError), timeoutMs);
|
|
39
|
+
try {
|
|
40
|
+
controller.signal.throwIfAborted();
|
|
41
|
+
return await awaitNetworkOperation(work(controller.signal), controller.signal);
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
callerSignal?.removeEventListener("abort", abort);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type NetworkRequestFactory = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
|
49
|
+
|
|
50
|
+
/** Lifecycle for already validated/pinned request options. Does not resolve or authorize targets. */
|
|
51
|
+
export const readNetworkResponse = <T>(input: {
|
|
52
|
+
request: NetworkRequestFactory;
|
|
53
|
+
options: RequestOptions;
|
|
54
|
+
signal: AbortSignal;
|
|
55
|
+
body?: Uint8Array;
|
|
56
|
+
read: (response: IncomingMessage, resolve: (value: T) => void, reject: (error: Error) => void) => void;
|
|
57
|
+
}): Promise<T> =>
|
|
58
|
+
new Promise<T>((resolve, reject) => {
|
|
59
|
+
input.signal.throwIfAborted();
|
|
60
|
+
let settled = false;
|
|
61
|
+
let outgoing: ClientRequest | undefined;
|
|
62
|
+
let incoming: IncomingMessage | undefined;
|
|
63
|
+
const finish = (result: { value: T } | { error: Error }) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
input.signal.removeEventListener("abort", abort);
|
|
67
|
+
// Discarded bodies must never be drained after the operation has settled.
|
|
68
|
+
incoming?.destroy();
|
|
69
|
+
outgoing?.destroy();
|
|
70
|
+
if ("error" in result) reject(result.error);
|
|
71
|
+
else resolve(result.value);
|
|
72
|
+
};
|
|
73
|
+
const fail = (error: Error) => finish({ error });
|
|
74
|
+
const abort = () => fail(input.signal.reason);
|
|
75
|
+
input.signal.addEventListener("abort", abort, { once: true });
|
|
76
|
+
try {
|
|
77
|
+
outgoing = input.request(input.options, (response) => {
|
|
78
|
+
incoming = response;
|
|
79
|
+
response.on("error", fail);
|
|
80
|
+
response.once("aborted", () => fail(new Error("Network response was aborted")));
|
|
81
|
+
if (settled) {
|
|
82
|
+
response.destroy();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
input.read(response, (value) => finish({ value }), fail);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
outgoing.on("error", fail);
|
|
92
|
+
if (settled || input.signal.aborted) {
|
|
93
|
+
if (input.signal.aborted) abort();
|
|
94
|
+
outgoing.destroy();
|
|
95
|
+
} else outgoing.end(input.body);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -9,8 +9,9 @@ const RETRY_MS = 5_000;
|
|
|
9
9
|
const log = logger("notifications:catalog");
|
|
10
10
|
|
|
11
11
|
const isSchemaUnavailable = (error: unknown): boolean => {
|
|
12
|
-
if (!error || typeof error !== "object"
|
|
13
|
-
|
|
12
|
+
if (!error || typeof error !== "object") return false;
|
|
13
|
+
const code = ("errno" in error ? error.errno : undefined) ?? ("code" in error ? error.code : undefined);
|
|
14
|
+
return code === "42P01" || code === "42703" || code === "3F000";
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
const upsertDefinition = async (definition: AnyBoundNotificationDefinition, db: typeof sql = sql): Promise<void> => {
|
|
@@ -95,6 +95,7 @@ const recover = (): Promise<void> => {
|
|
|
95
95
|
const lifecycle = createRuntimeLifecycle({
|
|
96
96
|
start: async () => {
|
|
97
97
|
recoveryTasks.open();
|
|
98
|
+
// Local delivery orchestration; email enters the shared SMTP budget.
|
|
98
99
|
const concurrency = Math.min(Math.max(Math.floor(runtimeOptions.concurrency ?? 4), 1), 16);
|
|
99
100
|
const recoveryIntervalMs = Math.max(runtimeOptions.recoveryIntervalMs ?? 30_000, 5_000);
|
|
100
101
|
worker = await deliveryJob().process(
|
|
@@ -4,6 +4,7 @@ import webpush, { type RequestDetails as WebPushRequestDetails, type RequestOpti
|
|
|
4
4
|
import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
|
|
5
5
|
import { normalizeNetworkHostname } from "../../shared/network-address";
|
|
6
6
|
import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../network-security";
|
|
7
|
+
import { awaitNetworkOperation, readNetworkResponse, withNetworkDeadline } from "../network-transport";
|
|
7
8
|
|
|
8
9
|
const RESPONSE_LIMIT_BYTES = 64 * 1_024;
|
|
9
10
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -19,6 +20,7 @@ type RequestDetailsFactory = (
|
|
|
19
20
|
type WebPushTransportDeps = {
|
|
20
21
|
generateRequestDetails?: RequestDetailsFactory;
|
|
21
22
|
request?: RequestFactory;
|
|
23
|
+
signal?: AbortSignal;
|
|
22
24
|
resolve?: (hostname: string) => Promise<PublicNetworkAddress[]>;
|
|
23
25
|
};
|
|
24
26
|
|
|
@@ -31,7 +33,9 @@ export const buildPinnedWebPushRequests = async (
|
|
|
31
33
|
const validatedSubscription = BrowserPushSubscriptionSchema.parse(subscription);
|
|
32
34
|
const endpoint = new URL(validatedSubscription.endpoint);
|
|
33
35
|
const hostname = normalizeNetworkHostname(endpoint.hostname);
|
|
34
|
-
|
|
36
|
+
deps.signal?.throwIfAborted();
|
|
37
|
+
const resolving = (deps.resolve ?? resolvePublicNetworkAddresses)(hostname);
|
|
38
|
+
const resolvedAddresses = deps.signal ? await awaitNetworkOperation(resolving, deps.signal) : await resolving;
|
|
35
39
|
const uniqueAddresses = resolvedAddresses.filter(
|
|
36
40
|
(target, index) => resolvedAddresses.findIndex((candidate) => candidate.address === target.address) === index,
|
|
37
41
|
);
|
|
@@ -61,32 +65,30 @@ export const buildPinnedWebPushRequests = async (
|
|
|
61
65
|
headers: { ...details.headers, host: endpoint.host },
|
|
62
66
|
servername: hostname,
|
|
63
67
|
rejectUnauthorized: true,
|
|
64
|
-
timeout: REQUEST_TIMEOUT_MS,
|
|
65
68
|
})),
|
|
66
69
|
};
|
|
67
70
|
};
|
|
68
71
|
|
|
69
|
-
const sendPreparedWebPushRequest =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const outgoing = request(options, (response) => {
|
|
72
|
+
const sendPreparedWebPushRequest = (
|
|
73
|
+
request: RequestFactory,
|
|
74
|
+
options: HttpsRequestOptions,
|
|
75
|
+
body: Buffer | null,
|
|
76
|
+
signal: AbortSignal,
|
|
77
|
+
): Promise<void> => {
|
|
78
|
+
let responseReceived = false;
|
|
79
|
+
return readNetworkResponse<void>({
|
|
80
|
+
request,
|
|
81
|
+
options,
|
|
82
|
+
body: body ?? undefined,
|
|
83
|
+
signal,
|
|
84
|
+
read: (response, resolve, reject) => {
|
|
83
85
|
responseReceived = true;
|
|
86
|
+
const finish = (result: { error?: Error }) => (result.error ? reject(result.error) : resolve());
|
|
84
87
|
let responseBody = "";
|
|
85
88
|
let bytes = 0;
|
|
86
89
|
response.on("data", (chunk: Buffer | string) => {
|
|
87
90
|
bytes += Buffer.byteLength(chunk);
|
|
88
91
|
if (bytes > RESPONSE_LIMIT_BYTES) {
|
|
89
|
-
response.destroy();
|
|
90
92
|
finish({ error: new Error("Web Push provider response is too large") });
|
|
91
93
|
return;
|
|
92
94
|
}
|
|
@@ -105,31 +107,36 @@ const sendPreparedWebPushRequest = async (request: RequestFactory, options: Http
|
|
|
105
107
|
}),
|
|
106
108
|
});
|
|
107
109
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
outgoing.on("error", (error) => finish({ error }));
|
|
112
|
-
if (body) outgoing.write(body);
|
|
113
|
-
outgoing.end();
|
|
110
|
+
},
|
|
111
|
+
}).catch((error) => {
|
|
112
|
+
throw responseReceived ? Object.assign(error, { providerResponse: true }) : error;
|
|
114
113
|
});
|
|
114
|
+
};
|
|
115
115
|
|
|
116
116
|
export const sendPinnedWebPush = async (
|
|
117
117
|
subscription: BrowserPushSubscription,
|
|
118
118
|
payload: string,
|
|
119
119
|
options: WebPushRequestOptions,
|
|
120
120
|
deps: WebPushTransportDeps = {},
|
|
121
|
-
): Promise<void> =>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
121
|
+
): Promise<void> =>
|
|
122
|
+
withNetworkDeadline(
|
|
123
|
+
options.timeout ?? REQUEST_TIMEOUT_MS,
|
|
124
|
+
deps.signal,
|
|
125
|
+
new Error("Web Push provider request timed out"),
|
|
126
|
+
async (signal) => {
|
|
127
|
+
const request = deps.request ?? httpsRequest;
|
|
128
|
+
const prepared = await buildPinnedWebPushRequests(subscription, payload, options, { ...deps, signal });
|
|
129
|
+
let lastError: unknown;
|
|
130
|
+
for (const attempt of prepared.attempts) {
|
|
131
|
+
try {
|
|
132
|
+
await sendPreparedWebPushRequest(request, attempt, prepared.body, signal);
|
|
133
|
+
return;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
signal.throwIfAborted();
|
|
136
|
+
if (error && typeof error === "object" && "providerResponse" in error) throw error;
|
|
137
|
+
lastError = error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw lastError instanceof Error ? lastError : new Error("Web Push provider request failed");
|
|
141
|
+
},
|
|
142
|
+
);
|
|
@@ -4,6 +4,7 @@ import { Readable } from "node:stream";
|
|
|
4
4
|
import { sql } from "bun";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { listApps } from "../../_internal/registry";
|
|
7
|
+
import { DownstreamBusyError, downstreamBudget } from "../downstream-budget";
|
|
7
8
|
import { parsePgJsonValue } from "../postgres";
|
|
8
9
|
import { get } from "../settings";
|
|
9
10
|
import { deleteMailObjects, verifyMailAttachment } from "./attachments";
|
|
@@ -97,6 +98,7 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
|
|
|
97
98
|
if (signal?.aborted) abort();
|
|
98
99
|
const timer = setTimeout(abort, 60_000);
|
|
99
100
|
const streams: Readable[] = [];
|
|
101
|
+
let releaseSmtp: (() => void) | undefined;
|
|
100
102
|
try {
|
|
101
103
|
for (const [index, ref] of refs.entries()) await verifyMailAttachment(ref, metadata[index]!, attempt.signal);
|
|
102
104
|
const attachments = [];
|
|
@@ -131,6 +133,8 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
|
|
|
131
133
|
}
|
|
132
134
|
// Load the checked profile by ID: a profile deleted (or recreated under its key) since the check is profile_removed.
|
|
133
135
|
credentials = await resolveMailCredentials({ id: row.profile_id });
|
|
136
|
+
releaseSmtp = await downstreamBudget.acquire({ downstream: "smtp", timeoutMs: 60_000, signal: attempt.signal });
|
|
137
|
+
attempt.signal.throwIfAborted();
|
|
134
138
|
const socket = new Socket();
|
|
135
139
|
// DNS can complete after cancellation; tear down a late connection too.
|
|
136
140
|
socket.once("connect", () => {
|
|
@@ -182,6 +186,7 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
|
|
|
182
186
|
signal?.removeEventListener("abort", abort);
|
|
183
187
|
attempt.abort();
|
|
184
188
|
for (const stream of streams) stream.destroy();
|
|
189
|
+
releaseSmtp?.();
|
|
185
190
|
}
|
|
186
191
|
} catch (error) {
|
|
187
192
|
if (error instanceof OutgoingMailError && ["profile_unknown", "profile_removed"].includes(error.code)) {
|
|
@@ -196,7 +201,7 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
|
|
|
196
201
|
]
|
|
197
202
|
: [];
|
|
198
203
|
const message =
|
|
199
|
-
credentials || error instanceof OutgoingMailError
|
|
204
|
+
credentials || error instanceof OutgoingMailError || error instanceof DownstreamBusyError
|
|
200
205
|
? smtpFailureMessage(error, secrets)
|
|
201
206
|
: "Outgoing mail delivery is temporarily unavailable.";
|
|
202
207
|
const lost = error instanceof OutgoingMailError && error.code === "attachment_lost";
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { getProcessApplicationId, getProcessPlatformPermissions } from "../../_internal/process-identity";
|
|
3
4
|
import {
|
|
4
5
|
MailBatchSchema,
|
|
6
|
+
type MailContent,
|
|
7
|
+
type MailContentContext,
|
|
5
8
|
type MailFilter,
|
|
6
9
|
MailFilterSchema,
|
|
7
10
|
type MailMessage,
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
type MailRecord,
|
|
14
17
|
type MailServiceError,
|
|
15
18
|
} from "../../contracts/outgoing-mail";
|
|
19
|
+
import { ServiceAccountSchema, UserSchema } from "../../contracts/shared";
|
|
16
20
|
import { cancelMailStreams } from "./attachments";
|
|
17
21
|
import { enqueueMail } from "./enqueue";
|
|
18
22
|
import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./messages";
|
|
@@ -36,13 +40,63 @@ const failure = (error: unknown): MailServiceError => {
|
|
|
36
40
|
return {
|
|
37
41
|
code: error.code,
|
|
38
42
|
message: error.message,
|
|
39
|
-
status: error.code === "mail_unavailable" || error.status === 502 ? 500 : error.status,
|
|
43
|
+
status: error.code === "mail_unavailable" || error.status === 502 || error.status === 503 ? 500 : error.status,
|
|
40
44
|
...("limit" in error && typeof error.limit === "number" ? { limit: error.limit } : {}),
|
|
41
45
|
...("used" in error && typeof error.used === "number" ? { used: error.used } : {}),
|
|
42
46
|
...("requested" in error && typeof error.requested === "number" ? { requested: error.requested } : {}),
|
|
43
47
|
};
|
|
44
48
|
};
|
|
49
|
+
// Runtime validation protects the transport boundary; authentication is owned by the caller's
|
|
50
|
+
// trusted server context. Unknown actor fields never reach the read audit.
|
|
51
|
+
const delegation = z.object({
|
|
52
|
+
kind: z.literal("invocation"),
|
|
53
|
+
callingAppId: z.string().min(1),
|
|
54
|
+
credentialKind: z.enum(["session", "oauth", "api_key", "mandate"]),
|
|
55
|
+
invocationId: z.string().min(1),
|
|
56
|
+
requestId: z.string().nullable().optional(),
|
|
57
|
+
mandateId: z.string().optional(),
|
|
58
|
+
mandateRevision: z.int().positive().optional(),
|
|
59
|
+
workloadType: z.string().optional(),
|
|
60
|
+
workloadId: z.string().optional(),
|
|
61
|
+
});
|
|
62
|
+
const contentContext = z.object({
|
|
63
|
+
actor: z.discriminatedUnion("kind", [
|
|
64
|
+
z.object({
|
|
65
|
+
kind: z.literal("user"),
|
|
66
|
+
user: UserSchema.refine((user) => z.uuid().safeParse(user.id).success && !!user.uid),
|
|
67
|
+
delegation: delegation.optional(),
|
|
68
|
+
}),
|
|
69
|
+
z.object({
|
|
70
|
+
kind: z.literal("service_account"),
|
|
71
|
+
serviceAccount: ServiceAccountSchema.refine((account) => account.status === "active"),
|
|
72
|
+
delegatedUser: UserSchema.nullable(),
|
|
73
|
+
scopes: z.array(z.string()),
|
|
74
|
+
delegation: delegation.optional(),
|
|
75
|
+
}),
|
|
76
|
+
]),
|
|
77
|
+
requestId: z.string().optional(),
|
|
78
|
+
});
|
|
45
79
|
export const mail = {
|
|
80
|
+
async content(id: string, context: MailContentContext): Promise<Result<MailContent, MailServiceError>> {
|
|
81
|
+
const caller = identity(true);
|
|
82
|
+
if (!caller.ok) return caller;
|
|
83
|
+
const parsedId = z.uuid().safeParse(id);
|
|
84
|
+
const parsedContext = contentContext.safeParse(context);
|
|
85
|
+
if (!parsedId.success || !parsedContext.success)
|
|
86
|
+
return fail({ code: "bad_input", message: "Provide a valid mail ID and authenticated actor context.", status: 400 });
|
|
87
|
+
try {
|
|
88
|
+
return ok(
|
|
89
|
+
await outgoingMailMessages.appContent(
|
|
90
|
+
parsedId.data,
|
|
91
|
+
caller.data,
|
|
92
|
+
getProcessPlatformPermissions().includes("mail:read"),
|
|
93
|
+
parsedContext.data,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return fail(failure(error));
|
|
98
|
+
}
|
|
99
|
+
},
|
|
46
100
|
async readableApps(): Promise<Result<string[], MailServiceError>> {
|
|
47
101
|
const caller = identity(true);
|
|
48
102
|
if (!caller.ok) return caller;
|
|
@@ -4,6 +4,8 @@ import { z } from "zod";
|
|
|
4
4
|
import {
|
|
5
5
|
type AdminMailFilter,
|
|
6
6
|
type AdminMailRecord,
|
|
7
|
+
type MailContent,
|
|
8
|
+
type MailContentContext,
|
|
7
9
|
type MailFilter,
|
|
8
10
|
type MailMessage,
|
|
9
11
|
type MailPage,
|
|
@@ -17,7 +19,7 @@ import { audit } from "../audit";
|
|
|
17
19
|
import { escapeLikePattern, parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
18
20
|
import type { UploadedAttachments } from "./attachments";
|
|
19
21
|
import { mailActorSnapshot, mailMessageId } from "./message";
|
|
20
|
-
import { type MailAuditContext, OutgoingMailError } from "./store";
|
|
22
|
+
import { type MailAuditContext, mailAppIdSha256, OutgoingMailError } from "./store";
|
|
21
23
|
|
|
22
24
|
export type MessageRow = {
|
|
23
25
|
id: string;
|
|
@@ -109,7 +111,7 @@ export const messageRecord = (row: LogRow): MailRecord => ({
|
|
|
109
111
|
...(row.error_message ? { error: row.error_message } : {}),
|
|
110
112
|
failures: MailRecordSchema.shape.failures.parse(parsePgJsonValue(row.failures)),
|
|
111
113
|
attempts: row.attempt_count,
|
|
112
|
-
...(row.actor_id ? { actor: { id: row.actor_id, name: row.actor_name ??
|
|
114
|
+
...(row.actor_id ? { actor: { id: row.actor_id, name: row.actor_name ?? "" } } : {}),
|
|
113
115
|
createdAt: date(row.created_at),
|
|
114
116
|
...(row.sent_at ? { sentAt: date(row.sent_at) } : {}),
|
|
115
117
|
...(row.content_purged_at ? { contentPurgedAt: date(row.content_purged_at) } : {}),
|
|
@@ -433,7 +435,84 @@ const content = async (id: string, context: MailAuditContext) =>
|
|
|
433
435
|
headers: z.record(z.string(), z.string()).nullable().parse(parsePgJsonValue(row.headers)),
|
|
434
436
|
};
|
|
435
437
|
});
|
|
436
|
-
|
|
438
|
+
/** One budget for database work, from transaction start through audit. Pool acquisition and commit are outside it. */
|
|
439
|
+
const CONTENT_BUDGET_MS = 5_000;
|
|
440
|
+
const appContent = async (id: string, readingAppId: string, canReadOtherApps: boolean, context: MailContentContext): Promise<MailContent> =>
|
|
441
|
+
sql.begin(async (tx) => {
|
|
442
|
+
const deadline = Date.now() + CONTENT_BUDGET_MS;
|
|
443
|
+
const limit = async () => {
|
|
444
|
+
const remaining = Math.floor(deadline - Date.now());
|
|
445
|
+
if (remaining < 1) throw new Error("Outgoing mail content DB budget exhausted.");
|
|
446
|
+
await tx`SELECT set_config('statement_timeout', ${`${remaining}ms`}, true)`;
|
|
447
|
+
};
|
|
448
|
+
await limit();
|
|
449
|
+
// Writers take this same key exclusively. Check grants in a fresh statement after acquiring it.
|
|
450
|
+
await tx`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
|
|
451
|
+
await limit();
|
|
452
|
+
const [row] = await tx<{ app_id: string; content_purged_at: Date | string | null; level: string | null }[]>`
|
|
453
|
+
SELECT m.app_id, m.content_purged_at, g.level FROM outgoing_mail.messages m
|
|
454
|
+
LEFT JOIN outgoing_mail.app_log_access g ON g.app_id = ${readingAppId} AND g.source_app_id = m.app_id
|
|
455
|
+
WHERE m.id = ${id}::uuid AND (m.app_id = ${readingAppId} OR
|
|
456
|
+
(${canReadOtherApps} AND ${readingAppId} <> 'core' AND m.app_id <> 'core' AND g.app_id IS NOT NULL))
|
|
457
|
+
FOR SHARE OF m`;
|
|
458
|
+
if (!row) throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
|
|
459
|
+
if (row.app_id !== readingAppId && row.level !== "content")
|
|
460
|
+
throw new OutgoingMailError("app_not_allowed", "This application may not read this mail content.", 403);
|
|
461
|
+
let result: MailContent;
|
|
462
|
+
if (row.content_purged_at !== null) result = { purged: true, contentPurgedAt: date(row.content_purged_at) };
|
|
463
|
+
else {
|
|
464
|
+
await limit();
|
|
465
|
+
// No body fetch before authorization. Never fetch HTML, headers, object refs or attachment digests.
|
|
466
|
+
const [body] = await tx<{ text_body: string | null; attachment_names: unknown }[]>`
|
|
467
|
+
SELECT text_body, ARRAY(SELECT item->>'filename' FROM jsonb_array_elements(attachments) AS item) AS attachment_names
|
|
468
|
+
FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
|
|
469
|
+
if (!body) throw new Error("Locked outgoing mail disappeared.");
|
|
470
|
+
result = { purged: false, text: body.text_body, attachmentNames: z.array(z.string()).parse(body.attachment_names) };
|
|
471
|
+
}
|
|
472
|
+
const actor = context.actor;
|
|
473
|
+
const user = actor.kind === "user" ? actor.user : undefined;
|
|
474
|
+
const provenance = actor.delegation;
|
|
475
|
+
await limit();
|
|
476
|
+
await audit.record(
|
|
477
|
+
{
|
|
478
|
+
action: "outgoing_mail.message.read",
|
|
479
|
+
outcome: "allowed",
|
|
480
|
+
target: { type: "outgoing_mail_message", id },
|
|
481
|
+
actor: user ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles } : undefined,
|
|
482
|
+
requestId: context.requestId ?? provenance?.requestId,
|
|
483
|
+
metadata: {
|
|
484
|
+
appId: row.app_id,
|
|
485
|
+
appIdSha256: mailAppIdSha256(row.app_id),
|
|
486
|
+
readingAppId,
|
|
487
|
+
readingAppIdSha256: mailAppIdSha256(readingAppId),
|
|
488
|
+
actorKind: actor.kind,
|
|
489
|
+
...(actor.kind === "service_account"
|
|
490
|
+
? {
|
|
491
|
+
serviceAccountId: actor.serviceAccount.id,
|
|
492
|
+
...(actor.delegatedUser ? { delegatedUserId: actor.delegatedUser.id } : {}),
|
|
493
|
+
}
|
|
494
|
+
: {}),
|
|
495
|
+
...(provenance
|
|
496
|
+
? {
|
|
497
|
+
delegation: {
|
|
498
|
+
kind: provenance.kind,
|
|
499
|
+
callingAppId: provenance.callingAppId,
|
|
500
|
+
credentialKind: provenance.credentialKind,
|
|
501
|
+
invocationId: provenance.invocationId,
|
|
502
|
+
mandateId: provenance.mandateId,
|
|
503
|
+
mandateRevision: provenance.mandateRevision,
|
|
504
|
+
workloadType: provenance.workloadType,
|
|
505
|
+
workloadId: provenance.workloadId,
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
: {}),
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
tx,
|
|
512
|
+
);
|
|
513
|
+
return result;
|
|
514
|
+
});
|
|
515
|
+
export const outgoingMailMessages = { read, metadata, known, accept, list, content, appContent };
|
|
437
516
|
|
|
438
517
|
/** Serialize updates to preserve failures from overlapping reports and replicas. */
|
|
439
518
|
export const applyOutgoingMailBounce = async (
|
|
@@ -5,7 +5,7 @@ import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../ne
|
|
|
5
5
|
export const outgoingMailAllowlist = () => parseNetworkAllowlistEnv("OUTGOING_MAIL_ALLOWED_TARGETS", env.OUTGOING_MAIL_ALLOWED_TARGETS);
|
|
6
6
|
|
|
7
7
|
/** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
|
|
8
|
-
export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses) => {
|
|
8
|
+
export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses, port?: number) => {
|
|
9
9
|
const hostname = normalizeNetworkHostname(host);
|
|
10
10
|
const deadline = new AbortController();
|
|
11
11
|
const timer = setTimeout(() => deadline.abort(new Error("Outgoing mail DNS lookup timed out.")), 5000);
|
|
@@ -14,7 +14,7 @@ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolv
|
|
|
14
14
|
try {
|
|
15
15
|
cancellation.throwIfAborted();
|
|
16
16
|
const addresses = await Promise.race([
|
|
17
|
-
resolve(hostname, outgoingMailAllowlist()),
|
|
17
|
+
resolve(hostname, outgoingMailAllowlist(), undefined, port),
|
|
18
18
|
new Promise<never>((_, reject) => {
|
|
19
19
|
abort = () => reject(cancellation.reason);
|
|
20
20
|
cancellation.addEventListener("abort", abort, { once: true });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { MailRetention } from "../../contracts/outgoing-mail";
|
|
3
3
|
import { toPgUuidArray } from "../postgres";
|
|
4
|
-
import { get } from "../settings";
|
|
4
|
+
import { get, read } from "../settings";
|
|
5
5
|
import { deleteMailObjects } from "./attachments";
|
|
6
6
|
import { messageAttachmentRefs } from "./messages";
|
|
7
7
|
|
|
@@ -38,6 +38,18 @@ export const readMailRetention = async (): Promise<MailRetention> => ({
|
|
|
38
38
|
contentDays: await get<number>(MAIL_RETENTION_KEYS[0]),
|
|
39
39
|
recordDays: await get<number>(MAIL_RETENTION_KEYS[1]),
|
|
40
40
|
});
|
|
41
|
+
export const readMailRetentionSettings = async (db?: typeof sql) => {
|
|
42
|
+
const content = await read(MAIL_RETENTION_KEYS[0], db);
|
|
43
|
+
const record = await read(MAIL_RETENTION_KEYS[1], db);
|
|
44
|
+
return {
|
|
45
|
+
contentDays: Number(content.value),
|
|
46
|
+
recordDays: Number(record.value),
|
|
47
|
+
unreadable: {
|
|
48
|
+
[MAIL_RETENTION_KEYS[0]]: content.stored === "unreadable",
|
|
49
|
+
[MAIL_RETENTION_KEYS[1]]: record.stored === "unreadable",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
41
53
|
export const retainOutgoingMail = async (signal?: AbortSignal): Promise<void> => {
|
|
42
54
|
const { contentDays, recordDays } = await readMailRetention();
|
|
43
55
|
const deadline = Date.now() + 5 * 60_000;
|