@zackbart/connecta 0.34.0 → 0.34.1
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/CHANGELOG.md +30 -0
- package/README.md +3 -2
- package/bin/version.mjs +1 -1
- package/dist/catalog-drift.d.ts +30 -26
- package/dist/catalog-drift.js +169 -84
- package/dist/catalog-service.js +1 -1
- package/dist/connectors/api-connector.d.ts +3 -0
- package/dist/connectors/api-connector.js +5 -1
- package/dist/connectors/option-shapes.js +1 -1
- package/dist/connectors/remote-mcp.d.ts +2 -0
- package/dist/connectors/remote-mcp.js +5 -1
- package/dist/describe-config.d.ts +2 -0
- package/dist/describe-config.js +1 -0
- package/dist/invocation.js +3 -2
- package/dist/meta-tools.js +3 -2
- package/dist/operator-ui/config-summary.js +3 -0
- package/dist/operator-ui/connection-map.js +2 -1
- package/dist/operator-ui/endpoints.js +1 -1
- package/dist/operator-ui/generated.js +3 -3
- package/dist/operator-ui/health.js +52 -0
- package/dist/operator-ui/overview.js +6 -17
- package/dist/operator-ui/view.d.ts +3 -4
- package/dist/operator-ui/view.js +29 -11
- package/dist/provider.d.ts +6 -1
- package/dist/provider.js +18 -1
- package/dist/providers/_shared/google/workspace.d.ts +3 -0
- package/dist/providers/_shared/google/workspace.js +1 -1
- package/dist/providers/basecamp/index.d.ts +2 -0
- package/dist/providers/breeze/index.d.ts +2 -0
- package/dist/providers/ccb/index.d.ts +2 -0
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.d.ts +2 -0
- package/dist/providers/cloudflare/index.js +5 -1
- package/dist/providers/cloudflare/openapi.generated.js +3296 -3206
- package/dist/providers/cloudflare/rest.js +19 -0
- package/dist/providers/cloudflare/skill.generated.js +2 -2
- package/dist/providers/cloudflare/value-safety.js +51 -18
- package/dist/providers/intercom/index.d.ts +24 -0
- package/dist/providers/intercom/index.js +121 -0
- package/dist/providers/intercom/skill.generated.js +8 -0
- package/dist/providers/linear/index.js +204 -19
- package/dist/providers/linear/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.d.ts +2 -0
- package/dist/providers/mixpanel/index.js +21 -17
- package/dist/providers/mixpanel/skill.generated.js +1 -1
- package/dist/providers/notion/index.d.ts +2 -0
- package/dist/providers/notion/index.js +1 -1
- package/dist/providers/overflow/index.d.ts +2 -0
- package/dist/providers/planning-center/index.d.ts +2 -0
- package/dist/providers/revenuecat/index.d.ts +5 -3
- package/dist/providers/revenuecat/index.js +147 -7
- package/dist/providers/revenuecat/skill.generated.js +3 -3
- package/dist/providers/stripe/index.d.ts +2 -0
- package/dist/providers/stripe/index.js +50 -30
- package/dist/providers/stripe/skill.generated.js +2 -1
- package/dist/providers/tithely/index.d.ts +2 -0
- package/dist/providers/vercel/index.d.ts +2 -0
- package/dist/providers/vercel/index.js +1 -1
- package/dist/providers/vercel/openapi.generated.js +391 -374
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/value-safety.js +14 -4
- package/dist/registry.d.ts +10 -14
- package/dist/registry.js +62 -62
- package/dist/routes/ui-data.js +8 -2
- package/dist/skills.js +8 -2
- package/dist/types.d.ts +18 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +48 -5
- package/documentation/code-mode.md +7 -5
- package/documentation/integrating.md +55 -1
- package/documentation/meta-tools.md +2 -2
- package/documentation/operating.md +1 -1
- package/documentation/operator-tests.md +8 -2
- package/documentation/operator-ui.md +58 -11
- package/documentation/provider-auth-migration.md +22 -0
- package/package.json +5 -1
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.34.1 — 2026-10-19
|
|
6
|
+
|
|
7
|
+
Connecta 0.34.1 is a patch release with a security fix. The Cloudflare provider passed stored AI Search crawler headers through unredacted, including on the routes 0.34.0 already served, so any caller allowed to read those resources could see header values that may carry credentials. 0.34.1 withholds them, along with conditional firewall match credentials, Vercel daemon probe headers, setup-form defaults and payment client secrets, and refuses Cloudforce One match responses that have no published schema before any request is sent. Deployments that use the Cloudflare or Vercel providers should upgrade.
|
|
8
|
+
|
|
9
|
+
Connectors can now set `readOnly: true` (#895). A read-only connector serves only reviewed reads on every endpoint, maintainers' plain `/mcp` included; write and unclassified tools are hidden from discovery and refused on every call path. It combines with endpoint and pool `readOnly`, and the default is unchanged. The Stripe guide now shows both setups side by side: OAuth to Stripe's hosted MCP, or a secret or restricted key through the REST connector, with or without `readOnly`. A new maintained Intercom provider supports personal OAuth or a shared bearer token in the US or EU region; its schemas are not pinned yet because Intercom's catalog requires authentication to list.
|
|
10
|
+
|
|
11
|
+
Catalog drift no longer counts as connector health (#867): a drifted connector reads Connected and stays out of Needs attention, while drift details remain on the connector page and in Activity. Connector cards, counts and the attention list keep their last verdict and position while details reload, and the top bar is more compact. Drift detection itself is corrected (#884): reordered schema arrays no longer report false changes for schemas pinned in the new format (existing pins keep their original hashing until re-reviewed), plan-gated tools can be marked optional, schema coverage is reported honestly, failed fingerprints mark verification unavailable instead of keeping a clean report, and per-person connectors keep separate observations. The Linear, Mixpanel, RevenueCat and Stripe reviews are reconciled with their live hosted catalogs and pin schemas; Mixpanel's Get-Events, Get-Dashboard, List-Cohorts and Get-Metric serve as reads again without deployment overrides, so a `classification: { mixpanel: { "Get-Events": "read" } }` override can be removed. Upgrading from 0.34.0 needs no configuration, storage migration or token rotation.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Add a maintained Intercom hosted MCP provider with explicit US/EU region selection, personal OAuth or shared bearer-token auth, reviewed tool classifications, and guidance for sensitive support data and optional Fin Operator actions. Permission-gated Fin tools are optional for missing-tool drift while present tools retain classification checks. Schema coverage reports present reviewed tools as unpinned. Schema digests remain unpinned because anonymous catalog discovery requires authentication.
|
|
16
|
+
- Add optional connector-level `readOnly` to every connector and maintained provider, enforcing read-only discovery and calls on every endpoint. Show the policy in the operator UI and clarify Stripe OAuth and API-key setup.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Correct catalog drift reporting with schema coverage and verification states,
|
|
21
|
+
identity-partitioned observations, optional gated tools, and versioned schema
|
|
22
|
+
set canonicalization that preserves existing pins. Detect documented tool
|
|
23
|
+
access changes and stale unclassified acknowledgements in provider checks.
|
|
24
|
+
Operator refresh responses include the catalog comparison from that refresh,
|
|
25
|
+
so hashing failures and changed schemas cannot return an earlier clean report.
|
|
26
|
+
Provider pin assertions select the stored digest format, and a generated-roster
|
|
27
|
+
guard verifies every pin against captured schemas and rejects input/output drift.
|
|
28
|
+
- Reconcile the maintained Linear catalog with hosted label lifecycle, inbox, saved view, triage, and diff tools. Preserve plan-gated customer requests, review the documented customer-needs read, and pin the observed hosted schemas so later changes fail closed.
|
|
29
|
+
- Refresh reviewed hosted Mixpanel schemas so Get-Events, Get-Dashboard, List-Cohorts, and Get-Metric remain available to read-only endpoints without deployment overrides. Review the limited-beta Get-Audit-Log as a read, retain Fill-Event-Metadata as a write, and classify Get-Experiment as destructive because its optional computations can persist results and auto-conclude experiments.
|
|
30
|
+
- Keep catalog drift informational, keep each connector's last verdict on every operator view while its details are read again so cards, counts and the attention list stop bouncing, keep attention rows in configuration order as their verdicts settle, safely hold prototype-named connector IDs, keep card and detail headers and Overview attention text on single lines with accessible ellipsis, including long connector ids, auth scopes and Read-only badges, reserve the card action footer and empty tool bar row, and compact the top bar for long deployment names and all page tabs.
|
|
31
|
+
- Refresh Vercel and Cloudflare REST contracts and value-safety reviews, withhold conditional firewall match credentials, stored AI Search crawler headers and daemon probe headers, setup-form defaults, and payment client secrets, refuse schemaless Cloudforce match responses before dispatch, admit two stateless Cloudflare POST reads, classify hosted Cloudflare documentation search as read-only, and reconcile Vercel's complete hosted tool inventory.
|
|
32
|
+
- Review RevenueCat subscription-group listing, audience preview, and paywall screenshot rendering as reads, pin four live schemas, and clarify permission-gated catalogs and conservative credential-validation routing.
|
|
33
|
+
- Refresh Stripe hosted MCP tool reviews for money movement, recipient setup, Atlas formation, and renamed feedback; remove retired reviews, pin captured schemas for drift detection, and document account, mode, and human-confirmation requirements.
|
|
34
|
+
|
|
5
35
|
## 0.34.0 — 2026-10-16
|
|
6
36
|
|
|
7
37
|
Connecta 0.34.0 rebuilds the operator UI around an Overview with connector health, endpoints, people and agents, a Needs attention list with fix prompts, and a connection map (#867). Maintainers now land on Overview at `/`; Endpoints moves to `/endpoints`, and `/access` redirects there. Update bookmarks, links and browser automation that expected Endpoints at `/`. Members still get their member page on every operator page path. The upgrade steps are in `documentation/migration-0.34.md`.
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ only the answer comes back.
|
|
|
29
29
|
|
|
30
30
|
## What you can connect
|
|
31
31
|
|
|
32
|
-
Maintained connections: <!-- providers:start -->Basecamp, Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Infisical, Linear, Mixpanel, Notion, Overflow, Planning Center, RevenueCat, Stripe, Tithe.ly, Vercel, and Wispr Flow<!-- providers:end -->.
|
|
32
|
+
Maintained connections: <!-- providers:start -->Basecamp, Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Infisical, Intercom, Linear, Mixpanel, Notion, Overflow, Planning Center, RevenueCat, Stripe, Tithe.ly, Vercel, and Wispr Flow<!-- providers:end -->.
|
|
33
33
|
Plus any remote MCP server or HTTP API you describe.
|
|
34
34
|
|
|
35
35
|
## Who can use it
|
|
@@ -104,7 +104,8 @@ has the full layout.
|
|
|
104
104
|
## Reads and writes
|
|
105
105
|
|
|
106
106
|
Every endpoint is writable or read-only, and you choose explicitly. A
|
|
107
|
-
read-only endpoint hides write tools and refuses every write.
|
|
107
|
+
read-only endpoint hides write tools and refuses every write. You can also make
|
|
108
|
+
one connection read-only across all endpoints. On a writable
|
|
108
109
|
one, your agent's host decides what to approve.
|
|
109
110
|
|
|
110
111
|
## Where it runs
|
package/bin/version.mjs
CHANGED
package/dist/catalog-drift.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { CatalogDriftCounts, CatalogDriftReport, Connector, Logger, ToolClassification, ToolDef } from "./types.js";
|
|
2
2
|
/**
|
|
3
|
-
* Rebuild a drift report as bounded counts and a
|
|
3
|
+
* Rebuild a drift report as bounded counts, closed verification facts, and a timestamp.
|
|
4
4
|
*
|
|
5
5
|
* `Connector.catalogDrift()` sits on the open plugin seam, and what it returns
|
|
6
|
-
* lands
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and a time and nothing else" is *made* true here — at the boundary where
|
|
6
|
+
* lands on authenticated connector status and the operator page. TypeScript
|
|
7
|
+
* constrains neither extra enumerable properties nor `observedAt` at runtime.
|
|
8
|
+
* Counts, closed verification facts, and a time are rebuilt at the boundary where
|
|
10
9
|
* third-party output becomes a response — rather than trusted. A report whose
|
|
11
10
|
* `observedAt` names no instant is no observation connecta can date, and is
|
|
12
11
|
* dropped whole (INV-6): a plugin forwarding a downstream's report could
|
|
@@ -29,11 +28,12 @@ interface VettedToolRecord {
|
|
|
29
28
|
* Digest of the input and output schemas that release read, or undefined
|
|
30
29
|
* when no release has recorded them. Undefined is not "unchanged": a
|
|
31
30
|
* manifest with no digest cannot report a schema change, and says so by
|
|
32
|
-
* counting none. The
|
|
33
|
-
*
|
|
31
|
+
* counting none and reporting the unpinned coverage. The provider check never
|
|
32
|
+
* creates or updates schema digests; live `tools/list` remains the schema
|
|
34
33
|
* agents receive ([#351](https://github.com/zackbart/connecta/issues/351)).
|
|
35
34
|
*/
|
|
36
35
|
schemaDigest?: string;
|
|
36
|
+
optional?: boolean;
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* The vetted manifest one hosted-MCP proxy ships: every tool name a release
|
|
@@ -59,6 +59,8 @@ export interface VettedCatalogInput {
|
|
|
59
59
|
* read them; an invented digest reports drift that never happened.
|
|
60
60
|
*/
|
|
61
61
|
schemaDigests?: Readonly<Record<string, string>>;
|
|
62
|
+
/** Reviewed names that may be absent under plan/permission gating. */
|
|
63
|
+
optionalTools?: ReadonlySet<string>;
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
64
66
|
* Digest the schemas of one downstream tool.
|
|
@@ -67,13 +69,17 @@ export interface VettedCatalogInput {
|
|
|
67
69
|
* `src/catalog-fingerprint.ts`: that one is deliberately conservative because a
|
|
68
70
|
* spurious cache write is cheap, while a spurious drift finding spends a
|
|
69
71
|
* maintainer's attention on a downstream that reordered its JSON keys.
|
|
72
|
+
* New pins use `sha256-sets-v1:` and sort required, enum, and type arrays only
|
|
73
|
+
* at schema keyword locations. Tuple arrays and JSON data keep their order.
|
|
74
|
+
* Legacy `sha256:` pins use the original literal-array form, so an upgrade
|
|
75
|
+
* cannot silently invalidate a reviewed provider manifest.
|
|
70
76
|
* Description and annotations are excluded — a reworded description is P1's
|
|
71
77
|
* business, and an annotation change is already its own drift category.
|
|
72
78
|
*
|
|
73
79
|
* Rejects rather than digesting part of a schema: a digest that ignored some
|
|
74
80
|
* leaf would let a reviewed read keep its verdict after that leaf changed.
|
|
75
81
|
*/
|
|
76
|
-
export declare function vettedSchemaDigest(tool: ToolDef): Promise<string>;
|
|
82
|
+
export declare function vettedSchemaDigest(tool: ToolDef, format?: "sets-v1" | "legacy"): Promise<string>;
|
|
77
83
|
/**
|
|
78
84
|
* Build a provider's manifest from the lists it already maintains.
|
|
79
85
|
*
|
|
@@ -92,6 +98,11 @@ export declare function reviewedCatalog(classification: ToolClassification, owne
|
|
|
92
98
|
* after review.
|
|
93
99
|
*/
|
|
94
100
|
export declare function reviewedClassification(classification: ToolClassification, owner: string): ToolClassification;
|
|
101
|
+
/** One whole-schema verification shared by reporting and serving this listing. */
|
|
102
|
+
export interface CatalogSchemaVerification {
|
|
103
|
+
changed: ReadonlySet<string>;
|
|
104
|
+
available: boolean;
|
|
105
|
+
}
|
|
95
106
|
/**
|
|
96
107
|
* Compare a live catalog with the manifest and count what moved.
|
|
97
108
|
*
|
|
@@ -110,25 +121,18 @@ export declare function catalogReviewOf(connector: Connector): VettedCatalog | u
|
|
|
110
121
|
* Classify downstream facts with a review: what the registry serves on every
|
|
111
122
|
* read, from whichever cache layer the facts came from.
|
|
112
123
|
*
|
|
113
|
-
* Every tool returned is
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* meanwhile no digested review vouches for a read (INV-1).
|
|
124
|
+
* Every tool returned is fresh. The registry supplies the same whole-schema
|
|
125
|
+
* verification it observed for this listing, including failures. Standalone
|
|
126
|
+
* callers verify here. No failed verification survives onto a later listing,
|
|
127
|
+
* and meanwhile no digested review vouches for a read (INV-1).
|
|
118
128
|
*/
|
|
119
|
-
export declare function classifyCatalog(catalog: VettedCatalog, connectorId: string, facts: readonly ToolDef[], logger: Logger,
|
|
129
|
+
export declare function classifyCatalog(catalog: VettedCatalog, connectorId: string, facts: readonly ToolDef[], logger: Logger, overrides?: Readonly<Record<string, "read" | "write">>, verification?: CatalogSchemaVerification): Promise<ToolDef[]>;
|
|
120
130
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* The check happens where the tools are already in hand, while serving a
|
|
125
|
-
* refresh the deployment asked for. It adds no request of its own, which is
|
|
126
|
-
* the whole boundary: connecta watches a contract while it is serving a
|
|
127
|
-
* refresh, and never initiates one to go looking
|
|
128
|
-
* ([#179](https://github.com/zackbart/connecta/issues/179),
|
|
129
|
-
* [#343](https://github.com/zackbart/connecta/issues/343)).
|
|
131
|
+
* Observe tools already fetched by a listing. No probe, persistence, or catalog
|
|
132
|
+
* payload: registry-owned partition tokens isolate deployment and subject.
|
|
133
|
+
* A digest failure replaces old success, while classification fails closed.
|
|
130
134
|
*/
|
|
131
|
-
export declare function observeReviewedDrift(connector: Connector, catalog: VettedCatalog, listed: readonly ToolDef[], logger: Logger): Promise<
|
|
132
|
-
/**
|
|
133
|
-
export declare function observedCatalogDrift(connector: Connector): CatalogDriftReport | undefined;
|
|
135
|
+
export declare function observeReviewedDrift(connector: Connector, catalog: VettedCatalog | undefined, listed: readonly ToolDef[], logger: Logger, partition?: object, droppedTools?: number): Promise<CatalogSchemaVerification | undefined>;
|
|
136
|
+
/** Look up only this runtime/catalog partition; never start a listing. */
|
|
137
|
+
export declare function observedCatalogDrift(connector: Connector, partition?: object): CatalogDriftReport | undefined;
|
|
134
138
|
export {};
|
package/dist/catalog-drift.js
CHANGED
|
@@ -20,13 +20,12 @@ function observationTime(value) {
|
|
|
20
20
|
return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
* Rebuild a drift report as bounded counts and a
|
|
23
|
+
* Rebuild a drift report as bounded counts, closed verification facts, and a timestamp.
|
|
24
24
|
*
|
|
25
25
|
* `Connector.catalogDrift()` sits on the open plugin seam, and what it returns
|
|
26
|
-
* lands
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* and a time and nothing else" is *made* true here — at the boundary where
|
|
26
|
+
* lands on authenticated connector status and the operator page. TypeScript
|
|
27
|
+
* constrains neither extra enumerable properties nor `observedAt` at runtime.
|
|
28
|
+
* Counts, closed verification facts, and a time are rebuilt at the boundary where
|
|
30
29
|
* third-party output becomes a response — rather than trusted. A report whose
|
|
31
30
|
* `observedAt` names no instant is no observation connecta can date, and is
|
|
32
31
|
* dropped whole (INV-6): a plugin forwarding a downstream's report could
|
|
@@ -41,6 +40,18 @@ export function boundedCatalogDrift(report) {
|
|
|
41
40
|
return undefined;
|
|
42
41
|
return {
|
|
43
42
|
observedAt,
|
|
43
|
+
...(typeof report.reviewedBaseline === "boolean" ? { reviewedBaseline: report.reviewedBaseline } : {}),
|
|
44
|
+
...(report.verification === "complete" || report.verification === "unavailable"
|
|
45
|
+
? { verification: report.verification }
|
|
46
|
+
: {}),
|
|
47
|
+
...(report.schemaCoverage && typeof report.schemaCoverage === "object"
|
|
48
|
+
? {
|
|
49
|
+
schemaCoverage: {
|
|
50
|
+
pinned: boundedCount(report.schemaCoverage.pinned),
|
|
51
|
+
unpinned: boundedCount(report.schemaCoverage.unpinned),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
44
55
|
unclassifiedTools: boundedCount(report.unclassifiedTools),
|
|
45
56
|
unservedTools: boundedCount(report.unservedTools),
|
|
46
57
|
annotationConflicts: boundedCount(report.annotationConflicts),
|
|
@@ -66,59 +77,98 @@ function canonicalKeys(value) {
|
|
|
66
77
|
const sorted = Object.fromEntries(entries);
|
|
67
78
|
return Object.keys(sorted).map((key) => [key, sorted[key]]);
|
|
68
79
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
const SCHEMA_MAPS = new Set([
|
|
81
|
+
"properties",
|
|
82
|
+
"patternProperties",
|
|
83
|
+
"$defs",
|
|
84
|
+
"definitions",
|
|
85
|
+
"dependentSchemas",
|
|
86
|
+
"dependencies",
|
|
87
|
+
]);
|
|
88
|
+
const SCHEMA_ARRAYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
|
89
|
+
const SCHEMA_CHILDREN = new Set([
|
|
90
|
+
"items",
|
|
91
|
+
"additionalItems",
|
|
92
|
+
"additionalProperties",
|
|
93
|
+
"unevaluatedProperties",
|
|
94
|
+
"unevaluatedItems",
|
|
95
|
+
"contains",
|
|
96
|
+
"propertyNames",
|
|
97
|
+
"not",
|
|
98
|
+
"if",
|
|
99
|
+
"then",
|
|
100
|
+
"else",
|
|
101
|
+
"contentSchema",
|
|
102
|
+
]);
|
|
103
|
+
function childMode(mode, key, value) {
|
|
104
|
+
if (mode === "root" || mode === "map")
|
|
105
|
+
return "schema";
|
|
106
|
+
if (mode !== "schema")
|
|
107
|
+
return "data";
|
|
108
|
+
if (key === "required" || key === "enum" || key === "type")
|
|
109
|
+
return "set";
|
|
110
|
+
if (SCHEMA_MAPS.has(key))
|
|
111
|
+
return "map";
|
|
112
|
+
if (SCHEMA_ARRAYS.has(key) || (key === "items" && Array.isArray(value)))
|
|
113
|
+
return "schema-array";
|
|
114
|
+
return SCHEMA_CHILDREN.has(key) ? "schema" : "data";
|
|
115
|
+
}
|
|
116
|
+
function canonicalJson(root, sets) {
|
|
76
117
|
const out = [];
|
|
77
|
-
const stack = [{ value: root }];
|
|
118
|
+
const stack = [{ value: root, mode: sets ? "root" : "data", out }];
|
|
78
119
|
let nodes = 0;
|
|
79
120
|
while (stack.length > 0) {
|
|
80
121
|
const task = stack.pop();
|
|
122
|
+
const out = task.out;
|
|
81
123
|
if ("text" in task) {
|
|
82
124
|
out.push(task.text);
|
|
83
125
|
continue;
|
|
84
126
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
127
|
+
if ("members" in task) {
|
|
128
|
+
out.push("[", task.members
|
|
129
|
+
.map((member) => member.join(""))
|
|
130
|
+
.sort()
|
|
131
|
+
.join(","), "]");
|
|
132
|
+
continue;
|
|
87
133
|
}
|
|
88
|
-
|
|
89
|
-
|
|
134
|
+
if (++nodes > MAX_SCHEMA_DIGEST_NODES)
|
|
135
|
+
throw new Error(`schema has more than ${MAX_SCHEMA_DIGEST_NODES} values; refusing to digest it in part.`);
|
|
136
|
+
const { value, mode } = task;
|
|
137
|
+
if (value === null || value === undefined)
|
|
90
138
|
out.push("null");
|
|
91
|
-
|
|
92
|
-
else if (typeof value === "string" || typeof value === "boolean") {
|
|
139
|
+
else if (typeof value === "string" || typeof value === "boolean")
|
|
93
140
|
out.push(JSON.stringify(value));
|
|
94
|
-
|
|
95
|
-
else if (typeof value === "number") {
|
|
141
|
+
else if (typeof value === "number")
|
|
96
142
|
out.push(Number.isFinite(value) ? JSON.stringify(value) : "null");
|
|
97
|
-
|
|
98
|
-
else if (typeof value === "function" || typeof value === "symbol") {
|
|
99
|
-
// Only array items reach here; object keys holding these are dropped.
|
|
143
|
+
else if (typeof value === "function" || typeof value === "symbol")
|
|
100
144
|
out.push("null");
|
|
101
|
-
|
|
102
|
-
else if (typeof value !== "object") {
|
|
145
|
+
else if (typeof value !== "object")
|
|
103
146
|
throw new Error(`schema holds a ${typeof value}, which JSON cannot carry.`);
|
|
104
|
-
}
|
|
105
147
|
else if (Array.isArray(value)) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
148
|
+
if (mode === "set") {
|
|
149
|
+
const members = Array.from({ length: value.length }, () => []);
|
|
150
|
+
stack.push({ members, out });
|
|
151
|
+
for (let index = value.length - 1; index >= 0; index--)
|
|
152
|
+
stack.push({ value: value[index], mode: "data", out: members[index] });
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
out.push("[");
|
|
156
|
+
stack.push({ text: "]", out });
|
|
157
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
158
|
+
stack.push({ value: value[index], mode: mode === "schema-array" ? "schema" : "data", out });
|
|
159
|
+
if (index > 0)
|
|
160
|
+
stack.push({ text: ",", out });
|
|
161
|
+
}
|
|
112
162
|
}
|
|
113
163
|
}
|
|
114
164
|
else {
|
|
115
165
|
out.push("{");
|
|
116
|
-
stack.push({ text: "}" });
|
|
166
|
+
stack.push({ text: "}", out });
|
|
117
167
|
const entries = canonicalKeys(value);
|
|
118
|
-
for (let index = entries.length - 1; index >= 0; index
|
|
168
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
119
169
|
const [key, item] = entries[index];
|
|
120
|
-
stack.push({ value: item });
|
|
121
|
-
stack.push({ text: `${index > 0 ? "," : ""}${JSON.stringify(key)}
|
|
170
|
+
stack.push({ value: item, mode: childMode(mode, key, item), out });
|
|
171
|
+
stack.push({ text: `${index > 0 ? "," : ""}${JSON.stringify(key)}:`, out });
|
|
122
172
|
}
|
|
123
173
|
}
|
|
124
174
|
}
|
|
@@ -131,19 +181,23 @@ function canonicalJson(root) {
|
|
|
131
181
|
* `src/catalog-fingerprint.ts`: that one is deliberately conservative because a
|
|
132
182
|
* spurious cache write is cheap, while a spurious drift finding spends a
|
|
133
183
|
* maintainer's attention on a downstream that reordered its JSON keys.
|
|
184
|
+
* New pins use `sha256-sets-v1:` and sort required, enum, and type arrays only
|
|
185
|
+
* at schema keyword locations. Tuple arrays and JSON data keep their order.
|
|
186
|
+
* Legacy `sha256:` pins use the original literal-array form, so an upgrade
|
|
187
|
+
* cannot silently invalidate a reviewed provider manifest.
|
|
134
188
|
* Description and annotations are excluded — a reworded description is P1's
|
|
135
189
|
* business, and an annotation change is already its own drift category.
|
|
136
190
|
*
|
|
137
191
|
* Rejects rather than digesting part of a schema: a digest that ignored some
|
|
138
192
|
* leaf would let a reviewed read keep its verdict after that leaf changed.
|
|
139
193
|
*/
|
|
140
|
-
export async function vettedSchemaDigest(tool) {
|
|
194
|
+
export async function vettedSchemaDigest(tool, format = "sets-v1") {
|
|
141
195
|
const bytes = encoder.encode(canonicalJson({
|
|
142
196
|
inputSchema: tool.inputSchema ?? null,
|
|
143
197
|
outputSchema: tool.outputSchema ?? null,
|
|
144
|
-
}));
|
|
198
|
+
}, format === "sets-v1"));
|
|
145
199
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
146
|
-
return
|
|
200
|
+
return `${format === "sets-v1" ? "sha256-sets-v1" : "sha256"}:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
147
201
|
}
|
|
148
202
|
/**
|
|
149
203
|
* Build a provider's manifest from the lists it already maintains.
|
|
@@ -160,6 +214,7 @@ export function vettedCatalog(input) {
|
|
|
160
214
|
const digest = digestFor(name);
|
|
161
215
|
tools.set(name, {
|
|
162
216
|
verdict: "read-only",
|
|
217
|
+
...(input.optionalTools?.has(name) ? { optional: true } : {}),
|
|
163
218
|
...(digest !== undefined ? { schemaDigest: digest } : {}),
|
|
164
219
|
});
|
|
165
220
|
}
|
|
@@ -170,6 +225,7 @@ export function vettedCatalog(input) {
|
|
|
170
225
|
const digest = digestFor(name);
|
|
171
226
|
tools.set(name, {
|
|
172
227
|
verdict,
|
|
228
|
+
...(input.optionalTools?.has(name) ? { optional: true } : {}),
|
|
173
229
|
...(digest !== undefined ? { schemaDigest: digest } : {}),
|
|
174
230
|
});
|
|
175
231
|
}
|
|
@@ -178,6 +234,10 @@ export function vettedCatalog(input) {
|
|
|
178
234
|
throw new Error(`vettedCatalog() recorded a schema digest for unclassified tool "${name}".`);
|
|
179
235
|
}
|
|
180
236
|
}
|
|
237
|
+
for (const name of input.optionalTools ?? []) {
|
|
238
|
+
if (!tools.has(name))
|
|
239
|
+
throw new Error(`vettedCatalog() marked unclassified tool "${name}" optional.`);
|
|
240
|
+
}
|
|
181
241
|
return { version: 1, tools };
|
|
182
242
|
}
|
|
183
243
|
const VERDICTS = {
|
|
@@ -190,8 +250,8 @@ const PUBLIC_VERDICTS = {
|
|
|
190
250
|
additive: "write",
|
|
191
251
|
destructive: "destructive",
|
|
192
252
|
};
|
|
193
|
-
const SCHEMA_DIGEST = /^sha256
|
|
194
|
-
const REVIEWED_TOOL_KEYS = new Set(["verdict", "reason", "schemaDigest"]);
|
|
253
|
+
const SCHEMA_DIGEST = /^sha256(?:-sets-v1)?:[0-9a-f]{64}$/;
|
|
254
|
+
const REVIEWED_TOOL_KEYS = new Set(["verdict", "reason", "schemaDigest", "optional"]);
|
|
195
255
|
function isRecord(value) {
|
|
196
256
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
197
257
|
}
|
|
@@ -232,7 +292,7 @@ function parseClassification(classification, owner) {
|
|
|
232
292
|
if (!REVIEWED_TOOL_KEYS.has(key))
|
|
233
293
|
fail(`tool "${name}" has unknown key "${key}".`);
|
|
234
294
|
}
|
|
235
|
-
const { verdict: publicVerdict, reason, schemaDigest: digest } = record;
|
|
295
|
+
const { verdict: publicVerdict, reason, schemaDigest: digest, optional } = record;
|
|
236
296
|
const verdict = typeof publicVerdict === "string" && Object.hasOwn(VERDICTS, publicVerdict) ? VERDICTS[publicVerdict] : undefined;
|
|
237
297
|
if (verdict === undefined) {
|
|
238
298
|
fail(`tool "${name}" needs verdict "read", "write", or "destructive".`);
|
|
@@ -241,10 +301,13 @@ function parseClassification(classification, owner) {
|
|
|
241
301
|
fail(`tool "${name}" reason must be a non-empty string.`);
|
|
242
302
|
}
|
|
243
303
|
if (digest !== undefined && (typeof digest !== "string" || !SCHEMA_DIGEST.test(digest))) {
|
|
244
|
-
fail(`tool "${name}" schemaDigest must be "sha256:" and 64 lowercase hex digits.`);
|
|
304
|
+
fail(`tool "${name}" schemaDigest must be "sha256:" or "sha256-sets-v1:" and 64 lowercase hex digits.`);
|
|
245
305
|
}
|
|
306
|
+
if (optional !== undefined && typeof optional !== "boolean")
|
|
307
|
+
fail(`tool "${name}" optional must be a boolean.`);
|
|
246
308
|
tools.set(name, {
|
|
247
309
|
verdict,
|
|
310
|
+
...(typeof optional === "boolean" ? { optional } : {}),
|
|
248
311
|
...(typeof digest === "string" ? { schemaDigest: digest } : {}),
|
|
249
312
|
});
|
|
250
313
|
frozen[name] =
|
|
@@ -253,6 +316,7 @@ function parseClassification(classification, owner) {
|
|
|
253
316
|
: Object.freeze({
|
|
254
317
|
verdict: PUBLIC_VERDICTS[verdict],
|
|
255
318
|
...(typeof reason === "string" ? { reason } : {}),
|
|
319
|
+
...(typeof optional === "boolean" ? { optional } : {}),
|
|
256
320
|
...(typeof digest === "string" ? { schemaDigest: digest } : {}),
|
|
257
321
|
});
|
|
258
322
|
}
|
|
@@ -360,7 +424,8 @@ async function changedSchemas(catalog, tools) {
|
|
|
360
424
|
const changed = new Set();
|
|
361
425
|
for (const definition of tools) {
|
|
362
426
|
const recorded = catalog.tools.get(definition.name)?.schemaDigest;
|
|
363
|
-
if (recorded !== undefined &&
|
|
427
|
+
if (recorded !== undefined &&
|
|
428
|
+
recorded !== (await vettedSchemaDigest(definition, recorded.startsWith("sha256:") ? "legacy" : "sets-v1"))) {
|
|
364
429
|
changed.add(definition.name);
|
|
365
430
|
}
|
|
366
431
|
}
|
|
@@ -387,8 +452,8 @@ function countDrift(catalog, tools, changed) {
|
|
|
387
452
|
annotationConflicts += 1;
|
|
388
453
|
}
|
|
389
454
|
let unservedTools = 0;
|
|
390
|
-
for (const name of catalog.tools
|
|
391
|
-
if (!served.has(name))
|
|
455
|
+
for (const [name, record] of catalog.tools) {
|
|
456
|
+
if (!record.optional && !served.has(name))
|
|
392
457
|
unservedTools += 1;
|
|
393
458
|
}
|
|
394
459
|
return {
|
|
@@ -432,18 +497,20 @@ export function catalogReviewOf(connector) {
|
|
|
432
497
|
* Classify downstream facts with a review: what the registry serves on every
|
|
433
498
|
* read, from whichever cache layer the facts came from.
|
|
434
499
|
*
|
|
435
|
-
* Every tool returned is
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
* meanwhile no digested review vouches for a read (INV-1).
|
|
500
|
+
* Every tool returned is fresh. The registry supplies the same whole-schema
|
|
501
|
+
* verification it observed for this listing, including failures. Standalone
|
|
502
|
+
* callers verify here. No failed verification survives onto a later listing,
|
|
503
|
+
* and meanwhile no digested review vouches for a read (INV-1).
|
|
440
504
|
*/
|
|
441
|
-
export async function classifyCatalog(catalog, connectorId, facts, logger,
|
|
442
|
-
let lapsed =
|
|
505
|
+
export async function classifyCatalog(catalog, connectorId, facts, logger, overrides, verification) {
|
|
506
|
+
let lapsed = verification
|
|
507
|
+
? verification.available
|
|
508
|
+
? verification.changed
|
|
509
|
+
: digestedTools(catalog, facts)
|
|
510
|
+
: undefined;
|
|
443
511
|
if (!lapsed) {
|
|
444
512
|
try {
|
|
445
513
|
lapsed = await changedSchemas(catalog, facts);
|
|
446
|
-
memo?.set(facts, lapsed);
|
|
447
514
|
}
|
|
448
515
|
catch (error) {
|
|
449
516
|
lapsed = digestedTools(catalog, facts);
|
|
@@ -455,39 +522,57 @@ export async function classifyCatalog(catalog, connectorId, facts, logger, memo,
|
|
|
455
522
|
.filter((fact) => catalog.unlisted !== "hide" || catalog.tools.has(fact.name))
|
|
456
523
|
.map((fact) => servedTool(catalog, fact, unverified, overrides?.[fact.name]));
|
|
457
524
|
}
|
|
458
|
-
/**
|
|
459
|
-
* The drift each reviewed connector object showed on its last listing in this
|
|
460
|
-
* runtime. Keyed by the connector rather than by registry, so a principal's
|
|
461
|
-
* registry and the root report one observation, as they serve one connector.
|
|
462
|
-
*/
|
|
525
|
+
/** Counts only, retained weakly by connector and an explicit runtime/catalog partition. */
|
|
463
526
|
const observations = new WeakMap();
|
|
464
527
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
* The check happens where the tools are already in hand, while serving a
|
|
469
|
-
* refresh the deployment asked for. It adds no request of its own, which is
|
|
470
|
-
* the whole boundary: connecta watches a contract while it is serving a
|
|
471
|
-
* refresh, and never initiates one to go looking
|
|
472
|
-
* ([#179](https://github.com/zackbart/connecta/issues/179),
|
|
473
|
-
* [#343](https://github.com/zackbart/connecta/issues/343)).
|
|
528
|
+
* Observe tools already fetched by a listing. No probe, persistence, or catalog
|
|
529
|
+
* payload: registry-owned partition tokens isolate deployment and subject.
|
|
530
|
+
* A digest failure replaces old success, while classification fails closed.
|
|
474
531
|
*/
|
|
475
|
-
export async function observeReviewedDrift(connector, catalog, listed, logger) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
532
|
+
export async function observeReviewedDrift(connector, catalog, listed, logger, partition = connector, droppedTools = 0) {
|
|
533
|
+
let verification;
|
|
534
|
+
let report = {
|
|
535
|
+
observedAt: new Date().toISOString(),
|
|
536
|
+
reviewedBaseline: catalog !== undefined,
|
|
537
|
+
unclassifiedTools: 0,
|
|
538
|
+
unservedTools: 0,
|
|
539
|
+
annotationConflicts: 0,
|
|
540
|
+
schemaChanges: 0,
|
|
541
|
+
...(droppedTools > 0 ? { droppedTools } : {}),
|
|
542
|
+
};
|
|
543
|
+
if (catalog) {
|
|
544
|
+
const reviewed = listed.filter((tool) => catalog.tools.has(tool.name));
|
|
545
|
+
const pinned = digestedTools(catalog, reviewed).size;
|
|
546
|
+
const schemaCoverage = { pinned, unpinned: reviewed.length - pinned };
|
|
547
|
+
try {
|
|
548
|
+
verification = { changed: await changedSchemas(catalog, listed), available: true };
|
|
549
|
+
report = {
|
|
550
|
+
...report,
|
|
551
|
+
...countDrift(catalog, listed, verification.changed),
|
|
552
|
+
schemaCoverage,
|
|
553
|
+
verification: "complete",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
verification = { changed: new Set(), available: false };
|
|
558
|
+
report = {
|
|
559
|
+
...report,
|
|
560
|
+
...countDrift(catalog, listed, verification.changed),
|
|
561
|
+
schemaCoverage,
|
|
562
|
+
verification: "unavailable",
|
|
563
|
+
};
|
|
564
|
+
logFailure(logger, "catalog drift check failed", failureRecord({ connector: connector.id }, error));
|
|
565
|
+
}
|
|
482
566
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
logFailure(logger, "catalog drift check failed", failureRecord({ connector: connector.id }, error));
|
|
567
|
+
let partitions = observations.get(connector);
|
|
568
|
+
if (!partitions) {
|
|
569
|
+
partitions = new WeakMap();
|
|
570
|
+
observations.set(connector, partitions);
|
|
488
571
|
}
|
|
572
|
+
partitions.set(partition, report);
|
|
573
|
+
return verification;
|
|
489
574
|
}
|
|
490
|
-
/**
|
|
491
|
-
export function observedCatalogDrift(connector) {
|
|
492
|
-
return observations.get(connector);
|
|
575
|
+
/** Look up only this runtime/catalog partition; never start a listing. */
|
|
576
|
+
export function observedCatalogDrift(connector, partition = connector) {
|
|
577
|
+
return observations.get(connector)?.get(partition);
|
|
493
578
|
}
|
package/dist/catalog-service.js
CHANGED
|
@@ -465,7 +465,7 @@ export class CatalogService {
|
|
|
465
465
|
// that asked for it. On a read-only endpoint a write is absent from search
|
|
466
466
|
// and describe, as if the connector never had it. Guides are not filtered.
|
|
467
467
|
discoveryCatalogs(ids, route) {
|
|
468
|
-
return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.readOnly
|
|
468
|
+
return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.readOnly || this.registry.getConnector(id)?.readOnly === true
|
|
469
469
|
? Effect.map(this.catalog(id, { signal }), (tools) => tools.filter((tool) => tool.classification === "read"))
|
|
470
470
|
: this.catalog(id, { signal }), {
|
|
471
471
|
...(this.requestSignal ? { signal: this.requestSignal } : {}),
|
|
@@ -112,6 +112,8 @@ export interface ApiOptions {
|
|
|
112
112
|
* construct.
|
|
113
113
|
*/
|
|
114
114
|
maxResultBytes?: number;
|
|
115
|
+
/** Serve only read-classified tools on every endpoint. Defaults to false. */
|
|
116
|
+
readOnly?: boolean;
|
|
115
117
|
/** Optional per-runtime downstream call-admission policy. */
|
|
116
118
|
callAdmission?: ConnectorCallAdmissionPolicy;
|
|
117
119
|
/**
|
|
@@ -146,6 +148,7 @@ export interface ApiOptions {
|
|
|
146
148
|
/** The closed options api() accepts; see `assertKnownOptions`. */
|
|
147
149
|
export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, unknown, true> & {
|
|
148
150
|
readonly shape: {
|
|
151
|
+
readOnly: import("../config-schema.js").Field<boolean, boolean | undefined, true>;
|
|
149
152
|
callAdmission: Omit<import("../config-schema.js").Field<unknown, ConnectorCallAdmissionPolicy | undefined, true>, "parse"> & {
|
|
150
153
|
parse(value: unknown, path: string): ConnectorCallAdmissionPolicy;
|
|
151
154
|
readonly shape: import("../config-schema.js").Shape & {
|
|
@@ -3,7 +3,7 @@ import { attachFailureFacts, carryFailureFacts, errorLabel } from "../operator-r
|
|
|
3
3
|
import { compileValidator, validateCatalogToolInput } from "../validate.js";
|
|
4
4
|
import { array, assertKnownOptions, instance, keys, optionsOf, strings } from "../config-schema.js";
|
|
5
5
|
import { describedEndpoint, describedOrigin, describedTools } from "../described.js";
|
|
6
|
-
import { CALL_ADMISSION, CREDENTIAL, USAGE_GUIDE } from "./option-shapes.js";
|
|
6
|
+
import { READ_ONLY, CALL_ADMISSION, CREDENTIAL, USAGE_GUIDE } from "./option-shapes.js";
|
|
7
7
|
import { assertStaticToolNames } from "../tool-name.js";
|
|
8
8
|
import { registerInvocationAuth } from "../invocation-auth.js";
|
|
9
9
|
import { redactCatalog, redactSentSecrets, sentSecretsFetch, trackCredentialReads } from "../sent-secrets.js";
|
|
@@ -41,6 +41,7 @@ function checkToolContract(id, tool) {
|
|
|
41
41
|
/** The closed options api() accepts; see `assertKnownOptions`. */
|
|
42
42
|
export const API_OPTIONS = optionsOf()({
|
|
43
43
|
...keys("title", "description", "authScope", "maxResultBytes", "testCredential", "testCredentials", "validateArgs"),
|
|
44
|
+
readOnly: READ_ONLY,
|
|
44
45
|
callAdmission: CALL_ADMISSION,
|
|
45
46
|
usageGuide: USAGE_GUIDE,
|
|
46
47
|
credential: CREDENTIAL,
|
|
@@ -90,6 +91,8 @@ function describedApiAuth(opts) {
|
|
|
90
91
|
*/
|
|
91
92
|
export function apiConnector(id, opts, oauth) {
|
|
92
93
|
opts = assertKnownOptions(opts, `api(${JSON.stringify(id)})`, API_OPTIONS);
|
|
94
|
+
if (opts.readOnly !== undefined)
|
|
95
|
+
READ_ONLY.parse(opts.readOnly, `Connector(${JSON.stringify(id)}).readOnly`);
|
|
93
96
|
if (opts.callAdmission !== undefined)
|
|
94
97
|
CALL_ADMISSION.parse(opts.callAdmission, `api(${JSON.stringify(id)}).callAdmission`);
|
|
95
98
|
assertStaticToolNames(opts.tools, `api(${JSON.stringify(id)}).tools`);
|
|
@@ -124,6 +127,7 @@ export function apiConnector(id, opts, oauth) {
|
|
|
124
127
|
...defined({
|
|
125
128
|
description: opts.description,
|
|
126
129
|
authScope: opts.authScope,
|
|
130
|
+
readOnly: opts.readOnly,
|
|
127
131
|
maxResultBytes: opts.maxResultBytes,
|
|
128
132
|
callAdmission: opts.callAdmission,
|
|
129
133
|
usageGuide: opts.usageGuide,
|