@lanes-sh/link 0.3.2 → 0.4.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 (68) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/identity.ts +29 -5
  24. package/src/cli/main.ts +37 -13
  25. package/src/cli/oauth.ts +89 -36
  26. package/src/cli/runtime/open.ts +13 -1
  27. package/src/cli/runtime/registry.ts +12 -0
  28. package/src/cli/selection.ts +12 -0
  29. package/src/cli/usage.ts +9 -1
  30. package/src/connectivity/auth/README.md +8 -1
  31. package/src/connectivity/auth/strategy/index.ts +128 -4
  32. package/src/connectivity/connector.ts +11 -0
  33. package/src/connectivity/index.ts +11 -1
  34. package/src/connectivity/manifest/auth.ts +19 -0
  35. package/src/connectivity/manifest/connector.ts +21 -0
  36. package/src/connectivity/manifest/primitives.ts +5 -1
  37. package/src/connectivity/manifest/provider.ts +30 -12
  38. package/src/connectivity/provider.ts +55 -0
  39. package/src/connectivity/transports/factory.ts +1 -0
  40. package/src/connectivity/transports/http/index.ts +73 -2
  41. package/src/dispatch/dispatch.ts +44 -5
  42. package/src/providers/bunq/hints.ts +43 -0
  43. package/src/providers/bunq/index.ts +87 -0
  44. package/src/providers/bunq/redact.ts +64 -0
  45. package/src/providers/bunq/specs/bunq.v1.json +864 -0
  46. package/src/providers/bunq/specs/vendor.ts +338 -0
  47. package/src/providers/bunq/strategy/handshake.ts +211 -0
  48. package/src/providers/bunq/strategy/index.ts +298 -0
  49. package/src/providers/bunq/strategy/keys.ts +72 -0
  50. package/src/providers/custom/index.ts +1 -6
  51. package/src/providers/custom/load.ts +56 -14
  52. package/src/providers/custom/template.ts +1 -1
  53. package/src/providers/discord/hints.ts +195 -0
  54. package/src/providers/discord/index.ts +121 -0
  55. package/src/providers/discord/redact.ts +99 -0
  56. package/src/providers/discord/specs/discord.v10.json +2333 -0
  57. package/src/providers/discord/specs/vendor.ts +164 -0
  58. package/src/providers/google/specs/vendor.ts +32 -317
  59. package/src/providers/index.ts +9 -0
  60. package/src/providers/reddit/index.ts +113 -0
  61. package/src/providers/reddit/oauth.ts +77 -0
  62. package/src/providers/reddit/redact.ts +33 -0
  63. package/src/providers/reddit/scopes.ts +27 -0
  64. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  65. package/src/providers/scopes.ts +2 -0
  66. package/src/providers/shared/openapi.ts +155 -0
  67. package/src/providers/shared/vendor-operations.ts +98 -0
  68. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -0,0 +1,298 @@
