@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,128 @@
|
|
|
1
|
+
import type { AuditDraft, AuditEvent, AuditSink } from '#audit';
|
|
2
|
+
import type { FetchLike } from './gcs.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The log to an OpenTelemetry collector, over OTLP/HTTP with a JSON body.
|
|
6
|
+
*
|
|
7
|
+
* `fetch` and a shape, rather than `@opentelemetry/*`. The SDK brings a
|
|
8
|
+
* provider, a processor, an exporter and a resource detector to solve batching
|
|
9
|
+
* and context propagation — neither of which applies here: there is one record
|
|
10
|
+
* type, it is already complete when it arrives, and there is no span to
|
|
11
|
+
* correlate it with. A repository that enforces a seven-day release-age floor
|
|
12
|
+
* on dependencies because it holds live refresh tokens does not add a
|
|
13
|
+
* transitive tree to build one JSON object.
|
|
14
|
+
*
|
|
15
|
+
* JSON rather than protobuf for the same reason — protobuf would be the whole
|
|
16
|
+
* of the dependency. Every collector that accepts OTLP/HTTP accepts
|
|
17
|
+
* `application/json`; the spec requires it.
|
|
18
|
+
*
|
|
19
|
+
* **Never the primary sink.** This is a copy, and `fanOutAudit` is what keeps
|
|
20
|
+
* that true: a collector being down must not fail a capability call, and the
|
|
21
|
+
* durable log is somewhere that does not need a network.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** OTel severity numbers. The spec's scale, not an invention. */
|
|
25
|
+
const SEVERITY_INFO = 9;
|
|
26
|
+
const SEVERITY_WARN = 13;
|
|
27
|
+
const SEVERITY_ERROR = 17;
|
|
28
|
+
|
|
29
|
+
export interface OtlpAuditOptions {
|
|
30
|
+
/** The logs endpoint, e.g. `https://collector.example/v1/logs`. */
|
|
31
|
+
readonly endpoint: string;
|
|
32
|
+
/** Static headers — an API key, a tenant id. Resolved from a credential ref by the caller. */
|
|
33
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
34
|
+
readonly serviceName?: string;
|
|
35
|
+
readonly now?: () => Date;
|
|
36
|
+
readonly fetch?: FetchLike;
|
|
37
|
+
/** Abandon a send after this long, so a hung collector cannot pile up copies. */
|
|
38
|
+
readonly timeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createOtlpAuditSink(options: OtlpAuditOptions): AuditSink {
|
|
42
|
+
const call = options.fetch ?? globalThis.fetch;
|
|
43
|
+
const now = options.now ?? ((): Date => new Date());
|
|
44
|
+
const serviceName = options.serviceName ?? 'lanes-link';
|
|
45
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async append(draft: AuditDraft): Promise<AuditEvent> {
|
|
49
|
+
const event: AuditEvent = { ...draft, id: `evt_${crypto.randomUUID()}`, timestamp: now() };
|
|
50
|
+
|
|
51
|
+
const response = await call(options.endpoint, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/json', ...options.headers },
|
|
54
|
+
body: JSON.stringify(payload(event, serviceName)),
|
|
55
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Raised so the fan-out can report it once. It never reaches a caller.
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const detail = (await response.text().catch(() => '')).slice(0, 200);
|
|
61
|
+
throw new Error(`OTLP collector answered ${response.status}. ${detail}`);
|
|
62
|
+
}
|
|
63
|
+
return event;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async close(): Promise<void> {},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function payload(event: AuditEvent, serviceName: string): unknown {
|
|
71
|
+
return {
|
|
72
|
+
resourceLogs: [
|
|
73
|
+
{
|
|
74
|
+
resource: { attributes: [stringAttribute('service.name', serviceName)] },
|
|
75
|
+
scopeLogs: [
|
|
76
|
+
{
|
|
77
|
+
scope: { name: 'lanes-link/audit' },
|
|
78
|
+
logRecords: [logRecord(event)],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function logRecord(event: AuditEvent): unknown {
|
|
87
|
+
return {
|
|
88
|
+
// Nanoseconds as a string: the spec's type is fixed64, and a millisecond
|
|
89
|
+
// timestamp times a million is past 2^53 — JSON's number would round it.
|
|
90
|
+
timeUnixNano: `${BigInt(event.timestamp.getTime()) * 1_000_000n}`,
|
|
91
|
+
severityNumber: severityOf(event),
|
|
92
|
+
severityText: severityTextOf(event),
|
|
93
|
+
// The capability, so a collector's default view is a list of what was
|
|
94
|
+
// attempted rather than a column of identical strings.
|
|
95
|
+
body: { stringValue: event.capability },
|
|
96
|
+
attributes: [
|
|
97
|
+
stringAttribute('lanes.profile', event.profile),
|
|
98
|
+
stringAttribute('lanes.principal', event.principal),
|
|
99
|
+
stringAttribute('lanes.provider', event.provider),
|
|
100
|
+
stringAttribute('lanes.authorization', event.authorization),
|
|
101
|
+
stringAttribute('lanes.status', event.status),
|
|
102
|
+
stringAttribute('lanes.event_id', event.id),
|
|
103
|
+
{ key: 'lanes.duration_ms', value: { intValue: String(event.durationMs) } },
|
|
104
|
+
...(event.connection ? [stringAttribute('lanes.connection', event.connection)] : []),
|
|
105
|
+
...(event.clientLabel ? [stringAttribute('lanes.client', event.clientLabel)] : []),
|
|
106
|
+
...(event.error ? [stringAttribute('lanes.error', event.error.kind)] : []),
|
|
107
|
+
// Already redacted by the time it reaches any sink — a provider declares
|
|
108
|
+
// what survives, and the default keeps no values at all. Serialised
|
|
109
|
+
// rather than flattened into attributes because the shape is nested and
|
|
110
|
+
// a collector's attribute model is not.
|
|
111
|
+
stringAttribute('lanes.arguments', JSON.stringify(event.arguments)),
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function severityOf(event: AuditEvent): number {
|
|
117
|
+
if (event.status === 'error') return SEVERITY_ERROR;
|
|
118
|
+
return event.authorization === 'allowed' ? SEVERITY_INFO : SEVERITY_WARN;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function severityTextOf(event: AuditEvent): string {
|
|
122
|
+
if (event.status === 'error') return 'ERROR';
|
|
123
|
+
return event.authorization === 'allowed' ? 'INFO' : 'WARN';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stringAttribute(key: string, value: string): unknown {
|
|
127
|
+
return { key, value: { stringValue: value } };
|
|
128
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { containedKey } from '#stores/blobs';
|
|
2
|
+
import type { BlobMetadata, BlobStore } from '#stores/blobs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* S3-compatible blob store — the `cloud` target's adapter.
|
|
6
|
+
*
|
|
7
|
+
* The workload is the owner layer: `providers/owner/src/memory.ts` writes entry
|
|
8
|
+
* bodies as blobs, and Cloud Run's disk does not survive an instance recycle.
|
|
9
|
+
* The filesystem adapter beside this one is not wrong there, it is *silently*
|
|
10
|
+
* wrong — writes succeed and the bytes are gone with the container.
|
|
11
|
+
*
|
|
12
|
+
* `Bun.S3Client` directly, for the reason `postgres.ts` uses `Bun.SQL` and
|
|
13
|
+
* `sqlite.ts` uses `bun:sqlite`: it is a built-in, so this adapter adds no
|
|
14
|
+
* dependency to a repository holding live refresh tokens. See ADR-004.
|
|
15
|
+
*
|
|
16
|
+
* Named for the protocol rather than the vendor, which is ADR-008's rule
|
|
17
|
+
* applied to an adapter: Supabase Storage, Cloudflare R2, MinIO, and AWS differ
|
|
18
|
+
* only in the endpoint. Supabase is what `docs/detailed/deployment-cloudrun.md`
|
|
19
|
+
* documents, because it is already the Postgres host and a second vendor for
|
|
20
|
+
* the blobs would be one more thing to hold.
|
|
21
|
+
*
|
|
22
|
+
* Containment comes from `containedKey`, not from anything here: `..` in an S3
|
|
23
|
+
* key is a literal character sequence rather than a directory operation, so the
|
|
24
|
+
* check is not load-bearing the way `filesystem.ts`'s is. It exists so both
|
|
25
|
+
* adapters answer identically, which is the only reason the shared contract
|
|
26
|
+
* suite can assert containment at all.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface S3BlobStoreOptions {
|
|
30
|
+
readonly bucket: string;
|
|
31
|
+
readonly accessKeyId: string;
|
|
32
|
+
readonly secretAccessKey: string;
|
|
33
|
+
/**
|
|
34
|
+
* The S3-compatible service endpoint. Required for anything that is not AWS
|
|
35
|
+
* — Supabase's is `https://<project-ref>.storage.supabase.co/storage/v1/s3`.
|
|
36
|
+
*/
|
|
37
|
+
readonly endpoint?: string;
|
|
38
|
+
readonly region?: string;
|
|
39
|
+
/**
|
|
40
|
+
* A key prefix inside the bucket — the bucket-relative equivalent of the
|
|
41
|
+
* filesystem adapter's `root`, and the same unit of separation. Two profiles
|
|
42
|
+
* sharing one bucket under different prefixes do not see each other's keys,
|
|
43
|
+
* and every key this store reports is relative to it.
|
|
44
|
+
*/
|
|
45
|
+
readonly prefix?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Keys requested per `ListObjectsV2` call. Defaults to the protocol's own
|
|
48
|
+
* ceiling, and exists so a test can force pagination without writing a
|
|
49
|
+
* thousand objects to prove the continuation-token path is followed.
|
|
50
|
+
*/
|
|
51
|
+
readonly pageSize?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `ListObjectsV2` returns at most 1000 keys per call whatever `maxKeys` says,
|
|
56
|
+
* so a store larger than one page is the normal case rather than the edge one.
|
|
57
|
+
*/
|
|
58
|
+
const PAGE_SIZE = 1000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a read failed because the object is not there.
|
|
62
|
+
*
|
|
63
|
+
* `get` and `list` return absence rather than throwing it, so this has to
|
|
64
|
+
* separate "no such key" from a credential, network, or bucket error — those
|
|
65
|
+
* must surface. S3 answers `NoSuchKey`; Bun maps some backends' 404 to
|
|
66
|
+
* `ENOENT`, and a `HeadObject` against a missing key is a bare `NoSuchKey`
|
|
67
|
+
* with no body, which some implementations report as `AccessDenied` when the
|
|
68
|
+
* caller lacks `s3:ListBucket`. Only the first two are treated as absence —
|
|
69
|
+
* swallowing `AccessDenied` would turn a misconfigured policy into an empty
|
|
70
|
+
* store, which is the failure this whole adapter exists to prevent.
|
|
71
|
+
*/
|
|
72
|
+
function isMissing(error: unknown): boolean {
|
|
73
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
74
|
+
return code === 'NoSuchKey' || code === 'ENOENT';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A configured prefix as a key prefix: empty, or ending in exactly one slash.
|
|
79
|
+
*
|
|
80
|
+
* Exported for its tests. An operator who writes `lanes-link` rather than
|
|
81
|
+
* `lanes-link/` must not get keys like `lanes-linknote.txt`, and finding that out
|
|
82
|
+
* should not require a bucket.
|
|
83
|
+
*
|
|
84
|
+
* A leading `./` is dropped for the same class of reason. It means "here" to
|
|
85
|
+
* every path API and means a directory named `.` to a bucket, so a target
|
|
86
|
+
* declaring `prefix: ./blobs` would silently address `./blobs/…` while every
|
|
87
|
+
* console, lifecycle rule and IAM condition written against it said `blobs/`.
|
|
88
|
+
* `layout.ts` no longer produces one; this is for the ones an operator writes.
|
|
89
|
+
*/
|
|
90
|
+
export function s3Prefix(prefix?: string): string {
|
|
91
|
+
if (prefix === undefined || prefix === '') return '';
|
|
92
|
+
|
|
93
|
+
const rooted = prefix.replace(/^(?:\.\/)+/, '');
|
|
94
|
+
if (rooted === '') return '';
|
|
95
|
+
return rooted.endsWith('/') ? rooted : `${rooted}/`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A caller's key as an object key: containment first, then the prefix.
|
|
100
|
+
*
|
|
101
|
+
* The order is the point. Prefixing first would let a `../` chew back through
|
|
102
|
+
* the prefix into another profile's key space, and the containment check would
|
|
103
|
+
* then see a key that resolves inside the bucket and allow it.
|
|
104
|
+
*/
|
|
105
|
+
export function s3ObjectKey(prefix: string | undefined, key: string): string {
|
|
106
|
+
return `${s3Prefix(prefix)}${containedKey(key)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createS3BlobStore(options: S3BlobStoreOptions): BlobStore {
|
|
110
|
+
const prefix = s3Prefix(options.prefix);
|
|
111
|
+
const objectKey = (key: string): string => s3ObjectKey(prefix, key);
|
|
112
|
+
|
|
113
|
+
const client = new Bun.S3Client({
|
|
114
|
+
bucket: options.bucket,
|
|
115
|
+
accessKeyId: options.accessKeyId,
|
|
116
|
+
secretAccessKey: options.secretAccessKey,
|
|
117
|
+
...(options.endpoint !== undefined ? { endpoint: options.endpoint } : {}),
|
|
118
|
+
...(options.region !== undefined ? { region: options.region } : {}),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Content type is not in a `ListObjectsV2` response, so it costs one
|
|
123
|
+
* `HeadObject` per key. The filesystem adapter pays the same shape of cost
|
|
124
|
+
* reading its `.meta` sidecars; here each one is a round trip, so they go
|
|
125
|
+
* concurrently and a key that vanishes mid-listing is dropped rather than
|
|
126
|
+
* failing the whole call.
|
|
127
|
+
*/
|
|
128
|
+
const describe = async (entry: { key: string }): Promise<BlobMetadata | null> => {
|
|
129
|
+
try {
|
|
130
|
+
const stat = await client.stat(entry.key);
|
|
131
|
+
return {
|
|
132
|
+
key: entry.key.slice(prefix.length),
|
|
133
|
+
size: stat.size,
|
|
134
|
+
modifiedAt: stat.lastModified,
|
|
135
|
+
...(stat.type ? { contentType: stat.type } : {}),
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isMissing(error)) return null;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
async put(key, data, blobOptions) {
|
|
145
|
+
// Uint8Array rather than the caller's view: `write` takes an
|
|
146
|
+
// ArrayBufferView, and passing one whose byteOffset is non-zero has
|
|
147
|
+
// uploaded the whole backing buffer on some paths.
|
|
148
|
+
const bytes = new Uint8Array(data);
|
|
149
|
+
await client.write(objectKey(key), bytes, {
|
|
150
|
+
...(blobOptions?.contentType ? { type: blobOptions.contentType } : {}),
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async get(key) {
|
|
155
|
+
try {
|
|
156
|
+
return await client.file(objectKey(key)).bytes();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (isMissing(error)) return null;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async has(key) {
|
|
164
|
+
return client.exists(objectKey(key));
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async delete(key) {
|
|
168
|
+
// S3 DELETE is idempotent: removing an absent key is a success, which is
|
|
169
|
+
// the contract the filesystem adapter gets from `rm --force`.
|
|
170
|
+
await client.delete(objectKey(key));
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async list(innerPrefix) {
|
|
174
|
+
const entries: BlobMetadata[] = [];
|
|
175
|
+
let continuationToken: string | undefined;
|
|
176
|
+
|
|
177
|
+
do {
|
|
178
|
+
const page = await client.list({
|
|
179
|
+
maxKeys: options.pageSize ?? PAGE_SIZE,
|
|
180
|
+
prefix: `${prefix}${innerPrefix ?? ''}`,
|
|
181
|
+
...(continuationToken !== undefined ? { continuationToken } : {}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const described = await Promise.all((page.contents ?? []).map(describe));
|
|
185
|
+
for (const entry of described) if (entry) entries.push(entry);
|
|
186
|
+
|
|
187
|
+
// `isTruncated` is the authority on whether another page exists; a
|
|
188
|
+
// present `nextContinuationToken` on a final page would otherwise loop.
|
|
189
|
+
continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;
|
|
190
|
+
} while (continuationToken !== undefined);
|
|
191
|
+
|
|
192
|
+
return entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Azure — not implemented
|
|
2
|
+
|
|
3
|
+
There is no Azure target. This folder exists to say what one would need, because
|
|
4
|
+
the answer is short and the alternative is someone reading `target.ts` to work it
|
|
5
|
+
out.
|
|
6
|
+
|
|
7
|
+
Three backends, and two of them already exist:
|
|
8
|
+
|
|
9
|
+
| Interface | What Azure offers | Adapter |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `Database` | Azure Database for PostgreSQL | `adapters/postgres.ts` — **reuse it** |
|
|
12
|
+
| `BlobStore` | Azure Blob Storage | `adapters/s3.ts` if the account has the S3-compatible endpoint enabled, otherwise a new `adapters/azure-blob.ts` |
|
|
13
|
+
| `SecretStore` | Key Vault | new: `adapters/azure-key-vault.ts` |
|
|
14
|
+
|
|
15
|
+
So the real work is one adapter, plus a `target.ts` here composing the three and
|
|
16
|
+
a deploy driver for Container Apps. Follow `../gcp/` — its `deploy.ts` builds an
|
|
17
|
+
argv array and never a shell string, and its `--dry-run` prints exactly what
|
|
18
|
+
would run.
|
|
19
|
+
|
|
20
|
+
The one thing not to copy from `../gcp/` is the assumption that `gcloud` is on
|
|
21
|
+
the PATH. That is a property of that deployment, not of deployments generally.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ConfigError, type Config, type DeployConfig, type TargetConfig } from '#profile';
|
|
2
|
+
import { VAULT_KEY_ENV, VAULT_KEY_REF } from '#secrets';
|
|
3
|
+
import { ok, print, style } from '#cli/output.ts';
|
|
4
|
+
import { isInteractive } from '#cli/prompt.ts';
|
|
5
|
+
import { ConfigDocument } from '#cli/config-edit.ts';
|
|
6
|
+
import { driverFor } from './drivers.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Getting a deployable target into the config, by asking rather than refusing.
|
|
10
|
+
*
|
|
11
|
+
* Split from `deploy.ts` because it is a different job: that file is the ordered
|
|
12
|
+
* list of things that have to happen to roll a revision, and this is the one
|
|
13
|
+
* question it has to settle before any of them — *where*. Keeping them together
|
|
14
|
+
* put a config editor, a survey and a rollout in one file and pushed it past the
|
|
15
|
+
* size budget `src/architecture.test.ts` holds.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface TargetBootstrap {
|
|
19
|
+
/** Override the declared `access` for this run. */
|
|
20
|
+
readonly access?: string | undefined;
|
|
21
|
+
readonly serviceAccount?: string | undefined;
|
|
22
|
+
readonly profile?: string | undefined;
|
|
23
|
+
/** Take what the config says without asking. Implied when nobody can answer. */
|
|
24
|
+
readonly nonInteractive?: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether this run asks its setup questions, or takes the stored answers.
|
|
29
|
+
*
|
|
30
|
+
* It asks every time, and that is the point: the values it collects are the ones
|
|
31
|
+
* that decide where a revision lands, and a deploy where they are invisible
|
|
32
|
+
* unless you open a YAML file is one you run without reading. Every prompt
|
|
33
|
+
* defaults to what the config already says, so pressing return through the whole
|
|
34
|
+
* survey changes nothing and re-generates nothing — the random project and
|
|
35
|
+
* bucket names are stored values by then, not fresh draws.
|
|
36
|
+
*
|
|
37
|
+
* Two things stop it. A run with nobody at the keyboard has to use what is
|
|
38
|
+
* stored or it dies at the first prompt, which would break every scripted
|
|
39
|
+
* deploy; and `--non-interactive` says so explicitly, for a terminal that
|
|
40
|
+
* happens to be attached to a job nobody is watching.
|
|
41
|
+
*
|
|
42
|
+
* Neither can rescue a target that is missing the answers. That still refuses,
|
|
43
|
+
* at the prompt it could not ask.
|
|
44
|
+
*/
|
|
45
|
+
export function willSurvey(
|
|
46
|
+
declared: TargetConfig | undefined,
|
|
47
|
+
flags: TargetBootstrap,
|
|
48
|
+
interactive: boolean,
|
|
49
|
+
): boolean {
|
|
50
|
+
if (declared?.deploy === undefined) return true;
|
|
51
|
+
return interactive && flags.nonInteractive !== true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The target this deploys, asked for whenever the config does not have it.
|
|
56
|
+
*
|
|
57
|
+
* Three cases, and the first used to be a refusal. A target that does not exist
|
|
58
|
+
* at all was `Target "cloud" is not declared` plus a trip to the documentation
|
|
59
|
+
* to hand-copy four adapter blocks — none of which has more than one workable
|
|
60
|
+
* answer on a deployment, and one of which (`storage: filesystem`) *appears* to
|
|
61
|
+
* work and silently discards everything on the next instance recycle. A command
|
|
62
|
+
* that knows the right answer and refuses to write it is a command that has
|
|
63
|
+
* chosen to be a worse copy of its own docs.
|
|
64
|
+
*
|
|
65
|
+
* Written back to the profile immediately rather than held for the run: the
|
|
66
|
+
* answers are configuration, and configuration that lives only in a shell
|
|
67
|
+
* history has to be retyped identically next time or it deploys somewhere else.
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveTarget(input: {
|
|
70
|
+
config: Config;
|
|
71
|
+
profilePath: string;
|
|
72
|
+
workspaceRoot: string;
|
|
73
|
+
profile: string;
|
|
74
|
+
target: string;
|
|
75
|
+
flags: TargetBootstrap;
|
|
76
|
+
}): Promise<TargetConfig> {
|
|
77
|
+
const { config, flags, target } = input;
|
|
78
|
+
const declared = config.targets[target];
|
|
79
|
+
const access = parseAccess(flags.access);
|
|
80
|
+
|
|
81
|
+
const overrides = {
|
|
82
|
+
...(access ? { access } : {}),
|
|
83
|
+
...(flags.serviceAccount !== undefined ? { service_account: flags.serviceAccount } : {}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Nobody to ask, or told not to: the config is the answer, and flags override
|
|
87
|
+
// for this run without editing anything.
|
|
88
|
+
if (!willSurvey(declared, flags, isInteractive())) {
|
|
89
|
+
return { ...declared!, deploy: { ...declared!.deploy!, ...overrides } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Only one platform exists, so asking which would be a question with one
|
|
93
|
+
// answer. When a second lands, this is where it is asked.
|
|
94
|
+
const driver = await driverFor('cloudrun');
|
|
95
|
+
const surveyed = await driver.survey({
|
|
96
|
+
current: { ...(declared?.deploy ?? {}), ...overrides },
|
|
97
|
+
profile: input.profile,
|
|
98
|
+
// A profile that authenticates remote clients itself wants the platform
|
|
99
|
+
// door open; IAM in front of it admits only callers that can mint the
|
|
100
|
+
// host's own identity token, which is none of them.
|
|
101
|
+
gated: config.auth.authorization !== undefined,
|
|
102
|
+
adapters: declared === undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const document = await ConfigDocument.open(input.workspaceRoot, input.profile);
|
|
106
|
+
|
|
107
|
+
if (declared) {
|
|
108
|
+
document.setIn(['targets', target, 'deploy'], withoutUndefined(surveyed.target.deploy!));
|
|
109
|
+
} else {
|
|
110
|
+
document.setIn(['targets', target], deepWithoutUndefined(surveyed.target));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `auth.authorization` is not part of the target and is written all the same:
|
|
114
|
+
// the question that decides it is "will a remote client reach this", which
|
|
115
|
+
// only a deploy is in a position to ask. See `SurveyResult`.
|
|
116
|
+
if (surveyed.authorization) {
|
|
117
|
+
document.setIn(['auth', 'authorization'], surveyed.authorization);
|
|
118
|
+
}
|
|
119
|
+
await document.save();
|
|
120
|
+
|
|
121
|
+
print('');
|
|
122
|
+
print(
|
|
123
|
+
ok(
|
|
124
|
+
declared
|
|
125
|
+
? `written to targets.${target}.deploy in ${input.profilePath}`
|
|
126
|
+
: `written to targets.${target} in ${input.profilePath}`,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
if (surveyed.authorization) {
|
|
130
|
+
print(ok('written to auth.authorization — this endpoint will issue its own tokens'));
|
|
131
|
+
}
|
|
132
|
+
print(style.dim(' Edit it there, or re-run with --access to change who may reach it.'));
|
|
133
|
+
|
|
134
|
+
return declared ? { ...declared, deploy: surveyed.target.deploy } : surveyed.target;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The environment a revision reads out of its own credential store.
|
|
139
|
+
*
|
|
140
|
+
* Only the vault key, today. It is here rather than in the driver because
|
|
141
|
+
* *whether* there is one is a property of the target's adapters, and which
|
|
142
|
+
* platform mechanism mounts it is not.
|
|
143
|
+
*/
|
|
144
|
+
export function vaultEnv(declared: TargetConfig): Record<string, string> | undefined {
|
|
145
|
+
const adapter = declared.vault?.adapter;
|
|
146
|
+
if (adapter !== 'secret' && adapter !== 'blob') return undefined;
|
|
147
|
+
return { [VAULT_KEY_ENV]: VAULT_KEY_REF };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseAccess(value: string | undefined): DeployConfig['access'] | undefined {
|
|
151
|
+
if (value === undefined) return undefined;
|
|
152
|
+
if (value !== 'iam' && value !== 'public') {
|
|
153
|
+
throw new ConfigError(`--access must be "iam" or "public", not "${value}"`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `setIn` writes an explicit null for an undefined value, which then fails validation. */
|
|
159
|
+
function withoutUndefined<T extends object>(value: T): Partial<T> {
|
|
160
|
+
return Object.fromEntries(
|
|
161
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined),
|
|
162
|
+
) as Partial<T>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The same, for a whole target — every block inside it is an object too. */
|
|
166
|
+
export function deepWithoutUndefined<T extends object>(value: T): Partial<T> {
|
|
167
|
+
return Object.fromEntries(
|
|
168
|
+
Object.entries(value)
|
|
169
|
+
.filter(([, entry]) => entry !== undefined)
|
|
170
|
+
.map(([key, entry]) => [
|
|
171
|
+
key,
|
|
172
|
+
entry !== null && typeof entry === 'object' && !Array.isArray(entry)
|
|
173
|
+
? deepWithoutUndefined(entry as object)
|
|
174
|
+
: entry,
|
|
175
|
+
]),
|
|
176
|
+
) as Partial<T>;
|
|
177
|
+
}
|