@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,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertKeyLength,
|
|
3
|
+
blobDocumentIO,
|
|
4
|
+
envOnlyKeySource,
|
|
5
|
+
fileDocumentIO,
|
|
6
|
+
fileKeySource,
|
|
7
|
+
generateKey,
|
|
8
|
+
open,
|
|
9
|
+
seal,
|
|
10
|
+
type DocumentIO,
|
|
11
|
+
type KeySource,
|
|
12
|
+
} from './document.ts';
|
|
13
|
+
import { assertValidSecretRef, type SecretRef, type SecretStore } from './index.ts';
|
|
14
|
+
import type { BlobStore } from '#stores/blobs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The **system** store — what the system holds in order to do its job.
|
|
18
|
+
*
|
|
19
|
+
* OAuth refresh tokens, OAuth client secrets, the profile API token. These
|
|
20
|
+
* authorise the system itself, and they are NEVER reachable from MCP, in any
|
|
21
|
+
* form, for any client. If an agent could read the Gmail refresh token it would
|
|
22
|
+
* simply call Google directly, and the entire policy layer would become
|
|
23
|
+
* decorative.
|
|
24
|
+
*
|
|
25
|
+
* Its sibling is `./vault.ts` — the owner's own passwords and API keys, which an
|
|
26
|
+
* agent may legitimately be granted access to under policy. They share this
|
|
27
|
+
* component, the document format, and the adapters. They share no key, no
|
|
28
|
+
* document, and no path from a `ProviderContext` — see the two tests named in
|
|
29
|
+
* `./index.ts`.
|
|
30
|
+
*
|
|
31
|
+
* WHAT THIS DOES NOT DO, stated because the security model says so rather than
|
|
32
|
+
* implying a guarantee the code does not deliver: encryption at rest protects
|
|
33
|
+
* stored credentials from someone reading the file. It does not protect a
|
|
34
|
+
* credential while a process is using it — the plaintext is in memory whenever
|
|
35
|
+
* a provider makes a call. That limit is inherent.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const MAGIC = 'lanes-link-credentials';
|
|
39
|
+
const KEY_ENV = 'LANES_LINK_CREDENTIAL_KEY';
|
|
40
|
+
const DEFAULT_BLOB_KEY = 'credentials.enc';
|
|
41
|
+
|
|
42
|
+
export interface FileSystemSecretsOptions {
|
|
43
|
+
/** Path to the encrypted file, e.g. `./data/personal/credentials.enc`. */
|
|
44
|
+
readonly path: string;
|
|
45
|
+
/**
|
|
46
|
+
* 32-byte key. When omitted, read from `LANES_LINK_CREDENTIAL_KEY` (base64),
|
|
47
|
+
* else from a sibling `.key` file, which is created on first use at 0600.
|
|
48
|
+
*/
|
|
49
|
+
readonly key?: Uint8Array;
|
|
50
|
+
readonly env?: Record<string, string | undefined>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createFileSecretStore(options: FileSystemSecretsOptions): SecretStore {
|
|
54
|
+
const keyPath = `${options.path}.key`;
|
|
55
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
56
|
+
|
|
57
|
+
return new DocumentSecretStore(
|
|
58
|
+
fileDocumentIO(options.path, keyPath),
|
|
59
|
+
fileKeySource({ keyPath, envVar: KEY_ENV, env, explicit: options.key }),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BlobSecretsOptions {
|
|
64
|
+
/** Where the document lives. Scope it before passing it in. */
|
|
65
|
+
readonly store: BlobStore;
|
|
66
|
+
readonly key?: string;
|
|
67
|
+
readonly encryptionKey?: Uint8Array;
|
|
68
|
+
readonly env?: Record<string, string | undefined>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createBlobSecretStore(options: BlobSecretsOptions): SecretStore {
|
|
72
|
+
const key = options.key ?? DEFAULT_BLOB_KEY;
|
|
73
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
74
|
+
|
|
75
|
+
return new DocumentSecretStore(
|
|
76
|
+
blobDocumentIO(options.store, key),
|
|
77
|
+
envOnlyKeySource({
|
|
78
|
+
envVar: KEY_ENV,
|
|
79
|
+
env,
|
|
80
|
+
explicit: options.encryptionKey,
|
|
81
|
+
label: key,
|
|
82
|
+
remedy: 'openssl rand -base64 32',
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A `SecretStore` over one encrypted document.
|
|
89
|
+
*
|
|
90
|
+
* The whole document is rewritten on every change, which is right for something
|
|
91
|
+
* holding tens of entries and would not be for something holding millions.
|
|
92
|
+
* Per-entry encryption with plaintext keys would be marginally more convenient
|
|
93
|
+
* and would leak which accounts exist.
|
|
94
|
+
*/
|
|
95
|
+
class DocumentSecretStore implements SecretStore {
|
|
96
|
+
readonly #io: DocumentIO;
|
|
97
|
+
readonly #keySource: KeySource;
|
|
98
|
+
#key: Uint8Array | undefined;
|
|
99
|
+
#cache: Map<string, string> | undefined;
|
|
100
|
+
|
|
101
|
+
constructor(io: DocumentIO, keySource: KeySource) {
|
|
102
|
+
this.#io = io;
|
|
103
|
+
this.#keySource = keySource;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async get(ref: SecretRef): Promise<string | null> {
|
|
107
|
+
assertValidSecretRef(ref);
|
|
108
|
+
return (await this.#load()).get(ref) ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async has(ref: SecretRef): Promise<boolean> {
|
|
112
|
+
assertValidSecretRef(ref);
|
|
113
|
+
return (await this.#load()).has(ref);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async set(ref: SecretRef, value: string): Promise<void> {
|
|
117
|
+
assertValidSecretRef(ref);
|
|
118
|
+
const entries = await this.#load();
|
|
119
|
+
entries.set(ref, value);
|
|
120
|
+
await this.#save(entries);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async delete(ref: SecretRef): Promise<void> {
|
|
124
|
+
assertValidSecretRef(ref);
|
|
125
|
+
const entries = await this.#load();
|
|
126
|
+
if (entries.delete(ref)) await this.#save(entries);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async list(prefix?: string): Promise<SecretRef[]> {
|
|
130
|
+
const entries = await this.#load();
|
|
131
|
+
return [...entries.keys()].filter((ref) => !prefix || ref.startsWith(prefix)).sort();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Drop the decrypted copy from memory.
|
|
136
|
+
*
|
|
137
|
+
* Named for the interface it satisfies rather than for the hygiene it also
|
|
138
|
+
* provides. It was `forget()` and nothing ever called it, which is precisely
|
|
139
|
+
* how a rotated token stayed invisible to a running endpoint.
|
|
140
|
+
*/
|
|
141
|
+
refresh(): void {
|
|
142
|
+
this.#cache = undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async #resolveKey(): Promise<Uint8Array> {
|
|
146
|
+
return (this.#key ??= await this.#keySource());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async #load(): Promise<Map<string, string>> {
|
|
150
|
+
if (this.#cache) return this.#cache;
|
|
151
|
+
|
|
152
|
+
const text = await this.#io.read();
|
|
153
|
+
if (text === null) return (this.#cache = new Map());
|
|
154
|
+
|
|
155
|
+
const entries = open<Record<string, string>>(
|
|
156
|
+
MAGIC,
|
|
157
|
+
await this.#resolveKey(),
|
|
158
|
+
text,
|
|
159
|
+
this.#io,
|
|
160
|
+
KEY_ENV,
|
|
161
|
+
);
|
|
162
|
+
return (this.#cache = new Map(Object.entries(entries)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #save(entries: Map<string, string>): Promise<void> {
|
|
166
|
+
await this.#io.write(seal(MAGIC, await this.#resolveKey(), Object.fromEntries(entries)));
|
|
167
|
+
this.#cache = entries;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { assertKeyLength };
|
|
172
|
+
/** A fresh credential-store key, base64, printed once and stored by the operator. */
|
|
173
|
+
export const generateCredentialKey = generateKey;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import {
|
|
2
|
+
blobDocumentIO,
|
|
3
|
+
envOnlyKeySource,
|
|
4
|
+
fileDocumentIO,
|
|
5
|
+
fileKeySource,
|
|
6
|
+
secretDocumentIO,
|
|
7
|
+
generateKey,
|
|
8
|
+
open,
|
|
9
|
+
seal,
|
|
10
|
+
type DocumentIO,
|
|
11
|
+
type KeySource,
|
|
12
|
+
} from './document.ts';
|
|
13
|
+
import type { BlobStore } from '#stores/blobs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The **vault** store — the owner's own passwords and API keys.
|
|
17
|
+
*
|
|
18
|
+
* Its sibling is `./system.ts`, and the difference between them is the whole
|
|
19
|
+
* point of both: system credentials authorise Lanes Link itself and are
|
|
20
|
+
* unreachable from MCP; vault items belong to the owner and an agent may be
|
|
21
|
+
* granted one, by name, under policy.
|
|
22
|
+
*
|
|
23
|
+
* They share this component, the document format in `./document.ts`, and the
|
|
24
|
+
* adapters. They share **no key, no document, and no environment variable** —
|
|
25
|
+
* `LANES_LINK_VAULT_KEY` against `LANES_LINK_CREDENTIAL_KEY`. One master secret
|
|
26
|
+
* reused across purposes turns any single compromise into a total one, which is
|
|
27
|
+
* why the merge stopped where it did: two implementations of one format was
|
|
28
|
+
* duplication worth removing, two keys is the security model.
|
|
29
|
+
*
|
|
30
|
+
* The shapes differ too, and honestly so. A system credential is a ref and a
|
|
31
|
+
* string. A vault item carries a description and belongs to a connection,
|
|
32
|
+
* because each one becomes its own `vault.get.<id>` capability — which is what
|
|
33
|
+
* makes per-item policy expressible without teaching the policy engine about
|
|
34
|
+
* arguments (ADR-012 §3).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const MAGIC = 'lanes-link-vault';
|
|
38
|
+
const DEFAULT_BLOB_KEY = 'vault.enc';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The environment variable the key arrives in, and where the sealed document
|
|
42
|
+
* sits when the target keeps it in its credential store.
|
|
43
|
+
*
|
|
44
|
+
* Exported because a deploy has to wire both: it mints the key into the store
|
|
45
|
+
* and tells the platform to mount it under this name. Spelling either of them a
|
|
46
|
+
* second time in `deployments/` is how they drift, and the failure that causes
|
|
47
|
+
* is a revision whose vault reads fail with the key sitting right there.
|
|
48
|
+
*/
|
|
49
|
+
export const VAULT_KEY_ENV = 'LANES_LINK_VAULT_KEY';
|
|
50
|
+
export const DEFAULT_SECRET_REF = 'vault/document';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where the key itself lives, for a target whose credential store can hold it.
|
|
54
|
+
*
|
|
55
|
+
* Beside the document rather than somewhere else, which reads wrong and is not:
|
|
56
|
+
* ADR-022 put the ciphertext in Secret Manager *because* the key already came
|
|
57
|
+
* from there, and a key the deploy cannot reach is one an operator has to carry
|
|
58
|
+
* by hand forever. What keeps them apart is that this one is mounted as an
|
|
59
|
+
* environment variable and the document is not — an attacker holding the
|
|
60
|
+
* document alone still holds ciphertext.
|
|
61
|
+
*/
|
|
62
|
+
export const VAULT_KEY_REF = 'vault/key';
|
|
63
|
+
|
|
64
|
+
const KEY_ENV = VAULT_KEY_ENV;
|
|
65
|
+
|
|
66
|
+
/** Item ids become part of a capability name, so they are held to its grammar. */
|
|
67
|
+
export const VAULT_ITEM_ID = /^[a-z0-9][a-z0-9_]*$/;
|
|
68
|
+
|
|
69
|
+
export interface VaultItem {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly value: string;
|
|
72
|
+
readonly description?: string;
|
|
73
|
+
readonly updatedAt: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Storage for vault items, scoped by connection.
|
|
78
|
+
*
|
|
79
|
+
* `ids` returns names only — there is no operation that enumerates values,
|
|
80
|
+
* which is the one property this and the system store genuinely share.
|
|
81
|
+
*/
|
|
82
|
+
export interface VaultStore {
|
|
83
|
+
get(connectionId: string, id: string): Promise<VaultItem | null>;
|
|
84
|
+
put(connectionId: string, item: Omit<VaultItem, 'updatedAt'>): Promise<void>;
|
|
85
|
+
delete(connectionId: string, id: string): Promise<boolean>;
|
|
86
|
+
/** Every `<connection>/<item>` pair held, so the runtime can register reads. */
|
|
87
|
+
ids(): Promise<ReadonlyArray<{ connectionId: string; id: string; description?: string }>>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type Entries = Record<string, { value: string; description?: string; updatedAt: string }>;
|
|
91
|
+
|
|
92
|
+
export interface FileVaultStoreOptions {
|
|
93
|
+
/** Path to the encrypted file, e.g. `./data/personal/vault.enc`. */
|
|
94
|
+
readonly path: string;
|
|
95
|
+
/**
|
|
96
|
+
* 32-byte key. When omitted, read from `LANES_LINK_VAULT_KEY` (base64), else
|
|
97
|
+
* from a sibling `.key` file created on first use at 0600.
|
|
98
|
+
*/
|
|
99
|
+
readonly key?: Uint8Array;
|
|
100
|
+
readonly env?: Record<string, string | undefined>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createFileVaultStore(options: FileVaultStoreOptions): VaultStore {
|
|
104
|
+
const keyPath = `${options.path}.key`;
|
|
105
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
106
|
+
|
|
107
|
+
return new DocumentVaultStore(
|
|
108
|
+
fileDocumentIO(options.path, keyPath),
|
|
109
|
+
fileKeySource({ keyPath, envVar: KEY_ENV, env, explicit: options.key }),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface BlobVaultStoreOptions {
|
|
114
|
+
/** Where the document lives. Scope it before passing it in. */
|
|
115
|
+
readonly store: BlobStore;
|
|
116
|
+
/** Object key. One document, so one key. */
|
|
117
|
+
readonly key?: string;
|
|
118
|
+
/** 32-byte key. When omitted, `LANES_LINK_VAULT_KEY` — and nothing else. */
|
|
119
|
+
readonly encryptionKey?: Uint8Array;
|
|
120
|
+
readonly env?: Record<string, string | undefined>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The vault in blob storage — what a deployed instance uses.
|
|
125
|
+
*
|
|
126
|
+
* The file adapter was unconditional before ADR-014: a deployed instance wrote
|
|
127
|
+
* its vault to a container filesystem, and every item in it was discarded by the
|
|
128
|
+
* next revision without an error to say so.
|
|
129
|
+
*/
|
|
130
|
+
export function createBlobVaultStore(options: BlobVaultStoreOptions): VaultStore {
|
|
131
|
+
const key = options.key ?? DEFAULT_BLOB_KEY;
|
|
132
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
133
|
+
|
|
134
|
+
return new DocumentVaultStore(
|
|
135
|
+
blobDocumentIO(options.store, key),
|
|
136
|
+
envOnlyKeySource({
|
|
137
|
+
envVar: KEY_ENV,
|
|
138
|
+
env,
|
|
139
|
+
explicit: options.encryptionKey,
|
|
140
|
+
label: key,
|
|
141
|
+
remedy: 'lanes link vault key generate',
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface SecretVaultStoreOptions {
|
|
147
|
+
/** The target's own secret store. */
|
|
148
|
+
readonly store: {
|
|
149
|
+
get(ref: string): Promise<string | null>;
|
|
150
|
+
set(ref: string, value: string): Promise<void>;
|
|
151
|
+
};
|
|
152
|
+
/** Where the document lives. One document, so one ref. */
|
|
153
|
+
readonly ref?: string;
|
|
154
|
+
/** 32-byte key. When omitted, `LANES_LINK_VAULT_KEY` — and nothing else. */
|
|
155
|
+
readonly encryptionKey?: Uint8Array;
|
|
156
|
+
readonly env?: Record<string, string | undefined>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The vault as a single entry in the secret store — what a deployment uses.
|
|
161
|
+
*
|
|
162
|
+
* This looks like the merge the file above spends three paragraphs forbidding,
|
|
163
|
+
* and it is not. What must never be shared is the **key**, and it is not: the
|
|
164
|
+
* document is sealed under `LANES_LINK_VAULT_KEY` before it gets here, so the
|
|
165
|
+
* secret store holds ciphertext it cannot read. Separate document, separate
|
|
166
|
+
* key, separate environment variable — the three things `./index.ts` names.
|
|
167
|
+
* The backend was never on that list, and the two stores have shared adapters
|
|
168
|
+
* since they were merged.
|
|
169
|
+
*
|
|
170
|
+
* **Why the bucket arrangement it replaces was backwards.** `blob` put the
|
|
171
|
+
* vault in object storage so reading it took two things: bucket access *and*
|
|
172
|
+
* the key. But the key already arrives from Secret Manager via `--set-secrets`,
|
|
173
|
+
* and anyone with Secret Manager read access already holds every OAuth refresh
|
|
174
|
+
* token — the whole of Gmail, Drive, and iCloud. The vault is the smaller
|
|
175
|
+
* asset and it was the one behind the taller fence. See ADR-022.
|
|
176
|
+
*
|
|
177
|
+
* Item *names* stay encrypted because they are inside the document, which is
|
|
178
|
+
* why this is one secret rather than one per item.
|
|
179
|
+
*
|
|
180
|
+
* The cost, stated: a secret store that versions writes gains a version per
|
|
181
|
+
* `vault put`. That is a few cents a month on Secret Manager and `vault put` is
|
|
182
|
+
* a rare human action, so nothing prunes them — a prune would need
|
|
183
|
+
* `secretmanager.versions.destroy`, which is deliberately not granted to the
|
|
184
|
+
* identity a deployment runs as.
|
|
185
|
+
*/
|
|
186
|
+
export function createSecretVaultStore(options: SecretVaultStoreOptions): VaultStore {
|
|
187
|
+
const ref = options.ref ?? DEFAULT_SECRET_REF;
|
|
188
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
189
|
+
|
|
190
|
+
return new DocumentVaultStore(
|
|
191
|
+
secretDocumentIO(options.store, ref),
|
|
192
|
+
envOnlyKeySource({
|
|
193
|
+
envVar: KEY_ENV,
|
|
194
|
+
env,
|
|
195
|
+
explicit: options.encryptionKey,
|
|
196
|
+
label: ref,
|
|
197
|
+
remedy: 'lanes link vault key generate',
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
class DocumentVaultStore implements VaultStore {
|
|
203
|
+
readonly #io: DocumentIO;
|
|
204
|
+
readonly #keySource: KeySource;
|
|
205
|
+
#key: Uint8Array | undefined;
|
|
206
|
+
#cache: Entries | undefined;
|
|
207
|
+
|
|
208
|
+
constructor(io: DocumentIO, keySource: KeySource) {
|
|
209
|
+
this.#io = io;
|
|
210
|
+
this.#keySource = keySource;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async get(connectionId: string, id: string): Promise<VaultItem | null> {
|
|
214
|
+
const entry = (await this.#load())[keyFor(connectionId, id)];
|
|
215
|
+
if (!entry) return null;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
value: entry.value,
|
|
220
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
221
|
+
updatedAt: entry.updatedAt,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async put(connectionId: string, item: Omit<VaultItem, 'updatedAt'>): Promise<void> {
|
|
226
|
+
assertItemId(item.id);
|
|
227
|
+
const entries = { ...(await this.#load()) };
|
|
228
|
+
|
|
229
|
+
entries[keyFor(connectionId, item.id)] = {
|
|
230
|
+
value: item.value,
|
|
231
|
+
...(item.description ? { description: item.description } : {}),
|
|
232
|
+
updatedAt: new Date().toISOString(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await this.#save(entries);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async delete(connectionId: string, id: string): Promise<boolean> {
|
|
239
|
+
const entries = { ...(await this.#load()) };
|
|
240
|
+
const key = keyFor(connectionId, id);
|
|
241
|
+
if (!(key in entries)) return false;
|
|
242
|
+
|
|
243
|
+
delete entries[key];
|
|
244
|
+
await this.#save(entries);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async ids(): Promise<ReadonlyArray<{ connectionId: string; id: string; description?: string }>> {
|
|
249
|
+
return listIds(await this.#load());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async #resolveKey(): Promise<Uint8Array> {
|
|
253
|
+
return (this.#key ??= await this.#keySource());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async #load(): Promise<Entries> {
|
|
257
|
+
if (this.#cache) return this.#cache;
|
|
258
|
+
|
|
259
|
+
const text = await this.#io.read();
|
|
260
|
+
if (text === null) return (this.#cache = {});
|
|
261
|
+
|
|
262
|
+
return (this.#cache = open<Entries>(MAGIC, await this.#resolveKey(), text, this.#io, KEY_ENV));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #save(entries: Entries): Promise<void> {
|
|
266
|
+
await this.#io.write(seal(MAGIC, await this.#resolveKey(), entries));
|
|
267
|
+
this.#cache = entries;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** An in-memory store, for tests and for a target that persists nothing. */
|
|
272
|
+
export function createMemoryVaultStore(seed: Entries = {}): VaultStore {
|
|
273
|
+
const entries: Entries = { ...seed };
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
async get(connectionId, id) {
|
|
277
|
+
const entry = entries[keyFor(connectionId, id)];
|
|
278
|
+
if (!entry) return null;
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
value: entry.value,
|
|
282
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
283
|
+
updatedAt: entry.updatedAt,
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
async put(connectionId, item) {
|
|
287
|
+
assertItemId(item.id);
|
|
288
|
+
entries[keyFor(connectionId, item.id)] = {
|
|
289
|
+
value: item.value,
|
|
290
|
+
...(item.description ? { description: item.description } : {}),
|
|
291
|
+
updatedAt: new Date().toISOString(),
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
async delete(connectionId, id) {
|
|
295
|
+
const key = keyFor(connectionId, id);
|
|
296
|
+
if (!(key in entries)) return false;
|
|
297
|
+
delete entries[key];
|
|
298
|
+
return true;
|
|
299
|
+
},
|
|
300
|
+
async ids() {
|
|
301
|
+
return listIds(entries);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function listIds(
|
|
307
|
+
entries: Entries,
|
|
308
|
+
): ReadonlyArray<{ connectionId: string; id: string; description?: string }> {
|
|
309
|
+
return Object.entries(entries)
|
|
310
|
+
.map(([key, entry]) => {
|
|
311
|
+
const slash = key.indexOf('/');
|
|
312
|
+
return {
|
|
313
|
+
connectionId: key.slice(0, slash),
|
|
314
|
+
id: key.slice(slash + 1),
|
|
315
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
316
|
+
};
|
|
317
|
+
})
|
|
318
|
+
.sort((a, b) => `${a.connectionId}/${a.id}`.localeCompare(`${b.connectionId}/${b.id}`));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function keyFor(connectionId: string, id: string): string {
|
|
322
|
+
return `${connectionId}/${id}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function assertItemId(id: string): void {
|
|
326
|
+
if (!VAULT_ITEM_ID.test(id)) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Vault item id ${JSON.stringify(id)} must be lowercase letters, digits and "_". ` +
|
|
329
|
+
'The id becomes part of the capability name — "vault.get.github_token" — which is what ' +
|
|
330
|
+
'lets policy grant one item without granting the rest (ADR-012).',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Generate a fresh vault key, distinct from the system store's. */
|
|
336
|
+
export const generateVaultKey = generateKey;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mergeCapabilities, type ProfileRuntime } from './mcp/index.ts';
|
|
2
|
+
import type { Principal } from '#auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Handing the endpoint some bytes, so a later call can name them.
|
|
6
|
+
*
|
|
7
|
+
* The one thing the protocol cannot do. A tool argument carries JSON, so a file
|
|
8
|
+
* in a tool call is base64 in the model's output — which for a 239 KB PDF is
|
|
9
|
+
* around 320,000 characters, past what a model can write in one message. There is
|
|
10
|
+
* no client-to-server binary channel in any released version of MCP, and `roots`
|
|
11
|
+
* never carried bytes even before it was deprecated. So the bytes come in over
|
|
12
|
+
* ordinary HTTP, out of band, and what travels through the model afterwards is a
|
|
13
|
+
* handle. That is also the shape the protocol's own draft file-transfer work
|
|
14
|
+
* settles on.
|
|
15
|
+
*
|
|
16
|
+
* This is what makes attachments work when the endpoint is not on the caller's
|
|
17
|
+
* machine. A `path` names the filesystem the *server* can see, which on a hosted
|
|
18
|
+
* deployment is a container and not the operator's Mac; a handle does not care
|
|
19
|
+
* where either end is.
|
|
20
|
+
*
|
|
21
|
+
* **Staged for one connection, not for the profile.** A blob store handed to a
|
|
22
|
+
* provider is already namespaced by provider and connection, and the point of
|
|
23
|
+
* that is that one account's bytes are not reachable from another. Staging into a
|
|
24
|
+
* shared area would quietly widen it. So an upload names its target, the
|
|
25
|
+
* principal has to be able to reach that target, and the handle resolves only
|
|
26
|
+
* from there.
|
|
27
|
+
*
|
|
28
|
+
* This file does HTTP and authorization; the write itself is
|
|
29
|
+
* `Dispatcher.stageAttachment`. Not a split for its own sake — `server` may not
|
|
30
|
+
* reach a store, and should not: how blobs are namespaced is not something a
|
|
31
|
+
* component that speaks HTTP should know, and putting the write behind the
|
|
32
|
+
* dispatcher is also what gets it into the audit log.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const ATTACHMENTS_PATH = '/attachments';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Largest upload accepted.
|
|
39
|
+
*
|
|
40
|
+
* Above every mail host's own ceiling — 35 MiB is the largest here — so this is
|
|
41
|
+
* never the binding limit on a legitimate send, and well under Bun's 128 MiB
|
|
42
|
+
* default body cap so a refusal is ours and legible rather than the runtime's.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
export interface StageOptions {
|
|
47
|
+
readonly profiles: ReadonlyMap<string, ProfileRuntime>;
|
|
48
|
+
readonly primary: string;
|
|
49
|
+
readonly principal: Principal;
|
|
50
|
+
readonly request: Request;
|
|
51
|
+
readonly clientLabel?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function stageAttachment(options: StageOptions): Promise<Response> {
|
|
55
|
+
const { request } = options;
|
|
56
|
+
|
|
57
|
+
if (request.method !== 'POST') {
|
|
58
|
+
return problem(405, 'Stage an attachment with POST.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const url = new URL(request.url);
|
|
62
|
+
const profileName = url.searchParams.get('profile') ?? options.primary;
|
|
63
|
+
const target = url.searchParams.get('connection');
|
|
64
|
+
|
|
65
|
+
if (!target) {
|
|
66
|
+
return problem(
|
|
67
|
+
400,
|
|
68
|
+
'Name the connection this is for, as ?connection=<provider>.<account> — a staged file belongs to one account, not to the endpoint.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const runtime = options.profiles.get(profileName);
|
|
73
|
+
if (!runtime) {
|
|
74
|
+
return problem(404, `No profile "${profileName}" on this endpoint.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const [providerId, ...rest] = target.split('.');
|
|
78
|
+
const connectionId = rest.join('.');
|
|
79
|
+
if (!providerId || !connectionId) {
|
|
80
|
+
return problem(400, `"${target}" is not a connection. Use <provider>.<account>.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The same policy that decides which connections a tool may name. Staging into
|
|
84
|
+
// an account the caller cannot act on would be a write they are not permitted
|
|
85
|
+
// to make, even though nothing is sent by it.
|
|
86
|
+
if (!reachable(runtime, options, profileName, target)) {
|
|
87
|
+
return problem(
|
|
88
|
+
403,
|
|
89
|
+
`Connection "${target}" is not reachable for this token in profile "${profileName}".`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const bytes = await readCapped(request);
|
|
94
|
+
if (!bytes) {
|
|
95
|
+
return problem(413, `An attachment must be smaller than ${MAX_UPLOAD_BYTES} bytes.`);
|
|
96
|
+
}
|
|
97
|
+
if (bytes.byteLength === 0) {
|
|
98
|
+
return problem(400, 'The request body was empty, so there is nothing to stage.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const filename = filenameFrom(request.headers.get('x-filename'));
|
|
102
|
+
const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
|
|
103
|
+
|
|
104
|
+
const staged = await runtime.dispatcher.stageAttachment({
|
|
105
|
+
principal: options.principal,
|
|
106
|
+
providerId,
|
|
107
|
+
connectionId,
|
|
108
|
+
bytes,
|
|
109
|
+
filename,
|
|
110
|
+
contentType,
|
|
111
|
+
clientLabel: options.clientLabel,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return Response.json({
|
|
115
|
+
handle: staged.handle,
|
|
116
|
+
filename,
|
|
117
|
+
bytes: bytes.byteLength,
|
|
118
|
+
content_type: contentType,
|
|
119
|
+
sha256: staged.sha256,
|
|
120
|
+
expires_at: new Date(staged.expiresAt).toISOString(),
|
|
121
|
+
connection: target,
|
|
122
|
+
profile: profileName,
|
|
123
|
+
hint: `Pass it as an attachment: { "handle": "${staged.handle}" }`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether this principal can reach that connection for anything at all. */
|
|
128
|
+
function reachable(
|
|
129
|
+
runtime: ProfileRuntime,
|
|
130
|
+
options: StageOptions,
|
|
131
|
+
profileName: string,
|
|
132
|
+
target: string,
|
|
133
|
+
): boolean {
|
|
134
|
+
const merged = mergeCapabilities({
|
|
135
|
+
profiles: new Map([[profileName, runtime]]),
|
|
136
|
+
principal: options.principal,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (const capability of merged.values()) {
|
|
140
|
+
for (const connections of capability.reachable.values()) {
|
|
141
|
+
if (connections.includes(target)) return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read the body, refusing past the cap.
|
|
150
|
+
*
|
|
151
|
+
* Streamed rather than buffered-then-measured, so an oversized upload costs the
|
|
152
|
+
* bytes it takes to notice rather than all of them.
|
|
153
|
+
*/
|
|
154
|
+
async function readCapped(request: Request): Promise<Uint8Array | null> {
|
|
155
|
+
const declared = Number(request.headers.get('content-length') ?? Number.NaN);
|
|
156
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) return null;
|
|
157
|
+
|
|
158
|
+
if (!request.body) return new Uint8Array();
|
|
159
|
+
|
|
160
|
+
const reader = request.body.getReader();
|
|
161
|
+
const chunks: Uint8Array[] = [];
|
|
162
|
+
let total = 0;
|
|
163
|
+
|
|
164
|
+
for (;;) {
|
|
165
|
+
const { done, value } = await reader.read();
|
|
166
|
+
if (done) break;
|
|
167
|
+
total += value.byteLength;
|
|
168
|
+
if (total > MAX_UPLOAD_BYTES) {
|
|
169
|
+
await reader.cancel().catch(() => {});
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
chunks.push(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const bytes = new Uint8Array(total);
|
|
176
|
+
let offset = 0;
|
|
177
|
+
for (const chunk of chunks) {
|
|
178
|
+
bytes.set(chunk, offset);
|
|
179
|
+
offset += chunk.byteLength;
|
|
180
|
+
}
|
|
181
|
+
return bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A filename safe to record and to put in a MIME header.
|
|
186
|
+
*
|
|
187
|
+
* Basename only, and no traversal: the value is echoed into a
|
|
188
|
+
* `Content-Disposition` and stored beside the bytes, and neither wants a path.
|
|
189
|
+
*/
|
|
190
|
+
function filenameFrom(header: string | null): string {
|
|
191
|
+
const candidate = (header ?? '').split(/[/\\]/).pop()?.trim() ?? '';
|
|
192
|
+
return candidate === '' || candidate === '.' || candidate === '..' ? 'attachment' : candidate;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function problem(status: number, message: string): Response {
|
|
196
|
+
return Response.json({ error: message }, { status });
|
|
197
|
+
}
|