@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,166 @@
1
+ import { z } from 'zod';
2
+ import { defineLocalProvider, type ProviderDefinition } from '#connectivity';
3
+ import type { IdentityEntry } from '#profile';
4
+
5
+ /**
6
+ * `identity` — who the owner is, for anything written as them.
7
+ *
8
+ * **Read-only by construction, for the same reason `setup` is.** ADR-007 keeps
9
+ * configuration mutation off the MCP surface, and this block *is* configuration:
10
+ * an agent that could rewrite whose name it signs with would be rewriting the
11
+ * one fact that stops it signing wrong. Declaring it is the owner's, in a
12
+ * terminal. So there is one capability, it is a read, and `provider.test.ts`
13
+ * asserts the capability list holds nothing else.
14
+ *
15
+ * Reporting it authorises nothing, which is the ADR-019 argument again. A name
16
+ * and an address are disclosed by the first message of any mailbox this endpoint
17
+ * serves — a caller holding a grant on a mail connection already has them —
18
+ * so withholding them here while serving the mailbox would be theatre. The
19
+ * difference is that reading them *here* means not having to guess, and a guess
20
+ * is what this exists to replace.
21
+ *
22
+ * Why it is its own provider rather than a third section of `setup_overview`:
23
+ * policy. `identity.*` is a grant an owner can give or withhold on its own, so
24
+ * an endpoint can describe what is connected without naming its owner, or name
25
+ * its owner without describing what is connected. Folding it into `setup` would
26
+ * have made those one decision, and they are not.
27
+ *
28
+ * `identity.list` is clean against the seven patterns in
29
+ * `dispatch/control-plane.test.ts`. So is `identity` as a prefix. Neither is a
30
+ * cosmetic name.
31
+ */
32
+
33
+ export interface IdentityProviderOptions {
34
+ /**
35
+ * Which profile this instance serves.
36
+ *
37
+ * Stamped at construction for the reason recorded on `SetupProviderOptions`:
38
+ * `makeHandler` strips `profile` off the arguments before dispatch and
39
+ * `ProviderContext` does not carry it, so a handler cannot learn it. One
40
+ * registry is built per profile, so each instance gets its own.
41
+ */
42
+ readonly profile: string;
43
+ /**
44
+ * Which target it is serving, for the command in the empty case.
45
+ *
46
+ * Needed for the same reason `setup`'s plans need it: `--profile` and
47
+ * `--target` are required flags (ADR-037), and a command handed to an agent
48
+ * without both is a paste that refuses. An agent pastes what it is given.
49
+ */
50
+ readonly target?: string;
51
+ /**
52
+ * What that profile declares, in declaration order.
53
+ *
54
+ * A snapshot rather than a function, unlike `setup`'s `reachable`. That one
55
+ * has to be re-evaluated per call because policy is; this is config, and the
56
+ * registry holding it is rebuilt whenever config is re-read — so an entry
57
+ * added by the CLI is served after the next reload, exactly as a new
58
+ * connection is, and a function here would only imply a freshness it could
59
+ * not deliver.
60
+ */
61
+ readonly entries?: readonly IdentityEntry[];
62
+ }
63
+
64
+ export function createIdentityProvider(options: IdentityProviderOptions): ProviderDefinition {
65
+ const entries = options.entries ?? [];
66
+
67
+ return defineLocalProvider({
68
+ id: 'identity',
69
+ name: 'Identity',
70
+ version: '1.0.0',
71
+ description:
72
+ 'The names, addresses, and handles this profile declares for its owner, and a note on ' +
73
+ 'when each applies. Read-only: what is declared is set in the CLI, because an agent able ' +
74
+ 'to change whose name it signs with could change the one fact that stops it signing wrong.',
75
+
76
+ configSchema: z.object({}),
77
+ connectionSchema: z.object({}),
78
+
79
+ bundles: [
80
+ {
81
+ name: 'read',
82
+ description: 'Read the declared identity. There is no write bundle, by design.',
83
+ oauth_scopes: [],
84
+ capabilities: ['list'],
85
+ default: true,
86
+ },
87
+ ],
88
+
89
+ capabilities: [
90
+ {
91
+ kind: 'tool',
92
+ name: 'list',
93
+ title: 'Who the owner is, in this profile',
94
+ description:
95
+ 'The names, addresses, and handles this profile declares for its owner, each with a ' +
96
+ 'note on when it applies. Call this before writing as them — signing a message, ' +
97
+ 'addressing one, choosing an account to act as — rather than inferring one from ' +
98
+ 'the conversation. A profile may declare several of a kind deliberately.',
99
+ inputSchema: z.object({}),
100
+ // No arguments to redact.
101
+ async handler(_input, _handlerContext) {
102
+ return {
103
+ content: [{ type: 'text', text: render(options.profile, options.target, entries) }],
104
+ };
105
+ },
106
+ },
107
+ ],
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Grouped by kind, in order of first appearance, and self-describing per line.
113
+ *
114
+ * Grouped because the failure this exists to prevent is a name used where an
115
+ * address was wanted, and adjacency is most of what stops that. The kind is
116
+ * repeated on every line anyway rather than written once as a heading: a line
117
+ * lifted out of this block on its own then still says what it is, and a model
118
+ * quoting one line is exactly what happens next.
119
+ *
120
+ * Order within a kind is declaration order, which is the owner's ranking.
121
+ */
122
+ function render(
123
+ profile: string,
124
+ target: string | undefined,
125
+ entries: readonly IdentityEntry[],
126
+ ): string {
127
+ if (entries.length === 0) {
128
+ // Both flags, spelled out. They are required (ADR-037), so a command
129
+ // missing either is one the owner pastes and watches refuse — and this is
130
+ // handed to an agent, which relays it verbatim.
131
+ const where = `--profile ${profile}${target ? ` --target ${target}` : ''}`;
132
+ return (
133
+ `Profile "${profile}" declares no identity.\n\n` +
134
+ 'Nothing here says what name or address to use, so do not invent one — ask. ' +
135
+ `The owner declares them with \`lanes link identity add <kind> <value> ${where}\`.`
136
+ );
137
+ }
138
+
139
+ const kinds = [...new Set(entries.map((entry) => entry.kind))];
140
+ const ordered = kinds.flatMap((kind) => entries.filter((entry) => entry.kind === kind));
141
+
142
+ const kindWidth = Math.max(...ordered.map((entry) => entry.kind.length));
143
+ const valueWidth = Math.max(...ordered.map((entry) => entry.value.length));
144
+
145
+ const lines = ordered.map((entry) => {
146
+ const head = ` ${entry.kind.padEnd(kindWidth)} ${entry.value}`;
147
+ return entry.note ? `${head.padEnd(kindWidth + valueWidth + 4)} — ${entry.note}` : head;
148
+ });
149
+
150
+ // Said here rather than left to the reader because "several names" is
151
+ // otherwise ambiguous in the one direction that matters: a model handed two
152
+ // with no ranking picks by position anyway, and may as well be told that
153
+ // position is what it means.
154
+ const several = kinds.some((kind) => entries.filter((entry) => entry.kind === kind).length > 1);
155
+
156
+ return [
157
+ `Identity for profile "${profile}".`,
158
+ '',
159
+ ...lines,
160
+ '',
161
+ several
162
+ ? 'Where a kind holds more than one, the first is the default and the notes say when to ' +
163
+ 'prefer another. If none of them fits what you are doing, ask rather than combining them.'
164
+ : 'Use these as written. If what you need is not here, ask rather than inferring it.',
165
+ ].join('\n');
166
+ }
@@ -6,6 +6,7 @@ import { github } from './github/index.ts';
6
6
  import { drive } from './google/drive/index.ts';
7
7
  import { driveMcp } from './google/drive-mcp/index.ts';
8
8
  import { gmail } from './google/gmail/index.ts';
9
+ import { gmailImap } from './google/gmail-imap/index.ts';
9
10
  import { gmailMcp } from './google/gmail-mcp/index.ts';
10
11
  import { sheets } from './google/sheets/index.ts';
11
12
  import { tasks } from './google/tasks/index.ts';
@@ -59,6 +60,7 @@ export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
59
60
  calendar,
60
61
  tasks,
61
62
  contacts,
63
+ gmailImap,
62
64
  gmailMcp,
63
65
  driveMcp,
64
66
  icloudMail,
@@ -87,6 +89,7 @@ export {
87
89
  drive,
88
90
  driveMcp,
89
91
  gmail,
92
+ gmailImap,
90
93
  gmailMcp,
91
94
  sheets,
92
95
  tasks,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The owner layer — memory, skills, vault.
2
+ * The owner layer — memory, skills, vault, setup, identity.
3
3
  *
4
4
  * Three providers that hold no third-party account: no OAuth, no vendor API, no
5
5
  * rate limit anyone else imposes. They are ordinary `defineLocalProvider`
@@ -19,7 +19,14 @@
19
19
  * read-only by construction — see ADR-019 for why describing setup is not one
20
20
  * of ADR-007's control-plane exclusions.
21
21
  *
22
- * The ids `memory`, `skills`, `vault`, and `setup` are reserved (`RESERVED_PROVIDER_IDS`)
22
+ * `identity` is the fifth and holds no account either. It says who the owner is
23
+ * — the names and addresses to write as them — and is read-only for the reason
24
+ * `setup` is: what it reports is configuration, and configuration is changed in
25
+ * the CLI. It is a provider of its own rather than a section of `setup` so that
26
+ * naming the owner and describing what is connected are two policy decisions
27
+ * instead of one.
28
+ *
29
+ * The ids `memory`, `skills`, `vault`, `setup`, and `identity` are reserved (`RESERVED_PROVIDER_IDS`)
23
30
  * and still refused by default — the registry has to be built with
24
31
  * `allowReserved` to hold them, so a third-party provider cannot claim a
25
32
  * namespace whose policy rules would then mean something else.
@@ -29,6 +36,7 @@ export { memoryProvider, memoryStorage, assertEntryId, type MemoryEntry } from '
29
36
  export { createSkillsProvider, type SkillsProviderOptions } from './skills/provider.ts';
30
37
  export { createVaultProvider, type VaultProviderOptions } from './vault/provider.ts';
31
38
  export { createSetupProvider, type SetupProviderOptions } from './setup/provider.ts';
39
+ export { createIdentityProvider, type IdentityProviderOptions } from './identity/provider.ts';
32
40
  export { planAll, planFor, type PlanContext, type ProviderPlan } from './setup/plan.ts';
33
41
  // The vault's *store* is not here: it is `#secrets`, beside the system
34
42
  // credential store it must never become. What lives in `./vault/` is the
@@ -1,5 +1,6 @@
1
1
  import { GOOGLE_SCOPE_MEANINGS } from './google/shared/scopes.ts';
2
2
  import { LINEAR_SCOPE_MEANINGS } from './linear/scopes.ts';
3
+ import { SLACK_SCOPE_MEANINGS } from './slack/scopes.ts';
3
4
 
4
5
  /**
5
6
  * What a scope actually permits, contributed by the provider that requests it.
@@ -23,4 +24,5 @@ export interface ScopeMeaning {
23
24
  export const SCOPE_MEANINGS: Record<string, ScopeMeaning> = {
24
25
  ...GOOGLE_SCOPE_MEANINGS,
25
26
  ...LINEAR_SCOPE_MEANINGS,
27
+ ...SLACK_SCOPE_MEANINGS,
26
28
  };
@@ -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;