@zackbart/connecta 0.38.0 → 0.40.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.
Files changed (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
@@ -25,7 +25,8 @@ export interface GuestApi {
25
25
  text: string;
26
26
  format: "text";
27
27
  }>;
28
- emit(block: GuestNativeBlock | GuestResult): PromiseLike<void>;
28
+ /** Forwards an authored block, a block from result.content, or an inline result; a paged handle is refused. */
29
+ emit(block: GuestNativeBlock | Exclude<GuestResult, GuestResultHandle>): PromiseLike<void>;
29
30
  }
30
31
  export interface GuestResourceResult {
31
32
  contents: Array<{
@@ -114,8 +115,8 @@ interface CatalogSearchArgs {
114
115
  includeSchemaKeys?: boolean;
115
116
  }
116
117
  interface CatalogDescribeArgs {
117
- address?: unknown;
118
- addresses?: unknown;
118
+ address?: string;
119
+ addresses?: string[];
119
120
  format?: SchemaFormat;
120
121
  fullDescriptions?: boolean;
121
122
  }
@@ -171,8 +172,21 @@ interface CatalogSearchPage {
171
172
  };
172
173
  }
173
174
  type CatalogSearchResult = CatalogSearchPage & {
174
- tools: Array<CatalogSearchTool & {
175
- connectorTitle?: string;
175
+ tools: CatalogSearchTool[];
176
+ /** Complete operation reads: pass args unchanged to connecta.call(address, args). */
177
+ readyCalls?: Array<{
178
+ address: string;
179
+ args: {
180
+ path: string;
181
+ };
182
+ classification: "read";
183
+ summary: string;
184
+ guideRequired?: true;
185
+ guideRequiredReasons?: GuideRequiredReason[];
186
+ }>;
187
+ /** Facts shared by a connector's rows, keyed by the id before the address's first dot. */
188
+ connectors?: Record<string, {
189
+ title?: string;
176
190
  guide?: string;
177
191
  guideSummary?: string;
178
192
  }>;
@@ -0,0 +1,35 @@
1
+ // Internal provider kit (decision 0011 S5). Unreachable from src/index.ts.
2
+ //
3
+ // A provider's own argument checks record every problem before refusing, so
4
+ // one invalid_args carries every finding and an agent repairs the call in one
5
+ // turn instead of one constraint per turn.
6
+ import { ConnectorCallError, issueSummary } from "../errors.js";
7
+ import { schemaExample } from "../validate.js";
8
+ /** Every argument problem one call has, then a single refusal. */
9
+ export class ArgumentProblems {
10
+ #issues = [];
11
+ /**
12
+ * Record one problem. A refusal holds only what the published schema
13
+ * declares and fixed text: `path` names declared properties and indices
14
+ * (`<key>` for any other key), `expected` states the allowed shape or bound,
15
+ * and `received` is a JSON type. Never a submitted string or key.
16
+ */
17
+ add(path, expected, received) {
18
+ this.#issues.push({ path, expected, ...(received === undefined ? {} : { received }) });
19
+ }
20
+ /**
21
+ * The one refusal for every recorded problem, or undefined when there is
22
+ * none. With the tool's schema it carries the schema's synthesized example;
23
+ * `withheld` states why a tool offers none.
24
+ */
25
+ refusal(lead, schema, withheld) {
26
+ if (!this.#issues.length)
27
+ return undefined;
28
+ return new ConnectorCallError("invalid_args", `${lead}: ${issueSummary(this.#issues)}`, {
29
+ validation: {
30
+ issues: this.#issues,
31
+ ...(withheld ? { exampleUnavailable: withheld } : schema ? schemaExample(schema) : {}),
32
+ },
33
+ });
34
+ }
35
+ }
@@ -30,6 +30,7 @@
30
30
  // ledger entries this kit forwards its matched proof and renewals to. A
31
31
  // write answered 401 stays unknown; only renewal removes its handoff, and
32
32
  // only a reviewed authentication-before-execution declaration proves refusal.
33
+ import { routesOf } from "../agent-routes.js";
33
34
  import { Data } from "effect";
34
35
  import { ConnectorCallError, isConnectorCallErrorCode, resendAdvice, unavailableCallError, } from "../errors.js";
35
36
  import { attachFailureFacts } from "../operator-record.js";
@@ -429,7 +430,7 @@ function verdict(failure, vendor, options, dispatch = options.dispatches.find((e
429
430
  body: undefined,
430
431
  unreadable: true,
431
432
  operation: dispatch.operation ?? { method: "", template: "" },
432
- }), vendor, options.withholds?.(dispatch.operation ?? { method: "", template: "" }) === true, !dispatch.write);
433
+ }), vendor, options.withholds?.(dispatch.operation ?? { method: "", template: "" }) === true, !dispatch.write, routesOf(options));
433
434
  return providerFailureMessage(mapped, failure.detail);
434
435
  }
