@k2b/cloud 0.6.0 → 0.8.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 +22 -11
- package/scripts/README.md +10 -0
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +20 -35
- package/scripts/preload.ts +16 -12
- package/scripts/sync-recovery-smoke.ts +22 -6
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/build-metadata.ts +8 -1
- package/src/_internal/capabilities.ts +131 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +80 -37
- 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/readiness.ts +4 -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 +18 -151
- package/src/access/PrincipalPicker.tsx +110 -0
- package/src/access/ResourceApiKeys.tsx +1 -1
- package/src/access/ui.ts +1 -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 +201 -0
- package/src/ai/browser.ts +4 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +229 -90
- package/src/ai/chat/builtin-tools.tsx +2 -4
- 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 +83 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +25 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +31 -47
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +21 -2
- package/src/ai/chat/tool-groups.ts +58 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +14 -10
- package/src/ai/chat-tasks.ts +297 -43
- package/src/ai/client/controller.ts +165 -39
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +29 -11
- package/src/ai/client/transport.ts +6 -0
- package/src/ai/code-capability-routes.ts +105 -0
- package/src/ai/code-capability-transport.ts +39 -0
- package/src/ai/code-execution.ts +53 -0
- package/src/ai/code-mode-skill.ts +118 -0
- package/src/ai/code-runtime-tools.ts +179 -0
- package/src/ai/code-source-contracts.ts +270 -0
- package/src/ai/code-source-tools.ts +188 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +99 -3
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +49 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +189 -33
- package/src/ai/fetch-file-tool.ts +2 -2
- package/src/ai/file-content-version.ts +27 -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 +8 -1
- package/src/ai/files-store.ts +212 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +42 -10
- package/src/ai/http.ts +26 -8
- package/src/ai/index.ts +26 -3
- package/src/ai/inference-calls.ts +197 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/live.ts +1 -1
- package/src/ai/markdown-pdf-tool.ts +2 -2
- package/src/ai/memories.ts +30 -27
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +64 -32
- package/src/ai/memory-tool.ts +1 -5
- package/src/ai/memory-workflow-evidence.ts +9 -4
- package/src/ai/message-queue.ts +178 -0
- package/src/ai/migrate.ts +189 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +75 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +18 -43
- package/src/ai/projects.ts +33 -14
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +192 -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 +241 -11
- package/src/ai/run-timeout.ts +12 -0
- package/src/ai/runtime-tools.ts +7 -5
- package/src/ai/runtime.ts +74 -17
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +4 -5
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +58 -0
- package/src/ai/skill-seeds.ts +114 -20
- package/src/ai/skill-tool.ts +21 -5
- package/src/ai/skills-routes.ts +49 -4
- package/src/ai/skills.ts +358 -163
- package/src/ai/store.ts +416 -201
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +12 -1
- package/src/ai/system-prompt.ts +14 -2
- package/src/ai/task-contracts.ts +84 -0
- package/src/ai/task-execution.ts +92 -0
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +32 -0
- package/src/ai/todo-tool.ts +19 -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 +3 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +60 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +80 -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-linux-identities.ts +10 -4
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/app-approval.ts +2 -2
- package/src/api/auth.ts +1 -1
- package/src/api/capabilities.ts +324 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +8 -2
- package/src/api/mcp.ts +45 -34
- 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 +67 -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 +6 -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 +116 -35
- 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 +77 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +10 -1
- package/src/cli/admin/instance.ts +2 -2
- package/src/cli/admin/linux.ts +4 -2
- package/src/cli/admin/sync.ts +1 -1
- package/src/cli/capabilities.ts +42 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/define-env.ts +97 -0
- package/src/config/env.ts +147 -51
- package/src/config/index.ts +3 -1
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +75 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +5 -2
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +5 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +18 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/index.ts +1 -0
- package/src/desktop/solid.tsx +1 -1
- package/src/index.ts +8 -6
- 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/ratelimit.ts +1 -1
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +3 -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/notification-sender.ts +12 -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/app-approval.ts +11 -10
- 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 +8 -5
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/identity/runtime-config.ts +6 -5
- package/src/services/index.ts +15 -0
- package/src/services/ipa/posix.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- 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 +21 -4
- package/src/services/mandates/policy.ts +165 -2
- package/src/services/notifications/batches.ts +125 -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 +131 -72
- package/src/services/pdf/index.ts +11 -8
- 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 +29 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/app.ts +5 -1
- package/src/services/settings/core-settings.ts +26 -35
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/index.ts +3 -3
- package/src/services/settings/store.ts +58 -84
- package/src/services/webauthn.ts +1 -1
- 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 +3 -0
- package/src/shared/markdown/extensions/info-blocks.ts +1 -1
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +30 -16
- package/src/ssr/AppLaunchpad.island.tsx +33 -136
- 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 +22 -23
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MinimalLayout.tsx +1 -1
- 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 +11 -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/index.ts +2 -2
- package/src/workflows/ai/runtime.ts +19 -90
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai/types.ts +6 -82
- 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 +4 -2
- 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/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/server/services/freeipa/test-certificates.ts +0 -44
- package/src/services/notifications/live.ts +0 -50
- package/src/services/session/test-fixture.ts +0 -18
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { MutationResult, UserProfile } from "../../../contracts/shared";
|
|
3
3
|
import { isAccountCategoryAllowed } from "../../account-category-policy";
|
|
4
4
|
import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
|
|
5
|
+
import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../../accounts/email-write";
|
|
5
6
|
import { resolveStoredAdminState } from "../../accounts/model";
|
|
6
7
|
import { writeLocalAccount } from "../../accounts/posix";
|
|
7
8
|
import type { AuditActor } from "../../audit";
|
|
@@ -31,6 +32,7 @@ export const create = async (params: {
|
|
|
31
32
|
admin?: boolean;
|
|
32
33
|
actor?: AuditActor;
|
|
33
34
|
}): Promise<MutationResult<{ id: string }>> => {
|
|
35
|
+
const email = normalizeAccountEmail(params.data.email);
|
|
34
36
|
const uid = await createLocalUid();
|
|
35
37
|
const admin = resolveStoredAdminState({
|
|
36
38
|
provider: "local",
|
|
@@ -42,6 +44,8 @@ export const create = async (params: {
|
|
|
42
44
|
return await writeLocalAccount(async (tx) => {
|
|
43
45
|
if (!(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
|
|
44
46
|
return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
|
|
47
|
+
await lockAccountEmail(tx, email);
|
|
48
|
+
if ((await findAccountsByEmail(tx, email)).length > 0) return emailAlreadyUsed();
|
|
45
49
|
const rows = await tx<{ id: string }[]>`
|
|
46
50
|
INSERT INTO auth.users (
|
|
47
51
|
uid,
|
|
@@ -58,7 +62,7 @@ export const create = async (params: {
|
|
|
58
62
|
${uid},
|
|
59
63
|
'local',
|
|
60
64
|
${params.profile},
|
|
61
|
-
${
|
|
65
|
+
${email},
|
|
62
66
|
${params.data.givenname ?? ""},
|
|
63
67
|
${params.data.sn ?? ""},
|
|
64
68
|
${params.data.displayName ?? ""},
|
|
@@ -118,21 +122,26 @@ export const update = async (params: {
|
|
|
118
122
|
mail?: string;
|
|
119
123
|
};
|
|
120
124
|
}): Promise<MutationResult<void>> => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
125
|
+
return sql.begin(async (tx) => {
|
|
126
|
+
if (params.data.mail !== undefined) await lockAccountEmail(tx, params.data.mail);
|
|
127
|
+
else await tx`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
|
|
128
|
+
const [existing] = await tx<{ mail: string | null }[]>`
|
|
129
|
+
SELECT mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
|
|
130
|
+
`;
|
|
131
|
+
if (!existing) return { ok: false, error: "Local user not found", status: 404 };
|
|
132
|
+
const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail);
|
|
133
|
+
const changed = email !== undefined && email !== normalizeAccountEmail(existing.mail ?? "");
|
|
134
|
+
if (changed && email && (await findAccountsByEmail(tx, email)).some((row) => row.id !== params.id)) return emailAlreadyUsed();
|
|
135
|
+
await tx`
|
|
136
|
+
UPDATE auth.users
|
|
137
|
+
SET given_name = CASE WHEN ${params.data.givenname !== undefined} THEN ${params.data.givenname ?? ""} ELSE given_name END,
|
|
138
|
+
sn = CASE WHEN ${params.data.sn !== undefined} THEN ${params.data.sn ?? ""} ELSE sn END,
|
|
139
|
+
display_name = CASE WHEN ${params.data.displayName !== undefined} THEN ${params.data.displayName ?? ""} ELSE display_name END,
|
|
140
|
+
mail = CASE WHEN ${changed} THEN ${email || null} ELSE mail END
|
|
141
|
+
WHERE id = ${params.id}::uuid
|
|
142
|
+
`;
|
|
143
|
+
return { ok: true, data: undefined };
|
|
144
|
+
});
|
|
136
145
|
};
|
|
137
146
|
|
|
138
147
|
export const setProfile = async (params: {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ClientRequest, IncomingMessage } from "node:http";
|
|
2
|
+
import { type RequestOptions, request } from "node:https";
|
|
3
|
+
import { resolvePublicNetworkAddresses } from "./network-security";
|
|
4
|
+
|
|
5
|
+
export type PublicHttpInput = {
|
|
6
|
+
url: string;
|
|
7
|
+
method: string;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
body?: Uint8Array;
|
|
10
|
+
maxBytes: number;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** One public HTTPS request, pinned to a validated address. Never redirects or retries.
|
|
15
|
+
* Callers own credential authorization, request budgets and response disclosure. */
|
|
16
|
+
export async function requestPublicHttps(
|
|
17
|
+
input: PublicHttpInput,
|
|
18
|
+
dependencies: {
|
|
19
|
+
resolve: typeof resolvePublicNetworkAddresses;
|
|
20
|
+
request: (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
|
21
|
+
} = { resolve: resolvePublicNetworkAddresses, request },
|
|
22
|
+
) {
|
|
23
|
+
if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 0) throw new Error("INVALID_BYTE_LIMIT");
|
|
24
|
+
const url = new URL(input.url);
|
|
25
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("HTTPS_REQUIRED");
|
|
26
|
+
input.signal.throwIfAborted();
|
|
27
|
+
let cancelLookup = () => {};
|
|
28
|
+
const addresses = await Promise.race([
|
|
29
|
+
dependencies.resolve(url.hostname),
|
|
30
|
+
new Promise<never>((_, reject) => {
|
|
31
|
+
cancelLookup = () => reject(new Error("HTTP_CANCELLED"));
|
|
32
|
+
input.signal.addEventListener("abort", cancelLookup, { once: true });
|
|
33
|
+
if (input.signal.aborted) cancelLookup();
|
|
34
|
+
}),
|
|
35
|
+
]).finally(() => input.signal.removeEventListener("abort", cancelLookup));
|
|
36
|
+
input.signal.throwIfAborted();
|
|
37
|
+
const target = addresses[0]!;
|
|
38
|
+
return new Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>((resolve, reject) => {
|
|
39
|
+
let settled = false;
|
|
40
|
+
const fail = () => finish(undefined);
|
|
41
|
+
const finish = (value?: { status: number; headers: Record<string, string>; body: Uint8Array }) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
settled = true;
|
|
44
|
+
input.signal.removeEventListener("abort", abort);
|
|
45
|
+
if (value) resolve(value);
|
|
46
|
+
else reject(new Error("HTTP_FAILED"));
|
|
47
|
+
};
|
|
48
|
+
const outgoing = dependencies.request(
|
|
49
|
+
{
|
|
50
|
+
hostname: target.address,
|
|
51
|
+
family: target.family,
|
|
52
|
+
port: url.port || 443,
|
|
53
|
+
servername: url.hostname.replace(/^\[|\]$/g, ""),
|
|
54
|
+
rejectUnauthorized: true,
|
|
55
|
+
path: url.pathname + url.search,
|
|
56
|
+
method: input.method,
|
|
57
|
+
headers: { ...input.headers, host: url.host, "accept-encoding": "identity" },
|
|
58
|
+
},
|
|
59
|
+
(incoming) => {
|
|
60
|
+
const status = incoming.statusCode ?? 0;
|
|
61
|
+
const headers: Record<string, string> = {};
|
|
62
|
+
for (const name of ["content-type", "retry-after", "etag", "last-modified"]) {
|
|
63
|
+
const value = incoming.headers[name];
|
|
64
|
+
if (typeof value === "string") headers[name] = value;
|
|
65
|
+
}
|
|
66
|
+
// A redirect never receives a second request or exposes a Location token.
|
|
67
|
+
if (status >= 300 && status < 400 && status !== 304) {
|
|
68
|
+
incoming.destroy();
|
|
69
|
+
finish({ status, headers, body: new Uint8Array() });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
|
|
73
|
+
incoming.destroy();
|
|
74
|
+
fail();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const chunks: Uint8Array[] = [];
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
incoming.on("data", (chunk: Buffer) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
bytes += chunk.byteLength;
|
|
82
|
+
if (bytes > input.maxBytes) {
|
|
83
|
+
incoming.destroy();
|
|
84
|
+
fail();
|
|
85
|
+
} else chunks.push(chunk);
|
|
86
|
+
});
|
|
87
|
+
incoming.on("end", () => finish({ status, headers, body: Buffer.concat(chunks, bytes) }));
|
|
88
|
+
incoming.on("error", fail);
|
|
89
|
+
incoming.on("aborted", fail);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
const abort = () => {
|
|
93
|
+
outgoing.destroy();
|
|
94
|
+
fail();
|
|
95
|
+
};
|
|
96
|
+
outgoing.on("error", fail); // Never expose errors containing injected headers.
|
|
97
|
+
input.signal.addEventListener("abort", abort, { once: true });
|
|
98
|
+
if (input.signal.aborted) abort();
|
|
99
|
+
else outgoing.end(input.body);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { type RailAdminInput, RailAdminInputSchema, RailAdminSchema, type RailAdminState } from "../contracts/rail-admin";
|
|
3
|
+
import { RailShortcutSchema } from "../contracts/rail-preferences";
|
|
4
|
+
import { hasRole, type User } from "../contracts/shared";
|
|
5
|
+
import { buildAccessPrincipalCondition, createAccess } from "../server/services/access";
|
|
6
|
+
|
|
7
|
+
export class RailAdminError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public status: 400 | 403 | 409,
|
|
10
|
+
message: string,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const requireAdmin = (actor: User | undefined) => {
|
|
16
|
+
if (!actor || !hasRole(actor, "admin")) throw new RailAdminError(403, "Administrator access required");
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const createRailShortcutsService = (db: SQL = sql) => {
|
|
20
|
+
const list = async (query: SQL = db): Promise<RailAdminState> => {
|
|
21
|
+
const [state] = await query<{ revision: number }[]>`SELECT revision FROM auth.rail_state WHERE singleton`;
|
|
22
|
+
const rows = await query<{ shortcut: unknown; access: unknown }[]>`
|
|
23
|
+
SELECT r.shortcut, COALESCE((
|
|
24
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
25
|
+
'id', a.id, 'permission', a.permission, 'createdAt', a.created_at,
|
|
26
|
+
'principal', CASE WHEN a.user_id IS NOT NULL THEN jsonb_build_object('type', 'user', 'userId', a.user_id)
|
|
27
|
+
WHEN a.group_id IS NOT NULL THEN jsonb_build_object('type', 'group', 'groupId', a.group_id)
|
|
28
|
+
ELSE jsonb_build_object('type', 'authenticated') END,
|
|
29
|
+
'displayName', COALESCE(u.display_name, g.name, '')
|
|
30
|
+
) ORDER BY a.created_at, a.id)
|
|
31
|
+
FROM auth.rail_shortcut_access ra JOIN auth.access a ON a.id = ra.access_id
|
|
32
|
+
LEFT JOIN auth.users u ON u.id = a.user_id LEFT JOIN auth.groups g ON g.id = a.group_id
|
|
33
|
+
WHERE ra.shortcut_id = r.id
|
|
34
|
+
), '[]'::jsonb) AS access
|
|
35
|
+
FROM auth.rail_shortcuts r ORDER BY r.position, r.id
|
|
36
|
+
`;
|
|
37
|
+
return RailAdminSchema.parse({ revision: state?.revision ?? 0, entries: rows });
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
async invalidateCache(actor: User | undefined) {
|
|
41
|
+
requireAdmin(actor);
|
|
42
|
+
// One generation switch invalidates every user, including concurrent old refills.
|
|
43
|
+
// Keep the configuration revision unchanged so open editors remain valid.
|
|
44
|
+
await db`UPDATE auth.rail_state SET cache_version = gen_random_uuid() WHERE singleton`;
|
|
45
|
+
},
|
|
46
|
+
async list(actor: User | undefined) {
|
|
47
|
+
requireAdmin(actor);
|
|
48
|
+
return list();
|
|
49
|
+
},
|
|
50
|
+
async save(actor: User | undefined, input: RailAdminInput) {
|
|
51
|
+
requireAdmin(actor);
|
|
52
|
+
const value = RailAdminInputSchema.parse(input);
|
|
53
|
+
return db.begin(async (tx) => {
|
|
54
|
+
const [state] = await tx<{ revision: number }[]>`SELECT revision FROM auth.rail_state WHERE singleton FOR UPDATE`;
|
|
55
|
+
if (state?.revision !== value.revision) throw new RailAdminError(409, "App bar configuration changed. Reload before saving.");
|
|
56
|
+
await tx`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM auth.rail_shortcut_access)`;
|
|
57
|
+
await tx`DELETE FROM auth.rail_shortcuts`;
|
|
58
|
+
for (const [position, entry] of value.entries.entries()) {
|
|
59
|
+
await tx`INSERT INTO auth.rail_shortcuts(id, position, shortcut)
|
|
60
|
+
VALUES (${entry.shortcut.id}, ${position}, (${JSON.stringify(entry.shortcut)}::text)::jsonb)`;
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
for (const access of entry.access) {
|
|
63
|
+
const key = JSON.stringify(access.principal);
|
|
64
|
+
if (seen.has(key)) continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
const created = await createAccess({ principal: access.principal, permission: "read" }, tx);
|
|
67
|
+
if (!created.ok) throw new RailAdminError(400, "A selected user or group no longer exists.");
|
|
68
|
+
await tx`INSERT INTO auth.rail_shortcut_access(shortcut_id, access_id) VALUES (${entry.shortcut.id}, ${created.data.id}::uuid)`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await tx`UPDATE auth.rail_state SET revision = revision + 1 WHERE singleton`;
|
|
72
|
+
return list(tx);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async forUser(userId: string) {
|
|
76
|
+
const match = buildAccessPrincipalCondition({
|
|
77
|
+
subject: { type: "user", userId },
|
|
78
|
+
columns: {
|
|
79
|
+
userId: db`a.user_id`,
|
|
80
|
+
groupId: db`a.group_id`,
|
|
81
|
+
serviceAccountId: db`a.service_account_id`,
|
|
82
|
+
authenticatedOnly: db`a.authenticated_only`,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const rows = await db<{ shortcut: unknown }[]>`
|
|
86
|
+
SELECT r.shortcut FROM auth.rail_shortcuts r WHERE EXISTS (
|
|
87
|
+
SELECT 1 FROM auth.rail_shortcut_access ra JOIN auth.access a ON a.id = ra.access_id
|
|
88
|
+
WHERE ra.shortcut_id = r.id AND a.permission = 'read' AND ${match}
|
|
89
|
+
) ORDER BY r.position, r.id
|
|
90
|
+
`;
|
|
91
|
+
return rows.map((row) => RailShortcutSchema.parse(row.shortcut));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
export const railShortcuts = createRailShortcutsService();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type RailSnapshot, RailSnapshotSchema } from "../contracts/rail-preferences";
|
|
2
|
+
import type { User } from "../contracts/shared";
|
|
3
|
+
import { railPreferences } from "./rail-preferences";
|
|
4
|
+
import { railShortcuts } from "./rail-shortcuts";
|
|
5
|
+
import { requestCacheRedis } from "./request-cache-redis";
|
|
6
|
+
|
|
7
|
+
// Private request metadata: never serialized into the public User contract.
|
|
8
|
+
const versions = new WeakMap<User, string>();
|
|
9
|
+
export const setRailCacheVersion = (user: User, version: unknown) => {
|
|
10
|
+
if (typeof version === "string") versions.set(user, version);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** TTL only bounds unused generations; freshness comes from the current identity query. */
|
|
14
|
+
const CACHE_TTL_SECONDS = 300;
|
|
15
|
+
export const createRailSnapshotReader =
|
|
16
|
+
(
|
|
17
|
+
load: (userId: string) => Promise<RailSnapshot>,
|
|
18
|
+
cache: { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<unknown> },
|
|
19
|
+
) =>
|
|
20
|
+
async (user: User): Promise<RailSnapshot> => {
|
|
21
|
+
const version = versions.get(user);
|
|
22
|
+
if (!version) return load(user.id);
|
|
23
|
+
const key = `appglobalcache:user:${user.id}:rail:v1:${version}`;
|
|
24
|
+
try {
|
|
25
|
+
const cached = await cache.get(key);
|
|
26
|
+
if (cached !== null) {
|
|
27
|
+
const parsed = RailSnapshotSchema.safeParse(JSON.parse(cached));
|
|
28
|
+
if (parsed.success) return parsed.data;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
/* Valkey is an optimization; authoritative reads still work. */
|
|
32
|
+
}
|
|
33
|
+
const value = await load(user.id);
|
|
34
|
+
// A concurrent mutation changes the generation. A late refill remains under
|
|
35
|
+
// this request's old key and cannot poison a subsequent request's snapshot.
|
|
36
|
+
try {
|
|
37
|
+
await cache.set(key, JSON.stringify(value));
|
|
38
|
+
} catch {
|
|
39
|
+
/* Retry on a later read. */
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const readRailSnapshot = createRailSnapshotReader(
|
|
45
|
+
async (userId) => {
|
|
46
|
+
const [personal, managedShortcuts] = await Promise.all([railPreferences.get(userId), railShortcuts.forUser(userId)]);
|
|
47
|
+
return { ...personal, managedShortcuts };
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
get: async (key) => (await requestCacheRedis()).get(key),
|
|
51
|
+
set: async (key, value) => (await requestCacheRedis()).set(key, value, "EX", CACHE_TTL_SECONDS),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RedisClient } from "bun";
|
|
2
|
+
import { env } from "../config/env";
|
|
3
|
+
|
|
4
|
+
// Cache commands must not queue across a disconnect: delayed fills/invalidations
|
|
5
|
+
// are not useful, and reads must be able to fall back to Postgres. Keep this
|
|
6
|
+
// connection separate from sessions, rate limits and other Redis consumers.
|
|
7
|
+
let client: RedisClient | undefined;
|
|
8
|
+
let initialConnection: Promise<void> | undefined;
|
|
9
|
+
let reconnecting: Promise<void> | undefined;
|
|
10
|
+
|
|
11
|
+
export const requestCacheRedis = async (): Promise<RedisClient> => {
|
|
12
|
+
if (!client) {
|
|
13
|
+
client = new RedisClient(env.REDIS_URL, { enableOfflineQueue: false });
|
|
14
|
+
// The first operation waits for normal connection establishment (Bun's
|
|
15
|
+
// connection timeout applies). Later disconnected operations fail promptly.
|
|
16
|
+
initialConnection = client.connect().catch(() => {});
|
|
17
|
+
}
|
|
18
|
+
await initialConnection;
|
|
19
|
+
if (!client.connected && !reconnecting) {
|
|
20
|
+
// Bun reconnects automatically; also allow recovery after its retry limit.
|
|
21
|
+
reconnecting = client
|
|
22
|
+
.connect()
|
|
23
|
+
.catch(() => {})
|
|
24
|
+
.finally(() => {
|
|
25
|
+
reconnecting = undefined;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return client;
|
|
29
|
+
};
|
|
@@ -11,7 +11,7 @@ import { invalidateIdentitySignerCache, prepareIdentitySigner } from "../identit
|
|
|
11
11
|
import { getIdentityRuntimeConfig } from "../identity/runtime-config";
|
|
12
12
|
import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
|
|
13
13
|
import * as settings from "../settings";
|
|
14
|
-
import { loadJwtSessionUser } from "./user";
|
|
14
|
+
import { loadJwtSessionIdentity, loadJwtSessionUser } from "./user";
|
|
15
15
|
|
|
16
16
|
export type SessionData = {
|
|
17
17
|
userId: string;
|
|
@@ -68,7 +68,6 @@ const authenticateVerified = async (credential: CloudSessionClaims): Promise<Aut
|
|
|
68
68
|
groupsAdmin,
|
|
69
69
|
});
|
|
70
70
|
if (!user) return null;
|
|
71
|
-
if (!(await isAccountCategoryAllowed(user))) return null;
|
|
72
71
|
if (isAccountExpired(user.accountExpires)) {
|
|
73
72
|
await session.revokeAllForUser(user.id);
|
|
74
73
|
return null;
|
|
@@ -89,6 +88,18 @@ const authenticateToken = async (token: string): Promise<AuthenticatedSession |
|
|
|
89
88
|
return credential ? authenticateVerified(credential) : null;
|
|
90
89
|
};
|
|
91
90
|
|
|
91
|
+
const authenticateUserId = async (token: string): Promise<string | null> => {
|
|
92
|
+
const credential = await verifyCredential(token);
|
|
93
|
+
if (!credential) return null;
|
|
94
|
+
const identity = await loadJwtSessionIdentity({ userId: credential.sub, sid: credential.sid, authEpoch: credential.auth_epoch });
|
|
95
|
+
if (!identity) return null;
|
|
96
|
+
if (isAccountExpired(identity.accountExpires)) {
|
|
97
|
+
await session.revokeAllForUser(identity.userId);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return identity.userId;
|
|
101
|
+
};
|
|
102
|
+
|
|
92
103
|
const revokeToken = async (token: string, requestId?: string | null): Promise<void> => {
|
|
93
104
|
if (isSessionJwtCandidate(token)) {
|
|
94
105
|
const claims = await verifySessionToken(token);
|
|
@@ -222,6 +233,8 @@ export const session = {
|
|
|
222
233
|
},
|
|
223
234
|
|
|
224
235
|
authenticate: authenticateToken,
|
|
236
|
+
/** Fresh session validity for user-scoped streams; does not grant resource access. */
|
|
237
|
+
authenticateUserId,
|
|
225
238
|
|
|
226
239
|
authenticateRequest: (c: Context, token: string): Promise<AuthenticatedSession | null> =>
|
|
227
240
|
requestCached(authenticationByRequest, c, token, async () => {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { type SQLQuery, sql } from "bun";
|
|
2
2
|
import type { User } from "../../contracts/shared";
|
|
3
|
+
import { decodeAccountCategoryEnabled } from "../account-category-policy";
|
|
3
4
|
import { buildRoles } from "../accounts/authz";
|
|
4
5
|
import { resolveProviderProfile } from "../accounts/base-user";
|
|
5
|
-
import { managedGroupIdsSubquery
|
|
6
|
+
import { managedGroupIdsSubquery } from "../accounts/group-sql";
|
|
6
7
|
import { buildIpaUserData, emptyIpaUserData, userIpaDataColumns, userIpaDataJoin } from "../accounts/ipa-data";
|
|
7
8
|
import { resolveAccountExpires } from "../accounts/model";
|
|
8
9
|
import { toPgTextArray } from "../postgres";
|
|
10
|
+
import { setRailCacheVersion } from "../rail-snapshot";
|
|
9
11
|
|
|
10
12
|
type DbRow = Record<string, unknown>;
|
|
11
13
|
|
|
@@ -69,14 +71,19 @@ export const buildProjectedUser = (row: DbRow): User => {
|
|
|
69
71
|
manages,
|
|
70
72
|
managesGroupIds,
|
|
71
73
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
const user: User =
|
|
75
|
+
provider === "ipa"
|
|
76
|
+
? { ...common, provider: "ipa", ipa: buildIpaUserData(row) ?? emptyIpaUserData() }
|
|
77
|
+
: { ...common, provider: "local", ipa: null };
|
|
78
|
+
setRailCacheVersion(user, row.rail_cache_version);
|
|
79
|
+
return user;
|
|
75
80
|
};
|
|
76
81
|
|
|
77
82
|
export const userProjectionSql = (groupsAdmin: string[]) => sql`
|
|
78
83
|
u.*,
|
|
79
|
-
|
|
84
|
+
(SELECT cache_version::text FROM auth.rail_state WHERE singleton) || ':' ||
|
|
85
|
+
COALESCE((SELECT cache_version::text FROM auth.rail_preferences WHERE user_id = u.id), 'default') AS rail_cache_version,
|
|
86
|
+
${userIpaDataColumns()},
|
|
80
87
|
CASE
|
|
81
88
|
WHEN u.provider = 'local' THEN u.admin
|
|
82
89
|
ELSE EXISTS(
|
|
@@ -85,41 +92,45 @@ export const userProjectionSql = (groupsAdmin: string[]) => sql`
|
|
|
85
92
|
AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
86
93
|
)
|
|
87
94
|
END AS effective_admin,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
SELECT
|
|
97
|
-
|
|
98
|
-
JOIN auth.groups g ON g.id = ug.group_id
|
|
95
|
+
membership.member_groups, membership.member_group_ids,
|
|
96
|
+
management.manages, management.manages_group_ids
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
/** One scan for membership names/IDs and one recursive traversal for management. */
|
|
100
|
+
export const userProjectionJoin = () => sql`
|
|
101
|
+
${userIpaDataJoin()}
|
|
102
|
+
LEFT JOIN LATERAL (
|
|
103
|
+
SELECT COALESCE(array_agg(g.name ORDER BY g.name), '{}') AS member_groups,
|
|
104
|
+
COALESCE(array_agg(g.id ORDER BY g.name), '{}') AS member_group_ids
|
|
105
|
+
FROM auth.user_groups_v2 ug JOIN auth.groups g ON g.id = ug.group_id
|
|
99
106
|
WHERE ug.user_id = u.id
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
) membership ON true
|
|
108
|
+
LEFT JOIN LATERAL (
|
|
109
|
+
SELECT COALESCE(array_agg(DISTINCT g.name ORDER BY g.name), '{}') AS manages,
|
|
110
|
+
COALESCE(array_agg(g.id ORDER BY g.name), '{}') AS manages_group_ids
|
|
111
|
+
FROM auth.groups g WHERE g.id IN (${managedGroupIdsSubquery(sql`u.id`)})
|
|
112
|
+
) management ON true
|
|
104
113
|
`;
|
|
105
114
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
type SessionUserParams = {
|
|
116
|
+
userId: string;
|
|
117
|
+
sid: string;
|
|
118
|
+
authEpoch: number;
|
|
119
|
+
/** Core's consent screen only: still validates family, epoch and expiry. */
|
|
120
|
+
allowPendingLegalConsent?: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Both projections use the same live validity checks. Neither caches authorization.
|
|
124
|
+
const loadSessionRow = async (params: SessionUserParams, projection: SQLQuery, join: SQLQuery, query: typeof sql) => {
|
|
125
|
+
const [row] = await query<DbRow[]>`
|
|
126
|
+
SELECT ${projection}, (
|
|
127
|
+
SELECT value FROM settings.entries WHERE key = 'user.category.' ||
|
|
128
|
+
CASE WHEN u.provider = 'ipa' THEN 'freeipa' WHEN u.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
|
|
129
|
+
) AS category_enabled
|
|
119
130
|
FROM auth.session_families sf
|
|
120
131
|
JOIN auth.users u ON u.id = sf.user_id
|
|
121
132
|
JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
|
|
122
|
-
${
|
|
133
|
+
${join}
|
|
123
134
|
WHERE sf.sid = ${params.sid}::uuid
|
|
124
135
|
AND sf.user_id = ${params.userId}::uuid
|
|
125
136
|
AND sf.auth_epoch = ${params.authEpoch}
|
|
@@ -130,14 +141,28 @@ export const loadJwtSessionUser = async (
|
|
|
130
141
|
SELECT 1 FROM auth.legal_acceptances la WHERE la.user_id = u.id
|
|
131
142
|
))
|
|
132
143
|
`;
|
|
133
|
-
return
|
|
144
|
+
return row && (await decodeAccountCategoryEnabled(row.category_enabled)) ? row : null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const loadJwtSessionUser = async (
|
|
148
|
+
params: SessionUserParams & { groupsAdmin: string[] },
|
|
149
|
+
query: typeof sql = sql,
|
|
150
|
+
): Promise<User | null> => {
|
|
151
|
+
const row = await loadSessionRow(params, userProjectionSql(params.groupsAdmin), userProjectionJoin(), query);
|
|
152
|
+
return row ? buildProjectedUser(row) : null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Identity-only consumers must not pay for group, IPA, role and rail projections. */
|
|
156
|
+
export const loadJwtSessionIdentity = async (params: SessionUserParams, query: typeof sql = sql) => {
|
|
157
|
+
const row = await loadSessionRow(params, sql`u.id, u.account_expires`, sql``, query);
|
|
158
|
+
return row ? { userId: String(row.id), accountExpires: resolveAccountExpires(row)?.toISOString() ?? null } : null;
|
|
134
159
|
};
|
|
135
160
|
|
|
136
161
|
export const loadCurrentUser = async (params: { userId: string; groupsAdmin: string[] }, query: typeof sql = sql): Promise<User | null> => {
|
|
137
162
|
const rows = await query<DbRow[]>`
|
|
138
163
|
SELECT ${userProjectionSql(params.groupsAdmin)}
|
|
139
164
|
FROM auth.users u
|
|
140
|
-
${
|
|
165
|
+
${userProjectionJoin()}
|
|
141
166
|
WHERE u.id = ${params.userId}::uuid
|
|
142
167
|
`;
|
|
143
168
|
return rows[0] ? buildProjectedUser(rows[0]) : null;
|
|
@@ -30,7 +30,11 @@ const redactSecretValue = (entry: SettingEntry): SettingEntry => {
|
|
|
30
30
|
|
|
31
31
|
export const settingsService = {
|
|
32
32
|
entry: {
|
|
33
|
-
list: async (config?: {
|
|
33
|
+
list: async (config?: {
|
|
34
|
+
pagination?: PageParams;
|
|
35
|
+
filter?: { query?: string; group?: string };
|
|
36
|
+
locale?: string;
|
|
37
|
+
}): Promise<Paginated<SettingEntry>> => {
|
|
34
38
|
const entries = await settingsPrimitives.getAll(config?.locale);
|
|
35
39
|
const query = config?.filter?.query?.trim().toLowerCase();
|
|
36
40
|
const group = config?.filter?.group?.trim().toLowerCase();
|