@lanes-sh/link 0.2.2 → 0.3.1

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 (143) hide show
  1. package/README.md +22 -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 +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -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
  }
@@ -55,6 +55,15 @@ export interface SetupNeeds {
55
55
  * `connect` will never ask for.
56
56
  */
57
57
  readonly brokered: boolean;
58
+ /**
59
+ * What `--auth pasted_token` would ask for, where the provider offers it.
60
+ *
61
+ * Separate from `requirements` because it is an alternative rather than a
62
+ * prerequisite: nothing needs it unless the browser route is closed to you.
63
+ * Reported only when it is *not* the chosen route, since when it is chosen it
64
+ * is a requirement above and naming it twice would read as two values.
65
+ */
66
+ readonly pastedCredential: string | undefined;
58
67
  }
59
68
 
60
69
  /**
@@ -64,8 +73,23 @@ export interface SetupNeeds {
64
73
  * command someone is told to paste and a command the CLI suggests should not be
65
74
  * two different sentences.
66
75
  */
67
- function storeCommand(ref: string, placeholder: string, profile: string): string {
68
- return `printf %s "${placeholder}" | lanes link secrets set ${ref} --profile ${profile}`;
76
+ function storeCommand(ref: string, placeholder: string, where: Selection): string {
77
+ return (
78
+ `printf %s "${placeholder}" | lanes link secrets set ${ref}` +
79
+ ` --profile ${where.profile} --target ${where.target}`
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Which store a suggested command should write into.
85
+ *
86
+ * One object rather than two positional strings, because the pair travels
87
+ * together everywhere and a caller that transposed them would produce a command
88
+ * that runs, writes a credential, and puts it somewhere nobody looks.
89
+ */
90
+ export interface Selection {
91
+ readonly profile: string;
92
+ readonly target: string;
69
93
  }
70
94
 
71
95
  /** How the value is spelled, for a ref that several prompts combine into. */
@@ -80,15 +104,79 @@ function placeholderFor(prompts: readonly SetupPrompt[]): string {
80
104
  return '<value>';
81
105
  }
82
106
 
107
+ /**
108
+ * What the key route needs, which is the key and nothing else.
109
+ *
110
+ * Never `needsId`, and that is the substantive difference from the block below:
111
+ * the key is shared across every connection of a vendor, so its ref does not
112
+ * derive from a connection id and nothing has to be named before it can be
113
+ * stored. The subject does derive per connection — but it is not a requirement
114
+ * because it cannot be placed ahead of time: it lives inside the pointer that
115
+ * `connect` itself writes, and there is no `secrets set` that would put it
116
+ * there.
117
+ */
118
+ function assertionRequirements(
119
+ assertion: NonNullable<Extract<ProviderManifest['auth'], { kind: 'oauth' }>['assertion']>,
120
+ where: Selection,
121
+ ): SetupNeeds {
122
+ const shared = assertion.setup.prompts.filter((prompt) => prompt.scope === 'shared');
123
+
124
+ return {
125
+ requirements: shared.map((prompt) => ({
126
+ ref: assertion.key_ref,
127
+ label: prompt.label,
128
+ secret: prompt.secret,
129
+ scope: 'shared' as const,
130
+ prompts: [prompt.key],
131
+ command: storeCommand(assertion.key_ref, '<value>', where),
132
+ })),
133
+ needsId: false,
134
+ brokered: false,
135
+ pastedCredential: undefined,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Whether the manifest describes an OAuth client of the operator's own.
141
+ *
142
+ * `defineProvider` permits a broker, or a shipped client id, with no client
143
+ * prompts — a provider with no bring-your-own path is a legal thing to be. Every
144
+ * surface that offers that path has to ask this first, or it offers a route with
145
+ * nothing behind it: the chooser, the client resolver, and the setup plan.
146
+ *
147
+ * Here rather than in any of the three because it was already written twice in
148
+ * two different components under two different names, and this change would
149
+ * have made it three.
150
+ */
151
+ export function hasOwnClientPath(manifest: ProviderManifest): boolean {
152
+ return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
153
+ }
154
+
83
155
  export function setupRequirements(
84
156
  manifest: ProviderManifest,
85
157
  connectionId: string | undefined,
86
- profile: string,
158
+ where: Selection,
87
159
  options: {
88
160
  /** `oauth_apps` entries this profile declares — the clients that are its own. */
89
161
  readonly ownClients?: readonly string[];
162
+ /**
163
+ * Which way in, for a provider offering two.
164
+ *
165
+ * Defaults to the browser flow, which is every provider's only route until
166
+ * one declares `auth.assertion` — so an omitted value reports exactly what
167
+ * it always reported. Passed by `connect` once the operator has chosen,
168
+ * because the two methods need different things and reporting the union
169
+ * would tell someone to store a client they are not going to use.
170
+ */
171
+ readonly method?: 'oauth' | 'assertion' | 'pasted';
90
172
  } = {},
91
173
  ): SetupNeeds {
174
+ const assertion = manifest.auth.kind === 'oauth' ? manifest.auth.assertion : undefined;
175
+
176
+ if (options.method === 'assertion' && assertion) {
177
+ return assertionRequirements(assertion, where);
178
+ }
179
+
92
180
  const prompts = manifest.setup?.prompts ?? [];
93
181
 
94
182
  // A shared prompt exists to collect a client the operator registers. When a
@@ -101,7 +189,20 @@ export function setupRequirements(
101
189
  !(manifest.auth.app !== undefined && (options.ownClients ?? []).includes(manifest.auth.app));
102
190
 
103
191
  const shared = brokered ? [] : prompts.filter((prompt) => prompt.scope === 'shared');
104
- const perConnection = prompts.filter((prompt) => prompt.scope === 'connection');
192
+
193
+ // An OAuth provider's per-connection prompt belongs to one route, not to the
194
+ // provider — it is what `--auth pasted_token` asks for, and nothing the
195
+ // browser route ever needs. Reporting it either way would put a
196
+ // mandatory-looking field in front of somebody whose whole path is a browser
197
+ // round trip, which is the same mistake `brokered` avoids one line above.
198
+ //
199
+ // No OAuth manifest had one of these until Slack kept its pasted token as the
200
+ // way past a workspace that has not approved the Lanes app.
201
+ const connectionPrompts = prompts.filter((prompt) => prompt.scope === 'connection');
202
+ const routed = manifest.auth.kind !== 'oauth' || options.method === 'pasted';
203
+
204
+ const pasted = routed ? [] : connectionPrompts;
205
+ const perConnection = routed ? connectionPrompts : [];
105
206
 
106
207
  const requirements: SetupRequirement[] = [];
107
208
 
@@ -115,7 +216,7 @@ export function setupRequirements(
115
216
  secret: prompt.secret,
116
217
  scope: 'shared',
117
218
  prompts: [prompt.key],
118
- command: storeCommand(prompt.credential_ref, '<value>', profile),
219
+ command: storeCommand(prompt.credential_ref, '<value>', where),
119
220
  });
120
221
  }
121
222
 
@@ -133,7 +234,7 @@ export function setupRequirements(
133
234
  secret: perConnection.some((prompt) => prompt.secret),
134
235
  scope: 'connection',
135
236
  prompts: perConnection.map((prompt) => prompt.key),
136
- command: storeCommand(ref, placeholderFor(perConnection), profile),
237
+ command: storeCommand(ref, placeholderFor(perConnection), where),
137
238
  });
138
239
  }
139
240
  }
@@ -142,5 +243,7 @@ export function setupRequirements(
142
243
  requirements,
143
244
  needsId: perConnection.length > 0 && connectionId === undefined,
144
245
  brokered,
246
+ pastedCredential:
247
+ pasted.length > 0 ? pasted.map((prompt) => prompt.label).join(', then ') : undefined,
145
248
  };
146
249
  }
@@ -166,7 +166,16 @@ const BY_EXTENSION: Record<string, string> = {
166
166
  '.yml': 'application/yaml',
167
167
  };
168
168
 
169
- function inferContentType(path: string): string | undefined {
169
+ /**
170
+ * Exported so `github.ts` answers identically.
171
+ *
172
+ * That adapter has nowhere to *store* a content type — a sidecar there would be
173
+ * a file in the owner's own repository, listed beside their entries — so the
174
+ * extension is all it has. Sharing the map means a `.md` written locally and
175
+ * the same `.md` in a repository do not report different types, which is what
176
+ * `#stores/blobs/conformance.ts` exists to hold.
177
+ */
178
+ export function inferContentType(path: string): string | undefined {
170
179
  const dot = path.lastIndexOf('.');
171
180
  return dot === -1 ? undefined : BY_EXTENSION[path.slice(dot).toLowerCase()];
172
181
  }