@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.10.1",
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.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",
101
101
  "@k2b/ui": "0.5.0",
102
102
  "@k2b/stdlib": "0.25.0",
103
- "@k2b/sync": "6.4.0",
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.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 {
@@ -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>;
@@ -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.")
@@ -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