@ganju/utils 0.0.14 → 0.0.16

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 (111) 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 +49 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +202 -17
  17. package/dist/data.d.ts +9 -0
  18. package/dist/data.d.ts.map +1 -0
  19. package/dist/data.js +78 -0
  20. package/dist/dataApi.d.ts +511 -0
  21. package/dist/dataApi.d.ts.map +1 -0
  22. package/dist/dataApi.js +355 -0
  23. package/dist/dataCompiler.d.ts +68 -0
  24. package/dist/dataCompiler.d.ts.map +1 -0
  25. package/dist/dataCompiler.js +726 -0
  26. package/dist/dataDdl.d.ts +29 -0
  27. package/dist/dataDdl.d.ts.map +1 -0
  28. package/dist/dataDdl.js +354 -0
  29. package/dist/dataEngine.d.ts +143 -0
  30. package/dist/dataEngine.d.ts.map +1 -0
  31. package/dist/dataEngine.js +2022 -0
  32. package/dist/dataIdentifiers.d.ts +8 -0
  33. package/dist/dataIdentifiers.d.ts.map +1 -0
  34. package/dist/dataIdentifiers.js +55 -0
  35. package/dist/dataImport.d.ts +77 -0
  36. package/dist/dataImport.d.ts.map +1 -0
  37. package/dist/dataImport.js +450 -0
  38. package/dist/dataRpc.d.ts +131 -0
  39. package/dist/dataRpc.d.ts.map +1 -0
  40. package/dist/dataRpc.js +2 -0
  41. package/dist/dataTypes.d.ts +157 -0
  42. package/dist/dataTypes.d.ts.map +1 -0
  43. package/dist/dataTypes.js +160 -0
  44. package/dist/index.d.ts +570 -36
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +72 -0
  47. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  48. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  49. package/dist/jsonSchemaToZodShape.js +21 -9
  50. package/dist/oauth.d.ts +14 -0
  51. package/dist/oauth.d.ts.map +1 -1
  52. package/dist/oauth.js +42 -2
  53. package/dist/pagination.d.ts +47 -0
  54. package/dist/pagination.d.ts.map +1 -0
  55. package/dist/pagination.js +160 -0
  56. package/dist/processQueueBatch.d.ts +6 -1
  57. package/dist/processQueueBatch.d.ts.map +1 -1
  58. package/dist/processQueueBatch.js +23 -10
  59. package/dist/queueFailure.d.ts +14 -0
  60. package/dist/queueFailure.d.ts.map +1 -0
  61. package/dist/queueFailure.js +43 -0
  62. package/dist/resourceRerank.d.ts +3 -2
  63. package/dist/resourceRerank.d.ts.map +1 -1
  64. package/dist/resourceRerank.js +8 -3
  65. package/dist/schema.d.ts +374 -29
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +290 -6
  68. package/dist/ssrf.d.ts +1 -0
  69. package/dist/ssrf.d.ts.map +1 -1
  70. package/dist/ssrf.js +48 -12
  71. package/dist/toolConfirmation.d.ts +48 -0
  72. package/dist/toolConfirmation.d.ts.map +1 -1
  73. package/dist/toolConfirmation.js +75 -1
  74. package/dist/toolCount.d.ts +13 -0
  75. package/dist/toolCount.d.ts.map +1 -0
  76. package/dist/toolCount.js +45 -0
  77. package/dist/toolLint.d.ts +1 -1
  78. package/dist/toolLint.d.ts.map +1 -1
  79. package/dist/toolLint.js +20 -0
  80. package/dist/untrustedContent.d.ts +135 -0
  81. package/dist/untrustedContent.d.ts.map +1 -0
  82. package/dist/untrustedContent.js +402 -0
  83. package/package.json +17 -1
  84. package/src/channelCustomer.ts +604 -0
  85. package/src/channelDebounce.ts +17 -0
  86. package/src/chunkOverlap.ts +6 -0
  87. package/src/chunking.ts +25 -4
  88. package/src/citations.ts +127 -0
  89. package/src/constants.ts +219 -17
  90. package/src/data.ts +74 -0
  91. package/src/dataApi.ts +428 -0
  92. package/src/dataCompiler.ts +948 -0
  93. package/src/dataDdl.ts +419 -0
  94. package/src/dataEngine.ts +2753 -0
  95. package/src/dataIdentifiers.ts +63 -0
  96. package/src/dataImport.ts +554 -0
  97. package/src/dataRpc.ts +121 -0
  98. package/src/dataTypes.ts +312 -0
  99. package/src/index.ts +156 -2
  100. package/src/jsonSchemaToZodShape.ts +31 -13
  101. package/src/oauth.ts +42 -1
  102. package/src/pagination.ts +193 -0
  103. package/src/processQueueBatch.ts +42 -12
  104. package/src/queueFailure.ts +63 -0
  105. package/src/resourceRerank.ts +10 -3
  106. package/src/schema.ts +346 -4
  107. package/src/ssrf.ts +40 -11
  108. package/src/toolConfirmation.ts +123 -0
  109. package/src/toolCount.ts +53 -0
  110. package/src/toolLint.ts +27 -0
  111. package/src/untrustedContent.ts +476 -0
