@lanes-sh/link 0.6.3 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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",
@@ -43,7 +43,6 @@
43
43
  "instructions",
44
44
  "README.md",
45
45
  "LICENSE",
46
- "bun.lock",
47
46
  "bunfig.toml",
48
47
  ".dockerignore"
49
48
  ],
@@ -157,6 +157,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
157
157
  target,
158
158
  rotatable,
159
159
  readable,
160
+ profiles: serving,
160
161
  });
161
162
 
162
163
  // Where the running instance will read its config. The bucket the target
@@ -92,6 +92,20 @@ export interface ProvisionInput {
92
92
  * what a target with no profile to walk still needs.
93
93
  */
94
94
  readonly readable?: readonly string[];
95
+ /**
96
+ * The profiles this revision serves, so the bucket conditions can name each
97
+ * one's provider manifests.
98
+ *
99
+ * Needed because Cloud Storage IAM conditions cannot express "any profile
100
+ * segment": their CEL is a restricted subset with no `matches`, so the only
101
+ * way to carve out `data/<profile>/providers.d/` is to enumerate the profiles
102
+ * and write one `startsWith` each. `deploy.ts` already resolved the list.
103
+ *
104
+ * Absent leaves the carve-out off entirely rather than guessing, which is the
105
+ * safe direction: the revision keeps write on its own data and the manifests
106
+ * inside it, exactly as it did before the carve-out existed.
107
+ */
108
+ readonly profiles?: readonly string[];
95
109
  }
96
110
 
97
111
  export interface SurveyInput {
@@ -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,6 +1,7 @@
1
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
+ import { layout } from '#profile';
4
5
  import { requireProject } from './gcloud.ts';
5
6
 
6
7
  /**
@@ -296,22 +297,34 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
296
297
  `resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
297
298
 
298
299
  // A provider manifest is configuration that happens to live inside the
299
- // profile's directory (ADR-030), so `data/` alone no longer separates
300
- // what the revision owns from what declares what it is. Anchored to the
301
- // profile segment rather than matched loosely: `contains("/providers.d/")`
302
- // would also catch a blob whose own key happened to spell it.
300
+ // profile's directory (ADR-030), so `data/` alone no longer separates what
301
+ // the revision owns from what declares what it is.
303
302
  //
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.
303
+ // **One `startsWith` per profile, because Cloud Storage IAM conditions
304
+ // cannot express anything else.** Their CEL is a restricted subset
305
+ // `resource.type`, `resource.name` with `startsWith`/`endsWith`/`==`, and
306
+ // the date functions and it has no `matches`. This was a regex, and it
307
+ // was refused twice over: first because it spelled the dot `\.`, which is
308
+ // not a CEL escape, so the string literal would not parse; then, with that
309
+ // fixed, because `matches` is `undeclared` in this dialect.
310
+ //
311
+ // Both bindings carry `tolerateFailure`, so each attempt printed a warning
312
+ // and left whatever conditions the bucket already had. On a real
313
+ // deployment that was `expression=true` on the read binding — every object
314
+ // in the bucket, the exact opposite of the narrowing the step title claims,
315
+ // and the state ADR-007 says must not exist.
316
+ //
317
+ // Enumerating the served profiles is expressible in the subset that does
318
+ // exist, and it makes `grants.test.ts` honest as a side effect: that file
319
+ // evaluates these as JavaScript, where `startsWith` means what it means
320
+ // here and `matches` quietly did not.
321
+ const manifestPrefixes = (input.profiles ?? []).map((profile) =>
322
+ objectsUnder(`${layout.providers(profile)}/`),
323
+ );
324
+ // No profiles leaves the carve-out off rather than guessing at one: the
325
+ // revision keeps write on its own data, as it did before this existed.
313
326
  const providerManifests =
314
- `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers[.]d/")`;
327
+ manifestPrefixes.length > 0 ? `(${manifestPrefixes.join(' || ')})` : null;
315
328
 
316
329
  steps.push({
317
330
  title: 'let the revision write its own data, but not the manifests in it',
@@ -327,7 +340,7 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
327
340
  '--role',
328
341
  'roles/storage.objectAdmin',
329
342
  '--condition',
330
- `title=owns-its-data,expression=${objectsUnder('data/')} && !${providerManifests}`,
343
+ `title=owns-its-data,expression=${objectsUnder('data/')}${providerManifests ? ` && !${providerManifests}` : ''}`,
331
344
  ],
332
345
  tolerateFailure: true,
333
346
  });
@@ -348,7 +361,7 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
348
361
  // The config the revision reads is the workspace file, the profiles
349
362
  // beside it, and each profile's own manifests, so name exactly those.
350
363
  '--condition',
351
- `title=reads-its-config,expression=${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')} || ${providerManifests}`,
364
+ `title=reads-its-config,expression=${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')}${providerManifests ? ` || ${providerManifests}` : ''}`,
352
365
  ],
353
366
  tolerateFailure: true,
354
367
  });