@ganju/utils 0.0.13 → 0.0.15

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.
Files changed (70) hide show
  1. package/dist/channelCustomer.d.ts +99 -0
  2. package/dist/channelCustomer.d.ts.map +1 -0
  3. package/dist/channelCustomer.js +533 -0
  4. package/dist/channelDebounce.d.ts +7 -0
  5. package/dist/channelDebounce.d.ts.map +1 -1
  6. package/dist/chunkOverlap.d.ts +2 -0
  7. package/dist/chunkOverlap.d.ts.map +1 -0
  8. package/dist/chunkOverlap.js +9 -0
  9. package/dist/chunking.d.ts.map +1 -1
  10. package/dist/chunking.js +24 -4
  11. package/dist/citations.d.ts +28 -0
  12. package/dist/citations.d.ts.map +1 -0
  13. package/dist/citations.js +111 -0
  14. package/dist/constants.d.ts +26 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +108 -10
  17. package/dist/index.d.ts +263 -31
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +70 -0
  20. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  21. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  22. package/dist/jsonSchemaToZodShape.js +21 -9
  23. package/dist/pagination.d.ts +47 -0
  24. package/dist/pagination.d.ts.map +1 -0
  25. package/dist/pagination.js +160 -0
  26. package/dist/processQueueBatch.d.ts +6 -1
  27. package/dist/processQueueBatch.d.ts.map +1 -1
  28. package/dist/processQueueBatch.js +23 -10
  29. package/dist/queueFailure.d.ts +14 -0
  30. package/dist/queueFailure.d.ts.map +1 -0
  31. package/dist/queueFailure.js +43 -0
  32. package/dist/resourceRerank.d.ts.map +1 -1
  33. package/dist/resourceRerank.js +3 -1
  34. package/dist/schema.d.ts +90 -26
  35. package/dist/schema.d.ts.map +1 -1
  36. package/dist/schema.js +87 -6
  37. package/dist/ssrf.d.ts +1 -0
  38. package/dist/ssrf.d.ts.map +1 -1
  39. package/dist/ssrf.js +48 -12
  40. package/dist/toolConfirmation.d.ts +48 -0
  41. package/dist/toolConfirmation.d.ts.map +1 -1
  42. package/dist/toolConfirmation.js +75 -1
  43. package/dist/toolCount.d.ts +13 -0
  44. package/dist/toolCount.d.ts.map +1 -0
  45. package/dist/toolCount.js +45 -0
  46. package/dist/toolLint.d.ts +1 -1
  47. package/dist/toolLint.d.ts.map +1 -1
  48. package/dist/toolLint.js +37 -3
  49. package/dist/untrustedContent.d.ts +135 -0
  50. package/dist/untrustedContent.d.ts.map +1 -0
  51. package/dist/untrustedContent.js +402 -0
  52. package/package.json +2 -1
  53. package/src/channelCustomer.ts +604 -0
  54. package/src/channelDebounce.ts +17 -0
  55. package/src/chunkOverlap.ts +6 -0
  56. package/src/chunking.ts +25 -4
  57. package/src/citations.ts +127 -0
  58. package/src/constants.ts +112 -10
  59. package/src/index.ts +152 -2
  60. package/src/jsonSchemaToZodShape.ts +31 -13
  61. package/src/pagination.ts +193 -0
  62. package/src/processQueueBatch.ts +42 -12
  63. package/src/queueFailure.ts +63 -0
  64. package/src/resourceRerank.ts +5 -1
  65. package/src/schema.ts +100 -4
  66. package/src/ssrf.ts +40 -11
  67. package/src/toolConfirmation.ts +123 -0
  68. package/src/toolCount.ts +53 -0
  69. package/src/toolLint.ts +46 -3
  70. package/src/untrustedContent.ts +476 -0
@@ -1,7 +1,7 @@
1
1
  import * as z from 'zod';
2
2
 