package/src/schema.ts CHANGED
@@ -3,9 +3,27 @@ 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';
14
+ import {
15
+ DATA_ACCESS_LEVELS,
16
+ DATA_COLUMN_TYPES,
17
+ DATA_MAX_COLUMNS,
18
+ DATA_MAX_DESCRIPTION,
19
+ DATA_MAX_ENUM_VALUES,
20
+ DATA_MAX_INSERT_ROWS,
21
+ DATA_MAX_LABEL,
22
+ DATA_IMPACT_MAX_IDS,
23
+ DATA_MAX_PAGE,
24
+ DATA_MAX_WRITE_OPERATIONS,
25
+ DATA_ON_DELETE
26
+ } from './dataTypes';
9
27
 
10
28
  // A prompt title becomes a slash command; it must not collide with a command
11
29
  // the channel runner handles itself (e.g. `/link`).
@@ -181,6 +199,15 @@ const INVITATION_RESPOND = z.object({
181
199
  action: z.enum(constants.INVITATION_RESPONSES)
182
200
  });
183
201
 
202
+ const TOOL_APPROVAL_GET = z.object({
203
+ approvalId: z.uuid()
204
+ });
205
+
206
+ const TOOL_APPROVAL_DECIDE = z.object({
207
+ approvalId: z.uuid(),
208
+ decision: z.enum(constants.TOOL_APPROVAL_DECISIONS)
209
+ });
210
+
184
211
  const INVITATION_GET_BY_TOKEN = z.object({
185
212
  token: z.string().min(8).max(128)
186
213
  });
