@k2b/cloud 0.10.1 → 0.10.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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,12 +89,12 @@
89
89
  "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
90
90
  },
91
91
  "dependencies": {
92
- "@firecrawl/anydoc": "0.1.9",
93
- "@napi-rs/canvas": "1.0.8",
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": "13.3.2",
97
- "@tabler/icons": "3.46.0",
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",
@@ -105,34 +105,33 @@
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.5",
109
- "katex": "0.16.47",
110
- "liquidjs": "10.27.2",
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": "9.1.1",
114
- "postcss": "8.5.25",
113
+ "nodemailer": "10.0.10",
114
+ "postcss": "8.5.28",
115
115
  "reflect-metadata": "0.2.2",
116
- "sanitize-html": "2.17.6",
117
- "seroval": "1.6.0",
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.1",
121
- "yaml": "2.9.0",
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": "7.29.7",
129
- "@babel/preset-typescript": "7.29.7",
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.12",
132
- "hono": "4.12.32",
133
- "solid-js": "1.9.14",
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.2"
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();
@@ -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
- }): Record<string, string> => {
198
- if (!input.enabled) return {};
199
-
200
- const errors: Record<string, string> = {};
201
- const enabledProfiles = input.profiles.filter((profile) => profile.enabled);
202
- const defaultProfile = input.profiles.find((profile) => profile.id === input.defaultModelId);
203
- if (!input.defaultModelId || !defaultProfile?.enabled || defaultProfile.capabilities.includes("transcription")) {
204
- errors["ai.default_model_id"] = "Choose an enabled model profile.";
205
- }
206
-
207
- if (input.backgroundModelId) {
208
- const backgroundProfile = input.profiles.find((profile) => profile.id === input.backgroundModelId);
209
- if (!backgroundProfile?.enabled || backgroundProfile.capabilities.includes("transcription"))
210
- errors["ai.background_model_id"] = "Choose an enabled chat model profile or use the platform default.";
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
- if (input.workflowModelId) {
221
- const workflowProfile = input.profiles.find((profile) => profile.id === input.workflowModelId);
222
- if (!workflowProfile?.enabled || workflowProfile.capabilities.includes("transcription"))
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
- if (input.audioModelId) {
227
- const audioProfile = input.profiles.find((profile) => profile.id === input.audioModelId);
228
- if (!audioProfile?.enabled || !audioProfile.capabilities.includes("transcription")) {
229
- errors["ai.audio_model_id"] = "Choose an enabled audio transcription profile or disable audio transcription.";
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 missingCredentials = enabledProfiles.filter(
235
- (profile) => providerRequiresCredential(profile.provider) && !configured.has(profile.id),
236
- );
237
- if (missingCredentials.length > 0) {
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 errors;
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 AuthContext, auth, jsonResponse, requiresAdmin, v } from "../server";
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 errors = validateAiSettingsConfiguration({
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: "Invalid AI configuration", errors: aiPlan.errors }, 400);
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: "Invalid AI configuration", errors: aiPlan.errors }, 400);
507
+ return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
501
508
  }
502
509
 
503
510
  try {
@@ -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.")
@@ -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: (row.transports ?? []) as AuthenticatorTransportFuture[],
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 as AuthenticatorTransportFuture[],
178
+ transports: credential.transports,
180
179
  })),
181
180
  });
182
181