@lanes-sh/link 0.2.2 → 0.3.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 (130) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,237 @@
1
+ import type { ProviderManifest } from '#connectivity';
2
+ import type { SecretStore } from '#secrets';
3
+ import { credentialRefForConnection } from '../../manifest/credential-ref.ts';
4
+ import { parseAssertionKey, signAssertion } from './key.ts';
5
+
6
+ /**
7
+ * OAuth 2.0 JWT bearer (RFC 7523) — a key the operator holds, in place of a
8
+ * person approving a consent screen.
9
+ *
10
+ * The one property that earns this its own folder: there is no refresh token,
11
+ * because there is nothing to refresh. A fresh assertion is signed whenever the
12
+ * last access token ages out, so nothing an issuer can expire sits between the
13
+ * operator and their data. An authorization-code refresh token is subject to
14
+ * whatever policy the issuer applies to it — one such policy expires them after
15
+ * seven days, and re-approving a browser screen every week is the failure this
16
+ * folder exists to remove.
17
+ *
18
+ * What it costs is reach. An assertion authenticates the *key*, and a key is
19
+ * not a person: it holds only what has been shared with it, unless the identity
20
+ * provider has been configured to let it act as someone, which is an
21
+ * administrator's grant rather than the operator's. `auth.assertion.delegation`
22
+ * on the manifest is which of the two a provider is, and the CLI is where that
23
+ * becomes a sentence.
24
+ */
25
+
26
+ /** RFC 7523's grant type, and the marker that identifies a stored credential as one. */
27
+ export const ASSERTION_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
28
+
29
+ /**
30
+ * What a connection stores when it authenticates this way.
31
+ *
32
+ * A pointer and not the key itself. One key covers every provider of a vendor,
33
+ * so it lives at a profile-shared ref and each connection records where to find
34
+ * it plus the one thing that genuinely differs per connection — who it acts as.
35
+ * Copying the key into seven connections would mean seven things to rotate.
36
+ */
37
+ export interface StoredAssertion {
38
+ readonly grant: typeof ASSERTION_GRANT;
39
+ readonly key_ref: string;
40
+ readonly subject?: string;
41
+ }
42
+
43
+ /**
44
+ * Whether a stored credential is one of these.
45
+ *
46
+ * Both methods write to the same ref — `<provider>/<connection>` — and this is
47
+ * what tells them apart. Shape rather than a flag in config, because
48
+ * `credentialResolver` is handed a registry and a store and never a connection
49
+ * row, so a declaration in config would be invisible exactly where the decision
50
+ * has to be made.
51
+ */
52
+ export function isStoredAssertion(value: unknown): value is StoredAssertion {
53
+ return (
54
+ typeof value === 'object' &&
55
+ value !== null &&
56
+ (value as { grant?: unknown }).grant === ASSERTION_GRANT &&
57
+ typeof (value as { key_ref?: unknown }).key_ref === 'string'
58
+ );
59
+ }
60
+
61
+ /**
62
+ * The stored credential for this connection, if it is an assertion pointer.
63
+ *
64
+ * `null` covers both "nothing stored" and "stored, but an authorization-code
65
+ * blob" — the caller wants the same thing in either case, which is to carry on
66
+ * down the path it was already on. Asked through `credentialRefForConnection`
67
+ * rather than by assembling the ref here, because two files deriving that
68
+ * separately is exactly the disagreement that function was extracted to end.
69
+ */
70
+ export async function storedAssertionFor(
71
+ manifest: ProviderManifest,
72
+ connectionId: string,
73
+ credentials: SecretStore,
74
+ ): Promise<StoredAssertion | null> {
75
+ const ref = credentialRefForConnection(manifest, connectionId);
76
+ if (!ref) return null;
77
+
78
+ const raw = await credentials.get(ref);
79
+ if (!raw) return null;
80
+
81
+ try {
82
+ const parsed: unknown = JSON.parse(raw);
83
+ return isStoredAssertion(parsed) ? parsed : null;
84
+ } catch {
85
+ // A credential that is not JSON at all is a pasted token, which is somebody
86
+ // else's case entirely. Not an error here.
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Minted tokens, for as long as this process lives.
93
+ *
94
+ * In memory rather than in the store, and that is a deliberate difference from
95
+ * the authorization-code path. There, the refresh token is the credential and
96
+ * persisting the rotation is the whole point. Here the credential is the key,
97
+ * which nothing at request time modifies — so writing the token back would make
98
+ * this ref rotatable, which a deployed revision would then need write access to
99
+ * bind, to cache something that costs one signature and one POST to remake.
100
+ */
101
+ const minted = new Map<string, { token: string; expiresAt: number }>();
102
+
103
+ /** Re-mint slightly early: a token that expires mid-flight fails the call it was fetched for. */
104
+ const EXPIRY_SKEW_MS = 60_000;
105
+
106
+ /**
107
+ * Emptied when a reload lands, and by tests.
108
+ *
109
+ * The cache key is `<provider>.<connection>` with no subject in it, so a
110
+ * connection re-connected to act as somebody else — or re-connected to a route
111
+ * that is not this one at all — would otherwise keep serving the token minted
112
+ * for who it used to be, for up to an hour after the config said otherwise.
113
+ * `server/generations.ts` clears this beside `clearUpstreamTokens`, which
114
+ * exists for the same reason on the other path.
115
+ */
116
+ export function clearMintedTokens(): void {
117
+ minted.clear();
118
+ }
119
+
120
+ interface TokenResponse {
121
+ readonly access_token?: string;
122
+ readonly expires_in?: number;
123
+ readonly error?: string;
124
+ readonly error_description?: string;
125
+ }
126
+
127
+ /**
128
+ * An access token for a connection that authenticates with a key.
129
+ *
130
+ * Reads the pointer, reads the key it names, signs, exchanges, caches. The
131
+ * manifest supplies the scopes and nothing else — where to exchange comes from
132
+ * the key file, so this stays a protocol implementation rather than a vendor's.
133
+ */
134
+ export async function resolveAssertionToken(input: {
135
+ readonly manifest: ProviderManifest;
136
+ readonly connectionId: string;
137
+ readonly stored: StoredAssertion;
138
+ readonly credentials: SecretStore;
139
+ readonly fetch?: typeof globalThis.fetch;
140
+ }): Promise<string> {
141
+ const { manifest, connectionId, stored, credentials } = input;
142
+ const cacheKey = `${manifest.id}.${connectionId}`;
143
+
144
+ const cached = minted.get(cacheKey);
145
+ if (cached && cached.expiresAt > Date.now() + EXPIRY_SKEW_MS) return cached.token;
146
+
147
+ const raw = await credentials.get(stored.key_ref);
148
+ if (!raw) {
149
+ throw new Error(
150
+ `No key stored at ${stored.key_ref}, which ${manifest.id}.${connectionId} authenticates with. ` +
151
+ `Run: lanes link connect ${manifest.id} --replace`,
152
+ );
153
+ }
154
+
155
+ const key = parseAssertionKey(raw, stored.key_ref);
156
+ const scopes = manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [];
157
+
158
+ const assertion = await signAssertion({
159
+ key,
160
+ scopes,
161
+ ...(stored.subject ? { subject: stored.subject } : {}),
162
+ });
163
+
164
+ const response = await (input.fetch ?? globalThis.fetch)(key.token_uri, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
167
+ body: new URLSearchParams({ grant_type: ASSERTION_GRANT, assertion }),
168
+ });
169
+
170
+ const body = (await response.json().catch(() => ({}))) as TokenResponse;
171
+
172
+ if (!response.ok || !body.access_token) {
173
+ throw new Error(refusalMessage(manifest, stored, body, response.status));
174
+ }
175
+
176
+ minted.set(cacheKey, {
177
+ token: body.access_token,
178
+ expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
179
+ });
180
+
181
+ return body.access_token;
182
+ }
183
+
184
+ /**
185
+ * Why the exchange was refused, in terms of what the operator can act on.
186
+ *
187
+ * Three of these are the whole population in practice and each has a different
188
+ * fix in a different console, so the raw `invalid_grant` is worth translating.
189
+ * An operator who reads only the error code goes looking in the wrong place —
190
+ * most often at the key, when the actual gap is a grant an administrator has
191
+ * not made yet.
192
+ */
193
+ function refusalMessage(
194
+ manifest: ProviderManifest,
195
+ stored: StoredAssertion,
196
+ body: TokenResponse,
197
+ status: number,
198
+ ): string {
199
+ const detail = body.error_description ?? body.error ?? `HTTP ${status}`;
200
+ const scopes = manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [];
201
+
202
+ const lines = [`${manifest.name} refused the key at ${stored.key_ref}: ${detail}`];
203
+
204
+ if (body.error === 'unauthorized_client') {
205
+ lines.push(
206
+ '',
207
+ stored.subject
208
+ ? ` The key is not permitted to act as ${stored.subject}. An administrator of that` +
209
+ '\n domain has to authorise this key for these scopes, all of them, exactly:'
210
+ : ' The key is not authorised for these scopes:',
211
+ ...scopes.map((scope) => ` ${scope}`),
212
+ '',
213
+ ' A partial list is refused the same way a missing one is.',
214
+ );
215
+ } else if (body.error === 'invalid_grant') {
216
+ // Listed rather than diagnosed. This one code covers an account that does
217
+ // not exist, a key that was deleted, a clock that is wrong, and a missing
218
+ // subject — and the description above is the only thing that distinguishes
219
+ // them. Asserting one of the four would send the reader to the wrong
220
+ // console three times in four, which is worse than naming all of them.
221
+ lines.push(
222
+ '',
223
+ ' The description above is the part that identifies which of these it is:',
224
+ ' - the account in the key no longer exists, or the key was deleted or disabled;',
225
+ " - this machine's clock is wrong by more than a few minutes, and an assertion is",
226
+ ' signed with a timestamp;',
227
+ ...(stored.subject
228
+ ? [` - ${stored.subject} is not an account the key may act as.`]
229
+ : [
230
+ ' - this account has to be reached by acting as someone, and this connection acts',
231
+ ` as nobody. Re-run and name one: lanes link connect ${manifest.id} --replace`,
232
+ ]),
233
+ );
234
+ }
235
+
236
+ return lines.join('\n');
237
+ }
@@ -0,0 +1,148 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The key half of RFC 7523: a private key on disk, and the assertion it signs.
5
+ *
6
+ * Split from `index.ts` because it is the only part with no I/O — no store, no
7
+ * network — which is what makes the claim set and the signature testable
8
+ * against a locally generated key rather than against a live token endpoint.
9
+ *
10
+ * Nothing here knows which vendor issued the key. The layout below is the one
11
+ * every authorization server that accepts this grant ships, and the endpoint to
12
+ * present the assertion at comes from the key file itself rather than from a
13
+ * constant, so adding a second vendor is a manifest and no code.
14
+ */
15
+
16
+ /**
17
+ * The issued key file, as the vendor's console writes it.
18
+ *
19
+ * Parsed rather than trusted: the common mistake is pasting the *client* JSON —
20
+ * the one with `installed` or `web` at the top level — which is a different
21
+ * file with a different purpose and would otherwise fail much later, at the
22
+ * token endpoint, as an unexplained 400.
23
+ */
24
+ export const assertionKeySchema = z.object({
25
+ /** Who the assertion is from. Also the address a resource is shared with. */
26
+ client_email: z.string().min(1),
27
+ /** PKCS#8 PEM. */
28
+ private_key: z.string().min(1),
29
+ /** Where the assertion is exchanged. Read from the file so no vendor is named here. */
30
+ token_uri: z.url(),
31
+ /** Names which key signed it, for a server holding more than one. */
32
+ private_key_id: z.string().optional(),
33
+ });
34
+
35
+ export type AssertionKey = z.infer<typeof assertionKeySchema>;
36
+
37
+ export function parseAssertionKey(raw: string, ref: string): AssertionKey {
38
+ let json: unknown;
39
+ try {
40
+ json = JSON.parse(raw);
41
+ } catch {
42
+ throw new Error(
43
+ `The key at ${ref} is not JSON. It should be the whole file the console downloaded, pasted verbatim.`,
44
+ );
45
+ }
46
+
47
+ const parsed = assertionKeySchema.safeParse(json);
48
+ if (parsed.success) return parsed.data;
49
+
50
+ // The two files are easy to confuse and the console offers both on adjacent
51
+ // pages, so say which one is in hand rather than listing missing fields.
52
+ const shape = json as Record<string, unknown> | null;
53
+ if (shape && (shape['installed'] !== undefined || shape['web'] !== undefined)) {
54
+ throw new Error(
55
+ `The key at ${ref} is an OAuth *client* file, not an account key. That one is for the ` +
56
+ 'browser flow. The key needed here is downloaded from the account itself and has ' +
57
+ '"private_key" in it.',
58
+ );
59
+ }
60
+
61
+ throw new Error(
62
+ `The key at ${ref} is missing ${parsed.error.issues.map((issue) => issue.path.join('.')).join(', ')}.`,
63
+ );
64
+ }
65
+
66
+ /** JWT's own encoding: base64 with a URL-safe alphabet and no padding. */
67
+ function base64url(bytes: Uint8Array | string): string {
68
+ const raw =
69
+ typeof bytes === 'string'
70
+ ? btoa(bytes)
71
+ : btoa(String.fromCharCode(...new Uint8Array(bytes)));
72
+ return raw.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
73
+ }
74
+
75
+ /**
76
+ * PKCS#8 PEM to the DER bytes `importKey` wants.
77
+ *
78
+ * Tolerant of how the key arrives: a console download carries real newlines, a
79
+ * value pasted through an environment variable or a JSON string often carries
80
+ * literal `\n` instead, and both are the same key. Rejecting the second would
81
+ * be a failure whose cause is invisible in a terminal.
82
+ */
83
+ function derFromPem(pem: string): Uint8Array {
84
+ const body = pem
85
+ .replaceAll('\\n', '\n')
86
+ .replace(/-----[^-]+-----/g, '')
87
+ .replace(/\s+/g, '');
88
+
89
+ const binary = atob(body);
90
+ const der = new Uint8Array(binary.length);
91
+ for (let index = 0; index < binary.length; index += 1) der[index] = binary.charCodeAt(index);
92
+ return der;
93
+ }
94
+
95
+ /** How long the assertion is good for. Kept short: it is minted per exchange. */
96
+ const ASSERTION_LIFETIME_SECONDS = 3600;
97
+
98
+ /**
99
+ * Sign the assertion this grant exchanges for a token.
100
+ *
101
+ * `sub` is what makes one identity act as another, and it is present only when
102
+ * the caller supplies one — an assertion carrying an empty `sub` is not the
103
+ * same request as one carrying none, and servers treat it as malformed rather
104
+ * than as absent.
105
+ */
106
+ export async function signAssertion(input: {
107
+ readonly key: AssertionKey;
108
+ readonly scopes: readonly string[];
109
+ /** The account to act as, where the key is only permitted to borrow one. */
110
+ readonly subject?: string | undefined;
111
+ /** Injected by tests so the claim set is checkable. */
112
+ readonly now?: number;
113
+ }): Promise<string> {
114
+ const issuedAt = Math.floor((input.now ?? Date.now()) / 1000);
115
+
116
+ const header = {
117
+ alg: 'RS256',
118
+ typ: 'JWT',
119
+ ...(input.key.private_key_id ? { kid: input.key.private_key_id } : {}),
120
+ };
121
+
122
+ const claims = {
123
+ iss: input.key.client_email,
124
+ scope: input.scopes.join(' '),
125
+ aud: input.key.token_uri,
126
+ iat: issuedAt,
127
+ exp: issuedAt + ASSERTION_LIFETIME_SECONDS,
128
+ ...(input.subject ? { sub: input.subject } : {}),
129
+ };
130
+
131
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`;
132
+
133
+ const privateKey = await crypto.subtle.importKey(
134
+ 'pkcs8',
135
+ derFromPem(input.key.private_key) as unknown as ArrayBuffer,
136
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
137
+ false,
138
+ ['sign'],
139
+ );
140
+
141
+ const signature = await crypto.subtle.sign(
142
+ 'RSASSA-PKCS1-v1_5',
143
+ privateKey,
144
+ new TextEncoder().encode(signingInput) as unknown as ArrayBuffer,
145
+ );
146
+
147
+ return `${signingInput}.${base64url(new Uint8Array(signature))}`;
148
+ }
@@ -42,7 +42,7 @@ export function credentialResolver(
42
42
  const ref = credentialRefForConnection(manifest, connectionId)!;
43
43
  const value = await secrets.get(ref);
44
44
  if (!value) {
45
- throw new Error(`No credential stored at ${ref}. Run: lanes link connect ${providerId}`);
45
+ throw new Error(`No credential stored at ${ref}. Connecting "${providerId}" again for this profile and target would store one.`);
46
46
  }
47
47
 
48
48
  switch (auth.kind) {
@@ -2,6 +2,7 @@ import { type ProviderManifest } from '#connectivity';
2
2
  import type { ProviderRegistry } from '#registry';
3
3
  import type { SecretStore } from '#secrets';
4
4
  import { credentialResolver } from './resolve.ts';
5
+ import { resolveAssertionToken, storedAssertionFor } from './oauth-jwt/index.ts';
5
6
  import { CredentialOAuthProvider } from './oauth-authcode/provider.ts';
6
7
 
7
8
  /**
@@ -81,6 +82,16 @@ export async function bearerTokenAsStored(
81
82
  ): Promise<string | null> {
82
83
  if (manifest.auth.kind !== 'oauth') return bearerToken(manifest, connectionId, secrets);
83
84
 
85
+ // An assertion credential has no token stored to prefer — the token is minted
86
+ // from the key, which is what "as stored" means here. Without this branch the
87
+ // identity call `connect` makes immediately after writing the pointer reads
88
+ // `access_token` off a blob that has none, sends no Authorization header, and
89
+ // reports the credential rejected.
90
+ const assertion = await storedAssertionFor(manifest, connectionId, secrets);
91
+ if (assertion) {
92
+ return resolveAssertionToken({ manifest, connectionId, stored: assertion, credentials: secrets });
93
+ }
94
+
84
95
  const provider = new CredentialOAuthProvider({
85
96
  manifest,
86
97
  connectionId,
@@ -53,6 +53,7 @@ export { defineLocalProvider, defineProviderWithCapabilities } from './provider.
53
53
  export type {
54
54
  ProviderManifest,
55
55
  ConnectorConfig,
56
+ AuthAssertion,
56
57
  AuthBroker,
57
58
  AuthConfig,
58
59
  SetupDeclaration,
@@ -69,6 +70,7 @@ export {
69
70
  bundleSchema,
70
71
  credentialRefForConnection,
71
72
  rotatableCredentialRefs,
73
+ hasOwnClientPath,
72
74
  setupRequirements,
73
75
  UNNAMED_ID,
74
76
  RESERVED_PROVIDER_IDS,
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { credentialRef, identifier } from './primitives.ts';
3
+ import { setupSchema } from './setup.ts';
3
4
 
4
5
  /**
5
6
  * Credential types — how we prove who we are, orthogonal to how we connect.
@@ -34,14 +35,86 @@ export const authBrokerSchema = z.object({
34
35
 
35
36
  export type AuthBroker = z.infer<typeof authBrokerSchema>;
36
37
 
38
+ /**
39
+ * A second way in, for an authorization server that also accepts an assertion.
40
+ *
41
+ * RFC 7523: instead of a person approving a consent screen, the operator holds
42
+ * a private key, signs a short-lived JWT with it, and exchanges that for an
43
+ * access token. There is no refresh token because there is nothing to refresh —
44
+ * a new assertion is signed whenever the last token ages out — which is the
45
+ * whole reason this exists beside `oauth`. An authorization-code refresh token
46
+ * can be expired by the issuer's own policy; a key the operator holds cannot.
47
+ *
48
+ * Declared *on* the OAuth block rather than as a fourth `kind`, because it is
49
+ * an alternative arrangement for the same provider rather than a different
50
+ * provider. Everything that branches on `kind` — where the credential lands,
51
+ * what setup requires, which refs a deployed revision may rewrite — is
52
+ * unchanged, and a manifest that omits this reads and behaves exactly as before.
53
+ *
54
+ * Which one a connection actually uses is not recorded here or in config. The
55
+ * stored credential's *shape* is the switch, the same way an `oauth_apps` entry
56
+ * is the switch between a broker's client and the operator's own.
57
+ */
58
+ export const authAssertionSchema = z.object({
59
+ /**
60
+ * What the operator types after `--auth`, and how a chosen method is named
61
+ * back to them.
62
+ *
63
+ * The provider's word rather than the protocol's. "Assertion" is what this is
64
+ * to the authorization server and means nothing to the person holding the
65
+ * file; they downloaded a service account key, and that is what the prompt
66
+ * and the flag should say. Keeping it here is also what stops the CLI from
67
+ * learning a vendor's vocabulary in order to print it.
68
+ */
69
+ method: identifier,
70
+ /** The short name shown beside the choice — a noun, not a sentence. */
71
+ label: z.string().min(1),
72
+ /**
73
+ * Whether the assertion may stand for itself, or must name a user to act as.
74
+ *
75
+ * `optional` — the key is an identity in its own right, and reaches whatever
76
+ * has been shared with it. `required` — it can only borrow someone else's,
77
+ * so a connection without a subject would authenticate cleanly and then find
78
+ * nothing there. The CLI refuses a blank subject on `required` for that
79
+ * reason: the failure is otherwise a 404 on every call with no explanation.
80
+ */
81
+ delegation: z.enum(['optional', 'required']).default('optional'),
82
+ /**
83
+ * Where the profile-shared key lives.
84
+ *
85
+ * Shared, not per-connection: one key covers every provider of a vendor, and
86
+ * asking for it once per provider would be seven pastes of the same file.
87
+ * The per-connection half is the subject, which is not a secret and is small
88
+ * enough to sit in the pointer the connection stores.
89
+ */
90
+ key_ref: credentialRef,
91
+ /** One line for the choice prompt: what this method reaches, and what it does not. */
92
+ reach: z.string().min(1),
93
+ /**
94
+ * What to call the account this acts as, when it acts as one.
95
+ *
96
+ * Asked per connection and stored beside the pointer. Not a secret — it is an
97
+ * address — but it lives in the credential store rather than in config
98
+ * because it is half of a credential, and splitting a credential across two
99
+ * files is how the halves come to disagree.
100
+ */
101
+ subject_label: z.string().min(1),
102
+ /** The console walkthrough for this method, rendered by the same code as `setup`. */
103
+ setup: setupSchema,
104
+ });
105
+
106
+ export type AuthAssertion = z.infer<typeof authAssertionSchema>;
107
+
37
108
  export const authOAuthSchema = z.object({
38
109
  kind: z.literal('oauth'),
39
110
  /**
40
111
  * `dynamic` — the authorization server offers Dynamic Client Registration, so
41
112
  * we register ourselves and the operator does nothing at all (Notion, Linear).
42
113
  *
43
- * `manual` — the vendor requires a pre-registered client, so the operator
44
- * supplies an id and secret (Google, including for Google's own MCP servers).
114
+ * `manual` — the vendor requires a pre-registered client. Who supplies it is
115
+ * the profile's to decide and not this field's: an id and secret the operator
116
+ * registered (Google, if they choose to), or the client behind `broker`.
117
+ * `manual` says only that self-registration is not on offer.
45
118
  */
46
119
  registration: z.enum(['dynamic', 'manual']).default('dynamic'),
47
120
  /** Which `oauth_apps` entry holds the client, for `manual`. Shared across providers of a vendor. */
@@ -57,6 +130,30 @@ export const authOAuthSchema = z.object({
57
130
  * that claimed one or the other would be wrong half the time.
58
131
  */
59
132
  broker: authBrokerSchema.optional(),
133
+ /**
134
+ * The other way in, where the vendor offers one. Absent means browser or nothing.
135
+ *
136
+ * Additive and inert on its own: declaring it makes `connect` offer a choice
137
+ * and makes the resolver able to read an assertion credential. It changes
138
+ * nothing about a connection that authorised in a browser.
139
+ */
140
+ assertion: authAssertionSchema.optional(),
141
+ /**
142
+ * Whether a token response carrying no refresh token is a failure.
143
+ *
144
+ * `required` — it is, and stopping is kinder than succeeding: the connection
145
+ * would work until the access token expires and then quietly stop. Google
146
+ * omits one when the account was already authorised for the app, which is a
147
+ * real and recoverable mistake.
148
+ *
149
+ * `optional` — the vendor issues a long-lived token and no refresh token is
150
+ * the normal, successful answer. Slack does this unless token rotation is
151
+ * enabled on the app, so demanding one would refuse every connection that
152
+ * worked.
153
+ */
154
+ refresh_token: z.enum(['required', 'optional']).default('required'),
155
+ /** Where the operator withdraws a grant, named in the refusal above. */
156
+ revoke_url: z.url().optional(),
60
157
  scopes: z.array(z.string()).default([]),
61
158
  /**
62
159
  * Usually discovered from the resource's metadata; set only to override.
@@ -30,6 +30,18 @@ export const identitySchema = z.discriminatedUnion('kind', [
30
30
  url: z.url(),
31
31
  /** Dotted path into the JSON body, e.g. `emailAddress` or `user.emailAddress`. */
32
32
  field: z.string().min(1),
33
+ /**
34
+ * A second path, shown in brackets, where `field` alone is not unique.
35
+ *
36
+ * Almost no provider needs one: an address identifies a Google or iCloud
37
+ * account globally, and a GitHub login is unique across GitHub. Slack is
38
+ * the exception, because the thing it calls a user is scoped to a
39
+ * workspace — the same person in two workspaces answers `auth.test` with
40
+ * the same `user`, and one account string is how `settleIdentity` decides a
41
+ * connect is a *reconnect*. Without this, connecting a second workspace
42
+ * matches the first and overwrites its credential.
43
+ */
44
+ qualifier: z.string().min(1).optional(),
33
45
  }),
34
46
  z.object({
35
47
  kind: z.literal('tool'),
@@ -22,11 +22,13 @@ export {
22
22
 
23
23
  export {
24
24
  authNoneSchema,
25
+ authAssertionSchema,
25
26
  authBrokerSchema,
26
27
  authOAuthSchema,
27
28
  authSchema,
28
29
  authStrategySchema,
29
30
  authTokenSchema,
31
+ type AuthAssertion,
30
32
  type AuthBroker,
31
33
  type AuthConfig,
32
34
  } from './auth.ts';
@@ -43,4 +45,4 @@ export {
43
45
  } from './provider.ts';
44
46
 
45
47
  export type { SetupRequirement, SetupNeeds } from './requirements.ts';
46
- export { setupRequirements, UNNAMED_ID } from './requirements.ts';
48
+ export { hasOwnClientPath, setupRequirements, UNNAMED_ID } from './requirements.ts';
@@ -65,7 +65,7 @@ export const providerManifestSchema = z.object({
65
65
  export type ProviderManifest = z.infer<typeof providerManifestSchema>;
66
66
 
67
67
  /** Provider ids reserved for the owner layer. */
68
- export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup'];
68
+ export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup', 'identity'];
69
69
 
70
70
  /**
71
71
  * Validate a manifest, with the cross-field rules the schema alone cannot
@@ -114,18 +114,47 @@ export function defineProvider(input: unknown): ProviderManifest {
114
114
  `Provider "${manifest.id}": a broker supplies a pre-registered client, so auth must declare registration "manual" and an "app" naming the oauth_apps entry that overrides it.`,
115
115
  );
116
116
  }
117
- // An MCP provider hands the exchange to the SDK, which posts to the token
118
- // endpoint with whatever `clientInformation()` returned. There is no seam
119
- // to route that through a broker without reimplementing its auth path, so
120
- // this is refused at definition rather than discovered after consent.
117
+ if (!manifest.auth.authorize_url) {
118
+ throw new Error(
119
+ `Provider "${manifest.id}": a broker performs the exchange, but the browser still goes to the vendor, so auth.authorize_url is required.`,
120
+ );
121
+ }
122
+ // An MCP provider hands the whole flow to the SDK, which posts to the token
123
+ // endpoint with whatever `clientInformation()` returned and has nowhere to
124
+ // route an exchange somebody else performs. Declaring both endpoints is
125
+ // what opts it off that path and onto the direct one, where the exchange is
126
+ // ours — so on an mcp connector the two arrive together or the manifest is
127
+ // describing a flow that cannot run. Refused here rather than discovered
128
+ // after the operator has already approved a consent screen. See ADR-040.
129
+ if (manifest.connector.kind === 'mcp' && !manifest.auth.token_url) {
130
+ throw new Error(
131
+ `Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot route it through a broker, unless the manifest declares its own endpoints. Add auth.token_url beside auth.authorize_url, or drop the broker and register dynamically.`,
132
+ );
133
+ }
134
+ }
135
+
136
+ if (manifest.auth.kind === 'oauth' && manifest.auth.assertion) {
137
+ // Same seam, same absence as the broker rule above. The SDK owns an mcp
138
+ // provider's exchange and takes a client, not a signed assertion — so the
139
+ // choice would be offered, accepted, and then have nowhere to go.
121
140
  if (manifest.connector.kind === 'mcp') {
122
141
  throw new Error(
123
- `Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot route it through a broker. Register a client (registration "manual") or use dynamic registration.`,
142
+ `Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot present a signed assertion. Remove auth.assertion.`,
124
143
  );
125
144
  }
126
- if (!manifest.auth.authorize_url) {
145
+ // The assertion carries `aud` from the key file, but the *scopes* it claims
146
+ // come from the manifest. A provider requesting none would mint a token
147
+ // permitted to do nothing and only find out at the first call.
148
+ if (manifest.auth.scopes.length === 0) {
127
149
  throw new Error(
128
- `Provider "${manifest.id}": a broker performs the exchange, but the browser still goes to the vendor, so auth.authorize_url is required.`,
150
+ `Provider "${manifest.id}": auth.assertion exchanges a signed assertion for a token scoped to auth.scopes, which is empty. There would be nothing to grant.`,
151
+ );
152
+ }
153
+ // The whole point of the alternative is that it asks for something. A block
154
+ // with no prompt reaches the walkthrough and then has nothing to collect.
155
+ if (manifest.auth.assertion.setup.prompts.length === 0) {
156
+ throw new Error(
157
+ `Provider "${manifest.id}": auth.assertion declares no setup prompts, so there is no way to learn what key to ask for.`,
129
158
  );
130
159
  }
131
160
  }