@lanes-sh/link 0.6.2 → 0.6.4

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/.dockerignore ADDED
@@ -0,0 +1,45 @@
1
+ # The build context for src/deployments/gcp/Dockerfile is this directory.
2
+ #
3
+ # The first block is a security control, not an image-size optimisation. `data/`
4
+ # holds the encrypted credential store *and* the key that opens it, and `*.key`
5
+ # catches the same file wherever else it lands. A credential baked into an image
6
+ # is pushed to a registry, cached on every builder that touched it, and readable
7
+ # by anyone who can pull the tag — and the deployed target does not want them
8
+ # anyway, because it reads credentials from Secret Manager.
9
+ data/
10
+ *.key
11
+ *.pem
12
+ *.enc
13
+ *.p12
14
+ .env
15
+ .env.*
16
+
17
+ # Not needed at runtime, and `.git` in particular carries every branch.
18
+ .git/
19
+ .gitignore
20
+ .worktrees/
21
+ node_modules/
22
+ **/node_modules/
23
+ coverage/
24
+ dist/
25
+ build/
26
+ *.tsbuildinfo
27
+
28
+ # Tests, docs, and tooling: the image runs the endpoint and nothing else.
29
+ #
30
+ # `instructions/` is what the CLI installs into a client — a skill and an agent
31
+ # definition. The endpoint describes itself from code (`server/mcp/instructions.ts`)
32
+ # precisely so a deployed revision does not need these files.
33
+ **/*.test.ts
34
+ **/*.test.json
35
+ docs/
36
+ instructions/
37
+ **/README.md
38
+ .lanes/
39
+ .claude/
40
+ .vscode/
41
+ .idea/
42
+ .DS_Store
43
+
44
+ # The compiled binary from `bun build --compile`, if one was made locally.
45
+ /lanes
package/README.md CHANGED
@@ -69,6 +69,20 @@ Your agents can now use it. Memory, tasks, files, skills, and the vault hold you
69
69
  rather than an account, so they are already there — nothing to connect, no credentials, no browser.
70
70
  Mail and calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
71
71
 
