@k2b/cloud 0.10.1 → 0.11.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 +21 -22
- package/src/ai/settings-messages.ts +129 -0
- package/src/ai/settings.ts +50 -39
- package/src/api/admin-core-settings.ts +14 -7
- package/src/contracts/capability-compatibility.ts +368 -0
- package/src/contracts/contact-directory.ts +165 -0
- package/src/contracts/index.ts +2 -0
- package/src/services/document-extraction.ts +2 -0
- package/src/services/webauthn.ts +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -89,50 +89,49 @@
|
|
|
89
89
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
-
"@firecrawl/anydoc": "0.
|
|
93
|
-
"@napi-rs/canvas": "1.0.
|
|
92
|
+
"@firecrawl/anydoc": "0.2.4",
|
|
93
|
+
"@napi-rs/canvas": "1.0.9",
|
|
94
94
|
"pdfjs-dist": "6.3.289",
|
|
95
95
|
"@hono/standard-validator": "0.2.2",
|
|
96
|
-
"@simplewebauthn/server": "
|
|
97
|
-
"@tabler/icons": "3.
|
|
96
|
+
"@simplewebauthn/server": "14.0.2",
|
|
97
|
+
"@tabler/icons": "3.47.0",
|
|
98
98
|
"@tailwindcss/typography": "0.5.20",
|
|
99
99
|
"@k2b/nessi": "0.12.1",
|
|
100
100
|
"@k2b/ssr": "0.14.0",
|
|
101
101
|
"@k2b/ui": "0.5.0",
|
|
102
102
|
"@k2b/stdlib": "0.25.0",
|
|
103
|
-
"@k2b/sync": "6.
|
|
103
|
+
"@k2b/sync": "6.5.0",
|
|
104
104
|
"@nats-io/transport-node": "3.4.0",
|
|
105
105
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
106
106
|
"bun-plugin-tailwind": "0.1.2",
|
|
107
107
|
"hono-openapi": "1.3.1",
|
|
108
|
-
"jose": "6.2.
|
|
109
|
-
"katex": "0.
|
|
110
|
-
"liquidjs": "10.
|
|
108
|
+
"jose": "6.2.12",
|
|
109
|
+
"katex": "0.18.7",
|
|
110
|
+
"liquidjs": "10.29.0",
|
|
111
111
|
"marked": "17.0.6",
|
|
112
112
|
"mermaid": "11.16.0",
|
|
113
|
-
"nodemailer": "
|
|
114
|
-
"postcss": "8.5.
|
|
113
|
+
"nodemailer": "10.0.10",
|
|
114
|
+
"postcss": "8.5.28",
|
|
115
115
|
"reflect-metadata": "0.2.2",
|
|
116
|
-
"sanitize-html": "2.17.
|
|
117
|
-
"seroval": "1.6.
|
|
116
|
+
"sanitize-html": "2.17.7",
|
|
117
|
+
"seroval": "1.6.7",
|
|
118
118
|
"tailwindcss": "4.3.3",
|
|
119
119
|
"web-push": "3.6.7",
|
|
120
|
-
"web-vitals": "6.2.
|
|
121
|
-
"yaml": "2.9.
|
|
120
|
+
"web-vitals": "6.2.2",
|
|
121
|
+
"yaml": "2.9.1",
|
|
122
122
|
"hash-wasm": "4.12.0",
|
|
123
|
-
"@types/nodemailer": "7.0.12",
|
|
124
123
|
"@types/sanitize-html": "2.16.1",
|
|
125
124
|
"@types/web-push": "3.6.4"
|
|
126
125
|
},
|
|
127
126
|
"devDependencies": {
|
|
128
|
-
"@babel/core": "
|
|
129
|
-
"@babel/preset-typescript": "
|
|
127
|
+
"@babel/core": "8.0.6",
|
|
128
|
+
"@babel/preset-typescript": "8.0.1",
|
|
130
129
|
"@types/bun": "1.4.2",
|
|
131
|
-
"babel-preset-solid": "1.9.
|
|
132
|
-
"hono": "4.
|
|
133
|
-
"solid-js": "1.9.
|
|
130
|
+
"babel-preset-solid": "1.9.15",
|
|
131
|
+
"hono": "4.13.8",
|
|
132
|
+
"solid-js": "1.9.15",
|
|
134
133
|
"typescript": "5.9.3",
|
|
135
|
-
"zod": "4.6.
|
|
134
|
+
"zod": "4.6.5"
|
|
136
135
|
},
|
|
137
136
|
"peerDependencies": {
|
|
138
137
|
"hono": "^4.12.25",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import type { AiModelReferenceSetting, AiSettingsIssue } from "./settings";
|
|
3
|
+
|
|
4
|
+
type Reference = { setting: string; profile: string; next: string };
|
|
5
|
+
|
|
6
|
+
const catalog = i18n.define({
|
|
7
|
+
baseLocale: "en",
|
|
8
|
+
messages: {
|
|
9
|
+
en: {
|
|
10
|
+
notSaved: "The AI settings were not saved. Resolve the problems below and save again.",
|
|
11
|
+
defaultModel: "default model",
|
|
12
|
+
backgroundModel: "background model",
|
|
13
|
+
workflowModel: "workflow model",
|
|
14
|
+
visionModel: "Vision tool model",
|
|
15
|
+
audioModel: "audio model",
|
|
16
|
+
selectAnother: ({ setting }: { setting: string }) => `Select another ${setting}`,
|
|
17
|
+
useDefaultModel: "use the default model instead",
|
|
18
|
+
useBackgroundModel: "use the background model instead",
|
|
19
|
+
disableVisionFallback: "disable the view_image fallback",
|
|
20
|
+
disableAudio: "disable audio transcription",
|
|
21
|
+
or: ({ first, second }: { first: string; second: string }) => `${first} or ${second}`,
|
|
22
|
+
modelRequired: "Choose a default model while AI is enabled.",
|
|
23
|
+
profileMissing: ({ setting, profile, next }: Reference) =>
|
|
24
|
+
`The ${setting} still references “${profile}”. ${next} before removing this profile.`,
|
|
25
|
+
profileDisabled: ({ setting, profile, next }: Reference) =>
|
|
26
|
+
`The ${setting} uses “${profile}”, which is disabled. ${next}, or enable the profile again.`,
|
|
27
|
+
noVision: ({ setting, profile, next }: Reference) => `“${profile}” does not support Vision, so it cannot be the ${setting}. ${next}.`,
|
|
28
|
+
transcriptionOnly: ({ setting, profile, next }: Reference) =>
|
|
29
|
+
`“${profile}” only transcribes audio, so it cannot be the ${setting}. ${next}.`,
|
|
30
|
+
noTranscription: ({ setting, profile, next }: Reference) =>
|
|
31
|
+
`“${profile}” does not transcribe audio, so it cannot be the ${setting}. ${next}.`,
|
|
32
|
+
credentialMissing: ({ profile }: { profile: string }) =>
|
|
33
|
+
`“${profile}” needs a provider API key. Enter the key in the profile or disable the profile.`,
|
|
34
|
+
},
|
|
35
|
+
de: {
|
|
36
|
+
notSaved: "Die KI-Einstellungen wurden nicht gespeichert. Behebe die folgenden Probleme und speichere erneut.",
|
|
37
|
+
defaultModel: "Standardmodell",
|
|
38
|
+
backgroundModel: "Hintergrundmodell",
|
|
39
|
+
workflowModel: "Workflow-Modell",
|
|
40
|
+
visionModel: "Modell für das Bildwerkzeug",
|
|
41
|
+
audioModel: "Audio-Modell",
|
|
42
|
+
selectAnother: ({ setting }) => `Wähle ein anderes ${setting}`,
|
|
43
|
+
useDefaultModel: "verwende stattdessen das Standardmodell",
|
|
44
|
+
useBackgroundModel: "verwende stattdessen das Hintergrundmodell",
|
|
45
|
+
disableVisionFallback: "deaktiviere den view_image-Fallback",
|
|
46
|
+
disableAudio: "deaktiviere die Audio-Transkription",
|
|
47
|
+
or: ({ first, second }) => `${first} oder ${second}`,
|
|
48
|
+
modelRequired: "Wähle ein Standardmodell, solange KI aktiviert ist.",
|
|
49
|
+
profileMissing: ({ setting, profile, next }) =>
|
|
50
|
+
`Das ${setting} verweist noch auf „${profile}“. ${next}, bevor du dieses Profil entfernst.`,
|
|
51
|
+
profileDisabled: ({ setting, profile, next }) =>
|
|
52
|
+
`Das ${setting} verwendet „${profile}“, aber dieses Profil ist deaktiviert. ${next}. Du kannst das Profil auch wieder aktivieren.`,
|
|
53
|
+
noVision: ({ setting, profile, next }) =>
|
|
54
|
+
`„${profile}“ unterstützt keine Bildverarbeitung und kann nicht als ${setting} verwendet werden. ${next}.`,
|
|
55
|
+
transcriptionOnly: ({ setting, profile, next }) =>
|
|
56
|
+
`„${profile}“ transkribiert nur Audio und kann nicht als ${setting} verwendet werden. ${next}.`,
|
|
57
|
+
noTranscription: ({ setting, profile, next }) =>
|
|
58
|
+
`„${profile}“ transkribiert kein Audio und kann nicht als ${setting} verwendet werden. ${next}.`,
|
|
59
|
+
credentialMissing: ({ profile }) =>
|
|
60
|
+
`„${profile}“ benötigt einen API-Schlüssel des Anbieters. Gib den Schlüssel im Profil ein oder deaktiviere das Profil.`,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type Messages = ReturnType<typeof catalog.resolve>["t"];
|
|
66
|
+
|
|
67
|
+
const settingNoun = (t: Messages, setting: AiModelReferenceSetting): string =>
|
|
68
|
+
({
|
|
69
|
+
"ai.default_model_id": t.defaultModel,
|
|
70
|
+
"ai.background_model_id": t.backgroundModel,
|
|
71
|
+
"ai.workflow_model_id": t.workflowModel,
|
|
72
|
+
"ai.vision_model_id": t.visionModel,
|
|
73
|
+
"ai.audio_model_id": t.audioModel,
|
|
74
|
+
})[setting];
|
|
75
|
+
|
|
76
|
+
/** The way out that keeps the feature working without this profile, if the setting is optional. */
|
|
77
|
+
const alternative = (t: Messages, setting: AiModelReferenceSetting): string | undefined =>
|
|
78
|
+
({
|
|
79
|
+
"ai.default_model_id": undefined,
|
|
80
|
+
"ai.background_model_id": t.useDefaultModel,
|
|
81
|
+
"ai.workflow_model_id": t.useBackgroundModel,
|
|
82
|
+
"ai.vision_model_id": t.disableVisionFallback,
|
|
83
|
+
"ai.audio_model_id": t.disableAudio,
|
|
84
|
+
})[setting];
|
|
85
|
+
|
|
86
|
+
export type AiSettingsIssueWithMessage = AiSettingsIssue & { message: string };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Turn validation issues into request-localized, actionable messages.
|
|
90
|
+
*
|
|
91
|
+
* `profiles` should list the prospective profiles first and the stored ones
|
|
92
|
+
* after them, so a profile that is being removed still has its label. Only
|
|
93
|
+
* ids and labels are read; provider keys never reach the messages.
|
|
94
|
+
*/
|
|
95
|
+
export const describeAiSettingsIssues = (
|
|
96
|
+
issues: readonly AiSettingsIssue[],
|
|
97
|
+
options: { locale?: string | null; profiles: ReadonlyArray<{ id: string; label: string }> },
|
|
98
|
+
): { issues: AiSettingsIssueWithMessage[]; errors: Record<string, string> } => {
|
|
99
|
+
const t = catalog.resolve(options.locale ? [options.locale] : []).t;
|
|
100
|
+
const labelOf = (profileId: string) => options.profiles.find((profile) => profile.id === profileId)?.label ?? profileId;
|
|
101
|
+
|
|
102
|
+
const described = issues.map((issue): AiSettingsIssueWithMessage => {
|
|
103
|
+
if (issue.code === "model_required") return { ...issue, message: t.modelRequired };
|
|
104
|
+
const profile = labelOf(issue.profileId);
|
|
105
|
+
if (issue.code === "provider_credential_missing") return { ...issue, message: t.credentialMissing({ profile }) };
|
|
106
|
+
|
|
107
|
+
const setting = settingNoun(t, issue.setting);
|
|
108
|
+
const other = alternative(t, issue.setting);
|
|
109
|
+
const select = t.selectAnother({ setting });
|
|
110
|
+
const reference = { setting, profile, next: other ? t.or({ first: select, second: other }) : select };
|
|
111
|
+
if (issue.code === "model_profile_missing") return { ...issue, message: t.profileMissing(reference) };
|
|
112
|
+
if (issue.code === "model_profile_disabled") return { ...issue, message: t.profileDisabled(reference) };
|
|
113
|
+
const message =
|
|
114
|
+
issue.setting === "ai.vision_model_id"
|
|
115
|
+
? t.noVision(reference)
|
|
116
|
+
: issue.setting === "ai.audio_model_id"
|
|
117
|
+
? t.noTranscription(reference)
|
|
118
|
+
: t.transcriptionOnly(reference);
|
|
119
|
+
return { ...issue, message };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const errors: Record<string, string> = {};
|
|
123
|
+
for (const issue of described)
|
|
124
|
+
errors[issue.setting] = errors[issue.setting] ? `${errors[issue.setting]} ${issue.message}` : issue.message;
|
|
125
|
+
return { issues: described, errors };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const aiSettingsNotSavedMessage = (locale?: string | null): string => catalog.resolve(locale ? [locale] : []).t.notSaved;
|
|
129
|
+
export const checkAiSettingsMessages = () => catalog.check();
|
package/src/ai/settings.ts
CHANGED
|
@@ -185,6 +185,27 @@ export const parseAiModelProfiles = (rawJson: string): { profiles: AiModelProfil
|
|
|
185
185
|
return { profiles };
|
|
186
186
|
};
|
|
187
187
|
|
|
188
|
+
export type AiModelReferenceSetting =
|
|
189
|
+
| "ai.default_model_id"
|
|
190
|
+
| "ai.background_model_id"
|
|
191
|
+
| "ai.workflow_model_id"
|
|
192
|
+
| "ai.vision_model_id"
|
|
193
|
+
| "ai.audio_model_id";
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One reason why a prospective AI configuration cannot be saved. Codes and
|
|
197
|
+
* profile ids are stable and locale-free; the admin route turns them into
|
|
198
|
+
* request-localized messages.
|
|
199
|
+
*/
|
|
200
|
+
export type AiSettingsIssue =
|
|
201
|
+
| { code: "model_required"; setting: AiModelReferenceSetting }
|
|
202
|
+
| {
|
|
203
|
+
code: "model_profile_missing" | "model_profile_disabled" | "model_profile_incompatible";
|
|
204
|
+
setting: AiModelReferenceSetting;
|
|
205
|
+
profileId: string;
|
|
206
|
+
}
|
|
207
|
+
| { code: "provider_credential_missing"; setting: "ai.model_profiles_json"; profileId: string };
|
|
208
|
+
|
|
188
209
|
export const validateAiSettingsConfiguration = (input: {
|
|
189
210
|
enabled: boolean;
|
|
190
211
|
defaultModelId: string;
|
|
@@ -194,51 +215,41 @@ export const validateAiSettingsConfiguration = (input: {
|
|
|
194
215
|
workflowModelId: string;
|
|
195
216
|
profiles: readonly AiModelProfile[];
|
|
196
217
|
credentialProfileIds: readonly string[];
|
|
197
|
-
}):
|
|
198
|
-
if (!input.enabled) return
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (input.visionModelId) {
|
|
214
|
-
const visionProfile = input.profiles.find((profile) => profile.id === input.visionModelId);
|
|
215
|
-
if (!visionProfile?.enabled || !visionProfile.capabilities.includes("vision")) {
|
|
216
|
-
errors["ai.vision_model_id"] = "Choose an enabled model profile with Vision support or disable the fallback.";
|
|
218
|
+
}): AiSettingsIssue[] => {
|
|
219
|
+
if (!input.enabled) return [];
|
|
220
|
+
|
|
221
|
+
const issues: AiSettingsIssue[] = [];
|
|
222
|
+
const isChatModel = (profile: AiModelProfile) => !profile.capabilities.includes("transcription");
|
|
223
|
+
const checkReference = (
|
|
224
|
+
setting: AiModelReferenceSetting,
|
|
225
|
+
profileId: string | undefined,
|
|
226
|
+
fits: (profile: AiModelProfile) => boolean,
|
|
227
|
+
required = false,
|
|
228
|
+
) => {
|
|
229
|
+
if (!profileId) {
|
|
230
|
+
if (required) issues.push({ code: "model_required", setting });
|
|
231
|
+
return;
|
|
217
232
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
errors["ai.workflow_model_id"] = "Choose an enabled chat model profile or use the background model.";
|
|
224
|
-
}
|
|
233
|
+
const profile = input.profiles.find((item) => item.id === profileId);
|
|
234
|
+
if (!profile) issues.push({ code: "model_profile_missing", setting, profileId });
|
|
235
|
+
else if (!profile.enabled) issues.push({ code: "model_profile_disabled", setting, profileId });
|
|
236
|
+
else if (!fits(profile)) issues.push({ code: "model_profile_incompatible", setting, profileId });
|
|
237
|
+
};
|
|
225
238
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
239
|
+
checkReference("ai.default_model_id", input.defaultModelId, isChatModel, true);
|
|
240
|
+
checkReference("ai.background_model_id", input.backgroundModelId, isChatModel);
|
|
241
|
+
checkReference("ai.workflow_model_id", input.workflowModelId, isChatModel);
|
|
242
|
+
checkReference("ai.vision_model_id", input.visionModelId, (profile) => profile.capabilities.includes("vision"));
|
|
243
|
+
checkReference("ai.audio_model_id", input.audioModelId, (profile) => profile.capabilities.includes("transcription"));
|
|
232
244
|
|
|
233
245
|
const configured = new Set(input.credentialProfileIds);
|
|
234
|
-
const
|
|
235
|
-
(profile
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
errors["ai.model_profiles_json"] = `Enter a provider API key for: ${missingCredentials.map((profile) => profile.label).join(", ")}.`;
|
|
246
|
+
for (const profile of input.profiles) {
|
|
247
|
+
if (profile.enabled && providerRequiresCredential(profile.provider) && !configured.has(profile.id)) {
|
|
248
|
+
issues.push({ code: "provider_credential_missing", setting: "ai.model_profiles_json", profileId: profile.id });
|
|
249
|
+
}
|
|
239
250
|
}
|
|
240
251
|
|
|
241
|
-
return
|
|
252
|
+
return issues;
|
|
242
253
|
};
|
|
243
254
|
|
|
244
255
|
export const planAiProfileCredentials = (input: {
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
splitAiModelAccess,
|
|
21
21
|
} from "../ai/model-access";
|
|
22
22
|
import { parseAiModelProfiles, planAiProfileCredentials, validateAiSettingsConfiguration } from "../ai/settings";
|
|
23
|
-
import { type
|
|
23
|
+
import { type AiSettingsIssueWithMessage, aiSettingsNotSavedMessage, describeAiSettingsIssues } from "../ai/settings-messages";
|
|
24
|
+
import { type AuthContext, auth, getLocale, jsonResponse, requiresAdmin, v } from "../server";
|
|
24
25
|
import { settingsDeleteLegacyKeys, settingsListLegacyKeys } from "../services";
|
|
25
26
|
import { readAccountCategoryPolicy } from "../services/account-category-policy";
|
|
26
27
|
import { audit } from "../services/audit";
|
|
@@ -97,6 +98,8 @@ const storeAiCredentials = async (
|
|
|
97
98
|
|
|
98
99
|
type AiSettingsMutationPlan = {
|
|
99
100
|
errors: FieldErrors;
|
|
101
|
+
/** Structured validation issues with stable codes; `errors` carries the same messages keyed by setting. */
|
|
102
|
+
issues?: AiSettingsIssueWithMessage[];
|
|
100
103
|
keepCredentialProfileIds?: string[];
|
|
101
104
|
modelProfileIds?: string[];
|
|
102
105
|
};
|
|
@@ -111,6 +114,7 @@ const prepareAiSettingsMutation = async (
|
|
|
111
114
|
updates: Record<string, unknown>,
|
|
112
115
|
resets: readonly string[],
|
|
113
116
|
aiSplit: ReturnType<typeof splitAiProfileCredentials>,
|
|
117
|
+
locale: string,
|
|
114
118
|
): Promise<AiSettingsMutationPlan> => {
|
|
115
119
|
const keys = [...Object.keys(updates), ...resets];
|
|
116
120
|
if (!keys.some((key) => AI_CONFIGURATION_KEYS.has(key))) return { errors: {} };
|
|
@@ -164,7 +168,7 @@ const prepareAiSettingsMutation = async (
|
|
|
164
168
|
keepCredentialProfileIds = credentialPlan.keepCredentialProfileIds;
|
|
165
169
|
}
|
|
166
170
|
|
|
167
|
-
const
|
|
171
|
+
const issues = validateAiSettingsConfiguration({
|
|
168
172
|
enabled: nextEnabled,
|
|
169
173
|
defaultModelId: String(valueAfterMutation(AI_DEFAULT_MODEL_KEY, currentDefaultModelId ?? "", updates, resets)),
|
|
170
174
|
backgroundModelId: String(valueAfterMutation(AI_BACKGROUND_MODEL_KEY, currentBackgroundModelId ?? "", updates, resets)),
|
|
@@ -174,8 +178,11 @@ const prepareAiSettingsMutation = async (
|
|
|
174
178
|
profiles: nextParsed.profiles,
|
|
175
179
|
credentialProfileIds: keepCredentialProfileIds ?? existingCredentialProfileIds,
|
|
176
180
|
});
|
|
181
|
+
// Stored profiles come second so a profile that is being removed keeps its label.
|
|
182
|
+
const described = describeAiSettingsIssues(issues, { locale, profiles: [...nextParsed.profiles, ...currentParsed.profiles] });
|
|
177
183
|
return {
|
|
178
|
-
errors,
|
|
184
|
+
errors: described.errors,
|
|
185
|
+
issues: described.issues,
|
|
179
186
|
keepCredentialProfileIds,
|
|
180
187
|
modelProfileIds: profilesUpdated || profilesReset ? nextParsed.profiles.map((profile) => profile.id) : undefined,
|
|
181
188
|
};
|
|
@@ -405,13 +412,13 @@ const app = new Hono<AuthContext>()
|
|
|
405
412
|
|
|
406
413
|
let aiPlan: AiSettingsMutationPlan;
|
|
407
414
|
try {
|
|
408
|
-
aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit);
|
|
415
|
+
aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
|
|
409
416
|
} catch (error) {
|
|
410
417
|
console.error("[settings] failed to validate the prospective AI configuration", error);
|
|
411
418
|
return c.json({ message: "Failed to validate AI settings" }, 500);
|
|
412
419
|
}
|
|
413
420
|
if (Object.keys(aiPlan.errors).length > 0) {
|
|
414
|
-
return c.json({ message:
|
|
421
|
+
return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
|
|
415
422
|
}
|
|
416
423
|
|
|
417
424
|
const fieldErrors: FieldErrors = {};
|
|
@@ -491,13 +498,13 @@ const app = new Hono<AuthContext>()
|
|
|
491
498
|
|
|
492
499
|
let aiPlan: AiSettingsMutationPlan;
|
|
493
500
|
try {
|
|
494
|
-
aiPlan = await prepareAiSettingsMutation({}, [key], null);
|
|
501
|
+
aiPlan = await prepareAiSettingsMutation({}, [key], null, getLocale(c));
|
|
495
502
|
} catch (error) {
|
|
496
503
|
console.error("[settings] failed to validate the prospective AI configuration", error);
|
|
497
504
|
return c.json({ message: "Failed to validate AI settings" }, 500);
|
|
498
505
|
}
|
|
499
506
|
if (Object.keys(aiPlan.errors).length > 0) {
|
|
500
|
-
return c.json({ message:
|
|
507
|
+
return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
|
|
501
508
|
}
|
|
502
509
|
|
|
503
510
|
try {
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CapabilityActionManifest, CapabilityQueryManifest } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A consumer-owned expectation of one provider capability: its kind, the input
|
|
6
|
+
* the consumer sends, and the minimum result data it reads.
|
|
7
|
+
*/
|
|
8
|
+
export type CapabilityContract = {
|
|
9
|
+
kind: "query" | "action";
|
|
10
|
+
idempotency?: "required";
|
|
11
|
+
input: z.ZodType;
|
|
12
|
+
data: z.ZodType;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CapabilityContractCandidate =
|
|
16
|
+
| { kind: "query"; operation: CapabilityQueryManifest }
|
|
17
|
+
| { kind: "action"; operation: CapabilityActionManifest };
|
|
18
|
+
|
|
19
|
+
export type CapabilityContractIssue = {
|
|
20
|
+
/** `kind`, `idempotency`, and `stream` concern the operation; `input` and `data` its published schemas. */
|
|
21
|
+
code: "kind" | "idempotency" | "stream" | "input" | "data";
|
|
22
|
+
/** JSON path inside the input or data schema, `$` for the root. */
|
|
23
|
+
path: string;
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Json = Record<string, unknown>;
|
|
28
|
+
type Side = { schema: unknown; root: Json };
|
|
29
|
+
type Mode = "input" | "data";
|
|
30
|
+
type PathIssue = { path: string; message: string };
|
|
31
|
+
const add = (issues: PathIssue[], path: string, message: string) => {
|
|
32
|
+
issues.push({ path, message });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const MAX_DEPTH = 32;
|
|
36
|
+
const IGNORED_KEYWORDS = new Set(["$schema", "$id", "$defs", "definitions", "title", "description", "default", "examples", "readOnly"]);
|
|
37
|
+
const SUPPORTED_KEYWORDS = new Set([
|
|
38
|
+
...IGNORED_KEYWORDS,
|
|
39
|
+
"type",
|
|
40
|
+
"const",
|
|
41
|
+
"enum",
|
|
42
|
+
"anyOf",
|
|
43
|
+
"oneOf",
|
|
44
|
+
"allOf",
|
|
45
|
+
"$ref",
|
|
46
|
+
"properties",
|
|
47
|
+
"required",
|
|
48
|
+
"additionalProperties",
|
|
49
|
+
"items",
|
|
50
|
+
"minItems",
|
|
51
|
+
"maxItems",
|
|
52
|
+
"minLength",
|
|
53
|
+
"maxLength",
|
|
54
|
+
"pattern",
|
|
55
|
+
"format",
|
|
56
|
+
"minimum",
|
|
57
|
+
"maximum",
|
|
58
|
+
"exclusiveMinimum",
|
|
59
|
+
"exclusiveMaximum",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const isObject = (value: unknown): value is Json => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
|
|
64
|
+
const deref = (side: Side, depth: number): Side | null => {
|
|
65
|
+
let current = side.schema;
|
|
66
|
+
for (let hops = 0; isObject(current) && typeof current.$ref === "string"; hops++) {
|
|
67
|
+
if (hops > depth) return null;
|
|
68
|
+
const ref = current.$ref;
|
|
69
|
+
if (ref === "#") current = side.root;
|
|
70
|
+
else if (ref.startsWith("#/$defs/")) current = (side.root.$defs as Json | undefined)?.[ref.slice(8)];
|
|
71
|
+
else return null;
|
|
72
|
+
}
|
|
73
|
+
return { schema: current, root: side.root };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** `true`, `{}`, and a schema with only annotations accept every value. */
|
|
77
|
+
const acceptsAnything = (schema: unknown): boolean =>
|
|
78
|
+
schema === true || (isObject(schema) && Object.keys(schema).every((key) => IGNORED_KEYWORDS.has(key)));
|
|
79
|
+
|
|
80
|
+
const typeSet = (schema: Json): Set<string> | null => {
|
|
81
|
+
if (typeof schema.type === "string") return new Set([schema.type]);
|
|
82
|
+
if (Array.isArray(schema.type)) return new Set(schema.type.filter((entry): entry is string => typeof entry === "string"));
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const valuesOf = (schema: Json): unknown[] | null => {
|
|
87
|
+
if ("const" in schema) return [schema.const];
|
|
88
|
+
if (Array.isArray(schema.enum)) return schema.enum;
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const jsonType = (value: unknown): string =>
|
|
93
|
+
value === null ? "null" : Array.isArray(value) ? "array" : Number.isInteger(value) ? "integer" : typeof value;
|
|
94
|
+
|
|
95
|
+
const typeAllows = (allowed: Set<string>, type: string): boolean => allowed.has(type) || (type === "integer" && allowed.has("number"));
|
|
96
|
+
|
|
97
|
+
const patternMatches = (pattern: string, value: string): boolean => {
|
|
98
|
+
try {
|
|
99
|
+
return new RegExp(pattern, "u").test(value);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Checks one literal value against a provider or contract schema. Unknown keywords never pass. */
|
|
106
|
+
const valueIssue = (value: unknown, sup: Side, depth: number): string | null => {
|
|
107
|
+
const resolved = deref(sup, depth);
|
|
108
|
+
if (!resolved) return "uses an unsupported reference";
|
|
109
|
+
const schema = resolved.schema;
|
|
110
|
+
if (acceptsAnything(schema)) return null;
|
|
111
|
+
if (!isObject(schema)) return "is not a JSON Schema object";
|
|
112
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
113
|
+
const branches = schema[keyword];
|
|
114
|
+
if (Array.isArray(branches) && !branches.some((branch) => valueIssue(value, { schema: branch, root: sup.root }, depth + 1) === null)) {
|
|
115
|
+
return `value ${JSON.stringify(value)} is not accepted`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const values = valuesOf(schema);
|
|
119
|
+
if (values && !values.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) {
|
|
120
|
+
return `value ${JSON.stringify(value)} is not accepted`;
|
|
121
|
+
}
|
|
122
|
+
const types = typeSet(schema);
|
|
123
|
+
if (types && !typeAllows(types, jsonType(value))) return `value ${JSON.stringify(value)} has the wrong type`;
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) return "value is too short";
|
|
126
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return "value is too long";
|
|
127
|
+
if (typeof schema.pattern === "string" && !patternMatches(schema.pattern, value)) return "value does not match the pattern";
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Bound = { value: number; exclusive: boolean };
|
|
133
|
+
const lowerBound = (schema: Json): Bound | null =>
|
|
134
|
+
typeof schema.exclusiveMinimum === "number"
|
|
135
|
+
? { value: schema.exclusiveMinimum, exclusive: true }
|
|
136
|
+
: typeof schema.minimum === "number"
|
|
137
|
+
? { value: schema.minimum, exclusive: false }
|
|
138
|
+
: null;
|
|
139
|
+
const upperBound = (schema: Json): Bound | null =>
|
|
140
|
+
typeof schema.exclusiveMaximum === "number"
|
|
141
|
+
? { value: schema.exclusiveMaximum, exclusive: true }
|
|
142
|
+
: typeof schema.maximum === "number"
|
|
143
|
+
? { value: schema.maximum, exclusive: false }
|
|
144
|
+
: null;
|
|
145
|
+
const withinLower = (sub: Bound | null, sup: Bound | null): boolean =>
|
|
146
|
+
!sup || (sub !== null && (sub.value > sup.value || (sub.value === sup.value && (sub.exclusive || !sup.exclusive))));
|
|
147
|
+
const withinUpper = (sub: Bound | null, sup: Bound | null): boolean =>
|
|
148
|
+
!sup || (sub !== null && (sub.value < sup.value || (sub.value === sup.value && (sub.exclusive || !sup.exclusive))));
|
|
149
|
+
|
|
150
|
+
const withinMin = (sub: unknown, sup: unknown): boolean => typeof sup !== "number" || (typeof sub === "number" && sub >= sup);
|
|
151
|
+
const withinMax = (sub: unknown, sup: unknown): boolean => typeof sup !== "number" || (typeof sub === "number" && sub <= sup);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Collects why `sub` may contain a value that `sup` rejects. The check is
|
|
155
|
+
* deliberately conservative: an unsupported keyword is an issue, never a pass.
|
|
156
|
+
*/
|
|
157
|
+
const subsetIssues = (sub: Side, sup: Side, path: string, mode: Mode, depth: number, issues: PathIssue[]): void => {
|
|
158
|
+
if (issues.length >= 5) return;
|
|
159
|
+
if (depth > MAX_DEPTH) {
|
|
160
|
+
add(issues, path, `schema is nested too deeply`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const subResolved = deref(sub, depth);
|
|
164
|
+
const supResolved = deref(sup, depth);
|
|
165
|
+
if (!subResolved || !supResolved) {
|
|
166
|
+
add(issues, path, `uses an unsupported reference`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const subSchema = subResolved.schema;
|
|
170
|
+
const supSchema = supResolved.schema;
|
|
171
|
+
if (acceptsAnything(supSchema)) return;
|
|
172
|
+
if (!isObject(supSchema)) {
|
|
173
|
+
if (supSchema === false) add(issues, path, `is not accepted`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (acceptsAnything(subSchema)) {
|
|
177
|
+
add(issues, path, `is unconstrained but must match the contract`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!isObject(subSchema)) return;
|
|
181
|
+
const unsupported = [...Object.keys(subSchema), ...Object.keys(supSchema)].find((key) => !SUPPORTED_KEYWORDS.has(key));
|
|
182
|
+
if (unsupported) {
|
|
183
|
+
add(issues, path, `uses the unsupported keyword ${unsupported}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const next = (schema: unknown, side: Side): Side => ({ schema, root: side.root });
|
|
187
|
+
|
|
188
|
+
// A union on the narrower side must fit branch by branch.
|
|
189
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
190
|
+
const branches = subSchema[keyword];
|
|
191
|
+
if (!Array.isArray(branches)) continue;
|
|
192
|
+
const { [keyword]: _branches, ...rest } = subSchema;
|
|
193
|
+
for (const branch of branches) {
|
|
194
|
+
const merged = isObject(branch) ? { ...rest, ...branch } : branch;
|
|
195
|
+
subsetIssues(next(merged, subResolved), supResolved, path, mode, depth + 1, issues);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (Array.isArray(subSchema.allOf)) {
|
|
200
|
+
// An intersection is narrower than each part; one fitting part is enough.
|
|
201
|
+
const { allOf, ...rest } = subSchema;
|
|
202
|
+
const fits = allOf.some((part) => {
|
|
203
|
+
const found: PathIssue[] = [];
|
|
204
|
+
subsetIssues(next(isObject(part) ? { ...rest, ...part } : part, subResolved), supResolved, path, mode, depth + 1, found);
|
|
205
|
+
return found.length === 0;
|
|
206
|
+
});
|
|
207
|
+
if (!fits) add(issues, path, `no part of the intersection matches the contract`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
211
|
+
const branches = supSchema[keyword];
|
|
212
|
+
if (!Array.isArray(branches)) continue;
|
|
213
|
+
const { [keyword]: _branches, ...rest } = supSchema;
|
|
214
|
+
const fits = branches.some((branch) => {
|
|
215
|
+
const found: PathIssue[] = [];
|
|
216
|
+
subsetIssues(subResolved, next(isObject(branch) ? { ...rest, ...branch } : branch, supResolved), path, mode, depth + 1, found);
|
|
217
|
+
return found.length === 0;
|
|
218
|
+
});
|
|
219
|
+
if (!fits) add(issues, path, `does not match any allowed shape`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(supSchema.allOf)) {
|
|
223
|
+
const { allOf, ...rest } = supSchema;
|
|
224
|
+
for (const part of allOf)
|
|
225
|
+
subsetIssues(subResolved, next(isObject(part) ? { ...rest, ...part } : part, supResolved), path, mode, depth + 1, issues);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const subValues = valuesOf(subSchema);
|
|
230
|
+
if (subValues) {
|
|
231
|
+
for (const value of subValues) {
|
|
232
|
+
const issue = valueIssue(value, supResolved, depth + 1);
|
|
233
|
+
if (issue) add(issues, path, `${issue}`);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (valuesOf(supSchema)) {
|
|
238
|
+
add(issues, path, `must be limited to the contract's allowed values`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const supTypes = typeSet(supSchema);
|
|
243
|
+
const subTypes = typeSet(subSchema);
|
|
244
|
+
if (supTypes) {
|
|
245
|
+
if (!subTypes) {
|
|
246
|
+
add(issues, path, `must declare a type`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const extra = [...subTypes].filter((type) => !typeAllows(supTypes, type));
|
|
250
|
+
if (extra.length > 0) {
|
|
251
|
+
add(issues, path, `allows ${extra.join(", ")} but the contract expects ${[...supTypes].join(", ")}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const types = subTypes ?? new Set<string>();
|
|
256
|
+
|
|
257
|
+
// Providers own their identifier and text formats; input checks compare shape, not string syntax.
|
|
258
|
+
if (types.has("string") && mode === "data") {
|
|
259
|
+
if (typeof supSchema.format === "string" && subSchema.format !== supSchema.format) {
|
|
260
|
+
add(issues, path, `must use the ${supSchema.format} format`);
|
|
261
|
+
}
|
|
262
|
+
const formatCoversPattern = typeof supSchema.format === "string" && subSchema.format === supSchema.format;
|
|
263
|
+
if (typeof supSchema.pattern === "string" && !formatCoversPattern && subSchema.pattern !== supSchema.pattern) {
|
|
264
|
+
add(issues, path, `must use the contract pattern`);
|
|
265
|
+
}
|
|
266
|
+
if (!withinMin(subSchema.minLength, supSchema.minLength)) add(issues, path, `must be at least ${supSchema.minLength} characters`);
|
|
267
|
+
if (!withinMax(subSchema.maxLength, supSchema.maxLength)) add(issues, path, `must be at most ${supSchema.maxLength} characters`);
|
|
268
|
+
}
|
|
269
|
+
if (types.has("number") || types.has("integer")) {
|
|
270
|
+
if (!withinLower(lowerBound(subSchema), lowerBound(supSchema)) || !withinUpper(upperBound(subSchema), upperBound(supSchema))) {
|
|
271
|
+
add(issues, path, `allows numbers outside the contract range`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (types.has("array")) {
|
|
275
|
+
if (!withinMin(subSchema.minItems, supSchema.minItems)) add(issues, path, `must contain at least ${supSchema.minItems} items`);
|
|
276
|
+
if (!withinMax(subSchema.maxItems, supSchema.maxItems)) add(issues, path, `must contain at most ${supSchema.maxItems} items`);
|
|
277
|
+
if (supSchema.items !== undefined) {
|
|
278
|
+
subsetIssues(next(subSchema.items ?? true, subResolved), next(supSchema.items, supResolved), `${path}[]`, mode, depth + 1, issues);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (types.has("object")) objectIssues(subSchema, subResolved, supSchema, supResolved, path, mode, depth, issues);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const objectIssues = (
|
|
285
|
+
subSchema: Json,
|
|
286
|
+
sub: Side,
|
|
287
|
+
supSchema: Json,
|
|
288
|
+
sup: Side,
|
|
289
|
+
path: string,
|
|
290
|
+
mode: Mode,
|
|
291
|
+
depth: number,
|
|
292
|
+
issues: PathIssue[],
|
|
293
|
+
) => {
|
|
294
|
+
const subProperties = isObject(subSchema.properties) ? subSchema.properties : {};
|
|
295
|
+
const supProperties = isObject(supSchema.properties) ? supSchema.properties : {};
|
|
296
|
+
const subRequired = new Set(Array.isArray(subSchema.required) ? subSchema.required : []);
|
|
297
|
+
const supRequired = Array.isArray(supSchema.required) ? supSchema.required : [];
|
|
298
|
+
const subAdditional = subSchema.additionalProperties ?? true;
|
|
299
|
+
const supAdditional = supSchema.additionalProperties ?? true;
|
|
300
|
+
const field = (name: string) => `${path}.${name}`;
|
|
301
|
+
|
|
302
|
+
for (const name of supRequired) {
|
|
303
|
+
if (typeof name === "string" && !subRequired.has(name)) {
|
|
304
|
+
add(
|
|
305
|
+
issues,
|
|
306
|
+
field(name),
|
|
307
|
+
`${mode === "data" ? "must always be returned" : "is required by the provider but optional in the contract"}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const [name, schema] of Object.entries(subProperties)) {
|
|
312
|
+
if (name in supProperties) {
|
|
313
|
+
subsetIssues({ schema, root: sub.root }, { schema: supProperties[name], root: sup.root }, field(name), mode, depth + 1, issues);
|
|
314
|
+
} else if (supAdditional === false) {
|
|
315
|
+
add(issues, field(name), `is not accepted`);
|
|
316
|
+
} else {
|
|
317
|
+
subsetIssues({ schema, root: sub.root }, { schema: supAdditional, root: sup.root }, field(name), mode, depth + 1, issues);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (subAdditional === false) return;
|
|
321
|
+
for (const [name, schema] of Object.entries(supProperties)) {
|
|
322
|
+
if (!(name in subProperties)) {
|
|
323
|
+
subsetIssues({ schema: subAdditional, root: sub.root }, { schema, root: sup.root }, field(name), mode, depth + 1, issues);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (supAdditional === false) add(issues, path, `must not allow undeclared properties`);
|
|
327
|
+
else
|
|
328
|
+
subsetIssues(
|
|
329
|
+
{ schema: subAdditional, root: sub.root },
|
|
330
|
+
{ schema: supAdditional, root: sup.root },
|
|
331
|
+
`${path}.*`,
|
|
332
|
+
mode,
|
|
333
|
+
depth + 1,
|
|
334
|
+
issues,
|
|
335
|
+
);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const schemaIssues = (sub: Json, sup: Json, mode: Mode): PathIssue[] => {
|
|
339
|
+
const issues: PathIssue[] = [];
|
|
340
|
+
subsetIssues({ schema: sub, root: sub }, { schema: sup, root: sup }, "$", mode, 0, issues);
|
|
341
|
+
return issues;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Explains why a published capability cannot serve a consumer contract.
|
|
346
|
+
* An empty list means every contract input is accepted by the provider and
|
|
347
|
+
* every result the provider may return satisfies the contract data schema.
|
|
348
|
+
* Input checks compare shape, required fields, values, and numeric bounds;
|
|
349
|
+
* string syntax such as identifier patterns stays with the provider.
|
|
350
|
+
*/
|
|
351
|
+
export const capabilityContractIssues = (
|
|
352
|
+
contract: CapabilityContract,
|
|
353
|
+
candidate: CapabilityContractCandidate,
|
|
354
|
+
): CapabilityContractIssue[] => {
|
|
355
|
+
if (candidate.kind !== contract.kind) {
|
|
356
|
+
return [{ code: "kind", path: "$", message: `Expected ${contract.kind === "query" ? "a Query" : "an Action"}` }];
|
|
357
|
+
}
|
|
358
|
+
const issues: CapabilityContractIssue[] = [];
|
|
359
|
+
if (candidate.operation.stream) issues.push({ code: "stream", path: "$", message: "Streaming capabilities are not supported" });
|
|
360
|
+
if (contract.idempotency === "required" && candidate.kind === "action" && candidate.operation.idempotency !== "required") {
|
|
361
|
+
issues.push({ code: "idempotency", path: "$", message: "The Action must require an idempotency key" });
|
|
362
|
+
}
|
|
363
|
+
const contractInput = z.toJSONSchema(contract.input, { io: "input" }) as Json;
|
|
364
|
+
const contractData = z.toJSONSchema(contract.data, { io: "output" }) as Json;
|
|
365
|
+
for (const issue of schemaIssues(contractInput, candidate.operation.inputSchema, "input")) issues.push({ code: "input", ...issue });
|
|
366
|
+
for (const issue of schemaIssues(candidate.operation.dataSchema, contractData, "data")) issues.push({ code: "data", ...issue });
|
|
367
|
+
return issues;
|
|
368
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CapabilityQualifiedIdSchema, CapabilitySemanticLinkSchema } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provider-neutral contact-directory contract.
|
|
6
|
+
*
|
|
7
|
+
* A consumer such as Mail maps each function to one Query or Action of one
|
|
8
|
+
* provider application. Providers keep their own identifiers, permissions,
|
|
9
|
+
* and extra result fields; the schemas below are the minimum both sides share.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Identifiers are opaque provider strings; each provider owns their syntax and length.
|
|
13
|
+
const ContactDirectoryIdSchema = z.string();
|
|
14
|
+
const TimestampSchema = z.string().datetime({ offset: true });
|
|
15
|
+
const NullableTextSchema = z.string().nullable();
|
|
16
|
+
const CursorSchema = z.string().min(1).max(256);
|
|
17
|
+
const EmailSchema = z.email().max(320);
|
|
18
|
+
const LinksSchema = z.array(CapabilitySemanticLinkSchema).min(1).max(10).optional();
|
|
19
|
+
|
|
20
|
+
const ContactDirectoryEmailSchema = z.object({ label: NullableTextSchema, email: z.email() }).loose();
|
|
21
|
+
const ContactDirectoryPhoneSchema = z.object({ label: NullableTextSchema, phone: z.string().min(1) }).loose();
|
|
22
|
+
|
|
23
|
+
const contactFacts = {
|
|
24
|
+
contactId: ContactDirectoryIdSchema,
|
|
25
|
+
bookId: ContactDirectoryIdSchema,
|
|
26
|
+
displayName: z.string().min(1),
|
|
27
|
+
companyName: NullableTextSchema,
|
|
28
|
+
jobTitle: NullableTextSchema,
|
|
29
|
+
contactPointsTruncated: z.boolean(),
|
|
30
|
+
links: LinksSchema,
|
|
31
|
+
updatedAt: TimestampSchema,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const ContactDirectorySuggestInputSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
query: z.string().min(2).max(500).describe("Text typed by the user, matched against names, organizations, and email addresses."),
|
|
37
|
+
cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page."),
|
|
38
|
+
limit: z.number().int().min(1).max(25).optional().describe("Maximum number of suggestions to return."),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
|
|
42
|
+
export const ContactDirectorySuggestionSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
...contactFacts,
|
|
45
|
+
emails: z.array(ContactDirectoryEmailSchema).min(1).max(20),
|
|
46
|
+
phones: z.array(ContactDirectoryPhoneSchema).max(20),
|
|
47
|
+
})
|
|
48
|
+
.loose();
|
|
49
|
+
export const ContactDirectorySuggestDataSchema = z.array(ContactDirectorySuggestionSchema).max(25);
|
|
50
|
+
|
|
51
|
+
export const ContactDirectoryResolveInputSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
emails: z.array(EmailSchema).min(1).max(100).describe("Normalized email addresses to resolve to every readable matching contact."),
|
|
54
|
+
contactIds: z
|
|
55
|
+
.array(ContactDirectoryIdSchema)
|
|
56
|
+
.max(20)
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Optional contact IDs returned by this provider that further restrict the matches."),
|
|
59
|
+
cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page."),
|
|
60
|
+
limit: z.number().int().min(1).max(50).optional().describe("Maximum number of exact contact matches to return."),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
|
|
64
|
+
export const ContactDirectoryMatchSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
ref: z.object({ type: CapabilityQualifiedIdSchema, id: ContactDirectoryIdSchema }).strict(),
|
|
67
|
+
...contactFacts,
|
|
68
|
+
bookName: z.string().min(1),
|
|
69
|
+
matchedEmails: z.array(EmailSchema).min(1).max(100),
|
|
70
|
+
emails: z.array(ContactDirectoryEmailSchema).max(20),
|
|
71
|
+
phones: z.array(ContactDirectoryPhoneSchema).max(20),
|
|
72
|
+
})
|
|
73
|
+
.loose();
|
|
74
|
+
export const ContactDirectoryResolveDataSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
items: z.array(ContactDirectoryMatchSchema).max(50),
|
|
77
|
+
matchedEmails: z.array(EmailSchema).max(100),
|
|
78
|
+
})
|
|
79
|
+
.loose();
|
|
80
|
+
|
|
81
|
+
export const ContactDirectoryReadInputSchema = z
|
|
82
|
+
.object({ id: ContactDirectoryIdSchema.describe("Contact ID returned by this provider.") })
|
|
83
|
+
.strict();
|
|
84
|
+
|
|
85
|
+
export const ContactDirectoryContactSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
id: ContactDirectoryIdSchema,
|
|
88
|
+
bookId: ContactDirectoryIdSchema,
|
|
89
|
+
displayName: z.string().min(1),
|
|
90
|
+
companyName: NullableTextSchema,
|
|
91
|
+
jobTitle: NullableTextSchema,
|
|
92
|
+
emails: z.array(ContactDirectoryEmailSchema).max(100),
|
|
93
|
+
phones: z.array(ContactDirectoryPhoneSchema).max(100),
|
|
94
|
+
updatedAt: TimestampSchema,
|
|
95
|
+
})
|
|
96
|
+
.loose();
|
|
97
|
+
|
|
98
|
+
export const ContactDirectoryBookListInputSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
query: z.string().max(500).optional().describe("Optional book name or description search."),
|
|
101
|
+
minimumPermission: z.literal("write").describe("Only books in which the caller can create contacts."),
|
|
102
|
+
cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page."),
|
|
103
|
+
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of books to return."),
|
|
104
|
+
})
|
|
105
|
+
.strict();
|
|
106
|
+
|
|
107
|
+
export const ContactDirectoryBookSchema = z
|
|
108
|
+
.object({ id: ContactDirectoryIdSchema, name: z.string().min(1), description: NullableTextSchema })
|
|
109
|
+
.loose();
|
|
110
|
+
export const ContactDirectoryBookListDataSchema = z.array(ContactDirectoryBookSchema).max(100);
|
|
111
|
+
|
|
112
|
+
const ContactPointLabelSchema = z.string().max(100).nullable().optional().describe("Optional label.");
|
|
113
|
+
const ContactNameFieldSchema = z.string().max(200).nullable().optional();
|
|
114
|
+
|
|
115
|
+
export const ContactDirectoryCreateInputSchema = z
|
|
116
|
+
.object({
|
|
117
|
+
bookId: ContactDirectoryIdSchema.describe("Writable book ID returned by the book list."),
|
|
118
|
+
label: ContactNameFieldSchema.describe("Optional display label."),
|
|
119
|
+
firstName: ContactNameFieldSchema.describe("Person first name."),
|
|
120
|
+
lastName: ContactNameFieldSchema.describe("Person last name."),
|
|
121
|
+
companyName: ContactNameFieldSchema.describe("Organization name."),
|
|
122
|
+
emails: z
|
|
123
|
+
.array(z.object({ label: ContactPointLabelSchema, email: EmailSchema.describe("Email address.") }).strict())
|
|
124
|
+
.max(20)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Email addresses."),
|
|
127
|
+
phones: z
|
|
128
|
+
.array(z.object({ label: ContactPointLabelSchema, phone: z.string().min(1).max(100).describe("Phone number.") }).strict())
|
|
129
|
+
.max(20)
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("Phone numbers."),
|
|
132
|
+
})
|
|
133
|
+
.strict();
|
|
134
|
+
export const ContactDirectoryCreateDataSchema = z.object({ contact: ContactDirectoryContactSchema }).loose();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The five contact-directory functions. `kind` and `idempotency` are part of
|
|
138
|
+
* the contract: a provider capability must match them to be selectable.
|
|
139
|
+
*/
|
|
140
|
+
export const contactDirectory = {
|
|
141
|
+
suggest: { kind: "query", input: ContactDirectorySuggestInputSchema, data: ContactDirectorySuggestDataSchema },
|
|
142
|
+
resolve: { kind: "query", input: ContactDirectoryResolveInputSchema, data: ContactDirectoryResolveDataSchema },
|
|
143
|
+
read: { kind: "query", input: ContactDirectoryReadInputSchema, data: ContactDirectoryContactSchema },
|
|
144
|
+
listWritableBooks: { kind: "query", input: ContactDirectoryBookListInputSchema, data: ContactDirectoryBookListDataSchema },
|
|
145
|
+
create: {
|
|
146
|
+
kind: "action",
|
|
147
|
+
idempotency: "required",
|
|
148
|
+
input: ContactDirectoryCreateInputSchema,
|
|
149
|
+
data: ContactDirectoryCreateDataSchema,
|
|
150
|
+
},
|
|
151
|
+
} as const;
|
|
152
|
+
|
|
153
|
+
export type ContactDirectoryFunction = keyof typeof contactDirectory;
|
|
154
|
+
export const CONTACT_DIRECTORY_FUNCTIONS = [
|
|
155
|
+
"suggest",
|
|
156
|
+
"resolve",
|
|
157
|
+
"read",
|
|
158
|
+
"listWritableBooks",
|
|
159
|
+
"create",
|
|
160
|
+
] as const satisfies readonly ContactDirectoryFunction[];
|
|
161
|
+
|
|
162
|
+
export type ContactDirectorySuggestion = z.output<typeof ContactDirectorySuggestionSchema>;
|
|
163
|
+
export type ContactDirectoryMatch = z.output<typeof ContactDirectoryMatchSchema>;
|
|
164
|
+
export type ContactDirectoryContact = z.output<typeof ContactDirectoryContactSchema>;
|
|
165
|
+
export type ContactDirectoryBook = z.output<typeof ContactDirectoryBookSchema>;
|
package/src/contracts/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ export * from "./announcements";
|
|
|
3
3
|
export * from "./app";
|
|
4
4
|
export * from "./app-approval";
|
|
5
5
|
export * from "./capabilities";
|
|
6
|
+
export * from "./capability-compatibility";
|
|
6
7
|
export * from "./commands";
|
|
8
|
+
export * from "./contact-directory";
|
|
7
9
|
export * from "./notification-types";
|
|
8
10
|
export * from "./posix";
|
|
9
11
|
export * from "./profile";
|
|
@@ -106,6 +106,8 @@ const mapConversionError = (error: unknown, format: DocumentFormat): DocumentExt
|
|
|
106
106
|
case "malformed":
|
|
107
107
|
case "missingPart":
|
|
108
108
|
return new DocumentExtractionError("malformed", "The document is malformed or incomplete.");
|
|
109
|
+
case "needsOcr":
|
|
110
|
+
return new DocumentExtractionError("ocr_required", "The PDF has no readable text and requires OCR.");
|
|
109
111
|
case "unsupported":
|
|
110
112
|
return format === "pdf"
|
|
111
113
|
? new DocumentExtractionError("ocr_required", "The PDF has no readable text and requires OCR.")
|
package/src/services/webauthn.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
2
|
import {
|
|
3
3
|
type AuthenticationResponseJSON,
|
|
4
|
-
type AuthenticatorTransportFuture,
|
|
5
4
|
generateAuthenticationOptions,
|
|
6
5
|
generateRegistrationOptions,
|
|
7
6
|
type PublicKeyCredentialCreationOptionsJSON,
|
|
@@ -137,7 +136,7 @@ const toCredential = (row: DbPasskeyRow): WebAuthnCredential => ({
|
|
|
137
136
|
id: row.credential_id,
|
|
138
137
|
publicKey: new Uint8Array(row.public_key) as WebAuthnCredential["publicKey"],
|
|
139
138
|
counter: Number(row.counter),
|
|
140
|
-
transports:
|
|
139
|
+
transports: row.transports ?? [],
|
|
141
140
|
});
|
|
142
141
|
|
|
143
142
|
const isExpired = (date: Date | null): boolean => Boolean(date && date.getTime() <= Date.now());
|
|
@@ -176,7 +175,7 @@ export const beginRegistration = async (params: { user: User }): Promise<Result<
|
|
|
176
175
|
},
|
|
177
176
|
excludeCredentials: existing.map((credential) => ({
|
|
178
177
|
id: credential.credentialId,
|
|
179
|
-
transports: credential.transports
|
|
178
|
+
transports: credential.transports,
|
|
180
179
|
})),
|
|
181
180
|
});
|
|
182
181
|
|