1
+ import type { AuthStrategy, AuthStrategyContext } from '#connectivity';
2
+ import { createInstallation, createSession, hostFor, registerDevice, baseHeaders } from './handshake.ts';
3
+ import { generateKeypair, signBody, verifyBody } from './keys.ts';
4
+
5
+ /**
6
+ * bunq, whose authentication is a protocol rather than a header.
7
+ *
8
+ * The one strategy, and the case ADR-008 was written around. Everything else
9
+ * about this provider is declared — the connector, the vendored operations, the
10
+ * redaction — and this file only ever sees a request on its way out and a
11
+ * response on its way back. It contains no endpoint knowledge and must not gain
12
+ * any.
13
+ *
14
+ * Three durable things come out of `setup` and live in the credential store:
15
+ * the private key, the installation token, and bunq's own public key. One
16
+ * ephemeral thing comes out of every session and lives in `state`: the session
17
+ * token. The split is forced rather than chosen — `AuthStrategyContext.write`
18
+ * is absent outside setup, and `rotatableCredentialRefs` grants a deployed
19
+ * revision write on nothing for a non-OAuth provider, so a session token
20
+ * physically cannot go in the credential store. `state` is the right home
21
+ * anyway: it is documented for exactly this, and everything in it is
22
+ * reconstructible from the API key.
23
+ */
24
+
25
+ /** Where the whole credential lives. Derived per connection: `bunq/<id>`. */
26
+ interface Stored {
27
+ readonly api_key: string;
28
+ readonly private_key: string;
29
+ readonly installation_token: string;
30
+ readonly server_public_key: string;
31
+ }
32
+
33
+ const SESSION_KEY = 'bunq:session';
34
+
35
+ /**
36
+ * When to open a new session before bunq closes the old one.
37
+ *
38
+ * bunq expires a session after the account's auto-logout setting, a week by
39
+ * default, and there is no endpoint that reports what that setting is. Six days
40
+ * is under the default with room to spare; an operator who shortened theirs is
41
+ * covered by the other half — `verify` clears the session on a 401, so the call
42
+ * after a surprise expiry succeeds. Costing one failed call is the honest
43
+ * trade against guessing a number we cannot read.
44
+ */
45
+ const SESSION_MAX_AGE_MS = 6 * 24 * 60 * 60 * 1000;
46
+
47
+ interface Session {
48
+ readonly token: string;
49
+ readonly createdAt: number;
50
+ }
51
+
52
+ /**
53
+ * One cache and one in-flight map, per process.
54
+ *
55
+ * The cache spares a state read per request. The in-flight map is the one that
56
+ * matters: `/session-server` allows **one call per thirty seconds**, so two
57
+ * concurrent requests both finding no session would make the second fail. They
58
+ * wait on the first instead.
59
+ *
60
+ * Keyed by **profile** as well as provider and connection, and that is not
61
+ * belt-and-braces. One endpoint process opens a `Runtime` per profile in the
62
+ * workspace, so `bunq.main` names two different bank accounts as soon as two
63
+ * profiles each connect bunq without renaming the connection. `state` and
64
+ * `credentials` are already scoped per profile; a process-wide cache in front
65
+ * of them is the one place that scoping could be lost, and losing it would send
66
+ * one profile's session token — signed with the other's key — to a bank.
67
+ */
68
+ const cached = new Map<string, Session>();
69
+ const opening = new Map<string, Promise<string>>();
70
+
71
+ /** Unique across everything that could hold a different bunq session. */
72
+ const cacheKey = (context: AuthStrategyContext): string =>
73
+ `${context.profile}.${context.manifest.id}.${context.connectionId}`;
74
+
75
+ function parse(raw: string | null, connectionId: string): Stored {
76
+ if (!raw) {
77
+ throw new Error(
78
+ `No bunq credential stored for connection "${connectionId}". Run: lanes link connect bunq`,
79
+ );
80
+ }
81
+
82
+ let value: unknown;
83
+ try {
84
+ value = JSON.parse(raw);
85
+ } catch {
86
+ // The API key as pasted, before `setup` has replaced it with the whole
87
+ // context. A connection in this state has not completed the handshake.
88
+ throw new Error(
89
+ `The bunq connection "${connectionId}" holds an API key but no installation. Run: lanes link connect bunq --replace`,
90
+ );
91
+ }
92
+
93
+ const stored = value as Partial<Stored>;
94
+ if (!stored.api_key || !stored.private_key || !stored.installation_token) {
95
+ throw new Error(`The bunq credential for "${connectionId}" is incomplete. Run: lanes link connect bunq --replace`);
96
+ }
97
+
98
+ return stored as Stored;
99
+ }
100
+
101
+ /**
102
+ * The API key, whether the ref holds one or a whole installed context.
103
+ *
104
+ * Re-running `connect` on an installed connection is the ordinary way to
105
+ * recover from a rotated key or a revoked device, so it has to be the same
106
+ * conversation as the first run rather than a different failure.
107
+ */
108
+ function apiKeyFrom(raw: string): string {
109
+ if (!raw.startsWith('{')) return raw;
110
+
111
+ try {
112
+ const parsed = JSON.parse(raw) as Partial<Stored>;
113
+ if (typeof parsed.api_key === 'string' && parsed.api_key.length > 0) return parsed.api_key;
114
+ } catch {
115
+ // Not JSON after all — a key that happens to start with a brace.
116
+ }
117
+
118
+ return raw;
119
+ }
120
+
121
+ async function sessionToken(
122
+ context: AuthStrategyContext,
123
+ stored: Stored,
124
+ fetcher: typeof globalThis.fetch,
125
+ ): Promise<string> {
126
+ const key = cacheKey(context);
127
+ const fresh = (session: Session | null): boolean =>
128
+ session !== null && Date.now() - session.createdAt < SESSION_MAX_AGE_MS;
129
+
130
+ const memo = cached.get(key) ?? null;
131
+ if (fresh(memo)) return memo!.token;
132
+
133
+ const persisted = await context.state.getJson<Session>(SESSION_KEY);
134
+ if (fresh(persisted)) {
135
+ cached.set(key, persisted!);
136
+ return persisted!.token;
137
+ }
138
+
139
+ const already = opening.get(key);
140
+ if (already) return already;
141
+
142
+ const attempt = (async () => {
143
+ context.log.debug('opening a bunq session');
144
+ const token = await createSession(
145
+ hostFor(context.manifest),
146
+ stored.installation_token,
147
+ stored.api_key,
148
+ stored.private_key,
149
+ fetcher,
150
+ );
151
+ const session: Session = { token, createdAt: Date.now() };
152
+ cached.set(key, session);
153
+ await context.state.setJson(SESSION_KEY, session);
154
+ return token;
155
+ })().finally(() => opening.delete(key));
156
+
157
+ opening.set(key, attempt);
158
+ return attempt;
159
+ }
160
+
161
+ export function createBunqStrategy(fetcher: typeof globalThis.fetch = globalThis.fetch): AuthStrategy {
162
+ return {
163
+ id: 'bunq',
164
+
165
+ /**
166
+ * Installation and device registration, run once at connect time.
167
+ *
168
+ * Ends by rewriting the credential the operator pasted: it arrives as a
169
+ * bare API key and leaves as the whole context. That keeps everything
170
+ * durable behind the single ref a connection is allowed to read, rather
171
+ * than inventing three more the allowlist would refuse.
172
+ *
173
+ * Deliberately stops short of opening a session, though it easily could and
174
+ * an earlier draft did. Two reasons, both about *when* this runs. `connect`
175
+ * performs the handshake under a provisional connection id and renames the
176
+ * connection afterwards — the credential moves with it, but state does not,
177
+ * so a session opened here would be stranded under `bunq.pending`. And
178
+ * `/session-server` allows one call per thirty seconds, so a stranded
179
+ * session is not merely wasted: it is thirty seconds during which the first
180
+ * real call is refused. The key is already proven by `device-server`, which
181
+ * is the step that rejects a wrong one.
182
+ */
183
+ async setup(context) {
184
+ const write = context.write;
185
+ if (!write) throw new Error('The bunq strategy can only be set up where credentials are writable.');
186
+
187
+ const ref = `${context.manifest.id}/${context.connectionId}`;
188
+ const held = (await context.credentials.get(ref))?.trim();
189
+ if (!held) throw new Error(`No bunq API key was stored at ${ref}.`);
190
+
191
+ // What is at the ref depends on whether this connection has been set up
192
+ // before. First time it is the key the operator pasted; on a re-connect
193
+ // it is the whole context this function wrote last time. Reading it as a
194
+ // key either way would send a JSON blob to `/device-server` as `secret`,
195
+ // and bunq would reject it with a wrong-key error naming nothing the
196
+ // operator did.
197
+ const apiKey = apiKeyFrom(held);
198
+
199
+ const host = hostFor(context.manifest);
200
+ const keys = generateKeypair();
201
+
202
+ const installation = await createInstallation(host, keys.publicKey, fetcher);
203
+ await registerDevice(
204
+ host,
205
+ installation.token,
206
+ apiKey,
207
+ String(context.options['description'] ?? 'Lanes Link'),
208
+ keys.privateKey,
209
+ fetcher,
210
+ );
211
+
212
+ const installed: Stored = {
213
+ api_key: apiKey,
214
+ private_key: keys.privateKey,
215
+ installation_token: installation.token,
216
+ server_public_key: installation.serverPublicKey,
217
+ };
218
+ await write(ref, JSON.stringify(installed));
219
+ },
220
+
221
+ async authorize(request, context) {
222
+ const stored = parse(
223
+ await context.credentials.get(`${context.manifest.id}/${context.connectionId}`),
224
+ context.connectionId,
225
+ );
226
+
227
+ const token = await sessionToken(context, stored, fetcher);
228
+ const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.clone().text();
229
+
230
+ // The URL is left exactly as the transport built it. It comes from the
231
+ // manifest's `base_url`, which is also where the handshake got its host,
232
+ // so there is nothing here that could put the two on different bunqs.
233
+ const authorised = new Request(request.url, {
234
+ method: request.method,
235
+ headers: new Headers(request.headers),
236
+ ...(body === '' ? {} : { body }),
237
+ signal: request.signal,
238
+ });
239
+
240
+ for (const [key, value] of Object.entries(baseHeaders())) {
241
+ // Never overwrite what the transport set from the operation itself.
242
+ if (!authorised.headers.has(key)) authorised.headers.set(key, value);
243
+ }
244
+ authorised.headers.set('x-bunq-client-authentication', token);
245
+ authorised.headers.set('x-bunq-client-signature', signBody(body, stored.private_key));
246
+
247
+ return authorised;
248
+ },
249
+
250
+ /**
251
+ * Two jobs, and the second is why this hook is wired at all.
252
+ *
253
+ * A 401 means the session went away earlier than the age check expected —
254
+ * an operator with a short auto-logout, or a session ended from the app.
255
+ * Dropping the cached token here is what makes the *next* call work without
256
+ * anyone intervening.
257
+ *
258
+ * The first job is the one bunq documents: the reply carries a signature
259
+ * over its body, and checking it against the key installation returned is
260
+ * how we know the answer came from bunq. A failure is logged rather than
261
+ * thrown — the response has already been received, the check is documented
262
+ * as optional, and turning a delivered answer into an exception would make
263
+ * a payment that *did* happen look like one that did not.
264
+ */
265
+ async verify(response, context) {
266
+ const key = cacheKey(context);
267
+
268
+ if (response.status === 401) {
269
+ cached.delete(key);
270
+ await context.state.delete(SESSION_KEY);
271
+ context.log.warn('bunq rejected the session; the next call will open a new one');
272
+ return;
273
+ }
274
+
275
+ // bunq signs its replies but does not promise to sign every one, and the
276
+ // documentation calls checking them optional. An absent header is
277
+ // therefore not a failure; a present one that does not verify is.
278
+ const signature = response.headers.get('x-bunq-server-signature');
279
+ if (!signature) return;
280
+
281
+ const raw = await context.credentials.get(`${context.manifest.id}/${context.connectionId}`);
282
+ let publicKey: string | undefined;
283
+ try {
284
+ publicKey = raw ? (JSON.parse(raw) as Partial<Stored>).server_public_key : undefined;
285
+ } catch {
286
+ // A connection still holding the bare pasted key. `authorize` has
287
+ // already refused it with a message that says what to do, and repeating
288
+ // that here would replace it with a parse error.
289
+ return;
290
+ }
291
+ if (!publicKey) return;
292
+
293
+ if (!verifyBody(await response.text(), signature, publicKey)) {
294
+ context.log.error('a bunq response failed signature verification');
295
+ }
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,72 @@
1
+ import { createSign, createVerify, generateKeyPairSync } from 'node:crypto';
2
+
3
+ /**
4
+ * The signing half of bunq's authentication.
5
+ *
6
+ * bunq is asymmetric where most APIs are not: the client generates a keypair,
7
+ * hands bunq the public half once during installation, and thereafter proves
8
+ * each request by signing it. Nothing here knows what a payment is or which
9
+ * endpoint it is going to — that is the boundary ADR-008 draws around a
10
+ * strategy, and this file is the part of it that is pure arithmetic.
11
+ *
12
+ * **Only the body is signed.** bunq used to require a signature over the whole
13
+ * request — method, path, headers, body — and stopped validating those on 28
14
+ * April 2020. Signing the old way now produces a signature bunq rejects, so
15
+ * getting this wrong fails closed rather than silently: `Sign only the request
16
+ * body (no headers, no URLs)`.
17
+ */
18
+
19
+ export interface Keypair {
20
+ /** PKCS#8 PEM. Stored, never sent. */
21
+ readonly privateKey: string;
22
+ /** SPKI PEM. This is what `POST /installation` hands over. */
23
+ readonly publicKey: string;
24
+ }
25
+
26
+ /**
27
+ * RSA 2048, which is what bunq's installation endpoint accepts.
28
+ *
29
+ * Generated once per connection at connect time and then persisted, because
30
+ * the public half is registered upstream — a new keypair would mean a new
31
+ * installation, and the old one would keep existing on bunq's side.
32
+ */
33
+ export function generateKeypair(): Keypair {
34
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', {
35
+ modulusLength: 2048,
36
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
37
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
38
+ });
39
+
40
+ return { privateKey, publicKey };
41
+ }
42
+
43
+ /**
44
+ * SHA-256 with RSA PKCS#1 v1.5, base64 — the value of `X-Bunq-Client-Signature`.
45
+ *
46
+ * A GET carries no body and signs the empty string, which is a signature bunq
47
+ * accepts and not a reason to omit the header.
48
+ */
49
+ export function signBody(body: string, privateKey: string): string {
50
+ return createSign('RSA-SHA256').update(body, 'utf8').sign(privateKey, 'base64');
51
+ }
52
+
53
+ /**
54
+ * Check `X-Bunq-Server-Signature` against the key installation returned.
55
+ *
56
+ * bunq documents this as optional and it is implemented anyway: a strategy that
57
+ * moves money is exactly where the reply being genuinely bunq's is worth
58
+ * establishing, and the seam already carries a `verify` hook for it.
59
+ *
60
+ * Returns a boolean rather than throwing so the caller decides what a mismatch
61
+ * means — which differs between a response that omitted the header entirely and
62
+ * one that carried a wrong signature.
63
+ */
64
+ export function verifyBody(body: string, signature: string, publicKey: string): boolean {
65
+ try {
66
+ return createVerify('RSA-SHA256').update(body, 'utf8').verify(publicKey, signature, 'base64');
67
+ } catch {
68
+ // A malformed key or a signature that is not base64. Indistinguishable from
69
+ // a wrong signature as far as the caller is concerned, and both are refusals.
70
+ return false;
71
+ }
72
+ }
@@ -12,10 +12,5 @@
12
12
  * connectivity type; `load.ts` reads and validates them.
13
13
  */
14
14
 
15
- export {
16
- loadProfileProviders,
17
- parseManifest,
18
- parseManifestFile,
19
- type LoadedManifest,
20
- } from './load.ts';
15
+ export { loadProfileProviders, parseManifest, type LoadedManifest } from './load.ts';
21
16
  export { manifestTemplate } from './template.ts';
@@ -1,9 +1,9 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
1
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
2
  import { parse as parseYaml } from 'yaml';
4
3
  import { defineProvider, type ProviderManifest } from '#connectivity';
5
- import { ConfigError, layout } from '#profile';
4
+ import { ConfigError, isRemoteWorkspace, layout, readWorkspaceFile, workspaceFiles } from '#profile';
6
5
  import { findSecrets, formatSecretFindings } from '#profile';
6
+ import type { BlobStore } from '#stores/blobs';
7
7
 
8
8
  /**
9
9
  * Provider manifests supplied by the operator.
@@ -21,41 +21,66 @@ import { findSecrets, formatSecretFindings } from '#profile';
21
21
  * personal's to read. The path comes from `layout` for the same reason every
22
22
  * other profile-owned path does: one place that knows the on-disk shape.
23
23
  * ADR-030.
24
+ *
25
+ * **Read through the workspace's store, not through `node:fs`.** A deployed
26
+ * revision is handed `LANES_LINK_HOME=gs://<bucket>`, and `join` collapses that
27
+ * to `gs:/bucket/…` — so a `readdir` threw ENOENT and the catch below reported
28
+ * the same empty list it reports for a workspace that simply has no manifests.
29
+ * The manifest was uploaded, the read grant covered it, and nothing looked at
30
+ * it: a custom provider worked locally and silently did not exist once
31
+ * deployed. Skills were already read through a `BlobStore` for exactly this
32
+ * reason; manifests now are too. ADR-046.
24
33
  */
25
34
 
26
35
  export interface LoadedManifest {
27
36
  readonly manifest: ProviderManifest;
37
+ /** Where this came from, for a refusal to name. A path, or a bucket URL. */
28
38
  readonly path: string;
29
39
  }
30
40
 
31
41
  export async function loadProfileProviders(
32
42
  workspaceRoot: string,
33
43
  profile: string,
44
+ /** Injected for tests. A bucket is the case this exists to cover. */
45
+ store?: BlobStore,
34
46
  ): Promise<LoadedManifest[]> {
35
- const directory = join(workspaceRoot, layout.providers(profile));
47
+ const files = store ?? workspaceFiles(workspaceRoot);
48
+ const directory = layout.providers(profile);
36
49
 
37
- let entries: string[];
50
+ let keys: string[];
38
51
  try {
39
- entries = await readdir(directory);
52
+ keys = (await files.list(`${directory}/`)).map((entry) => entry.key);
40
53
  } catch {
41
54
  return []; // No custom providers is the normal case.
42
55
  }
43
56
 
44
57
  const loaded: LoadedManifest[] = [];
45
58
 
46
- for (const name of entries.sort()) {
59
+ for (const key of keys.sort()) {
60
+ const name = key.slice(directory.length + 1);
61
+
62
+ // A manifest is a file in this directory, not below it. An OpenAPI document
63
+ // a manifest points at may well sit in a subdirectory, and it is not one.
64
+ if (name.length === 0 || name.includes('/')) continue;
47
65
  if (!name.endsWith('.yaml') && !name.endsWith('.yml')) continue;
48
66
  if (name.endsWith('.example.yaml')) continue;
49
67
 
50
- const path = join(directory, name);
51
- loaded.push({ manifest: await parseManifestFile(path), path });
68
+ const text = await readWorkspaceFile(files, key);
69
+ if (text === null) continue; // Listed, then gone. Not worth failing over.
70
+
71
+ const where = describe(workspaceRoot, key);
72
+ loaded.push({
73
+ manifest: resolveSpecPath(parseManifest(text, where), workspaceRoot, key),
74
+ path: where,
75
+ });
52
76
  }
53
77
 
54
78
  return loaded;
55
79
  }
56
80
 
57
- export async function parseManifestFile(path: string): Promise<ProviderManifest> {
58
- return resolveSpecPath(parseManifest(await readFile(path, 'utf8'), path), path);
81
+ /** Where a manifest came from, in the spelling this workspace uses. */
82
+ function describe(workspaceRoot: string, key: string): string {
83
+ return isRemoteWorkspace(workspaceRoot) ? `${workspaceRoot}/${key}` : join(workspaceRoot, key);
59
84
  }
60
85
 
61
86
  /**
@@ -69,15 +94,34 @@ export async function parseManifestFile(path: string): Promise<ProviderManifest>
69
94
  *
70
95
  * Resolved against the manifest's own directory, the way one file referencing
71
96
  * another normally works.
97
+ *
98
+ * A bucket-hosted workspace has no such directory to resolve against: the
99
+ * generator wants a filesystem path or a URL, and `gs://…/spec.json` is
100
+ * neither. Refused here rather than at the first call, where it arrives as a
101
+ * discovery failure the runtime swallows — leaving a provider with no
102
+ * capabilities and nothing saying why.
72
103
  */
73
- function resolveSpecPath(manifest: ProviderManifest, source: string): ProviderManifest {
104
+ function resolveSpecPath(
105
+ manifest: ProviderManifest,
106
+ workspaceRoot: string,
107
+ key: string,
108
+ ): ProviderManifest {
74
109
  const connector = manifest.connector;
75
110
  if (connector.kind !== 'http') return manifest;
76
111
  if (/^https?:/i.test(connector.openapi) || isAbsolute(connector.openapi)) return manifest;
77
112
 
113
+ if (isRemoteWorkspace(workspaceRoot)) {
114
+ throw new ConfigError(
115
+ `${describe(workspaceRoot, key)}: openapi "${connector.openapi}" is a relative path, ` +
116
+ `but this workspace is ${workspaceRoot}. A document in a bucket cannot be opened as a ` +
117
+ 'file — publish the spec at a URL and name that instead.',
118
+ );
119
+ }
120
+
121
+ const directory = dirname(join(workspaceRoot, key));
78
122
  return {
79
123
  ...manifest,
80
- connector: { ...connector, openapi: resolve(dirname(source), connector.openapi) },
124
+ connector: { ...connector, openapi: resolve(directory, connector.openapi) },
81
125
  };
82
126
  }
83
127
 
@@ -111,5 +155,3 @@ export function parseManifest(text: string, source = '<manifest>'): ProviderMani
111
155
  throw new ConfigError(`${source}: ${(error as Error).message}`);
112
156
  }
113
157
  }
114
-
115
- /** A starting point, written out by `lanes link provider new`. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The scaffold `lanes link` writes, one per connectivity type.
2
+ * A hand-editable starting point, one per connectivity type.
3
3
  *
4
4
  * A custom provider is the same declaration a built-in is — this is the *only*
5
5
  * difference between `#providers/google/gmail/` and a file an operator drops in