@k2b/cloud 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +158 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +7 -5
- package/src/_internal/define-app.ts +57 -6
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +11 -1
- package/src/_internal/registry.ts +8 -1
- package/src/_internal/runtime-context.ts +8 -1
- package/src/access/AppAccessEditor.tsx +2 -0
- package/src/access/PermissionEditor.tsx +9 -3
- package/src/access/PrincipalPicker.tsx +14 -4
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-messages.ts +6 -0
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +7 -0
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +31 -22
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +5 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api.ts +26 -9
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +55 -6
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider.ts +1 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +4 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +21 -44
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +27 -22
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +16 -0
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +28 -4
- package/src/api/index.ts +7 -1
- package/src/api/mcp.ts +175 -2
- package/src/api/me.ts +6 -4
- package/src/api/pwa-phone.ts +75 -26
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/app-pwa/contracts.ts +4 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +1 -0
- package/src/app-pwa/service.ts +91 -0
- package/src/app-pwa/transport.ts +22 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/capabilities/server-invoke.ts +2 -11
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +5 -2
- package/src/cli/apps.ts +89 -26
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +26 -2
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +21 -5
- package/src/contracts/capabilities.ts +143 -3
- package/src/contracts/index.ts +2 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/pwa.ts +17 -1
- package/src/contracts/registry.ts +6 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +1 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +1 -1
- package/src/server/middleware/auth.ts +21 -7
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +2 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +6 -0
- package/src/services/accounts/entities.ts +14 -3
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +62 -10
- package/src/services/accounts/users.ts +7 -6
- package/src/services/app-access.ts +25 -7
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/ledger.ts +28 -10
- package/src/services/geo/service.ts +43 -24
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/invocation-authority.ts +4 -0
- package/src/services/identity/invocation-token.ts +238 -56
- package/src/services/identity/issue-invocation.ts +6 -1
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +13 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +71 -43
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +9 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +2 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +8 -6
- package/src/ssr/PwaLayout.tsx +16 -1
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +25 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/index.ts +8 -0
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +2 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -10,47 +10,35 @@ import { sql } from "bun";
|
|
|
10
10
|
import { Hono } from "hono";
|
|
11
11
|
import { describeRoute } from "hono-openapi";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
+
import { ObjectStoreConfigurationError, ObjectStoreConnectionError, testObjectStoreConnection } from "../_internal/object-store-config";
|
|
13
14
|
import { listApps } from "../_internal/registry";
|
|
14
|
-
import { listAiCredentialProfileIds, pruneAiCredentials, setAiCredential, splitAiProfileCredentials } from "../ai/credentials";
|
|
15
15
|
import { enrichDirtyAiConversations } from "../ai/enrich";
|
|
16
|
-
import {
|
|
17
|
-
type AiModelAccessChange,
|
|
18
|
-
AiModelAccessConflict,
|
|
19
|
-
AiModelAccessInvalid,
|
|
20
|
-
aiModelAccess,
|
|
21
|
-
splitAiModelAccess,
|
|
22
|
-
} from "../ai/model-access";
|
|
23
|
-
import { listAiRequestHeaderNames, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "../ai/request-headers";
|
|
24
|
-
import {
|
|
25
|
-
parseAiModelProfiles,
|
|
26
|
-
planAiProfileCredentials,
|
|
27
|
-
prepareAiModelProfileImages,
|
|
28
|
-
validateAiSettingsConfiguration,
|
|
29
|
-
} from "../ai/settings";
|
|
30
|
-
import { type AiSettingsIssueWithMessage, aiSettingsNotSavedMessage, describeAiSettingsIssues } from "../ai/settings-messages";
|
|
31
16
|
import { type AuthContext, auth, getLocale, jsonResponse, requiresAdmin, v } from "../server";
|
|
32
|
-
import { settingsDeleteLegacyKeys, settingsListLegacyKeys } from "../services";
|
|
17
|
+
import { settingsDeleteLegacyKeys, settingsListLegacyKeys, settingsService } from "../services";
|
|
33
18
|
import { readAccountCategoryPolicy } from "../services/account-category-policy";
|
|
34
19
|
import { audit } from "../services/audit";
|
|
20
|
+
import { ConfigConflict } from "../services/config-conflict";
|
|
35
21
|
import { validateFreeIpaCaCert } from "../services/freeipa-config";
|
|
36
22
|
import { ImageMetadataError } from "../services/image-metadata";
|
|
37
23
|
import { testFreeIpaConnection } from "../services/ipa/connection";
|
|
38
24
|
import { GotenbergRenderError, testGotenberg } from "../services/pdf";
|
|
39
25
|
import * as settings from "../services/settings";
|
|
40
26
|
import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
|
|
41
|
-
|
|
42
27
|
import { assertGeoBudgetPair, GeoBudgetSettingError } from "../services/settings/geo-budget";
|
|
43
28
|
import { invalidateSettingsCacheForAdmin } from "../services/settings/store";
|
|
29
|
+
import { assertSettingVersions } from "../services/settings/versions";
|
|
44
30
|
|
|
31
|
+
const BulkUpdatesSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
updates: z.record(z.string(), z.unknown()).optional().default({}),
|
|
34
|
+
resets: z.array(z.string()).optional().default([]),
|
|
35
|
+
replaceUnreadable: z.array(z.string()).optional().default([]),
|
|
36
|
+
expectedUpdatedAt: z.record(z.string(), z.string().nullable()).optional().default({}),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
45
39
|
const BulkSaveSchema = z.union([
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
updates: z.record(z.string(), z.unknown()).optional().default({}),
|
|
49
|
-
resets: z.array(z.string()).optional().default([]),
|
|
50
|
-
replaceUnreadable: z.array(z.string()).optional().default([]),
|
|
51
|
-
})
|
|
52
|
-
.strict(),
|
|
53
|
-
z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[], replaceUnreadable: [] as string[] })),
|
|
40
|
+
BulkUpdatesSchema,
|
|
41
|
+
z.record(z.string(), z.unknown()).transform((updates) => BulkUpdatesSchema.parse({ updates })),
|
|
54
42
|
]);
|
|
55
43
|
const LEGAL_DOCUMENTS = [
|
|
56
44
|
{ kind: "terms", path: "/legal/terms" },
|
|
@@ -72,150 +60,8 @@ const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
|
|
|
72
60
|
const GEO_KEY = "geo.api_key";
|
|
73
61
|
const GEO_KEY_DEDICATED = "Set or remove the Geoapify key with /api/admin/core/geo/key, which verifies it first.";
|
|
74
62
|
|
|
75
|
-
const AI_PROFILES_KEY = "ai.model_profiles_json";
|
|
76
|
-
const AI_ENABLED_KEY = "ai.enabled";
|
|
77
|
-
const AI_DEFAULT_MODEL_KEY = "ai.default_model_id";
|
|
78
|
-
const AI_BACKGROUND_MODEL_KEY = "ai.background_model_id";
|
|
79
|
-
const AI_AUDIO_MODEL_KEY = "ai.audio_model_id";
|
|
80
|
-
const AI_VISION_MODEL_KEY = "ai.vision_model_id";
|
|
81
|
-
const AI_WORKFLOW_MODEL_KEY = "ai.workflow_model_id";
|
|
82
|
-
const AI_CONFIGURATION_KEYS = new Set([
|
|
83
|
-
AI_ENABLED_KEY,
|
|
84
|
-
AI_DEFAULT_MODEL_KEY,
|
|
85
|
-
AI_BACKGROUND_MODEL_KEY,
|
|
86
|
-
AI_VISION_MODEL_KEY,
|
|
87
|
-
AI_AUDIO_MODEL_KEY,
|
|
88
|
-
AI_WORKFLOW_MODEL_KEY,
|
|
89
|
-
AI_PROFILES_KEY,
|
|
90
|
-
]);
|
|
91
63
|
const INTEGER_FREEIPA_SETTINGS = new Set(["freeipa.sync_guard.max_user_changes", "freeipa.sync_guard.max_group_deletions"]);
|
|
92
64
|
|
|
93
|
-
/**
|
|
94
|
-
* Move provider keys out of the model-profiles value before it is stored.
|
|
95
|
-
*
|
|
96
|
-
* The admin form posts profiles as one value, and a newly typed key rides along
|
|
97
|
-
* on its profile. Keys must not land in the setting — it is a `text` setting and
|
|
98
|
-
* would be handed back to the browser in full — so they go to
|
|
99
|
-
* `ai.model_credentials` here and are stripped from what gets saved. A profile
|
|
100
|
-
* whose key field was left empty keeps whatever is already stored.
|
|
101
|
-
*
|
|
102
|
-
* Doing this in the route rather than the settings service keeps the generic
|
|
103
|
-
* store free of AI knowledge, and keeps the admin page at a single save request.
|
|
104
|
-
*/
|
|
105
|
-
const storeAiCredentials = async (
|
|
106
|
-
split: NonNullable<ReturnType<typeof splitAiProfileCredentials>>,
|
|
107
|
-
keepCredentialProfileIds: readonly string[],
|
|
108
|
-
db: typeof sql,
|
|
109
|
-
): Promise<void> => {
|
|
110
|
-
for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret, db);
|
|
111
|
-
await pruneAiCredentials(keepCredentialProfileIds, db);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
type AiSettingsMutationPlan = {
|
|
115
|
-
errors: FieldErrors;
|
|
116
|
-
/** Structured validation issues with stable codes; `errors` carries the same messages keyed by setting. */
|
|
117
|
-
issues?: AiSettingsIssueWithMessage[];
|
|
118
|
-
keepCredentialProfileIds?: string[];
|
|
119
|
-
headerPlan?: ReturnType<typeof planAiProfileRequestHeaders>;
|
|
120
|
-
modelProfileIds?: string[];
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const valueAfterMutation = <T>(key: string, current: T, updates: Record<string, unknown>, resets: readonly string[]): T => {
|
|
124
|
-
if (key in updates) return updates[key] as T;
|
|
125
|
-
if (resets.includes(key)) return SETTINGS_MAP.get(key)?.default as T;
|
|
126
|
-
return current;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const prepareAiSettingsMutation = async (
|
|
130
|
-
updates: Record<string, unknown>,
|
|
131
|
-
resets: readonly string[],
|
|
132
|
-
aiSplit: ReturnType<typeof splitAiProfileCredentials>,
|
|
133
|
-
locale: string,
|
|
134
|
-
): Promise<AiSettingsMutationPlan> => {
|
|
135
|
-
const keys = [...Object.keys(updates), ...resets];
|
|
136
|
-
if (!keys.some((key) => AI_CONFIGURATION_KEYS.has(key))) return { errors: {} };
|
|
137
|
-
|
|
138
|
-
const [
|
|
139
|
-
currentEnabled,
|
|
140
|
-
currentDefaultModelId,
|
|
141
|
-
currentBackgroundModelId,
|
|
142
|
-
currentVisionModelId,
|
|
143
|
-
currentAudioModelId,
|
|
144
|
-
currentWorkflowModelId,
|
|
145
|
-
currentProfilesJson,
|
|
146
|
-
] = await Promise.all([
|
|
147
|
-
settings.get<boolean>(AI_ENABLED_KEY),
|
|
148
|
-
settings.get<string>(AI_DEFAULT_MODEL_KEY),
|
|
149
|
-
settings.get<string>(AI_BACKGROUND_MODEL_KEY),
|
|
150
|
-
settings.get<string>(AI_VISION_MODEL_KEY),
|
|
151
|
-
settings.get<string>(AI_AUDIO_MODEL_KEY),
|
|
152
|
-
settings.get<string>(AI_WORKFLOW_MODEL_KEY),
|
|
153
|
-
settings.get<string>(AI_PROFILES_KEY),
|
|
154
|
-
]);
|
|
155
|
-
|
|
156
|
-
const currentParsed = parseAiModelProfiles(currentProfilesJson ?? "[]");
|
|
157
|
-
const profilesUpdated = AI_PROFILES_KEY in updates;
|
|
158
|
-
const profilesReset = resets.includes(AI_PROFILES_KEY);
|
|
159
|
-
const nextParsed: ReturnType<typeof parseAiModelProfiles> = profilesUpdated
|
|
160
|
-
? parseAiModelProfiles(aiSplit?.profilesJson ?? "[]")
|
|
161
|
-
: profilesReset
|
|
162
|
-
? { profiles: [] }
|
|
163
|
-
: currentParsed;
|
|
164
|
-
if (nextParsed.error) return { errors: nextParsed.error.fields ?? { [AI_PROFILES_KEY]: nextParsed.error.message } };
|
|
165
|
-
|
|
166
|
-
const nextEnabled = Boolean(valueAfterMutation(AI_ENABLED_KEY, currentEnabled, updates, resets));
|
|
167
|
-
const existingCredentialProfileIds = profilesUpdated || (!profilesReset && nextEnabled) ? await listAiCredentialProfileIds() : [];
|
|
168
|
-
|
|
169
|
-
let keepCredentialProfileIds: string[] | undefined;
|
|
170
|
-
if (profilesUpdated || profilesReset) {
|
|
171
|
-
const credentialPlan = planAiProfileCredentials({
|
|
172
|
-
currentProfiles: currentParsed.profiles,
|
|
173
|
-
nextProfiles: nextParsed.profiles,
|
|
174
|
-
existingCredentialProfileIds,
|
|
175
|
-
submittedCredentialProfileIds: aiSplit?.credentials.map(({ profileId }) => profileId) ?? [],
|
|
176
|
-
});
|
|
177
|
-
if (credentialPlan.unsupportedCredentialProfileId) {
|
|
178
|
-
return {
|
|
179
|
-
errors: {
|
|
180
|
-
[AI_PROFILES_KEY]: `Profile "${credentialPlan.unsupportedCredentialProfileId}" does not support provider credentials.`,
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
keepCredentialProfileIds = credentialPlan.keepCredentialProfileIds;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const headerPlan =
|
|
188
|
-
profilesUpdated || profilesReset
|
|
189
|
-
? planAiProfileRequestHeaders({
|
|
190
|
-
currentProfiles: currentParsed.profiles,
|
|
191
|
-
nextProfiles: nextParsed.profiles,
|
|
192
|
-
existingNames: profilesUpdated ? await listAiRequestHeaderNames() : {},
|
|
193
|
-
submitted: aiSplit?.requestHeaders ?? [],
|
|
194
|
-
})
|
|
195
|
-
: undefined;
|
|
196
|
-
if (headerPlan?.error) return { errors: { [AI_PROFILES_KEY]: headerPlan.error } };
|
|
197
|
-
|
|
198
|
-
const issues = validateAiSettingsConfiguration({
|
|
199
|
-
enabled: nextEnabled,
|
|
200
|
-
defaultModelId: String(valueAfterMutation(AI_DEFAULT_MODEL_KEY, currentDefaultModelId ?? "", updates, resets)),
|
|
201
|
-
backgroundModelId: String(valueAfterMutation(AI_BACKGROUND_MODEL_KEY, currentBackgroundModelId ?? "", updates, resets)),
|
|
202
|
-
audioModelId: String(valueAfterMutation(AI_AUDIO_MODEL_KEY, currentAudioModelId ?? "", updates, resets)),
|
|
203
|
-
visionModelId: String(valueAfterMutation(AI_VISION_MODEL_KEY, currentVisionModelId ?? "", updates, resets)),
|
|
204
|
-
workflowModelId: String(valueAfterMutation(AI_WORKFLOW_MODEL_KEY, currentWorkflowModelId ?? "", updates, resets)),
|
|
205
|
-
profiles: nextParsed.profiles,
|
|
206
|
-
credentialProfileIds: keepCredentialProfileIds ?? existingCredentialProfileIds,
|
|
207
|
-
});
|
|
208
|
-
// Stored profiles come second so a profile that is being removed keeps its label.
|
|
209
|
-
const described = describeAiSettingsIssues(issues, { locale, profiles: [...nextParsed.profiles, ...currentParsed.profiles] });
|
|
210
|
-
return {
|
|
211
|
-
errors: described.errors,
|
|
212
|
-
issues: described.issues,
|
|
213
|
-
headerPlan,
|
|
214
|
-
keepCredentialProfileIds,
|
|
215
|
-
modelProfileIds: profilesUpdated || profilesReset ? nextParsed.profiles.map((profile) => profile.id) : undefined,
|
|
216
|
-
};
|
|
217
|
-
};
|
|
218
|
-
|
|
219
65
|
const invalidateCommittedSettings = async (keys: readonly string[]): Promise<void> => {
|
|
220
66
|
try {
|
|
221
67
|
await settings.invalidateSettingsCache(keys);
|
|
@@ -316,6 +162,22 @@ const app = new Hono<AuthContext>()
|
|
|
316
162
|
);
|
|
317
163
|
return c.json({ items });
|
|
318
164
|
})
|
|
165
|
+
// The current values and versions of one settings group, so a form can continue after a save without a reload.
|
|
166
|
+
.get(
|
|
167
|
+
"/entries",
|
|
168
|
+
auth.requireRole("admin"),
|
|
169
|
+
describeRoute({
|
|
170
|
+
tags: ["Administration"],
|
|
171
|
+
summary: "Read the settings of one group with their versions",
|
|
172
|
+
...requiresAdmin,
|
|
173
|
+
}),
|
|
174
|
+
v("query", z.object({ group: z.string().min(1).max(64) })),
|
|
175
|
+
async (c) => {
|
|
176
|
+
const result = await settingsService.entry.list({ filter: { group: c.req.valid("query").group }, locale: getLocale(c) });
|
|
177
|
+
c.header("Cache-Control", "no-store");
|
|
178
|
+
return c.json({ items: result.items });
|
|
179
|
+
},
|
|
180
|
+
)
|
|
319
181
|
.get("/legacy", auth.requireRole("admin"), async (c) => {
|
|
320
182
|
return c.json(await settingsListLegacyKeys(await liveSettingKeys()));
|
|
321
183
|
})
|
|
@@ -345,6 +207,33 @@ const app = new Hono<AuthContext>()
|
|
|
345
207
|
return c.json({ message: "Failed to test Gotenberg PDF rendering" }, 500);
|
|
346
208
|
}
|
|
347
209
|
})
|
|
210
|
+
.post(
|
|
211
|
+
"/test-storage",
|
|
212
|
+
auth.requireRole("admin"),
|
|
213
|
+
describeRoute({
|
|
214
|
+
tags: ["Administration"],
|
|
215
|
+
summary: "Test the saved Garage storage connection",
|
|
216
|
+
...requiresAdmin,
|
|
217
|
+
responses: { 200: jsonResponse(z.object({ ok: z.literal(true) }), "Object written, read and deleted") },
|
|
218
|
+
}),
|
|
219
|
+
async (c) => {
|
|
220
|
+
c.header("Cache-Control", "no-store");
|
|
221
|
+
try {
|
|
222
|
+
await testObjectStoreConnection();
|
|
223
|
+
return c.json({ ok: true as const });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return c.json(
|
|
226
|
+
{
|
|
227
|
+
message:
|
|
228
|
+
error instanceof ObjectStoreConfigurationError || error instanceof ObjectStoreConnectionError
|
|
229
|
+
? error.message
|
|
230
|
+
: "Storage test failed. Check the saved settings, Garage availability and bucket permissions, then retry.",
|
|
231
|
+
},
|
|
232
|
+
502,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
)
|
|
348
237
|
.post("/test-freeipa", auth.requireRole("admin"), async (c) => {
|
|
349
238
|
const result = await testFreeIpaConnection();
|
|
350
239
|
if (result.ok) return c.json({ ok: true });
|
|
@@ -368,12 +257,12 @@ const app = new Hono<AuthContext>()
|
|
|
368
257
|
}),
|
|
369
258
|
v("json", BulkSaveSchema),
|
|
370
259
|
async (c) => {
|
|
371
|
-
const { updates, resets, replaceUnreadable } = c.req.valid("json");
|
|
260
|
+
const { updates, resets, replaceUnreadable, expectedUpdatedAt } = c.req.valid("json");
|
|
372
261
|
const replace = new Set(replaceUnreadable);
|
|
373
262
|
const updateKeys = Object.keys(updates);
|
|
374
263
|
const keys = [...updateKeys, ...resets];
|
|
375
264
|
|
|
376
|
-
if (keys.length === 0 && replace.size === 0) {
|
|
265
|
+
if (keys.length === 0 && replace.size === 0 && Object.keys(expectedUpdatedAt).length === 0) {
|
|
377
266
|
return c.body(null, 204);
|
|
378
267
|
}
|
|
379
268
|
|
|
@@ -383,6 +272,9 @@ const app = new Hono<AuthContext>()
|
|
|
383
272
|
ownership[key] = `Unknown setting "${key}"`;
|
|
384
273
|
}
|
|
385
274
|
}
|
|
275
|
+
for (const key of Object.keys(expectedUpdatedAt)) {
|
|
276
|
+
if (!keys.includes(key)) ownership[key] = `Setting "${key}" must be included in updates or resets`;
|
|
277
|
+
}
|
|
386
278
|
for (const key of replace) {
|
|
387
279
|
if (!Object.hasOwn(updates, key)) ownership[key] = `Setting "${key}" must be included in updates to replace its unreadable value`;
|
|
388
280
|
}
|
|
@@ -405,38 +297,7 @@ const app = new Hono<AuthContext>()
|
|
|
405
297
|
return c.json({ message: settingsNotSavedMessage(getLocale(c)), errors: unreadable, code: "setting_unreadable" }, 409);
|
|
406
298
|
}
|
|
407
299
|
|
|
408
|
-
|
|
409
|
-
// value that gets checked is the value that gets stored.
|
|
410
|
-
const aiSplit = AI_PROFILES_KEY in updates ? splitAiProfileCredentials(updates[AI_PROFILES_KEY]) : null;
|
|
411
|
-
if (AI_PROFILES_KEY in updates && !aiSplit) {
|
|
412
|
-
// Unsplittable means the keys could not be separated out, and storing the
|
|
413
|
-
// value as posted would put them back in a setting the browser reads.
|
|
414
|
-
return c.json({ message: "Invalid values", errors: { [AI_PROFILES_KEY]: "Model profiles must be a JSON array" } }, 400);
|
|
415
|
-
}
|
|
416
|
-
let accessChanges: AiModelAccessChange[] = [];
|
|
417
|
-
if (aiSplit) {
|
|
418
|
-
try {
|
|
419
|
-
aiSplit.profilesJson = prepareAiModelProfileImages(aiSplit.profilesJson);
|
|
420
|
-
} catch (error) {
|
|
421
|
-
if (error instanceof ImageMetadataError)
|
|
422
|
-
return c.json({ message: error.message, code: error.code, errors: { [AI_PROFILES_KEY]: error.message } }, 422);
|
|
423
|
-
throw error;
|
|
424
|
-
}
|
|
425
|
-
try {
|
|
426
|
-
const split = splitAiModelAccess(aiSplit.profilesJson);
|
|
427
|
-
aiSplit.profilesJson = split.profilesJson;
|
|
428
|
-
accessChanges = split.changes;
|
|
429
|
-
} catch {
|
|
430
|
-
return c.json(
|
|
431
|
-
{
|
|
432
|
-
message: "Invalid model permissions",
|
|
433
|
-
errors: { [AI_PROFILES_KEY]: "Invalid Assistant model permissions. Reload the settings and try again." },
|
|
434
|
-
},
|
|
435
|
-
400,
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
const finalValues: Record<string, unknown> = aiSplit ? { ...updates, [AI_PROFILES_KEY]: aiSplit.profilesJson } : updates;
|
|
300
|
+
const finalValues = updates;
|
|
440
301
|
|
|
441
302
|
// Validate everything up front. settings.set validates as it writes, so a
|
|
442
303
|
// single bad field used to leave the earlier keys already saved — with the
|
|
@@ -461,26 +322,17 @@ const app = new Hono<AuthContext>()
|
|
|
461
322
|
return c.json({ message: "Invalid values", errors: invalid }, 400);
|
|
462
323
|
}
|
|
463
324
|
|
|
464
|
-
let aiPlan: AiSettingsMutationPlan;
|
|
465
|
-
try {
|
|
466
|
-
aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
|
|
467
|
-
} catch (error) {
|
|
468
|
-
console.error("[settings] failed to validate the prospective AI configuration", error);
|
|
469
|
-
return c.json({ message: "Failed to validate AI settings" }, 500);
|
|
470
|
-
}
|
|
471
|
-
if (Object.keys(aiPlan.errors).length > 0) {
|
|
472
|
-
return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
325
|
const fieldErrors: FieldErrors = {};
|
|
476
326
|
try {
|
|
477
327
|
// Every settings and AI credential write runs on the transaction's own
|
|
478
328
|
// connection, so a failure rolls the whole save back.
|
|
479
329
|
await sql.begin(async (tx) => {
|
|
330
|
+
await assertSettingVersions(expectedUpdatedAt, tx);
|
|
480
331
|
await assertGeoBudgetPair(tx, validatedValues, resets);
|
|
481
332
|
for (const [key, value] of Object.entries(validatedValues)) {
|
|
482
333
|
try {
|
|
483
334
|
await settings.set(key, value, tx, { replaceUnreadable: replace.has(key) });
|
|
335
|
+
await tx`UPDATE settings.entries SET updated_by=${c.get("user").id}::uuid WHERE key=${key}`;
|
|
484
336
|
} catch (error) {
|
|
485
337
|
fieldErrors[key] = `Failed to save ${key}`;
|
|
486
338
|
throw error;
|
|
@@ -512,15 +364,6 @@ const app = new Hono<AuthContext>()
|
|
|
512
364
|
},
|
|
513
365
|
tx,
|
|
514
366
|
);
|
|
515
|
-
if (aiSplit && aiPlan.keepCredentialProfileIds) {
|
|
516
|
-
await storeAiCredentials(aiSplit, aiPlan.keepCredentialProfileIds, tx);
|
|
517
|
-
} else if (aiPlan.keepCredentialProfileIds) {
|
|
518
|
-
await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
|
|
519
|
-
}
|
|
520
|
-
if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
|
|
521
|
-
if (aiPlan.modelProfileIds) {
|
|
522
|
-
await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
|
|
523
|
-
}
|
|
524
367
|
});
|
|
525
368
|
} catch (error) {
|
|
526
369
|
if (error instanceof GeoBudgetSettingError) return error.getResponse();
|
|
@@ -535,12 +378,11 @@ const app = new Hono<AuthContext>()
|
|
|
535
378
|
);
|
|
536
379
|
}
|
|
537
380
|
if (error instanceof ImageMetadataError) return c.json({ message: error.message, code: error.code, errors: fieldErrors }, 422);
|
|
538
|
-
if (error instanceof
|
|
381
|
+
if (error instanceof ConfigConflict)
|
|
539
382
|
return c.json(
|
|
540
|
-
{
|
|
541
|
-
|
|
383
|
+
{ code: error.code, current: error.current, key: error.key, changedBy: error.changedBy, changedAt: error.changedAt },
|
|
384
|
+
409,
|
|
542
385
|
);
|
|
543
|
-
}
|
|
544
386
|
console.error("[settings] failed to commit settings update", error);
|
|
545
387
|
return c.json(
|
|
546
388
|
{
|
|
@@ -565,17 +407,6 @@ const app = new Hono<AuthContext>()
|
|
|
565
407
|
}
|
|
566
408
|
if (key === GEO_KEY) return c.json({ message: GEO_KEY_DEDICATED, errors: { [GEO_KEY]: GEO_KEY_DEDICATED } }, 400);
|
|
567
409
|
|
|
568
|
-
let aiPlan: AiSettingsMutationPlan;
|
|
569
|
-
try {
|
|
570
|
-
aiPlan = await prepareAiSettingsMutation({}, [key], null, getLocale(c));
|
|
571
|
-
} catch (error) {
|
|
572
|
-
console.error("[settings] failed to validate the prospective AI configuration", error);
|
|
573
|
-
return c.json({ message: "Failed to validate AI settings" }, 500);
|
|
574
|
-
}
|
|
575
|
-
if (Object.keys(aiPlan.errors).length > 0) {
|
|
576
|
-
return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
410
|
try {
|
|
580
411
|
await sql.begin(async (tx) => {
|
|
581
412
|
await assertGeoBudgetPair(tx, {}, [key]);
|
|
@@ -591,13 +422,6 @@ const app = new Hono<AuthContext>()
|
|
|
591
422
|
},
|
|
592
423
|
tx,
|
|
593
424
|
);
|
|
594
|
-
if (aiPlan.keepCredentialProfileIds) {
|
|
595
|
-
await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
|
|
596
|
-
}
|
|
597
|
-
if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
|
|
598
|
-
if (aiPlan.modelProfileIds) {
|
|
599
|
-
await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, [], tx);
|
|
600
|
-
}
|
|
601
425
|
});
|
|
602
426
|
} catch (error) {
|
|
603
427
|
if (error instanceof GeoBudgetSettingError || error instanceof settings.SettingUnreadableError) return error.getResponse();
|
package/src/api/app-approval.ts
CHANGED
|
@@ -27,9 +27,10 @@ import {
|
|
|
27
27
|
type createAppApprovalService,
|
|
28
28
|
} from "../services/app-approval";
|
|
29
29
|
import { type NewBrowserSignInNotifier, readBrowserCookie } from "../services/known-browsers";
|
|
30
|
-
import { SessionAppDeviceInactiveError, SessionEpochChangedError } from "../services/session";
|
|
30
|
+
import { SessionAccountMismatchError, SessionAppDeviceInactiveError, SessionEpochChangedError } from "../services/session";
|
|
31
31
|
import { appNameIn } from "../ssr/runtime";
|
|
32
32
|
import { createDeviceActions } from "./app-approval-actions";
|
|
33
|
+
import { accountMismatchResponse } from "./auth/account-mismatch";
|
|
33
34
|
|
|
34
35
|
const PairingIdSchema = z.object({ pairingId: z.string().uuid() }).strict();
|
|
35
36
|
const DevicePageSchema = z.object({ after: z.string().uuid().optional() }).strict();
|
|
@@ -56,6 +57,7 @@ export const createAppApprovalRoutes = (
|
|
|
56
57
|
.use("/device", noTransform())
|
|
57
58
|
.use("/device/*", noTransform())
|
|
58
59
|
.onError((error, c) => {
|
|
60
|
+
if (error instanceof SessionAccountMismatchError) return accountMismatchResponse(c);
|
|
59
61
|
if (error instanceof AppApprovalError) return c.json({ code: error.code, message: error.code }, error.status);
|
|
60
62
|
if (error instanceof HTTPException && error.status < 500) return c.json({ code: "INVALID_REQUEST" }, error.status);
|
|
61
63
|
// Never include SQL, pairing secrets, signature bodies or keys in errors.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { CompiledCapabilities } from "../_internal/capabilities";
|
|
5
|
+
import { AppEventEnvelopeSchema, appEventSubscriptionId } from "../contracts/app-events";
|
|
6
|
+
import { deliverAppEvent } from "../events/app-events";
|
|
7
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
|
|
8
|
+
import { verifyAppEventInvocation } from "../services/identity/invocation-token";
|
|
9
|
+
|
|
10
|
+
const DeliveryInput = z
|
|
11
|
+
.object({ envelope: AppEventEnvelopeSchema, subscriptionId: z.string().max(400), attempt: z.number().int().min(1).max(8) })
|
|
12
|
+
.strict();
|
|
13
|
+
export const createAppEventDeliveryRoutes = (compiled: CompiledCapabilities, verify = verifyAppEventInvocation) =>
|
|
14
|
+
new Hono().use(bodyLimit({ maxSize: 8192 })).post("/deliver", async (c) => {
|
|
15
|
+
const payload: unknown = await c.req.json().catch(() => null);
|
|
16
|
+
const parsed = DeliveryInput.safeParse(payload);
|
|
17
|
+
if (!parsed.success) return c.json({ code: "schema_mismatch" }, 422);
|
|
18
|
+
const { envelope, subscriptionId, attempt } = parsed.data;
|
|
19
|
+
const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/i)?.[1];
|
|
20
|
+
if (!token || !(await verify(token, { appId: compiled.manifest.appId, subscriptionId, eventId: envelope.id, payload })))
|
|
21
|
+
return c.json({ code: "unauthorized" }, 401);
|
|
22
|
+
const sub = compiled.subscribes.get(envelope.type);
|
|
23
|
+
if (
|
|
24
|
+
!sub ||
|
|
25
|
+
sub.manifest.version !== envelope.version ||
|
|
26
|
+
subscriptionId !== appEventSubscriptionId(compiled.manifest.appId, envelope.type, envelope.version)
|
|
27
|
+
)
|
|
28
|
+
return c.json({ code: "subscription_removed" }, 410);
|
|
29
|
+
if (envelope.place) return c.json({ code: "resource_events_unsupported" }, 410);
|
|
30
|
+
if (!sub.definition.schema.safeParse(envelope.data).success) return c.json({ code: "schema_mismatch" }, 422);
|
|
31
|
+
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30000)]);
|
|
32
|
+
let onAbort: () => void = () => {};
|
|
33
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
34
|
+
onAbort = () => reject(signal.reason);
|
|
35
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
36
|
+
if (signal.aborted) onAbort();
|
|
37
|
+
});
|
|
38
|
+
setRequestTimeout(c, 40);
|
|
39
|
+
try {
|
|
40
|
+
await Promise.race([
|
|
41
|
+
deliverAppEvent({ compiled, subscriptionId, envelope, context: { subscriptionId, eventId: envelope.id, attempt, signal } }),
|
|
42
|
+
aborted,
|
|
43
|
+
]);
|
|
44
|
+
return c.json({ done: true });
|
|
45
|
+
} catch {
|
|
46
|
+
return c.json({ code: signal.aborted && signal.reason?.name === "TimeoutError" ? "handler_timeout" : "handler_failed" }, 503);
|
|
47
|
+
} finally {
|
|
48
|
+
signal.removeEventListener("abort", onAbort);
|
|
49
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
3
|
+
import { describeRoute } from "hono-openapi";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { AppDetailsSchema } from "../contracts/app-details";
|
|
6
|
+
import { AppEventEnvelopeSchema } from "../contracts/app-events";
|
|
7
|
+
import { CapabilityQualifiedIdSchema } from "../contracts/capabilities";
|
|
8
|
+
import { type AuthContext, auth, expectUserBackedActor, getLocale, v } from "../server";
|
|
9
|
+
import { AppAccessManagementError } from "../services/app-access-management";
|
|
10
|
+
import { appDetails } from "../services/app-details";
|
|
11
|
+
import { appEventShape, appEventShapeId, recordedAppEventShape } from "../services/app-event-shapes";
|
|
12
|
+
import { appEventBroker } from "../services/app-events";
|
|
13
|
+
|
|
14
|
+
const App = z.string().regex(/^[a-z][a-z0-9-]{0,79}$/);
|
|
15
|
+
const Filter = z.object({
|
|
16
|
+
app: App.optional(),
|
|
17
|
+
state: z.enum(["pending", "retrying", "done", "failed", "cancelled"]).optional(),
|
|
18
|
+
eventType: CapabilityQualifiedIdSchema.optional(),
|
|
19
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
20
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
21
|
+
});
|
|
22
|
+
export const createAppEventRoutes = (
|
|
23
|
+
authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
|
|
24
|
+
service = appEventBroker,
|
|
25
|
+
details = appDetails,
|
|
26
|
+
) =>
|
|
27
|
+
new Hono<AuthContext>()
|
|
28
|
+
.use(authenticate)
|
|
29
|
+
.use(auth.requireOAuthScope("admin"))
|
|
30
|
+
.get(
|
|
31
|
+
"/apps/:appId",
|
|
32
|
+
describeRoute({ tags: ["App events"], summary: "Inspect one application's access, events and notifications" }),
|
|
33
|
+
v("param", z.object({ appId: App })),
|
|
34
|
+
async (c) => {
|
|
35
|
+
try {
|
|
36
|
+
const result = await details(c.req.valid("param").appId, { authority: auth.getAuthority(c), locale: getLocale(c) });
|
|
37
|
+
return result ? c.json(AppDetailsSchema.parse(result)) : c.json({ code: "app_not_found" }, 404);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof AppAccessManagementError) return c.json({ code: error.code, message: error.message }, error.status);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
.get("/catalog", describeRoute({ tags: ["App events"], summary: "List declared event types and subscribers" }), async (c) =>
|
|
45
|
+
c.json(await service.catalog(getLocale(c))),
|
|
46
|
+
)
|
|
47
|
+
.get(
|
|
48
|
+
"/subscriptions",
|
|
49
|
+
describeRoute({ tags: ["App events"], summary: "Inspect declared subscriptions and delivery counts" }),
|
|
50
|
+
v("query", z.object({ app: App.optional() })),
|
|
51
|
+
async (c) => c.json(await service.subscriptions(c.req.valid("query").app)),
|
|
52
|
+
)
|
|
53
|
+
.get("/deliveries", describeRoute({ tags: ["App events"], summary: "List app event deliveries" }), v("query", Filter), async (c) =>
|
|
54
|
+
c.json(await service.deliveries(c.req.valid("query"))),
|
|
55
|
+
)
|
|
56
|
+
.post(
|
|
57
|
+
"/deliveries/:id/redeliver",
|
|
58
|
+
describeRoute({ tags: ["App events"], summary: "Retry one delivery preserving its event ID" }),
|
|
59
|
+
v("param", z.object({ id: z.uuid() })),
|
|
60
|
+
async (c) => {
|
|
61
|
+
const user = expectUserBackedActor(c);
|
|
62
|
+
const result = await service.redeliver(c.req.valid("param").id, {
|
|
63
|
+
userId: user.id,
|
|
64
|
+
uid: user.uid,
|
|
65
|
+
provider: user.provider,
|
|
66
|
+
roles: user.roles,
|
|
67
|
+
});
|
|
68
|
+
return result ? c.json(result) : c.json({ code: "not_redeliverable" }, 409);
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
.post("/status/acknowledge", describeRoute({ tags: ["App events"], summary: "Acknowledge a reconciled retention gap" }), async (c) => {
|
|
72
|
+
const user = expectUserBackedActor(c);
|
|
73
|
+
const result = await service.acknowledgeGap({ userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles });
|
|
74
|
+
return result ? c.json(result) : c.json({ code: "no_gap_detected" }, 409);
|
|
75
|
+
})
|
|
76
|
+
.get(
|
|
77
|
+
"/status",
|
|
78
|
+
describeRoute({ tags: ["App events"], summary: "Read broker progress, retention gaps and rejected-event metadata" }),
|
|
79
|
+
async (c) => c.json(await service.status()),
|
|
80
|
+
)
|
|
81
|
+
.get(
|
|
82
|
+
"/record/:eventType",
|
|
83
|
+
describeRoute({ tags: ["App events"], summary: "Record schema-only JSONL fixtures; no stored event values are returned" }),
|
|
84
|
+
v("param", z.object({ eventType: CapabilityQualifiedIdSchema })),
|
|
85
|
+
v("query", z.object({ shapeOnly: z.literal("true"), limit: z.coerce.number().int().min(1).max(100).default(10) })),
|
|
86
|
+
async (c) => {
|
|
87
|
+
const { eventType } = c.req.valid("param");
|
|
88
|
+
const { limit } = c.req.valid("query");
|
|
89
|
+
const catalog = await service.catalog();
|
|
90
|
+
const rows = await sql<
|
|
91
|
+
{ version: number; envelope: { data: unknown } }[]
|
|
92
|
+
>`SELECT version, envelope FROM events.app_events WHERE type=${eventType} AND state='accepted' ORDER BY seq DESC LIMIT ${limit}`;
|
|
93
|
+
try {
|
|
94
|
+
const shapes = new Map<string, { version: number; shape: ReturnType<typeof recordedAppEventShape> }>();
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const shape = recordedAppEventShape(row.envelope.data);
|
|
97
|
+
shapes.set(JSON.stringify([row.version, shape]), { version: row.version, shape });
|
|
98
|
+
}
|
|
99
|
+
const lines = [...shapes.values()].map((row) => {
|
|
100
|
+
const declaration = catalog.events.find((event) => event.type === eventType && event.version === row.version);
|
|
101
|
+
if (!declaration) throw new Error("shape_unavailable");
|
|
102
|
+
return JSON.stringify(
|
|
103
|
+
AppEventEnvelopeSchema.parse({
|
|
104
|
+
id: appEventShapeId(eventType, row.version, row.shape),
|
|
105
|
+
type: eventType,
|
|
106
|
+
version: row.version,
|
|
107
|
+
source: eventType.split(".")[0],
|
|
108
|
+
time: "2000-01-01T00:00:00.000Z",
|
|
109
|
+
subject: { type: declaration.subject, id: "00000000-0000-4000-8000-000000000001" },
|
|
110
|
+
...(declaration.place ? { place: { type: declaration.place, id: "00000000-0000-4000-8000-000000000001" } } : {}),
|
|
111
|
+
data: appEventShape(declaration.dataSchema, 0, row.shape),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
return c.body(lines.length ? lines.join("\n") + "\n" : "", 200, {
|
|
116
|
+
"content-type": "application/x-ndjson",
|
|
117
|
+
"cache-control": "no-store",
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
return c.json({ code: "shape_unavailable", message: "A deterministic fixture cannot be generated for this declaration." }, 422);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Context, Hono } from "hono";
|
|
2
|
+
import { bindAppAccessRequest } from "../_internal/app-access-request";
|
|
3
|
+
import { hasLocalResourceAppAccess } from "../_internal/app-access-resources";
|
|
4
|
+
import { getApp } from "../_internal/registry";
|
|
5
|
+
import type { AppMeta } from "../contracts/app";
|
|
6
|
+
import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
|
|
7
|
+
import { rateLimit } from "../server/middleware/rate-limit";
|
|
8
|
+
import { canUseApp } from "../services/app-access";
|
|
9
|
+
import { resolveInvocationAuthority } from "../services/identity/invocation-actor";
|
|
10
|
+
import { isInvocationJwtCandidate, verifyAppAccessProbe } from "../services/identity/invocation-token";
|
|
11
|
+
|
|
12
|
+
/** Framework-only endpoint, mounted before the app gate to avoid recursive entry checks. */
|
|
13
|
+
export const createResourceAppAccessRoutes = (app: Pick<AppMeta, "id" | "access">) => {
|
|
14
|
+
const router = new Hono<AuthContext>();
|
|
15
|
+
if (app.access?.kind === "resources")
|
|
16
|
+
router.post("/check", auth.readOnly, async (c) => {
|
|
17
|
+
c.header("Cache-Control", "private, no-store");
|
|
18
|
+
const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/i)?.[1];
|
|
19
|
+
if (!token || !isInvocationJwtCandidate(token)) return c.body(null, 401);
|
|
20
|
+
const claims = await verifyAppAccessProbe(token, app.id);
|
|
21
|
+
if (!claims) return c.body(null, 401);
|
|
22
|
+
if (!hasLocalResourceAppAccess(app.id)) return c.body(null, 403);
|
|
23
|
+
const authority = await resolveInvocationAuthority(claims);
|
|
24
|
+
if (!authority) return c.body(null, 401);
|
|
25
|
+
return c.body(null, (await canUseApp(bindAppAccessRequest(authority, c.req.raw), app)) ? 204 : 403);
|
|
26
|
+
});
|
|
27
|
+
if (app.id === "core")
|
|
28
|
+
router.post(
|
|
29
|
+
"/apps/:appId",
|
|
30
|
+
auth.readOnly,
|
|
31
|
+
async (c, next) => {
|
|
32
|
+
c.header("Cache-Control", "private, no-store");
|
|
33
|
+
const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/i)?.[1];
|
|
34
|
+
if (token && isInvocationJwtCandidate(token)) return c.body(null, 401);
|
|
35
|
+
await next();
|
|
36
|
+
},
|
|
37
|
+
auth.requireRole("authenticated"),
|
|
38
|
+
rateLimit(),
|
|
39
|
+
(c) => answerCoreProbe(c, auth.getAuthority(c)),
|
|
40
|
+
);
|
|
41
|
+
return router;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const answerCoreProbe = async (c: Context<AuthContext>, authority: RequestAuthority): Promise<Response> => {
|
|
45
|
+
const target = await getApp(c.req.param("appId") ?? "");
|
|
46
|
+
if (!target || target.access?.kind !== "resources") return c.body(null, 404);
|
|
47
|
+
return c.body(null, (await canUseApp(bindAppAccessRequest(authority, c.req.raw), target)) ? 204 : 403);
|
|
48
|
+
};
|