@@ -279,6 +306,10 @@ const ARTIFACT_CREATE_WEBSITE_VIEW = z.object({
279
306
  .refine(
280
307
  v => /^https?:\/\//i.test(v),
281
308
  'Only http and https URLs are supported'
309
+ )
310
+ .refine(
311
+ v => !isBlockedUrl(v),
312
+ "This address points to a private network and can't be crawled"
282
313
  ),
283
314
  description: z.string().optional(),
284
315
  maxPages: z.number().int().min(1).max(constants.CRAWL_MAX_PAGES_LIMIT),
@@ -292,6 +323,10 @@ const ARTIFACT_CREATE_WEBSITE = z.object({
292
323
  .refine(
293
324
  v => /^https?:\/\//i.test(v),
294
325
  'Only http and https URLs are supported'
326
+ )
327
+ .refine(
328
+ v => !isBlockedUrl(v),
329
+ "This address points to a private network and can't be crawled"
295
330
  ),
296
331
  description: z.string().optional(),
297
332
  crawlConfig: z.object({
@@ -489,6 +524,214 @@ const ARTIFACT_REMOVE_RESOURCE = z.object({
489
524
  organizationId: z.uuid()
490
525
  });
491
526
 
527
+ const DATA_DATABASE_GET = z.object({
528
+ projectId: z.uuid(),
529
+ userId: z.uuid(),
530
+ organizationId: z.uuid()
531
+ });
532
+
533
+ // The name is what the owner types to delete it; it defaults to the
534
+ // project's slug. `replaceDeleted` starts over while a deleted database is
535
+ // still in its grace period, giving up the chance to restore it.
536
+ const DATA_DATABASE_CREATE = DATA_DATABASE_GET.extend({
537
+ name: z.string().trim().min(1).max(100).optional(),
538
+ replaceDeleted: z.boolean().optional()
539
+ });
540
+
541
+ const DATA_DATABASE_DELETE = DATA_DATABASE_GET.extend({
542
+ confirmName: z.string().min(1)
543
+ });
544
+
545
+ // The bodies and query strings of the Data API. Each one is what a client
546
+ // sends; the route schemas below add the path's ids. The database object
547
+ // checks every rule again (names, types, relations), so these describe the
548
+ // shape and leave the meaning to it.
549
+ const DATA_IDENTIFIER = z.string().min(1).max(63);
550
+ const DATA_DEFAULT_VALUE = z.union([
551
+ z.string(),
552
+ z.number(),
553
+ z.boolean(),
554
+ z.null()
555
+ ]);
556
+ const DATA_TEXT = (max: number) => z.string().max(max).nullable().optional();
557
+
558
+ const DATA_COLUMN_INPUT = z.strictObject({
559
+ name: DATA_IDENTIFIER,
560
+ type: z.enum(DATA_COLUMN_TYPES),
561
+ label: DATA_TEXT(DATA_MAX_LABEL),
562
+ description: DATA_TEXT(DATA_MAX_DESCRIPTION),
563
+ required: z.boolean().optional(),
564
+ unique: z.boolean().optional(),
565
+ filterable: z.boolean().optional(),
566
+ searchable: z.boolean().optional(),
567
+ personal: z.boolean().optional(),
568
+ values: z.array(z.string().min(1)).max(DATA_MAX_ENUM_VALUES).optional(),
569
+ table: DATA_IDENTIFIER.optional(),
570
+ onDelete: z.enum(DATA_ON_DELETE).optional(),
571
+ min: z.number().nullable().optional(),
572
+ default: DATA_DEFAULT_VALUE.optional()
573
+ });
574
+
575
+ // The CLI's schema file holds a list of these.
576
+ const DATA_TABLE_INPUT = z.strictObject({
577
+ name: DATA_IDENTIFIER,
578
+ label: DATA_TEXT(DATA_MAX_LABEL),
579
+ description: DATA_TEXT(DATA_MAX_DESCRIPTION),
580
+ displayColumn: DATA_IDENTIFIER.nullable().optional(),
581
+ access: z.enum(DATA_ACCESS_LEVELS).optional(),
582
+ customerScoped: z.boolean().optional(),
583
+ history: z.boolean().optional(),
584
+ columns: z.array(DATA_COLUMN_INPUT).max(DATA_MAX_COLUMNS)
585
+ });
586
+
587
+ const DATA_TABLE_PATCH = DATA_TABLE_INPUT.omit({ columns: true }).partial();
588
+
589
+ // A null puts the setting back to its default.
590
+ const DATA_COLUMN_PATCH = DATA_COLUMN_INPUT.partial();
591
+
592
+ const DATA_TABLE_DROP_BODY = z.strictObject({ confirmName: z.string().min(1) });
593
+
594
+ const DATA_ROW_VALUES = z.record(z.string(), z.unknown());
595
+
596
+ // A JSON value carried in a query string, e.g. `where={"status":"open"}`.
597
+ const DATA_JSON_PARAM = z.string().transform((text, ctx) => {
598
+ try {
599
+ return JSON.parse(text) as unknown;
600
+ } catch {
601
+ ctx.addIssue({
602
+ code: 'custom',
603
+ message: 'This is JSON, e.g. {"status":"open"}'
604
+ });
605
+ return z.NEVER;
606
+ }
607
+ });
608
+
609
+ // A comma-separated list in a query string: `sort=-created_at,name`.
610
+ const DATA_LIST_PARAM = z.string().transform(text =>
611
+ text
612
+ .split(',')
613
+ .map(item => item.trim())
614
+ .filter(Boolean)
615
+ );
616
+
617
+ const DATA_ROW_LIST_QUERY = z.strictObject({
618
+ where: DATA_JSON_PARAM.optional(),
619
+ sort: DATA_LIST_PARAM.optional(),
620
+ include: DATA_LIST_PARAM.optional(),
621
+ limit: z.coerce.number().int().min(1).max(DATA_MAX_PAGE).optional(),
622
+ cursor: z.string().max(2000).optional(),
623
+ q: z.string().max(500).optional(),
624
+ timeZone: z.string().max(64).optional()
625
+ });
626
+
627
+ const DATA_ROW_GET_QUERY = DATA_ROW_LIST_QUERY.pick({
628
+ include: true,
629
+ timeZone: true
630
+ });
631
+
632
+ const DATA_ROW_CREATE_BODY = z.union([
633
+ z.strictObject({ values: DATA_ROW_VALUES }),
634
+ z.strictObject({
635
+ rows: z.array(DATA_ROW_VALUES).min(1).max(DATA_MAX_INSERT_ROWS)
636
+ })
637
+ ]);
638
+
639
+ const DATA_ROW_VERSION = z.number().int().min(1);
640
+
641
+ // `set` takes values, or relative changes such as { "stock": { "decrement": 2 } }.
642
+ const DATA_ROW_UPDATE_BODY = z.strictObject({
643
+ set: DATA_ROW_VALUES,
644
+ version: DATA_ROW_VERSION.optional()
645
+ });
646
+
647
+ const DATA_ROW_DELETE_BODY = z.strictObject({
648
+ version: DATA_ROW_VERSION.optional()
649
+ });
650
+
651
+ const DATA_ROW_ID = z.coerce.number().int().min(1);
652
+
653
+ // Without `ids`, every row of the table.
654
+ const DATA_DELETE_PREVIEW_BODY = z.strictObject({
655
+ ids: z.array(z.number().int().min(1)).min(1).max(DATA_IMPACT_MAX_IDS).optional()
656
+ });
657
+
658
+ const DATA_WRITE_TARGET = {
659
+ table: DATA_IDENTIFIER,
660
+ id: z
661
+ .union([
662
+ z.number().int().min(1),
663
+ z.strictObject({ $ref: z.number().int().min(0) })
664
+ ])
665
+ .optional(),
666
+ where: z.record(z.string(), z.unknown()).optional(),
667
+ expect: z.number().int().min(0).optional(),
668
+ version: DATA_ROW_VERSION.optional()
669
+ };
670
+
671
+ // Applied together or not at all. A later step can use an earlier insert's
672
+ // id as { "$ref": <step> }.
673
+ const DATA_WRITE_OPERATION = z.discriminatedUnion('op', [
674
+ z.strictObject({
675
+ op: z.literal('insert'),
676
+ table: DATA_IDENTIFIER,
677
+ values: DATA_ROW_VALUES.optional(),
678
+ rows: z.array(DATA_ROW_VALUES).min(1).max(DATA_MAX_INSERT_ROWS).optional()
679
+ }),
680
+ z.strictObject({
681
+ op: z.literal('update'),
682
+ ...DATA_WRITE_TARGET,
683
+ set: DATA_ROW_VALUES
684
+ }),
685
+ z.strictObject({ op: z.literal('delete'), ...DATA_WRITE_TARGET })
686
+ ]);
687
+
688
+ const DATA_WRITE_BODY = z.strictObject({
689
+ operations: z
690
+ .array(DATA_WRITE_OPERATION)
691
+ .min(1)
692
+ .max(DATA_MAX_WRITE_OPERATIONS)
693
+ });
694
+
695
+ const DATA_ROW_HISTORY_QUERY = z.strictObject({
696
+ limit: z.coerce.number().int().min(1).max(100).optional(),
697
+ cursor: z.coerce.number().int().min(1).optional(),
698
+ timeZone: z.string().max(64).optional()
699
+ });
700
+
701
+ // A time within the last 30 days, or a bookmark this API handed out (a drop,
702
+ // a type change, or the restore to undo).
703
+ const DATA_RESTORE_POINT_BODY = z.union([
704
+ z.strictObject({
705
+ timestamp: z.union([z.iso.datetime({ offset: true }), z.number().int()])
706
+ }),
707
+ z.strictObject({ bookmark: z.string().min(1).max(200) })
708
+ ]);
709
+
710
+ const DATA_EXPORT_BODY = z.strictObject({
711
+ format: z.enum(['jsonl', 'csv']).optional()
712
+ });
713
+
714
+ const DATA_EXPORT_QUERY = z.strictObject({
715
+ exportId: z.uuid().optional()
716
+ });
717
+
718
+ // Without `table`, the export's schema.json.
719
+ const DATA_EXPORT_FILE_QUERY = z.strictObject({
720
+ exportId: z.uuid(),
721
+ table: DATA_IDENTIFIER.optional()
722
+ });
723
+
724
+ const DATA_CHANGE_LIST_QUERY = z.strictObject({
725
+ limit: z.coerce.number().int().min(1).max(100).optional(),
726
+ cursor: z.uuid().optional()
727
+ });
728
+
729
+ const DATA_TABLE_PARAMS = DATA_DATABASE_GET.extend({ table: DATA_IDENTIFIER });
730
+ const DATA_COLUMN_PARAMS = DATA_TABLE_PARAMS.extend({
731
+ column: DATA_IDENTIFIER
732
+ });
733
+ const DATA_ROW_PARAMS = DATA_TABLE_PARAMS.extend({ id: DATA_ROW_ID });
734
+
492
735
  const ARTIFACT_UPLOAD_RESOURCE_FILE = z.object({
493
736
  resourceId: z.uuid(),
494
737
  projectId: z.uuid(),
@@ -722,11 +965,11 @@ const ORGANIZATION_UPDATE_LLM_VIEW = refineUpdateBaseUrl(
722
965
  })
723
966
  );
724
967
 
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.
968
+ // The channel settings an owner can write. Strict: a typo or a setting that
969
+ // no longer exists is refused rather than stored, where nothing would read it.
727
970
  // `debounceMs` is how long a burst from one participant is buffered before the
728
971
  // agent answers it as a single turn; 0 disables buffering.
729
- const CHANNEL_CONFIG = z.looseObject({
972
+ const CHANNEL_CONFIG = z.strictObject({
730
973
  debounceMs: z
731
974
  .number()
732
975
  .int()
@@ -737,7 +980,16 @@ const CHANNEL_CONFIG = z.looseObject({
737
980
  value <= constants.CHANNEL_DEBOUNCE_MAX_MS),
738
981
  `debounceMs must be 0 (disabled) or between ${constants.CHANNEL_DEBOUNCE_MIN_MS} and ${constants.CHANNEL_DEBOUNCE_MAX_MS} ms`
739
982
  )
740
- .optional()
983
+ .optional(),
984
+ // Replies one customer gets in 24 hours; 0 turns the limit off.
985
+ participantDailyLimit: z
986
+ .number()
987
+ .int()
988
+ .min(PARTICIPANT_DAILY_LIMIT_OFF)
989
+ .max(PARTICIPANT_DAILY_LIMIT_MAX)
990
+ .optional(),
991
+ // Remember each customer's name, address, allergies and dates.
992
+ participantMemory: z.boolean().optional()
741
993
  });
742
994
 
743
995
  const CHANNEL_CREATE = z.object({
@@ -754,6 +1006,9 @@ const CHANNEL_UPDATE = z.object({
754
1006
  channelId: z.uuid(),
755
1007
  status: z.enum(constants.CHANNEL_STATUS).optional(),
756
1008
  config: CHANNEL_CONFIG.optional(),
1009
+ // Keys to change on the stored config, merged by the database, so two
1010
+ // settings saved at once don't overwrite each other.
1011
+ configPatch: CHANNEL_CONFIG.optional(),
757
1012
  credentials: z.record(z.string(), z.string()).optional(),
758
1013
  llmId: z.uuid().nullable().optional(),
759
1014
  projectId: z.uuid(),
@@ -975,6 +1230,15 @@ const MCP_PROXY_CONFIG = z.object({
975
1230
  allowedResources: z.array(z.string().min(1).max(2048)).max(500).optional(),
976
1231
  allowedPrompts: z.array(z.string().min(1).max(256)).max(500).optional(),
977
1232
  auth: MCP_PROXY_AUTH.default({ kind: constants.MCP_PROXY_AUTH_KIND_NONE }),
1233
+ // Whether the remote's own readOnlyHint / destructiveHint decide which of its
1234
+ // tools are confirmed. Off (or absent), every proxied tool counts as
1235
+ // sensitive unless the owner marked it below: a server that labels its
1236
+ // delete "read-only" would otherwise skip the question.
1237
+ trustAnnotations: z.boolean().optional(),
1238
+ // Remote tool names the owner marked as only reading. Read-only whatever the
1239
+ // remote says, trusted or not. Left optional rather than defaulted, so a
1240
+ // save that doesn't mention it keeps the stored marks.
1241
+ readOnlyTools: z.array(z.string().min(1).max(256)).max(500).optional(),
978
1242
  timeoutMs: z
979
1243
  .number()
980
1244
  .int()
@@ -1247,6 +1511,53 @@ const ARTIFACT_CUSTOM_CODE_LIST_LOGS = z.object({
1247
1511
  organizationId: z.uuid()
1248
1512
  });
1249
1513
 
1514
+ // Tool observability on the project home: per-tool health over a window.
1515
+ const PROJECT_TOOL_HEALTH = z.object({
1516
+ days: z.coerce
1517
+ .number()
1518
+ .int()
1519
+ .refine(days => constants.OBSERVABILITY_RANGE_DAYS.includes(days), {
1520
+ message: 'Range is not valid'
1521
+ })
1522
+ .default(7),
1523
+ projectId: z.uuid(),
1524
+ userId: z.uuid(),
1525
+ organizationId: z.uuid()
1526
+ });
1527
+
1528
+ // The calls behind a row of that table, newest first. Paged by the id of the
1529
+ // last call shown, not its time: calls written in one insert share a
1530
+ // timestamp, and the client only sees it to the millisecond.
1531
+ const PROJECT_TOOL_CALLS = z.object({
1532
+ tool: z.string().min(1).max(256).optional(),
1533
+ status: z.enum(['all', 'error']).default('all'),
1534
+ limit: z.coerce
1535
+ .number()
1536
+ .int()
1537
+ .min(1)
1538
+ .max(constants.OBSERVABILITY_CALLS_MAX_LIMIT)
1539
+ .default(constants.OBSERVABILITY_CALLS_DEFAULT_LIMIT),
1540
+ after: z.uuid().optional(),
1541
+ projectId: z.uuid(),
1542
+ userId: z.uuid(),
1543
+ organizationId: z.uuid()
1544
+ });
1545
+
1546
+ // One recorded call, whole, and the session timeline it belongs to.
1547
+ const PROJECT_TOOL_CALL = z.object({
1548
+ requestId: z.uuid(),
1549
+ projectId: z.uuid(),
1550
+ userId: z.uuid(),
1551
+ organizationId: z.uuid()
1552
+ });
1553
+
1554
+ const PROJECT_MCP_SESSION = z.object({
1555
+ sessionId: z.uuid(),
1556
+ projectId: z.uuid(),
1557
+ userId: z.uuid(),
1558
+ organizationId: z.uuid()
1559
+ });
1560
+
1250
1561
  // Reads the artifact's version history and which one is currently active.
1251
1562
  const ARTIFACT_CUSTOM_CODE_LIST_VERSIONS = z.object({
1252
1563
  projectId: z.uuid(),
@@ -1609,6 +1920,8 @@ export const Schema = {
1609
1920
  PROJECT_INVITATION_REMOVE,
1610
1921
  INVITATION_RESPOND,
1611
1922
  INVITATION_GET_BY_TOKEN,
1923
+ TOOL_APPROVAL_GET,
1924
+ TOOL_APPROVAL_DECIDE,
1612
1925
  ORGANIZATION_MEMBER_LIST,
1613
1926
  ORGANIZATION_MEMBER_REMOVE,
1614
1927
  PROJECT_MEMBER_LIST,
@@ -1635,6 +1948,31 @@ export const Schema = {
1635
1948
  ARTIFACT_UPDATE_RESOURCE_VIEW,
1636
1949
  ARTIFACT_GET_RESOURCE,
1637
1950
  ARTIFACT_GET_RESOURCE_BY_ID,
1951
+ DATA_DATABASE_GET,
1952
+ DATA_DATABASE_CREATE,
1953
+ DATA_DATABASE_DELETE,
1954
+ DATA_COLUMN_INPUT,
1955
+ DATA_TABLE_INPUT,
1956
+ DATA_TABLE_PATCH,
1957
+ DATA_COLUMN_PATCH,
1958
+ DATA_TABLE_DROP_BODY,
1959
+ DATA_ROW_LIST_QUERY,
1960
+ DATA_ROW_GET_QUERY,
1961
+ DATA_ROW_CREATE_BODY,
1962
+ DATA_ROW_UPDATE_BODY,
1963
+ DATA_ROW_DELETE_BODY,
1964
+ DATA_DELETE_PREVIEW_BODY,
1965
+ DATA_WRITE_OPERATION,
1966
+ DATA_WRITE_BODY,
1967
+ DATA_ROW_HISTORY_QUERY,
1968
+ DATA_RESTORE_POINT_BODY,
1969
+ DATA_EXPORT_BODY,
1970
+ DATA_EXPORT_QUERY,
1971
+ DATA_EXPORT_FILE_QUERY,
1972
+ DATA_CHANGE_LIST_QUERY,
1973
+ DATA_TABLE_PARAMS,
1974
+ DATA_COLUMN_PARAMS,
1975
+ DATA_ROW_PARAMS,
1638
1976
  ARTIFACT_REMOVE_RESOURCE,
1639
1977
  ARTIFACT_UPLOAD_RESOURCE_FILE,
1640
1978
  ARTIFACT_DOWNLOAD_RESOURCE_FILE,
@@ -1688,6 +2026,10 @@ export const Schema = {
1688
2026
  ARTIFACT_CUSTOM_CODE_ROLLBACK,
1689
2027
  ARTIFACT_CUSTOM_CODE_LIST_LOGS,
1690
2028
  ARTIFACT_CUSTOM_CODE_LIST_VERSIONS,
2029
+ PROJECT_TOOL_HEALTH,
2030
+ PROJECT_TOOL_CALLS,
2031
+ PROJECT_TOOL_CALL,
2032
+ PROJECT_MCP_SESSION,
1691
2033
  CUSTOM_CODE_INVOKE_REQUEST,
1692
2034
  CUSTOM_CODE_INVOKE_RESPONSE,
1693
2035
  CUSTOM_CODE_BROKER_CONNECTION,
package/src/ssrf.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Literal-host SSRF screening shared by the proxied tool dispatchers
2
- // (http-endpoint, mcp-proxy). The Workers runtime can't resolve DNS, so we can
2
+ // (http-endpoint, mcp-proxy) and the website crawler. The Workers runtime can't resolve DNS, so we can
3
3
  // only screen literal hosts and IPs (not DNS rebinding). Private/loopback/
4
4
  // link-local ranges are always rejected; callers may further narrow with an
5
5
  // allowlist.
@@ -13,22 +13,51 @@ export const ipv4InPrivateRange = (host: string): boolean => {
13
13
  if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata)
14
14
  if (a === 192 && b === 168) return true; // private
15
15
  if (a === 172 && b >= 16 && b <= 31) return true; // private
16
+ if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
16
17
  return false;
17
18
  };
18
19
 
20
+ // The IPv4 address an IPv4-mapped IPv6 literal reaches. URL parsing rewrites
21
+ // `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, so both spellings are read.
22
+ const mappedIpv4 = (host: string): string | null => {
23
+ if (!host.startsWith('::ffff:')) return null;
24
+ const rest = host.slice('::ffff:'.length);
25
+ if (rest.includes('.')) return rest;
26
+ const match = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
27
+ if (!match) return null;
28
+ const hi = Number.parseInt(match[1], 16);
29
+ const lo = Number.parseInt(match[2], 16);
30
+ return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
31
+ };
32
+
19
33
  export const isBlockedHost = (hostname: string): boolean => {
20
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
34
+ const host = hostname
35
+ .toLowerCase()
36
+ .replace(/^\[|\]$/g, '')
37
+ .replace(/\.$/, '');
21
38
  if (!host) return true;
22
39
  if (host === 'localhost' || host.endsWith('.localhost')) return true;
23
- if (host === '::1' || host === '::') return true;
24
- // IPv6 link-local (fe80::/10) and unique-local (fc00::/7).
25
- if (
26
- host.startsWith('fe80:') ||
27
- host.startsWith('fc') ||
28
- host.startsWith('fd')
29
- ) {
40
+ if (host.includes(':')) {
41
+ // An IPv6 literal. Its prefixes are only checked here: a domain name such
42
+ // as fdic.gov or fcbarcelona.com starts with the same letters.
43
+ if (host === '::1' || host === '::') return true;
44
+ // Link-local (fe80::/10) and unique-local (fc00::/7).
45
+ if (/^fe[89ab]/.test(host) || /^f[cd]/.test(host)) return true;
46
+ const mapped = mappedIpv4(host);
47
+ return mapped !== null && ipv4InPrivateRange(mapped);
48
+ }
49
+ return ipv4InPrivateRange(host);
50
+ };
51
+
52
+ // Whether a URL may not be fetched on a user's behalf: not http(s), not a URL
53
+ // at all, or pointing at a private, loopback or link-local host.
54
+ export const isBlockedUrl = (url: string): boolean => {
55
+ let parsed: URL;
56
+ try {
57
+ parsed = new URL(url);
58
+ } catch {
30
59
  return true;
31
60
  }
32
- if (ipv4InPrivateRange(host)) return true;
33
- return false;
61
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true;
62
+ return isBlockedHost(parsed.hostname);
34
63
  };
@@ -274,6 +274,94 @@ export const isSensitiveTool = (
274
274
  ): boolean =>
275
275
  annotations?.readOnlyHint !== true && annotations?.destructiveHint !== false;
276
276
 
277
+ // How far a proxied tool's own hints are believed, from its install's config:
278
+ // the remote's read-only / destructive hints only when the owner chose to
279
+ // trust that server, and the owner's own "only reads" mark on a tool always.
280
+ export interface ProxiedToolTrust {
281
+ trustHints: boolean;
282
+ readOnly: boolean;
283
+ }
284
+
285
+ export const proxiedToolTrust = (
286
+ config:
287
+ | { trustAnnotations?: unknown; readOnlyTools?: unknown }
288
+ | null
289
+ | undefined,
290
+ remoteToolName: string
291
+ ): ProxiedToolTrust => ({
292
+ trustHints: config?.trustAnnotations === true,
293
+ readOnly:
294
+ Array.isArray(config?.readOnlyTools) &&
295
+ config.readOnlyTools.includes(remoteToolName)
296
+ });
297
+
298
+ /**
299
+ * The annotations a proxied tool is registered with. A remote server's hints
300
+ * are its own claim about itself, and one that marks a delete read-only would
301
+ * skip every confirmation — so unless the organization chose to trust that
302
+ * server, the read-only and destructive hints are dropped and the tool reads
303
+ * as sensitive by the defaults above. A tool the owner marked as only reading
304
+ * is read-only whatever the remote says, trusted or not. Clients see the
305
+ * same, so they ask about the same tools. The harmless parts (title,
306
+ * openWorldHint, idempotentHint) pass through.
307
+ */
308
+ export const proxiedToolAnnotations = <
309
+ T extends { readOnlyHint?: boolean; destructiveHint?: boolean }
310
+ >(
311
+ annotations: T | null | undefined,
312
+ trust: ProxiedToolTrust
313
+ ): Partial<T> | undefined => {
314
+ const result = { ...annotations } as Partial<T>;
315
+ if (trust.readOnly) {
316
+ delete result.destructiveHint;
317
+ return { ...result, readOnlyHint: true };
318
+ }
319
+ if (!trust.trustHints) {
320
+ delete result.readOnlyHint;
321
+ delete result.destructiveHint;
322
+ }
323
+ return Object.keys(result).length > 0 ? result : undefined;
324
+ };
325
+
326
+ // The install-config keys that hold the owner's trust marks above.
327
+ const PROXIED_TOOL_MARK_KEYS = ['trustAnnotations', 'readOnlyTools'] as const;
328
+
329
+ /**
330
+ * An install's new config with the owner's trust marks carried over from the
331
+ * stored one wherever the new one doesn't say. A save that only knew about
332
+ * the allow-lists (an older dashboard, a script) must not quietly turn the
333
+ * marks off; one that sends them, even as false or empty, decides.
334
+ */
335
+ export const keepProxiedToolMarks = (
336
+ stored: unknown,
337
+ incoming: unknown
338
+ ): unknown => {
339
+ if (
340
+ !stored ||
341
+ typeof stored !== 'object' ||
342
+ !incoming ||
343
+ typeof incoming !== 'object'
344
+ ) {
345
+ return incoming;
346
+ }
347
+ const merged = { ...(incoming as Record<string, unknown>) };
348
+ for (const key of PROXIED_TOOL_MARK_KEYS) {
349
+ if (merged[key] === undefined && key in stored) {
350
+ merged[key] = (stored as Record<string, unknown>)[key];
351
+ }
352
+ }
353
+ return merged;
354
+ };
355
+
356
+ /** Whether a proxied tool is confirmed, decided as its registration is. */
357
+ export const isSensitiveProxiedTool = (
358
+ annotations:
359
+ | { readOnlyHint?: boolean; destructiveHint?: boolean }
360
+ | null
361
+ | undefined,
362
+ trust: ProxiedToolTrust
363
+ ): boolean => isSensitiveTool(proxiedToolAnnotations(annotations, trust));
364
+
277
365
  // JSON with keys sorted at every depth, so two argument objects that say the
278
366
  // same thing serialize the same whatever order the model emitted them in.
279
367
  export const stableJson = (value: unknown): string => {
@@ -289,3 +377,38 @@ export const stableJson = (value: unknown): string => {
289
377
  }
290
378
  return JSON.stringify(value) ?? 'null';
291
379
  };
380
+
381
+ export type ToolApprovalStatus =
382
+ | 'pending'
383
+ | 'approved'
384
+ | 'denied'
385
+ | 'used'
386
+ | 'expired';
387
+
388
+ /**
389
+ * Where an approval stands. Spent and refused come first, since an answer
390
+ * stays the answer after the window closes; past its time, one never answered
391
+ * or never used can no longer be.
392
+ */
393
+ export const toolApprovalStatus = (
394
+ approval: {
395
+ expiresAt: Date;
396
+ approvedAt: Date | null;
397
+ deniedAt: Date | null;
398
+ usedAt: Date | null;
399
+ },
400
+ now: number = Date.now()
401
+ ): ToolApprovalStatus => {
402
+ if (approval.usedAt) return 'used';
403
+ if (approval.deniedAt) return 'denied';
404
+ if (approval.expiresAt.getTime() <= now) return 'expired';
405
+ return approval.approvedAt ? 'approved' : 'pending';
406
+ };
407
+
408
+ /** The dashboard page where the user answers one approval. */
409
+ export const toolApprovalUrl = (
410
+ webUrl: string,
411
+ path: string,
412
+ approvalId: string
413
+ ): string =>
414
+ `${webUrl.replace(/\/+$/, '')}${path}/${encodeURIComponent(approvalId)}`;
@@ -0,0 +1,53 @@
1
+ // How many tools an install puts in front of a model, which is what the tool
2
+ // limit counts. Imports nothing, so node --test runs it directly.
3
+
4
+ // The same values as constants.TOOL_DEFINITION_KEY_MCP_PROXY and
5
+ // TOOL_DEFINITION_KEY_CUSTOM_CODE, written out rather than imported, since
6
+ // this file imports nothing.
7
+ const MCP_PROXY_TOOL_KEY = 'mcp-proxy';
8
+ const CUSTOM_CODE_TOOL_KEY = 'custom-code';
9
+
10
+ // The tools an allow-list keeps out of `names`. An empty or absent list keeps
11
+ // all of them, as the MCP boot loop reads it.
12
+ const allowedCount = (names: string[], config: unknown): number => {
13
+ const allowedTools = (config as { allowedTools?: unknown } | null)
14
+ ?.allowedTools;
15
+ if (!Array.isArray(allowedTools) || allowedTools.length === 0) {
16
+ return names.length;
17
+ }
18
+ const allowed = new Set(allowedTools);
19
+ return names.filter(name => allowed.has(name)).length;
20
+ };
21
+
22
+ /**
23
+ * An mcp-proxy row exposes each discovered remote tool its allow-list keeps,
24
+ * so a single GitHub install can be dozens of tools. A custom-code row exposes
25
+ * the functions of its published version the allow-list keeps — pass their
26
+ * names as `customCodeFunctions`; without them the row counts as one. Every
27
+ * other row is one tool.
28
+ */
29
+ export const countInstallTools = (
30
+ install: {
31
+ toolKey: string;
32
+ config: unknown;
33
+ metadata: unknown;
34
+ },
35
+ customCodeFunctions?: string[]
36
+ ): number => {
37
+ if (install.toolKey === CUSTOM_CODE_TOOL_KEY) {
38
+ return customCodeFunctions
39
+ ? allowedCount(customCodeFunctions, install.config)
40
+ : 1;
41
+ }
42
+ if (install.toolKey !== MCP_PROXY_TOOL_KEY) return 1;
43
+
44
+ const discovered = (
45
+ install.metadata as { discovery?: { tools?: unknown } } | null
46
+ )?.discovery?.tools;
47
+ if (!Array.isArray(discovered)) return 0;
48
+
49
+ const names = discovered
50
+ .map(tool => (tool as { name?: unknown } | null)?.name)
51
+ .filter((name): name is string => typeof name === 'string');
52
+ return allowedCount(names, install.config);
53
+ };