@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,492 @@
1
+ import { createSign } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import {
5
+ assertValidSecretRef,
6
+ isValidSecretRef,
7
+ type SecretRef,
8
+ type SecretStore,
9
+ } from '#secrets';
10
+
11
+ /**
12
+ * Google Secret Manager credential store — the `cloud` target's adapter.
13
+ *
14
+ * Over REST with `fetch`, and with no dependency, following the precedent set
15
+ * when Gmail and Drive moved to their REST APIs: the client library exists to
16
+ * wrap an HTTP call this file makes in four lines, and it would pull a
17
+ * dependency tree into a process that holds live refresh tokens. `bunfig.toml`
18
+ * imposes a seven-day release-age floor, so the library would also have to be
19
+ * a week old before a deploy could use it.
20
+ *
21
+ * What this adapter provides that the file adapter cannot: the deployed target
22
+ * scales to zero and has no persistent disk, so a credential has to outlive the
23
+ * container. Encryption at rest is Google's, and the same limitation the file
24
+ * adapter states applies here too — a credential in use is plaintext in memory.
25
+ *
26
+ * IAM, not code, is the boundary, and every grant a revision gets is bound to a
27
+ * named secret: `roles/secretmanager.secretAccessor` on each ref it reads, and
28
+ * `roles/secretmanager.secretVersionAdder` on each one it rotates — the vault
29
+ * document (ADR-022) and the OAuth tokens it refreshes while serving (ADR-026).
30
+ * `secrets.create` is the half that stays with the operator, because it is
31
+ * project-level: an instance holding it could mint credential references of its
32
+ * own, and that is the line, not writing.
33
+ *
34
+ * Read used to be project-level too, on the argument that `secrets.create` was
35
+ * the only line worth drawing. The argument holds and the grant still did not:
36
+ * a deploy can be pointed at a project that holds other things, and nothing on
37
+ * the serving path ever needed the reach — reads go by explicit ref, `list()` is
38
+ * a CLI call, and `secretAccessor` never carried `secrets.list` anyway. The set
39
+ * is derived at deploy time by `readableRefs`.
40
+ *
41
+ * `set` is written around that split — see its own note.
42
+ */
43
+
44
+ const API = 'https://secretmanager.googleapis.com/v1';
45
+ const METADATA_TOKEN_URL =
46
+ 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token';
47
+ const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
48
+ const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
49
+
50
+ /**
51
+ * `/` is legal in a `SecretRef` and illegal in a Secret Manager id, which
52
+ * allows only `[A-Za-z0-9_-]`. `__` reads well and keeps `gmail/main` legible
53
+ * in the console as `gmail__main`.
54
+ *
55
+ * The encoding is confined to this file. Nothing outside the adapter ever sees
56
+ * a mangled reference, and `list()` hands back real refs.
57
+ */
58
+ const SEPARATOR = '__';
59
+
60
+ /** Secret Manager's own constraint on an id, checked before the round trip. */
61
+ const SECRET_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/;
62
+
63
+ export function encodeRef(ref: SecretRef): string {
64
+ assertValidSecretRef(ref);
65
+ const id = ref.replaceAll('/', SEPARATOR);
66
+
67
+ // Verified by round trip rather than by a rule about underscores, because
68
+ // the rule has more cases than it looks like: `a/b__c` and `a/b_/c` both
69
+ // encode to something that decodes back to a *different* reference. Refusing
70
+ // is right — the alternative is two credentials silently sharing one secret.
71
+ if (decodeRef(id) !== ref) {
72
+ throw new Error(
73
+ `Credential reference ${JSON.stringify(ref)} cannot be stored in Secret Manager: ` +
74
+ `"${SEPARATOR}" separates its segments, so a segment may not contain "${SEPARATOR}" ` +
75
+ 'or end in "_". Rename the reference.',
76
+ );
77
+ }
78
+ if (!SECRET_ID_PATTERN.test(id)) {
79
+ throw new Error(
80
+ `Credential reference ${JSON.stringify(ref)} encodes to ${JSON.stringify(id)}, ` +
81
+ 'which is not a valid Secret Manager id ([A-Za-z0-9_-], 255 characters).',
82
+ );
83
+ }
84
+ return id;
85
+ }
86
+
87
+ export function decodeRef(secretId: string): string {
88
+ return secretId.split(SEPARATOR).join('/');
89
+ }
90
+
91
+ /** How the adapter gets an OAuth access token for the Secret Manager API. */
92
+ export interface AccessTokenSource {
93
+ token(): Promise<string>;
94
+ }
95
+
96
+ export interface GcpSecretManagerOptions {
97
+ /** The Google Cloud project holding the secrets. */
98
+ readonly project: string;
99
+ /**
100
+ * Where the token comes from. Defaults to Application Default Credentials:
101
+ * `GOOGLE_ACCESS_TOKEN`, then `GOOGLE_APPLICATION_CREDENTIALS`, then the
102
+ * gcloud well-known file, then the metadata server.
103
+ */
104
+ readonly tokens?: AccessTokenSource;
105
+ readonly fetch?: typeof globalThis.fetch;
106
+ readonly env?: Record<string, string | undefined>;
107
+ }
108
+
109
+ export class GcpSecretManagerStore implements SecretStore {
110
+ readonly #project: string;
111
+ readonly #tokens: AccessTokenSource;
112
+ readonly #fetch: typeof globalThis.fetch;
113
+
114
+ constructor(options: GcpSecretManagerOptions) {
115
+ if (!options.project) {
116
+ throw new Error(
117
+ 'The gcp-secret-manager credential adapter needs a project. ' +
118
+ 'Set `credentials.project` on the target.',
119
+ );
120
+ }
121
+ this.#project = options.project;
122
+ this.#fetch = options.fetch ?? globalThis.fetch;
123
+ this.#tokens =
124
+ options.tokens ??
125
+ new ApplicationDefaultCredentials({
126
+ ...(options.fetch ? { fetch: options.fetch } : {}),
127
+ ...(options.env ? { env: options.env } : {}),
128
+ });
129
+ }
130
+
131
+ async get(ref: SecretRef): Promise<string | null> {
132
+ const response = await this.#call(
133
+ 'GET',
134
+ `/projects/${this.#project}/secrets/${encodeRef(ref)}/versions/latest:access`,
135
+ );
136
+
137
+ // A ref with no secret, and a ref whose secret has no live version, are the
138
+ // same answer to the caller: nothing is stored.
139
+ if (response.status === 404) return null;
140
+ const body = await this.#json<{ payload?: { data?: string } }>(response, `read ${ref}`);
141
+
142
+ const data = body.payload?.data;
143
+ return data === undefined ? null : Buffer.from(data, 'base64').toString('utf8');
144
+ }
145
+
146
+ /**
147
+ * The same read as `get`, with the value dropped.
148
+ *
149
+ * It used to fetch version *metadata* instead, on the reasoning that an
150
+ * existence check should not pull plaintext across the network. That reads
151
+ * well and cost a deployment: metadata needs `secretmanager.versions.get`,
152
+ * and the role a revision is granted — `roles/secretmanager.secretAccessor` —
153
+ * contains exactly `secretmanager.versions.access` and not that. So every
154
+ * deployed instance died on its boot reconcile with a 403 naming a permission
155
+ * nobody had asked for, on the one call that was trying to be frugal.
156
+ *
157
+ * The alternative was granting `roles/secretmanager.viewer` alongside, which
158
+ * would let the revision enumerate every secret in the project — including
159
+ * those of whatever else shares it. Paying one payload read to avoid that is
160
+ * the right way round, and the caller is authorised to read the value anyway:
161
+ * a `has` that returns true is followed by a `get` in every case there is.
162
+ */
163
+ async has(ref: SecretRef): Promise<boolean> {
164
+ return (await this.get(ref)) !== null;
165
+ }
166
+
167
+ /**
168
+ * Add a version, and create the container only when there is none.
169
+ *
170
+ * The order is the whole point, and it used to be the other way round. Create
171
+ * first, tolerate `ALREADY_EXISTS`, then add — which reads as harmless and
172
+ * asks every writer for the permission only a *first* writer needs. Secret
173
+ * Manager checks IAM before existence, so an identity holding
174
+ * `secretVersionAdder` on the secret and nothing else does not get 409 back
175
+ * from that create. It gets 403, and the write fails having been authorised
176
+ * for the only call it was going to make.
177
+ *
178
+ * Which is a deployed revision, on the path that refreshes an OAuth token:
179
+ * reading mail persists a rotated access token, so every read past the first
180
+ * hour died on `secretmanager.secrets.create` — a permission deliberately
181
+ * never granted, because it is project-level and would let a revision mint
182
+ * credential references of its own.
183
+ *
184
+ * The race the old comment describes still cannot break this: two writers
185
+ * both 404, both create, the loser gets `ALREADY_EXISTS`, and both add.
186
+ */
187
+ async set(ref: SecretRef, value: string): Promise<void> {
188
+ const id = encodeRef(ref);
189
+ const version = `/projects/${this.#project}/secrets/${id}:addVersion`;
190
+ const payload = { payload: { data: Buffer.from(value, 'utf8').toString('base64') } };
191
+
192
+ const added = await this.#call('POST', version, payload);
193
+ if (added.status !== 404) {
194
+ await this.#json(added, `write ${ref}`, ref);
195
+ return;
196
+ }
197
+
198
+ const created = await this.#call(
199
+ 'POST',
200
+ `/projects/${this.#project}/secrets?secretId=${encodeURIComponent(id)}`,
201
+ { replication: { automatic: {} } },
202
+ );
203
+ if (!created.ok && created.status !== 409) {
204
+ await this.#json(created, `create secret for ${ref}`, ref);
205
+ }
206
+
207
+ await this.#json(await this.#call('POST', version, payload), `write ${ref}`, ref);
208
+ }
209
+
210
+ async delete(ref: SecretRef): Promise<void> {
211
+ // The whole secret, not the latest version: a version-level delete would
212
+ // leave `list` reporting a ref that `get` cannot read.
213
+ const response = await this.#call(
214
+ 'DELETE',
215
+ `/projects/${this.#project}/secrets/${encodeRef(ref)}`,
216
+ );
217
+ if (response.status === 404) return; // Deleting what is not there is a no-op.
218
+ await this.#json(response, `delete ${ref}`);
219
+ }
220
+
221
+ async list(prefix?: string): Promise<SecretRef[]> {
222
+ const refs: SecretRef[] = [];
223
+ let pageToken: string | undefined;
224
+
225
+ do {
226
+ const query = new URLSearchParams({ pageSize: '100' });
227
+ if (pageToken) query.set('pageToken', pageToken);
228
+
229
+ const response = await this.#call('GET', `/projects/${this.#project}/secrets?${query}`);
230
+ const body = await this.#json<{
231
+ secrets?: { name?: string }[];
232
+ nextPageToken?: string;
233
+ }>(response, 'list secrets');
234
+
235
+ for (const secret of body.secrets ?? []) {
236
+ const id = secret.name?.split('/').pop();
237
+ if (!id) continue;
238
+
239
+ const ref = decodeRef(id);
240
+ // A project may hold secrets this system did not write — a Cloud Build
241
+ // key, another app's token. Anything that does not decode to a valid
242
+ // reference is not ours, and is skipped rather than reported.
243
+ if (!isValidSecretRef(ref)) continue;
244
+ if (prefix && !ref.startsWith(prefix)) continue;
245
+ refs.push(ref);
246
+ }
247
+
248
+ pageToken = body.nextPageToken;
249
+ } while (pageToken);
250
+
251
+ return refs.sort();
252
+ }
253
+
254
+ async #call(method: string, path: string, body?: unknown): Promise<Response> {
255
+ const token = await this.#tokens.token();
256
+ return this.#fetch(`${API}${path}`, {
257
+ method,
258
+ headers: {
259
+ authorization: `Bearer ${token}`,
260
+ accept: 'application/json',
261
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
262
+ },
263
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Parse a response, or fail with what Google actually said.
269
+ *
270
+ * A 403 here is nearly always a missing IAM role, and the API says which
271
+ * permission was denied. Swallowing that in favour of "request failed" turns
272
+ * a one-line fix into an afternoon.
273
+ */
274
+ async #json<T>(response: Response, action: string, ref?: SecretRef): Promise<T> {
275
+ const text = await response.text();
276
+
277
+ if (!response.ok) {
278
+ let detail = text.slice(0, 400);
279
+ try {
280
+ const parsed = JSON.parse(text) as { error?: { message?: string; status?: string } };
281
+ if (parsed.error?.message) {
282
+ detail = parsed.error.status
283
+ ? `${parsed.error.status}: ${parsed.error.message}`
284
+ : parsed.error.message;
285
+ }
286
+ } catch {
287
+ // Not JSON — an HTML error page from a proxy, most likely. Keep it raw.
288
+ }
289
+ const hint = response.status === 403 && ref ? grantHint(detail, ref) : '';
290
+ throw new Error(
291
+ `Secret Manager could not ${action} (HTTP ${response.status}). ${detail}${hint}`,
292
+ );
293
+ }
294
+
295
+ return text ? (JSON.parse(text) as T) : ({} as T);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The half of a denied write Google cannot know.
301
+ *
302
+ * It names the permission, which is exact and is not an instruction. This
303
+ * surfaces to an agent holding no cloud context at all — a refused token
304
+ * rotation reaches whoever asked to read their mail — so the message has to
305
+ * carry the command rather than the IAM vocabulary that fixes it.
306
+ *
307
+ * Both cases are one situation seen from either side: a credential the revision
308
+ * was never bound to, which is what a connection made since the last deploy
309
+ * looks like.
310
+ */
311
+ function grantHint(detail: string, ref: SecretRef): string {
312
+ if (detail.includes('versions.add')) {
313
+ return (
314
+ ` This identity may read "${ref}" and not rotate it. A deployment binds that per secret,` +
315
+ ' so a connection made since the last one has no binding yet: run `lanes link deploy`.'
316
+ );
317
+ }
318
+ if (detail.includes('secrets.create')) {
319
+ return (
320
+ ` Nothing is stored at "${ref}" yet, and creating a credential reference is the operator's` +
321
+ ' to do, never a running instance\'s: run `lanes link deploy`, or `lanes link secrets push`' +
322
+ ' from a target that holds it.'
323
+ );
324
+ }
325
+ return '';
326
+ }
327
+
328
+ interface CachedToken {
329
+ readonly value: string;
330
+ readonly expiresAt: number;
331
+ }
332
+
333
+ /**
334
+ * Application Default Credentials, the subset that matters here.
335
+ *
336
+ * Resolution order is Google's own, so a machine already set up for `gcloud`
337
+ * needs no configuration:
338
+ *
339
+ * 1. `GOOGLE_ACCESS_TOKEN` — an explicit override, and what CI usually has.
340
+ * 2. `GOOGLE_APPLICATION_CREDENTIALS` — a service account key file.
341
+ * 3. `~/.config/gcloud/application_default_credentials.json` — `gcloud auth
342
+ * application-default login`.
343
+ * 4. The metadata server — Cloud Run, and every other GCP compute surface.
344
+ *
345
+ * Workload identity federation and impersonation are not implemented. They
346
+ * would each be a further exchange, and neither is reachable from the two
347
+ * places this runs: an operator's laptop and Cloud Run.
348
+ */
349
+ export class ApplicationDefaultCredentials implements AccessTokenSource {
350
+ readonly #fetch: typeof globalThis.fetch;
351
+ readonly #env: Record<string, string | undefined>;
352
+ #cached: CachedToken | undefined;
353
+
354
+ constructor(
355
+ options: { fetch?: typeof globalThis.fetch; env?: Record<string, string | undefined> } = {},
356
+ ) {
357
+ this.#fetch = options.fetch ?? globalThis.fetch;
358
+ this.#env = options.env ?? (process.env as Record<string, string | undefined>);
359
+ }
360
+
361
+ async token(): Promise<string> {
362
+ const override = this.#env['GOOGLE_ACCESS_TOKEN'];
363
+ if (override) return override;
364
+
365
+ // Refreshed a minute early: a token that expires mid-request fails the
366
+ // request, and the retry would land in the same window.
367
+ if (this.#cached && this.#cached.expiresAt > Date.now() + 60_000) return this.#cached.value;
368
+
369
+ const fresh = await this.#mint();
370
+ this.#cached = fresh;
371
+ return fresh.value;
372
+ }
373
+
374
+ async #mint(): Promise<CachedToken> {
375
+ const keyFile = this.#env['GOOGLE_APPLICATION_CREDENTIALS'];
376
+ if (keyFile) return this.#fromKeyFile(keyFile);
377
+
378
+ const wellKnown = join(
379
+ this.#env['CLOUDSDK_CONFIG'] ?? join(homedir(), '.config', 'gcloud'),
380
+ 'application_default_credentials.json',
381
+ );
382
+ if (await Bun.file(wellKnown).exists()) return this.#fromKeyFile(wellKnown);
383
+
384
+ return this.#fromMetadataServer();
385
+ }
386
+
387
+ async #fromKeyFile(path: string): Promise<CachedToken> {
388
+ let key: Record<string, string>;
389
+ try {
390
+ key = (await Bun.file(path).json()) as Record<string, string>;
391
+ } catch (error) {
392
+ throw new Error(`Could not read Google credentials from ${path}: ${(error as Error).message}`);
393
+ }
394
+
395
+ if (key['type'] === 'authorized_user') {
396
+ return this.#exchange(new URLSearchParams({
397
+ grant_type: 'refresh_token',
398
+ client_id: key['client_id'] ?? '',
399
+ client_secret: key['client_secret'] ?? '',
400
+ refresh_token: key['refresh_token'] ?? '',
401
+ }), path);
402
+ }
403
+
404
+ if (key['type'] === 'service_account') {
405
+ return this.#exchange(new URLSearchParams({
406
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
407
+ assertion: signJwtAssertion(key),
408
+ }), path);
409
+ }
410
+
411
+ throw new Error(
412
+ `${path}: unsupported Google credential type ${JSON.stringify(key['type'] ?? 'unknown')}. ` +
413
+ 'Expected "authorized_user" or "service_account".',
414
+ );
415
+ }
416
+
417
+ async #exchange(body: URLSearchParams, source: string): Promise<CachedToken> {
418
+ const response = await this.#fetch(OAUTH_TOKEN_URL, {
419
+ method: 'POST',
420
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
421
+ body,
422
+ });
423
+
424
+ const text = await response.text();
425
+ if (!response.ok) {
426
+ throw new Error(`Could not exchange the credentials in ${source} for a token: ${text.slice(0, 300)}`);
427
+ }
428
+ return toCachedToken(JSON.parse(text) as { access_token?: string; expires_in?: number }, source);
429
+ }
430
+
431
+ async #fromMetadataServer(): Promise<CachedToken> {
432
+ let response: Response;
433
+ try {
434
+ response = await this.#fetch(METADATA_TOKEN_URL, {
435
+ headers: { 'metadata-flavor': 'Google' },
436
+ signal: AbortSignal.timeout(3_000),
437
+ });
438
+ } catch {
439
+ // The last stop in the chain, so this is where "no credentials at all"
440
+ // surfaces — say what to do rather than reporting a DNS failure for a
441
+ // hostname the operator has never heard of.
442
+ throw new Error(
443
+ 'No Google credentials found. On Cloud Run the metadata server supplies them; ' +
444
+ 'locally run `gcloud auth application-default login`, or set ' +
445
+ 'GOOGLE_APPLICATION_CREDENTIALS to a service account key, or GOOGLE_ACCESS_TOKEN directly.',
446
+ );
447
+ }
448
+
449
+ if (!response.ok) {
450
+ throw new Error(
451
+ `The metadata server refused a token (HTTP ${response.status}). ` +
452
+ 'Check the service account attached to this revision.',
453
+ );
454
+ }
455
+ return toCachedToken(
456
+ (await response.json()) as { access_token?: string; expires_in?: number },
457
+ 'the metadata server',
458
+ );
459
+ }
460
+ }
461
+
462
+ function toCachedToken(
463
+ body: { access_token?: string; expires_in?: number },
464
+ source: string,
465
+ ): CachedToken {
466
+ if (!body.access_token) throw new Error(`${source} returned no access token`);
467
+ return {
468
+ value: body.access_token,
469
+ expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
470
+ };
471
+ }
472
+
473
+ /** The signed assertion a service account key trades for an access token. */
474
+ function signJwtAssertion(key: Record<string, string>): string {
475
+ const issuedAt = Math.floor(Date.now() / 1000);
476
+ const encode = (value: unknown): string =>
477
+ Buffer.from(JSON.stringify(value)).toString('base64url');
478
+
479
+ const body = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({
480
+ iss: key['client_email'],
481
+ scope: CLOUD_PLATFORM_SCOPE,
482
+ aud: key['token_uri'] ?? OAUTH_TOKEN_URL,
483
+ iat: issuedAt,
484
+ exp: issuedAt + 3600,
485
+ })}`;
486
+
487
+ const signature = createSign('RSA-SHA256')
488
+ .update(body)
489
+ .sign(key['private_key'] ?? '', 'base64url');
490
+
491
+ return `${body}.${signature}`;
492
+ }
@@ -0,0 +1,191 @@
1
+ import { containedKey, type BlobKey, type BlobMetadata, type BlobStore } from '#stores/blobs';
2
+ import { ApplicationDefaultCredentials, type AccessTokenSource } from './gcp-secret-manager.ts';
3
+ import { s3ObjectKey, s3Prefix } from './s3.ts';
4
+
5
+ /**
6
+ * Google Cloud Storage, over its JSON API.
7
+ *
8
+ * **Why this exists beside `s3.ts`, which GCS can also speak.** The S3
9
+ * interoperability API authenticates with HMAC keys, and those have to be
10
+ * created by hand in the console, stored as two credential refs, and rotated
11
+ * separately from everything else. This one authenticates as the service
12
+ * account the deploy already created and already granted `objectAdmin` on the
13
+ * bucket — so the bucket needs no credential of its own, and setting it up is
14
+ * a command rather than a console visit. Setup cost is the thing being
15
+ * optimised; the adapter is 200 lines either way.
16
+ *
17
+ * `s3.ts` stays, and stays the answer for R2, MinIO, Supabase Storage, and
18
+ * AWS. This is the GCP-shaped shortcut, not a replacement.
19
+ *
20
+ * Vendor-named, like `gcp-secret-manager.ts` beside it, because it speaks
21
+ * Google's own API rather than a protocol anyone else implements. ADR-013's
22
+ * rule is that an *adapter for a protocol* takes the protocol's name; a client
23
+ * for one vendor's API cannot honestly claim one. `src/architecture.test.ts`
24
+ * scopes the vendor ban to the code a request passes through, not to
25
+ * `deployments/`.
26
+ *
27
+ * `fetch` and no client library, the same as the Secret Manager adapter: a
28
+ * repository holding live refresh tokens does not add a transitive dependency
29
+ * tree to save a hundred lines of URL building.
30
+ */
31
+
32
+ /**
33
+ * Declared here rather than imported from `#auth`, which has the same type:
34
+ * `deployments` may not import `auth` (`src/architecture.test.ts`), and a
35
+ * one-line structural type is a smaller cost than a dependency pointing the
36
+ * wrong way. It exists at all because `typeof globalThis.fetch` under Bun's
37
+ * types also carries `preconnect`, so a test double would have to stub a
38
+ * method nothing calls.
39
+ */
40
+ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
41
+
42
+ const STORAGE_HOST = 'https://storage.googleapis.com';
43
+
44
+ /** GCS returns at most this many items per list page whatever `maxResults` says. */
45
+ const PAGE_SIZE = 1000;
46
+
47
+ export interface GcsBlobStoreOptions {
48
+ readonly bucket: string;
49
+ /**
50
+ * A key prefix inside the bucket — the bucket-relative equivalent of the
51
+ * filesystem adapter's root, so two profiles sharing one bucket under
52
+ * different prefixes do not see each other's keys.
53
+ */
54
+ readonly prefix?: string;
55
+ readonly tokens?: AccessTokenSource;
56
+ readonly fetch?: FetchLike;
57
+ }
58
+
59
+ export function createGcsBlobStore(options: GcsBlobStoreOptions): BlobStore {
60
+ const call: FetchLike = options.fetch ?? globalThis.fetch;
61
+ const tokens = options.tokens ?? new ApplicationDefaultCredentials();
62
+ const prefix = s3Prefix(options.prefix);
63
+ const bucket = encodeURIComponent(options.bucket);
64
+
65
+ // Containment and prefixing are shared with `s3.ts` rather than reimplemented:
66
+ // `#stores/blobs/conformance.ts` asserts that a key one target refuses is not
67
+ // one another accepts, and that can only hold if there is one rule.
68
+ const objectKey = (key: BlobKey): string => s3ObjectKey(options.prefix, key);
69
+
70
+ const authorized = async (init: RequestInit = {}): Promise<RequestInit> => ({
71
+ ...init,
72
+ headers: { ...init.headers, authorization: `Bearer ${await tokens.token()}` },
73
+ });
74
+
75
+ const request = async (url: string, init: RequestInit = {}): Promise<Response> =>
76
+ call(url, await authorized(init));
77
+
78
+ return {
79
+ async put(key, data, putOptions) {
80
+ // `uploadType=media` is the single-request upload: the whole body is the
81
+ // object. Resumable uploads matter above a few megabytes, and nothing
82
+ // here writes one — a memory entry is a Markdown file and an audit event
83
+ // is a few hundred bytes.
84
+ const url =
85
+ `${STORAGE_HOST}/upload/storage/v1/b/${bucket}/o` +
86
+ `?uploadType=media&name=${encodeURIComponent(objectKey(key))}`;
87
+
88
+ const response = await request(url, {
89
+ method: 'POST',
90
+ body: data,
91
+ headers: {
92
+ 'content-type': putOptions?.contentType ?? 'application/octet-stream',
93
+ },
94
+ });
95
+ if (!response.ok) throw await failure('write', key, response);
96
+ },
97
+
98
+ async get(key) {
99
+ const response = await request(objectUrl(bucket, objectKey(key), '?alt=media'));
100
+ // Absence is a value, not an error: `get` returns null and every caller
101
+ // is written against that.
102
+ if (response.status === 404) return null;
103
+ if (!response.ok) throw await failure('read', key, response);
104
+
105
+ return new Uint8Array(await response.arrayBuffer());
106
+ },
107
+
108
+ async has(key) {
109
+ // Metadata only — no `alt=media`, so this does not pull the body back
110
+ // just to answer a yes-or-no.
111
+ const response = await request(objectUrl(bucket, objectKey(key)));
112
+ if (response.status === 404) return false;
113
+ if (!response.ok) throw await failure('stat', key, response);
114
+ return true;
115
+ },
116
+
117
+ async delete(key) {
118
+ const response = await request(objectUrl(bucket, objectKey(key)), { method: 'DELETE' });
119
+ // Deleting what is not there is not a failure. Callers use this to make
120
+ // absence true, and a sweep that raced another sweep must not throw.
121
+ if (response.status === 404) return;
122
+ if (!response.ok) throw await failure('delete', key, response);
123
+ },
124
+
125
+ async list(innerPrefix) {
126
+ const found: BlobMetadata[] = [];
127
+ let pageToken: string | undefined;
128
+
129
+ do {
130
+ const query = new URLSearchParams({
131
+ prefix: `${prefix}${innerPrefix ?? ''}`,
132
+ maxResults: String(PAGE_SIZE),
133
+ });
134
+ if (pageToken) query.set('pageToken', pageToken);
135
+
136
+ const response = await request(`${STORAGE_HOST}/storage/v1/b/${bucket}/o?${query}`);
137
+ if (!response.ok) throw await failure('list', innerPrefix ?? '', response);
138
+
139
+ const page = (await response.json()) as {
140
+ items?: Array<{ name: string; size?: string; contentType?: string; updated?: string }>;
141
+ nextPageToken?: string;
142
+ };
143
+
144
+ for (const item of page.items ?? []) {
145
+ found.push({
146
+ key: item.name.slice(prefix.length),
147
+ size: Number(item.size ?? 0),
148
+ ...(item.contentType ? { contentType: item.contentType } : {}),
149
+ modifiedAt: item.updated ? new Date(item.updated) : new Date(0),
150
+ });
151
+ }
152
+
153
+ pageToken = page.nextPageToken;
154
+ } while (pageToken !== undefined);
155
+
156
+ return found;
157
+ },
158
+ };
159
+ }
160
+
161
+ function objectUrl(bucket: string, key: string, suffix = ''): string {
162
+ // The object name is one path segment with its slashes escaped: GCS
163
+ // addresses `a/b.json` as the single name `a%2Fb.json`, and leaving the
164
+ // slash unescaped addresses a different resource that answers 404.
165
+ return `${STORAGE_HOST}/storage/v1/b/${bucket}/o/${encodeURIComponent(key)}${suffix}`;
166
+ }
167
+
168
+ /**
169
+ * A failure that names the operation and the key.
170
+ *
171
+ * `403` is the one worth spelling out: it is what an operator hits when the
172
+ * bucket exists but the service account was never granted `objectAdmin` on it,
173
+ * and the raw message ("does not have storage.objects.create access") does not
174
+ * say which identity was refused or where to fix it.
175
+ */
176
+ async function failure(operation: string, key: string, response: Response): Promise<Error> {
177
+ const body = await response.text().catch(() => '');
178
+ const detail = body.slice(0, 400);
179
+
180
+ if (response.status === 403) {
181
+ return new Error(
182
+ `GCS refused to ${operation} "${key}" (403). The identity this endpoint runs as is not ` +
183
+ 'granted roles/storage.objectAdmin on the bucket. `lanes link deploy` grants it to the ' +
184
+ `service account it creates; a local run uses your own gcloud credentials. ${detail}`,
185
+ );
186
+ }
187
+ return new Error(`GCS failed to ${operation} "${key}" (${response.status}). ${detail}`);
188
+ }
189
+
190
+ /** Re-exported so a caller can validate a key without reaching into `#stores/blobs`. */
191
+ export { containedKey };