3
3
  export type JsonSchemaProperty = {
4
- type: 'string' | 'number' | 'boolean' | 'object' | 'array';
4
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
5
5
  description?: string;
6
6
  minimum?: number;
7
7
  maximum?: number;
@@ -10,6 +10,10 @@ export type JsonSchemaProperty = {
10
10
  pattern?: string;
11
11
  enum?: string[];
12
12
  items?: JsonSchemaProperty;
13
+ minItems?: number;
14
+ maxItems?: number;
15
+ properties?: Record<string, JsonSchemaProperty>;
16
+ required?: string[];
13
17
  };
14
18
 
15
19
  export type JsonSchema = {
@@ -35,8 +39,9 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
35
39
  }
36
40
  break;
37
41
  }
38
- case 'number': {
39
- let num = z.number();
42
+ case 'number':
43
+ case 'integer': {
44
+ let num = prop.type === 'integer' ? z.number().int() : z.number();
40
45
  if (prop.minimum !== undefined) num = num.min(prop.minimum);
41
46
  if (prop.maximum !== undefined) num = num.max(prop.maximum);
42
47
  field = num;
@@ -45,11 +50,21 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
45
50
  case 'boolean':
46
51
  field = z.boolean();
47
52
  break;
48
- case 'array':
49
- field = z.array(prop.items ? propertyToZod(prop.items) : z.any());
53
+ case 'array': {
54
+ let arr = z.array(prop.items ? propertyToZod(prop.items) : z.any());
55
+ if (prop.minItems !== undefined) arr = arr.min(prop.minItems);
56
+ if (prop.maxItems !== undefined) arr = arr.max(prop.maxItems);
57
+ field = arr;
50
58
  break;
59
+ }
51
60
  case 'object':
52
- field = z.record(z.string(), z.any());
61
+ // With declared properties, keep them: this shape is also what
62
+ // `tools/list` advertises, and a bare record there tells the model an
63
+ // item is any object at all, so it invents the field names. Loose,
64
+ // because a schema without `additionalProperties: false` allows extras.
65
+ field = prop.properties
66
+ ? z.looseObject(shapeOf(prop.properties, prop.required))
67
+ : z.record(z.string(), z.any());
53
68
  break;
54
69
  default:
55
70
  field = z.any();
@@ -62,12 +77,10 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
62
77
  return field;
63
78
  };
64
79
 
65
- export const jsonSchemaToZodShape = (
66
- schema: JsonSchema
67
- ): Record<string, z.ZodTypeAny> => {
68
- const properties = schema.properties ?? {};
69
- const required = schema.required ?? [];
70
-
80
+ function shapeOf(
81
+ properties: Record<string, JsonSchemaProperty> = {},
82
+ required: string[] = []
83
+ ): Record<string, z.ZodTypeAny> {
71
84
  const shape: Record<string, z.ZodTypeAny> = {};
72
85
 
73
86
  for (const [key, prop] of Object.entries(properties)) {
@@ -76,7 +89,12 @@ export const jsonSchemaToZodShape = (
76
89
  }
77
90
 
78
91
  return shape;
79
- };
92
+ }
93
+
94
+ export const jsonSchemaToZodShape = (
95
+ schema: JsonSchema
96
+ ): Record<string, z.ZodTypeAny> =>
97
+ shapeOf(schema.properties, schema.required);
80
98
 
81
99
  export interface SchemaViolation {
82
100
  // Dotted path to the offending field, empty for the value as a whole.
@@ -0,0 +1,193 @@
1
+ // Large tool results, in one shape: a list a tool owns is paged by a cursor the
2
+ // model passes back, and text that has to be cut says so, how much was left
3
+ // out, and where to continue when the result itself carried a cursor.
4
+ //
5
+ // Imports nothing, so node --test runs it directly.
6
+
7
+ // Keys a paged API commonly answers with. Read at the top level only: deeper
8
+ // than that, a key with one of these names is somebody's data.
9
+ const NEXT_CURSOR_KEYS = [
10
+ 'nextCursor',
11
+ 'next_cursor',
12
+ 'nextPageToken',
13
+ 'next_page_token',
14
+ 'nextPage',
15
+ 'next_page',
16
+ 'next'
17
+ ];
18
+
19
+ /**
20
+ * The cursor for the next page a JSON result names at its top level, if any.
21
+ * Cut text can lose it, so it is read from the parsed value before cutting.
22
+ */
23
+ export const findNextCursor = (value: unknown): string | undefined => {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
25
+ return undefined;
26
+ }
27
+ for (const key of NEXT_CURSOR_KEYS) {
28
+ const found = (value as Record<string, unknown>)[key];
29
+ if (typeof found === 'string' && found) return `${key}: ${found}`;
30
+ if (typeof found === 'number') return `${key}: ${found}`;
31
+ }
32
+ return undefined;
33
+ };
34
+
35
+ const kb = (bytes: number) => `${Math.max(1, Math.round(bytes / 1024))} KB`;
36
+
37
+ /**
38
+ * `text` within `maxBytes` of UTF-8, or its start with a note that says it was
39
+ * cut. A character split by the cut is dropped rather than shown broken.
40
+ * `next` names the result's own cursor, for "call again with it".
41
+ */
42
+ export const capText = (
43
+ text: string,
44
+ maxBytes: number,
45
+ next?: string
46
+ ): string => {
47
+ const bytes = new TextEncoder().encode(text);
48
+ if (bytes.byteLength <= maxBytes) return text;
49
+
50
+ const kept = new TextDecoder()
51
+ .decode(bytes.slice(0, maxBytes))
52
+ .replace(/�+$/, '');
53
+ const continueWith = next
54
+ ? ` The result names its next page (${next}): call again with it to continue.`
55
+ : ' To see the rest, call again asking for less — narrower filters, a smaller page or fewer fields.';
56
+ return `${kept}\n\n[Truncated: showing the first ${kb(maxBytes)} of ${kb(
57
+ bytes.byteLength
58
+ )}.${continueWith}]`;
59
+ };
60
+
61
+ // Opaque to the model, so it is passed back as given rather than edited.
62
+ const encodeCursor = (key: string): string =>
63
+ btoa(String.fromCharCode(...new TextEncoder().encode(key)))
64
+ .replace(/\+/g, '-')
65
+ .replace(/\//g, '_')
66
+ .replace(/=+$/, '');
67
+
68
+ const decodeCursor = (cursor: string): string | null => {
69
+ try {
70
+ const base64 = cursor.replace(/-/g, '+').replace(/_/g, '/');
71
+ const binary = atob(base64);
72
+ return new TextDecoder('utf-8', { fatal: true }).decode(
73
+ Uint8Array.from(binary, char => char.charCodeAt(0))
74
+ );
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ // Between a cursor's key and the count of items with that key already served.
81
+ // A key is a uri or a name, never a NUL.
82
+ const CURSOR_SEEN_SEPARATOR = '\u0000';
83
+
84
+ // A cursor with no count (one issued before the count was added) resumes
85
+ // after every item with its key.
86
+ const splitCursorKey = (decoded: string): { key: string; seen: number } => {
87
+ const at = decoded.lastIndexOf(CURSOR_SEEN_SEPARATOR);
88
+ if (at === -1) return { key: decoded, seen: Infinity };
89
+ const count = decoded.slice(at + 1);
90
+ if (!/^\d+$/.test(count)) throw new InvalidCursorError();
91
+ return { key: decoded.slice(0, at), seen: Number(count) };
92
+ };
93
+
94
+ export class InvalidCursorError extends Error {
95
+ constructor() {
96
+ super(
97
+ 'That cursor is not valid. Call again without a cursor to start from the first page.'
98
+ );
99
+ this.name = 'InvalidCursorError';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * What `list-resources` answers, wherever it runs (the MCP worker, and the
105
+ * channel runner, which lists proxied resources too): one page by uri, how many
106
+ * there are in all, and the cursor for the next page when there is one.
107
+ */
108
+ export const listResourcesPage = (
109
+ resources: Array<{
110
+ uri: string;
111
+ title?: string | null;
112
+ description?: string | null;
113
+ mimeType?: string | null;
114
+ }>,
115
+ args: { cursor?: unknown; limit?: unknown },
116
+ limits: { defaultLimit: number; maxLimit: number }
117
+ ): string => {
118
+ const { page, total, nextCursor } = paginate(resources, r => r.uri, {
119
+ cursor: typeof args.cursor === 'string' ? args.cursor : undefined,
120
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
121
+ ...limits
122
+ });
123
+ return JSON.stringify({
124
+ resources: page.map(r => ({
125
+ uri: r.uri,
126
+ title: r.title || undefined,
127
+ description: r.description || undefined,
128
+ mimeType: r.mimeType || undefined
129
+ })),
130
+ total,
131
+ ...(nextCursor ? { nextCursor } : {})
132
+ });
133
+ };
134
+
135
+ /**
136
+ * One page of `items`, ordered by `keyOf`, starting after the item the cursor
137
+ * names. Keyed rather than counted, so an item added or removed between calls
138
+ * doesn't shift the next page onto ones already seen.
139
+ */
140
+ export const paginate = <T>(
141
+ items: T[],
142
+ keyOf: (item: T) => string,
143
+ options: {
144
+ cursor?: string;
145
+ limit?: number;
146
+ defaultLimit: number;
147
+ maxLimit: number;
148
+ }
149
+ ): { page: T[]; nextCursor?: string; total: number } => {
150
+ const limit = Math.min(
151
+ Math.max(Math.floor(Number(options.limit) || options.defaultLimit), 1),
152
+ options.maxLimit
153
+ );
154
+ const sorted = [...items].sort((a, b) => {
155
+ const left = keyOf(a);
156
+ const right = keyOf(b);
157
+ return left < right ? -1 : left > right ? 1 : 0;
158
+ });
159
+
160
+ let start = 0;
161
+ if (options.cursor) {
162
+ const decoded = decodeCursor(options.cursor);
163
+ if (decoded === null) throw new InvalidCursorError();
164
+ const { key: after, seen } = splitCursorKey(decoded);
165
+ start = sorted.findIndex(item => keyOf(item) >= after);
166
+ if (start === -1) start = sorted.length;
167
+ // Items that share the cursor's key: skip the ones already served, in the
168
+ // order the sort (which is stable) keeps them.
169
+ for (
170
+ let skipped = 0;
171
+ start < sorted.length && keyOf(sorted[start]) === after && skipped < seen;
172
+ skipped++
173
+ ) {
174
+ start++;
175
+ }
176
+ }
177
+
178
+ const page = sorted.slice(start, start + limit);
179
+ const more = start + limit < sorted.length;
180
+ if (!more || page.length === 0) return { page, total: sorted.length };
181
+
182
+ // The last key served, and how many items with that key have been served
183
+ // so far, so a run of equal keys split across pages resumes inside the run.
184
+ const end = start + page.length;
185
+ const lastKey = keyOf(page[page.length - 1]);
186
+ let seen = 0;
187
+ for (let i = end - 1; i >= 0 && keyOf(sorted[i]) === lastKey; i--) seen++;
188
+ return {
189
+ page,
190
+ total: sorted.length,
191
+ nextCursor: encodeCursor(`${lastKey}${CURSOR_SEEN_SEPARATOR}${seen}`)
192
+ };
193
+ };
@@ -1,8 +1,12 @@
1
1
  import { isRateLimitError, isTransientInfraError } from './retry';
2
2
  import { constants } from './constants';
3
+ import { isPermanentJobError, planQueueFailure } from './queueFailure';
4
+ import type { QueueFailureKind } from './queueFailure';
3
5
 
4
6
  export interface QueueMessageLike<T> {
5
7
  body: T;
8
+ // Deliveries so far, including this one (Cloudflare Queues' `attempts`).
9
+ attempts?: number;
6
10
  ack: () => void;
7
11
  retry: (opts?: { delaySeconds?: number }) => void;
8
12
  }
@@ -12,32 +16,58 @@ export interface QueueBatchLike<T> {
12
16
  messages: readonly QueueMessageLike<T>[];
13
17
  }
14
18
 
19
+ export interface QueueFailureContext {
20
+ // No delivery follows this one — the moment to mark the work failed. Before
21
+ // it, a retry may still succeed, and a status flipped to failed in between
22
+ // would show a resource as broken while it is being fixed.
23
+ final: boolean;
24
+ }
25
+
15
26
  export interface ProcessQueueBatchHandlers<T> {
16
27
  process: (body: T) => Promise<void>;
17
- onError: (error: unknown, body: T, queueName: string) => Promise<void>;
28
+ onError: (
29
+ error: unknown,
30
+ body: T,
31
+ queueName: string,
32
+ context: QueueFailureContext
33
+ ) => Promise<void>;
34
+ // The consumer's configured `max_retries`.
35
+ maxRetries?: number;
18
36
  }
19
37
 
38
+ const failureKind = (error: unknown): QueueFailureKind => {
39
+ if (isPermanentJobError(error)) return 'permanent';
40
+ if (isTransientInfraError(error)) return 'transient';
41
+ if (isRateLimitError(error)) return 'rateLimit';
42
+ return 'other';
43
+ };
44
+
20
45
  export const processQueueBatch = async <T>(
21
46
  batch: QueueBatchLike<T>,
22
47
  handlers: ProcessQueueBatchHandlers<T>
23
48
  ): Promise<void> => {
49
+ const maxRetries = handlers.maxRetries ?? constants.QUEUE_MAX_RETRIES;
24
50
  for (const message of batch.messages) {
25
51
  try {
26
52
  await handlers.process(message.body);
27
53
  message.ack();
28
54
  } catch (error) {
29
- if (isTransientInfraError(error)) {
30
- // Transient Cloudflare platform hiccup (e.g. a Durable Object /
31
- // Container reset). The job itself is fine, so re-run it after a short
32
- // backoff without reporting an error or marking the work failed.
33
- message.retry({ delaySeconds: constants.TRANSIENT_BACKOFF_SECONDS });
34
- continue;
35
- }
36
- await handlers.onError(error, message.body, batch.queue);
37
- if (isRateLimitError(error)) {
38
- message.retry({
39
- delaySeconds: constants.RATE_LIMIT_BACKOFF_SECONDS
55
+ const plan = planQueueFailure(
56
+ failureKind(error),
57
+ message.attempts,
58
+ maxRetries
59
+ );
60
+ if (plan.report) {
61
+ await handlers.onError(error, message.body, batch.queue, {
62
+ final: plan.final
40
63
  });
64
+ }
65
+ if (plan.action === 'ack') {
66
+ message.ack();
67
+ } else if (plan.backoff === 'transient') {
68
+ message.retry({ delaySeconds: constants.TRANSIENT_BACKOFF_SECONDS });
69
+ } else if (plan.backoff === 'rateLimit') {
70
+ message.retry({ delaySeconds: constants.RATE_LIMIT_BACKOFF_SECONDS });
41
71
  } else {
42
72
  message.retry();
43
73
  }
@@ -0,0 +1,63 @@
1
+ // How a failed queue job is handled: retried, given up on, or reported. Kept
2
+ // apart from processQueueBatch, which applies it, so the decision is testable
3
+ // on its own.
4
+
5
+ // A failure that no retry can fix — a credential that needs the user to
6
+ // reconnect, a resource that no longer exists. The job is reported once and
7
+ // acknowledged rather than retried into the dead-letter queue. Recognised by
8
+ // its `permanent` flag rather than its class, so an error built by another
9
+ // copy of this module still counts.
10
+ export class PermanentJobError extends Error {
11
+ readonly permanent = true;
12
+
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = 'PermanentJobError';
16
+ }
17
+ }
18
+
19
+ export const isPermanentJobError = (error: unknown): boolean =>
20
+ !!error &&
21
+ typeof error === 'object' &&
22
+ (error as { permanent?: unknown }).permanent === true;
23
+
24
+ export type QueueFailureKind =
25
+ | 'permanent'
26
+ | 'transient'
27
+ | 'rateLimit'
28
+ | 'other';
29
+
30
+ export interface QueueFailurePlan {
31
+ // Hand the failure to the job's onError, which reports it.
32
+ report: boolean;
33
+ // No delivery follows this one: the job's work may be marked failed.
34
+ final: boolean;
35
+ // What to tell the queue about this message.
36
+ action: 'ack' | 'retry';
37
+ // Which backoff a retry waits out; null for the queue's default.
38
+ backoff: 'transient' | 'rateLimit' | null;
39
+ }
40
+
41
+ // `attempts` is the queue's own count, 1 on the first delivery; a consumer
42
+ // with `maxRetries` retries gets maxRetries + 1 deliveries in all.
43
+ export const planQueueFailure = (
44
+ kind: QueueFailureKind,
45
+ attempts: number | undefined,
46
+ maxRetries: number
47
+ ): QueueFailurePlan => {
48
+ if (kind === 'permanent') {
49
+ return { report: true, final: true, action: 'ack', backoff: null };
50
+ }
51
+ const final = (attempts ?? 1) > maxRetries;
52
+ if (kind === 'transient') {
53
+ // A platform hiccup is retried quietly; only the last one is worth a
54
+ // report, since nothing will run the job after it.
55
+ return { report: final, final, action: 'retry', backoff: 'transient' };
56
+ }
57
+ return {
58
+ report: true,
59
+ final,
60
+ action: 'retry',
61
+ backoff: kind === 'rateLimit' ? 'rateLimit' : null
62
+ };
63
+ };
@@ -37,7 +37,11 @@ export const createResourceReranker = (
37
37
  top_k: documents.length
38
38
  })) as RerankResponse;
39
39
 
40
- const scores: Array<number | undefined> = new Array(documents.length);
40
+ // Filled, not just sized: `some` skips the holes of a bare new Array(n), so
41
+ // a document the response left out would pass the check below unscored.
42
+ const scores: Array<number | undefined> = new Array(documents.length).fill(
43
+ undefined
44
+ );
41
45
  for (const item of result?.response ?? []) {
42
46
  if (
43
47
  typeof item.id === 'number' &&
package/src/schema.ts CHANGED
@@ -3,9 +3,14 @@ import { z } from 'zod';
3
3
  import { constants } from './constants';
4
4
  import { isAllowedLlmBaseUrl, requiresLlmBaseUrl } from './llmBaseUrl';
5
5
  import { isReservedSlug, isValidSlugFormat } from './slug';
6
+ import { isBlockedUrl } from './ssrf';
6
7
  import { isReservedToolName } from './reservedToolName';
7
8
  import { slugifyTitle } from './slugifyTitle';
8
9
  import { TOOL_EFFECTS } from './toolConfirmation';
10
+ import {
11
+ PARTICIPANT_DAILY_LIMIT_MAX,
12
+ PARTICIPANT_DAILY_LIMIT_OFF
13
+ } from './channelCustomer';
9
14
 
10
15
  // A prompt title becomes a slash command; it must not collide with a command
11
16
  // the channel runner handles itself (e.g. `/link`).
@@ -181,6 +186,15 @@ const INVITATION_RESPOND = z.object({
181
186
  action: z.enum(constants.INVITATION_RESPONSES)
182
187
  });
183
188
 
189
+ const TOOL_APPROVAL_GET = z.object({
190
+ approvalId: z.uuid()
191
+ });
192
+
193
+ const TOOL_APPROVAL_DECIDE = z.object({
194
+ approvalId: z.uuid(),
195
+ decision: z.enum(constants.TOOL_APPROVAL_DECISIONS)
196
+ });
197
+
184
198
  const INVITATION_GET_BY_TOKEN = z.object({
185
199
  token: z.string().min(8).max(128)
186
200
  });
@@ -279,6 +293,10 @@ const ARTIFACT_CREATE_WEBSITE_VIEW = z.object({
279
293
  .refine(
280
294
  v => /^https?:\/\//i.test(v),
281
295
  'Only http and https URLs are supported'
296
+ )
297
+ .refine(
298
+ v => !isBlockedUrl(v),
299
+ "This address points to a private network and can't be crawled"
282
300
  ),
283
301
  description: z.string().optional(),
284
302
  maxPages: z.number().int().min(1).max(constants.CRAWL_MAX_PAGES_LIMIT),
@@ -292,6 +310,10 @@ const ARTIFACT_CREATE_WEBSITE = z.object({
292
310
  .refine(
293
311
  v => /^https?:\/\//i.test(v),
294
312
  'Only http and https URLs are supported'
313
+ )
314
+ .refine(
315
+ v => !isBlockedUrl(v),
316
+ "This address points to a private network and can't be crawled"
295
317
  ),
296
318
  description: z.string().optional(),
297
319
  crawlConfig: z.object({
@@ -722,11 +744,11 @@ const ORGANIZATION_UPDATE_LLM_VIEW = refineUpdateBaseUrl(
722
744
  })
723
745
  );
724
746
 
725
- // `channel.config` is an open bag, so this stays loose — it only pins down the
726
- // keys we actually read, and passes everything else through untouched.
747
+ // The channel settings an owner can write. Strict: a typo or a setting that
748
+ // no longer exists is refused rather than stored, where nothing would read it.
727
749
  // `debounceMs` is how long a burst from one participant is buffered before the
728
750
  // agent answers it as a single turn; 0 disables buffering.
729
- const CHANNEL_CONFIG = z.looseObject({
751
+ const CHANNEL_CONFIG = z.strictObject({
730
752
  debounceMs: z
731
753
  .number()
732
754
  .int()
@@ -737,7 +759,16 @@ const CHANNEL_CONFIG = z.looseObject({
737
759
  value <= constants.CHANNEL_DEBOUNCE_MAX_MS),
738
760
  `debounceMs must be 0 (disabled) or between ${constants.CHANNEL_DEBOUNCE_MIN_MS} and ${constants.CHANNEL_DEBOUNCE_MAX_MS} ms`
739
761
  )
740
- .optional()
762
+ .optional(),
763
+ // Replies one customer gets in 24 hours; 0 turns the limit off.
764
+ participantDailyLimit: z
765
+ .number()
766
+ .int()
767
+ .min(PARTICIPANT_DAILY_LIMIT_OFF)
768
+ .max(PARTICIPANT_DAILY_LIMIT_MAX)
769
+ .optional(),
770
+ // Remember each customer's name, address, allergies and dates.
771
+ participantMemory: z.boolean().optional()
741
772
  });
742
773
 
743
774
  const CHANNEL_CREATE = z.object({
@@ -754,6 +785,9 @@ const CHANNEL_UPDATE = z.object({
754
785
  channelId: z.uuid(),
755
786
  status: z.enum(constants.CHANNEL_STATUS).optional(),
756
787
  config: CHANNEL_CONFIG.optional(),
788
+ // Keys to change on the stored config, merged by the database, so two
789
+ // settings saved at once don't overwrite each other.
790
+ configPatch: CHANNEL_CONFIG.optional(),
757
791
  credentials: z.record(z.string(), z.string()).optional(),
758
792
  llmId: z.uuid().nullable().optional(),
759
793
  projectId: z.uuid(),
@@ -975,6 +1009,15 @@ const MCP_PROXY_CONFIG = z.object({
975
1009
  allowedResources: z.array(z.string().min(1).max(2048)).max(500).optional(),
976
1010
  allowedPrompts: z.array(z.string().min(1).max(256)).max(500).optional(),
977
1011
  auth: MCP_PROXY_AUTH.default({ kind: constants.MCP_PROXY_AUTH_KIND_NONE }),
1012
+ // Whether the remote's own readOnlyHint / destructiveHint decide which of its
1013
+ // tools are confirmed. Off (or absent), every proxied tool counts as
1014
+ // sensitive unless the owner marked it below: a server that labels its
1015
+ // delete "read-only" would otherwise skip the question.
1016
+ trustAnnotations: z.boolean().optional(),
1017
+ // Remote tool names the owner marked as only reading. Read-only whatever the
1018
+ // remote says, trusted or not. Left optional rather than defaulted, so a
1019
+ // save that doesn't mention it keeps the stored marks.
1020
+ readOnlyTools: z.array(z.string().min(1).max(256)).max(500).optional(),
978
1021
  timeoutMs: z
979
1022
  .number()
980
1023
  .int()
@@ -1247,6 +1290,53 @@ const ARTIFACT_CUSTOM_CODE_LIST_LOGS = z.object({
1247
1290
  organizationId: z.uuid()
1248
1291
  });
1249
1292
 
1293
+ // Tool observability on the project home: per-tool health over a window.
1294
+ const PROJECT_TOOL_HEALTH = z.object({
1295
+ days: z.coerce
1296
+ .number()
1297
+ .int()
1298
+ .refine(days => constants.OBSERVABILITY_RANGE_DAYS.includes(days), {
1299
+ message: 'Range is not valid'
1300
+ })
1301
+ .default(7),
1302
+ projectId: z.uuid(),
1303
+ userId: z.uuid(),
1304
+ organizationId: z.uuid()
1305
+ });
1306
+
1307
+ // The calls behind a row of that table, newest first. Paged by the id of the
1308
+ // last call shown, not its time: calls written in one insert share a
1309
+ // timestamp, and the client only sees it to the millisecond.
1310
+ const PROJECT_TOOL_CALLS = z.object({
1311
+ tool: z.string().min(1).max(256).optional(),
1312
+ status: z.enum(['all', 'error']).default('all'),
1313
+ limit: z.coerce
1314
+ .number()
1315
+ .int()
1316
+ .min(1)
1317
+ .max(constants.OBSERVABILITY_CALLS_MAX_LIMIT)
1318
+ .default(constants.OBSERVABILITY_CALLS_DEFAULT_LIMIT),
1319
+ after: z.uuid().optional(),
1320
+ projectId: z.uuid(),
1321
+ userId: z.uuid(),
1322
+ organizationId: z.uuid()
1323
+ });
1324
+
1325
+ // One recorded call, whole, and the session timeline it belongs to.
1326
+ const PROJECT_TOOL_CALL = z.object({
1327
+ requestId: z.uuid(),
1328
+ projectId: z.uuid(),
1329
+ userId: z.uuid(),
1330
+ organizationId: z.uuid()
1331
+ });
1332
+
1333
+ const PROJECT_MCP_SESSION = z.object({
1334
+ sessionId: z.uuid(),
1335
+ projectId: z.uuid(),
1336
+ userId: z.uuid(),
1337
+ organizationId: z.uuid()
1338
+ });
1339
+
1250
1340
  // Reads the artifact's version history and which one is currently active.
1251
1341
  const ARTIFACT_CUSTOM_CODE_LIST_VERSIONS = z.object({
1252
1342
  projectId: z.uuid(),
@@ -1609,6 +1699,8 @@ export const Schema = {
1609
1699
  PROJECT_INVITATION_REMOVE,
1610
1700
  INVITATION_RESPOND,
1611
1701
  INVITATION_GET_BY_TOKEN,
1702
+ TOOL_APPROVAL_GET,
1703
+ TOOL_APPROVAL_DECIDE,
1612
1704
  ORGANIZATION_MEMBER_LIST,
1613
1705
  ORGANIZATION_MEMBER_REMOVE,
1614
1706
  PROJECT_MEMBER_LIST,
@@ -1688,6 +1780,10 @@ export const Schema = {
1688
1780
  ARTIFACT_CUSTOM_CODE_ROLLBACK,
1689
1781
  ARTIFACT_CUSTOM_CODE_LIST_LOGS,
1690
1782
  ARTIFACT_CUSTOM_CODE_LIST_VERSIONS,
1783
+ PROJECT_TOOL_HEALTH,
1784
+ PROJECT_TOOL_CALLS,
1785
+ PROJECT_TOOL_CALL,
1786
+ PROJECT_MCP_SESSION,
1691
1787
  CUSTOM_CODE_INVOKE_REQUEST,
1692
1788
  CUSTOM_CODE_INVOKE_RESPONSE,
1693
1789
  CUSTOM_CODE_BROKER_CONNECTION,