@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,195 @@
|
|
|
1
|
+
import type { AuditLogger } from '#audit';
|
|
2
|
+
import type { SecretRef, SecretStore } from '#secrets';
|
|
3
|
+
import { scopeSecrets } from '#secrets';
|
|
4
|
+
import type { RuntimeState } from '#stores/state';
|
|
5
|
+
import type { BlobStore } from '#stores/blobs';
|
|
6
|
+
import { scopeBlobStore } from '#stores/blobs';
|
|
7
|
+
import type {
|
|
8
|
+
ConnectionInfo,
|
|
9
|
+
Logger,
|
|
10
|
+
ProviderContext,
|
|
11
|
+
ProviderDefinition,
|
|
12
|
+
ProviderManifest,
|
|
13
|
+
ScopedStore,
|
|
14
|
+
} from '#connectivity';
|
|
15
|
+
import { credentialRefForConnection } from '#connectivity';
|
|
16
|
+
import type { ConnectionConfig } from '#profile';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Assembling what a provider is handed.
|
|
20
|
+
*
|
|
21
|
+
* Providers receive scoped capabilities, never raw backends. Every boundary
|
|
22
|
+
* here is enforced by construction rather than by asking providers to behave:
|
|
23
|
+
* the state namespace is applied before the provider sees the store, the blob
|
|
24
|
+
* namespace likewise, and credentials are restricted to an explicit allowlist
|
|
25
|
+
* computed from what the connection declares.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** `<provider>/<connection>` — the namespace both state and blobs are scoped to. */
|
|
29
|
+
export function scopeNamespace(provider: string, connectionId: string): string {
|
|
30
|
+
return `${provider}/${connectionId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createScopedStore(state: RuntimeState, namespace: string): ScopedStore {
|
|
34
|
+
return {
|
|
35
|
+
get: (key) => state.kv.get(namespace, key),
|
|
36
|
+
set: (key, value) => state.kv.set(namespace, key, value),
|
|
37
|
+
delete: (key) => state.kv.delete(namespace, key),
|
|
38
|
+
keys: () => state.kv.keys(namespace),
|
|
39
|
+
|
|
40
|
+
async getJson<T>(key: string): Promise<T | null> {
|
|
41
|
+
const raw = await state.kv.get(namespace, key);
|
|
42
|
+
if (raw === null) return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(raw) as T;
|
|
45
|
+
} catch {
|
|
46
|
+
// Stored state that will not parse is a provider bug, not a reason to
|
|
47
|
+
// fail the request; treat it as absent and let the provider rewrite it.
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
setJson: (key, value) => state.kv.set(namespace, key, JSON.stringify(value)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which credential refs this connection may read.
|
|
58
|
+
*
|
|
59
|
+
* A provider declares them via `credentialRefs`; if it declares nothing, the
|
|
60
|
+
* connection's own `credential_ref` is the entire allowlist. Either way the set
|
|
61
|
+
* is computed here rather than asked for at read time, so a provider cannot
|
|
62
|
+
* widen it by asking for more.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveSecretRefs(
|
|
65
|
+
manifest: ProviderManifest,
|
|
66
|
+
connection: ConnectionConfig,
|
|
67
|
+
definition?: ProviderDefinition,
|
|
68
|
+
/**
|
|
69
|
+
* Which `app` entries this profile declares a client of its own for.
|
|
70
|
+
*
|
|
71
|
+
* Only consulted for a provider that could also authorise against a broker.
|
|
72
|
+
* Defaults to none, which is correct for every provider that declares no
|
|
73
|
+
* broker: the gate below leaves those exactly as they were.
|
|
74
|
+
*/
|
|
75
|
+
ownClients: readonly string[] = [],
|
|
76
|
+
): SecretRef[] {
|
|
77
|
+
const refs = new Set<SecretRef>(
|
|
78
|
+
definition?.credentialRefs?.(connection.id, connection.config ?? {}) ?? [],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (connection.credential_ref) refs.add(connection.credential_ref);
|
|
82
|
+
|
|
83
|
+
const auth = manifest.auth;
|
|
84
|
+
|
|
85
|
+
if (auth.kind === 'oauth') {
|
|
86
|
+
// The tokens for this connection, and — for a manual registration — the
|
|
87
|
+
// shared app credentials every connection of that vendor authorises
|
|
88
|
+
// against. Nothing wider: `gmail.main` must not reach `gmail/side`.
|
|
89
|
+
refs.add(`${manifest.id}/${connection.id}`);
|
|
90
|
+
if (auth.registration === 'manual' && auth.app) {
|
|
91
|
+
// Only where this profile holds a client of its own. When the client is
|
|
92
|
+
// the broker's there is no id, secret, or registration anywhere in this
|
|
93
|
+
// store, so naming the three refs would grant reach to nothing while
|
|
94
|
+
// describing an arrangement that does not exist — and a grant list is
|
|
95
|
+
// read by people deciding what a provider can see.
|
|
96
|
+
if (!auth.broker || ownClients.includes(auth.app)) {
|
|
97
|
+
refs.add(`${auth.app}/client_id`);
|
|
98
|
+
refs.add(`${auth.app}/client_secret`);
|
|
99
|
+
refs.add(`${auth.app}/client`);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
refs.add(`${manifest.id}/client`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (auth.kind !== 'none' && auth.kind !== 'oauth') {
|
|
107
|
+
// The connection's own credential, wherever the manifest puts it — which
|
|
108
|
+
// may be a ref shared with sibling providers of the same vendor
|
|
109
|
+
// (`icloud/ada` serves mail, calendar, and contacts). Shared across
|
|
110
|
+
// *providers* is not shared across *accounts*: this adds exactly one ref, so
|
|
111
|
+
// `icloud_mail.will` still cannot reach `icloud/sam`.
|
|
112
|
+
const ref = credentialRefForConnection(manifest, connection.id);
|
|
113
|
+
if (ref) refs.add(ref);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return [...refs];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface BuildContextOptions {
|
|
120
|
+
readonly manifest: ProviderManifest;
|
|
121
|
+
/** Present only for `local` providers. */
|
|
122
|
+
readonly definition?: ProviderDefinition | undefined;
|
|
123
|
+
readonly connection: ConnectionConfig;
|
|
124
|
+
readonly state: RuntimeState;
|
|
125
|
+
readonly credentials: SecretStore;
|
|
126
|
+
readonly storage: BlobStore;
|
|
127
|
+
readonly audit: AuditLogger;
|
|
128
|
+
readonly log: Logger;
|
|
129
|
+
readonly signal: AbortSignal;
|
|
130
|
+
/** The same closure the connector context gets. Absent for `local` providers. */
|
|
131
|
+
readonly authorize?: ((request: Request) => Promise<Request>) | undefined;
|
|
132
|
+
/** `oauth_apps` entries this profile declares. See `resolveSecretRefs`. */
|
|
133
|
+
readonly ownClients?: readonly string[] | undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildProviderContext(options: BuildContextOptions): ProviderContext {
|
|
137
|
+
const { manifest, definition, connection } = options;
|
|
138
|
+
const namespace = scopeNamespace(manifest.id, connection.id);
|
|
139
|
+
|
|
140
|
+
const info: ConnectionInfo = {
|
|
141
|
+
id: connection.id,
|
|
142
|
+
key: `${manifest.id}.${connection.id}`,
|
|
143
|
+
displayName: connection.account,
|
|
144
|
+
config: connection.config ?? {},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
connection: info,
|
|
149
|
+
state: createScopedStore(options.state, namespace),
|
|
150
|
+
storage: scopeBlobStore(options.storage, namespace),
|
|
151
|
+
credentials: scopeSecrets(
|
|
152
|
+
options.credentials,
|
|
153
|
+
resolveSecretRefs(manifest, connection, definition, options.ownClients ?? []),
|
|
154
|
+
),
|
|
155
|
+
audit: options.audit,
|
|
156
|
+
log: options.log,
|
|
157
|
+
signal: options.signal,
|
|
158
|
+
...(options.authorize ? { authorize: options.authorize } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A logger that prefixes provider and connection, so lines are attributable. */
|
|
163
|
+
export function createProviderLogger(base: Logger, provider: string, connection: string): Logger {
|
|
164
|
+
const prefix = `[${provider}.${connection}]`;
|
|
165
|
+
return {
|
|
166
|
+
debug: (message, detail) => base.debug(`${prefix} ${message}`, detail),
|
|
167
|
+
info: (message, detail) => base.info(`${prefix} ${message}`, detail),
|
|
168
|
+
warn: (message, detail) => base.warn(`${prefix} ${message}`, detail),
|
|
169
|
+
error: (message, detail) => base.error(`${prefix} ${message}`, detail),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createConsoleLogger(level: 'debug' | 'info' | 'warn' | 'error' = 'info'): Logger {
|
|
174
|
+
const order = { debug: 0, info: 1, warn: 2, error: 3 } as const;
|
|
175
|
+
const threshold = order[level];
|
|
176
|
+
|
|
177
|
+
const emit = (
|
|
178
|
+
kind: keyof typeof order,
|
|
179
|
+
message: string,
|
|
180
|
+
detail?: Record<string, unknown>,
|
|
181
|
+
): void => {
|
|
182
|
+
if (order[kind] < threshold) return;
|
|
183
|
+
const suffix = detail && Object.keys(detail).length > 0 ? ` ${JSON.stringify(detail)}` : '';
|
|
184
|
+
// stderr throughout: stdout belongs to the CLI's own output, and a log line
|
|
185
|
+
// interleaved into a printed token or config diff is worse than useless.
|
|
186
|
+
process.stderr.write(`${kind.padEnd(5)} ${message}${suffix}\n`);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
debug: (message, detail) => emit('debug', message, detail),
|
|
191
|
+
info: (message, detail) => emit('info', message, detail),
|
|
192
|
+
warn: (message, detail) => emit('warn', message, detail),
|
|
193
|
+
error: (message, detail) => emit('error', message, detail),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { AuditDraft, AuditLogger, AuditSink, AuthorizationResult } from '#audit';
|
|
2
|
+
import { keepKeys, redactAllValues } from '#audit';
|
|
3
|
+
import type { Principal } from '#auth';
|
|
4
|
+
import type { SecretStore } from '#secrets';
|
|
5
|
+
import type { RuntimeState } from '#stores/state';
|
|
6
|
+
import type { PolicyDocument } from '#policy';
|
|
7
|
+
import { RateLimiter, evaluate } from '#policy';
|
|
8
|
+
import type { BlobStore } from '#stores/blobs';
|
|
9
|
+
import type { AnyConnector, CapabilityResult, ConnectorContext, Logger } from '#connectivity';
|
|
10
|
+
import { isToolResult } from '#connectivity';
|
|
11
|
+
import type { Config } from '#profile';
|
|
12
|
+
import { buildProviderContext, createProviderLogger } from './context.ts';
|
|
13
|
+
import { stageAttachment, type StagedAttachment, type StageRequest } from './staging.ts';
|
|
14
|
+
import type { ProviderRegistry } from '#registry';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The dispatch path.
|
|
18
|
+
*
|
|
19
|
+
* Every invocation goes through here in one fixed order — authenticate,
|
|
20
|
+
* resolve, authorize, rate-limit, dispatch, audit — with no way around it.
|
|
21
|
+
* The ordering is not stylistic: policy is evaluated before a provider is
|
|
22
|
+
* reached, so a provider never sees a request it was not authorised to serve,
|
|
23
|
+
* and authorization is never something provider code could get wrong.
|
|
24
|
+
*
|
|
25
|
+
* Exactly one audit event is written per invocation, on every path including
|
|
26
|
+
* denials. That is enforced structurally by `finally` rather than by
|
|
27
|
+
* remembering to call it at each return.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface DispatchDeps {
|
|
31
|
+
readonly config: Config;
|
|
32
|
+
readonly registry: ProviderRegistry;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the connector for a provider. Supplied by the caller so core does
|
|
35
|
+
* not import connector implementations, keeping the dependency direction
|
|
36
|
+
* intact: infrastructure -> sdk -> core -> connectors.
|
|
37
|
+
*/
|
|
38
|
+
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
39
|
+
/** Attach whatever the manifest's auth kind requires to an outbound request. */
|
|
40
|
+
readonly authorizeRequest?: (
|
|
41
|
+
providerId: string,
|
|
42
|
+
connectionId: string,
|
|
43
|
+
request: Request,
|
|
44
|
+
) => Promise<Request>;
|
|
45
|
+
readonly policy: PolicyDocument;
|
|
46
|
+
/** Optional instance floor. Empty in M1; composition is tighten-only. */
|
|
47
|
+
readonly floor?: PolicyDocument;
|
|
48
|
+
readonly state: RuntimeState;
|
|
49
|
+
/**
|
|
50
|
+
* Where events go. Separate from `state` because the log is no longer in
|
|
51
|
+
* it — and because dispatch only ever writes, so taking the sink rather than
|
|
52
|
+
* the whole store means this path structurally cannot read the log back.
|
|
53
|
+
*/
|
|
54
|
+
readonly audit: AuditSink;
|
|
55
|
+
readonly credentials: SecretStore;
|
|
56
|
+
readonly storage: BlobStore;
|
|
57
|
+
readonly limiter: RateLimiter;
|
|
58
|
+
readonly log: Logger;
|
|
59
|
+
readonly now?: () => number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DispatchRequest {
|
|
63
|
+
readonly principal: Principal;
|
|
64
|
+
/** Fully qualified, e.g. `example.echo`. */
|
|
65
|
+
readonly capabilityId: string;
|
|
66
|
+
/** Fully qualified, e.g. `example.a`. */
|
|
67
|
+
readonly connectionKey: string;
|
|
68
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
69
|
+
/** Self-reported by the client. Observability only — never authorization. */
|
|
70
|
+
readonly clientLabel?: string | undefined;
|
|
71
|
+
readonly signal?: AbortSignal;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `result` is a union because a capability is not necessarily a tool — a
|
|
76
|
+
* resource read, a resource listing, and a prompt render come back through this
|
|
77
|
+
* same path, deliberately, so that all four are policy-checked and audited
|
|
78
|
+
* identically. Narrow it with the `is*Result` guards from the provider SDK.
|
|
79
|
+
*/
|
|
80
|
+
export type DispatchOutcome =
|
|
81
|
+
| { readonly ok: true; readonly result: CapabilityResult }
|
|
82
|
+
| { readonly ok: false; readonly authorization: AuthorizationResult; readonly message: string };
|
|
83
|
+
|
|
84
|
+
export class Dispatcher {
|
|
85
|
+
readonly #deps: DispatchDeps;
|
|
86
|
+
readonly #now: () => number;
|
|
87
|
+
|
|
88
|
+
constructor(deps: DispatchDeps) {
|
|
89
|
+
this.#deps = deps;
|
|
90
|
+
this.#now = deps.now ?? Date.now;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Take bytes for a later call to name, and record that it happened.
|
|
95
|
+
*
|
|
96
|
+
* The body is in `staging.ts`; what belongs here is that it goes through the
|
|
97
|
+
* dispatcher at all. Staging is a write against one account — it puts the
|
|
98
|
+
* operator's file inside the endpoint, where a later send can post it outward
|
|
99
|
+
* — so it is audited on the same path as the send rather than beside it.
|
|
100
|
+
*/
|
|
101
|
+
async stageAttachment(request: StageRequest): Promise<StagedAttachment> {
|
|
102
|
+
return stageAttachment(
|
|
103
|
+
{
|
|
104
|
+
storage: this.#deps.storage,
|
|
105
|
+
audit: this.#deps.audit,
|
|
106
|
+
profile: this.#deps.config.instance.profile,
|
|
107
|
+
now: this.#now,
|
|
108
|
+
},
|
|
109
|
+
request,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Record an attempt that was refused before dispatch.
|
|
115
|
+
*
|
|
116
|
+
* Policy-filtered discovery means a capability the caller may not use is
|
|
117
|
+
* never advertised, so a call naming it is rejected by the protocol layer
|
|
118
|
+
* and never reaches `invoke`. Nothing was invoked — but an agent probing for
|
|
119
|
+
* tools it does not have is precisely the behaviour the log exists to
|
|
120
|
+
* capture, and without this it would leave no trace at all.
|
|
121
|
+
*/
|
|
122
|
+
async recordRefusal(request: {
|
|
123
|
+
readonly principal: Principal;
|
|
124
|
+
readonly capabilityId: string;
|
|
125
|
+
readonly connectionKey?: string | undefined;
|
|
126
|
+
readonly clientLabel?: string | undefined;
|
|
127
|
+
}): Promise<void> {
|
|
128
|
+
const [providerId = ''] = splitKey(request.capabilityId);
|
|
129
|
+
|
|
130
|
+
await this.#deps.audit.append({
|
|
131
|
+
profile: this.#deps.config.instance.profile,
|
|
132
|
+
principal: request.principal.id,
|
|
133
|
+
...(request.clientLabel ? { clientLabel: request.clientLabel } : {}),
|
|
134
|
+
provider: providerId,
|
|
135
|
+
...(request.connectionKey ? { connection: request.connectionKey } : {}),
|
|
136
|
+
capability: request.capabilityId,
|
|
137
|
+
arguments: {},
|
|
138
|
+
authorization: 'denied_default',
|
|
139
|
+
status: 'not_invoked',
|
|
140
|
+
durationMs: 0,
|
|
141
|
+
error: {
|
|
142
|
+
kind: 'not_available',
|
|
143
|
+
message: 'capability is not advertised to this principal',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async invoke(request: DispatchRequest): Promise<DispatchOutcome> {
|
|
149
|
+
const started = this.#now();
|
|
150
|
+
const { config, registry, state, audit } = this.#deps;
|
|
151
|
+
const [providerId = ''] = splitKey(request.capabilityId);
|
|
152
|
+
|
|
153
|
+
const annotations: Record<string, unknown> = {};
|
|
154
|
+
let authorization: AuthorizationResult = 'denied_default';
|
|
155
|
+
let status: AuditDraft['status'] = 'not_invoked';
|
|
156
|
+
let auditArguments: Record<string, unknown> = redactAllValues(request.arguments);
|
|
157
|
+
let error: { kind: string; message: string } | undefined;
|
|
158
|
+
let outcome: DispatchOutcome = {
|
|
159
|
+
ok: false,
|
|
160
|
+
authorization: 'denied_default',
|
|
161
|
+
message: 'not evaluated',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const registered = registry.findCapability(request.capabilityId);
|
|
166
|
+
if (!registered) {
|
|
167
|
+
error = { kind: 'unknown_capability', message: `No such capability: ${request.capabilityId}` };
|
|
168
|
+
// An unknown capability is reported as a plain denial rather than
|
|
169
|
+
// "no such tool", so probing cannot distinguish a capability that does
|
|
170
|
+
// not exist from one this profile may not use.
|
|
171
|
+
return (outcome = deny('denied_default', `Capability ${request.capabilityId} is not available`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const entry = registry.get(providerId)!;
|
|
175
|
+
|
|
176
|
+
// Apply redaction now that we know which capability this is, so a denial
|
|
177
|
+
// is logged with the same redaction an allowed call would get.
|
|
178
|
+
//
|
|
179
|
+
// Local capabilities carry their own rule. Discovered ones cannot: we did
|
|
180
|
+
// not author them and cannot know what is sensitive, so the default
|
|
181
|
+
// withholds every value and a manifest opts specific keys back in.
|
|
182
|
+
const declaredKeys = entry.manifest.redact?.[registered.capability?.name ?? registered.discovered?.name ?? ''];
|
|
183
|
+
const redact =
|
|
184
|
+
registered.capability?.redact ?? (declaredKeys ? keepKeys(...declaredKeys) : undefined);
|
|
185
|
+
|
|
186
|
+
auditArguments = (redact ?? redactAllValues)(request.arguments);
|
|
187
|
+
|
|
188
|
+
const declared = config.connections.find(
|
|
189
|
+
(connection) => `${connection.provider}.${connection.id}` === request.connectionKey,
|
|
190
|
+
);
|
|
191
|
+
if (!declared || declared.provider !== providerId) {
|
|
192
|
+
return (outcome = deny(
|
|
193
|
+
'denied_default',
|
|
194
|
+
`Connection ${request.connectionKey} is not available`,
|
|
195
|
+
));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const decision = evaluate(
|
|
199
|
+
{
|
|
200
|
+
principal: request.principal.id,
|
|
201
|
+
capability: request.capabilityId,
|
|
202
|
+
connection: request.connectionKey,
|
|
203
|
+
},
|
|
204
|
+
this.#deps.policy,
|
|
205
|
+
this.#deps.floor,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
authorization = decision.reason;
|
|
209
|
+
if (!decision.allowed) {
|
|
210
|
+
return (outcome = deny(
|
|
211
|
+
decision.reason,
|
|
212
|
+
`Not permitted: ${request.capabilityId} on ${request.connectionKey}`,
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Two buckets: one for the endpoint as a whole, one per connection to
|
|
217
|
+
// protect a vendor's quota from an agent stuck in a retry loop.
|
|
218
|
+
const perProfile = this.#deps.limiter.take(
|
|
219
|
+
`profile:${config.instance.profile}`,
|
|
220
|
+
config.limits.requests_per_minute,
|
|
221
|
+
);
|
|
222
|
+
const perConnection = perProfile.allowed
|
|
223
|
+
? this.#deps.limiter.take(
|
|
224
|
+
`connection:${request.connectionKey}`,
|
|
225
|
+
config.limits.upstream_calls_per_minute,
|
|
226
|
+
)
|
|
227
|
+
: perProfile;
|
|
228
|
+
|
|
229
|
+
if (!perProfile.allowed || !perConnection.allowed) {
|
|
230
|
+
const retryAfterMs = Math.max(perProfile.retryAfterMs, perConnection.retryAfterMs);
|
|
231
|
+
authorization = 'denied_rate_limited';
|
|
232
|
+
return (outcome = deny(
|
|
233
|
+
'denied_rate_limited',
|
|
234
|
+
`Rate limit exceeded. Retry in ${Math.ceil(retryAfterMs / 1000)}s.`,
|
|
235
|
+
));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const record = await state.connections.get(declared.provider, declared.id);
|
|
239
|
+
if (record && record.status !== 'active') {
|
|
240
|
+
authorization = 'denied_connection_unauthorized';
|
|
241
|
+
return (outcome = deny(
|
|
242
|
+
'denied_connection_unauthorized',
|
|
243
|
+
record.status === 'unauthorized'
|
|
244
|
+
? `Connection ${request.connectionKey} has no valid credential. Run: lanes link connect ${request.connectionKey}`
|
|
245
|
+
: `Connection ${request.connectionKey} is disabled.`,
|
|
246
|
+
));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const auditLogger: AuditLogger = {
|
|
250
|
+
annotate(detail) {
|
|
251
|
+
Object.assign(annotations, detail);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// One closure, handed to both contexts. A provider that authors a
|
|
256
|
+
// capability its transport cannot express still calls the vendor through
|
|
257
|
+
// the same authorizer the transport would have used.
|
|
258
|
+
const authorize = (outbound: Request): Promise<Request> =>
|
|
259
|
+
this.#deps.authorizeRequest
|
|
260
|
+
? this.#deps.authorizeRequest(providerId, declared.id, outbound)
|
|
261
|
+
: Promise.resolve(outbound);
|
|
262
|
+
|
|
263
|
+
const providerContext = buildProviderContext({
|
|
264
|
+
manifest: entry.manifest,
|
|
265
|
+
definition: entry.definition,
|
|
266
|
+
connection: declared,
|
|
267
|
+
state,
|
|
268
|
+
credentials: this.#deps.credentials,
|
|
269
|
+
storage: this.#deps.storage,
|
|
270
|
+
audit: auditLogger,
|
|
271
|
+
log: createProviderLogger(this.#deps.log, providerId, declared.id),
|
|
272
|
+
signal: request.signal ?? new AbortController().signal,
|
|
273
|
+
// Which vendors this profile registered a client of its own for. A
|
|
274
|
+
// provider that could be brokered but is not reads its client from the
|
|
275
|
+
// store, so it has to be able to.
|
|
276
|
+
ownClients: Object.keys(this.#deps.config.oauth_apps),
|
|
277
|
+
...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const connector = this.#deps.connectorFor(providerId, declared.id);
|
|
281
|
+
if (!connector) {
|
|
282
|
+
status = 'error';
|
|
283
|
+
error = { kind: 'no_connector', message: `No connector for provider ${providerId}` };
|
|
284
|
+
return (outcome = {
|
|
285
|
+
ok: false,
|
|
286
|
+
authorization: 'allowed',
|
|
287
|
+
message: `Provider ${providerId} has no usable connector.`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const connectorContext: ConnectorContext = {
|
|
292
|
+
manifest: entry.manifest,
|
|
293
|
+
provider: providerContext,
|
|
294
|
+
authorize,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// The connector owns argument validation: a local provider validates
|
|
298
|
+
// against its Zod schema, a proxied one against the upstream JSON Schema
|
|
299
|
+
// the MCP layer already enforced at registration.
|
|
300
|
+
const target = registered.discovered ?? {
|
|
301
|
+
name: registered.capability!.name,
|
|
302
|
+
description: registered.capability!.description,
|
|
303
|
+
inputSchema: {},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const result = await connector.invoke(target, request.arguments, connectorContext);
|
|
307
|
+
// Only a tool can report a soft failure. A resource or a prompt that
|
|
308
|
+
// cannot produce its answer throws, and lands in the catch below.
|
|
309
|
+
status = isToolResult(result) && result.isError ? 'error' : 'ok';
|
|
310
|
+
return (outcome = { ok: true, result });
|
|
311
|
+
} catch (caught) {
|
|
312
|
+
status = 'error';
|
|
313
|
+
error = { kind: 'provider_error', message: (caught as Error).message };
|
|
314
|
+
return (outcome = {
|
|
315
|
+
ok: false,
|
|
316
|
+
authorization,
|
|
317
|
+
message: `${request.capabilityId} failed: ${(caught as Error).message}`,
|
|
318
|
+
});
|
|
319
|
+
} finally {
|
|
320
|
+
// One event per invocation, on every path. An audit log that records
|
|
321
|
+
// only successes cannot answer "what did this agent try to do", which is
|
|
322
|
+
// the question worth asking.
|
|
323
|
+
await audit.append({
|
|
324
|
+
profile: config.instance.profile,
|
|
325
|
+
principal: request.principal.id,
|
|
326
|
+
...(request.clientLabel ? { clientLabel: request.clientLabel } : {}),
|
|
327
|
+
provider: providerId,
|
|
328
|
+
connection: request.connectionKey,
|
|
329
|
+
capability: request.capabilityId,
|
|
330
|
+
arguments:
|
|
331
|
+
Object.keys(annotations).length > 0
|
|
332
|
+
? { ...auditArguments, _annotations: annotations }
|
|
333
|
+
: auditArguments,
|
|
334
|
+
authorization: outcome.ok ? 'allowed' : authorization,
|
|
335
|
+
status,
|
|
336
|
+
durationMs: this.#now() - started,
|
|
337
|
+
...(error ? { error } : {}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function deny(authorization: AuthorizationResult, message: string): DispatchOutcome {
|
|
344
|
+
return { ok: false, authorization, message };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function splitKey(key: string): [string, string] {
|
|
348
|
+
const index = key.indexOf('.');
|
|
349
|
+
return index === -1 ? [key, ''] : [key.slice(0, index), key.slice(index + 1)];
|
|
350
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The invocation path — the one sequence every call runs, with no way around
|
|
3
|
+
* it:
|
|
4
|
+
*
|
|
5
|
+
* policy → limits → provider → audit
|
|
6
|
+
*
|
|
7
|
+
* The ordering is not stylistic. Policy is evaluated *before* a provider is
|
|
8
|
+
* reached, so a provider never sees a request it was not authorised to serve
|
|
9
|
+
* and authorization is never something provider code could get wrong. Exactly
|
|
10
|
+
* one audit event is written per invocation, by a `finally` rather than by
|
|
11
|
+
* remembering to call it at each return.
|
|
12
|
+
*
|
|
13
|
+
* `context.ts` is what a provider is handed: scoped state, scoped blobs, an
|
|
14
|
+
* allowlist of credential refs, and a logger. Never a raw backend.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
buildProviderContext,
|
|
19
|
+
createConsoleLogger,
|
|
20
|
+
createProviderLogger,
|
|
21
|
+
createScopedStore,
|
|
22
|
+
resolveSecretRefs,
|
|
23
|
+
scopeNamespace,
|
|
24
|
+
type BuildContextOptions,
|
|
25
|
+
} from './context.ts';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
Dispatcher,
|
|
29
|
+
type DispatchDeps,
|
|
30
|
+
type DispatchOutcome,
|
|
31
|
+
type DispatchRequest,
|
|
32
|
+
} from './dispatch.ts';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { AuditSink } from '#audit';
|
|
3
|
+
import type { Principal } from '#auth';
|
|
4
|
+
import { newHandle, putStaged, sweepStaged, STAGED_TTL_MS } from '#connectivity/mail';
|
|
5
|
+
import type { BlobStore } from '#stores/blobs';
|
|
6
|
+
import { scopeBlobStore } from '#stores/blobs';
|
|
7
|
+
import { scopeNamespace } from './context.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Taking bytes now for a call to name later.
|
|
11
|
+
*
|
|
12
|
+
* In dispatch rather than in the transport that receives the upload, for two
|
|
13
|
+
* reasons. The dependency direction is one: `server` may not reach a store, and
|
|
14
|
+
* should not — how blobs are namespaced is not something a component that speaks
|
|
15
|
+
* HTTP has any business knowing. The audit log is the other, and the better one.
|
|
16
|
+
* Staging is a write against one account: it puts the operator's file *inside*
|
|
17
|
+
* the endpoint, where a later send can post it outward. That belongs in the same
|
|
18
|
+
* log as the send, and this is the only place that writes it.
|
|
19
|
+
*
|
|
20
|
+
* Beside `dispatch.ts` rather than in it because it shares nothing with `invoke`
|
|
21
|
+
* but the deps — no policy evaluation, no rate limit, no connector — and that
|
|
22
|
+
* file was at the size budget, which is the budget doing its job.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface StageRequest {
|
|
26
|
+
readonly principal: Principal;
|
|
27
|
+
readonly providerId: string;
|
|
28
|
+
readonly connectionId: string;
|
|
29
|
+
readonly bytes: Uint8Array;
|
|
30
|
+
readonly filename: string;
|
|
31
|
+
readonly contentType: string;
|
|
32
|
+
readonly clientLabel?: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StagedAttachment {
|
|
36
|
+
readonly handle: string;
|
|
37
|
+
readonly sha256: string;
|
|
38
|
+
readonly expiresAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function stageAttachment(
|
|
42
|
+
deps: {
|
|
43
|
+
readonly storage: BlobStore;
|
|
44
|
+
readonly audit: AuditSink;
|
|
45
|
+
readonly profile: string;
|
|
46
|
+
readonly now: () => number;
|
|
47
|
+
},
|
|
48
|
+
request: StageRequest,
|
|
49
|
+
): Promise<StagedAttachment> {
|
|
50
|
+
// Namespaced per provider and connection, exactly as `buildProviderContext`
|
|
51
|
+
// scopes the store it hands a provider — which is what makes the handle
|
|
52
|
+
// resolvable from that connection and from no other.
|
|
53
|
+
const storage = scopeBlobStore(
|
|
54
|
+
deps.storage,
|
|
55
|
+
scopeNamespace(request.providerId, request.connectionId),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Swept before writing: staging is the only thing that creates this garbage,
|
|
59
|
+
// and there is no scheduler in this process, so the moment someone stages is
|
|
60
|
+
// both the cheapest and the most reliable time to clear the last batch. A
|
|
61
|
+
// failure to sweep must not fail the upload — the worst case is a stale blob
|
|
62
|
+
// that the next sweep catches.
|
|
63
|
+
await sweepStaged(storage).catch(() => 0);
|
|
64
|
+
|
|
65
|
+
const handle = newHandle();
|
|
66
|
+
const sha256 = createHash('sha256').update(request.bytes).digest('hex');
|
|
67
|
+
const expiresAt = deps.now() + STAGED_TTL_MS;
|
|
68
|
+
|
|
69
|
+
await putStaged(storage, {
|
|
70
|
+
handle,
|
|
71
|
+
bytes: request.bytes,
|
|
72
|
+
metadata: {
|
|
73
|
+
filename: request.filename,
|
|
74
|
+
content_type: request.contentType,
|
|
75
|
+
sha256,
|
|
76
|
+
expires_at: expiresAt,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await deps.audit.append({
|
|
81
|
+
profile: deps.profile,
|
|
82
|
+
principal: request.principal.id,
|
|
83
|
+
...(request.clientLabel ? { clientLabel: request.clientLabel } : {}),
|
|
84
|
+
provider: request.providerId,
|
|
85
|
+
connection: `${request.providerId}.${request.connectionId}`,
|
|
86
|
+
capability: 'attachments.stage',
|
|
87
|
+
// Identifiers, not content — the same rule the send path follows. The
|
|
88
|
+
// filename and digest are what make "which file entered the endpoint" an
|
|
89
|
+
// answerable question; the bytes are the thing being withheld.
|
|
90
|
+
arguments: {
|
|
91
|
+
filename: request.filename,
|
|
92
|
+
bytes: request.bytes.byteLength,
|
|
93
|
+
content_type: request.contentType,
|
|
94
|
+
sha256,
|
|
95
|
+
},
|
|
96
|
+
authorization: 'allowed',
|
|
97
|
+
status: 'ok',
|
|
98
|
+
durationMs: 0,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { handle, sha256, expiresAt };
|
|
102
|
+
}
|