72
+ ## In the Lanes desktop app
73
+
74
+ Prefer not to use a terminal? The [Lanes desktop app](https://lanes.sh/desktop) drives this CLI from
75
+ a settings page. **Settings → Integrations → Lanes Link** installs it, holds the profile and target
76
+ every command runs against, connects your accounts, starts and stops the endpoint, and registers it
77
+ with Claude Code or Codex.
78
+
79
+ ![The Lanes Link page in the Lanes desktop app: the CLI status card and its version, the target and profile selectors, the endpoint row with its running state, and the list of connected accounts.](docs/images/lanes-link-desktop.png)
80
+
81
+ It runs the commands above rather than reimplementing them, so consent and the token stay here where
82
+ they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
83
+ Lanes v0.47.0, as a research preview.
84
+ **[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
85
+
72
86
  ## What your agent gets
73
87
 
74
88
  | | | Manage it with |
@@ -166,6 +180,7 @@ than being rebuilt.
166
180
  ## Docs
167
181
 
168
182
  - **[Quickstart](docs/quickstart.md)** — from nothing to a working endpoint
183
+ - **[In the Lanes desktop app](https://lanes.sh/docs/desktop/lanes-link)** — the page that drives it
169
184
  - **[Connect your accounts](docs/connect.md)** — every provider, and what each one needs
170
185
  - **[Add it to your agent](docs/clients.md)** — Claude Code, Codex, Claude Desktop, claude.ai, ChatGPT
171
186
  - **[Deploy to your own cloud](docs/deploy.md)** — five commands to a URL
package/bunfig.toml ADDED
@@ -0,0 +1,17 @@
1
+ [install]
2
+ # Supply chain: this repository holds live OAuth refresh tokens, so a
3
+ # dependency compromise is a live threat. The common attack is to publish a
4
+ # compromised version and yank it within hours; a release-age floor keeps a
5
+ # version that young out of the lockfile entirely.
6
+ #
7
+ # 604800 seconds = 7 days.
8
+ #
9
+ # An urgent security fix can be pulled in ahead of the window by installing an
10
+ # exact version explicitly, or by adding the package to minimumReleaseAgeExcludes.
11
+ minimumReleaseAge = 604800
12
+ minimumReleaseAgeExcludes = []
13
+
14
+ # Pin exact versions rather than ranges, so the lockfile is the only thing
15
+ # that decides what gets installed. Bun does not run dependency lifecycle
16
+ # scripts unless a package is listed in trustedDependencies; keep that empty.
17
+ exact = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -42,7 +42,9 @@
42
42
  "!src/**/*.test.ts",
43
43
  "instructions",
44
44
  "README.md",
45
- "LICENSE"
45
+ "LICENSE",
46
+ "bunfig.toml",
47
+ ".dockerignore"
46
48
  ],
47
49
  "scripts": {
48
50
  "test": "bun test",
@@ -0,0 +1,48 @@
1
+ import { bindConnectionCredentials, type BindOutcome } from '#deployments/bind.ts';
2
+ import type { Runtime } from '../../runtime.ts';
3
+
4
+ /**
5
+ * Step 7 of `connect`: bind the credential to the revision that will serve it.
6
+ *
7
+ * Split out for the reason every other step in this directory was — `index.ts`
8
+ * holds the order, not the substance, and the file has a size budget
9
+ * (`architecture.test.ts`) that exists to keep it that way.
10
+ *
11
+ * What it is for is in `deployments/bind.ts`. Two decisions belong here.
12
+ *
13
+ * **It is called ahead of `connect`'s early return, not after it.** Re-running
14
+ * `connect` against an existing connection is what an operator does to repair
15
+ * one, and that run reaches the end with no config changes to make. Binding
16
+ * after the return would skip the repair path, leaving the command that looks
17
+ * like the fix doing nothing about the actual fault.
18
+ *
19
+ * **A failure is a note, never a throw.** The credential is in the store and the
20
+ * config is about to be saved by the time this runs, so failing the command
21
+ * would report "connect failed" about a connect that happened — and connecting
22
+ * an account against a deployed target from a machine that has never had
23
+ * `gcloud` installed has to keep working.
24
+ *
25
+ * The third decision is which connection to bind. The declared row is
26
+ * preferred over one assembled from the arguments, because an operator may have
27
+ * written a `credential_ref` into the profile by hand and that field decides
28
+ * where a `local` provider's credential lives — binding the derived ref instead
29
+ * would grant a secret nobody writes and leave the written one unbound, which is
30
+ * the failure `rotatableCredentialRefsFor` documents from the other direction.
31
+ */
32
+ export function bindNewCredential(
33
+ runtime: Runtime,
34
+ providerId: string,
35
+ connectionId: string,
36
+ account: string,
37
+ ): Promise<BindOutcome> {
38
+ const declared = runtime.config.connections.find(
39
+ (candidate) => candidate.provider === providerId && candidate.id === connectionId,
40
+ );
41
+
42
+ return bindConnectionCredentials({
43
+ deploy: runtime.declared.deploy,
44
+ target: runtime.target,
45
+ connection: declared ?? { provider: providerId, id: connectionId, account },
46
+ manifest: runtime.manifestFor(providerId),
47
+ });
48
+ }
@@ -16,6 +16,7 @@ import { chooseAuthMethod } from './method.ts';
16
16
  import { preflight } from './requirements.ts';
17
17
  import { ALREADY, NOTHING, renderOutcome, where, type ConnectOutcome } from './outcome.ts';
18
18
  import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
19
+ import { bindNewCredential } from './bind-credential.ts';
19
20
  import { ensureStaticCredential } from './setup.ts';
20
21
  import { settleIdentity } from './settle.ts';
21
22
  import { runStrategySetup } from './strategy.ts';
@@ -356,6 +357,9 @@ export async function runConnect(
356
357
  ? ['that is your own memory, tasks, assets, skills and vault — no account, nothing stored until you use them']
357
358
  : [];
358
359
 
360
+ // 7. Bind the credential to the revision that serves it — `bind-credential.ts`.
361
+ const bound = await bindNewCredential(runtime, providerId, connectionId, account);
362
+ if (bound.failed) notes.push(bound.failed);
359
363
  if (changes.length === 0 && granted.length === 0) {
360
364
  return {
361
365
  ...NOTHING,
@@ -364,6 +368,7 @@ export async function runConnect(
364
368
  account,
365
369
  label,
366
370
  ...where(runtime),
371
+ ...(notes.length > 0 ? { notes } : {}),
367
372
  discovered: discovered.length,
368
373
  next: ALREADY,
369
374
  };
@@ -4,6 +4,7 @@ import { announce, announceProfile, emit, fail, ok, print, warn } from '../../ou
4
4
  import { staleNudge } from '../../release.ts';
5
5
  import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
6
6
  import type { FetchLike } from '#deployments/knowledge.ts';
7
+ import { unboundRotatableRefs } from '#deployments/bind.ts';
7
8
  import { credentialAge, reportCapabilityDrift } from './findings.ts';
8
9
  import { migratedContract, migratedRenamedProviders } from './migrate.ts';
9
10
 
@@ -276,6 +277,37 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
276
277
  });
277
278
  }
278
279
 
280
+ // Whether the revision can still rewrite what it serves.
281
+ //
282
+ // A *problem*, not a warning: an unbound credential is a connection that
283
+ // works until its access token expires and then stops, and every other
284
+ // report on this machine calls it healthy in the meantime — `status` and
285
+ // `setup_overview` read the state store, and the state store knows nothing
286
+ // about IAM. This is the only place that asks the thing that decides.
287
+ //
288
+ // Skipped entirely for a local target, where credentials are a file this
289
+ // process owns and there is no revision to grant anything to.
290
+ const rotation = await unboundRotatableRefs({
291
+ deploy: runtime.declared.deploy,
292
+ target: runtime.target,
293
+ connections: runtime.config.connections,
294
+ manifestFor: runtime.manifestFor,
295
+ });
296
+ if (rotation.unbound.length > 0) {
297
+ problems.push({
298
+ kind: 'unbound_credentials',
299
+ message:
300
+ `the deployed endpoint can read ${rotation.unbound.join(', ')} but not rotate ` +
301
+ `${rotation.unbound.length === 1 ? 'it' : 'them'} — so ${rotation.unbound.length === 1 ? 'that connection' : 'those connections'} ` +
302
+ 'will fail about an hour after each use, when the token refresh tries to persist. ' +
303
+ 'A connection made since the last deploy is the usual cause',
304
+ fix: forSelection('lanes link deploy'),
305
+ });
306
+ }
307
+ if (rotation.unavailable) {
308
+ warnings.push({ kind: 'rotation_uncheckable', message: rotation.unavailable });
309
+ }
310
+
279
311
  if (problems.length > 0) process.exitCode = 1;
280
312
 
281
313
  return emit(flags.json, { ok: problems.length === 0, checks, warnings, problems }, () => {
@@ -0,0 +1,214 @@
1
+ import type { ConnectionConfig, DeployConfig } from '#profile';
2
+ import { credentialRefFor, rotatableCredentialRefsFor } from '#registry';
3
+ import type { SecretRef } from '#secrets';
4
+ import type { DeployDriver } from './driver.ts';
5
+ import { driverFor } from './drivers.ts';
6
+ import { secretGrantSteps } from './gcp/provision.ts';
7
+ import { requireProject } from './gcp/gcloud.ts';
8
+ import { encodeRef } from './adapters/gcp-secret-manager.ts';
9
+
10
+ /**
11
+ * Binding one connection's credentials to the revision that will serve them.
12
+ *
13
+ * **The invariant this restores: a revision can rotate every credential it
14
+ * serves.** `provisionSteps` establishes it over the connections the config held
15
+ * *at deploy time*, one secret at a time, which is the right shape — a
16
+ * resource-level grant needs no condition to be scoped. What nothing did was
17
+ * keep it true afterwards. `connect` writes a credential into the same store and
18
+ * binds nothing, so from the next connect until the next deploy the invariant is
19
+ * false and no command says so.
20
+ *
21
+ * The failure it produces is the worst shape available. Read is unaffected, so
22
+ * the connection authorises, answers, and reports `active`; only the *write* on
23
+ * the far side of the first token refresh is denied, roughly an hour later, by
24
+ * which time the connect that caused it is not the recent event. `status` and
25
+ * `setup_overview` both keep saying "connected and reachable" throughout,
26
+ * because both read the state store and the state store knows nothing about IAM.
27
+ *
28
+ * **Every path that writes a credential reaches this one.** `connect custom`
29
+ * delegates to `runConnect` after writing its manifest, and `connectFamily`
30
+ * calls `runConnect` per member, so binding at that one call site covers all
31
+ * three. The reverse direction needs nothing: `disconnect` deletes the whole
32
+ * secret rather than a version, and a secret's IAM policy goes with it, so there
33
+ * is no orphaned binding to revoke.
34
+ *
35
+ * Same steps as the deploy, from the same functions, over one connection's refs.
36
+ * Not a second implementation of the grant: `reconcile.ts` argues that a preview
37
+ * computed differently from the mutation eventually becomes a lie, and the
38
+ * argument is stronger here, because two spellings of a grant do not disagree on
39
+ * screen — they disagree about which permission actually exists.
40
+ */
41
+
42
+ export interface BindOutcome {
43
+ /** Refs the revision can now read and rotate. Empty is a normal result. */
44
+ readonly bound: readonly SecretRef[];
45
+ /**
46
+ * Why nothing was bound, when nothing was and that is fine: a local target, a
47
+ * deployment with no runtime service account, or a provider holding no
48
+ * credential at all.
49
+ */
50
+ readonly skipped?: string | undefined;
51
+ /**
52
+ * A binding that could not be applied, as a sentence for the operator.
53
+ *
54
+ * Carried rather than thrown, and that is deliberate. By the time this runs
55
+ * the credential is already in the store and the config is already saved, so
56
+ * failing the command would report "connect failed" about a connect that
57
+ * happened. It also must not require `gcloud` to be installed: someone can
58
+ * legitimately connect an account against a deployed target from a machine
59
+ * that has never deployed one.
60
+ */
61
+ readonly failed?: string | undefined;
62
+ }
63
+
64
+ const NOTHING_TO_BIND = 'this provider stores no credential';
65
+
66
+ export async function bindConnectionCredentials(input: {
67
+ readonly deploy: DeployConfig | undefined;
68
+ readonly target: string;
69
+ readonly connection: ConnectionConfig;
70
+ /**
71
+ * The provider's manifest, so an omitted `credential_ref` can be derived.
72
+ *
73
+ * Typed off `#registry`'s own signature rather than by importing
74
+ * `ProviderManifest`: `#deployments` may not import `#connectivity`
75
+ * (`architecture.test.ts`), which is the same rule that put `credentialRefFor`
76
+ * in `#registry` in the first place. `prepare.ts` avoids it by never naming
77
+ * the type; this one has to name it, so it derives it.
78
+ */
79
+ readonly manifest: Parameters<typeof rotatableCredentialRefsFor>[1];
80
+ /** Injectable so a test asserts the argv without a cloud project near it. */
81
+ readonly driver?: DeployDriver | undefined;
82
+ }): Promise<BindOutcome> {
83
+ const { deploy, connection, manifest } = input;
84
+ if (!deploy) return { bound: [], skipped: 'this target runs here, not on a platform' };
85
+
86
+ const cloudrun = requireProject(deploy, input.target);
87
+ const serviceAccount = cloudrun.service_account;
88
+ if (!serviceAccount) {
89
+ return { bound: [], skipped: 'this deployment declares no runtime service account' };
90
+ }
91
+
92
+ // Both halves, because a connection made since the last deploy has neither.
93
+ // The read grant is the one an older deployment's project-wide
94
+ // `secretAccessor` happens to cover, which is exactly what made this failure
95
+ // partial and therefore slow to find; a deployment provisioned since that
96
+ // changed has no read on it either.
97
+ const readable = credentialRefFor(connection, manifest);
98
+ const rotatable = rotatableCredentialRefsFor(connection, manifest);
99
+ if (!readable && rotatable.length === 0) return { bound: [], skipped: NOTHING_TO_BIND };
100
+
101
+ const steps = secretGrantSteps({
102
+ project: cloudrun.project,
103
+ serviceAccount,
104
+ readable: readable ? [readable] : [],
105
+ rotatable,
106
+ });
107
+
108
+ const driver = input.driver ?? (await driverFor(deploy.platform));
109
+
110
+ for (const step of steps) {
111
+ const result = await driver.run(step.argv, { quiet: true });
112
+ // `tolerateFailure` on these means "the secret may already exist", which is
113
+ // the create step's success case. A binding that genuinely could not be
114
+ // applied is still worth saying out loud — quietly tolerating it here is how
115
+ // the deploy path let this class of gap through in the first place.
116
+ if (!result.ok && !step.argv.includes('add-iam-policy-binding')) continue;
117
+ if (!result.ok) {
118
+ return {
119
+ bound: [],
120
+ failed:
121
+ `could not bind ${connection.provider}.${connection.id}'s credential to ` +
122
+ `${serviceAccount}, so the deployed endpoint will be able to read it and not ` +
123
+ 'rotate it — which fails about an hour after the first use. ' +
124
+ `Run \`lanes link deploy --target ${input.target}\` to bind it. ` +
125
+ `(${driver.tool}: ${result.stderr.trim().split('\n').slice(-1)[0] ?? 'failed'})`,
126
+ };
127
+ }
128
+ }
129
+
130
+ return { bound: readable ? [readable, ...rotatable] : rotatable };
131
+ }
132
+
133
+ /**
134
+ * The credentials a deployed revision serves but cannot rewrite.
135
+ *
136
+ * The detection half of the same invariant `bindConnectionCredentials` keeps.
137
+ * Binding at connect time closes the gap going forward; this one answers for a
138
+ * workspace that already has it, and for every way a binding can go missing that
139
+ * no command is watching — a secret rebuilt by hand, a service account replaced,
140
+ * a profile edited in an editor, a connection made by an older CLI.
141
+ *
142
+ * Asked of the platform rather than derived from a record this repository keeps,
143
+ * because a record would only ever agree with itself. IAM is the thing that
144
+ * actually decides, so IAM is what gets read.
145
+ *
146
+ * One call per ref, concurrently. `doctor` is the command where a few seconds
147
+ * buys an answer nothing else on the machine can give — and the alternative,
148
+ * finding out from a 403 an hour after a connect, is the failure this exists to
149
+ * pre-empt.
150
+ */
151
+ export async function unboundRotatableRefs(input: {
152
+ readonly deploy: DeployConfig | undefined;
153
+ readonly target: string;
154
+ readonly connections: readonly ConnectionConfig[];
155
+ readonly manifestFor: (providerId: string) => Parameters<typeof rotatableCredentialRefsFor>[1];
156
+ readonly driver?: DeployDriver | undefined;
157
+ }): Promise<{ unbound: readonly SecretRef[]; unavailable?: string | undefined }> {
158
+ const { deploy } = input;
159
+ if (!deploy) return { unbound: [] };
160
+
161
+ const cloudrun = requireProject(deploy, input.target);
162
+ const serviceAccount = cloudrun.service_account;
163
+ if (!serviceAccount) return { unbound: [] };
164
+
165
+ const refs = new Set<SecretRef>();
166
+ for (const connection of input.connections) {
167
+ for (const ref of rotatableCredentialRefsFor(connection, input.manifestFor(connection.provider))) {
168
+ refs.add(ref);
169
+ }
170
+ }
171
+ if (refs.size === 0) return { unbound: [] };
172
+
173
+ const driver = input.driver ?? (await driverFor(deploy.platform));
174
+ const member = `serviceAccount:${serviceAccount}`;
175
+
176
+ const verdicts = await Promise.all(
177
+ [...refs].map(async (ref) => {
178
+ const result = await driver.run(
179
+ ['secrets', 'get-iam-policy', encodeRef(ref), '--project', cloudrun.project, '--format', 'json'],
180
+ { quiet: true },
181
+ );
182
+ // A ref whose policy cannot be read is not reported as unbound: "could not
183
+ // look" and "is not granted" send an operator to different places, and
184
+ // this command exists to be trusted about the second one.
185
+ if (!result.ok) return { ref, bound: true, unreadable: true };
186
+
187
+ try {
188
+ const policy = JSON.parse(result.stdout) as {
189
+ bindings?: { role?: string; members?: string[] }[];
190
+ };
191
+ const bound = (policy.bindings ?? []).some(
192
+ (binding) =>
193
+ binding.role === 'roles/secretmanager.secretVersionAdder' &&
194
+ (binding.members ?? []).includes(member),
195
+ );
196
+ return { ref, bound, unreadable: false };
197
+ } catch {
198
+ return { ref, bound: true, unreadable: true };
199
+ }
200
+ }),
201
+ );
202
+
203
+ const unreadable = verdicts.filter((verdict) => verdict.unreadable).length;
204
+ return {
205
+ unbound: verdicts.filter((verdict) => !verdict.bound).map((verdict) => verdict.ref),
206
+ ...(unreadable > 0
207
+ ? {
208
+ unavailable:
209
+ `${unreadable} of ${refs.size} credential policies could not be read, so this ` +
210
+ `check covered the rest. ${driver.tool} has to be installed and authorised for it.`,
211
+ }
212
+ : {}),
213
+ };
214
+ }
@@ -18,9 +18,31 @@ WORKDIR /app
18
18
  # The manifest first, so a source-only change reuses the install layer. One
19
19
  # package now, rather than fifteen manifests copied ahead of the install to
20
20
  # satisfy workspace resolution.
21
- COPY package.json bun.lock bunfig.toml ./
21
+ #
22
+ # `bun.lock*`, not `bun.lock`, and the star is the whole point: **npm strips a
23
+ # root lockfile out of a published tarball whatever `files` says.** Verified
24
+ # against npm 12, which is what `release.yml` installs before publishing; npm
25
+ # 11.6 still packs it, so a local `npm pack` says the opposite and the gap only
26
+ # shows up in what people actually install.
27
+ #
28
+ # This image is built from whichever tree `lanes link deploy` is run against, and
29
+ # there are two of them. From a checkout the lockfile is present and pins the
30
+ # whole transitive set. From a bun-global install — the only install method this
31
+ # CLI documents — it cannot be there at all, and naming it outright made deploy
32
+ # impossible for every one of those users: the build pulled the base image,
33
+ # pushed a context, and then died on `stat bun.lock: file does not exist`.
34
+ COPY package.json bunfig.toml bun.lock* ./
22
35
 
23
- RUN bun install --frozen-lockfile
36
+ # Not `--frozen-lockfile || bun install`: that swallows a genuine
37
+ # lockfile-versus-manifest disagreement in a checkout build, which is the one
38
+ # thing the frozen flag is for. The branch says which tree it is building and
39
+ # holds the guarantee that tree can offer.
40
+ #
41
+ # What the fallback gives up is narrower than it looks. Every runtime dependency
42
+ # in `package.json` is an exact version, so the direct set is identical either
43
+ # way; the lockfile pins what those depend on in turn, and a published package
44
+ # has never been able to carry one.
45
+ RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
24
46
 
25
47
  COPY src/ src/
26
48
 
@@ -1,4 +1,4 @@
1
- import { VAULT_DOCUMENT_REF } from '#secrets';
1
+ import { VAULT_DOCUMENT_REF, type SecretRef } from '#secrets';
2
2
  import type { DeployStep, ProvisionInput } from '../driver.ts';
3
3
  import { encodeRef } from '../adapters/gcp-secret-manager.ts';
4
4
  import { requireProject } from './gcloud.ts';
@@ -43,6 +43,109 @@ const REQUIRED_SERVICES = [
43
43
  'cloudresourcemanager.googleapis.com',
44
44
  ];
45
45
 
46
+ /**
47
+ * Who the grant is for, and on what.
48
+ *
49
+ * A named type because these two functions are now called from two places that
50
+ * must not disagree — see the note on `secretGrantSteps`.
51
+ */
52
+ export interface SecretGrant {
53
+ readonly project: string;
54
+ readonly serviceAccount: string;
55
+ readonly refs: readonly SecretRef[];
56
+ }
57
+
58
+ /** Read, named one secret at a time, so the grant needs no condition to be scoped. */
59
+ export function readSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
60
+ return refs.map((ref) => ({
61
+ title: `let the revision read ${ref}`,
62
+ argv: [
63
+ 'secrets',
64
+ 'add-iam-policy-binding',
65
+ encodeRef(ref),
66
+ '--project',
67
+ project,
68
+ '--member',
69
+ `serviceAccount:${serviceAccount}`,
70
+ '--role',
71
+ 'roles/secretmanager.secretAccessor',
72
+ // Bindings are printed as the whole policy otherwise, which is pages
73
+ // of YAML per deploy and buries everything after it.
74
+ '--condition',
75
+ 'None',
76
+ ],
77
+ tolerateFailure: true,
78
+ }));
79
+ }
80
+
81
+ /**
82
+ * Write, two steps each, and the first is what keeps the second narrow: the
83
+ * secret is created here so the revision only ever needs to *add a version*,
84
+ * never `secrets.create`, which is a project-level permission that would let it
85
+ * mint credential references of its own.
86
+ */
87
+ export function rotateSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
88
+ return refs.flatMap((ref) => {
89
+ const id = encodeRef(ref);
90
+ return [
91
+ {
92
+ title: `create the secret ${id}, so the revision never needs secrets.create`,
93
+ argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
94
+ tolerateFailure: true,
95
+ },
96
+ {
97
+ title: `let the revision rewrite ${ref}, and nothing else in the store`,
98
+ argv: [
99
+ 'secrets',
100
+ 'add-iam-policy-binding',
101
+ id,
102
+ '--project',
103
+ project,
104
+ '--member',
105
+ `serviceAccount:${serviceAccount}`,
106
+ '--role',
107
+ 'roles/secretmanager.secretVersionAdder',
108
+ '--condition',
109
+ 'None',
110
+ ],
111
+ tolerateFailure: true,
112
+ },
113
+ ];
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Both halves for one connection's credentials, for a caller that is not a deploy.
119
+ *
120
+ * **This exists because the grant was a deploy-time snapshot of a set that
121
+ * changes between deploys.** `provisionSteps` walks the config's connections and
122
+ * binds each secret it finds; `connect` then adds a connection, writes its
123
+ * credential, and binds nothing. The revision can read the new secret — an older
124
+ * deployment's project-wide `secretAccessor` covers it, and a current one does
125
+ * not — but it cannot add a version, so the first OAuth refresh 403s. The
126
+ * connection works for exactly as long as its initial access token lasts, which
127
+ * is about an hour, and then stops for a reason nothing on the connection says.
128
+ *
129
+ * So the same steps are reachable from `connect`, over one connection's refs
130
+ * rather than the whole config's. Deliberately the *same functions* rather than
131
+ * a second implementation: `reconcile.ts` makes the same argument for planning
132
+ * and applying, and it holds harder here, because a second spelling of a grant
133
+ * is not a wrong answer on screen, it is a permission that is missing in one
134
+ * path and present in the other.
135
+ */
136
+ export function secretGrantSteps(
137
+ grant: Omit<SecretGrant, 'refs'> & {
138
+ readonly readable: readonly SecretRef[];
139
+ readonly rotatable: readonly SecretRef[];
140
+ },
141
+ ): DeployStep[] {
142
+ const { project, serviceAccount } = grant;
143
+ return [
144
+ ...readSteps({ project, serviceAccount, refs: grant.readable }),
145
+ ...rotateSteps({ project, serviceAccount, refs: grant.rotatable }),
146
+ ];
147
+ }
148
+
46
149
  export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
47
150
  const cloudrun = requireProject(input.deploy, input.target);
48
151
  const { project, region, service_account: serviceAccount } = cloudrun;
@@ -116,27 +219,7 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
116
219
  // Affordable because the serving path reads by explicit ref: `list()` is a
117
220
  // CLI call, and `secretAccessor` never carried `secrets.list` anyway.
118
221
  // `readableRefs` derives the set from config and manifests at deploy time.
119
- for (const ref of input.readable ?? []) {
120
- steps.push({
121
- title: `let the revision read ${ref}`,
122
- argv: [
123
- 'secrets',
124
- 'add-iam-policy-binding',
125
- encodeRef(ref),
126
- '--project',
127
- project,
128
- '--member',
129
- `serviceAccount:${serviceAccount}`,
130
- '--role',
131
- 'roles/secretmanager.secretAccessor',
132
- // Bindings are printed as the whole policy otherwise, which is pages
133
- // of YAML per deploy and buries everything after it.
134
- '--condition',
135
- 'None',
136
- ],
137
- tolerateFailure: true,
138
- });
139
- }
222
+ steps.push(...readSteps({ project, serviceAccount, refs: input.readable ?? [] }));
140
223
  }
141
224
 
142
225
  // What a revision rewrites in its own credential store, named one secret at a
@@ -165,32 +248,8 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
165
248
  ]
166
249
  : [];
167
250
 
168
- for (const ref of writable) {
169
- const id = encodeRef(ref);
170
-
171
- steps.push({
172
- title: `create the secret ${id}, so the revision never needs secrets.create`,
173
- argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
174
- tolerateFailure: true,
175
- });
176
-
177
- steps.push({
178
- title: `let the revision rewrite ${ref}, and nothing else in the store`,
179
- argv: [
180
- 'secrets',
181
- 'add-iam-policy-binding',
182
- id,
183
- '--project',
184
- project,
185
- '--member',
186
- `serviceAccount:${serviceAccount}`,
187
- '--role',
188
- 'roles/secretmanager.secretVersionAdder',
189
- '--condition',
190
- 'None',
191
- ],
192
- tolerateFailure: true,
193
- });
251
+ if (serviceAccount) {
252
+ steps.push(...rotateSteps({ project, serviceAccount, refs: writable }));
194
253
  }
195
254
 
196
255
  // Any target that addresses a bucket, which deployed means all of them:
@@ -241,8 +300,18 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
241
300
  // what the revision owns from what declares what it is. Anchored to the
242
301
  // profile segment rather than matched loosely: `contains("/providers.d/")`
243
302
  // would also catch a blob whose own key happened to spell it.
303
+ //
304
+ // The dot is a character class, not `\.`, and that is the fix rather than
305
+ // a style: this string is a *CEL string literal* holding a regex, so it is
306
+ // unescaped once by CEL before the regex engine ever sees it. `\.` is not
307
+ // a CEL escape sequence, so the whole expression failed to compile —
308
+ // `token recognition error at: '"^projects/_/buckets/...providers\.'` —
309
+ // and both bindings below carry `tolerateFailure`, so a deploy printed two
310
+ // warnings and carried on with the scoping silently not applied. `[.]` is
311
+ // the same regex and survives a layer of string unescaping unchanged,
312
+ // which is what keeps the next person from reintroducing it.
244
313
  const providerManifests =
245
- `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers\\.d/")`;
314
+ `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers[.]d/")`;
246
315
 
247
316
  steps.push({
248
317
  title: 'let the revision write its own data, but not the manifests in it',