@lanes-sh/link 0.7.1 → 0.8.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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/brand.ts +39 -10
- package/src/cli/callback-page.ts +37 -104
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -75
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
package/src/profile/testing.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function workspaceYaml(
|
|
|
58
58
|
return (
|
|
59
59
|
`contract: ${SUPPORTED_CONTRACT}\n` +
|
|
60
60
|
(options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
|
|
61
|
-
`
|
|
61
|
+
`workspaces:\n${blocks}\n`
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -73,6 +73,73 @@ export function pointerYaml(
|
|
|
73
73
|
return (
|
|
74
74
|
`contract: ${SUPPORTED_CONTRACT}\n` +
|
|
75
75
|
(options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
|
|
76
|
-
`
|
|
76
|
+
`workspaces:\n${local} ${target}:\n at: ${workspace}\n`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A `connections.yaml` holding the owner layer plus whatever a test names.
|
|
82
|
+
*
|
|
83
|
+
* Every fixture that opens a runtime needs one now: a profile's grants name rows
|
|
84
|
+
* in this file, and `assertGrantsResolve` refuses a grant with nothing behind it
|
|
85
|
+
* (ADR-057). Thirty tests writing the seven owner-layer rows by hand is how a
|
|
86
|
+
* fixture ends up subtly different from what `newConnectionsTemplate` writes.
|
|
87
|
+
*/
|
|
88
|
+
export function connectionsYaml(
|
|
89
|
+
extra: readonly { id: string; provider: string; account: string }[] = [],
|
|
90
|
+
): string {
|
|
91
|
+
const owner = [
|
|
92
|
+
{ id: 'main', provider: 'memory', account: 'Memory' },
|
|
93
|
+
{ id: 'main', provider: 'tasks', account: 'Tasks' },
|
|
94
|
+
{ id: 'main', provider: 'assets', account: 'Assets' },
|
|
95
|
+
{ id: 'main', provider: 'skills', account: 'Skills' },
|
|
96
|
+
{ id: 'main', provider: 'vault', account: 'Vault' },
|
|
97
|
+
{ id: 'main', provider: 'setup', account: 'Setup' },
|
|
98
|
+
{ id: 'main', provider: 'entities', account: 'Entities' },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const rows = [...owner, ...extra]
|
|
102
|
+
.map((row) => ` - { id: ${row.id}, provider: ${row.provider}, account: ${row.account} }`)
|
|
103
|
+
.join('\n');
|
|
104
|
+
|
|
105
|
+
return `contract: ${SUPPORTED_CONTRACT}\nconnections:\n${rows}\noauth_apps: {}\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The owner-layer grant rows a fresh profile carries, plus whatever a test names. */
|
|
109
|
+
export function grantsYaml(
|
|
110
|
+
extra: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[] = [],
|
|
111
|
+
): string {
|
|
112
|
+
const owner = ['memory', 'tasks', 'assets', 'skills', 'vault', 'setup', 'entities'].map(
|
|
113
|
+
(provider) => ({ connection: `${provider}.main`, allow: [`${provider}.*`], deny: [] }),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return [...owner, ...extra]
|
|
117
|
+
.map(
|
|
118
|
+
(grant) =>
|
|
119
|
+
` - { connection: ${grant.connection}, allow: [${(grant.allow ?? []).join(', ')}], ` +
|
|
120
|
+
`deny: [${(grant.deny ?? []).join(', ')}] }`,
|
|
121
|
+
)
|
|
122
|
+
.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A whole profile document at the current contract, for a test that needs one. */
|
|
126
|
+
export function profileYaml(
|
|
127
|
+
profile: string,
|
|
128
|
+
options: {
|
|
129
|
+
port?: number;
|
|
130
|
+
grants?: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[];
|
|
131
|
+
members?: readonly string[];
|
|
132
|
+
} = {},
|
|
133
|
+
): string {
|
|
134
|
+
const members = (options.members ?? [])
|
|
135
|
+
.map((subject) => ` - { subject: ${subject}, role: owner }`)
|
|
136
|
+
.join('\n');
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
`contract: ${SUPPORTED_CONTRACT}\n` +
|
|
140
|
+
`instance:\n profile: ${profile}\n port: ${options.port ?? 7337}\n host: 127.0.0.1\n` +
|
|
141
|
+
`auth:\n mode: bearer\n token_ref: profile/token\n` +
|
|
142
|
+
`grants:\n${grantsYaml(options.grants ?? [])}\n` +
|
|
143
|
+
`members:${members ? `\n${members}` : ' []'}\n`
|
|
77
144
|
);
|
|
78
145
|
}
|
package/src/profile/workspace.ts
CHANGED
|
@@ -5,7 +5,16 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { parse as parseYaml } from 'yaml';
|
|
7
7
|
import { ConfigError } from './load.ts';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
SUPPORTED_CONTRACT,
|
|
10
|
+
connectionsFileSchema,
|
|
11
|
+
workspaceSchema,
|
|
12
|
+
type Config,
|
|
13
|
+
type ConnectionsFile,
|
|
14
|
+
type WorkspaceConfig,
|
|
15
|
+
} from './schema.ts';
|
|
16
|
+
import { assertConnectionsUnique, assertNoRenamedProviders } from './connections.ts';
|
|
17
|
+
import { findSecrets, formatSecretFindings } from './secret-detection.ts';
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* Workspace and profile resolution.
|
|
@@ -152,6 +161,52 @@ export async function loadProfileConfig(
|
|
|
152
161
|
return parseConfig(text, shown);
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
/** `connections.yaml` sits beside `lanes-link.yaml`, at the workspace root. */
|
|
165
|
+
export const CONNECTIONS_FILE = 'connections.yaml';
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every account authorised in this workspace (ADR-057).
|
|
169
|
+
*
|
|
170
|
+
* Absent is not an error. A workspace that has never connected anything has no
|
|
171
|
+
* file, and returning an empty set rather than throwing is what lets `profile
|
|
172
|
+
* add`, `status` and `doctor` answer on a fresh install — the same reasoning
|
|
173
|
+
* `readWorkspace` uses for returning null.
|
|
174
|
+
*
|
|
175
|
+
* Read through the workspace store rather than the filesystem, so a deployed
|
|
176
|
+
* revision whose root is a bucket URL loads it (ADR-049). That was the exact
|
|
177
|
+
* bug manifests hit: a filesystem read against a `gs://` root silently found
|
|
178
|
+
* nothing and every custom provider vanished.
|
|
179
|
+
*/
|
|
180
|
+
export async function readConnections(workspaceRoot: string): Promise<ConnectionsFile> {
|
|
181
|
+
const path = join(workspaceRoot, CONNECTIONS_FILE);
|
|
182
|
+
const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), CONNECTIONS_FILE);
|
|
183
|
+
if (text === null) return { contract: SUPPORTED_CONTRACT, connections: [], oauth_apps: {} };
|
|
184
|
+
|
|
185
|
+
const raw = parseYaml(text);
|
|
186
|
+
|
|
187
|
+
const secrets = findSecrets(raw);
|
|
188
|
+
if (secrets.length > 0) {
|
|
189
|
+
throw new ConfigError(
|
|
190
|
+
`${path}: ${formatSecretFindings(secrets)}`,
|
|
191
|
+
secrets.map((finding) => finding.path),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const parsed = connectionsFileSchema.safeParse(raw);
|
|
196
|
+
if (!parsed.success) {
|
|
197
|
+
throw new ConfigError(
|
|
198
|
+
`${path}:\n${parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n')}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
assertConnectionsUnique(parsed.data.connections);
|
|
203
|
+
assertNoRenamedProviders(
|
|
204
|
+
parsed.data.connections,
|
|
205
|
+
'lanes link doctor --fix --profile <name> --workspace <name>',
|
|
206
|
+
);
|
|
207
|
+
return parsed.data;
|
|
208
|
+
}
|
|
209
|
+
|
|
155
210
|
export async function readWorkspace(workspaceRoot: string): Promise<WorkspaceConfig | null> {
|
|
156
211
|
const path = join(workspaceRoot, WORKSPACE_FILE);
|
|
157
212
|
const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), WORKSPACE_FILE);
|
|
@@ -219,7 +274,7 @@ export function noProfileNamed(
|
|
|
219
274
|
if (available.length === 0) {
|
|
220
275
|
return new ConfigError(
|
|
221
276
|
`--profile is required, and ${workspaceRoot} holds no profiles yet.\n` +
|
|
222
|
-
' Create one with: lanes link profile add <name> --
|
|
277
|
+
' Create one with: lanes link profile add <name> --workspace local',
|
|
223
278
|
);
|
|
224
279
|
}
|
|
225
280
|
|
|
@@ -230,7 +285,7 @@ export function noProfileNamed(
|
|
|
230
285
|
'nothing else selects one.\n\n' +
|
|
231
286
|
` Profiles in ${workspaceRoot}\n` +
|
|
232
287
|
available.map((name) => ` ${name}`).join('\n') +
|
|
233
|
-
`\n\n e.g. lanes link status --profile ${available[0]} --
|
|
288
|
+
`\n\n e.g. lanes link status --profile ${available[0]} --workspace <name>` +
|
|
234
289
|
(stale
|
|
235
290
|
? `\n\n LANES_LINK_PROFILE=${stale} is set in this shell and is no longer read.\n` +
|
|
236
291
|
' Unset it, or pass --profile.'
|
|
@@ -12,5 +12,5 @@
|
|
|
12
12
|
* connectivity type; `load.ts` reads and validates them.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
export {
|
|
15
|
+
export { loadWorkspaceProviders, parseManifest, type LoadedManifest } from './load.ts';
|
|
16
16
|
export { manifestTemplate } from './template.ts';
|
|
@@ -38,14 +38,13 @@ export interface LoadedManifest {
|
|
|
38
38
|
readonly path: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export async function
|
|
41
|
+
export async function loadWorkspaceProviders(
|
|
42
42
|
workspaceRoot: string,
|
|
43
|
-
profile: string,
|
|
44
43
|
/** Injected for tests. A bucket is the case this exists to cover. */
|
|
45
44
|
store?: BlobStore,
|
|
46
45
|
): Promise<LoadedManifest[]> {
|
|
47
46
|
const files = store ?? workspaceFiles(workspaceRoot);
|
|
48
|
-
const directory = layout.providers(
|
|
47
|
+
const directory = layout.providers();
|
|
49
48
|
|
|
50
49
|
let keys: string[];
|
|
51
50
|
try {
|
|
@@ -128,7 +128,7 @@ function render(
|
|
|
128
128
|
// Both flags, spelled out. They are required (ADR-037), so a command
|
|
129
129
|
// missing either is one the owner pastes and watches refuse — and this is
|
|
130
130
|
// handed to an agent, which relays it verbatim.
|
|
131
|
-
const where = `--profile ${profile}${target ? ` --
|
|
131
|
+
const where = `--profile ${profile}${target ? ` --workspace ${target}` : ''}`;
|
|
132
132
|
return (
|
|
133
133
|
`Profile "${profile}" declares no identity.\n\n` +
|
|
134
134
|
'Nothing here says what name or address to use, so do not invent one — ask. ' +
|
|
@@ -272,8 +272,26 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
|
|
|
272
272
|
tag: z.string().optional().describe('Restrict to entries carrying this tag'),
|
|
273
273
|
limit: z.number().int().min(1).max(50).optional().describe(`Maximum results (default ${DEFAULT_LIMIT})`),
|
|
274
274
|
}),
|
|
275
|
-
//
|
|
276
|
-
//
|
|
275
|
+
// Kept, and this reverses the rule the rest of this file follows.
|
|
276
|
+
//
|
|
277
|
+
// The old comment here read "a memory query is as revealing as a Gmail
|
|
278
|
+
// search query, and frequently more so", which is true and is an argument
|
|
279
|
+
// about the wrong thing. A search term is not the owner's material: it is
|
|
280
|
+
// what an *agent* went looking for in that material, which is precisely
|
|
281
|
+
// the question this log exists to answer. Withholding it leaves a record
|
|
282
|
+
// saying memory was searched, twice, and matched nothing, with no way to
|
|
283
|
+
// tell a calendar lookup from a rummage through someone's medical notes.
|
|
284
|
+
//
|
|
285
|
+
// The body stays withheld either way — `entry` and `get` keep only `uri`
|
|
286
|
+
// and `id`, so what was *found* is still not in here. This records the
|
|
287
|
+
// question, not the answer.
|
|
288
|
+
//
|
|
289
|
+
// The reason for the old rule was `audit/fanout.ts`: a workspace may ship
|
|
290
|
+
// copies to stdout or an OTLP collector, and those leave the machine. That
|
|
291
|
+
// is a real exposure and it is the operator's to weigh, which is what a
|
|
292
|
+
// declared sink is. It is not a reason to withhold from the durable log on
|
|
293
|
+
// the operator's own disk.
|
|
294
|
+
redact: keepKeys('query', 'tag', 'limit'),
|
|
277
295
|
async handler({ query, tag, limit }, context) {
|
|
278
296
|
const needle = query.toLowerCase();
|
|
279
297
|
const entries = await allEntries(context.storage);
|
|
@@ -137,7 +137,7 @@ export function planFor(
|
|
|
137
137
|
// missing either is one that refuses, or worse, writes a credential into a
|
|
138
138
|
// store the endpoint that asked for it does not read.
|
|
139
139
|
const command =
|
|
140
|
-
`lanes link connect ${manifest.id} --profile ${context.profile} --
|
|
140
|
+
`lanes link connect ${manifest.id} --profile ${context.profile} --workspace ${context.target}` +
|
|
141
141
|
(needsId ? ' --id <name>' : connectionId ? ` --id ${connectionId}` : '');
|
|
142
142
|
|
|
143
143
|
return {
|
|
@@ -105,12 +105,12 @@ export const slack = defineProvider({
|
|
|
105
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 https://lanes.sh/docs/link/slack.',
|
|
106
106
|
'Choose "Install to Workspace" and approve. A Slack admin may have to approve it for you.',
|
|
107
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 --
|
|
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 --workspace local --auth pasted_token --replace.',
|
|
109
109
|
],
|
|
110
110
|
troubleshooting:
|
|
111
111
|
'Slack refused the token. The usual causes are a bot token (xoxb-) pasted where the user token (xoxp-) belongs, ' +
|
|
112
112
|
'a scope missing from USER TOKEN SCOPES, or an app that was reinstalled since — reinstalling mints a new token. ' +
|
|
113
|
-
'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --profile personal --
|
|
113
|
+
'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --profile personal --workspace local --auth pasted_token --replace.',
|
|
114
114
|
prompts: [
|
|
115
115
|
{
|
|
116
116
|
key: 'token',
|
|
@@ -1,21 +1,43 @@
|
|
|
1
|
-
import type { PolicyDocument, PolicyRule } from '#policy';
|
|
2
|
-
import type { Config } from '#profile';
|
|
1
|
+
import type { PolicyDocument, PolicyRule, ProfilePolicy } from '#policy';
|
|
2
|
+
import type { Config, GrantConfig } from '#profile';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Turn the config's `
|
|
5
|
+
* Turn the config's `grants:` rows into the documents the policy engine
|
|
6
6
|
* evaluates.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* what
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Two format differences are reconciled here, and keeping them in one place is
|
|
9
|
+
* what stops either leaking into the other side.
|
|
10
|
+
*
|
|
11
|
+
* The config keeps `allow` and `deny` as separate lists because that is what
|
|
12
|
+
* reads clearly in YAML; the engine wants one list carrying an effect, because
|
|
13
|
+
* evaluation must consider both together to make deny win regardless of
|
|
14
|
+
* ordering.
|
|
15
|
+
*
|
|
16
|
+
* And the config is a *sequence* of rows while the engine wants a lookup by
|
|
17
|
+
* connection (ADR-058). A profile cannot declare the same connection twice —
|
|
18
|
+
* `assertGrantsResolve` refuses it at load, precisely so that this conversion
|
|
19
|
+
* has no collision to resolve and no precedence to invent.
|
|
20
|
+
*
|
|
21
|
+
* This is the only place `#profile`'s shape meets `#policy`'s. `#policy` may
|
|
22
|
+
* not import `#profile` and does not need to: it takes a map of rule lists and
|
|
23
|
+
* knows nothing about YAML, grants, or where a connection was declared.
|
|
12
24
|
*/
|
|
13
|
-
export function toPolicyDocument(config: Config):
|
|
25
|
+
export function toPolicyDocument(config: Config): ProfilePolicy {
|
|
26
|
+
const byConnection = new Map<string, PolicyDocument>();
|
|
27
|
+
|
|
28
|
+
for (const grant of config.grants) {
|
|
29
|
+
byConnection.set(grant.connection, { rules: rulesFor(grant) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { byConnection };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rulesFor(grant: GrantConfig): PolicyRule[] {
|
|
14
36
|
const rules: PolicyRule[] = [];
|
|
15
37
|
|
|
16
38
|
for (const [effect, entries] of [
|
|
17
|
-
['allow',
|
|
18
|
-
['deny',
|
|
39
|
+
['allow', grant.allow],
|
|
40
|
+
['deny', grant.deny],
|
|
19
41
|
] as const) {
|
|
20
42
|
for (const entry of entries) {
|
|
21
43
|
rules.push({
|
|
@@ -28,5 +50,5 @@ export function toPolicyDocument(config: Config): PolicyDocument {
|
|
|
28
50
|
}
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
return
|
|
53
|
+
return rules;
|
|
32
54
|
}
|
|
@@ -144,7 +144,7 @@ export function planIsNoop(plan: ReconcilePlan): boolean {
|
|
|
144
144
|
* half-configured Gmail account must not stop the other providers from serving.
|
|
145
145
|
*/
|
|
146
146
|
export async function planReconcile(
|
|
147
|
-
|
|
147
|
+
connections: readonly ConnectionConfig[],
|
|
148
148
|
state: RuntimeState,
|
|
149
149
|
credentials: SecretStore,
|
|
150
150
|
/**
|
|
@@ -164,7 +164,7 @@ export async function planReconcile(
|
|
|
164
164
|
const unauthorized: string[] = [];
|
|
165
165
|
const declared = new Set<string>();
|
|
166
166
|
|
|
167
|
-
for (const connection of
|
|
167
|
+
for (const connection of connections) {
|
|
168
168
|
const key = `${connection.provider}.${connection.id}`;
|
|
169
169
|
declared.add(key);
|
|
170
170
|
|
|
@@ -229,11 +229,11 @@ export async function planReconcile(
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
export async function applyReconcile(
|
|
232
|
-
|
|
232
|
+
connections: readonly ConnectionConfig[],
|
|
233
233
|
state: RuntimeState,
|
|
234
234
|
plan: ReconcilePlan,
|
|
235
235
|
): Promise<void> {
|
|
236
|
-
const byKey = new Map(
|
|
236
|
+
const byKey = new Map(connections.map((one) => [`${one.provider}.${one.id}`, one]));
|
|
237
237
|
|
|
238
238
|
for (const action of plan.actions) {
|
|
239
239
|
if (action.kind === 'unchanged') continue;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IssuedTokenAuthenticator,
|
|
3
|
+
OAuthServer,
|
|
4
|
+
OAuthStore,
|
|
5
|
+
OidcAuthenticator,
|
|
6
|
+
OidcVerifier,
|
|
7
|
+
lanesFederation,
|
|
8
|
+
type Authenticator,
|
|
9
|
+
} from '#auth';
|
|
10
|
+
import type { Logger } from '#connectivity';
|
|
11
|
+
import type { Runtime } from '#cli/runtime.ts';
|
|
12
|
+
import { MCP_PATH } from './index.ts';
|
|
13
|
+
import type { AuthorizationSurface } from './oauth.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deciding how a remote client proves who it is.
|
|
17
|
+
*
|
|
18
|
+
* Its own file, away from the lifecycle in `endpoint.ts`, because the two
|
|
19
|
+
* answer different questions and only one of them is interesting. Starting an
|
|
20
|
+
* endpoint is bind, serve, reload, stop. This is which of three models the
|
|
21
|
+
* profile declared — none, an issuer of its own, or somebody else's — and each
|
|
22
|
+
* arm carries an argument that has to be read to be changed safely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The remote-client gate, if this profile declares one.
|
|
27
|
+
*
|
|
28
|
+
* Endpoint-scoped rather than per profile, like the bearer token and for the
|
|
29
|
+
* same reason (ADR-009): one URL serves every profile in the workspace, so
|
|
30
|
+
* there is one place a client authorises and one set of tokens.
|
|
31
|
+
*
|
|
32
|
+
* Returns null when `auth.authorization` is absent, and everything downstream
|
|
33
|
+
* treats null as "exactly as before" — no metadata published, no pointer on the
|
|
34
|
+
* `401`, one authenticator instead of a chain.
|
|
35
|
+
*/
|
|
36
|
+
export async function openAuthorization(
|
|
37
|
+
primary: Runtime,
|
|
38
|
+
log: Logger,
|
|
39
|
+
members: (subject: string) => Promise<readonly string[]>,
|
|
40
|
+
): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
|
|
41
|
+
const declared = primary.config.auth.authorization;
|
|
42
|
+
if (!declared) return null;
|
|
43
|
+
|
|
44
|
+
const profile = primary.resolution.profile;
|
|
45
|
+
|
|
46
|
+
if (declared.mode === 'oidc') {
|
|
47
|
+
const audience = await primary.credentials.get(declared.client_id_ref);
|
|
48
|
+
if (!audience) {
|
|
49
|
+
// Refuse rather than verify without an audience. A verifier that cannot
|
|
50
|
+
// check who a token was issued for accepts every token the issuer minted
|
|
51
|
+
// for anything, which is the failure this mode exists to prevent.
|
|
52
|
+
throw new Error(
|
|
53
|
+
`auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
|
|
54
|
+
`this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const verifier = new OidcVerifier({
|
|
59
|
+
issuer: declared.issuer,
|
|
60
|
+
audience,
|
|
61
|
+
allowedSubjects: declared.allowed_subjects,
|
|
62
|
+
...(declared.introspection_endpoint
|
|
63
|
+
? { introspectionEndpoint: declared.introspection_endpoint }
|
|
64
|
+
: {}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
// The issuer is somebody else's origin, so it is a constant here rather
|
|
69
|
+
// than derived from the request.
|
|
70
|
+
surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
|
|
71
|
+
authenticator: new OidcAuthenticator(verifier, profile),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const store = new OAuthStore(primary.state.kv);
|
|
76
|
+
|
|
77
|
+
const server = new OAuthServer({
|
|
78
|
+
store,
|
|
79
|
+
accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
|
|
80
|
+
// So a replayed refresh token leaves a line. It is refused rather than
|
|
81
|
+
// acted on (ADR-035), and a refusal nobody can see is how a connector
|
|
82
|
+
// losing its authorization came to need log forensics to explain.
|
|
83
|
+
log,
|
|
84
|
+
// Identity comes from lanes.sh, not from a credential pasted into a form on
|
|
85
|
+
// this machine (ADR-062). What this endpoint decides is the half lanes.sh
|
|
86
|
+
// cannot know: which of *its* profiles name that person.
|
|
87
|
+
federation: lanesFederation({ profilesFor: members }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
|
|
92
|
+
authenticator: new IssuedTokenAuthenticator(store, profile),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/server/edge.ts
CHANGED
|
@@ -33,11 +33,20 @@ export const FAILED_AUTH_PER_MINUTE = 30;
|
|
|
33
33
|
* - `/authorize` compares against the endpoint token, which is another read of
|
|
34
34
|
* the credential store.
|
|
35
35
|
* - `/token` reads and writes bucket objects.
|
|
36
|
+
* - `/state` and `/audit` verify the pairing token, which on a deployed
|
|
37
|
+
* workspace is a Secret Manager call and has no cache behind it at all.
|
|
36
38
|
*
|
|
37
39
|
* `/health` presented with *no* credential is deliberately free: it reads
|
|
38
40
|
* nothing, and it is what a platform probe and `lanes link outputs` send.
|
|
39
41
|
* Metering it would put a ceiling on the one request that costs nothing to
|
|
40
42
|
* answer.
|
|
43
|
+
*
|
|
44
|
+
* The read surface is **not** given that exemption, and the asymmetry is the
|
|
45
|
+
* point: nothing legitimate calls `/state` without a credential — no probe, no
|
|
46
|
+
* CLI command — so there is no free request to protect. Metering it
|
|
47
|
+
* unconditionally also means the ceiling does not depend on `readRoutes`
|
|
48
|
+
* continuing to parse the header before it asks the store anything. That
|
|
49
|
+
* short-circuit is an optimisation; this is the guarantee.
|
|
41
50
|
*/
|
|
42
51
|
export const UNAUTHENTICATED_PER_MINUTE = 30;
|
|
43
52
|
|
|
@@ -141,6 +150,9 @@ export function unauthenticatedRefusal(input: {
|
|
|
141
150
|
readonly healthPath: string;
|
|
142
151
|
readonly isAuthorizationPath: (pathname: string) => boolean;
|
|
143
152
|
readonly authorizationEnabled: boolean;
|
|
153
|
+
/** Passed rather than imported, for the reason `isAuthorizationPath` is. */
|
|
154
|
+
readonly isReadPath: (pathname: string) => boolean;
|
|
155
|
+
readonly readEnabled: boolean;
|
|
144
156
|
}): Response | undefined {
|
|
145
157
|
// A `/health` carrying no credential reads nothing and is deliberately free —
|
|
146
158
|
// it is what a platform probe and `lanes link outputs` send, and a ceiling on
|
|
@@ -149,7 +161,8 @@ export function unauthenticatedRefusal(input: {
|
|
|
149
161
|
const costly =
|
|
150
162
|
input.pathname === input.healthPath
|
|
151
163
|
? input.request.headers.get('authorization') !== null
|
|
152
|
-
: input.
|
|
164
|
+
: (input.readEnabled && input.isReadPath(input.pathname)) ||
|
|
165
|
+
(input.authorizationEnabled && input.isAuthorizationPath(input.pathname));
|
|
153
166
|
|
|
154
167
|
if (!costly) return undefined;
|
|
155
168
|
|