@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
@@ -1,5 +1,5 @@
1
1
  import type { ProviderManifest } from '#connectivity';
2
- import { setupRequirements, type SetupRequirement } from '#connectivity';
2
+ import { hasOwnClientPath, setupRequirements, type SetupRequirement } from '#connectivity';
3
3
 
4
4
  /**
5
5
  * What connecting a provider involves, assembled from its manifest.
@@ -68,10 +68,22 @@ export interface ProviderPlan {
68
68
  readonly clientOperator?: string;
69
69
  /** The line that opts out of it and registers one of your own instead. */
70
70
  readonly ownClientCommand?: string;
71
+ /** What `--auth pasted_token` asks for, where that is a way in. */
72
+ readonly pastedCredential?: string;
73
+ /** The line that takes that way in. */
74
+ readonly tokenCommand?: string;
71
75
  }
72
76
 
73
77
  export interface PlanContext {
74
78
  readonly profile: string;
79
+ /**
80
+ * Which target's stores the emitted command should act on.
81
+ *
82
+ * Stamped like `profile` is, and for a stronger reason: a connection's
83
+ * credential lives in one target's store, so a `connect` that lands in the
84
+ * wrong one authorises an account the endpoint asking for it cannot read.
85
+ */
86
+ readonly target: string;
75
87
  /** Every configured connection this caller may see, as `provider.id`. */
76
88
  readonly connections: readonly string[];
77
89
  /**
@@ -88,21 +100,22 @@ export function planFor(
88
100
  context: PlanContext,
89
101
  connectionId?: string,
90
102
  ): ProviderPlan {
91
- const { requirements, needsId, brokered } = setupRequirements(
103
+ const { requirements, needsId, brokered, pastedCredential } = setupRequirements(
92
104
  manifest,
93
105
  connectionId,
94
- context.profile,
106
+ { profile: context.profile, target: context.target },
95
107
  { ...(context.ownClients ? { ownClients: context.ownClients } : {}) },
96
108
  );
97
109
 
98
110
  const connected = context.connections.filter((key) => key.startsWith(`${manifest.id}.`));
99
111
 
100
- // `--profile` always, never conditionally. One endpoint serves every profile,
101
- // and the shell this command is pasted into may default to a different one —
102
- // which is exactly what `resolveSelection`'s "never a silent pick" rule
103
- // exists to prevent.
112
+ // Both, always, never conditionally. One endpoint serves every profile and
113
+ // each profile may declare several targets, and the shell this is pasted into
114
+ // supplies neither nothing but the command line does. An emitted command
115
+ // missing either is one that refuses, or worse, writes a credential into a
116
+ // store the endpoint that asked for it does not read.
104
117
  const command =
105
- `lanes link connect ${manifest.id} --profile ${context.profile}` +
118
+ `lanes link connect ${manifest.id} --profile ${context.profile} --target ${context.target}` +
106
119
  (needsId ? ' --id <name>' : connectionId ? ` --id ${connectionId}` : '');
107
120
 
108
121
  return {
@@ -119,13 +132,22 @@ export function planFor(
119
132
  needsId,
120
133
  command,
121
134
  brokered,
135
+ ...(pastedCredential
136
+ ? { pastedCredential, tokenCommand: `${command} --auth pasted_token` }
137
+ : {}),
122
138
  ...(brokered && manifest.auth.kind === 'oauth' && manifest.auth.broker
123
139
  ? {
124
140
  clientOperator: manifest.auth.broker.operator,
125
141
  // The steps stay in `steps` either way. A renderer decides whether to
126
142
  // show a console walkthrough for a path nobody has asked for; the
127
143
  // plan's job is to say the path exists and what opens it.
128
- ownClientCommand: `${command} --own-client`,
144
+ //
145
+ // Offered only where the manifest actually describes a client to
146
+ // register. Slack's does not — it asks for a token, never for a
147
+ // client id and secret — and `resolveOAuthClient` refuses the flag on
148
+ // exactly that ground, so printing it here would be handing somebody
149
+ // a command that answers back with "there is no such path".
150
+ ...(hasOwnClientPath(manifest) ? { ownClientCommand: `${command} --own-client` } : {}),
129
151
  }
130
152
  : {}),
131
153
  };
@@ -47,6 +47,13 @@ export interface SetupProviderOptions {
47
47
  * here — one registry is built per profile, so each instance gets its own.
48
48
  */
49
49
  readonly profile: string;
50
+ /**
51
+ * Which target this instance's stores came from.
52
+ *
53
+ * Stamped at construction for the same reason `profile` is, and it travels no
54
+ * further than the commands this provider emits — nothing here opens a store.
55
+ */
56
+ readonly target: string;
50
57
  /** Sibling profile names on this endpoint. Names only; already at `/health`. */
51
58
  readonly profiles?: readonly string[];
52
59
  /**
@@ -79,6 +86,7 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
79
86
 
80
87
  const context = () => ({
81
88
  profile: options.profile,
89
+ target: options.target,
82
90
  connections: reachable().map((connection) => connection.key),
83
91
  ...(options.ownClients ? { ownClients: options.ownClients } : {}),
84
92
  });
@@ -309,6 +317,21 @@ function renderProvider(plan: ProviderPlan): string {
309
317
  );
310
318
  }
311
319
 
320
+ // An alternative, said as one. It is not a value the command above needs, and
321
+ // rendering it beside the requirements — which is what it did before there
322
+ // was anywhere else to put it — reads as a second mandatory step in a setup
323
+ // whose whole selling point is that it has none.
324
+ if (plan.tokenCommand && plan.pastedCredential) {
325
+ lines.push(
326
+ '',
327
+ 'If the browser path is refused — a workspace that has not approved this app, which an ' +
328
+ `admin decides — the same command takes --auth pasted_token and asks for the ` +
329
+ `${plan.pastedCredential}:`,
330
+ ` ${plan.tokenCommand}`,
331
+ ' The console steps for obtaining one are in the setup documentation above.',
332
+ );
333
+ }
334
+
312
335
  if (plan.browser) {
313
336
  lines.push(
314
337
  '',
@@ -1,68 +1,116 @@
1
1
  import { defineProvider } from '#connectivity';
2
2
  import { SLACK_REDACT } from './redact.ts';
3
+ import { SLACK_APP, SLACK_BROKER, SLACK_SCOPES } from './oauth.ts';
3
4
 
4
5
  /**
5
6
  * Slack, through the server Slack runs.
6
7
  *
7
- * The only provider here whose vendor has closed every door but one. Slack's
8
- * MCP server does not offer Dynamic Client Registration their documentation
9
- * says so outright and a client of your own cannot work either: Slack
10
- * requires an HTTPS redirect URI, and `connect` listens on `http://127.0.0.1`
11
- * on a port the kernel picks. There is no proxy, tunnel, or flag that makes a
12
- * loopback listener HTTPS. A broker would answer it and `defineProvider`
13
- * refuses one on an mcp connector, because the SDK owns that exchange.
8
+ * Slack does not offer Dynamic Client Registration and is not going to: it
9
+ * would let a client authenticate a user without an app existing, and on
10
+ * Enterprise Grid an admin approves each app first. So a client has to be
11
+ * pre-registered and the question this provider used to answer wrongly is
12
+ * *whose*.
14
13
  *
15
- * What is left is the user token the Slack app mints when you install it, sent
16
- * as `Authorization: Bearer`. Slack supports that path deliberately; it is the
17
- * arrangement their own docs describe for a client that cannot register. See
18
- * ADR-033.
14
+ * It was the operator's: create an app, transcribe sixteen user-token scopes,
15
+ * install it, paste the `xoxp-` it mints. That was the honest shape of it only
16
+ * while the alternative was believed impossible. It was not. Every client that
17
+ * reaches Slack without a console visit does the same thing — registers one app
18
+ * and ships its id — and this now does too, with the secret behind the broker
19
+ * ADR-028 already built for Google and the redirect on a port Slack has been
20
+ * told about. ADR-040 records what changed and why the reasoning in ADR-033 no
21
+ * longer holds.
19
22
  *
20
- * Unlike GitHub, this does cost a console visit creating a Slack app is the
21
- * only way to get a user token at all, and no amount of implementation work on
22
- * this side removes it. The setup block is therefore longer than any other here
23
- * except Google's, and that is the honest shape of it.
23
+ * The paste is still here, behind `--auth pasted_token`. A workspace whose admin has not
24
+ * approved the Lanes app cannot use the flow above, and that is not a decision
25
+ * the person running this command can make.
24
26
  */
25
27
  export const slack = defineProvider({
26
28
  id: 'slack',
27
29
  name: 'Slack',
28
30
  description: 'Messages, threads, channels, files, and canvases, via Slack\'s official MCP server.',
29
31
  connector: { kind: 'mcp', endpoint: 'https://mcp.slack.com/mcp' },
30
- auth: { kind: 'bearer' },
31
32
  /**
32
- * The person, not the workspace, and the distinction is load-bearing.
33
+ * An mcp connector that names its own endpoints, which is what takes it off
34
+ * the SDK's flow and onto the one this repository drives.
33
35
  *
34
- * `settleIdentity` matches a resolved account against existing connections
35
- * to decide whether this is a reconnect or a new account. Labelled by
36
- * workspace, a second person's token in the same workspace would look like a
37
- * reconnect of the first and overwrite their credential. `auth.test` returns
38
- * both; `user` is the one that is unique per token.
36
+ * Not an override of discovery for its own sake — Slack publishes perfectly
37
+ * good metadata at `/.well-known/oauth-authorization-server` and these two
38
+ * values are copied from it. It is that the SDK ends its flow by posting to
39
+ * the token endpoint with the client *it* holds, and the client here is held
40
+ * by a broker. Declaring the endpoints is how a manifest says the exchange is
41
+ * ours to route. See `defineProvider` and ADR-040.
42
+ */
43
+ auth: {
44
+ kind: 'oauth',
45
+ registration: 'manual',
46
+ app: SLACK_APP,
47
+ authorize_url: 'https://slack.com/oauth/v2_user/authorize',
48
+ token_url: 'https://slack.com/api/oauth.v2.user.access',
49
+ scopes: [...SLACK_SCOPES],
50
+ broker: SLACK_BROKER,
51
+ /**
52
+ * Slack returns no refresh token, and that is the successful answer.
53
+ *
54
+ * A user token is long-lived unless token rotation is enabled on the app.
55
+ * Demanding one here would refuse every connection that worked — the
56
+ * default exists for Google, where a missing refresh token means the grant
57
+ * already existed and the connection would die in an hour.
58
+ */
59
+ refresh_token: 'optional',
60
+ },
61
+ /**
62
+ * The person *and* the workspace, because either alone collides.
63
+ *
64
+ * `settleIdentity` matches a resolved account against existing connections to
65
+ * decide whether this is a reconnect or a new account, so the string has to
66
+ * be unique per credential. Neither half of `auth.test` is:
67
+ *
68
+ * - `team` alone — two people in one workspace look like one account, and
69
+ * the second connect overwrites the first's token.
70
+ * - `user` alone — Slack's "user" is a workspace-scoped handle, so one
71
+ * person in two workspaces looks like a reconnect and the second
72
+ * workspace overwrites the first. Connecting more than one workspace is
73
+ * the ordinary case here, which made this the more likely of the two.
74
+ *
75
+ * Together they are unique, and `alice (Acme)` is a row somebody can read.
39
76
  *
40
77
  * Slack answers a bad token with HTTP 200 and `{ok: false}`, so a wrong token
41
78
  * reaches `connect`'s "which account is this?" fallback rather than a clear
42
79
  * refusal. Discovery fails loudly one step later, which is where the real
43
80
  * error is.
44
81
  */
45
- identity: { kind: 'http', url: 'https://slack.com/api/auth.test', field: 'user' },
82
+ identity: {
83
+ kind: 'http',
84
+ url: 'https://slack.com/api/auth.test',
85
+ field: 'user',
86
+ qualifier: 'team',
87
+ },
46
88
  redact: SLACK_REDACT,
89
+ /**
90
+ * Read only by `--auth pasted_token`. The browser route asks for nothing.
91
+ *
92
+ * `connection` scope rather than `shared` is what makes `--own-client` refuse
93
+ * with "no bring-your-own client path", which is true: this asks for a token,
94
+ * never for a client of the operator's to register.
95
+ */
47
96
  setup: {
48
97
  summary:
49
- 'Slack needs an app of its own there is no personal access token and no way to register ' +
50
- 'automatically, because Slack requires an HTTPS callback and this CLI listens on localhost. ' +
51
- 'You create the app once, install it to your workspace, and paste the user token it mints.',
98
+ 'Slack normally needs nothing set upone browser round trip against the app Lanes ' +
99
+ 'registered. Pasting a token is the way past a workspace whose admin has not approved ' +
100
+ 'that app, using one from an app the workspace already trusts.',
52
101
  docs: 'docs/detailed/setup/slack.md',
53
102
  docs_url: 'https://api.slack.com/apps',
54
103
  steps: [
55
- 'Open https://api.slack.com/apps and choose "Create New App" → "From scratch". Name it "Lanes Link" and pick the workspace.',
56
- 'Open "OAuth & Permissions" and scroll to "Scopes". Add these under USER TOKEN SCOPES — not Bot Token Scopes; the MCP server reads the user token: search:read.public, search:read.private, search:read.im, search:read.mpim, search:read.users, search:read.files, channels:history, groups:history, im:history, mpim:history, channels:read, groups:read, mpim:read, users:read, chat:write, files:read.',
57
- 'For reactions, canvases, or creating channels, add reactions:write, canvases:read, canvases:write, or channels:write as well. Those tools are listed either way and fail at call time without the scope.',
58
- 'Scroll up and choose "Install to Workspace", then approve. A Slack admin may have to approve it for you.',
59
- 'Copy the "User OAuth Token" from the same page. It starts with xoxp- not the bot token, which starts with xoxb- and will not work here.',
60
- 'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --replace.',
104
+ 'Open https://api.slack.com/apps and choose "Create New App" → "From scratch". Name it and pick the workspace.',
105
+ 'Open "OAuth & Permissions" and add the scopes you need under USER TOKEN SCOPES — not Bot Token Scopes; the MCP server reads the user token. The full set this provider asks for in the browser is listed in docs/detailed/setup/slack.md.',
106
+ 'Choose "Install to Workspace" and approve. A Slack admin may have to approve it for you.',
107
+ 'Copy the "User OAuth Token". It starts with xoxp- not the bot token, which starts with xoxb- and will not work here.',
108
+ 'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --profile personal --target local --auth pasted_token --replace.',
61
109
  ],
62
110
  troubleshooting:
63
111
  'Slack refused the token. The usual causes are a bot token (xoxb-) pasted where the user token (xoxp-) belongs, ' +
64
112
  'a scope missing from USER TOKEN SCOPES, or an app that was reinstalled since — reinstalling mints a new token. ' +
65
- 'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --replace.',
113
+ 'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --profile personal --target local --auth pasted_token --replace.',
66
114
  prompts: [
67
115
  {
68
116
  key: 'token',
@@ -0,0 +1,103 @@
1
+ import type { AuthBroker } from '#connectivity';
2
+ import { brokerOriginOverride } from '#connectivity/auth/index.ts';
3
+
4
+ /**
5
+ * Where Slack's client comes from, and why it cannot come from here.
6
+ *
7
+ * Slack refuses Dynamic Client Registration and says so in its documentation.
8
+ * That is not an omission waiting to be filled: DCR would let a client
9
+ * authenticate a user without an app existing, and on Enterprise Grid an admin
10
+ * approves each app before it can authenticate anyone. Waiting for it is
11
+ * waiting for something that is not coming.
12
+ *
13
+ * So somebody has to be pre-registered. Until now that somebody was the
14
+ * operator, once per person, in a browser tab, transcribing sixteen scopes. It
15
+ * is this project instead now — one app, registered once, exactly the
16
+ * arrangement every other client that connects to Slack without a console visit
17
+ * uses. See ADR-040.
18
+ */
19
+ export const SLACK_APP = 'slack';
20
+
21
+ const BROKER_ORIGIN = 'https://api.lanes.sh';
22
+ const BROKER_PATH = '/v1/auth/link/slack';
23
+
24
+ /**
25
+ * The client Lanes operates, reached at an origin an override can move.
26
+ *
27
+ * Same shape as Google's and for the same reasons — see
28
+ * `../google/shared/oauth.ts`. What differs is what a broker outage costs.
29
+ * Slack issues a long-lived user token and no refresh token unless token
30
+ * rotation is switched on for the app, so this is consulted at `connect` and
31
+ * never again: an outage here cannot interrupt an agent mid-request the way
32
+ * ADR-028 warned a shared dependency can. That is the whole of why the fallback
33
+ * below is a reasonable second answer rather than a necessary one.
34
+ */
35
+ export function slackBroker(env?: Record<string, string | undefined>): AuthBroker {
36
+ return {
37
+ url: `${brokerOriginOverride(env) ?? BROKER_ORIGIN}${BROKER_PATH}`,
38
+ operator: 'Lanes',
39
+ docs_url: 'https://lanes.sh/link#slack',
40
+ };
41
+ }
42
+
43
+ export const SLACK_BROKER: AuthBroker = slackBroker();
44
+
45
+ /**
46
+ * Where Slack sends the browser back — and why it is not this machine.
47
+ *
48
+ * Slack refuses to register a Redirect URL that is not HTTPS. Verified against
49
+ * a real app: the field rejects `http://localhost:<port>/callback` outright, so
50
+ * a loopback listener cannot be named to Slack at all. A CLI cannot be HTTPS
51
+ * either — there is no certificate for 127.0.0.1 a browser will accept.
52
+ *
53
+ * So the redirect goes to the broker, which bounces it straight down to the
54
+ * listener `connect` opened, carrying the port in `state`. The URL itself is
55
+ * not written here: `/config` publishes it, because which one is correct
56
+ * depends on which deployment answered, and a broker running on loopback for a
57
+ * test would otherwise need its own spelling of it.
58
+ *
59
+ * The cost is that Slack cannot be connected without the broker. Google's is
60
+ * optional — a profile may register its own client and never call it — and
61
+ * Slack's is not, because the broker *is* the HTTPS origin. Recorded in
62
+ * ADR-040, and softened by Slack issuing no refresh token: an outage stops
63
+ * `connect`, never a connection already made.
64
+ */
65
+
66
+ /**
67
+ * What the browser grant asks for.
68
+ *
69
+ * These are user-token scopes: Slack's MCP server reads the user token, and a
70
+ * bot token is a different credential that does not work there at all. The list
71
+ * is what the setup page used to ask the operator to transcribe by hand, moved
72
+ * to where it can be shown before consent instead — which is what restores the
73
+ * scope-disclosure gate ADR-033 recorded as permanently absent for Slack.
74
+ *
75
+ * The last four are what `reactions`, `canvases`, and channel creation need.
76
+ * They are requested rather than left out because the tools appear in the list
77
+ * either way and fail at call time without them, and a tool that is visible and
78
+ * always fails is worse than a scope on the consent screen. What an agent may
79
+ * actually call is bounded by policy, where `connect` grants read and nothing
80
+ * else by default.
81
+ */
82
+ export const SLACK_SCOPES = [
83
+ 'search:read.public',
84
+ 'search:read.private',
85
+ 'search:read.im',
86
+ 'search:read.mpim',
87
+ 'search:read.users',
88
+ 'search:read.files',
89
+ 'channels:history',
90
+ 'groups:history',
91
+ 'im:history',
92
+ 'mpim:history',
93
+ 'channels:read',
94
+ 'groups:read',
95
+ 'mpim:read',
96
+ 'users:read',
97
+ 'chat:write',
98
+ 'files:read',
99
+ 'reactions:write',
100
+ 'canvases:read',
101
+ 'canvases:write',
102
+ 'channels:write',
103
+ ] as const;
@@ -0,0 +1,37 @@
1
+ import type { ScopeMeaning } from '../scopes.ts';
2
+
3
+ /**
4
+ * Slack's user-token scopes, said in words before the browser opens.
5
+ *
6
+ * These used to be sixteen lines of a setup page the operator transcribed into
7
+ * a console, where nothing described them and nothing could refuse. Asking for
8
+ * them in a browser is what makes `confirmScopes` apply to Slack at all — the
9
+ * gate ADR-033 recorded as permanently absent here.
10
+ *
11
+ * `broad` is set for the four that reach private conversations and the one that
12
+ * writes as the person. A grant of `search:read.private` is not "search" in the
13
+ * sense a reader assumes: it covers every private channel and DM the account
14
+ * can see, which is usually the most sensitive thing in the workspace.
15
+ */
16
+ export const SLACK_SCOPE_MEANINGS: Record<string, ScopeMeaning> = {
17
+ 'search:read.public': { meaning: 'search public channels' },
18
+ 'search:read.private': { meaning: 'search private channels', broad: true },
19
+ 'search:read.im': { meaning: 'search direct messages', broad: true },
20
+ 'search:read.mpim': { meaning: 'search group direct messages', broad: true },
21
+ 'search:read.users': { meaning: 'search people in the workspace' },
22
+ 'search:read.files': { meaning: 'search files' },
23
+ 'channels:history': { meaning: 'read messages in public channels' },
24
+ 'groups:history': { meaning: 'read messages in private channels', broad: true },
25
+ 'im:history': { meaning: 'read direct messages', broad: true },
26
+ 'mpim:history': { meaning: 'read group direct messages', broad: true },
27
+ 'channels:read': { meaning: 'list public channels' },
28
+ 'groups:read': { meaning: 'list private channels' },
29
+ 'mpim:read': { meaning: 'list group direct messages' },
30
+ 'users:read': { meaning: 'read people and profiles' },
31
+ 'chat:write': { meaning: 'send messages as you', broad: true },
32
+ 'files:read': { meaning: 'read files and their contents' },
33
+ 'reactions:write': { meaning: 'add and remove reactions as you' },
34
+ 'canvases:read': { meaning: 'read canvases' },
35
+ 'canvases:write': { meaning: 'create and edit canvases' },
36
+ 'channels:write': { meaning: 'create and manage public channels' },
37
+ };
@@ -24,7 +24,9 @@ import { streamLogger } from './logging.ts';
24
24
  * nobody wrote — so the endpoint refuses rather than
25
25
  * serving an empty workspace.
26
26
  * LANES_LINK_TARGET which target's adapters to open. Baked to `cloud`.
27
- * LANES_LINK_PROFILE optional; the workspace default otherwise.
27
+ * LANES_LINK_PROFILE the primary profile this revision serves. Required —
28
+ * there is no workspace default any more (ADR-037), and
29
+ * `lanes link deploy` sets it at rollout.
28
30
  * PORT injected by Cloud Run. 8080 is its default.
29
31
  */
30
32
 
@@ -42,6 +44,21 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
42
44
  }
43
45
  const host = env['LANES_LINK_HOST'] ?? '0.0.0.0';
44
46
 
47
+ // In this container's own voice, not the CLI's. A revision serves one primary
48
+ // profile — it decides the token that opens the endpoint — and nothing here can
49
+ // pick one. `lanes link deploy` sets the variable at rollout, in the same
50
+ // `gcloud run deploy` as the image, so the two never disagree; a container
51
+ // started by hand has to be told. Refusing here beats a CLI-shaped "pass
52
+ // --profile" reaching a log where there is no command line to pass it on.
53
+ if (!env['LANES_LINK_PROFILE']) {
54
+ process.stderr.write(
55
+ 'LANES_LINK_PROFILE is not set on this service. A deployed revision serves one\n' +
56
+ 'primary profile and must be told which. `lanes link deploy` sets it at rollout;\n' +
57
+ 'if this container was started by hand, pass -e LANES_LINK_PROFILE=<name>.\n',
58
+ );
59
+ process.exit(1);
60
+ }
61
+
45
62
  const log = (message: string): void => {
46
63
  process.stdout.write(`${new Date().toISOString()} ${message}\n`);
47
64
  };