@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,187 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { completionPage } from './callback-page.ts';
3
+ import { OAuthError } from './oauth-error.ts';
4
+
5
+ /**
6
+ * The loopback listener both OAuth paths come back to.
7
+ *
8
+ * Two callers want the same door and want it differently. `runOAuthFlow` drives
9
+ * the whole protocol and reads the code itself; `captureOAuthCallback` hands the
10
+ * protocol to the MCP SDK and supplies only somewhere for the browser to land.
11
+ * What they share is everything about the listener — that it binds loopback,
12
+ * serves exactly one callback, compares `state` in constant time, leaves a page
13
+ * a person can read, and drains before it closes — and holding that in one file
14
+ * is what stops the two drifting into disagreeing about it.
15
+ *
16
+ * Split out of `oauth.ts` when that file passed the size budget, along the seam
17
+ * the budget exists to find: the flow, and the door it knocks on.
18
+ */
19
+
20
+ const base64url = (input: Buffer): string => input.toString('base64url');
21
+
22
+ /** Compare the returned state in constant time — it is a CSRF defence. */
23
+ export function stateMatches(expected: string, received: string): boolean {
24
+ const a = createHash('sha256').update(expected).digest();
25
+ const b = createHash('sha256').update(received).digest();
26
+ return timingSafeEqual(a, b);
27
+ }
28
+
29
+ /**
30
+ * What the browser is left looking at, in the two shapes it comes in.
31
+ *
32
+ * The label is what the caller knows and this file cannot: which provider the
33
+ * grant was for. Given one, the page names it under "Connected" the way the
34
+ * invite page names the workspace; without one it says only that the flow
35
+ * finished, which is what every path said before.
36
+ */
37
+ export const connectedPage = (label?: string): Response =>
38
+ completionPage({
39
+ ...(label ? { label: 'Connected', heading: label } : { heading: 'Connected' }),
40
+ detail: 'You can close this tab and return to the terminal.',
41
+ ok: true,
42
+ });
43
+
44
+ export const failedPage = (detail: string): Response =>
45
+ completionPage({ heading: 'Authorization failed', detail, ok: false });
46
+
47
+ /**
48
+ * Close the listener without cutting off the response in flight.
49
+ *
50
+ * Forcing the socket shut the instant the code is read leaves the operator
51
+ * looking at a connection error on a flow that in fact succeeded. So: stop
52
+ * gracefully, wait for the in-flight callback to drain, and force only if
53
+ * graceful genuinely did not finish — a keep-alive connection must not be able
54
+ * to hold the CLI open forever either.
55
+ */
56
+ export async function shutdown(server: { stop(force?: boolean): Promise<void> }): Promise<void> {
57
+ let drained = false;
58
+ const graceful = server.stop().then(() => {
59
+ drained = true;
60
+ });
61
+
62
+ await withTimeout(graceful, 2_000).catch(() => {});
63
+ if (!drained) await server.stop(true);
64
+ }
65
+
66
+ /**
67
+ * Race a promise against a deadline, and **clear the timer either way**.
68
+ *
69
+ * A bare `Promise.race` with `setTimeout` leaks: when the real promise wins,
70
+ * the timer is still pending, and a pending timer keeps the event loop alive
71
+ * for its full duration. With a five-minute OAuth deadline that turns a
72
+ * finished `connect` into a terminal that prints "Next: lanes link start" and
73
+ * then sits there for five minutes — which is exactly what it did.
74
+ *
75
+ * Rejects with `OAuthError(message)` if given one, otherwise resolves to
76
+ * undefined at the deadline, which is what the shutdown path wants.
77
+ */
78
+ export async function withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
79
+ let timer: ReturnType<typeof setTimeout> | undefined;
80
+
81
+ try {
82
+ return await Promise.race([
83
+ promise,
84
+ new Promise<T>((resolve, reject) => {
85
+ timer = setTimeout(
86
+ () => (message ? reject(new OAuthError(message)) : resolve(undefined as T)),
87
+ ms,
88
+ );
89
+ }),
90
+ ]);
91
+ } finally {
92
+ if (timer) clearTimeout(timer);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Capture exactly one OAuth callback on loopback.
98
+ *
99
+ * Split out from `runOAuthFlow` so the SDK can drive the protocol — discovery,
100
+ * registration, PKCE, exchange — while we supply only the redirect target. The
101
+ * listener exists for the duration of one consent and no longer.
102
+ */
103
+ export interface CallbackCapture {
104
+ readonly redirectUri: string;
105
+ /**
106
+ * The `state` this listener will accept, for the provider to put on the
107
+ * authorization URL. The SDK asks its provider for one and sends whatever it
108
+ * gets; nothing generates it for us.
109
+ */
110
+ readonly state: string;
111
+ wait(timeoutMs?: number): Promise<{ code: string; iss?: string }>;
112
+ close(): Promise<void>;
113
+ }
114
+
115
+ export function captureOAuthCallback(options: { label?: string } = {}): CallbackCapture {
116
+ const state = base64url(randomBytes(24));
117
+ let resolveCode: (value: { code: string; iss?: string }) => void;
118
+ let rejectCode: (error: Error) => void;
119
+
120
+ const received = new Promise<{ code: string; iss?: string }>((resolve, reject) => {
121
+ resolveCode = resolve;
122
+ rejectCode = reject;
123
+ });
124
+
125
+ const server = Bun.serve({
126
+ hostname: '127.0.0.1',
127
+ port: 0,
128
+ fetch(request) {
129
+ const url = new URL(request.url);
130
+ if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
131
+
132
+ const error = url.searchParams.get('error');
133
+ if (error) {
134
+ rejectCode(
135
+ new OAuthError(
136
+ error === 'access_denied'
137
+ ? 'Authorization was declined in the browser.'
138
+ : `Authorization failed: ${error}`,
139
+ ),
140
+ );
141
+ return failedPage('You can close this tab.');
142
+ }
143
+
144
+ const code = url.searchParams.get('code');
145
+ if (!code) {
146
+ rejectCode(new OAuthError('The callback carried no authorization code.'));
147
+ return failedPage('No code returned.');
148
+ }
149
+
150
+ // Checked here because nothing else checks it. The SDK's own docs say it
151
+ // does not validate `state`, and it only sends one if the provider hands
152
+ // it over — so this listener mints the value, `CredentialOAuthProvider`
153
+ // returns it from `state()`, and the two are compared on the way back.
154
+ //
155
+ // Without it this resolves on the first `/callback?code=` to reach the
156
+ // port, whoever sent it: any local process, or a page the operator has
157
+ // open, can sweep loopback during the five-minute window. PKCE usually
158
+ // turns that into a failed exchange, but a manifest is free to name an
159
+ // authorization server that does not enforce it, and there the code
160
+ // would be redeemed and the connection bound to someone else's account.
161
+ const returnedState = url.searchParams.get('state');
162
+ if (!returnedState || !stateMatches(state, returnedState)) {
163
+ rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
164
+ return failedPage('Unexpected callback.');
165
+ }
166
+
167
+ const iss = url.searchParams.get('iss');
168
+ resolveCode({ code, ...(iss ? { iss } : {}) });
169
+ return connectedPage(options.label);
170
+ },
171
+ });
172
+
173
+ return {
174
+ redirectUri: `http://127.0.0.1:${server.port}/callback`,
175
+ state,
176
+
177
+ async wait(timeoutMs = 5 * 60_000) {
178
+ return withTimeout(
179
+ received,
180
+ timeoutMs,
181
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser.`,
182
+ );
183
+ },
184
+
185
+ close: () => shutdown(server),
186
+ };
187
+ }
@@ -13,9 +13,18 @@ import { OAuthError } from './oauth-error.ts';
13
13
  */
14
14
 
15
15
  export interface OAuthTokens {
16
- readonly refreshToken: string;
16
+ /** Absent where the vendor issues a long-lived token and never renews it. */
17
+ readonly refreshToken?: string;
17
18
  readonly accessToken: string;
18
- readonly expiresIn: number;
19
+ /**
20
+ * Absent where the vendor states no lifetime.
21
+ *
22
+ * Not defaulted to an hour: with no refresh token there is nothing to renew
23
+ * with, so a made-up expiry would have `doctor` reporting a healthy
24
+ * connection as stale forever and would tell the operator to re-run a command
25
+ * that fixes nothing.
26
+ */
27
+ readonly expiresIn?: number;
19
28
  readonly scope: string;
20
29
  /**
21
30
  * An identity assertion, present when `openid` was granted.
@@ -38,11 +47,37 @@ export interface ExchangeInput {
38
47
 
39
48
  export type ExchangeCode = (input: ExchangeInput) => Promise<OAuthTokens>;
40
49
 
50
+ /**
51
+ * What a response without a refresh token means for this vendor.
52
+ *
53
+ * The two readings are opposite and neither is guessable from the response.
54
+ * Google omitting one means the account was already authorised and the
55
+ * connection will die in an hour — worth stopping for. Slack omitting one is
56
+ * the ordinary success: a user token is long-lived unless token rotation is
57
+ * switched on, so demanding one would refuse every connection that worked.
58
+ */
59
+ export interface RefreshTokenPolicy {
60
+ readonly required: boolean;
61
+ /** Named in the refusal, so it is not always the vendor who first needed it. */
62
+ readonly vendor: string;
63
+ /** Where the operator withdraws the existing grant, when the manifest says. */
64
+ readonly revokeUrl?: string | undefined;
65
+ }
66
+
41
67
  /** Straight to the vendor, signed with a client this machine holds. */
42
68
  export function directExchange(options: {
43
69
  readonly tokenUrl: string;
44
70
  readonly clientId: string;
45
- readonly clientSecret: string;
71
+ /**
72
+ * Absent for a public client, where PKCE is the whole of the protection.
73
+ *
74
+ * A client id shipped in a public repository has no secret to go with it, and
75
+ * inventing an empty one would be sent as `client_secret=` — which some
76
+ * authorization servers read as a malformed confidential client rather than
77
+ * as a public one, and refuse for a reason that names neither.
78
+ */
79
+ readonly clientSecret?: string | undefined;
80
+ readonly refreshToken: RefreshTokenPolicy;
46
81
  readonly fetch?: typeof globalThis.fetch | undefined;
47
82
  }): ExchangeCode {
48
83
  return async (input) => {
@@ -56,7 +91,7 @@ export function directExchange(options: {
56
91
  code: input.code,
57
92
  redirect_uri: input.redirectUri,
58
93
  client_id: options.clientId,
59
- client_secret: options.clientSecret,
94
+ ...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
60
95
  code_verifier: input.codeVerifier,
61
96
  }),
62
97
  });
@@ -71,19 +106,23 @@ export function directExchange(options: {
71
106
  error_description?: string;
72
107
  };
73
108
 
109
+ // Not every vendor signals failure with a status. Slack answers a refused
110
+ // exchange with HTTP 200 and `{ok: false, error: ...}`, so the absent token
111
+ // is what has to be trusted here rather than the code.
74
112
  if (!response.ok || !body.access_token) {
75
113
  throw new OAuthError(
76
114
  `Token exchange failed: ${body.error ?? response.status} ${body.error_description ?? ''}`.trim(),
77
115
  );
78
116
  }
79
117
 
80
- return settle(body, input.scopes);
118
+ return settle(body, input.scopes, options.refreshToken);
81
119
  };
82
120
  }
83
121
 
84
122
  /** Through a broker, which holds the secret this machine does not have. */
85
123
  export function brokerExchangeVia(options: {
86
124
  readonly url: string;
125
+ readonly refreshToken: RefreshTokenPolicy;
87
126
  readonly fetch?: typeof globalThis.fetch | undefined;
88
127
  }): ExchangeCode {
89
128
  return async (input) => {
@@ -110,7 +149,7 @@ export function brokerExchangeVia(options: {
110
149
  throw new OAuthError('The token exchange returned no access token.');
111
150
  }
112
151
 
113
- return settle(tokens, input.scopes);
152
+ return settle(tokens, input.scopes, options.refreshToken);
114
153
  };
115
154
  }
116
155
 
@@ -123,23 +162,26 @@ function settle(
123
162
  scope?: string | undefined;
124
163
  },
125
164
  scopes: readonly string[],
165
+ refreshToken: RefreshTokenPolicy,
126
166
  ): OAuthTokens {
127
- if (!body.refresh_token) {
167
+ if (!body.refresh_token && refreshToken.required) {
128
168
  // Without one, the connection works until the access token expires and then
129
- // quietly stops. Better to fail now with the actual cause. Google is named
130
- // because Google is who reaches this path: every provider that redeems a
131
- // code here is a Google REST one, and the others hand the exchange to the
132
- // MCP SDK. Naming the page beats describing it.
169
+ // quietly stops. Better to fail now with the actual cause. The vendor is
170
+ // named rather than assumed: this used to say "Google" on the grounds that
171
+ // Google was the only provider redeeming a code here, and it is not any
172
+ // more.
133
173
  throw new OAuthError(
134
- 'Google returned no refresh token. This usually means the account was already authorised ' +
135
- 'for this app; revoke it at https://myaccount.google.com/permissions and try again.',
174
+ `${refreshToken.vendor} returned no refresh token. This usually means the account was ` +
175
+ 'already authorised for this app; revoke it' +
176
+ (refreshToken.revokeUrl ? ` at ${refreshToken.revokeUrl}` : '') +
177
+ ' and try again.',
136
178
  );
137
179
  }
138
180
 
139
181
  return {
140
- refreshToken: body.refresh_token,
182
+ ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
141
183
  accessToken: body.access_token!,
142
- expiresIn: body.expires_in ?? 3600,
184
+ ...(body.expires_in !== undefined ? { expiresIn: body.expires_in } : {}),
143
185
  scope: body.scope ?? scopes.join(' '),
144
186
  ...(body.id_token ? { idToken: body.id_token } : {}),
145
187
  };
package/src/cli/oauth.ts CHANGED
@@ -1,7 +1,20 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
- import { completionPage } from './callback-page.ts';
3
2
  import { OAuthError } from './oauth-error.ts';
4
- import { directExchange, type ExchangeCode, type OAuthTokens } from './oauth-exchange.ts';
3
+ import {
4
+ captureOAuthCallback,
5
+ connectedPage,
6
+ failedPage,
7
+ shutdown,
8
+ stateMatches,
9
+ withTimeout,
10
+ type CallbackCapture,
11
+ } from './oauth-callback.ts';
12
+ import {
13
+ directExchange,
14
+ type ExchangeCode,
15
+ type OAuthTokens,
16
+ type RefreshTokenPolicy,
17
+ } from './oauth-exchange.ts';
5
18
 
6
19
  /**
7
20
  * The OAuth authorization-code exchange, run entirely from the CLI.
@@ -36,7 +49,23 @@ export interface OAuthFlowOptions {
36
49
  * state check, and the browser cannot drift apart between the two paths.
37
50
  */
38
51
  readonly exchange?: ExchangeCode;
52
+ /** What a response carrying no refresh token means for this vendor. */
53
+ readonly refreshToken: RefreshTokenPolicy;
39
54
  readonly authorizeParams?: Readonly<Record<string, string>>;
55
+ /**
56
+ * An HTTPS URL to name as the redirect, for a vendor that will take no other.
57
+ *
58
+ * Absent for every vendor that accepts a loopback callback, which is all of
59
+ * them but Slack — there the listener names itself and this is the whole of
60
+ * it. Present where the vendor's redirect must be HTTPS: the browser then
61
+ * lands on that URL, which bounces it down to the listener opened here, and
62
+ * the port it needs to bounce to travels in `state`.
63
+ *
64
+ * The listener is unchanged either way. It still binds loopback on a port the
65
+ * kernel picked, still serves exactly one callback, and still checks `state`
66
+ * on the way back. What changes is only the address the vendor is told.
67
+ */
68
+ readonly relayRedirect?: string;
40
69
  /** What the completion page names as connected. A provider's display name. */
41
70
  readonly connectionLabel?: string;
42
71
  /** How long to wait for the operator to finish in the browser. */
@@ -49,7 +78,12 @@ export interface OAuthFlowOptions {
49
78
  // Re-exported so the flow stays one import for its callers even though the
50
79
  // exchange half now lives next door.
51
80
  export { OAuthError };
52
- export type { ExchangeCode, OAuthTokens };
81
+ export type { ExchangeCode, OAuthTokens, RefreshTokenPolicy };
82
+
83
+ // Re-exported so a caller wanting a loopback callback still has one import to
84
+ // reach for. Which file it lives in is this module's business, not theirs.
85
+ export { captureOAuthCallback };
86
+ export type { CallbackCapture };
53
87
 
54
88
  const base64url = (input: Buffer): string => input.toString('base64url');
55
89
 
@@ -60,13 +94,6 @@ export function createPkcePair(): { verifier: string; challenge: string } {
60
94
  return { verifier, challenge };
61
95
  }
62
96
 
63
- /** Compare the returned state in constant time — it is a CSRF defence. */
64
- function stateMatches(expected: string, received: string): boolean {
65
- const a = createHash('sha256').update(expected).digest();
66
- const b = createHash('sha256').update(received).digest();
67
- return timingSafeEqual(a, b);
68
- }
69
-
70
97
  export function defaultOpenBrowser(url: string): void {
71
98
  const command =
72
99
  process.platform === 'darwin'
@@ -85,24 +112,6 @@ export function defaultOpenBrowser(url: string): void {
85
112
  }
86
113
  }
87
114
 
88
- /**
89
- * What the browser is left looking at, in the two shapes it comes in.
90
- *
91
- * The label is what the caller knows and this file cannot: which provider the
92
- * grant was for. Given one, the page names it under "Connected" the way the
93
- * invite page names the workspace; without one it says only that the flow
94
- * finished, which is what every path said before.
95
- */
96
- const connectedPage = (label?: string): Response =>
97
- completionPage({
98
- ...(label ? { label: 'Connected', heading: label } : { heading: 'Connected' }),
99
- detail: 'You can close this tab and return to the terminal.',
100
- ok: true,
101
- });
102
-
103
- const failedPage = (detail: string): Response =>
104
- completionPage({ heading: 'Authorization failed', detail, ok: false });
105
-
106
115
  /**
107
116
  * Run the flow and return the tokens.
108
117
  *
@@ -112,7 +121,6 @@ const failedPage = (detail: string): Response =>
112
121
  */
113
122
  export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthTokens> {
114
123
  const { verifier, challenge } = createPkcePair();
115
- const state = base64url(randomBytes(24));
116
124
  const timeoutMs = options.timeoutMs ?? 5 * 60_000;
117
125
 
118
126
  let resolveCode: (code: string) => void;
@@ -124,7 +132,7 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
124
132
 
125
133
  const server = Bun.serve({
126
134
  hostname: '127.0.0.1',
127
- port: 0, // let the OS choose; Google's Desktop client type accepts any loopback port
135
+ port: 0, // let the OS choose; nothing outside this machine names this port
128
136
  fetch(request) {
129
137
  const url = new URL(request.url);
130
138
  if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
@@ -143,8 +151,9 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
143
151
 
144
152
  const returnedState = url.searchParams.get('state');
145
153
  if (!returnedState || !stateMatches(state, returnedState)) {
146
- // A mismatched state means this callback did not come from the request
147
- // we started. Refuse it rather than redeeming whatever code it carries.
154
+ // A mismatched state means this callback did not come from the
155
+ // request we started. Refuse it rather than redeeming whatever code
156
+ // it carries.
148
157
  rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
149
158
  return failedPage('Unexpected callback.');
150
159
  }
@@ -160,7 +169,25 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
160
169
  },
161
170
  });
162
171
 
163
- const redirectUri = `http://127.0.0.1:${server.port}/callback`;
172
+ // Where the vendor is told to send the browser. The relay bounces it back
173
+ // here; without one it comes here directly, which is every other provider.
174
+ const redirectUri = options.relayRedirect ?? `http://127.0.0.1:${server.port}/callback`;
175
+
176
+ /**
177
+ * The CSRF binding, and — behind a relay — the only way home.
178
+ *
179
+ * Built after the listener because half of it is the port the kernel just
180
+ * chose. The relay has nowhere else to learn that from: `state` is opaque to
181
+ * the vendor, round-trips untouched, and is already checked on the way back,
182
+ * so carrying the port in it costs no extra parameter and no state held
183
+ * anywhere. Without a relay it stays what it always was.
184
+ *
185
+ * Safe to declare after the handler that reads it: `Bun.serve` returns
186
+ * synchronously and nothing can be served until this frame yields.
187
+ */
188
+ const state = options.relayRedirect
189
+ ? `${base64url(randomBytes(24))}.${server.port}`
190
+ : base64url(randomBytes(24));
164
191
 
165
192
  try {
166
193
  const authorizeUrl = new URL(options.authorizeUrl);
@@ -196,159 +223,22 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
196
223
  }
197
224
  }
198
225
 
199
- /**
200
- * Close the listener without cutting off the response in flight.
201
- *
202
- * Forcing the socket shut the instant the code is read leaves the operator
203
- * looking at a connection error on a flow that in fact succeeded. So: stop
204
- * gracefully, wait for the in-flight callback to drain, and force only if
205
- * graceful genuinely did not finish — a keep-alive connection must not be able
206
- * to hold the CLI open forever either.
207
- */
208
- async function shutdown(server: { stop(force?: boolean): Promise<void> }): Promise<void> {
209
- let drained = false;
210
- const graceful = server.stop().then(() => {
211
- drained = true;
212
- });
213
-
214
- await withTimeout(graceful, 2_000).catch(() => {});
215
- if (!drained) await server.stop(true);
216
- }
217
-
218
- /**
219
- * Race a promise against a deadline, and **clear the timer either way**.
220
- *
221
- * A bare `Promise.race` with `setTimeout` leaks: when the real promise wins,
222
- * the timer is still pending, and a pending timer keeps the event loop alive
223
- * for its full duration. With a five-minute OAuth deadline that turns a
224
- * finished `connect` into a terminal that prints "Next: lanes link start" and
225
- * then sits there for five minutes — which is exactly what it did.
226
- *
227
- * Rejects with `OAuthError(message)` if given one, otherwise resolves to
228
- * undefined at the deadline, which is what the shutdown path wants.
229
- */
230
- async function withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
231
- let timer: ReturnType<typeof setTimeout> | undefined;
232
-
233
- try {
234
- return await Promise.race([
235
- promise,
236
- new Promise<T>((resolve, reject) => {
237
- timer = setTimeout(
238
- () => (message ? reject(new OAuthError(message)) : resolve(undefined as T)),
239
- ms,
240
- );
241
- }),
242
- ]);
243
- } finally {
244
- if (timer) clearTimeout(timer);
245
- }
246
- }
247
-
248
226
  function defaultExchange(options: OAuthFlowOptions): ExchangeCode {
249
- if (!options.tokenUrl || !options.clientSecret) {
227
+ // The secret is no longer part of this condition. A public client has none —
228
+ // its id ships in the manifest and PKCE is what protects the exchange — so
229
+ // requiring one here refused the shipped-client path before it sent anything.
230
+ if (!options.tokenUrl) {
250
231
  throw new OAuthError(
251
- 'runOAuthFlow needs either a tokenUrl and clientSecret to redeem the code with, or an exchange to redeem it through.',
232
+ 'runOAuthFlow needs a tokenUrl to redeem the code at, or an exchange to redeem it through.',
252
233
  );
253
234
  }
254
235
  return directExchange({
255
236
  tokenUrl: options.tokenUrl,
256
237
  clientId: options.clientId,
257
- clientSecret: options.clientSecret,
238
+ refreshToken: options.refreshToken,
239
+ ...(options.clientSecret ? { clientSecret: options.clientSecret } : {}),
258
240
  ...(options.fetch ? { fetch: options.fetch } : {}),
259
241
  });
260
242
  }
261
243
 
262
244
 
263
- /**
264
- * Capture exactly one OAuth callback on loopback.
265
- *
266
- * Split out from `runOAuthFlow` so the SDK can drive the protocol — discovery,
267
- * registration, PKCE, exchange — while we supply only the redirect target. The
268
- * listener exists for the duration of one consent and no longer.
269
- */
270
- export interface CallbackCapture {
271
- readonly redirectUri: string;
272
- /**
273
- * The `state` this listener will accept, for the provider to put on the
274
- * authorization URL. The SDK asks its provider for one and sends whatever it
275
- * gets; nothing generates it for us.
276
- */
277
- readonly state: string;
278
- wait(timeoutMs?: number): Promise<{ code: string; iss?: string }>;
279
- close(): Promise<void>;
280
- }
281
-
282
- export function captureOAuthCallback(options: { label?: string } = {}): CallbackCapture {
283
- const state = base64url(randomBytes(24));
284
- let resolveCode: (value: { code: string; iss?: string }) => void;
285
- let rejectCode: (error: Error) => void;
286
-
287
- const received = new Promise<{ code: string; iss?: string }>((resolve, reject) => {
288
- resolveCode = resolve;
289
- rejectCode = reject;
290
- });
291
-
292
- const server = Bun.serve({
293
- hostname: '127.0.0.1',
294
- port: 0,
295
- fetch(request) {
296
- const url = new URL(request.url);
297
- if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
298
-
299
- const error = url.searchParams.get('error');
300
- if (error) {
301
- rejectCode(
302
- new OAuthError(
303
- error === 'access_denied'
304
- ? 'Authorization was declined in the browser.'
305
- : `Authorization failed: ${error}`,
306
- ),
307
- );
308
- return failedPage('You can close this tab.');
309
- }
310
-
311
- const code = url.searchParams.get('code');
312
- if (!code) {
313
- rejectCode(new OAuthError('The callback carried no authorization code.'));
314
- return failedPage('No code returned.');
315
- }
316
-
317
- // Checked here because nothing else checks it. The SDK's own docs say it
318
- // does not validate `state`, and it only sends one if the provider hands
319
- // it over — so this listener mints the value, `CredentialOAuthProvider`
320
- // returns it from `state()`, and the two are compared on the way back.
321
- //
322
- // Without it this resolves on the first `/callback?code=` to reach the
323
- // port, whoever sent it: any local process, or a page the operator has
324
- // open, can sweep loopback during the five-minute window. PKCE usually
325
- // turns that into a failed exchange, but a manifest is free to name an
326
- // authorization server that does not enforce it, and there the code
327
- // would be redeemed and the connection bound to someone else's account.
328
- const returnedState = url.searchParams.get('state');
329
- if (!returnedState || !stateMatches(state, returnedState)) {
330
- rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
331
- return failedPage('Unexpected callback.');
332
- }
333
-
334
- const iss = url.searchParams.get('iss');
335
- resolveCode({ code, ...(iss ? { iss } : {}) });
336
- return connectedPage(options.label);
337
- },
338
- });
339
-
340
- return {
341
- redirectUri: `http://127.0.0.1:${server.port}/callback`,
342
- state,
343
-
344
- async wait(timeoutMs = 5 * 60_000) {
345
- return withTimeout(
346
- received,
347
- timeoutMs,
348
- `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser.`,
349
- );
350
- },
351
-
352
- close: () => shutdown(server),
353
- };
354
- }
package/src/cli/output.ts CHANGED
@@ -106,15 +106,31 @@ export async function waiting<T>(label: string, work: () => Promise<T>): Promise
106
106
  * The line every command prints before acting, read-only commands included.
107
107
  *
108
108
  * This is the primary guard against operating on the wrong instance, and it
109
- * costs one line. It names where each value came from, because "profile: work"
110
- * is much less useful than knowing it came from an environment variable you
111
- * forgot you exported.
109
+ * costs one line. It used to name where each value came from, which mattered
110
+ * while four things could supply them. Only the command line can now (ADR-037),
111
+ * so the parenthetical would say `(flag)` twice on every line forever — which
112
+ * `target.ts` already argues is how a line stops being read.
112
113
  */
114
+ /**
115
+ * The same line for a command that opens no target.
116
+ *
117
+ * `identity list` reads a block declared once in the YAML, so it takes no
118
+ * `--target` (ADR-037) and there is none to name. Printing `target undefined`
119
+ * or omitting the line entirely were the alternatives; the first is a lie and
120
+ * the second loses the guard that this line exists to be.
121
+ */
122
+ export function announceProfile(selection: {
123
+ readonly profile: string;
124
+ readonly workspaceRoot: string;
125
+ }): void {
126
+ print(style.dim(`profile ${style.bold(selection.profile)} ${selection.workspaceRoot}`));
127
+ }
128
+
113
129
  export function announce(resolution: Resolution): void {
114
130
  print(
115
131
  style.dim(
116
- `profile ${style.bold(resolution.profile)} (${resolution.profileSource}) ` +
117
- `target ${style.bold(resolution.target)} (${resolution.targetSource}) ` +
132
+ `profile ${style.bold(resolution.profile)} ` +
133
+ `target ${style.bold(resolution.target)} ` +
118
134
  `${resolution.workspaceRoot}`,
119
135
  ),
120
136
  );