435
436
  const uncertainWrite = options.classification === "write" &&
@@ -455,10 +456,10 @@ function verdict(failure, vendor, options, dispatch = options.dispatches.find((e
455
456
  : unreadable, { retryable: false });
456
457
  }
457
458
  case "StatusFailure":
458
- return statusVerdict(failure, vendor, options.withholds?.(failure.operation) === true, options.dispatches.some((dispatch) => dispatch.failure === failure && !dispatch.write));
459
+ return statusVerdict(failure, vendor, options.withholds?.(failure.operation) === true, options.dispatches.some((dispatch) => dispatch.failure === failure && !dispatch.write), routesOf(options));
459
460
  }
460
461
  }
461
- function statusVerdict(failure, vendor, withheld, read) {
462
+ function statusVerdict(failure, vendor, withheld, read, routes) {
462
463
  const input = classificationInput(failure, vendor, withheld);
463
464
  const { headers, presence } = input;
464
465
  // Classification first, over facts and headers only.
@@ -477,7 +478,7 @@ function statusVerdict(failure, vendor, withheld, read) {
477
478
  withheld,
478
479
  ...(withheld ? {} : { body: failure.body }),
479
480
  });
480
- const error = new ConnectorCallError(code, words ? words(detail) : detail, {
481
+ const error = new ConnectorCallError(code, words ? words(detail, routes) : detail, {
481
482
  ...(retryable === undefined ? {} : { retryable }),
482
483
  ...(wait === undefined ? {} : { retryAfterMs: wait }),
483
484
  ...(current ? { current } : {}),
@@ -3,10 +3,29 @@
3
3
  // `fields` narrows a result to the dot paths an agent names. It runs on the
4
4
  // result a tool already built (after value safety, the privacy projection and
5
5
  // any named projection), so it can only remove. A list narrows each item.
6
+ //
7
+ // Every `fields` argument states where its paths start, its anchor, in the
8
+ // schema description, derived here from the same contract that validates it:
9
+ // a named read's result, each item of a list's `items`, or a hatch's response
10
+ // body in `data`. A path is checked against the published output schema as
11
+ // deep as that schema is closed, so a path outside the declared contract is
12
+ // refused before anything is sent. A declared path the response happens to
13
+ // lack is never refused: absent optional values and empty lists are ordinary.
6
14
  import { Schema } from "effect";
7
- import { ConnectorCallError } from "../errors.js";
8
- import { isMaxChars, isMinChars } from "./json-schema.js";
15
+ import { ArgumentProblems } from "./argument-problems.js";
16
+ import { catalogSchema, isMaxChars, isMinChars } from "./json-schema.js";
9
17
  const MAX_FIELDS = 50;
18
+ /** Choices listed in a refusal or a warning; more read as "a declared property". */
19
+ const MAX_CHOICES = 40;
20
+ const ANCHORS = {
21
+ result: "the result",
22
+ item: "each item in items",
23
+ data: "the response body in data, each item when the body is a list",
24
+ };
25
+ /** The description of a `fields` argument: its anchor, one valid example, then any tool note. */
26
+ export function fieldsDescription(anchor, example, note = "It only narrows the result.") {
27
+ return `Dot paths to keep. Paths start at ${ANCHORS[anchor]}, e.g. ${JSON.stringify([example])}. ${note}`;
28
+ }
10
29
  /** The `fields` argument: at most 50 dot paths of 1 to 200 characters. */
11
30
  export function fieldsInput(description) {
12
31
  return Schema.Array(Schema.String.check(isMinChars(1), isMaxChars(200)))
@@ -16,18 +35,97 @@ export function fieldsInput(description) {
16
35
  function record(value) {
17
36
  return value !== null && typeof value === "object" && !Array.isArray(value);
18
37
  }
38
+ /** A dot path's property names; `a[].b` and `a.b` both select `b` in each item of `a`. */
39
+ const segments = (path) => path.replace(/\[\](?=\.|$)/g, "").split(".");
40
+ /**
41
+ * Collect the properties a path segment may name at a schema position into
42
+ * `into`, false when the position is open (any key may appear: `{}`, a bare
43
+ * object or array, a record, a reference). Arrays are transparent, as
44
+ * selection narrows them item by item; a leaf adds nothing and stays closed.
45
+ * Below the first segment, JSON Schema's default applies: an object is closed
46
+ * only when it says `additionalProperties: false`. The first segment keeps the
47
+ * declared properties of the result or item as its closed set.
48
+ */
49
+ function propertiesAt(node, into, root) {
50
+ if (!record(node))
51
+ return node === false;
52
+ const additional = node["additionalProperties"];
53
+ if ("$ref" in node || "allOf" in node || (additional !== undefined && additional !== false))
54
+ return false;
55
+ const branches = [node["anyOf"], node["oneOf"]].flatMap((value) => (Array.isArray(value) ? value : []));
56
+ if (!branches.every((branch) => propertiesAt(branch, into, root)))
57
+ return false;
58
+ if (node["items"] !== undefined && !propertiesAt(node["items"], into, root))
59
+ return false;
60
+ const properties = node["properties"];
61
+ if (record(properties)) {
62
+ for (const [name, child] of Object.entries(properties))
63
+ into.set(name, [...(into.get(name) ?? []), child]);
64
+ return root || additional === false;
65
+ }
66
+ if (branches.length || node["items"] !== undefined || "enum" in node || "const" in node)
67
+ return true;
68
+ const types = [node["type"]].flat();
69
+ return node["type"] !== undefined && !types.includes("object") && !types.includes("array");
70
+ }
71
+ const choices = (names) => names.length > 0 && names.length <= MAX_CHOICES ? `one of ${names.join(", ")}` : "a declared property";
72
+ /**
73
+ * Record every `fields` path the contract cannot carry. A JSON Schema is the
74
+ * published shape at the anchor (a result, or a list's item), checked as deep
75
+ * as it is closed; a set names only the first segments, which is all a hatch's
76
+ * pinned index can promise. A finding names the entry's index, its segment
77
+ * position and the declared properties there, never the submitted text.
78
+ */
79
+ function fieldsIssues(problems, fields, contract, of) {
80
+ const root = contract instanceof Set
81
+ ? { type: "object", properties: Object.fromEntries([...contract].map((n) => [n, {}])) }
82
+ : contract;
83
+ fields?.forEach((field, index) => {
84
+ let nodes = [root];
85
+ for (const [depth, name] of segments(field).entries()) {
86
+ const at = new Map();
87
+ // Selection keeps a scalar a path runs past, so below the first segment only a closed object refuses.
88
+ if (!nodes.every((node) => propertiesAt(node, at, depth === 0)) || (depth > 0 && at.size === 0))
89
+ return;
90
+ const next = at.get(name);
91
+ if (next) {
92
+ nodes = next;
93
+ continue;
94
+ }
95
+ const declared = [...at.keys()];
96
+ problems.add(`/fields/${index}`, depth === 0
97
+ ? `a path beginning with a property of ${of}: ${choices(declared)}`
98
+ : `a declared path of ${of}; segment ${depth + 1} names a property declared at that level: ${choices(declared)}`);
99
+ return;
100
+ }
101
+ });
102
+ }
103
+ /** The single refusal naming every bad `fields` path, if any; `withheld` says why it has no example. */
104
+ export function fieldsProblem(fields, contract, of, withheld) {
105
+ const problems = new ArgumentProblems();
106
+ fieldsIssues(problems, fields, contract, of);
107
+ return problems.refusal("Invalid fields", undefined, withheld);
108
+ }
19
109
  /**
20
- * The refusal for a path whose first segment is not a property the result can
21
- * carry, if any. Named tools pass their output Schema's property names and
22
- * the hatch its operation's declared response roots, so a bad path fails
23
- * before anything is sent.
110
+ * The `fields` contract for a named read's result (`"result"`) or a list's
111
+ * item (`"item"`). `shape` is that result or item: a Schema rendered as the
112
+ * catalog renders it, or the catalog JSON Schema itself. Its first declared
113
+ * property is the description's example, so the example is always valid.
24
114
  */
25
- export function fieldsProblem(fields, names, of) {
26
- const bad = fields?.find((field) => !names.has(field.replace(/\[\](?=\.|$)/g, "").split(".")[0]));
27
- if (bad === undefined)
28
- return undefined;
29
- const choices = names.size > 0 && names.size <= 40 ? `: one of ${[...names].join(", ")}` : "";
30
- return new ConnectorCallError("invalid_args", `fields path ${bad} does not begin with a property of ${of}${choices}.`);
115
+ export function fieldsContract(anchor, shape, note, of = anchor === "item" ? "an item" : "the result") {
116
+ const schema = "ast" in shape ? catalogSchema(shape, "output") : shape;
117
+ const at = new Map();
118
+ propertiesAt(schema, at, true);
119
+ const example = [...at.keys()][0];
120
+ if (example === undefined)
121
+ throw new Error("fields needs a contract that declares at least one property.");
122
+ const description = fieldsDescription(anchor, example, note);
123
+ return {
124
+ description,
125
+ input: fieldsInput(description),
126
+ issues: (problems, fields) => fieldsIssues(problems, fields, schema, of),
127
+ problem: (fields) => fieldsProblem(fields, schema, of),
128
+ };
31
129
  }
32
130
  /** Keep only the named dot paths; arrays are narrowed item by item. */
33
131
  export function selectFields(data, fields) {
@@ -40,10 +138,91 @@ export function selectFields(data, fields) {
40
138
  return value;
41
139
  const children = new Map();
42
140
  for (const path of paths) {
43
- const [key, ...tail] = path.replace(/\[\](?=\.|$)/g, "").split(".");
141
+ const [key, ...tail] = segments(path);
44
142
  children.set(key, [...(children.get(key) ?? []), tail.join(".")]);
45
143
  }
46
144
  return Object.fromEntries([...children].flatMap(([key, tails]) => Object.hasOwn(value, key) ? [[key, tails.includes("") ? value[key] : pick(value[key], tails)]] : []));
47
145
  };
48
146
  return pick(data, fields);
49
147
  }
148
+ const empty = (value) => value === null ||
149
+ value === undefined ||
150
+ (Array.isArray(value) ? value.length === 0 : record(value) && !Object.keys(value).length);
151
+ /** Warning bounds: each warning's characters, the count kept, and every kept warning's characters together. */
152
+ export const WARNING_BUDGET = { chars: 512, warnings: 10, totalChars: 4096 };
153
+ /**
154
+ * Keep warnings within WARNING_BUDGET in order, cutting a long one with an
155
+ * ellipsis, then one count of what was omitted. Merged reviewed warnings and
156
+ * `fields` reports share the budget.
157
+ */
158
+ /** At most `max` UTF-16 units, never splitting a surrogate pair. */
159
+ function codePointSlice(text, max) {
160
+ const end = max > 0 && /[\uD800-\uDBFF]/.test(text[max - 1] ?? "") ? max - 1 : max;
161
+ return text.slice(0, end);
162
+ }
163
+ export function boundWarnings(warnings) {
164
+ const kept = [];
165
+ let total = 0;
166
+ for (const warning of warnings) {
167
+ const text = warning.length > WARNING_BUDGET.chars ? `${codePointSlice(warning, WARNING_BUDGET.chars - 1)}…` : warning;
168
+ // Leave room for the omitted count.
169
+ if (kept.length >= WARNING_BUDGET.warnings || total + text.length > WARNING_BUDGET.totalChars - 64)
170
+ break;
171
+ kept.push(text);
172
+ total += text.length;
173
+ }
174
+ const omitted = warnings.length - kept.length;
175
+ return omitted ? [...kept, `${omitted} more warning${omitted === 1 ? "" : "s"} omitted.`] : kept;
176
+ }
177
+ /** Declared names listed in one warning, within its character budget. */
178
+ function named(names) {
179
+ let text = "";
180
+ let shown = 0;
181
+ for (const name of names) {
182
+ const next = text ? `${text}, ${name}` : name;
183
+ if (next.length > WARNING_BUDGET.chars - 200)
184
+ break;
185
+ text = next;
186
+ shown += 1;
187
+ }
188
+ return shown < names.length ? `${text}${text ? " and " : ""}${names.length - shown} more` : text;
189
+ }
190
+ /**
191
+ * One warning per `fields` entry that matched nothing in a nonempty response,
192
+ * for contracts that cannot refuse it in advance. It names the entry's index,
193
+ * the segment where matching stopped and the properties the contract declares
194
+ * there, from `declared` (paths in `fields` coordinates). It never lists the
195
+ * response's own keys, which in an open map are data, nor the submitted text.
196
+ * A path some item matched, a path past a scalar (kept whole), or any path of
197
+ * an empty response, is no warning.
198
+ */
199
+ export function fieldsMisses(data, fields, declared = []) {
200
+ if (!fields?.length || empty(data))
201
+ return [];
202
+ const flat = (values) => values.flatMap((value) => (Array.isArray(value) ? flat(value) : [value]));
203
+ const contract = declared.map((path) => segments(path.replace(/^\[\]\.?/, "")));
204
+ return fields.flatMap((field, index) => {
205
+ const path = segments(field);
206
+ let values = flat([data]);
207
+ for (const [depth, name] of path.entries()) {
208
+ const holders = values.filter((value) => record(value) && Object.hasOwn(value, name));
209
+ if (holders.length) {
210
+ values = flat(holders.map((value) => value[name]));
211
+ continue;
212
+ }
213
+ // Selection keeps a scalar or null a path runs past, so only an object lacking the key is a miss.
214
+ if (!values.some(record))
215
+ return [];
216
+ const prefix = path.slice(0, depth);
217
+ const names = [
218
+ ...new Set(contract
219
+ .filter((parts) => parts.length > depth && prefix.every((part, at) => parts[at] === part))
220
+ .map((parts) => parts[depth])),
221
+ ];
222
+ return [
223
+ `fields/${index} matched nothing in this response after value safety and privacy filtering, at segment ${depth + 1}; ${names.length ? `declared there: ${named(names)}` : "the operation declares no properties there"}.`,
224
+ ];
225
+ }
226
+ return [];
227
+ });
228
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * At most 100 paths and 4 KiB of path JSON, `withheld` included, an eighth of
3
+ * the 32 KiB details budget. That holds every path of nine in ten operations
4
+ * in each maintained index; larger responses keep their shallowest paths,
5
+ * roots first. A path over 200 characters, longer than one `fields` entry may
6
+ * be, is never listed.
7
+ */
8
+ export const RESPONSE_BUDGET = { paths: 100, chars: 4096, pathChars: 200 };
9
+ /** `a[].b` → `["a", "b"]`. */
10
+ function names(path) {
11
+ return path.split(".").map((part) => part.replace(/\[\]$/, ""));
12
+ }
13
+ /**
14
+ * The capped response contract for one operation, or undefined when it
15
+ * declares no response properties (`fields` is then refused). Deterministic:
16
+ * paths keep the index's order within each depth.
17
+ */
18
+ export function responseContract(index, op, safety) {
19
+ const declared = index.fields(op).filter((field) => field.in === "response");
20
+ if (declared.length === 0)
21
+ return undefined;
22
+ const anchor = declared.some((field) => field.path.startsWith("[]")) ? "data[]" : "data";
23
+ // The coordinates `fields` validates: an item path of a list body drops its leading `[].`.
24
+ const paths = [
25
+ ...new Set(declared.map((field) => field.path.replace(/^\[\]\.?/, "")).filter((path) => /^[^.[]/.test(path))),
26
+ ];
27
+ // Every root `fields` accepts is listed, including one the index names only beneath it.
28
+ const spelled = new Map();
29
+ for (const path of [...paths.map((path) => names(path)[0]), ...paths]) {
30
+ const key = names(path).join(".");
31
+ if (!spelled.has(key))
32
+ spelled.set(key, path);
33
+ }
34
+ let withholds;
35
+ try {
36
+ withholds = safety.withholds({ method: op.method, template: op.path });
37
+ }
38
+ catch {
39
+ withholds = () => false;
40
+ }
41
+ // The hint reads paths with their list markers, a list body's items included.
42
+ const known = new Set([...spelled]
43
+ .filter(([, path]) => withholds([...(anchor === "data[]" ? ["[]"] : []), ...path.split(".")]))
44
+ .map(([key]) => key));
45
+ const depth = (path) => path.split(".").length;
46
+ const ordered = [...spelled.values()]
47
+ .map((path, at) => ({ path, at }))
48
+ .sort((a, b) => depth(a.path) - depth(b.path) || a.at - b.at);
49
+ const fields = [];
50
+ const withheld = [];
51
+ let chars = 0;
52
+ let kept = 0;
53
+ for (const { path } of ordered) {
54
+ // A path longer than a fields input can be is counted, never listed.
55
+ if (path.length > RESPONSE_BUDGET.pathChars)
56
+ continue;
57
+ const hinted = known.has(names(path).join("."));
58
+ const cost = (JSON.stringify(path).length + 1) * (hinted ? 2 : 1);
59
+ if (kept >= RESPONSE_BUDGET.paths || chars + cost > RESPONSE_BUDGET.chars)
60
+ break;
61
+ fields.push(path);
62
+ if (hinted)
63
+ withheld.push(path);
64
+ chars += cost;
65
+ kept += 1;
66
+ }
67
+ return {
68
+ anchor,
69
+ fields,
70
+ ...(withheld.length ? { withheld } : {}),
71
+ ...(kept < ordered.length ? { omitted: ordered.length - kept } : {}),
72
+ };
73
+ }