@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
|
@@ -42,7 +42,7 @@ export const legalConsent = {
|
|
|
42
42
|
groupsAdmin,
|
|
43
43
|
allowPendingLegalConsent: true,
|
|
44
44
|
});
|
|
45
|
-
if (!user || isAccountExpired(user.accountExpires)
|
|
45
|
+
if (!user || isAccountExpired(user.accountExpires)) return null;
|
|
46
46
|
const [row] = await sql<{ pending: boolean }[]>`
|
|
47
47
|
SELECT legal_pending AND NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${user.id}::uuid) AS pending
|
|
48
48
|
FROM auth.session_families WHERE sid = ${claims.sid}::uuid
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readWebVitals } from "./web-vitals";
|
|
1
2
|
import { sql } from "bun";
|
|
2
3
|
import type { PaginationParams } from "../../contracts/shared";
|
|
3
4
|
import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
|
|
@@ -129,7 +130,7 @@ function write(params: WriteParams): void {
|
|
|
129
130
|
${params.level},
|
|
130
131
|
${params.source},
|
|
131
132
|
${params.message},
|
|
132
|
-
${safeMetadata ? JSON.stringify(safeMetadata) : null}::jsonb
|
|
133
|
+
(${safeMetadata ? JSON.stringify(safeMetadata) : null}::text)::jsonb
|
|
133
134
|
)
|
|
134
135
|
`.catch((err: Error) => console.error("[logging] DB write failed:", err.message));
|
|
135
136
|
}
|
|
@@ -194,7 +195,7 @@ const list = async (
|
|
|
194
195
|
WHERE source = ${filterSource}
|
|
195
196
|
AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
196
197
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
197
|
-
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '
|
|
198
|
+
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
198
199
|
`;
|
|
199
200
|
|
|
200
201
|
dataRows = await sql`
|
|
@@ -203,7 +204,7 @@ const list = async (
|
|
|
203
204
|
WHERE source = ${filterSource}
|
|
204
205
|
AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
205
206
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
206
|
-
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '
|
|
207
|
+
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
207
208
|
ORDER BY created_at DESC
|
|
208
209
|
LIMIT ${perPage} OFFSET ${offset}
|
|
209
210
|
`;
|
|
@@ -214,7 +215,7 @@ const list = async (
|
|
|
214
215
|
WHERE source = ANY(${filterSourcesLiteral}::text[])
|
|
215
216
|
AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
216
217
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
217
|
-
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '
|
|
218
|
+
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
218
219
|
`;
|
|
219
220
|
|
|
220
221
|
dataRows = await sql`
|
|
@@ -223,7 +224,7 @@ const list = async (
|
|
|
223
224
|
WHERE source = ANY(${filterSourcesLiteral}::text[])
|
|
224
225
|
AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
225
226
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
226
|
-
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '
|
|
227
|
+
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
227
228
|
ORDER BY created_at DESC
|
|
228
229
|
LIMIT ${perPage} OFFSET ${offset}
|
|
229
230
|
`;
|
|
@@ -233,7 +234,7 @@ const list = async (
|
|
|
233
234
|
FROM logging.entries
|
|
234
235
|
WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
235
236
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
236
|
-
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '
|
|
237
|
+
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
237
238
|
`;
|
|
238
239
|
|
|
239
240
|
dataRows = await sql`
|
|
@@ -241,7 +242,7 @@ const list = async (
|
|
|
241
242
|
FROM logging.entries
|
|
242
243
|
WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
243
244
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
244
|
-
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '
|
|
245
|
+
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
245
246
|
ORDER BY created_at DESC
|
|
246
247
|
LIMIT ${perPage} OFFSET ${offset}
|
|
247
248
|
`;
|
|
@@ -321,8 +322,8 @@ const timeseries = async (
|
|
|
321
322
|
AND (${level}::text IS NULL OR entries.level = ${level})
|
|
322
323
|
AND (
|
|
323
324
|
${searchPattern}::text IS NULL
|
|
324
|
-
OR entries.message ILIKE ${searchPattern} ESCAPE '
|
|
325
|
-
OR entries.metadata::text ILIKE ${searchPattern} ESCAPE '
|
|
325
|
+
OR entries.message ILIKE ${searchPattern} ESCAPE '\\'
|
|
326
|
+
OR entries.metadata::text ILIKE ${searchPattern} ESCAPE '\\'
|
|
326
327
|
)
|
|
327
328
|
GROUP BY at, entries.level
|
|
328
329
|
)
|
|
@@ -369,8 +370,8 @@ const statsBy = async (
|
|
|
369
370
|
AND (${level}::text IS NULL OR level = ${level})
|
|
370
371
|
AND (
|
|
371
372
|
${searchPattern}::text IS NULL
|
|
372
|
-
OR message ILIKE ${searchPattern} ESCAPE '
|
|
373
|
-
OR metadata::text ILIKE ${searchPattern} ESCAPE '
|
|
373
|
+
OR message ILIKE ${searchPattern} ESCAPE '\\'
|
|
374
|
+
OR metadata::text ILIKE ${searchPattern} ESCAPE '\\'
|
|
374
375
|
)
|
|
375
376
|
GROUP BY level
|
|
376
377
|
ORDER BY count DESC, key ASC
|
|
@@ -387,8 +388,8 @@ const statsBy = async (
|
|
|
387
388
|
AND (${level}::text IS NULL OR level = ${level})
|
|
388
389
|
AND (
|
|
389
390
|
${searchPattern}::text IS NULL
|
|
390
|
-
OR message ILIKE ${searchPattern} ESCAPE '
|
|
391
|
-
OR metadata::text ILIKE ${searchPattern} ESCAPE '
|
|
391
|
+
OR message ILIKE ${searchPattern} ESCAPE '\\'
|
|
392
|
+
OR metadata::text ILIKE ${searchPattern} ESCAPE '\\'
|
|
392
393
|
)
|
|
393
394
|
GROUP BY source
|
|
394
395
|
ORDER BY count DESC, key ASC
|
|
@@ -466,6 +467,7 @@ const summary = async (): Promise<LogSummary> => {
|
|
|
466
467
|
|
|
467
468
|
/** Admin service object for querying/managing logs. */
|
|
468
469
|
export const logging = {
|
|
470
|
+
webVitals: readWebVitals,
|
|
469
471
|
list,
|
|
470
472
|
getById,
|
|
471
473
|
getSources,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getProcessApplicationId } from "../../_internal/process-identity";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import type { SyncEvent } from "@k2b/sync";
|
|
3
4
|
import { sql } from "bun";
|
|
@@ -1034,7 +1035,7 @@ const detailAttributes = (detail: Record<string, unknown>): TraceAttributes => {
|
|
|
1034
1035
|
};
|
|
1035
1036
|
|
|
1036
1037
|
/** The app hosting this process; Sync events carry no app name. */
|
|
1037
|
-
const processAppId =
|
|
1038
|
+
const processAppId = getProcessApplicationId;
|
|
1038
1039
|
|
|
1039
1040
|
const traceSyncEvent = async (event: SyncEvent, application?: string): Promise<void> => {
|
|
1040
1041
|
const resource = event.resource;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const ranges = {
|
|
5
|
+
"1h": { hours: 1, bucket: 60 },
|
|
6
|
+
"6h": { hours: 6, bucket: 300 },
|
|
7
|
+
"24h": { hours: 24, bucket: 900 },
|
|
8
|
+
"7d": { hours: 168, bucket: 3600 },
|
|
9
|
+
"30d": { hours: 720, bucket: 21600 },
|
|
10
|
+
} as const;
|
|
11
|
+
const querySchema = z.object({
|
|
12
|
+
range: z.enum(["1h", "6h", "24h", "7d", "30d"]).default("24h"),
|
|
13
|
+
appId: z.string().max(64).default(""),
|
|
14
|
+
route: z.string().max(200).default(""),
|
|
15
|
+
page: z.number().int().min(1).max(100000).default(1),
|
|
16
|
+
});
|
|
17
|
+
export type WebVitalsQuery = z.input<typeof querySchema>;
|
|
18
|
+
export type WebVitalName = "LCP" | "INP" | "CLS";
|
|
19
|
+
export type WebVitalMetric = { name: WebVitalName; count: number; p75: number };
|
|
20
|
+
export type WebVitalsOverview = {
|
|
21
|
+
summary: WebVitalMetric[];
|
|
22
|
+
series: (WebVitalMetric & { bucket: number })[];
|
|
23
|
+
routes: (WebVitalMetric & { appId: string; route: string })[];
|
|
24
|
+
totalRoutes: number;
|
|
25
|
+
page: number;
|
|
26
|
+
perPage: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Admin diagnostics. Deduplicate the last valid report inside [since, until).
|
|
30
|
+
* Attribute it to its last receipt time; this is not navigation-start time.
|
|
31
|
+
* Only aggregates leave Postgres. Retention applies before this read.
|
|
32
|
+
*/
|
|
33
|
+
export async function readWebVitals(input: WebVitalsQuery, until = new Date()): Promise<WebVitalsOverview> {
|
|
34
|
+
const query = querySchema.parse(input);
|
|
35
|
+
const { hours, bucket } = ranges[query.range];
|
|
36
|
+
const since = new Date(until.getTime() - hours * 3600000);
|
|
37
|
+
const perPage = 50;
|
|
38
|
+
const [result] = await sql<Omit<WebVitalsOverview, "page" | "perPage">[]>`
|
|
39
|
+
WITH stored AS MATERIALIZED (
|
|
40
|
+
SELECT id, created_at,
|
|
41
|
+
logging.object_metadata(metadata) AS metadata
|
|
42
|
+
FROM logging.entries
|
|
43
|
+
WHERE source = 'web-vitals' AND level = 'info' AND created_at >= ${since} AND created_at < ${until}
|
|
44
|
+
), candidates AS MATERIALIZED (
|
|
45
|
+
SELECT id, created_at, metadata->>'appId' AS app, metadata->>'routeTemplate' AS route,
|
|
46
|
+
metadata->>'name' AS name, metadata->>'id' AS metric_id,
|
|
47
|
+
CASE WHEN jsonb_typeof(metadata->'value') = 'number' THEN (metadata->>'value')::numeric END AS value
|
|
48
|
+
FROM stored
|
|
49
|
+
WHERE jsonb_typeof(metadata->'appId') = 'string' AND metadata->>'appId' ~ '^[a-z0-9-]{1,64}$'
|
|
50
|
+
AND jsonb_typeof(metadata->'routeTemplate') = 'string'
|
|
51
|
+
AND length(metadata->>'routeTemplate') BETWEEN 1 AND 200 AND metadata->>'routeTemplate' ~ '^/[a-zA-Z0-9_/:.*-]*$'
|
|
52
|
+
AND jsonb_typeof(metadata->'id') = 'string' AND length(metadata->>'id') BETWEEN 1 AND 100
|
|
53
|
+
AND metadata->>'name' IN ('LCP','INP','CLS')
|
|
54
|
+
AND (${query.appId} = '' OR metadata->>'appId' = ${query.appId})
|
|
55
|
+
AND (${query.route} = '' OR metadata->>'routeTemplate' = ${query.route})
|
|
56
|
+
), latest AS MATERIALIZED (
|
|
57
|
+
SELECT DISTINCT ON (app, route, name, metric_id) app, route, name, value::double precision AS value, created_at
|
|
58
|
+
FROM candidates WHERE value >= 0 AND value <= 1.7976931348623157e308::numeric
|
|
59
|
+
ORDER BY app, route, name, metric_id, created_at DESC, id DESC
|
|
60
|
+
), route_stats AS MATERIALIZED (
|
|
61
|
+
SELECT app, route, name, count(*)::int AS count,
|
|
62
|
+
percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75
|
|
63
|
+
FROM latest GROUP BY app, route, name
|
|
64
|
+
), route_keys AS MATERIALIZED (
|
|
65
|
+
SELECT DISTINCT app, route FROM route_stats ORDER BY app, route LIMIT ${perPage} OFFSET ${(query.page - 1) * perPage}
|
|
66
|
+
)
|
|
67
|
+
SELECT
|
|
68
|
+
COALESCE((SELECT jsonb_agg(s) FROM (SELECT name, count(*)::int AS count, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75 FROM latest GROUP BY name ORDER BY name) s), '[]'::jsonb) AS summary,
|
|
69
|
+
COALESCE((SELECT jsonb_agg(s ORDER BY s.bucket, s.name) FROM (SELECT name,
|
|
70
|
+
floor(extract(epoch FROM created_at) / ${bucket}) * ${bucket} * 1000 AS bucket,
|
|
71
|
+
count(*)::int AS count, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75
|
|
72
|
+
FROM latest GROUP BY name, bucket) s), '[]'::jsonb) AS series,
|
|
73
|
+
COALESCE((SELECT jsonb_agg(s ORDER BY s."appId", s.route, s.name) FROM (
|
|
74
|
+
SELECT l.app AS "appId", l.route, l.name, l.count, l.p75
|
|
75
|
+
FROM route_stats l JOIN route_keys k USING (app, route)
|
|
76
|
+
) s), '[]'::jsonb) AS routes,
|
|
77
|
+
(SELECT count(*)::int FROM (SELECT DISTINCT app, route FROM route_stats) r) AS "totalRoutes"
|
|
78
|
+
`;
|
|
79
|
+
if (!result) throw new Error("Web Vitals aggregation returned no result");
|
|
80
|
+
return { ...result, page: query.page, perPage };
|
|
81
|
+
}
|
|
@@ -9,6 +9,10 @@ import { parsePgJsonValue } from "../postgres";
|
|
|
9
9
|
import { isMandatePolicyNarrowing, type MandatePolicyV1, MandatePolicyV1Schema, mandatePolicyAllows, parseMandatePolicy } from "./policy";
|
|
10
10
|
|
|
11
11
|
export {
|
|
12
|
+
CapabilityGrantsSchema,
|
|
13
|
+
CapabilityGrantSchema,
|
|
14
|
+
type CapabilityGrant,
|
|
15
|
+
capabilityGrantAllows,
|
|
12
16
|
isMandatePolicyNarrowing,
|
|
13
17
|
MANDATE_POLICY_MAX_IDENTIFIERS,
|
|
14
18
|
MANDATE_POLICY_VERSION,
|
|
@@ -176,7 +180,7 @@ const mapMandate = (row: MandateRow): Mandate => ({
|
|
|
176
180
|
revokeReason: row.revoke_reason,
|
|
177
181
|
});
|
|
178
182
|
|
|
179
|
-
const selectMandate = sql`
|
|
183
|
+
const selectMandate = () => sql`
|
|
180
184
|
SELECT id, subject_kind, subject_user_id, subject_service_account_id,
|
|
181
185
|
owner_app_id, workload_type, workload_id, policy, state, revision, expires_at,
|
|
182
186
|
confirmed_at, confirmation_deadline,
|
|
@@ -224,7 +228,7 @@ const parsePolicyForWorkload = (
|
|
|
224
228
|
|
|
225
229
|
const load = async (mandateId: string, db: SQL): Promise<Mandate | null> => {
|
|
226
230
|
if (!UUID.safeParse(mandateId).success) return null;
|
|
227
|
-
const [row] = await db<MandateRow[]>`${selectMandate} WHERE id = ${mandateId}::uuid`;
|
|
231
|
+
const [row] = await db<MandateRow[]>`${selectMandate()} WHERE id = ${mandateId}::uuid`;
|
|
228
232
|
return row ? mapMandate(row) : null;
|
|
229
233
|
};
|
|
230
234
|
|
|
@@ -437,7 +441,7 @@ export const listMandates = async (config: {
|
|
|
437
441
|
const where = conditions.slice(1).reduce((combined, condition) => sql`${combined} AND ${condition}`, conditions[0]!);
|
|
438
442
|
const [countRows, rows] = await Promise.all([
|
|
439
443
|
db<{ count: number }[]>`SELECT COUNT(*)::int AS count FROM auth.mandates WHERE ${where}`,
|
|
440
|
-
db<MandateRow[]>`${selectMandate} WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ${perPage} OFFSET ${offset}`,
|
|
444
|
+
db<MandateRow[]>`${selectMandate()} WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ${perPage} OFFSET ${offset}`,
|
|
441
445
|
]);
|
|
442
446
|
const total = countRows[0]?.count ?? 0;
|
|
443
447
|
return { items: rows.map(mapMandate), page, perPage, total, hasNext: page * perPage < total };
|
|
@@ -616,6 +620,8 @@ export const validateMandateIssueAuthority = async (
|
|
|
616
620
|
targetAppId: string;
|
|
617
621
|
operation: string;
|
|
618
622
|
actionApproval?: "none" | "approved";
|
|
623
|
+
input?: unknown;
|
|
624
|
+
capabilityApproval?: "none" | "rememberable" | "always";
|
|
619
625
|
expectedRevision?: number;
|
|
620
626
|
requestId?: string;
|
|
621
627
|
},
|
|
@@ -669,7 +675,15 @@ export const validateMandateIssueAuthority = async (
|
|
|
669
675
|
} catch {
|
|
670
676
|
return denyMandateIssue(err.forbidden("Mandate policy is invalid"));
|
|
671
677
|
}
|
|
672
|
-
if (
|
|
678
|
+
if (
|
|
679
|
+
!mandatePolicyAllows(policy, {
|
|
680
|
+
appId: target.data,
|
|
681
|
+
operation: operation.data,
|
|
682
|
+
actionApproval: input.actionApproval ?? "none",
|
|
683
|
+
input: input.input,
|
|
684
|
+
capabilityApproval: input.capabilityApproval,
|
|
685
|
+
})
|
|
686
|
+
) {
|
|
673
687
|
return denyMandateIssue(err.forbidden("Mandate policy does not allow this operation"));
|
|
674
688
|
}
|
|
675
689
|
const mandate = mapMandate(row);
|
|
@@ -25,12 +25,48 @@ const operationSchema = z.union([
|
|
|
25
25
|
}, "Expected a canonical mandate operation"),
|
|
26
26
|
]);
|
|
27
27
|
|
|
28
|
+
export const CapabilityGrantSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
appId: CapabilityAppIdSchema.describe("Exact discovered application ID."),
|
|
31
|
+
capabilityId: CapabilityLocalIdSchema.describe("Exact local capability ID from discovery, without application prefix."),
|
|
32
|
+
kind: z.enum(["query", "action"]).describe("Discovered capability kind."),
|
|
33
|
+
fixedInput: z.record(z.string(), z.json()).default({}).describe("Exact top-level input values. Omitted fields remain unrestricted."),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export const CapabilityGrantsSchema = z.array(CapabilityGrantSchema).max(MANDATE_POLICY_MAX_IDENTIFIERS / 2);
|
|
37
|
+
export type CapabilityGrant = z.output<typeof CapabilityGrantSchema>;
|
|
38
|
+
|
|
39
|
+
const equalJson = (a: unknown, b: unknown): boolean => {
|
|
40
|
+
if (a === b) return true;
|
|
41
|
+
if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
|
|
42
|
+
if (Array.isArray(a) || Array.isArray(b))
|
|
43
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => equalJson(v, b[i]));
|
|
44
|
+
const ak = Object.keys(a),
|
|
45
|
+
bk = Object.keys(b);
|
|
46
|
+
return ak.length === bk.length && ak.every((key) => Object.hasOwn(b, key) && equalJson(Reflect.get(a, key), Reflect.get(b, key)));
|
|
47
|
+
};
|
|
48
|
+
export const capabilityGrantAllows = (
|
|
49
|
+
grant: CapabilityGrant,
|
|
50
|
+
input: { appId: string; capabilityId: string; kind: "query" | "action"; input: unknown },
|
|
51
|
+
): boolean =>
|
|
52
|
+
grant.appId === input.appId &&
|
|
53
|
+
grant.capabilityId === input.capabilityId &&
|
|
54
|
+
grant.kind === input.kind &&
|
|
55
|
+
Object.entries(grant.fixedInput).every(
|
|
56
|
+
([key, value]) =>
|
|
57
|
+
input.input !== null &&
|
|
58
|
+
typeof input.input === "object" &&
|
|
59
|
+
Object.hasOwn(input.input, key) &&
|
|
60
|
+
equalJson(value, Reflect.get(input.input, key)),
|
|
61
|
+
);
|
|
62
|
+
|
|
28
63
|
export const MandatePolicyV1Schema = z
|
|
29
64
|
.object({
|
|
30
65
|
version: z.literal(MANDATE_POLICY_VERSION),
|
|
31
66
|
apps: z.union([z.literal("*"), identifierList(CapabilityAppIdSchema)]),
|
|
32
67
|
operations: z.union([z.literal("*"), identifierList(operationSchema)]),
|
|
33
68
|
actions: z.enum(["deny", "require_approval", "preapproved"]),
|
|
69
|
+
grants: CapabilityGrantsSchema.optional(),
|
|
34
70
|
})
|
|
35
71
|
.strict()
|
|
36
72
|
.superRefine((policy, context) => {
|
|
@@ -70,14 +106,41 @@ const ACTION_AUTHORITY = { deny: 0, require_approval: 1, preapproved: 2 } as con
|
|
|
70
106
|
export const isMandatePolicyNarrowing = (current: MandatePolicyV1, next: MandatePolicyV1): boolean =>
|
|
71
107
|
setAllows(current.apps, next.apps) &&
|
|
72
108
|
setAllows(current.operations, next.operations) &&
|
|
73
|
-
ACTION_AUTHORITY[next.actions] <= ACTION_AUTHORITY[current.actions]
|
|
109
|
+
ACTION_AUTHORITY[next.actions] <= ACTION_AUTHORITY[current.actions] &&
|
|
110
|
+
(current.grants === undefined ||
|
|
111
|
+
(next.grants !== undefined &&
|
|
112
|
+
next.grants.every((grant) =>
|
|
113
|
+
current.grants!.some((existing) => capabilityGrantAllows(existing, { ...grant, input: grant.fixedInput })),
|
|
114
|
+
)));
|
|
74
115
|
|
|
75
116
|
export const mandatePolicyAllows = (
|
|
76
117
|
policy: MandatePolicyV1,
|
|
77
|
-
input: {
|
|
118
|
+
input: {
|
|
119
|
+
appId: string;
|
|
120
|
+
operation: string;
|
|
121
|
+
actionApproval: "none" | "approved";
|
|
122
|
+
input?: unknown;
|
|
123
|
+
capabilityApproval?: "none" | "rememberable" | "always";
|
|
124
|
+
},
|
|
78
125
|
): boolean => {
|
|
79
126
|
if (policy.apps !== "*" && !policy.apps.includes(input.appId)) return false;
|
|
80
127
|
if (policy.operations !== "*" && !policy.operations.includes(input.operation)) return false;
|
|
128
|
+
if (policy.grants !== undefined) {
|
|
129
|
+
const [operation, capabilityId] = input.operation.split(":");
|
|
130
|
+
const kind =
|
|
131
|
+
operation === "capability.query"
|
|
132
|
+
? "query"
|
|
133
|
+
: operation === "capability.action.run" || operation === "capability.action.review"
|
|
134
|
+
? "action"
|
|
135
|
+
: null;
|
|
136
|
+
if (
|
|
137
|
+
!kind ||
|
|
138
|
+
!capabilityId ||
|
|
139
|
+
!policy.grants.some((grant) => capabilityGrantAllows(grant, { appId: input.appId, capabilityId, kind, input: input.input }))
|
|
140
|
+
)
|
|
141
|
+
return false;
|
|
142
|
+
if (kind === "action" && input.capabilityApproval !== "none" && input.capabilityApproval !== "rememberable") return false;
|
|
143
|
+
}
|
|
81
144
|
if (!input.operation.startsWith("capability.action.run:")) return true;
|
|
82
145
|
if (policy.actions === "deny") return false;
|
|
83
146
|
if (policy.actions === "require_approval") return input.actionApproval === "approved";
|
|
@@ -4,6 +4,7 @@ import type { Worker } from "@k2b/sync";
|
|
|
4
4
|
import { sql } from "bun";
|
|
5
5
|
import { lazySync } from "../../_internal/process-sync";
|
|
6
6
|
import { markdown } from "../../shared/markdown";
|
|
7
|
+
import { type AuditActor, audit } from "../audit";
|
|
7
8
|
import { logger, trace } from "../logging";
|
|
8
9
|
import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
9
10
|
|
|
@@ -406,7 +407,7 @@ export const createDraft = async (params: {
|
|
|
406
407
|
subject: string;
|
|
407
408
|
bodyMarkdown: string;
|
|
408
409
|
selection: NotificationBatchSelection;
|
|
409
|
-
|
|
410
|
+
actor: AuditActor & { userId: string };
|
|
410
411
|
}): Promise<Result<NotificationBatch>> => {
|
|
411
412
|
const subject = params.subject.trim();
|
|
412
413
|
const bodyMarkdown = params.bodyMarkdown.trim();
|
|
@@ -422,12 +423,24 @@ export const createDraft = async (params: {
|
|
|
422
423
|
}
|
|
423
424
|
const hash = selectionHash(selection);
|
|
424
425
|
const bodyHtml = markdown.renderSync(bodyMarkdown);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
426
|
+
return sql.begin(async (tx) => {
|
|
427
|
+
const rows = await tx<BatchRow[]>`
|
|
428
|
+
INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
|
|
429
|
+
VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::text::jsonb, ${hash}, ${params.actor.userId}::uuid)
|
|
430
|
+
RETURNING *
|
|
431
|
+
`;
|
|
432
|
+
const batch = mapBatch(rows[0]!);
|
|
433
|
+
await audit.record(
|
|
434
|
+
{
|
|
435
|
+
action: "accounts.notification_batch.create",
|
|
436
|
+
outcome: "allowed",
|
|
437
|
+
actor: params.actor,
|
|
438
|
+
target: { type: "notification_batch", id: batch.id },
|
|
439
|
+
},
|
|
440
|
+
tx,
|
|
441
|
+
);
|
|
442
|
+
return ok(batch);
|
|
443
|
+
});
|
|
431
444
|
};
|
|
432
445
|
|
|
433
446
|
export const get = async (id: string): Promise<NotificationBatch | null> => {
|
|
@@ -504,7 +517,7 @@ export const listRecipients = async (params: {
|
|
|
504
517
|
|
|
505
518
|
export const finalize = async (params: {
|
|
506
519
|
id: string;
|
|
507
|
-
|
|
520
|
+
actor: AuditActor & { userId: string };
|
|
508
521
|
expectedSelectionHash: string;
|
|
509
522
|
expectedDeliverableCount: number;
|
|
510
523
|
expectedRecipientHash: string;
|
|
@@ -568,7 +581,7 @@ export const finalize = async (params: {
|
|
|
568
581
|
UPDATE notifications.batches
|
|
569
582
|
SET
|
|
570
583
|
status = 'ready',
|
|
571
|
-
finalized_by = ${params.
|
|
584
|
+
finalized_by = ${params.actor.userId}::uuid,
|
|
572
585
|
finalized_at = now(),
|
|
573
586
|
target_count = ${candidates.length},
|
|
574
587
|
deliverable_count = ${deliverableCount},
|
|
@@ -576,6 +589,16 @@ export const finalize = async (params: {
|
|
|
576
589
|
WHERE id = ${params.id}::uuid
|
|
577
590
|
RETURNING *
|
|
578
591
|
`;
|
|
592
|
+
await audit.record(
|
|
593
|
+
{
|
|
594
|
+
action: "accounts.notification_batch.finalize",
|
|
595
|
+
outcome: "allowed",
|
|
596
|
+
actor: params.actor,
|
|
597
|
+
target: { type: "notification_batch", id: params.id },
|
|
598
|
+
metadata: { targetCount: candidates.length, deliverableCount },
|
|
599
|
+
},
|
|
600
|
+
tx,
|
|
601
|
+
);
|
|
579
602
|
return ok(mapBatch(updated[0]!));
|
|
580
603
|
});
|
|
581
604
|
|
|
@@ -584,28 +607,45 @@ export const finalize = async (params: {
|
|
|
584
607
|
return ok({ batch: result.data, jobId });
|
|
585
608
|
};
|
|
586
609
|
|
|
587
|
-
export const retryFailed = async (params: {
|
|
610
|
+
export const retryFailed = async (params: {
|
|
611
|
+
id: string;
|
|
612
|
+
actor: AuditActor & { userId: string };
|
|
613
|
+
}): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
|
|
588
614
|
const batch = await get(params.id);
|
|
589
615
|
if (!batch) return fail(err.notFound("Notification batch not found"));
|
|
590
616
|
if (batch.status === "draft" || batch.status === "cancelled") {
|
|
591
617
|
return fail(err.conflict("Only finalized notification batches can retry recipients"));
|
|
592
618
|
}
|
|
593
619
|
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
620
|
+
const result = await sql.begin(async (tx) => {
|
|
621
|
+
const updated = await tx<BatchRow[]>`
|
|
622
|
+
UPDATE notifications.batch_recipients
|
|
623
|
+
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
624
|
+
WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
|
|
625
|
+
RETURNING user_id
|
|
626
|
+
`;
|
|
627
|
+
if (updated.length === 0) {
|
|
628
|
+
return fail(err.conflict("No failed deliverable recipients can be retried"));
|
|
629
|
+
}
|
|
603
630
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
631
|
+
await tx`
|
|
632
|
+
UPDATE notifications.batches
|
|
633
|
+
SET status = 'ready', completed_at = NULL, last_error = NULL
|
|
634
|
+
WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
|
|
635
|
+
`;
|
|
636
|
+
await audit.record(
|
|
637
|
+
{
|
|
638
|
+
action: "accounts.notification_batch.retry_failed",
|
|
639
|
+
outcome: "allowed",
|
|
640
|
+
actor: params.actor,
|
|
641
|
+
target: { type: "notification_batch", id: params.id },
|
|
642
|
+
metadata: { recipientCount: updated.length },
|
|
643
|
+
},
|
|
644
|
+
tx,
|
|
645
|
+
);
|
|
646
|
+
return ok(null);
|
|
647
|
+
});
|
|
648
|
+
if (!result.ok) return result;
|
|
609
649
|
const refreshed = await refreshBatchCounters(params.id);
|
|
610
650
|
// A completing worker may already have sampled zero remaining recipients.
|
|
611
651
|
// Explicit retries need their own wakeup even while that worker holds its key.
|
|
@@ -615,6 +655,7 @@ export const retryFailed = async (params: { id: string }): Promise<Result<{ batc
|
|
|
615
655
|
|
|
616
656
|
export const retryRecipient = async (params: {
|
|
617
657
|
id: string;
|
|
658
|
+
actor: AuditActor & { userId: string };
|
|
618
659
|
userId: string;
|
|
619
660
|
}): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
|
|
620
661
|
const batch = await get(params.id);
|
|
@@ -623,30 +664,44 @@ export const retryRecipient = async (params: {
|
|
|
623
664
|
return fail(err.conflict("Only finalized notification batches can retry recipients"));
|
|
624
665
|
}
|
|
625
666
|
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (!updated[0]) {
|
|
636
|
-
const existing = await sql<BatchRow[]>`
|
|
637
|
-
SELECT status, recipient
|
|
638
|
-
FROM notifications.batch_recipients
|
|
639
|
-
WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
|
|
667
|
+
const result = await sql.begin(async (tx) => {
|
|
668
|
+
const updated = await tx<BatchRow[]>`
|
|
669
|
+
UPDATE notifications.batch_recipients
|
|
670
|
+
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
671
|
+
WHERE batch_id = ${params.id}::uuid
|
|
672
|
+
AND user_id = ${params.userId}::uuid
|
|
673
|
+
AND status = 'error'
|
|
674
|
+
AND recipient IS NOT NULL
|
|
675
|
+
RETURNING *
|
|
640
676
|
`;
|
|
641
|
-
if (!
|
|
642
|
-
|
|
643
|
-
|
|
677
|
+
if (!updated[0]) {
|
|
678
|
+
const existing = await tx<BatchRow[]>`
|
|
679
|
+
SELECT status, recipient
|
|
680
|
+
FROM notifications.batch_recipients
|
|
681
|
+
WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
|
|
682
|
+
`;
|
|
683
|
+
if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
|
|
684
|
+
return fail(err.conflict("Only failed deliverable recipients can be retried"));
|
|
685
|
+
}
|
|
644
686
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
687
|
+
await tx`
|
|
688
|
+
UPDATE notifications.batches
|
|
689
|
+
SET status = 'ready', completed_at = NULL, last_error = NULL
|
|
690
|
+
WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
|
|
691
|
+
`;
|
|
692
|
+
await audit.record(
|
|
693
|
+
{
|
|
694
|
+
action: "accounts.notification_batch.retry_recipient",
|
|
695
|
+
outcome: "allowed",
|
|
696
|
+
actor: params.actor,
|
|
697
|
+
target: { type: "notification_batch", id: params.id },
|
|
698
|
+
metadata: { recipientUserId: params.userId },
|
|
699
|
+
},
|
|
700
|
+
tx,
|
|
701
|
+
);
|
|
702
|
+
return ok(null);
|
|
703
|
+
});
|
|
704
|
+
if (!result.ok) return result;
|
|
650
705
|
const refreshed = await refreshBatchCounters(params.id);
|
|
651
706
|
const { jobId } = await batchJob().submit({
|
|
652
707
|
key: `${params.id}:retry:${crypto.randomUUID()}`,
|
|
@@ -655,17 +710,29 @@ export const retryRecipient = async (params: {
|
|
|
655
710
|
return ok({ batch: refreshed ?? batch, jobId });
|
|
656
711
|
};
|
|
657
712
|
|
|
658
|
-
export const removeDraft = async (params: { id: string }): Promise<Result<{ id: string }>> => {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
713
|
+
export const removeDraft = async (params: { id: string; actor: AuditActor & { userId: string } }): Promise<Result<{ id: string }>> => {
|
|
714
|
+
return sql.begin(async (tx) => {
|
|
715
|
+
const rows = await tx<BatchRow[]>`
|
|
716
|
+
DELETE FROM notifications.batches
|
|
717
|
+
WHERE id = ${params.id}::uuid AND status = 'draft'
|
|
718
|
+
RETURNING id
|
|
719
|
+
`;
|
|
720
|
+
if (rows[0]) {
|
|
721
|
+
await audit.record(
|
|
722
|
+
{
|
|
723
|
+
action: "accounts.notification_batch.delete",
|
|
724
|
+
outcome: "allowed",
|
|
725
|
+
actor: params.actor,
|
|
726
|
+
target: { type: "notification_batch", id: params.id },
|
|
727
|
+
},
|
|
728
|
+
tx,
|
|
729
|
+
);
|
|
730
|
+
return ok({ id: params.id });
|
|
731
|
+
}
|
|
732
|
+
const existing = await tx`SELECT id FROM notifications.batches WHERE id = ${params.id}::uuid`;
|
|
733
|
+
if (!existing[0]) return fail(err.notFound("Notification batch not found"));
|
|
734
|
+
return fail(err.conflict("Only draft notification batches can be deleted"));
|
|
735
|
+
});
|
|
669
736
|
};
|
|
670
737
|
|
|
671
738
|
export const start = async (): Promise<void> => {
|
|
@@ -152,6 +152,18 @@ const browserDriver = {
|
|
|
152
152
|
},
|
|
153
153
|
deliver: async (value: unknown) => {
|
|
154
154
|
const payload: BrowserDeliveryPayload = BrowserDeliveryPayloadSchema.parse(value);
|
|
155
|
+
// A queued payload can outlive logout, disable, or rebinding this browser to another user.
|
|
156
|
+
const [endpoint] = await sql<{ id: string }[]>`
|
|
157
|
+
SELECT id FROM notifications.endpoints
|
|
158
|
+
WHERE id = ${payload.endpointId}::uuid AND channel = 'browser'
|
|
159
|
+
AND verified_at IS NOT NULL AND disabled_at IS NULL
|
|
160
|
+
`;
|
|
161
|
+
if (!endpoint) {
|
|
162
|
+
throw Object.assign(new Error("Browser notification endpoint is no longer registered"), {
|
|
163
|
+
code: "endpoint_gone",
|
|
164
|
+
retryable: false,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
155
167
|
await ensureWebPushConfigured();
|
|
156
168
|
try {
|
|
157
169
|
await sendPinnedWebPush(
|
|
@@ -6,7 +6,6 @@ import { logger } from "../logging";
|
|
|
6
6
|
import { escapeLikePattern } from "../postgres";
|
|
7
7
|
import { sendEmail } from "./email";
|
|
8
8
|
import "./browser";
|
|
9
|
-
import { notificationLive } from "./live";
|
|
10
9
|
import { notificationObservability } from "./observability";
|
|
11
10
|
import { sendTypedNotification, type TypedNotificationSendResult } from "./platform";
|
|
12
11
|
import { userNotifications } from "./user";
|
|
@@ -610,7 +609,6 @@ export const notifications = {
|
|
|
610
609
|
sendAllPendingSystem,
|
|
611
610
|
getStatusSummary,
|
|
612
611
|
getSearchSummary,
|
|
613
|
-
live: notificationLive,
|
|
614
612
|
observability: notificationObservability,
|
|
615
613
|
user: userNotifications,
|
|
616
614
|
};
|