@lanes-sh/link 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { McpOpenAPITool, ParameterMapper } from 'mcp-from-openapi';
|
|
2
|
+
import { shortenName } from '../mcp/index.ts';
|
|
3
|
+
import {
|
|
4
|
+
READ_BUNDLE,
|
|
5
|
+
WRITE_BUNDLE,
|
|
6
|
+
type Connector,
|
|
7
|
+
type ConnectorContext,
|
|
8
|
+
type DiscoveredCapability,
|
|
9
|
+
type ToolResult,
|
|
10
|
+
} from '#connectivity';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `http` connector — a REST API described by OpenAPI.
|
|
14
|
+
*
|
|
15
|
+
* This is what stops the next service without an MCP server becoming another
|
|
16
|
+
* 612 lines. An operation becomes a capability mechanically: `mcp-from-openapi`
|
|
17
|
+
* resolves `$ref`s and `allOf`, merges path, query, header, and body parameters
|
|
18
|
+
* into one input schema, and hands back a mapper describing where each argument
|
|
19
|
+
* belongs in the request. We contribute the bundle split, the filtering, and
|
|
20
|
+
* the auth — never per-endpoint translation.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface HttpConnectorOptions {
|
|
24
|
+
readonly baseUrl: string;
|
|
25
|
+
/** URL or filesystem path to an OpenAPI 3.x document. */
|
|
26
|
+
readonly openapi: string;
|
|
27
|
+
readonly include?: readonly string[];
|
|
28
|
+
readonly exclude?: readonly string[];
|
|
29
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `*` matches any run of characters; anything else is literal.
|
|
34
|
+
*
|
|
35
|
+
* Matched against operationId, path, and tags, because which of those an
|
|
36
|
+
* operator reaches for depends entirely on how the vendor wrote their spec.
|
|
37
|
+
*/
|
|
38
|
+
export function globMatches(pattern: string, value: string): boolean {
|
|
39
|
+
if (!pattern.includes('*')) return pattern === value;
|
|
40
|
+
const expression = pattern
|
|
41
|
+
.split('*')
|
|
42
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
43
|
+
.join('.*');
|
|
44
|
+
return new RegExp(`^${expression}$`, 'i').test(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The HTTP method decides the bundle.
|
|
49
|
+
*
|
|
50
|
+
* A spec yields meaningful read/write groups with no curation, which is what
|
|
51
|
+
* lets `connect` grant something sensible by default on an API nobody has
|
|
52
|
+
* hand-classified. GET and HEAD are the only verbs that are reliably safe.
|
|
53
|
+
*/
|
|
54
|
+
export function bundleForMethod(method: string): string {
|
|
55
|
+
return /^(get|head)$/i.test(method) ? READ_BUNDLE : WRITE_BUNDLE;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selected(tool: McpOpenAPITool, options: HttpConnectorOptions): boolean {
|
|
59
|
+
const candidates = [
|
|
60
|
+
tool.metadata.operationId ?? '',
|
|
61
|
+
tool.metadata.path,
|
|
62
|
+
tool.name,
|
|
63
|
+
...(tool.metadata.tags ?? []),
|
|
64
|
+
].filter(Boolean);
|
|
65
|
+
|
|
66
|
+
const matches = (patterns: readonly string[]): boolean =>
|
|
67
|
+
patterns.some((pattern) => candidates.some((candidate) => globMatches(pattern, candidate)));
|
|
68
|
+
|
|
69
|
+
if (options.exclude?.length && matches(options.exclude)) return false;
|
|
70
|
+
if (options.include?.length) return matches(options.include);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createHttpConnector(options: HttpConnectorOptions): Connector {
|
|
75
|
+
let generated: McpOpenAPITool[] | undefined;
|
|
76
|
+
|
|
77
|
+
const load = async (): Promise<McpOpenAPITool[]> => {
|
|
78
|
+
if (generated) return generated;
|
|
79
|
+
|
|
80
|
+
// Imported here rather than at the top of the file, so a process that
|
|
81
|
+
// never opens a connector never loads it. `mcp-from-openapi` brings a
|
|
82
|
+
// swagger parser and a YAML parser of its own, around two and a half
|
|
83
|
+
// megabytes with its transitive tree.
|
|
84
|
+
//
|
|
85
|
+
// Measured, and worth being accurate about: this buys **no** startup time
|
|
86
|
+
// that a stopwatch can see — `audit verify` runs in 0.16s either way. It is
|
|
87
|
+
// here for the same reason `deployments/target.ts` imports its cloud
|
|
88
|
+
// adapters inside their branch: code that cannot run should not be loaded,
|
|
89
|
+
// so a parser bug or a supply-chain problem in it cannot reach a process
|
|
90
|
+
// that was never going to call it. The types above are `import type` and
|
|
91
|
+
// cost nothing either way.
|
|
92
|
+
const { OpenAPIToolGenerator } = await import('mcp-from-openapi');
|
|
93
|
+
|
|
94
|
+
// `fromURL` performs SSRF checks on the spec URL, which matters because the
|
|
95
|
+
// URL comes from an operator-supplied manifest and this process can reach
|
|
96
|
+
// the local network.
|
|
97
|
+
const generator = /^https?:/i.test(options.openapi)
|
|
98
|
+
? await OpenAPIToolGenerator.fromURL(options.openapi)
|
|
99
|
+
: await OpenAPIToolGenerator.fromFile(options.openapi);
|
|
100
|
+
|
|
101
|
+
generated = await generator.generateTools();
|
|
102
|
+
return generated;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
kind: 'http',
|
|
107
|
+
|
|
108
|
+
async discover(context): Promise<DiscoveredCapability[]> {
|
|
109
|
+
const tools = await load();
|
|
110
|
+
const chosen = tools.filter((tool) => selected(tool, options));
|
|
111
|
+
const names = chosen.map((tool) => tool.name);
|
|
112
|
+
|
|
113
|
+
return chosen.map((tool) => {
|
|
114
|
+
// Google's operationIds are already namespaced — `gmail.users.labels.list`
|
|
115
|
+
// — so without this the wire name becomes `gmail_gmail_users_labels_list`.
|
|
116
|
+
// Same fix, and the same reason, as the Notion tools named `notion-*`.
|
|
117
|
+
const name = shortenName(context.manifest.id, tool.name, names);
|
|
118
|
+
|
|
119
|
+
// Appended, never substituted. A generated description says what the
|
|
120
|
+
// arguments are and nothing about what the capability is *for*, because
|
|
121
|
+
// the vendor wrote the document for someone who already knows the
|
|
122
|
+
// product. The manifest is not the authority on the first half, so it
|
|
123
|
+
// adds to it rather than replacing it.
|
|
124
|
+
const described =
|
|
125
|
+
tool.description || `${tool.metadata.method.toUpperCase()} ${tool.metadata.path}`;
|
|
126
|
+
const hint = context.manifest.hints?.[name];
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
name,
|
|
130
|
+
description: hint ? `${described}\n\n${hint}` : described,
|
|
131
|
+
inputSchema: tool.inputSchema as unknown as Record<string, unknown>,
|
|
132
|
+
bundle: bundleForMethod(tool.metadata.method),
|
|
133
|
+
// Everything needed to rebuild the request without re-reading the spec,
|
|
134
|
+
// so a cold instance serves from the cache alone.
|
|
135
|
+
target: {
|
|
136
|
+
path: tool.metadata.path,
|
|
137
|
+
method: tool.metadata.method,
|
|
138
|
+
mapper: tool.mapper as unknown as Record<string, unknown>[],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async invoke(capability, args, context: ConnectorContext): Promise<ToolResult> {
|
|
145
|
+
const method = String(capability.target?.['method'] ?? 'get').toUpperCase();
|
|
146
|
+
const template = String(capability.target?.['path'] ?? '');
|
|
147
|
+
const mapper = (capability.target?.['mapper'] ?? []) as ParameterMapper[];
|
|
148
|
+
|
|
149
|
+
const url = new URL(options.baseUrl.replace(/\/$/, '') + buildPath(template, args, mapper));
|
|
150
|
+
const headers = new Headers({ accept: 'application/json' });
|
|
151
|
+
const body = collect(args, mapper, 'body');
|
|
152
|
+
|
|
153
|
+
for (const [key, value] of Object.entries(collect(args, mapper, 'query'))) {
|
|
154
|
+
if (value === undefined) continue;
|
|
155
|
+
|
|
156
|
+
// An array is repeated, not joined. OpenAPI's default for a query
|
|
157
|
+
// parameter is `style: form, explode: true`, which means one occurrence
|
|
158
|
+
// per element — and `String(['From','Subject'])` produces the single
|
|
159
|
+
// value `From,Subject` instead. That failed silently and specifically:
|
|
160
|
+
// Gmail's `metadataHeaders` matched no header name at all, so asking
|
|
161
|
+
// for From and Subject returned a message with an empty header list
|
|
162
|
+
// and no error to explain it.
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
for (const element of value) {
|
|
165
|
+
if (element !== undefined && element !== null) url.searchParams.append(key, String(element));
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
url.searchParams.set(key, String(value));
|
|
171
|
+
}
|
|
172
|
+
for (const [key, value] of Object.entries(collect(args, mapper, 'header'))) {
|
|
173
|
+
if (value !== undefined) headers.set(key, String(value));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hasBody = Object.keys(body).length > 0;
|
|
177
|
+
if (hasBody) headers.set('content-type', 'application/json');
|
|
178
|
+
|
|
179
|
+
// Auth is attached by core, from the manifest's auth kind or its strategy.
|
|
180
|
+
// A connector never sees a raw credential.
|
|
181
|
+
const request = await context.authorize(
|
|
182
|
+
new Request(url.href, {
|
|
183
|
+
method,
|
|
184
|
+
headers,
|
|
185
|
+
...(hasBody ? { body: JSON.stringify(body) } : {}),
|
|
186
|
+
signal: context.provider.signal,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
191
|
+
const response = await doFetch(request);
|
|
192
|
+
|
|
193
|
+
// Cloned so a verifier can read the body without consuming it for us.
|
|
194
|
+
await context.verify?.(response.clone() as unknown as Response);
|
|
195
|
+
|
|
196
|
+
const text = await response.text();
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: 'text', text: `${response.status} ${response.statusText}\n${text}` }],
|
|
200
|
+
isError: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A write that answers 204 has confirmed itself, but an empty string is not
|
|
205
|
+
// a confirmation an agent can read — it is indistinguishable from a call
|
|
206
|
+
// that did nothing, and settling it cost a second, read-only call. Every
|
|
207
|
+
// other transport synthesises a receipt for this reason: `{ deleted: true,
|
|
208
|
+
// uid, … }` in dav, `{ written: true, path, bytes }` in fs. This one is
|
|
209
|
+
// generic and has no such knowledge to draw on, so it claims only what the
|
|
210
|
+
// status licenses — that the request succeeded. A body that exists, `{}`
|
|
211
|
+
// included, is an answer rather than an absent one and passes through.
|
|
212
|
+
if (text.trim() === '') {
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{ type: 'text', text: JSON.stringify({ ok: true, status: response.status }, null, 2) },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { content: [{ type: 'text', text }] };
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Substitute `{param}` placeholders from the arguments the mapper marks as path. */
|
|
226
|
+
function buildPath(
|
|
227
|
+
template: string,
|
|
228
|
+
args: Readonly<Record<string, unknown>>,
|
|
229
|
+
mapper: readonly ParameterMapper[],
|
|
230
|
+
): string {
|
|
231
|
+
const values = collect(args, mapper, 'path');
|
|
232
|
+
|
|
233
|
+
return template.replace(/\{([^}]+)\}/g, (_match, name: string) => {
|
|
234
|
+
const value = values[name];
|
|
235
|
+
if (value === undefined) throw new Error(`Missing path parameter "${name}"`);
|
|
236
|
+
return encodeURIComponent(String(value));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Pull the arguments belonging to one part of the request. */
|
|
241
|
+
function collect(
|
|
242
|
+
args: Readonly<Record<string, unknown>>,
|
|
243
|
+
mapper: readonly ParameterMapper[],
|
|
244
|
+
location: string,
|
|
245
|
+
): Record<string, unknown> {
|
|
246
|
+
const out: Record<string, unknown> = {};
|
|
247
|
+
|
|
248
|
+
for (const entry of mapper) {
|
|
249
|
+
if (entry.type !== location) continue;
|
|
250
|
+
const value = args[entry.inputKey];
|
|
251
|
+
if (value !== undefined) out[entry.key] = value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import PostalMime from 'postal-mime';
|
|
2
|
+
import type { MailboxAttachmentSource } from '#connectivity/mail';
|
|
3
|
+
import { quoted, type ImapClient, type ImapSession } from './client.ts';
|
|
4
|
+
import { asText, itemValue } from './parser.ts';
|
|
5
|
+
import { decodeMailboxName, encodeMailboxName } from './utf7.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Re-attaching an attachment that is already in the mailbox.
|
|
9
|
+
*
|
|
10
|
+
* This is the source worth having most, and the one the reported failure
|
|
11
|
+
* actually needed: a PDF arrived by mail, and forwarding it should not require
|
|
12
|
+
* the bytes to make a round trip through the model. Here they never leave the
|
|
13
|
+
* process.
|
|
14
|
+
*
|
|
15
|
+
* A message is named by its RFC 2822 `Message-ID` rather than by UID, because
|
|
16
|
+
* that is what `get_message` and `search_messages` already report and it does not
|
|
17
|
+
* oblige the caller to also remember which folder the message was in. The cost is
|
|
18
|
+
* a search: INBOX first, since that is where it almost always is, then the rest
|
|
19
|
+
* of the folders in the order the server lists them.
|
|
20
|
+
*
|
|
21
|
+
* Kept out of `commands.ts` because that file is near the size budget and this is
|
|
22
|
+
* a self-contained question — which bytes, given a Message-ID — rather than
|
|
23
|
+
* another operation.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Mailboxes we never search: a copy of the message is not the message. */
|
|
27
|
+
const SKIP_FLAGS = new Set(['\\trash', '\\junk']);
|
|
28
|
+
|
|
29
|
+
export function mailboxAttachments(client: ImapClient): MailboxAttachmentSource {
|
|
30
|
+
return async ({ messageId, attachmentId }) =>
|
|
31
|
+
client.run(async (session) => {
|
|
32
|
+
const bytes = await findMessageBytes(session, messageId);
|
|
33
|
+
if (!bytes) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`No message with Message-ID ${messageId} in this account, so its attachment cannot be re-attached.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const mail = await PostalMime.parse(bytes);
|
|
40
|
+
const attachments = mail.attachments;
|
|
41
|
+
|
|
42
|
+
if (attachments.length === 0) {
|
|
43
|
+
throw new Error(`Message ${messageId} has no attachments.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const chosen = pick(attachments, attachmentId, messageId);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
bytes:
|
|
50
|
+
typeof chosen.content === 'string'
|
|
51
|
+
? new TextEncoder().encode(chosen.content)
|
|
52
|
+
: new Uint8Array(chosen.content),
|
|
53
|
+
filename: chosen.filename ?? null,
|
|
54
|
+
contentType: chosen.mimeType ?? null,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ParsedAttachment = Awaited<ReturnType<typeof PostalMime.parse>>['attachments'][number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which of a message's attachments was meant.
|
|
63
|
+
*
|
|
64
|
+
* By filename, or by 1-based position, or — when the message carries exactly one
|
|
65
|
+
* — by there being no ambiguity to resolve. An unmatched name lists what is
|
|
66
|
+
* actually there, because the alternative is a caller guessing twice.
|
|
67
|
+
*/
|
|
68
|
+
function pick(
|
|
69
|
+
attachments: readonly ParsedAttachment[],
|
|
70
|
+
attachmentId: string | undefined,
|
|
71
|
+
messageId: string,
|
|
72
|
+
): ParsedAttachment {
|
|
73
|
+
const names = attachments.map((entry, index) => entry.filename ?? `#${index + 1}`);
|
|
74
|
+
|
|
75
|
+
if (attachmentId === undefined) {
|
|
76
|
+
if (attachments.length === 1) return attachments[0]!;
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Message ${messageId} has ${attachments.length} attachments, so attachment_id is needed. They are: ${names.join(', ')}.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const exact = attachments.find((entry) => entry.filename === attachmentId);
|
|
83
|
+
if (exact) return exact;
|
|
84
|
+
|
|
85
|
+
const insensitive = attachments.find(
|
|
86
|
+
(entry) => entry.filename?.toLowerCase() === attachmentId.toLowerCase(),
|
|
87
|
+
);
|
|
88
|
+
if (insensitive) return insensitive;
|
|
89
|
+
|
|
90
|
+
const position = Number(attachmentId);
|
|
91
|
+
if (Number.isInteger(position) && position >= 1 && position <= attachments.length) {
|
|
92
|
+
return attachments[position - 1]!;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Message ${messageId} has no attachment "${attachmentId}". They are: ${names.join(', ')}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** INBOX, then every other mailbox, until one holds this Message-ID. */
|
|
101
|
+
async function findMessageBytes(
|
|
102
|
+
session: ImapSession,
|
|
103
|
+
messageId: string,
|
|
104
|
+
): Promise<Uint8Array | null> {
|
|
105
|
+
for (const mailbox of await searchOrder(session)) {
|
|
106
|
+
const bytes = await fetchByMessageId(session, mailbox, messageId);
|
|
107
|
+
if (bytes) return bytes;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function searchOrder(session: ImapSession): Promise<string[]> {
|
|
113
|
+
const result = await session.command('LIST "" "*"');
|
|
114
|
+
|
|
115
|
+
const mailboxes: string[] = [];
|
|
116
|
+
for (const tokens of result.untagged) {
|
|
117
|
+
if (asText(tokens[1]) !== 'LIST') continue;
|
|
118
|
+
|
|
119
|
+
const flags = tokens[2]?.kind === 'list' ? tokens[2].items : [];
|
|
120
|
+
if (flags.some((flag) => SKIP_FLAGS.has(asText(flag)?.toLowerCase() ?? ''))) continue;
|
|
121
|
+
// `\Noselect` names a folder that only holds other folders; EXAMINE fails on
|
|
122
|
+
// it, which would abort the walk before reaching the mailbox we want.
|
|
123
|
+
if (flags.some((flag) => asText(flag)?.toLowerCase() === '\\noselect')) continue;
|
|
124
|
+
|
|
125
|
+
const name = decodeMailboxName(asText(tokens[4]) ?? '');
|
|
126
|
+
if (name) mailboxes.push(name);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rest = mailboxes.filter((name) => name.toUpperCase() !== 'INBOX');
|
|
130
|
+
return ['INBOX', ...rest];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function fetchByMessageId(
|
|
134
|
+
session: ImapSession,
|
|
135
|
+
mailbox: string,
|
|
136
|
+
messageId: string,
|
|
137
|
+
): Promise<Uint8Array | null> {
|
|
138
|
+
try {
|
|
139
|
+
// EXAMINE and BODY.PEEK throughout: looking for something to attach must not
|
|
140
|
+
// mark mail as read, which is the same rule every read path here follows.
|
|
141
|
+
await session.command(`EXAMINE ${quoted(encodeMailboxName(mailbox))}`);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Angle brackets stripped because servers disagree about whether the stored
|
|
147
|
+
// header value includes them, and HEADER matching is a substring test.
|
|
148
|
+
const bare = messageId.replace(/^</, '').replace(/>$/, '');
|
|
149
|
+
const found = await session.command(`UID SEARCH HEADER MESSAGE-ID ${quoted(bare)}`);
|
|
150
|
+
|
|
151
|
+
const uid = found.untagged
|
|
152
|
+
.filter((tokens) => asText(tokens[1]) === 'SEARCH')
|
|
153
|
+
.flatMap((tokens) => tokens.slice(2).map((token) => Number(asText(token))))
|
|
154
|
+
.find((value) => Number.isFinite(value));
|
|
155
|
+
|
|
156
|
+
if (uid === undefined) return null;
|
|
157
|
+
|
|
158
|
+
const fetched = await session.command(`UID FETCH ${uid} (BODY.PEEK[])`);
|
|
159
|
+
const record = fetched.untagged.find(
|
|
160
|
+
(tokens) => asText(tokens[2]) === 'FETCH' && tokens[3]?.kind === 'list',
|
|
161
|
+
);
|
|
162
|
+
if (!record || record[3]?.kind !== 'list') return null;
|
|
163
|
+
|
|
164
|
+
const body = itemValue(record[3].items, 'BODY');
|
|
165
|
+
return body?.kind === 'literal' ? body.bytes : null;
|
|
166
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { READ_BUNDLE, WRITE_BUNDLE, type DiscoveredCapability } from '#connectivity';
|
|
2
|
+
import { attachmentsJsonSchema } from '#connectivity/mail';
|
|
3
|
+
import {
|
|
4
|
+
OPERATIONS,
|
|
5
|
+
SETTABLE_FLAGS,
|
|
6
|
+
SPECIAL_USE_FLAGS,
|
|
7
|
+
mailboxArgument,
|
|
8
|
+
object,
|
|
9
|
+
} from './operations.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What an IMAP connection exposes, as data.
|
|
13
|
+
*
|
|
14
|
+
* Fixed rather than discovered: IMAP describes its *extensions* through
|
|
15
|
+
* CAPABILITY but never its operations, so the set is RFC 3501's rather than the
|
|
16
|
+
* vendor's. Two of them are conditional — `MOVE` is an extension a server may
|
|
17
|
+
* not have, and sending needs an SMTP block in the manifest.
|
|
18
|
+
*/
|
|
19
|
+
export function imapCapabilities(input: {
|
|
20
|
+
readonly supportsMove: boolean;
|
|
21
|
+
readonly canSend: boolean;
|
|
22
|
+
}): DiscoveredCapability[] {
|
|
23
|
+
const capabilities: DiscoveredCapability[] = [
|
|
24
|
+
{
|
|
25
|
+
name: OPERATIONS.listMailboxes,
|
|
26
|
+
description:
|
|
27
|
+
'List mailboxes (folders) in the account, with the flags that say what each is for.',
|
|
28
|
+
bundle: READ_BUNDLE,
|
|
29
|
+
inputSchema: object({
|
|
30
|
+
pattern: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
default: '*',
|
|
33
|
+
description: 'IMAP LIST pattern — "*" for everything, "INBOX/*" for one subtree.',
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
target: { operation: OPERATIONS.listMailboxes },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: OPERATIONS.searchMessages,
|
|
40
|
+
description:
|
|
41
|
+
'Search a mailbox and return message summaries, most recent first. Reading does not mark anything as read.',
|
|
42
|
+
bundle: READ_BUNDLE,
|
|
43
|
+
inputSchema: object({
|
|
44
|
+
mailbox: mailboxArgument,
|
|
45
|
+
from: { type: 'string', description: 'Substring of the From header.' },
|
|
46
|
+
to: { type: 'string', description: 'Substring of the To header.' },
|
|
47
|
+
subject: { type: 'string', description: 'Substring of the Subject header.' },
|
|
48
|
+
text: { type: 'string', description: 'Substring of headers or body.' },
|
|
49
|
+
since: { type: 'string', description: 'On or after this date (YYYY-MM-DD).' },
|
|
50
|
+
before: { type: 'string', description: 'Before this date (YYYY-MM-DD).' },
|
|
51
|
+
unseen: { type: 'boolean', description: 'Only messages not yet read.' },
|
|
52
|
+
flagged: { type: 'boolean', description: 'Only flagged messages.' },
|
|
53
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 },
|
|
54
|
+
}),
|
|
55
|
+
target: { operation: OPERATIONS.searchMessages },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: OPERATIONS.getMessage,
|
|
59
|
+
description:
|
|
60
|
+
'Fetch one message in full: headers, body text, and a list of its attachments. Does not mark it read.',
|
|
61
|
+
bundle: READ_BUNDLE,
|
|
62
|
+
inputSchema: object(
|
|
63
|
+
{
|
|
64
|
+
mailbox: mailboxArgument,
|
|
65
|
+
uid: { type: 'integer', description: 'UID as search_messages reports it.' },
|
|
66
|
+
include_body: { type: 'boolean', default: true },
|
|
67
|
+
},
|
|
68
|
+
['uid'],
|
|
69
|
+
),
|
|
70
|
+
target: { operation: OPERATIONS.getMessage },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: OPERATIONS.markMessages,
|
|
74
|
+
description: 'Add or remove flags on messages — read, flagged, answered, draft.',
|
|
75
|
+
bundle: WRITE_BUNDLE,
|
|
76
|
+
inputSchema: object(
|
|
77
|
+
{
|
|
78
|
+
mailbox: mailboxArgument,
|
|
79
|
+
uids: { type: 'array', items: { type: 'integer' }, minItems: 1, maxItems: 200 },
|
|
80
|
+
add_flags: { type: 'array', items: { enum: [...SETTABLE_FLAGS] } },
|
|
81
|
+
remove_flags: { type: 'array', items: { enum: [...SETTABLE_FLAGS] } },
|
|
82
|
+
},
|
|
83
|
+
['uids'],
|
|
84
|
+
),
|
|
85
|
+
target: { operation: OPERATIONS.markMessages },
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if (input.supportsMove) {
|
|
91
|
+
capabilities.push({
|
|
92
|
+
name: OPERATIONS.moveMessages,
|
|
93
|
+
description:
|
|
94
|
+
'Move messages to another mailbox — archiving, filing, or marking as junk. ' +
|
|
95
|
+
'Give either destination (an exact name from list_mailboxes) or destination_flag, ' +
|
|
96
|
+
'never both. Prefer the flag: mailbox names are localised and vary by provider, ' +
|
|
97
|
+
'so destination_flag: "\\Junk" is how a message is reported as junk portably.',
|
|
98
|
+
bundle: WRITE_BUNDLE,
|
|
99
|
+
inputSchema: object(
|
|
100
|
+
{
|
|
101
|
+
mailbox: mailboxArgument,
|
|
102
|
+
uids: { type: 'array', items: { type: 'integer' }, minItems: 1, maxItems: 200 },
|
|
103
|
+
destination: { type: 'string', description: 'Target mailbox, by exact name.' },
|
|
104
|
+
destination_flag: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
enum: [...SPECIAL_USE_FLAGS],
|
|
107
|
+
description:
|
|
108
|
+
'Target mailbox by its RFC 6154 special-use attribute, resolved against ' +
|
|
109
|
+
'what the server advertises. Survives a mailbox being named Spam, Junk, or ' +
|
|
110
|
+
'Junk E-mail.',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
// `destination` is no longer required, because `destination_flag`
|
|
114
|
+
// satisfies the same need; the handler refuses neither-and-both.
|
|
115
|
+
// JSON Schema could express that with `oneOf`, and does not here:
|
|
116
|
+
// `sanitizeSchema` flattens what it does not recognise, and a
|
|
117
|
+
// constraint that survives to some clients and not others is worse
|
|
118
|
+
// than one enforced in exactly one place.
|
|
119
|
+
['uids'],
|
|
120
|
+
),
|
|
121
|
+
target: { operation: OPERATIONS.moveMessages },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if (input.canSend) {
|
|
127
|
+
capabilities.push({
|
|
128
|
+
name: OPERATIONS.sendMessage,
|
|
129
|
+
description:
|
|
130
|
+
'Send a message, with attachments, filing a copy in the Sent mailbox. Attachments are named by reference — a path, an HTTPS URL, or another message in this mailbox — and this endpoint reads the bytes itself, so never encode a file into the call.',
|
|
131
|
+
bundle: WRITE_BUNDLE,
|
|
132
|
+
inputSchema: object(
|
|
133
|
+
{
|
|
134
|
+
to: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
135
|
+
cc: { type: 'array', items: { type: 'string' } },
|
|
136
|
+
bcc: { type: 'array', items: { type: 'string' } },
|
|
137
|
+
subject: { type: 'string' },
|
|
138
|
+
text: { type: 'string' },
|
|
139
|
+
html: { type: 'string' },
|
|
140
|
+
in_reply_to: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
description: 'Message-ID being replied to, which threads the reply.',
|
|
143
|
+
},
|
|
144
|
+
from_name: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
description:
|
|
147
|
+
"Name to show beside the address, e.g. \"Ada Lovelace\". Defaults to this connection's from_name; without either, recipients see a bare address.",
|
|
148
|
+
},
|
|
149
|
+
attachments: attachmentsJsonSchema,
|
|
150
|
+
},
|
|
151
|
+
['to', 'subject'],
|
|
152
|
+
),
|
|
153
|
+
target: { operation: OPERATIONS.sendMessage },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return capabilities;
|
|
158
|
+
}
|