@k2b/cloud 0.6.0-rc.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 +98 -0
- package/package.json +128 -0
- package/public/logo.svg +1 -0
- package/scripts/app-favicon.test.ts +49 -0
- package/scripts/app-favicon.ts +51 -0
- package/scripts/build.ts +205 -0
- package/scripts/preload.ts +164 -0
- package/scripts/sync-recovery-smoke.ts +200 -0
- package/src/_internal/app-favicon.ts +2 -0
- package/src/_internal/app-presentation.ts +61 -0
- package/src/_internal/app-workspace-state.ts +24 -0
- package/src/_internal/bounded-json.ts +47 -0
- package/src/_internal/build-metadata.ts +9 -0
- package/src/_internal/capabilities.ts +1118 -0
- package/src/_internal/define-app.ts +801 -0
- package/src/_internal/heartbeat.ts +167 -0
- package/src/_internal/help-catalog.ts +241 -0
- package/src/_internal/help.ts +95 -0
- package/src/_internal/nats-connection.ts +34 -0
- package/src/_internal/page-responses.ts +40 -0
- package/src/_internal/process-sync.ts +117 -0
- package/src/_internal/readiness.ts +3 -0
- package/src/_internal/registry-validation.ts +136 -0
- package/src/_internal/registry-watch.ts +52 -0
- package/src/_internal/registry.ts +338 -0
- package/src/_internal/runtime-compatibility.ts +64 -0
- package/src/_internal/runtime-context.ts +84 -0
- package/src/_internal/runtime-watcher.ts +78 -0
- package/src/_internal/static-assets.ts +86 -0
- package/src/_internal/status-preserving-ssr.ts +49 -0
- package/src/access/PermissionEditor.tsx +486 -0
- package/src/access/ResourceApiKeys.tsx +271 -0
- package/src/access/messages.ts +121 -0
- package/src/access/ui.ts +16 -0
- package/src/account/Avatar.tsx +28 -0
- package/src/account/EntitySearch.tsx +363 -0
- package/src/account/avatar-upload-dialog.tsx +197 -0
- package/src/account/avatar-upload.ts +149 -0
- package/src/account/ui.ts +3 -0
- package/src/ai/admin.ts +47 -0
- package/src/ai/approval-routes.ts +130 -0
- package/src/ai/approvals.ts +139 -0
- package/src/ai/assistant-models.ts +61 -0
- package/src/ai/attachments.ts +40 -0
- package/src/ai/browser.ts +92 -0
- package/src/ai/capabilities.ts +714 -0
- package/src/ai/capability-execution.ts +151 -0
- package/src/ai/chat/blocks.render.test.tsx +1108 -0
- package/src/ai/chat/blocks.tsx +730 -0
- package/src/ai/chat/builtin-tools.tsx +428 -0
- package/src/ai/chat/capability-table.tsx +87 -0
- package/src/ai/chat/composer-adapter.ts +169 -0
- package/src/ai/chat/file-tools.tsx +81 -0
- package/src/ai/chat/message-actions.tsx +370 -0
- package/src/ai/chat/message-utils.ts +479 -0
- package/src/ai/chat/messages.ts +43 -0
- package/src/ai/chat/presentation.tsx +395 -0
- package/src/ai/chat/primitives.tsx +3 -0
- package/src/ai/chat/tool-disclosure.tsx +41 -0
- package/src/ai/chat/turn-navigator-utils.ts +28 -0
- package/src/ai/chat/turn-navigator.tsx +164 -0
- package/src/ai/chat/user-message.tsx +149 -0
- package/src/ai/chat/visual-tools.tsx +435 -0
- package/src/ai/chat/web-tools.tsx +202 -0
- package/src/ai/chat-task-contracts.ts +113 -0
- package/src/ai/chat-tasks.ts +952 -0
- package/src/ai/client/controller.ts +1211 -0
- package/src/ai/client/file-source.ts +95 -0
- package/src/ai/client/live-connection.ts +140 -0
- package/src/ai/client/projection.ts +178 -0
- package/src/ai/client/transport.ts +111 -0
- package/src/ai/compaction.ts +182 -0
- package/src/ai/credentials.ts +124 -0
- package/src/ai/default-tool-contracts.ts +108 -0
- package/src/ai/default-tools.ts +127 -0
- package/src/ai/enrich.ts +280 -0
- package/src/ai/executor.ts +1624 -0
- package/src/ai/fetch-file-tool.ts +313 -0
- package/src/ai/file-context.ts +92 -0
- package/src/ai/file-media-type.ts +28 -0
- package/src/ai/file-mount.ts +16 -0
- package/src/ai/file-tools.ts +566 -0
- package/src/ai/files-store.ts +390 -0
- package/src/ai/firecrawl-tools.ts +305 -0
- package/src/ai/grids-skill.ts +126 -0
- package/src/ai/http.ts +265 -0
- package/src/ai/index.ts +337 -0
- package/src/ai/kit-skill.ts +22 -0
- package/src/ai/limits.ts +5 -0
- package/src/ai/live-events.ts +289 -0
- package/src/ai/live-messages.ts +45 -0
- package/src/ai/live-outbox.ts +102 -0
- package/src/ai/live-routes.ts +412 -0
- package/src/ai/live.ts +2 -0
- package/src/ai/maintenance.ts +302 -0
- package/src/ai/markdown-pdf-tool.ts +95 -0
- package/src/ai/memories.ts +605 -0
- package/src/ai/memory-learning-runs.ts +222 -0
- package/src/ai/memory-learning.ts +619 -0
- package/src/ai/memory-tool.ts +120 -0
- package/src/ai/memory-workflow-evidence.ts +158 -0
- package/src/ai/migrate.ts +2082 -0
- package/src/ai/model-access-migrate.ts +30 -0
- package/src/ai/model-access.ts +191 -0
- package/src/ai/personal-agent.ts +6 -0
- package/src/ai/prefs.ts +72 -0
- package/src/ai/project-tool.ts +94 -0
- package/src/ai/projects-routes.ts +277 -0
- package/src/ai/projects.ts +902 -0
- package/src/ai/protocol.ts +279 -0
- package/src/ai/provider.ts +73 -0
- package/src/ai/public-projection.ts +25 -0
- package/src/ai/resource-markers.ts +31 -0
- package/src/ai/resource-refs.ts +70 -0
- package/src/ai/routes.ts +1131 -0
- package/src/ai/runtime-tools.ts +39 -0
- package/src/ai/runtime.ts +544 -0
- package/src/ai/settings.ts +529 -0
- package/src/ai/short-id.ts +45 -0
- package/src/ai/skill-catalog.ts +73 -0
- package/src/ai/skill-format.ts +145 -0
- package/src/ai/skill-seeds.ts +430 -0
- package/src/ai/skill-tool.ts +81 -0
- package/src/ai/skills-routes.ts +143 -0
- package/src/ai/skills.ts +934 -0
- package/src/ai/solid.ts +27 -0
- package/src/ai/store.ts +3113 -0
- package/src/ai/stream.ts +308 -0
- package/src/ai/structured-runs.ts +66 -0
- package/src/ai/structured.ts +186 -0
- package/src/ai/system-prompt.ts +170 -0
- package/src/ai/task-prompt.ts +22 -0
- package/src/ai/timeline.ts +148 -0
- package/src/ai/tool-audit.ts +177 -0
- package/src/ai/tool-result-budget.ts +16 -0
- package/src/ai/tools.ts +155 -0
- package/src/ai/types.ts +967 -0
- package/src/ai/ui.tsx +39 -0
- package/src/ai/usage-migrate.ts +81 -0
- package/src/ai/usage.ts +315 -0
- package/src/ai/validate.ts +54 -0
- package/src/ai/vision-tool.ts +79 -0
- package/src/api/accounts-entities.ts +174 -0
- package/src/api/admin-ai-projects.ts +74 -0
- package/src/api/admin-ai-skills.ts +76 -0
- package/src/api/admin-ai-usage.ts +42 -0
- package/src/api/admin-core-settings.ts +508 -0
- package/src/api/admin-identity.ts +148 -0
- package/src/api/admin-lifecycle.ts +206 -0
- package/src/api/admin-linux-identities.ts +66 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/app-approval.ts +129 -0
- package/src/api/apps.ts +51 -0
- package/src/api/auth/schemas.ts +54 -0
- package/src/api/auth.ts +370 -0
- package/src/api/capabilities.ts +658 -0
- package/src/api/help-messages.ts +19 -0
- package/src/api/help.ts +78 -0
- package/src/api/index.ts +108 -0
- package/src/api/mcp.ts +744 -0
- package/src/api/me-rail.ts +45 -0
- package/src/api/me.ts +753 -0
- package/src/api/search/schemas.ts +79 -0
- package/src/api/search.bench.ts +185 -0
- package/src/api/search.ts +407 -0
- package/src/api/sync-ops.ts +79 -0
- package/src/api/widgets.ts +143 -0
- package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
- package/src/browser/CloudResourceSearch.tsx +791 -0
- package/src/browser/app-approval-vault.ts +305 -0
- package/src/browser/app-approval.ts +234 -0
- package/src/browser/live-websocket.ts +268 -0
- package/src/browser/locale-preference.ts +25 -0
- package/src/browser/notification-target.ts +27 -0
- package/src/browser/notification-timeout.ts +14 -0
- package/src/browser/notifications.ts +181 -0
- package/src/browser/resource-clipboard.ts +70 -0
- package/src/browser/resource-picker.tsx +39 -0
- package/src/browser/resource-search-messages.ts +55 -0
- package/src/browser/resource-search.ts +27 -0
- package/src/capabilities/client.ts +149 -0
- package/src/capabilities/response.ts +39 -0
- package/src/capabilities/server.ts +215 -0
- package/src/capabilities/signals.ts +4 -0
- package/src/capabilities/testing.ts +12 -0
- package/src/capabilities/types.ts +32 -0
- package/src/cli/access.ts +499 -0
- package/src/cli/account.ts +382 -0
- package/src/cli/admin/account-administration.ts +45 -0
- package/src/cli/admin/account-categories.ts +49 -0
- package/src/cli/admin/ai-usage.ts +118 -0
- package/src/cli/admin/app-sign-in.ts +49 -0
- package/src/cli/admin/data.ts +343 -0
- package/src/cli/admin/documentation.ts +34 -0
- package/src/cli/admin/gateway.ts +155 -0
- package/src/cli/admin/index.ts +61 -0
- package/src/cli/admin/instance.ts +250 -0
- package/src/cli/admin/jobs.ts +223 -0
- package/src/cli/admin/legal.ts +122 -0
- package/src/cli/admin/linux.ts +53 -0
- package/src/cli/admin/logs.ts +324 -0
- package/src/cli/admin/metrics.ts +142 -0
- package/src/cli/admin/nats.ts +167 -0
- package/src/cli/admin/notifications.ts +516 -0
- package/src/cli/admin/shared.ts +113 -0
- package/src/cli/admin/sync.ts +188 -0
- package/src/cli/admin/telemetry.ts +314 -0
- package/src/cli/admin/webhooks.ts +222 -0
- package/src/cli/admin/workflows.ts +367 -0
- package/src/cli/apps.ts +40 -0
- package/src/cli/capabilities.ts +239 -0
- package/src/cli/commands.ts +604 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/locale.ts +31 -0
- package/src/cli/output.ts +58 -0
- package/src/clients/core.ts +20 -0
- package/src/config/env.ts +54 -0
- package/src/config/index.ts +6 -0
- package/src/contracts/account-categories.ts +62 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app-approval.ts +197 -0
- package/src/contracts/app.ts +162 -0
- package/src/contracts/capabilities.ts +484 -0
- package/src/contracts/index.ts +14 -0
- package/src/contracts/notification-live.ts +66 -0
- package/src/contracts/notification-types.ts +235 -0
- package/src/contracts/posix.ts +69 -0
- package/src/contracts/profile.ts +85 -0
- package/src/contracts/rail-preferences.ts +53 -0
- package/src/contracts/registry.ts +110 -0
- package/src/contracts/resource-clipboard.ts +33 -0
- package/src/contracts/settings-types.ts +98 -0
- package/src/contracts/shared.ts +408 -0
- package/src/contracts/user-notifications.ts +101 -0
- package/src/contracts/widgets.ts +183 -0
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/index.ts +58 -0
- package/src/server/actor.ts +41 -0
- package/src/server/api/index.ts +2 -0
- package/src/server/api/respond.ts +96 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/help.ts +187 -0
- package/src/server/index.ts +89 -0
- package/src/server/locale.ts +55 -0
- package/src/server/middleware/auth.ts +386 -0
- package/src/server/middleware/index.ts +28 -0
- package/src/server/middleware/invocation.ts +55 -0
- package/src/server/middleware/middleware.ts +65 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +125 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/route-template.ts +46 -0
- package/src/server/middleware/runtime.ts +22 -0
- package/src/server/middleware/settings.ts +55 -0
- package/src/server/middleware/validator.ts +49 -0
- package/src/server/middleware/workload.ts +22 -0
- package/src/server/ratelimit.ts +151 -0
- package/src/server/request-cookies.ts +18 -0
- package/src/server/services/access.ts +644 -0
- package/src/server/services/freeipa/client.ts +137 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +123 -0
- package/src/server/services/freeipa/test-certificates.ts +44 -0
- package/src/server/services/freeipa/tls.ts +44 -0
- package/src/server/services/freeipa/transport.ts +116 -0
- package/src/server/services/freeipa/util.ts +59 -0
- package/src/server/services/geo.ts +198 -0
- package/src/server/services/index.ts +48 -0
- package/src/server/services/pagination.ts +22 -0
- package/src/server/services/services.ts +13 -0
- package/src/server/time.ts +33 -0
- package/src/services/account-category-policy.ts +51 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +940 -0
- package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
- package/src/services/account-lifecycle/notification-sender.ts +13 -0
- package/src/services/account-lifecycle/scheduler.ts +506 -0
- package/src/services/account-model.ts +22 -0
- package/src/services/accounts/app.ts +1671 -0
- package/src/services/accounts/authz.ts +44 -0
- package/src/services/accounts/avatar.ts +120 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +46 -0
- package/src/services/accounts/duplicate-emails.ts +82 -0
- package/src/services/accounts/entities.ts +622 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +272 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +66 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +499 -0
- package/src/services/accounts/model.ts +129 -0
- package/src/services/accounts/notification-sender.ts +31 -0
- package/src/services/accounts/posix.ts +386 -0
- package/src/services/accounts/request-policy.ts +11 -0
- package/src/services/accounts/switching.ts +126 -0
- package/src/services/accounts/users.ts +745 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/app-approval.ts +498 -0
- package/src/services/audit/index.ts +438 -0
- package/src/services/auth-flows/index.ts +15 -0
- package/src/services/auth-flows/ipa.ts +197 -0
- package/src/services/auth-flows/magic-link.ts +185 -0
- package/src/services/auth-flows/notification-sender.ts +10 -0
- package/src/services/auth-flows/password-reset.ts +268 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/document-extraction.ts +148 -0
- package/src/services/freeipa-config.ts +179 -0
- package/src/services/gateway.ts +186 -0
- package/src/services/identity/constants.ts +18 -0
- package/src/services/identity/index.ts +52 -0
- package/src/services/identity/invocation-actor.ts +120 -0
- package/src/services/identity/invocation-authority.ts +53 -0
- package/src/services/identity/invocation-operations.ts +15 -0
- package/src/services/identity/invocation-token.ts +276 -0
- package/src/services/identity/key-config.ts +41 -0
- package/src/services/identity/key-ring.ts +530 -0
- package/src/services/identity/metrics.ts +29 -0
- package/src/services/identity/routes.ts +18 -0
- package/src/services/identity/runtime-config.ts +88 -0
- package/src/services/identity/session-token.ts +152 -0
- package/src/services/identity/signer-refresh.ts +44 -0
- package/src/services/identity/workload-auth.ts +64 -0
- package/src/services/index.ts +187 -0
- package/src/services/ipa/auth.ts +216 -0
- package/src/services/ipa/connection.ts +57 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/groups.ts +722 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/posix.ts +19 -0
- package/src/services/ipa/profile.ts +132 -0
- package/src/services/ipa/runtime.ts +28 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.ts +100 -0
- package/src/services/ipa/sync.ts +862 -0
- package/src/services/ipa/users.ts +806 -0
- package/src/services/legal-consent.ts +76 -0
- package/src/services/logging/index.ts +477 -0
- package/src/services/logging/redaction.ts +20 -0
- package/src/services/logging/trace.ts +1231 -0
- package/src/services/mandates/index.ts +855 -0
- package/src/services/mandates/policy.ts +85 -0
- package/src/services/network-security.ts +31 -0
- package/src/services/notifications/batches.ts +722 -0
- package/src/services/notifications/browser.ts +252 -0
- package/src/services/notifications/catalog.ts +113 -0
- package/src/services/notifications/channels.ts +104 -0
- package/src/services/notifications/dispatcher.ts +139 -0
- package/src/services/notifications/email.ts +118 -0
- package/src/services/notifications/index.ts +633 -0
- package/src/services/notifications/live.ts +50 -0
- package/src/services/notifications/observability.ts +407 -0
- package/src/services/notifications/platform.ts +421 -0
- package/src/services/notifications/runtime.ts +120 -0
- package/src/services/notifications/user.ts +248 -0
- package/src/services/notifications/web-push-transport.ts +135 -0
- package/src/services/oauth-tokens.ts +209 -0
- package/src/services/outbox.ts +187 -0
- package/src/services/pdf/gotenberg.ts +290 -0
- package/src/services/pdf/index.ts +43 -0
- package/src/services/pdf/markdown.ts +209 -0
- package/src/services/pdf/template-preview.ts +122 -0
- package/src/services/postgres.ts +76 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +51 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +263 -0
- package/src/services/rail-preferences.ts +27 -0
- package/src/services/runtime-lifecycle.ts +191 -0
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.ts +891 -0
- package/src/services/service-accounts.ts +184 -0
- package/src/services/session/index.ts +236 -0
- package/src/services/session/test-fixture.ts +18 -0
- package/src/services/session/user.ts +144 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +86 -0
- package/src/services/settings/core-settings.ts +796 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +374 -0
- package/src/services/settings/index.ts +215 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +241 -0
- package/src/services/settings/templates.ts +4 -0
- package/src/services/sync-ops.ts +403 -0
- package/src/services/topic-cursor.ts +22 -0
- package/src/services/weather/forecast.ts +323 -0
- package/src/services/weather/geo.ts +119 -0
- package/src/services/weather/index.ts +67 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +133 -0
- package/src/services/weather/migrate.ts +45 -0
- package/src/services/weather/settings.ts +66 -0
- package/src/services/weather/short-id.ts +57 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/services/webauthn.ts +385 -0
- package/src/shared/account-action-notice.ts +70 -0
- package/src/shared/account-display.ts +15 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/ai-platform-prompt.ts +134 -0
- package/src/shared/ai-usage.ts +49 -0
- package/src/shared/app-presentation.ts +48 -0
- package/src/shared/app-url.ts +12 -0
- package/src/shared/branding.ts +14 -0
- package/src/shared/capability-messages.ts +70 -0
- package/src/shared/email-html.ts +54 -0
- package/src/shared/format.ts +91 -0
- package/src/shared/help.ts +68 -0
- package/src/shared/icons.ts +400 -0
- package/src/shared/index.ts +54 -0
- package/src/shared/locale.ts +48 -0
- package/src/shared/login-method.ts +44 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +105 -0
- package/src/shared/markdown/extensions/guided-help.ts +100 -0
- package/src/shared/markdown/extensions/images.ts +79 -0
- package/src/shared/markdown/extensions/info-blocks.ts +97 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +43 -0
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +109 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +193 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/network-address.ts +106 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/template-rendering.ts +140 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +53 -0
- package/src/ssr/AdminSidebar.tsx +50 -0
- package/src/ssr/AppLaunchpad.island.tsx +236 -0
- package/src/ssr/AppWorkspaceController.island.tsx +21 -0
- package/src/ssr/BrowserNotifications.island.tsx +135 -0
- package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
- package/src/ssr/GlobalSearchDialog.tsx +35 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
- package/src/ssr/Layout.render.test.tsx +148 -0
- package/src/ssr/Layout.tsx +292 -0
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutFooter.tsx +22 -0
- package/src/ssr/LayoutHeader.tsx +81 -0
- package/src/ssr/LayoutHelp.tsx +869 -0
- package/src/ssr/LayoutPreferences.island.tsx +36 -0
- package/src/ssr/LayoutRail.tsx +58 -0
- package/src/ssr/MinimalLayout.tsx +46 -0
- package/src/ssr/PageError.tsx +29 -0
- package/src/ssr/ProfilePreferences.island.tsx +92 -0
- package/src/ssr/RailApps.island.tsx +61 -0
- package/src/ssr/RailEditor.tsx +293 -0
- package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/admin-active-link.ts +47 -0
- package/src/ssr/admin-navigation.ts +78 -0
- package/src/ssr/app-appearance.ts +23 -0
- package/src/ssr/app-navigation.ts +41 -0
- package/src/ssr/help-messages.ts +104 -0
- package/src/ssr/index.ts +14 -0
- package/src/ssr/islands/SearchBar.island.tsx +80 -0
- package/src/ssr/islands/index.ts +16 -0
- package/src/ssr/layout-context.ts +17 -0
- package/src/ssr/layout-help-markdown.ts +9 -0
- package/src/ssr/layout-help-navigation.ts +24 -0
- package/src/ssr/layout-help-url.ts +5 -0
- package/src/ssr/layout-runtime.ts +22 -0
- package/src/ssr/page-error-messages.ts +25 -0
- package/src/ssr/platform-messages.ts +135 -0
- package/src/ssr/preference-controller.ts +44 -0
- package/src/ssr/profile-preferences-messages.ts +29 -0
- package/src/ssr/rail-context.ts +21 -0
- package/src/ssr/rail-messages.ts +66 -0
- package/src/ssr/rail-navigation.ts +60 -0
- package/src/ssr/runtime.ts +31 -0
- package/src/ssr/url-filter.ts +127 -0
- package/src/styles/base-popover.css +36 -0
- package/src/styles/effects.css +851 -0
- package/src/styles/global.css +268 -0
- package/src/styles/input.css +58 -0
- package/src/styles/tokens.css +230 -0
- package/src/styles/utilities-buttons.css +405 -0
- package/src/styles/utilities-data.css +33 -0
- package/src/styles/utilities-feedback.css +232 -0
- package/src/styles/utilities-layout.css +603 -0
- package/src/styles/utilities-markdown-editor.css +525 -0
- package/src/styles/utilities-markdown-table.css +234 -0
- package/src/styles/utilities-navigation.css +1054 -0
- package/src/types/ambient.d.ts +24 -0
- package/src/workflows/ai/index.ts +17 -0
- package/src/workflows/ai/runtime.ts +286 -0
- package/src/workflows/ai/store.ts +314 -0
- package/src/workflows/ai/types.ts +111 -0
- package/src/workflows/ai-actions.ts +269 -0
- package/src/workflows/authoring.ts +88 -0
- package/src/workflows/builtins.ts +193 -0
- package/src/workflows/contracts.ts +280 -0
- package/src/workflows/definition.ts +334 -0
- package/src/workflows/editor.ts +113 -0
- package/src/workflows/index.ts +71 -0
- package/src/workflows/language/binder.ts +45 -0
- package/src/workflows/language/canonical.ts +29 -0
- package/src/workflows/language/compiler.ts +396 -0
- package/src/workflows/language/expressions.ts +34 -0
- package/src/workflows/language/index.ts +20 -0
- package/src/workflows/language/references.ts +55 -0
- package/src/workflows/language/schema.ts +134 -0
- package/src/workflows/language/strict-yaml.ts +131 -0
- package/src/workflows/manifest.ts +24 -0
- package/src/workflows/module.ts +12 -0
- package/src/workflows/runtime/coordinator.ts +115 -0
- package/src/workflows/runtime/executor.ts +1003 -0
- package/src/workflows/runtime/index.ts +60 -0
- package/src/workflows/runtime/instant.ts +8 -0
- package/src/workflows/runtime/ports.ts +213 -0
- package/src/workflows/runtime/schedule.ts +203 -0
- package/src/workflows/runtime/trigger.ts +37 -0
- package/src/workflows/runtime/wake.ts +13 -0
- package/src/workflows/store/actions.ts +438 -0
- package/src/workflows/store/budget.ts +145 -0
- package/src/workflows/store/definitions.ts +345 -0
- package/src/workflows/store/events.ts +383 -0
- package/src/workflows/store/index.ts +107 -0
- package/src/workflows/store/observability.ts +594 -0
- package/src/workflows/store/runs.ts +975 -0
- package/src/workflows/store/transaction.ts +23 -0
- package/src/workflows/store/worker.ts +439 -0
- package/src/workflows/testing/conformance.ts +72 -0
- package/src/workflows/testing/index.ts +11 -0
- package/src/workflows/testing/process-fixtures.ts +275 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { err, fail, ok, type PageParams, type Paginated, paginate, type Result } from "@k2b/stdlib";
|
|
2
|
+
import { type SQL, sql } from "bun";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CapabilityAppIdSchema } from "../../contracts/capabilities";
|
|
5
|
+
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
6
|
+
import { audit } from "../audit";
|
|
7
|
+
import { logger } from "../logging";
|
|
8
|
+
import { parsePgJsonValue } from "../postgres";
|
|
9
|
+
import { isMandatePolicyNarrowing, type MandatePolicyV1, MandatePolicyV1Schema, mandatePolicyAllows, parseMandatePolicy } from "./policy";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
isMandatePolicyNarrowing,
|
|
13
|
+
MANDATE_POLICY_MAX_IDENTIFIERS,
|
|
14
|
+
MANDATE_POLICY_VERSION,
|
|
15
|
+
type MandatePolicyV1,
|
|
16
|
+
MandatePolicyV1Schema,
|
|
17
|
+
mandatePolicyAllows,
|
|
18
|
+
parseMandatePolicy,
|
|
19
|
+
} from "./policy";
|
|
20
|
+
|
|
21
|
+
export type MandateState = "active" | "paused" | "revoked";
|
|
22
|
+
export type MandateSubject = { type: "user"; id: string } | { type: "service_account"; id: string };
|
|
23
|
+
export const MANDATE_CONFIRMATION_TTL_MS = 15 * 60 * 1_000;
|
|
24
|
+
/** One maximum-size provisioning page may await confirmation per creator. */
|
|
25
|
+
export const MANDATE_MAX_PENDING_PER_USER = 100;
|
|
26
|
+
|
|
27
|
+
export type Mandate = {
|
|
28
|
+
id: string;
|
|
29
|
+
subject: MandateSubject;
|
|
30
|
+
ownerAppId: string;
|
|
31
|
+
workloadType: string;
|
|
32
|
+
workloadId: string;
|
|
33
|
+
policy: MandatePolicyV1;
|
|
34
|
+
state: MandateState;
|
|
35
|
+
revision: number;
|
|
36
|
+
expiresAt: string | null;
|
|
37
|
+
confirmedAt: string | null;
|
|
38
|
+
confirmationDeadline: string | null;
|
|
39
|
+
createdByUserId: string | null;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
updatedAt: string;
|
|
42
|
+
revokedAt: string | null;
|
|
43
|
+
revokedByUserId: string | null;
|
|
44
|
+
revokedByAppId: string | null;
|
|
45
|
+
revokeReason: string | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type MandateInteractiveAuthority = { kind: "interactive"; userId: string };
|
|
49
|
+
export type MandateAdminAuthority = { kind: "admin"; userId: string };
|
|
50
|
+
export type MandateWorkloadAuthority = { kind: "workload"; ownerAppId: string };
|
|
51
|
+
export type MandateCreateAuthority = MandateInteractiveAuthority | MandateAdminAuthority;
|
|
52
|
+
export type MandateMutationAuthority = MandateInteractiveAuthority | MandateAdminAuthority | MandateWorkloadAuthority;
|
|
53
|
+
|
|
54
|
+
export type MandateMetricName =
|
|
55
|
+
| "issue_allowed"
|
|
56
|
+
| "issue_denied_invalid"
|
|
57
|
+
| "issue_denied_unavailable"
|
|
58
|
+
| "issue_denied_subject"
|
|
59
|
+
| "issue_denied_owner"
|
|
60
|
+
| "issue_denied_inactive"
|
|
61
|
+
| "issue_denied_revision"
|
|
62
|
+
| "issue_denied_policy"
|
|
63
|
+
| "issue_failed_internal";
|
|
64
|
+
|
|
65
|
+
const mandateMetricNames: MandateMetricName[] = [
|
|
66
|
+
"issue_allowed",
|
|
67
|
+
"issue_denied_invalid",
|
|
68
|
+
"issue_denied_unavailable",
|
|
69
|
+
"issue_denied_subject",
|
|
70
|
+
"issue_denied_owner",
|
|
71
|
+
"issue_denied_inactive",
|
|
72
|
+
"issue_denied_revision",
|
|
73
|
+
"issue_denied_policy",
|
|
74
|
+
"issue_failed_internal",
|
|
75
|
+
];
|
|
76
|
+
const mandateMetricCounters = new Map<MandateMetricName, number>(mandateMetricNames.map((name) => [name, 0]));
|
|
77
|
+
|
|
78
|
+
/** Process-local counters with a fixed label set; no user/workload IDs enter metric cardinality. */
|
|
79
|
+
export const mandateMetrics = {
|
|
80
|
+
increment(name: MandateMetricName): void {
|
|
81
|
+
mandateMetricCounters.set(name, (mandateMetricCounters.get(name) ?? 0) + 1);
|
|
82
|
+
},
|
|
83
|
+
snapshot(): Readonly<Record<MandateMetricName, number>> {
|
|
84
|
+
return Object.freeze(
|
|
85
|
+
Object.fromEntries(mandateMetricNames.map((name) => [name, mandateMetricCounters.get(name) ?? 0])) as Record<
|
|
86
|
+
MandateMetricName,
|
|
87
|
+
number
|
|
88
|
+
>,
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type MandateIssueAuthority = {
|
|
94
|
+
mandateId: string;
|
|
95
|
+
mandateRevision: number;
|
|
96
|
+
subject: MandateSubject;
|
|
97
|
+
ownerAppId: string;
|
|
98
|
+
workloadType: string;
|
|
99
|
+
workloadId: string;
|
|
100
|
+
targetAppId: string;
|
|
101
|
+
operation: string;
|
|
102
|
+
policy: MandatePolicyV1;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type MandateIssueError = Extract<Result<never>, { ok: false }>["error"];
|
|
106
|
+
|
|
107
|
+
const denyMandateIssue = (error: MandateIssueError): Result<MandateIssueAuthority> => fail(error);
|
|
108
|
+
|
|
109
|
+
type MandateRow = {
|
|
110
|
+
id: string;
|
|
111
|
+
subject_kind: MandateSubject["type"];
|
|
112
|
+
subject_user_id: string | null;
|
|
113
|
+
subject_service_account_id: string | null;
|
|
114
|
+
owner_app_id: string;
|
|
115
|
+
workload_type: string;
|
|
116
|
+
workload_id: string;
|
|
117
|
+
policy: unknown;
|
|
118
|
+
state: MandateState;
|
|
119
|
+
revision: number | bigint;
|
|
120
|
+
expires_at: Date | string | null;
|
|
121
|
+
confirmed_at: Date | string | null;
|
|
122
|
+
confirmation_deadline: Date | string | null;
|
|
123
|
+
created_by_user_id: string | null;
|
|
124
|
+
created_at: Date | string;
|
|
125
|
+
updated_at: Date | string;
|
|
126
|
+
revoked_at: Date | string | null;
|
|
127
|
+
revoked_by_user_id: string | null;
|
|
128
|
+
revoked_by_app_id: string | null;
|
|
129
|
+
revoke_reason: string | null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type MandateCurrentRow = MandateRow & { subject_active: boolean; not_expired: boolean };
|
|
133
|
+
|
|
134
|
+
const UUID = z.string().uuid();
|
|
135
|
+
const WorkloadTypeSchema = z
|
|
136
|
+
.string()
|
|
137
|
+
.trim()
|
|
138
|
+
.min(1)
|
|
139
|
+
.max(120)
|
|
140
|
+
.regex(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/);
|
|
141
|
+
const WorkloadIdSchema = z.string().trim().min(1).max(200);
|
|
142
|
+
const OperationSchema = z.string().trim().min(1).max(180);
|
|
143
|
+
const RequestIdSchema = z
|
|
144
|
+
.string()
|
|
145
|
+
.min(1)
|
|
146
|
+
.max(200)
|
|
147
|
+
.regex(/^[\x21-\x7e]+$/);
|
|
148
|
+
const RevisionSchema = z.number().int().positive();
|
|
149
|
+
|
|
150
|
+
const iso = (value: Date | string): string => new Date(value).toISOString();
|
|
151
|
+
const nullableIso = (value: Date | string | null): string | null => (value === null ? null : iso(value));
|
|
152
|
+
|
|
153
|
+
const subjectFromRow = (row: MandateRow): MandateSubject =>
|
|
154
|
+
row.subject_kind === "user"
|
|
155
|
+
? { type: "user", id: UUID.parse(row.subject_user_id) }
|
|
156
|
+
: { type: "service_account", id: UUID.parse(row.subject_service_account_id) };
|
|
157
|
+
|
|
158
|
+
const mapMandate = (row: MandateRow): Mandate => ({
|
|
159
|
+
id: row.id,
|
|
160
|
+
subject: subjectFromRow(row),
|
|
161
|
+
ownerAppId: row.owner_app_id,
|
|
162
|
+
workloadType: row.workload_type,
|
|
163
|
+
workloadId: row.workload_id,
|
|
164
|
+
policy: parseMandatePolicy(parsePgJsonValue(row.policy)),
|
|
165
|
+
state: row.state,
|
|
166
|
+
revision: Number(row.revision),
|
|
167
|
+
expiresAt: nullableIso(row.expires_at),
|
|
168
|
+
confirmedAt: nullableIso(row.confirmed_at),
|
|
169
|
+
confirmationDeadline: nullableIso(row.confirmation_deadline),
|
|
170
|
+
createdByUserId: row.created_by_user_id,
|
|
171
|
+
createdAt: iso(row.created_at),
|
|
172
|
+
updatedAt: iso(row.updated_at),
|
|
173
|
+
revokedAt: nullableIso(row.revoked_at),
|
|
174
|
+
revokedByUserId: row.revoked_by_user_id,
|
|
175
|
+
revokedByAppId: row.revoked_by_app_id,
|
|
176
|
+
revokeReason: row.revoke_reason,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const selectMandate = sql`
|
|
180
|
+
SELECT id, subject_kind, subject_user_id, subject_service_account_id,
|
|
181
|
+
owner_app_id, workload_type, workload_id, policy, state, revision, expires_at,
|
|
182
|
+
confirmed_at, confirmation_deadline,
|
|
183
|
+
created_by_user_id, created_at, updated_at, revoked_at, revoked_by_user_id,
|
|
184
|
+
revoked_by_app_id, revoke_reason
|
|
185
|
+
FROM auth.mandates
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
const validSubject = async (subject: MandateSubject, db: SQL): Promise<boolean> => {
|
|
189
|
+
if (subject.type === "user") {
|
|
190
|
+
const [row] = await db<{ provider: "local" | "ipa"; profile: "guest" | "user" }[]>`
|
|
191
|
+
SELECT provider, profile FROM auth.users
|
|
192
|
+
WHERE id = ${subject.id}::uuid
|
|
193
|
+
AND (account_expires IS NULL OR account_expires > now())
|
|
194
|
+
`;
|
|
195
|
+
return !!row && (await isAccountCategoryAllowed(row, db));
|
|
196
|
+
}
|
|
197
|
+
const [row] = await db<{ active: boolean }[]>`
|
|
198
|
+
SELECT EXISTS (
|
|
199
|
+
SELECT 1 FROM auth.service_accounts
|
|
200
|
+
WHERE id = ${subject.id}::uuid
|
|
201
|
+
AND kind = 'resource_bound'
|
|
202
|
+
AND status = 'active'
|
|
203
|
+
) AS active
|
|
204
|
+
`;
|
|
205
|
+
return row?.active === true;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const canActForSubject = (authority: MandateInteractiveAuthority | MandateAdminAuthority, subject: MandateSubject): boolean =>
|
|
209
|
+
authority.kind === "admin" || (subject.type === "user" && subject.id === authority.userId);
|
|
210
|
+
|
|
211
|
+
const parsePolicyForWorkload = (
|
|
212
|
+
policy: unknown,
|
|
213
|
+
workload: { subject: MandateSubject; ownerAppId: string; workloadType: string },
|
|
214
|
+
): Result<MandatePolicyV1> => {
|
|
215
|
+
const parsed = MandatePolicyV1Schema.safeParse(policy);
|
|
216
|
+
if (!parsed.success) return fail(err.badInput(parsed.error.issues[0]?.message ?? "Invalid mandate policy"));
|
|
217
|
+
const hasWildcard = parsed.data.apps === "*" || parsed.data.operations === "*";
|
|
218
|
+
const isCoreAiChatTask = workload.subject.type === "user" && workload.ownerAppId === "core" && workload.workloadType === "ai.chat-task";
|
|
219
|
+
if (hasWildcard && (!isCoreAiChatTask || parsed.data.actions !== "require_approval")) {
|
|
220
|
+
return fail(err.forbidden("Wildcard mandate policy is not allowed for this workload"));
|
|
221
|
+
}
|
|
222
|
+
return ok(parsed.data);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const load = async (mandateId: string, db: SQL): Promise<Mandate | null> => {
|
|
226
|
+
if (!UUID.safeParse(mandateId).success) return null;
|
|
227
|
+
const [row] = await db<MandateRow[]>`${selectMandate} WHERE id = ${mandateId}::uuid`;
|
|
228
|
+
return row ? mapMandate(row) : null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const mutationAccess = (mandate: Mandate, authority: MandateMutationAuthority): boolean =>
|
|
232
|
+
authority.kind === "workload" ? authority.ownerAppId === mandate.ownerAppId : canActForSubject(authority, mandate.subject);
|
|
233
|
+
|
|
234
|
+
const mutationAuditActor = (authority: MandateMutationAuthority): { userId: string } | null =>
|
|
235
|
+
(authority.kind === "interactive" || authority.kind === "admin") && UUID.safeParse(authority.userId).success
|
|
236
|
+
? { userId: authority.userId }
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
const mutationAuditMetadata = (authority: MandateMutationAuthority, metadata: Record<string, unknown>): Record<string, unknown> => ({
|
|
240
|
+
...metadata,
|
|
241
|
+
...(authority.kind === "workload" ? { ownerAppId: authority.ownerAppId.slice(0, 80) } : {}),
|
|
242
|
+
...(authority.kind === "admin" ? { adminAction: true } : {}),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const createAuditActor = (authority: MandateCreateAuthority): { userId: string } | null =>
|
|
246
|
+
UUID.safeParse(authority.userId).success ? { userId: authority.userId } : null;
|
|
247
|
+
|
|
248
|
+
const createAuditMetadata = (authority: MandateCreateAuthority): Record<string, unknown> =>
|
|
249
|
+
authority.kind === "admin" ? { adminAction: true } : {};
|
|
250
|
+
|
|
251
|
+
const auditedMutation = <T>(
|
|
252
|
+
options: { db?: SQL },
|
|
253
|
+
params: {
|
|
254
|
+
action: string;
|
|
255
|
+
authority: MandateMutationAuthority;
|
|
256
|
+
targetId: (result: Result<T>) => string | null;
|
|
257
|
+
metadata: Record<string, unknown>;
|
|
258
|
+
mutate: (db: SQL) => Promise<Result<T>>;
|
|
259
|
+
},
|
|
260
|
+
): Promise<Result<T>> => {
|
|
261
|
+
const run = async (db: SQL): Promise<Result<T>> => {
|
|
262
|
+
const result = await params.mutate(db);
|
|
263
|
+
return audit.recordResult({
|
|
264
|
+
action: params.action,
|
|
265
|
+
actor: mutationAuditActor(params.authority),
|
|
266
|
+
target: { type: "mandate", id: params.targetId(result) },
|
|
267
|
+
metadata: mutationAuditMetadata(params.authority, params.metadata),
|
|
268
|
+
result,
|
|
269
|
+
db,
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
return options.db ? run(options.db) : sql.begin(run);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const changedRow = async (
|
|
276
|
+
mandateId: string,
|
|
277
|
+
expectedRevision: number,
|
|
278
|
+
authority: MandateMutationAuthority,
|
|
279
|
+
db: SQL,
|
|
280
|
+
update: (mandate: Mandate) => Promise<Result<MandateRow | Mandate | null>>,
|
|
281
|
+
): Promise<Result<Mandate>> => {
|
|
282
|
+
const current = await load(mandateId, db);
|
|
283
|
+
if (!current) return fail(err.notFound("Mandate"));
|
|
284
|
+
if (!mutationAccess(current, authority)) return fail(err.forbidden("Cannot change this mandate"));
|
|
285
|
+
if (current.revision !== expectedRevision) return fail(err.conflict("Mandate revision changed"));
|
|
286
|
+
if (current.state === "revoked") return fail(err.conflict("Mandate is revoked"));
|
|
287
|
+
const result = await update(current);
|
|
288
|
+
if (!result.ok) return result;
|
|
289
|
+
const row = result.data;
|
|
290
|
+
if (row) return ok("ownerAppId" in row ? row : mapMandate(row));
|
|
291
|
+
return fail(err.conflict("Mandate changed; read it and retry"));
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const lockWorkload = async (ownerAppId: string, workloadType: string, workloadId: string, db: SQL): Promise<void> => {
|
|
295
|
+
const coordinate = JSON.stringify([ownerAppId, workloadType, workloadId]);
|
|
296
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended(${"mandate-workload:" + coordinate}, 0))`;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const createMandateInDb = async (
|
|
300
|
+
input: {
|
|
301
|
+
authority: MandateCreateAuthority;
|
|
302
|
+
subject: MandateSubject;
|
|
303
|
+
ownerAppId: string;
|
|
304
|
+
workloadType: string;
|
|
305
|
+
workloadId: string;
|
|
306
|
+
policy: unknown;
|
|
307
|
+
expiresAt?: string | null;
|
|
308
|
+
confirmation: "immediate" | "owner";
|
|
309
|
+
},
|
|
310
|
+
db: SQL,
|
|
311
|
+
): Promise<Result<Mandate>> => {
|
|
312
|
+
const authorityUserId = input.authority.userId;
|
|
313
|
+
if (!UUID.safeParse(authorityUserId).success || !UUID.safeParse(input.subject.id).success) {
|
|
314
|
+
return fail(err.badInput("Invalid mandate subject"));
|
|
315
|
+
}
|
|
316
|
+
const ownerAppId = CapabilityAppIdSchema.safeParse(input.ownerAppId.trim());
|
|
317
|
+
const workloadType = WorkloadTypeSchema.safeParse(input.workloadType);
|
|
318
|
+
const workloadId = WorkloadIdSchema.safeParse(input.workloadId);
|
|
319
|
+
if (!ownerAppId.success || !workloadType.success || !workloadId.success) return fail(err.badInput("Invalid mandate workload"));
|
|
320
|
+
if (!canActForSubject(input.authority, input.subject)) return fail(err.forbidden("Cannot create a mandate for this subject"));
|
|
321
|
+
const policy = parsePolicyForWorkload(input.policy, input);
|
|
322
|
+
if (!policy.ok) return policy;
|
|
323
|
+
const expiresAt = input.expiresAt == null ? null : new Date(input.expiresAt);
|
|
324
|
+
if (expiresAt && (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= Date.now())) {
|
|
325
|
+
return fail(err.badInput("Mandate expiry must be in the future"));
|
|
326
|
+
}
|
|
327
|
+
if (!(await validSubject(input.subject, db))) return fail(err.forbidden("Mandate subject is not active"));
|
|
328
|
+
const ownerConfirmation = input.confirmation === "owner";
|
|
329
|
+
if (ownerConfirmation) {
|
|
330
|
+
// Serialize quota decisions across Core replicas; expired registrations do
|
|
331
|
+
// not consume capacity while the bounded orphan reconciler catches up.
|
|
332
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended(${"mandate-pending:" + authorityUserId}, 0))`;
|
|
333
|
+
const [pending] = await db<{ count: number }[]>`
|
|
334
|
+
SELECT count(*)::int AS count FROM (
|
|
335
|
+
SELECT 1 FROM auth.mandates
|
|
336
|
+
WHERE created_by_user_id = ${authorityUserId}::uuid
|
|
337
|
+
AND confirmed_at IS NULL AND state IN ('active', 'paused')
|
|
338
|
+
AND confirmation_deadline > now()
|
|
339
|
+
AND (expires_at IS NULL OR expires_at > now())
|
|
340
|
+
LIMIT ${MANDATE_MAX_PENDING_PER_USER}
|
|
341
|
+
) pending
|
|
342
|
+
`;
|
|
343
|
+
if ((pending?.count ?? 0) >= MANDATE_MAX_PENDING_PER_USER) {
|
|
344
|
+
return fail(err.conflict("Pending mandate limit reached; confirm or revoke an existing registration"));
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
await lockWorkload(ownerAppId.data, workloadType.data, workloadId.data, db);
|
|
348
|
+
}
|
|
349
|
+
const confirmationTtlSeconds = MANDATE_CONFIRMATION_TTL_MS / 1_000;
|
|
350
|
+
const [row] = await db<MandateRow[]>`
|
|
351
|
+
INSERT INTO auth.mandates (
|
|
352
|
+
subject_kind, subject_user_id, subject_service_account_id,
|
|
353
|
+
owner_app_id, workload_type, workload_id, policy, expires_at,
|
|
354
|
+
confirmed_at, confirmation_deadline, created_by_user_id
|
|
355
|
+
) VALUES (
|
|
356
|
+
${input.subject.type},
|
|
357
|
+
${input.subject.type === "user" ? input.subject.id : null}::uuid,
|
|
358
|
+
${input.subject.type === "service_account" ? input.subject.id : null}::uuid,
|
|
359
|
+
${ownerAppId.data}, ${workloadType.data}, ${workloadId.data}, ${policy.data}::jsonb,
|
|
360
|
+
${expiresAt},
|
|
361
|
+
CASE WHEN ${ownerConfirmation} THEN NULL ELSE now() END,
|
|
362
|
+
CASE WHEN ${ownerConfirmation} THEN now() + (${confirmationTtlSeconds} * interval '1 second') ELSE NULL END,
|
|
363
|
+
${input.authority.userId}::uuid
|
|
364
|
+
)
|
|
365
|
+
ON CONFLICT DO NOTHING
|
|
366
|
+
RETURNING *
|
|
367
|
+
`;
|
|
368
|
+
return row ? ok(mapMandate(row)) : fail(err.conflict("Mandate workload"));
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
type CreateMandateInput = Omit<Parameters<typeof createMandateInDb>[0], "confirmation">;
|
|
372
|
+
|
|
373
|
+
const createMandateWithConfirmation = (
|
|
374
|
+
input: CreateMandateInput,
|
|
375
|
+
confirmation: "immediate" | "owner",
|
|
376
|
+
options: { db?: SQL } = {},
|
|
377
|
+
): Promise<Result<Mandate>> => {
|
|
378
|
+
const run = async (db: SQL): Promise<Result<Mandate>> => {
|
|
379
|
+
const result = await createMandateInDb({ ...input, confirmation }, db);
|
|
380
|
+
return audit.recordResult({
|
|
381
|
+
action: "mandate.create",
|
|
382
|
+
actor: createAuditActor(input.authority),
|
|
383
|
+
target: { type: "mandate", id: result.ok ? result.data.id : null },
|
|
384
|
+
metadata: {
|
|
385
|
+
subjectType: input.subject.type,
|
|
386
|
+
ownerAppId: input.ownerAppId.slice(0, 80),
|
|
387
|
+
workloadType: input.workloadType.slice(0, 120),
|
|
388
|
+
workloadId: input.workloadId.slice(0, 200),
|
|
389
|
+
hasExpiry: input.expiresAt != null,
|
|
390
|
+
confirmation,
|
|
391
|
+
...createAuditMetadata(input.authority),
|
|
392
|
+
},
|
|
393
|
+
result,
|
|
394
|
+
db,
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
return options.db ? run(options.db) : sql.begin(run);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/** Creates a mandate that is immediately usable inside the caller's workload transaction. */
|
|
401
|
+
export const createMandate = (input: CreateMandateInput, options: { db?: SQL } = {}): Promise<Result<Mandate>> =>
|
|
402
|
+
createMandateWithConfirmation(input, "immediate", options);
|
|
403
|
+
|
|
404
|
+
/** Creates a remote-workload mandate that remains unusable until its owner confirms persistence. */
|
|
405
|
+
export const createPendingMandate = (input: CreateMandateInput, options: { db?: SQL } = {}): Promise<Result<Mandate>> =>
|
|
406
|
+
createMandateWithConfirmation(input, "owner", options);
|
|
407
|
+
|
|
408
|
+
export const getMandate = (mandateId: string, options: { db?: SQL } = {}): Promise<Mandate | null> => load(mandateId, options.db ?? sql);
|
|
409
|
+
|
|
410
|
+
export type MandateListScope = { kind: "user"; userId: string } | { kind: "owner"; ownerAppId: string } | { kind: "admin" };
|
|
411
|
+
|
|
412
|
+
export const listMandates = async (config: {
|
|
413
|
+
scope: MandateListScope;
|
|
414
|
+
pagination?: PageParams;
|
|
415
|
+
filter?: { ownerAppId?: string; state?: MandateState };
|
|
416
|
+
db?: SQL;
|
|
417
|
+
}): Promise<Paginated<Mandate>> => {
|
|
418
|
+
const db = config.db ?? sql;
|
|
419
|
+
const requestedPerPage = config.pagination?.perPage ?? 50;
|
|
420
|
+
const { page, perPage, offset } = paginate({
|
|
421
|
+
...config.pagination,
|
|
422
|
+
perPage: Math.max(1, Math.min(100, Number.isFinite(requestedPerPage) ? Math.floor(requestedPerPage) : 50)),
|
|
423
|
+
});
|
|
424
|
+
const conditions = [sql`TRUE`];
|
|
425
|
+
if (config.scope.kind === "user") {
|
|
426
|
+
const userId = UUID.safeParse(config.scope.userId);
|
|
427
|
+
conditions.push(userId.success ? sql`subject_kind = 'user' AND subject_user_id = ${userId.data}::uuid` : sql`FALSE`);
|
|
428
|
+
} else if (config.scope.kind === "owner") {
|
|
429
|
+
const owner = CapabilityAppIdSchema.safeParse(config.scope.ownerAppId);
|
|
430
|
+
conditions.push(owner.success ? sql`owner_app_id = ${owner.data}` : sql`FALSE`);
|
|
431
|
+
}
|
|
432
|
+
if (config.filter?.ownerAppId) {
|
|
433
|
+
const owner = CapabilityAppIdSchema.safeParse(config.filter.ownerAppId);
|
|
434
|
+
conditions.push(owner.success ? sql`owner_app_id = ${owner.data}` : sql`FALSE`);
|
|
435
|
+
}
|
|
436
|
+
if (config.filter?.state) conditions.push(sql`state = ${config.filter.state}`);
|
|
437
|
+
const where = conditions.slice(1).reduce((combined, condition) => sql`${combined} AND ${condition}`, conditions[0]!);
|
|
438
|
+
const [countRows, rows] = await Promise.all([
|
|
439
|
+
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}`,
|
|
441
|
+
]);
|
|
442
|
+
const total = countRows[0]?.count ?? 0;
|
|
443
|
+
return { items: rows.map(mapMandate), page, perPage, total, hasNext: page * perPage < total };
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const confirmMandateInDb = async (
|
|
447
|
+
input: { mandateId: string; expectedRevision: number; authority: MandateWorkloadAuthority },
|
|
448
|
+
db: SQL,
|
|
449
|
+
): Promise<Result<Mandate>> => {
|
|
450
|
+
let existing = await load(input.mandateId, db);
|
|
451
|
+
if (!existing) return fail(err.notFound("Mandate"));
|
|
452
|
+
if (existing.ownerAppId !== input.authority.ownerAppId) return fail(err.forbidden("Cannot confirm this mandate"));
|
|
453
|
+
await lockWorkload(existing.ownerAppId, existing.workloadType, existing.workloadId, db);
|
|
454
|
+
existing = await load(input.mandateId, db);
|
|
455
|
+
if (!existing) return fail(err.notFound("Mandate"));
|
|
456
|
+
if (existing.state === "revoked") return fail(err.conflict("Mandate is revoked"));
|
|
457
|
+
if (existing.revision !== input.expectedRevision) return fail(err.conflict("Mandate revision changed"));
|
|
458
|
+
if (existing.confirmedAt) return ok(existing);
|
|
459
|
+
const [claimed] = await db<{ id: string }[]>`
|
|
460
|
+
SELECT id FROM auth.mandates
|
|
461
|
+
WHERE owner_app_id = ${existing.ownerAppId}
|
|
462
|
+
AND workload_type = ${existing.workloadType} AND workload_id = ${existing.workloadId}
|
|
463
|
+
AND confirmed_at IS NOT NULL AND state IN ('active', 'paused')
|
|
464
|
+
AND id <> ${existing.id}::uuid
|
|
465
|
+
`;
|
|
466
|
+
if (claimed) return fail(err.conflict("Mandate workload already has confirmed authority"));
|
|
467
|
+
const [row] = await db<MandateRow[]>`
|
|
468
|
+
UPDATE auth.mandates
|
|
469
|
+
SET confirmed_at = now(), confirmation_deadline = NULL, updated_at = now()
|
|
470
|
+
WHERE id = ${existing.id}::uuid
|
|
471
|
+
AND revision = ${input.expectedRevision}::bigint
|
|
472
|
+
AND state <> 'revoked'
|
|
473
|
+
AND confirmed_at IS NULL
|
|
474
|
+
AND confirmation_deadline > now()
|
|
475
|
+
RETURNING *
|
|
476
|
+
`;
|
|
477
|
+
return row ? ok(mapMandate(row)) : fail(err.conflict("Mandate confirmation expired or changed"));
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export const confirmMandate = (input: Parameters<typeof confirmMandateInDb>[0], options: { db?: SQL } = {}): Promise<Result<Mandate>> =>
|
|
481
|
+
auditedMutation(options, {
|
|
482
|
+
action: "mandate.confirm",
|
|
483
|
+
authority: input.authority,
|
|
484
|
+
targetId: () => input.mandateId,
|
|
485
|
+
metadata: { expectedRevision: input.expectedRevision },
|
|
486
|
+
mutate: (db) => confirmMandateInDb(input, db),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const updateMandatePolicyInDb = async (
|
|
490
|
+
input: {
|
|
491
|
+
mandateId: string;
|
|
492
|
+
expectedRevision: number;
|
|
493
|
+
authority: MandateMutationAuthority;
|
|
494
|
+
policy: unknown;
|
|
495
|
+
},
|
|
496
|
+
db: SQL,
|
|
497
|
+
): Promise<Result<Mandate>> => {
|
|
498
|
+
return changedRow(input.mandateId, input.expectedRevision, input.authority, db, async (current) => {
|
|
499
|
+
const policy = parsePolicyForWorkload(input.policy, {
|
|
500
|
+
subject: current.subject,
|
|
501
|
+
ownerAppId: current.ownerAppId,
|
|
502
|
+
workloadType: current.workloadType,
|
|
503
|
+
});
|
|
504
|
+
if (!policy.ok) return policy;
|
|
505
|
+
if (input.authority.kind === "workload" && !isMandatePolicyNarrowing(current.policy, policy.data)) {
|
|
506
|
+
return fail(err.forbidden("Workloads may only narrow mandate policy"));
|
|
507
|
+
}
|
|
508
|
+
if (JSON.stringify(current.policy) === JSON.stringify(policy.data)) return ok(current);
|
|
509
|
+
const [row] = await db<MandateRow[]>`
|
|
510
|
+
UPDATE auth.mandates
|
|
511
|
+
SET policy = ${policy.data}::jsonb, revision = revision + 1, updated_at = now()
|
|
512
|
+
WHERE id = ${current.id}::uuid AND revision = ${input.expectedRevision}::bigint AND state <> 'revoked'
|
|
513
|
+
RETURNING *
|
|
514
|
+
`;
|
|
515
|
+
return ok(row ?? null);
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
export const updateMandatePolicy = (
|
|
520
|
+
input: Parameters<typeof updateMandatePolicyInDb>[0],
|
|
521
|
+
options: { db?: SQL } = {},
|
|
522
|
+
): Promise<Result<Mandate>> =>
|
|
523
|
+
auditedMutation(options, {
|
|
524
|
+
action: "mandate.policy.update",
|
|
525
|
+
authority: input.authority,
|
|
526
|
+
targetId: () => input.mandateId,
|
|
527
|
+
metadata: { expectedRevision: input.expectedRevision },
|
|
528
|
+
mutate: (db) => updateMandatePolicyInDb(input, db),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
/** Policy is the only mutable authority payload; lifecycle has explicit operations below. */
|
|
532
|
+
export const updateMandate = updateMandatePolicy;
|
|
533
|
+
|
|
534
|
+
const setMandateState = async (
|
|
535
|
+
input: { mandateId: string; expectedRevision: number; authority: MandateMutationAuthority },
|
|
536
|
+
state: "paused" | "active",
|
|
537
|
+
options: { db?: SQL } = {},
|
|
538
|
+
): Promise<Result<Mandate>> => {
|
|
539
|
+
const db = options.db ?? sql;
|
|
540
|
+
return changedRow(input.mandateId, input.expectedRevision, input.authority, db, async (current) => {
|
|
541
|
+
if (state === "active" && input.authority.kind === "workload") return fail(err.forbidden("Workloads cannot resume mandates"));
|
|
542
|
+
if (state === "active" && !(await validSubject(current.subject, db))) return ok(null);
|
|
543
|
+
if (current.state === state) return ok(current);
|
|
544
|
+
if (state === "paused" && current.state !== "active") return ok(null);
|
|
545
|
+
if (state === "active" && current.state !== "paused") return ok(null);
|
|
546
|
+
const [row] = await db<MandateRow[]>`
|
|
547
|
+
UPDATE auth.mandates
|
|
548
|
+
SET state = ${state}, revision = revision + 1, updated_at = now()
|
|
549
|
+
WHERE id = ${current.id}::uuid AND revision = ${input.expectedRevision}::bigint AND state = ${current.state}
|
|
550
|
+
RETURNING *
|
|
551
|
+
`;
|
|
552
|
+
return ok(row ?? null);
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
export const pauseMandate = (
|
|
557
|
+
input: { mandateId: string; expectedRevision: number; authority: MandateMutationAuthority },
|
|
558
|
+
options: { db?: SQL } = {},
|
|
559
|
+
): Promise<Result<Mandate>> =>
|
|
560
|
+
auditedMutation(options, {
|
|
561
|
+
action: "mandate.pause",
|
|
562
|
+
authority: input.authority,
|
|
563
|
+
targetId: () => input.mandateId,
|
|
564
|
+
metadata: { expectedRevision: input.expectedRevision },
|
|
565
|
+
mutate: (db) => setMandateState(input, "paused", { db }),
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
export const resumeMandate = (
|
|
569
|
+
input: { mandateId: string; expectedRevision: number; authority: MandateInteractiveAuthority | MandateAdminAuthority },
|
|
570
|
+
options: { db?: SQL } = {},
|
|
571
|
+
): Promise<Result<Mandate>> =>
|
|
572
|
+
auditedMutation(options, {
|
|
573
|
+
action: "mandate.resume",
|
|
574
|
+
authority: input.authority,
|
|
575
|
+
targetId: () => input.mandateId,
|
|
576
|
+
metadata: { expectedRevision: input.expectedRevision },
|
|
577
|
+
mutate: (db) => setMandateState(input, "active", { db }),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const revokeMandateInDb = async (
|
|
581
|
+
input: { mandateId: string; expectedRevision: number; authority: MandateMutationAuthority; reason: string },
|
|
582
|
+
db: SQL,
|
|
583
|
+
): Promise<Result<Mandate>> => {
|
|
584
|
+
const reason = input.reason.trim();
|
|
585
|
+
if (!reason || reason.length > 500) return fail(err.badInput("Mandate revocation reason must be between 1 and 500 characters"));
|
|
586
|
+
const existing = await load(input.mandateId, db);
|
|
587
|
+
if (!existing) return fail(err.notFound("Mandate"));
|
|
588
|
+
if (!mutationAccess(existing, input.authority)) return fail(err.forbidden("Cannot revoke this mandate"));
|
|
589
|
+
if (existing.state === "revoked") return ok(existing);
|
|
590
|
+
if (existing.revision !== input.expectedRevision) return fail(err.conflict("Mandate revision changed"));
|
|
591
|
+
const [row] = await db<MandateRow[]>`
|
|
592
|
+
UPDATE auth.mandates
|
|
593
|
+
SET state = 'revoked', revision = revision + 1, updated_at = now(), revoked_at = now(),
|
|
594
|
+
revoked_by_user_id = ${input.authority.kind === "interactive" || input.authority.kind === "admin" ? input.authority.userId : null}::uuid,
|
|
595
|
+
revoked_by_app_id = ${input.authority.kind === "workload" ? input.authority.ownerAppId : null},
|
|
596
|
+
revoke_reason = ${reason}
|
|
597
|
+
WHERE id = ${existing.id}::uuid AND revision = ${input.expectedRevision}::bigint AND state <> 'revoked'
|
|
598
|
+
RETURNING *
|
|
599
|
+
`;
|
|
600
|
+
return row ? ok(mapMandate(row)) : fail(err.conflict("Mandate changed; read it and retry"));
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
export const revokeMandate = (input: Parameters<typeof revokeMandateInDb>[0], options: { db?: SQL } = {}): Promise<Result<Mandate>> =>
|
|
604
|
+
auditedMutation(options, {
|
|
605
|
+
action: "mandate.revoke",
|
|
606
|
+
authority: input.authority,
|
|
607
|
+
targetId: () => input.mandateId,
|
|
608
|
+
metadata: { expectedRevision: input.expectedRevision },
|
|
609
|
+
mutate: (db) => revokeMandateInDb(input, db),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
export const validateMandateIssueAuthority = async (
|
|
613
|
+
input: {
|
|
614
|
+
mandateId: string;
|
|
615
|
+
ownerAppId: string;
|
|
616
|
+
targetAppId: string;
|
|
617
|
+
operation: string;
|
|
618
|
+
actionApproval?: "none" | "approved";
|
|
619
|
+
expectedRevision?: number;
|
|
620
|
+
requestId?: string;
|
|
621
|
+
},
|
|
622
|
+
options: { db?: SQL } = {},
|
|
623
|
+
): Promise<Result<MandateIssueAuthority>> => {
|
|
624
|
+
if (!UUID.safeParse(input.mandateId).success) {
|
|
625
|
+
return denyMandateIssue(err.forbidden("Mandate identifier is invalid"));
|
|
626
|
+
}
|
|
627
|
+
const owner = CapabilityAppIdSchema.safeParse(input.ownerAppId);
|
|
628
|
+
const target = CapabilityAppIdSchema.safeParse(input.targetAppId);
|
|
629
|
+
const operation = OperationSchema.safeParse(input.operation);
|
|
630
|
+
const expectedRevision = input.expectedRevision === undefined ? null : RevisionSchema.safeParse(input.expectedRevision);
|
|
631
|
+
const requestId = input.requestId === undefined ? null : RequestIdSchema.safeParse(input.requestId);
|
|
632
|
+
if (!owner.success || !target.success || !operation.success || expectedRevision?.success === false || requestId?.success === false) {
|
|
633
|
+
return denyMandateIssue(err.badInput("Invalid mandate invocation"));
|
|
634
|
+
}
|
|
635
|
+
const db = options.db ?? sql;
|
|
636
|
+
const [row] = await db<MandateCurrentRow[]>`
|
|
637
|
+
SELECT mandate.*,
|
|
638
|
+
(mandate.expires_at IS NULL OR mandate.expires_at > now()) AS not_expired,
|
|
639
|
+
CASE mandate.subject_kind
|
|
640
|
+
WHEN 'user' THEN user_account.id IS NOT NULL
|
|
641
|
+
AND (user_account.account_expires IS NULL OR user_account.account_expires > now())
|
|
642
|
+
WHEN 'service_account' THEN service_account.id IS NOT NULL
|
|
643
|
+
AND service_account.kind = 'resource_bound'
|
|
644
|
+
AND service_account.status = 'active'
|
|
645
|
+
ELSE false
|
|
646
|
+
END AS subject_active
|
|
647
|
+
FROM auth.mandates mandate
|
|
648
|
+
LEFT JOIN auth.users user_account ON user_account.id = mandate.subject_user_id
|
|
649
|
+
LEFT JOIN auth.service_accounts service_account ON service_account.id = mandate.subject_service_account_id
|
|
650
|
+
WHERE mandate.id = ${input.mandateId}::uuid
|
|
651
|
+
LIMIT 1
|
|
652
|
+
FOR SHARE OF mandate
|
|
653
|
+
`;
|
|
654
|
+
if (!row) return denyMandateIssue(err.forbidden("Mandate is unavailable"));
|
|
655
|
+
if (!row.subject_active || !(await validSubject(subjectFromRow(row), db)))
|
|
656
|
+
return denyMandateIssue(err.forbidden("Mandate subject is unavailable"));
|
|
657
|
+
if (row.owner_app_id !== owner.data) {
|
|
658
|
+
return denyMandateIssue(err.forbidden("Mandate owner does not match"));
|
|
659
|
+
}
|
|
660
|
+
if (row.state !== "active" || row.confirmed_at === null || !row.not_expired) {
|
|
661
|
+
return denyMandateIssue(err.forbidden("Mandate is not active"));
|
|
662
|
+
}
|
|
663
|
+
if (input.expectedRevision !== undefined && Number(row.revision) !== input.expectedRevision) {
|
|
664
|
+
return denyMandateIssue(err.conflict("Mandate revision changed"));
|
|
665
|
+
}
|
|
666
|
+
let policy: MandatePolicyV1;
|
|
667
|
+
try {
|
|
668
|
+
policy = parseMandatePolicy(parsePgJsonValue(row.policy));
|
|
669
|
+
} catch {
|
|
670
|
+
return denyMandateIssue(err.forbidden("Mandate policy is invalid"));
|
|
671
|
+
}
|
|
672
|
+
if (!mandatePolicyAllows(policy, { appId: target.data, operation: operation.data, actionApproval: input.actionApproval ?? "none" })) {
|
|
673
|
+
return denyMandateIssue(err.forbidden("Mandate policy does not allow this operation"));
|
|
674
|
+
}
|
|
675
|
+
const mandate = mapMandate(row);
|
|
676
|
+
return ok({
|
|
677
|
+
mandateId: mandate.id,
|
|
678
|
+
mandateRevision: mandate.revision,
|
|
679
|
+
subject: mandate.subject,
|
|
680
|
+
ownerAppId: mandate.ownerAppId,
|
|
681
|
+
workloadType: mandate.workloadType,
|
|
682
|
+
workloadId: mandate.workloadId,
|
|
683
|
+
targetAppId: target.data,
|
|
684
|
+
operation: operation.data,
|
|
685
|
+
policy,
|
|
686
|
+
});
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const metricForIssueDenial = (error: MandateIssueError): Exclude<MandateMetricName, "issue_allowed"> => {
|
|
690
|
+
switch (error.message) {
|
|
691
|
+
case "Mandate is unavailable":
|
|
692
|
+
return "issue_denied_unavailable";
|
|
693
|
+
case "Mandate subject is unavailable":
|
|
694
|
+
return "issue_denied_subject";
|
|
695
|
+
case "Mandate owner does not match":
|
|
696
|
+
return "issue_denied_owner";
|
|
697
|
+
case "Mandate is not active":
|
|
698
|
+
return "issue_denied_inactive";
|
|
699
|
+
case "Mandate revision changed":
|
|
700
|
+
return "issue_denied_revision";
|
|
701
|
+
case "Mandate policy is invalid":
|
|
702
|
+
case "Mandate policy does not allow this operation":
|
|
703
|
+
return "issue_denied_policy";
|
|
704
|
+
default:
|
|
705
|
+
return "issue_denied_invalid";
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const issueAuditParams = <T>(
|
|
710
|
+
input: Parameters<typeof validateMandateIssueAuthority>[0],
|
|
711
|
+
result: Result<T>,
|
|
712
|
+
mandateRevision: number | null,
|
|
713
|
+
db: SQL,
|
|
714
|
+
) => {
|
|
715
|
+
const requestId = input.requestId === undefined ? null : RequestIdSchema.safeParse(input.requestId);
|
|
716
|
+
return {
|
|
717
|
+
action: "mandate.issue",
|
|
718
|
+
target: { type: "mandate", id: input.mandateId.slice(0, 36) },
|
|
719
|
+
requestId: requestId?.success ? requestId.data : null,
|
|
720
|
+
metadata: {
|
|
721
|
+
ownerAppId: input.ownerAppId.slice(0, 80),
|
|
722
|
+
targetAppId: input.targetAppId.slice(0, 80),
|
|
723
|
+
operation: input.operation.slice(0, 180),
|
|
724
|
+
revision: mandateRevision,
|
|
725
|
+
...(requestId?.success ? { requestId: requestId.data } : {}),
|
|
726
|
+
provenance: "background-broker",
|
|
727
|
+
},
|
|
728
|
+
result,
|
|
729
|
+
db,
|
|
730
|
+
};
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Holds the mandate row through issuance so a concurrent pause or revoke
|
|
735
|
+
* cannot commit between validation and the caller's in-memory signing step.
|
|
736
|
+
*/
|
|
737
|
+
export const withMandateIssueAuthority = <T>(
|
|
738
|
+
input: Parameters<typeof validateMandateIssueAuthority>[0],
|
|
739
|
+
use: (authority: MandateIssueAuthority) => Promise<T>,
|
|
740
|
+
options: { db?: SQL } = {},
|
|
741
|
+
): Promise<Result<T>> => {
|
|
742
|
+
const run = async (db: SQL): Promise<Result<T>> => {
|
|
743
|
+
const authority = await validateMandateIssueAuthority(input, { db });
|
|
744
|
+
if (!authority.ok) {
|
|
745
|
+
mandateMetrics.increment(metricForIssueDenial(authority.error));
|
|
746
|
+
return audit.recordResult(issueAuditParams(input, authority, input.expectedRevision ?? null, db));
|
|
747
|
+
}
|
|
748
|
+
let issued: Result<T>;
|
|
749
|
+
let callbackFailed = false;
|
|
750
|
+
try {
|
|
751
|
+
issued = ok(await use(authority.data));
|
|
752
|
+
} catch {
|
|
753
|
+
callbackFailed = true;
|
|
754
|
+
issued = fail(err.internal("Mandate invocation issuance failed"));
|
|
755
|
+
}
|
|
756
|
+
const audited = await audit.recordResult(issueAuditParams(input, issued, authority.data.mandateRevision, db));
|
|
757
|
+
mandateMetrics.increment(callbackFailed ? "issue_failed_internal" : "issue_allowed");
|
|
758
|
+
return audited;
|
|
759
|
+
};
|
|
760
|
+
// Return failures as data so an enclosing key-lock transaction can commit
|
|
761
|
+
// the failure audit before its caller maps the Result to an HTTP error.
|
|
762
|
+
return options.db ? run(options.db) : sql.begin(run);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const ORPHAN_RECONCILE_LIMIT = 100;
|
|
766
|
+
const ORPHAN_RECONCILE_INTERVAL_MS = 60_000;
|
|
767
|
+
const log = logger("mandates");
|
|
768
|
+
let mandateMaintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
|
769
|
+
let mandateMaintenanceRunning = false;
|
|
770
|
+
|
|
771
|
+
/** Revokes a bounded batch of remote mandates whose owner never confirmed persistence. */
|
|
772
|
+
export const reconcileUnconfirmedMandates = async (options: { db?: SQL; limit?: number } = {}): Promise<number> => {
|
|
773
|
+
const limit = Math.max(1, Math.min(ORPHAN_RECONCILE_LIMIT, Math.floor(options.limit ?? ORPHAN_RECONCILE_LIMIT)));
|
|
774
|
+
const run = async (db: SQL): Promise<number> => {
|
|
775
|
+
const rows = await db<MandateRow[]>`
|
|
776
|
+
WITH candidates AS (
|
|
777
|
+
SELECT id
|
|
778
|
+
FROM auth.mandates
|
|
779
|
+
WHERE state IN ('active', 'paused')
|
|
780
|
+
AND confirmed_at IS NULL
|
|
781
|
+
AND confirmation_deadline <= now()
|
|
782
|
+
ORDER BY confirmation_deadline, id
|
|
783
|
+
LIMIT ${limit}
|
|
784
|
+
FOR UPDATE SKIP LOCKED
|
|
785
|
+
)
|
|
786
|
+
UPDATE auth.mandates mandate
|
|
787
|
+
SET state = 'revoked',
|
|
788
|
+
revision = mandate.revision + 1,
|
|
789
|
+
updated_at = now(),
|
|
790
|
+
revoked_at = now(),
|
|
791
|
+
revoke_reason = 'Workload registration was not confirmed'
|
|
792
|
+
FROM candidates
|
|
793
|
+
WHERE mandate.id = candidates.id
|
|
794
|
+
RETURNING mandate.*
|
|
795
|
+
`;
|
|
796
|
+
for (const row of rows) {
|
|
797
|
+
await audit.record(
|
|
798
|
+
{
|
|
799
|
+
action: "mandate.orphan.revoke",
|
|
800
|
+
outcome: "allowed",
|
|
801
|
+
target: { type: "mandate", id: row.id },
|
|
802
|
+
reason: "Workload registration was not confirmed",
|
|
803
|
+
metadata: {
|
|
804
|
+
ownerAppId: row.owner_app_id,
|
|
805
|
+
workloadType: row.workload_type,
|
|
806
|
+
workloadId: row.workload_id,
|
|
807
|
+
revision: Number(row.revision),
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
db,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return rows.length;
|
|
814
|
+
};
|
|
815
|
+
return options.db ? run(options.db) : sql.begin(run);
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
export const startMandateMaintenance = (): (() => void) => {
|
|
819
|
+
if (mandateMaintenanceTimer) return () => undefined;
|
|
820
|
+
const reconcile = async (): Promise<void> => {
|
|
821
|
+
if (mandateMaintenanceRunning) return;
|
|
822
|
+
mandateMaintenanceRunning = true;
|
|
823
|
+
try {
|
|
824
|
+
await reconcileUnconfirmedMandates();
|
|
825
|
+
} catch (error) {
|
|
826
|
+
log.error("Mandate orphan reconciliation failed", { error: error instanceof Error ? error.message : String(error) });
|
|
827
|
+
} finally {
|
|
828
|
+
mandateMaintenanceRunning = false;
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
void reconcile();
|
|
832
|
+
mandateMaintenanceTimer = setInterval(() => void reconcile(), ORPHAN_RECONCILE_INTERVAL_MS);
|
|
833
|
+
mandateMaintenanceTimer.unref?.();
|
|
834
|
+
return () => {
|
|
835
|
+
if (mandateMaintenanceTimer) clearInterval(mandateMaintenanceTimer);
|
|
836
|
+
mandateMaintenanceTimer = null;
|
|
837
|
+
};
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
export const mandates = {
|
|
841
|
+
create: createMandate,
|
|
842
|
+
createPending: createPendingMandate,
|
|
843
|
+
get: getMandate,
|
|
844
|
+
list: listMandates,
|
|
845
|
+
confirm: confirmMandate,
|
|
846
|
+
update: updateMandate,
|
|
847
|
+
updatePolicy: updateMandatePolicy,
|
|
848
|
+
pause: pauseMandate,
|
|
849
|
+
resume: resumeMandate,
|
|
850
|
+
revoke: revokeMandate,
|
|
851
|
+
validateIssueAuthority: validateMandateIssueAuthority,
|
|
852
|
+
withIssueAuthority: withMandateIssueAuthority,
|
|
853
|
+
reconcileUnconfirmed: reconcileUnconfirmedMandates,
|
|
854
|
+
metrics: mandateMetrics,
|
|
855
|
+
} as const;
|