@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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the sliver of nodemailer this codebase uses.
|
|
3
|
+
*
|
|
4
|
+
* Declared here rather than by adding `@types/nodemailer`, which is a large
|
|
5
|
+
* surface tracking its own version and would be a second dependency to keep
|
|
6
|
+
* honest — in a repository that has six, holds live credentials, and enforces a
|
|
7
|
+
* release-age floor for exactly that reason. What we call is a few fields wide
|
|
8
|
+
* and stable; if that stops being true, this file fails to compile, which is the
|
|
9
|
+
* behaviour we want.
|
|
10
|
+
*
|
|
11
|
+
* Moved here from `transports/imap/` when a second caller appeared: an HTTP mail
|
|
12
|
+
* API needs the same composed bytes SMTP does, so composition outgrew the
|
|
13
|
+
* transport that first needed it.
|
|
14
|
+
*/
|
|
15
|
+
declare module 'nodemailer' {
|
|
16
|
+
interface Envelope {
|
|
17
|
+
from: string;
|
|
18
|
+
to: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One attachment. `content` takes the raw bytes — nodemailer picks the
|
|
23
|
+
* transfer encoding and wraps base64 at 76 columns per RFC 2045 §6.8, which is
|
|
24
|
+
* the kind of detail that corrupts a PDF quietly when hand-rolled.
|
|
25
|
+
*/
|
|
26
|
+
interface Attachment {
|
|
27
|
+
filename?: string;
|
|
28
|
+
content?: Buffer | Uint8Array | string;
|
|
29
|
+
contentType?: string;
|
|
30
|
+
encoding?: string;
|
|
31
|
+
cid?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A display name plus an address. Nodemailer encodes the name if it needs it. */
|
|
35
|
+
interface Address {
|
|
36
|
+
name: string;
|
|
37
|
+
address: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Message {
|
|
41
|
+
from?: string | Address;
|
|
42
|
+
to?: string[];
|
|
43
|
+
cc?: string[];
|
|
44
|
+
bcc?: string[];
|
|
45
|
+
subject?: string;
|
|
46
|
+
text?: string;
|
|
47
|
+
html?: string;
|
|
48
|
+
inReplyTo?: string;
|
|
49
|
+
references?: string[];
|
|
50
|
+
attachments?: Attachment[];
|
|
51
|
+
/** A pre-composed RFC 822 message, sent verbatim. */
|
|
52
|
+
raw?: Buffer | Uint8Array;
|
|
53
|
+
envelope?: Envelope;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SentInfo {
|
|
57
|
+
messageId: string;
|
|
58
|
+
/** Present with `streamTransport` + `buffer`: the composed message. */
|
|
59
|
+
message: Buffer;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Transport {
|
|
63
|
+
sendMail(message: Message): Promise<SentInfo>;
|
|
64
|
+
close(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TransportOptions {
|
|
68
|
+
host?: string;
|
|
69
|
+
port?: number;
|
|
70
|
+
secure?: boolean;
|
|
71
|
+
requireTLS?: boolean;
|
|
72
|
+
auth?: { user: string; pass: string };
|
|
73
|
+
/** Compose without sending, which is how the Sent copy stays identical. */
|
|
74
|
+
streamTransport?: boolean;
|
|
75
|
+
buffer?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nodemailer: {
|
|
79
|
+
createTransport(options: TransportOptions): Transport;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default nodemailer;
|
|
83
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import type { BlobStore } from '#stores/blobs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Staged attachments — bytes handed to the endpoint out of band, then named by
|
|
6
|
+
* an opaque handle.
|
|
7
|
+
*
|
|
8
|
+
* This is the source that survives the endpoint not being on the caller's
|
|
9
|
+
* machine. A `path` means nothing to a container, so on a hosted deployment the
|
|
10
|
+
* bytes have to arrive some other way, and MCP offers none: there is no
|
|
11
|
+
* client-to-server binary channel in any released version of the protocol, and
|
|
12
|
+
* `roots` never carried bytes even before it was deprecated. Every serious
|
|
13
|
+
* treatment of the problem lands on the same shape — upload out of band, get a
|
|
14
|
+
* handle, pass the handle as an ordinary argument — which is also what the
|
|
15
|
+
* protocol's own draft file-transfer proposal settles on.
|
|
16
|
+
*
|
|
17
|
+
* Two blobs per handle rather than one framed payload: `BlobStore.get` returns
|
|
18
|
+
* bytes and nothing else, so the filename and type live in a sidecar and the
|
|
19
|
+
* payload stays byte-exact.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const STAGED_PREFIX = 'attachments/';
|
|
23
|
+
|
|
24
|
+
export const stagedBytesKey = (handle: string): string => `${STAGED_PREFIX}${handle}`;
|
|
25
|
+
export const stagedMetaKey = (handle: string): string => `${STAGED_PREFIX}${handle}.json`;
|
|
26
|
+
|
|
27
|
+
/** How long a staged file stays fetchable. Long enough to compose a mail around. */
|
|
28
|
+
export const STAGED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handles are checked against this before they reach the store.
|
|
32
|
+
*
|
|
33
|
+
* `assertSafeKey` would already reject traversal, but a caller-supplied handle
|
|
34
|
+
* deserves a readable refusal rather than an internal invariant surfacing as a
|
|
35
|
+
* tool error.
|
|
36
|
+
*/
|
|
37
|
+
const HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
38
|
+
|
|
39
|
+
export interface StagedMetadata {
|
|
40
|
+
readonly filename?: string;
|
|
41
|
+
readonly content_type?: string;
|
|
42
|
+
readonly sha256?: string;
|
|
43
|
+
readonly expires_at?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StagedFile {
|
|
47
|
+
readonly bytes: Uint8Array;
|
|
48
|
+
readonly filename: string | null;
|
|
49
|
+
readonly contentType: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isHandle(value: string): boolean {
|
|
53
|
+
return HANDLE.test(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `att_` plus 128 bits, which is unguessable and still fits on one line. */
|
|
57
|
+
export function newHandle(): string {
|
|
58
|
+
return `att_${randomBytes(16).toString('hex')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function putStaged(
|
|
62
|
+
storage: BlobStore,
|
|
63
|
+
input: {
|
|
64
|
+
readonly handle: string;
|
|
65
|
+
readonly bytes: Uint8Array;
|
|
66
|
+
readonly metadata: StagedMetadata;
|
|
67
|
+
},
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await storage.put(stagedBytesKey(input.handle), input.bytes, {
|
|
70
|
+
contentType: input.metadata.content_type ?? 'application/octet-stream',
|
|
71
|
+
});
|
|
72
|
+
await storage.put(
|
|
73
|
+
stagedMetaKey(input.handle),
|
|
74
|
+
new TextEncoder().encode(JSON.stringify(input.metadata)),
|
|
75
|
+
{ contentType: 'application/json' },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Null for both "never staged" and "staged and expired" — the caller cannot act on the difference. */
|
|
80
|
+
export async function getStaged(
|
|
81
|
+
storage: BlobStore,
|
|
82
|
+
handle: string,
|
|
83
|
+
): Promise<StagedFile | null> {
|
|
84
|
+
if (!isHandle(handle)) throw new Error(`"${handle}" is not a handle.`);
|
|
85
|
+
|
|
86
|
+
const bytes = await storage.get(stagedBytesKey(handle));
|
|
87
|
+
if (!bytes) return null;
|
|
88
|
+
|
|
89
|
+
const meta = await storage.get(stagedMetaKey(handle));
|
|
90
|
+
if (!meta) return { bytes, filename: null, contentType: null };
|
|
91
|
+
|
|
92
|
+
let parsed: StagedMetadata;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(new TextDecoder().decode(meta)) as StagedMetadata;
|
|
95
|
+
} catch {
|
|
96
|
+
// Unreadable sidecar. The payload is still the payload, so serve it rather
|
|
97
|
+
// than losing a file to a corrupt metadata write.
|
|
98
|
+
return { bytes, filename: null, contentType: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parsed.expires_at !== undefined && parsed.expires_at < Date.now()) return null;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
bytes,
|
|
105
|
+
filename: parsed.filename ?? null,
|
|
106
|
+
contentType: parsed.content_type ?? null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Drop everything past its expiry.
|
|
112
|
+
*
|
|
113
|
+
* Swept on write rather than on a timer: there is no scheduler in this process,
|
|
114
|
+
* and staging is the only thing that creates the garbage, so the moment someone
|
|
115
|
+
* stages a file is both the cheapest and the most reliable time to clear the
|
|
116
|
+
* last batch.
|
|
117
|
+
*/
|
|
118
|
+
export async function sweepStaged(storage: BlobStore): Promise<number> {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
let removed = 0;
|
|
121
|
+
|
|
122
|
+
for (const blob of await storage.list(STAGED_PREFIX)) {
|
|
123
|
+
if (!blob.key.endsWith('.json')) continue;
|
|
124
|
+
|
|
125
|
+
const meta = await storage.get(blob.key);
|
|
126
|
+
if (!meta) continue;
|
|
127
|
+
|
|
128
|
+
let expiresAt: number | undefined;
|
|
129
|
+
try {
|
|
130
|
+
expiresAt = (JSON.parse(new TextDecoder().decode(meta)) as StagedMetadata).expires_at;
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (expiresAt === undefined || expiresAt >= now) continue;
|
|
135
|
+
|
|
136
|
+
const handle = blob.key.slice(STAGED_PREFIX.length, -'.json'.length);
|
|
137
|
+
await storage.delete(stagedBytesKey(handle));
|
|
138
|
+
await storage.delete(blob.key);
|
|
139
|
+
removed += 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return removed;
|
|
143
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fetching bytes from a URL a caller named, without becoming an SSRF gadget.
|
|
6
|
+
*
|
|
7
|
+
* This is the one attachment source that adds an attack surface rather than just
|
|
8
|
+
* a capability, and the threat model is concrete rather than theoretical: an
|
|
9
|
+
* agent reads a hostile email, the email says "attach the file at this URL", and
|
|
10
|
+
* the endpoint obliges. Deployed on Cloud Run the interesting target is one hop
|
|
11
|
+
* away — `169.254.169.254` hands out the service account token to anything that
|
|
12
|
+
* asks it.
|
|
13
|
+
*
|
|
14
|
+
* Four rules, in order of how much they buy:
|
|
15
|
+
*
|
|
16
|
+
* 1. HTTPS only. Plaintext to a named host is not a use case worth the hole.
|
|
17
|
+
* 2. Resolve the hostname and check **every** address it answers with, not the
|
|
18
|
+
* first. A name with one public and one private A record would otherwise
|
|
19
|
+
* pass a check and then connect wherever the OS felt like.
|
|
20
|
+
* 3. Do not follow redirects implicitly. Each hop is a fresh URL that has to
|
|
21
|
+
* pass the same checks, because a public host redirecting to `127.0.0.1` is
|
|
22
|
+
* the standard bypass.
|
|
23
|
+
* 4. Cap the body while reading it, not by trusting `Content-Length`.
|
|
24
|
+
*
|
|
25
|
+
* What this deliberately does not do is parse IP literals by hand. MCP's own
|
|
26
|
+
* security guidance is blunt that custom parsers miss octal, hex and
|
|
27
|
+
* IPv4-mapped-IPv6 encodings, so the only IPs classified here are ones `isIP`
|
|
28
|
+
* has already accepted as canonical or `dns.lookup` produced — an octal literal
|
|
29
|
+
* like `0177.0.0.1` is not a valid address, so it falls through to name
|
|
30
|
+
* resolution and fails there.
|
|
31
|
+
*
|
|
32
|
+
* Known residual risk: a name could resolve to a safe address during the check
|
|
33
|
+
* and a hostile one during the connection. Closing that needs the connection
|
|
34
|
+
* pinned to the address we validated, which means owning the socket — `fetch`
|
|
35
|
+
* does not expose it, and rewriting the URL to the literal IP would break TLS
|
|
36
|
+
* verification, trading a narrow race for a broken one. Recorded rather than
|
|
37
|
+
* hidden.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const MAX_REDIRECTS = 3;
|
|
41
|
+
|
|
42
|
+
export interface FetchedFile {
|
|
43
|
+
readonly bytes: Uint8Array;
|
|
44
|
+
readonly contentType: string | null;
|
|
45
|
+
readonly filename: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AddressLookup = (hostname: string) => Promise<readonly string[]>;
|
|
49
|
+
|
|
50
|
+
const resolveAddresses: AddressLookup = async (hostname) => {
|
|
51
|
+
if (isIP(hostname) !== 0) return [hostname];
|
|
52
|
+
const answers = await lookup(hostname, { all: true, verbatim: true });
|
|
53
|
+
return answers.map((answer) => answer.address);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export async function fetchFromUrl(input: {
|
|
57
|
+
readonly url: string;
|
|
58
|
+
readonly maxBytes: number;
|
|
59
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
60
|
+
readonly addresses?: AddressLookup;
|
|
61
|
+
readonly signal?: AbortSignal | undefined;
|
|
62
|
+
}): Promise<FetchedFile> {
|
|
63
|
+
const doFetch = input.fetch ?? globalThis.fetch;
|
|
64
|
+
const addresses = input.addresses ?? resolveAddresses;
|
|
65
|
+
|
|
66
|
+
let target = await checkedUrl(input.url, addresses);
|
|
67
|
+
|
|
68
|
+
for (let hop = 0; ; hop += 1) {
|
|
69
|
+
const response = await doFetch(target.href, {
|
|
70
|
+
redirect: 'manual',
|
|
71
|
+
headers: { accept: '*/*' },
|
|
72
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (response.status >= 300 && response.status < 400) {
|
|
76
|
+
const location = response.headers.get('location');
|
|
77
|
+
if (!location) throw new Error(`${target.href} redirected without a Location header.`);
|
|
78
|
+
if (hop >= MAX_REDIRECTS) {
|
|
79
|
+
throw new Error(`${input.url} redirected more than ${MAX_REDIRECTS} times.`);
|
|
80
|
+
}
|
|
81
|
+
// Resolved against the current URL so a relative Location works, then
|
|
82
|
+
// checked from scratch: a hop is a new destination, not a continuation.
|
|
83
|
+
target = await checkedUrl(new URL(location, target).href, addresses);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`${target.href} answered ${response.status} ${response.statusText}.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
bytes: await readCapped(response, input.maxBytes, target.href),
|
|
93
|
+
contentType: normalizeContentType(response.headers.get('content-type')),
|
|
94
|
+
filename: filenameFromDisposition(response.headers.get('content-disposition')),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse, require HTTPS, and refuse a host that resolves anywhere internal. */
|
|
100
|
+
async function checkedUrl(candidate: string, addresses: AddressLookup): Promise<URL> {
|
|
101
|
+
let url: URL;
|
|
102
|
+
try {
|
|
103
|
+
url = new URL(candidate);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error(`"${candidate}" is not a URL.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (url.protocol !== 'https:') {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Attachment URLs must be https — "${url.protocol}//" is not fetched. Stage the file instead if it is not reachable over HTTPS.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let resolved: readonly string[];
|
|
115
|
+
try {
|
|
116
|
+
resolved = await addresses(url.hostname);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(`${url.hostname} does not resolve.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (resolved.length === 0) throw new Error(`${url.hostname} does not resolve.`);
|
|
122
|
+
|
|
123
|
+
for (const address of resolved) {
|
|
124
|
+
if (isBlocked(address)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`${url.hostname} resolves to ${address}, which is a private, loopback, or link-local address. Refusing to fetch it.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return url;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Is this address one we must never connect to on a caller's behalf?
|
|
136
|
+
*
|
|
137
|
+
* Covers the ranges RFC 9728 §7.7 names, plus multicast and reserved space. The
|
|
138
|
+
* IPv4-mapped and NAT64 cases unwrap and re-check, because `::ffff:127.0.0.1` is
|
|
139
|
+
* loopback wearing a different spelling.
|
|
140
|
+
*/
|
|
141
|
+
export function isBlocked(address: string): boolean {
|
|
142
|
+
const version = isIP(address);
|
|
143
|
+
if (version === 4) return isBlockedV4(address);
|
|
144
|
+
if (version === 6) return isBlockedV6(address);
|
|
145
|
+
// Not an address at all. Treated as blocked because every caller here has
|
|
146
|
+
// already resolved a name, so an unparseable value means something upstream
|
|
147
|
+
// is wrong and connecting anyway is the worse failure.
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isBlockedV4(address: string): boolean {
|
|
152
|
+
const parts = address.split('.').map(Number);
|
|
153
|
+
const [a = 0, b = 0] = parts;
|
|
154
|
+
|
|
155
|
+
if (a === 0) return true; // 0.0.0.0/8 — "this host"
|
|
156
|
+
if (a === 10) return true; // private
|
|
157
|
+
if (a === 127) return true; // loopback
|
|
158
|
+
if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata
|
|
159
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
160
|
+
if (a === 192 && b === 168) return true; // private
|
|
161
|
+
if (a === 192 && b === 0) return true; // 192.0.0.0/24 protocol assignments
|
|
162
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
|
163
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
|
|
164
|
+
if (a >= 224) return true; // multicast, reserved, broadcast
|
|
165
|
+
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isBlockedV6(address: string): boolean {
|
|
170
|
+
const bytes = ipv6Bytes(address);
|
|
171
|
+
if (!bytes) return true;
|
|
172
|
+
|
|
173
|
+
// IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) both carry a v4
|
|
174
|
+
// address in the last four bytes. Judge them as what they actually reach.
|
|
175
|
+
const mapped =
|
|
176
|
+
bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
|
|
177
|
+
const nat64 =
|
|
178
|
+
bytes[0] === 0x00 &&
|
|
179
|
+
bytes[1] === 0x64 &&
|
|
180
|
+
bytes[2] === 0xff &&
|
|
181
|
+
bytes[3] === 0x9b &&
|
|
182
|
+
bytes.slice(4, 12).every((byte) => byte === 0);
|
|
183
|
+
if (mapped || nat64) {
|
|
184
|
+
return isBlockedV4(bytes.slice(12).join('.'));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (bytes.every((byte) => byte === 0)) return true; // ::
|
|
188
|
+
if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return true; // ::1
|
|
189
|
+
if ((bytes[0]! & 0xfe) === 0xfc) return true; // fc00::/7 unique local
|
|
190
|
+
if (bytes[0] === 0xfe && (bytes[1]! & 0xc0) === 0x80) return true; // fe80::/10 link-local
|
|
191
|
+
if (bytes[0] === 0xff) return true; // ff00::/8 multicast
|
|
192
|
+
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Expand an IPv6 address to its sixteen bytes.
|
|
198
|
+
*
|
|
199
|
+
* Only ever called with a string `isIP` accepted, so the shape is already
|
|
200
|
+
* canonical — this handles `::` compression and a trailing dotted-quad, and
|
|
201
|
+
* returns null rather than guessing on anything else.
|
|
202
|
+
*/
|
|
203
|
+
function ipv6Bytes(address: string): number[] | null {
|
|
204
|
+
let text = address;
|
|
205
|
+
|
|
206
|
+
// A trailing IPv4 form (::ffff:127.0.0.1) becomes two more hextets.
|
|
207
|
+
const dotted = text.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
208
|
+
if (dotted) {
|
|
209
|
+
const octets = dotted[1]!.split('.').map(Number);
|
|
210
|
+
if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return null;
|
|
211
|
+
const high = ((octets[0]! << 8) | octets[1]!).toString(16);
|
|
212
|
+
const low = ((octets[2]! << 8) | octets[3]!).toString(16);
|
|
213
|
+
text = `${text.slice(0, dotted.index)}${high}:${low}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const [head, tail, ...extra] = text.split('::');
|
|
217
|
+
if (extra.length > 0) return null;
|
|
218
|
+
|
|
219
|
+
const parse = (group: string | undefined): number[] => {
|
|
220
|
+
if (!group) return [];
|
|
221
|
+
return group.split(':').flatMap((hextet) => {
|
|
222
|
+
const value = Number.parseInt(hextet, 16);
|
|
223
|
+
return [(value >> 8) & 0xff, value & 0xff];
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const front = parse(head);
|
|
228
|
+
const back = tail === undefined ? [] : parse(tail);
|
|
229
|
+
const gap = 16 - front.length - back.length;
|
|
230
|
+
if (gap < 0) return null;
|
|
231
|
+
if (tail === undefined && gap !== 0) return null;
|
|
232
|
+
|
|
233
|
+
return [...front, ...Array<number>(gap).fill(0), ...back];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Read the body, stopping at the cap.
|
|
238
|
+
*
|
|
239
|
+
* Streamed rather than `arrayBuffer()` because `Content-Length` is the server's
|
|
240
|
+
* claim about the server's own body — a hostile or broken one can understate it,
|
|
241
|
+
* and buffering first to measure second is how a size limit becomes a memory
|
|
242
|
+
* limit instead.
|
|
243
|
+
*/
|
|
244
|
+
async function readCapped(response: Response, maxBytes: number, href: string): Promise<Uint8Array> {
|
|
245
|
+
const declared = Number(response.headers.get('content-length') ?? Number.NaN);
|
|
246
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
247
|
+
throw new Error(`${href} is ${declared} bytes, over the ${maxBytes} byte limit.`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!response.body) return new Uint8Array();
|
|
251
|
+
|
|
252
|
+
const reader = response.body.getReader();
|
|
253
|
+
const chunks: Uint8Array[] = [];
|
|
254
|
+
let total = 0;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
for (;;) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done) break;
|
|
260
|
+
total += value.byteLength;
|
|
261
|
+
if (total > maxBytes) {
|
|
262
|
+
throw new Error(`${href} is larger than the ${maxBytes} byte limit.`);
|
|
263
|
+
}
|
|
264
|
+
chunks.push(value);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
await reader.cancel().catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const bytes = new Uint8Array(total);
|
|
271
|
+
let offset = 0;
|
|
272
|
+
for (const chunk of chunks) {
|
|
273
|
+
bytes.set(chunk, offset);
|
|
274
|
+
offset += chunk.byteLength;
|
|
275
|
+
}
|
|
276
|
+
return bytes;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeContentType(header: string | null): string | null {
|
|
280
|
+
if (!header) return null;
|
|
281
|
+
const value = header.split(';')[0]?.trim().toLowerCase();
|
|
282
|
+
return value ? value : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `attachment; filename="report.pdf"`, and the RFC 5987 `filename*` form. */
|
|
286
|
+
export function filenameFromDisposition(header: string | null): string | null {
|
|
287
|
+
if (!header) return null;
|
|
288
|
+
|
|
289
|
+
const extended = header.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)/i);
|
|
290
|
+
if (extended?.[1]) {
|
|
291
|
+
try {
|
|
292
|
+
return decodeURIComponent(extended[1].trim());
|
|
293
|
+
} catch {
|
|
294
|
+
/* fall through to the plain form */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const plain = header.match(/filename\s*=\s*("([^"]*)"|[^;]+)/i);
|
|
299
|
+
const value = plain?.[2] ?? plain?.[1];
|
|
300
|
+
return value ? value.trim() : null;
|
|
301
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { credentialRef, identifier } from './primitives.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Credential types — how we prove who we are, orthogonal to how we connect.
|
|
6
|
+
*
|
|
7
|
+
* One folder per method under `../auth/`, and the union below is the complete
|
|
8
|
+
* list of what a manifest may declare today. Adding one is a schema member
|
|
9
|
+
* here, a folder there, and a branch in `../auth/resolve.ts` — see that
|
|
10
|
+
* directory's README, which names the ones not built yet.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const authNoneSchema = z.object({ kind: z.literal('none') });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A client somebody else operates, so the operator does not have to register one.
|
|
17
|
+
*
|
|
18
|
+
* An installed application cannot hold a confidential client: whatever ships in
|
|
19
|
+
* the binary is readable by whoever runs it. That leaves exactly two honest
|
|
20
|
+
* arrangements, and this declares the second — the operator registers their own,
|
|
21
|
+
* or somebody runs one and performs the exchange on their behalf. The secret
|
|
22
|
+
* lives behind these endpoints and never reaches this machine.
|
|
23
|
+
*
|
|
24
|
+
* Vendor-free by construction. The URL is data a provider supplies, so nothing
|
|
25
|
+
* in this component learns whose client is behind it, or whose broker.
|
|
26
|
+
*/
|
|
27
|
+
export const authBrokerSchema = z.object({
|
|
28
|
+
/** Base URL. `<url>/config`, `<url>/exchange`, `<url>/refresh`. */
|
|
29
|
+
url: z.url(),
|
|
30
|
+
/** Who runs it, named in the sentence shown before consent. */
|
|
31
|
+
operator: z.string().min(1),
|
|
32
|
+
docs_url: z.url().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type AuthBroker = z.infer<typeof authBrokerSchema>;
|
|
36
|
+
|
|
37
|
+
export const authOAuthSchema = z.object({
|
|
38
|
+
kind: z.literal('oauth'),
|
|
39
|
+
/**
|
|
40
|
+
* `dynamic` — the authorization server offers Dynamic Client Registration, so
|
|
41
|
+
* we register ourselves and the operator does nothing at all (Notion, Linear).
|
|
42
|
+
*
|
|
43
|
+
* `manual` — the vendor requires a pre-registered client, so the operator
|
|
44
|
+
* supplies an id and secret (Google, including for Google's own MCP servers).
|
|
45
|
+
*/
|
|
46
|
+
registration: z.enum(['dynamic', 'manual']).default('dynamic'),
|
|
47
|
+
/** Which `oauth_apps` entry holds the client, for `manual`. Shared across providers of a vendor. */
|
|
48
|
+
app: identifier.optional(),
|
|
49
|
+
/**
|
|
50
|
+
* Where to authorise when the profile declares no `oauth_apps` entry of its own.
|
|
51
|
+
*
|
|
52
|
+
* Additive rather than a third `registration` value, because the manifest is
|
|
53
|
+
* not what chooses. `registration: manual` stays true either way — the vendor
|
|
54
|
+
* does require a pre-registered client — and `app` still names the entry that
|
|
55
|
+
* overrides this. What decides is the profile: declaring the entry means "my
|
|
56
|
+
* own client", leaving it out means "the one the broker operates". A manifest
|
|
57
|
+
* that claimed one or the other would be wrong half the time.
|
|
58
|
+
*/
|
|
59
|
+
broker: authBrokerSchema.optional(),
|
|
60
|
+
scopes: z.array(z.string()).default([]),
|
|
61
|
+
/**
|
|
62
|
+
* Usually discovered from the resource's metadata; set only to override.
|
|
63
|
+
*
|
|
64
|
+
* Required for an `http` connector, which has no MCP metadata document to
|
|
65
|
+
* discover from — a REST API is just a base URL, and where its authorization
|
|
66
|
+
* server lives is not something the API itself announces.
|
|
67
|
+
*/
|
|
68
|
+
authorize_url: z.url().optional(),
|
|
69
|
+
token_url: z.url().optional(),
|
|
70
|
+
/**
|
|
71
|
+
* Extra parameters on the authorization request.
|
|
72
|
+
*
|
|
73
|
+
* Google needs `access_type=offline` and `prompt=consent`, without which it
|
|
74
|
+
* returns an access token and no refresh token — the connection then works
|
|
75
|
+
* for an hour and dies, which is a miserable thing to debug.
|
|
76
|
+
*/
|
|
77
|
+
authorize_params: z.record(z.string(), z.string()).optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const authTokenSchema = z.object({
|
|
81
|
+
kind: z.enum(['bearer', 'api_key', 'header', 'basic']),
|
|
82
|
+
/** Header name for `header` / `api_key`. Defaults to `Authorization` / `X-API-Key`. */
|
|
83
|
+
header: z.string().optional(),
|
|
84
|
+
/** Send an `api_key` as a query parameter instead of a header. */
|
|
85
|
+
query: z.string().optional(),
|
|
86
|
+
/**
|
|
87
|
+
* The vendor group this credential belongs to, when several providers share
|
|
88
|
+
* one secret *per account*.
|
|
89
|
+
*
|
|
90
|
+
* Apple issues an app-specific password at account scope, so one password
|
|
91
|
+
* genuinely unlocks iCloud Mail, Calendar, and Contacts together — three
|
|
92
|
+
* providers, because they speak two protocols, but one thing to type. Setting
|
|
93
|
+
* `app: icloud` on all three moves the derived ref from `<provider>/<account>`
|
|
94
|
+
* to `icloud/<account>`.
|
|
95
|
+
*
|
|
96
|
+
* The same field on an *OAuth* block means something different and narrower:
|
|
97
|
+
* which `oauth_apps` entry holds the client. It deliberately does not move
|
|
98
|
+
* where tokens land, because Gmail and Drive share a Google client while
|
|
99
|
+
* holding separate tokens granted under different scopes.
|
|
100
|
+
*/
|
|
101
|
+
app: identifier.optional(),
|
|
102
|
+
/**
|
|
103
|
+
* Omit for a per-account credential, which is the usual case and derives.
|
|
104
|
+
*
|
|
105
|
+
* Declaring one means the opposite: a single secret shared by *every* account
|
|
106
|
+
* of this provider — a service key, where the key is itself the identity.
|
|
107
|
+
*/
|
|
108
|
+
credential_ref: credentialRef.optional(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A pluggable strategy, for auth no declarative form should try to express.
|
|
113
|
+
*
|
|
114
|
+
* This is the *only* place per-vendor code is permitted outside `local`
|
|
115
|
+
* providers, and it exists because real APIs do things config cannot describe:
|
|
116
|
+
* bunq generates an RSA keypair, runs a three-step handshake, signs every
|
|
117
|
+
* request, and verifies the response signature. That earns ~150 lines of auth.
|
|
118
|
+
* It must never come to mean per-endpoint code again.
|
|
119
|
+
*/
|
|
120
|
+
export const authStrategySchema = z.object({
|
|
121
|
+
kind: z.literal('strategy'),
|
|
122
|
+
strategy: identifier,
|
|
123
|
+
credential_ref: credentialRef.optional(),
|
|
124
|
+
/** Strategy-specific settings, validated by the strategy itself. */
|
|
125
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const authSchema = z.discriminatedUnion('kind', [
|
|
129
|
+
authNoneSchema,
|
|
130
|
+
authOAuthSchema,
|
|
131
|
+
authTokenSchema,
|
|
132
|
+
authStrategySchema,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
export type AuthConfig = z.infer<typeof authSchema>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { identifier } from './primitives.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A named group of capabilities.
|
|
6
|
+
*
|
|
7
|
+
* For `http` connectors these are derived from the HTTP method — GET and HEAD
|
|
8
|
+
* are `read`, mutating verbs are `write` — so a spec yields meaningful bundles
|
|
9
|
+
* with no curation. A manifest may still declare its own.
|
|
10
|
+
*/
|
|
11
|
+
export const bundleSchema = z.object({
|
|
12
|
+
name: identifier,
|
|
13
|
+
description: z.string().default(''),
|
|
14
|
+
oauth_scopes: z.array(z.string()).default([]),
|
|
15
|
+
/** Glob patterns over capability names. Empty means "everything not matched by another bundle". */
|
|
16
|
+
capabilities: z.array(z.string()).default([]),
|
|
17
|
+
default: z.boolean().default(false),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const READ_BUNDLE = 'read';
|
|
21
|
+
export const WRITE_BUNDLE = 'write';
|
|
22
|
+
|
|
23
|
+
export type ScopeBundle = z.infer<typeof bundleSchema>;
|