@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.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. 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
+ }