@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.
- package/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The key half of RFC 7523: a private key on disk, and the assertion it signs.
|
|
5
|
+
*
|
|
6
|
+
* Split from `index.ts` because it is the only part with no I/O — no store, no
|
|
7
|
+
* network — which is what makes the claim set and the signature testable
|
|
8
|
+
* against a locally generated key rather than against a live token endpoint.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows which vendor issued the key. The layout below is the one
|
|
11
|
+
* every authorization server that accepts this grant ships, and the endpoint to
|
|
12
|
+
* present the assertion at comes from the key file itself rather than from a
|
|
13
|
+
* constant, so adding a second vendor is a manifest and no code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The issued key file, as the vendor's console writes it.
|
|
18
|
+
*
|
|
19
|
+
* Parsed rather than trusted: the common mistake is pasting the *client* JSON —
|
|
20
|
+
* the one with `installed` or `web` at the top level — which is a different
|
|
21
|
+
* file with a different purpose and would otherwise fail much later, at the
|
|
22
|
+
* token endpoint, as an unexplained 400.
|
|
23
|
+
*/
|
|
24
|
+
export const assertionKeySchema = z.object({
|
|
25
|
+
/** Who the assertion is from. Also the address a resource is shared with. */
|
|
26
|
+
client_email: z.string().min(1),
|
|
27
|
+
/** PKCS#8 PEM. */
|
|
28
|
+
private_key: z.string().min(1),
|
|
29
|
+
/** Where the assertion is exchanged. Read from the file so no vendor is named here. */
|
|
30
|
+
token_uri: z.url(),
|
|
31
|
+
/** Names which key signed it, for a server holding more than one. */
|
|
32
|
+
private_key_id: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type AssertionKey = z.infer<typeof assertionKeySchema>;
|
|
36
|
+
|
|
37
|
+
export function parseAssertionKey(raw: string, ref: string): AssertionKey {
|
|
38
|
+
let json: unknown;
|
|
39
|
+
try {
|
|
40
|
+
json = JSON.parse(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`The key at ${ref} is not JSON. It should be the whole file the console downloaded, pasted verbatim.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = assertionKeySchema.safeParse(json);
|
|
48
|
+
if (parsed.success) return parsed.data;
|
|
49
|
+
|
|
50
|
+
// The two files are easy to confuse and the console offers both on adjacent
|
|
51
|
+
// pages, so say which one is in hand rather than listing missing fields.
|
|
52
|
+
const shape = json as Record<string, unknown> | null;
|
|
53
|
+
if (shape && (shape['installed'] !== undefined || shape['web'] !== undefined)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`The key at ${ref} is an OAuth *client* file, not an account key. That one is for the ` +
|
|
56
|
+
'browser flow. The key needed here is downloaded from the account itself and has ' +
|
|
57
|
+
'"private_key" in it.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(
|
|
62
|
+
`The key at ${ref} is missing ${parsed.error.issues.map((issue) => issue.path.join('.')).join(', ')}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** JWT's own encoding: base64 with a URL-safe alphabet and no padding. */
|
|
67
|
+
function base64url(bytes: Uint8Array | string): string {
|
|
68
|
+
const raw =
|
|
69
|
+
typeof bytes === 'string'
|
|
70
|
+
? btoa(bytes)
|
|
71
|
+
: btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
|
72
|
+
return raw.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* PKCS#8 PEM to the DER bytes `importKey` wants.
|
|
77
|
+
*
|
|
78
|
+
* Tolerant of how the key arrives: a console download carries real newlines, a
|
|
79
|
+
* value pasted through an environment variable or a JSON string often carries
|
|
80
|
+
* literal `\n` instead, and both are the same key. Rejecting the second would
|
|
81
|
+
* be a failure whose cause is invisible in a terminal.
|
|
82
|
+
*/
|
|
83
|
+
function derFromPem(pem: string): Uint8Array {
|
|
84
|
+
const body = pem
|
|
85
|
+
.replaceAll('\\n', '\n')
|
|
86
|
+
.replace(/-----[^-]+-----/g, '')
|
|
87
|
+
.replace(/\s+/g, '');
|
|
88
|
+
|
|
89
|
+
const binary = atob(body);
|
|
90
|
+
const der = new Uint8Array(binary.length);
|
|
91
|
+
for (let index = 0; index < binary.length; index += 1) der[index] = binary.charCodeAt(index);
|
|
92
|
+
return der;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How long the assertion is good for. Kept short: it is minted per exchange. */
|
|
96
|
+
const ASSERTION_LIFETIME_SECONDS = 3600;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sign the assertion this grant exchanges for a token.
|
|
100
|
+
*
|
|
101
|
+
* `sub` is what makes one identity act as another, and it is present only when
|
|
102
|
+
* the caller supplies one — an assertion carrying an empty `sub` is not the
|
|
103
|
+
* same request as one carrying none, and servers treat it as malformed rather
|
|
104
|
+
* than as absent.
|
|
105
|
+
*/
|
|
106
|
+
export async function signAssertion(input: {
|
|
107
|
+
readonly key: AssertionKey;
|
|
108
|
+
readonly scopes: readonly string[];
|
|
109
|
+
/** The account to act as, where the key is only permitted to borrow one. */
|
|
110
|
+
readonly subject?: string | undefined;
|
|
111
|
+
/** Injected by tests so the claim set is checkable. */
|
|
112
|
+
readonly now?: number;
|
|
113
|
+
}): Promise<string> {
|
|
114
|
+
const issuedAt = Math.floor((input.now ?? Date.now()) / 1000);
|
|
115
|
+
|
|
116
|
+
const header = {
|
|
117
|
+
alg: 'RS256',
|
|
118
|
+
typ: 'JWT',
|
|
119
|
+
...(input.key.private_key_id ? { kid: input.key.private_key_id } : {}),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const claims = {
|
|
123
|
+
iss: input.key.client_email,
|
|
124
|
+
scope: input.scopes.join(' '),
|
|
125
|
+
aud: input.key.token_uri,
|
|
126
|
+
iat: issuedAt,
|
|
127
|
+
exp: issuedAt + ASSERTION_LIFETIME_SECONDS,
|
|
128
|
+
...(input.subject ? { sub: input.subject } : {}),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`;
|
|
132
|
+
|
|
133
|
+
const privateKey = await crypto.subtle.importKey(
|
|
134
|
+
'pkcs8',
|
|
135
|
+
derFromPem(input.key.private_key) as unknown as ArrayBuffer,
|
|
136
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
137
|
+
false,
|
|
138
|
+
['sign'],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const signature = await crypto.subtle.sign(
|
|
142
|
+
'RSASSA-PKCS1-v1_5',
|
|
143
|
+
privateKey,
|
|
144
|
+
new TextEncoder().encode(signingInput) as unknown as ArrayBuffer,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return `${signingInput}.${base64url(new Uint8Array(signature))}`;
|
|
148
|
+
}
|
|
@@ -42,7 +42,7 @@ export function credentialResolver(
|
|
|
42
42
|
const ref = credentialRefForConnection(manifest, connectionId)!;
|
|
43
43
|
const value = await secrets.get(ref);
|
|
44
44
|
if (!value) {
|
|
45
|
-
throw new Error(`No credential stored at ${ref}.
|
|
45
|
+
throw new Error(`No credential stored at ${ref}. Connecting "${providerId}" again for this profile and target would store one.`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
switch (auth.kind) {
|
|
@@ -2,6 +2,7 @@ import { type ProviderManifest } from '#connectivity';
|
|
|
2
2
|
import type { ProviderRegistry } from '#registry';
|
|
3
3
|
import type { SecretStore } from '#secrets';
|
|
4
4
|
import { credentialResolver } from './resolve.ts';
|
|
5
|
+
import { resolveAssertionToken, storedAssertionFor } from './oauth-jwt/index.ts';
|
|
5
6
|
import { CredentialOAuthProvider } from './oauth-authcode/provider.ts';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -81,6 +82,16 @@ export async function bearerTokenAsStored(
|
|
|
81
82
|
): Promise<string | null> {
|
|
82
83
|
if (manifest.auth.kind !== 'oauth') return bearerToken(manifest, connectionId, secrets);
|
|
83
84
|
|
|
85
|
+
// An assertion credential has no token stored to prefer — the token is minted
|
|
86
|
+
// from the key, which is what "as stored" means here. Without this branch the
|
|
87
|
+
// identity call `connect` makes immediately after writing the pointer reads
|
|
88
|
+
// `access_token` off a blob that has none, sends no Authorization header, and
|
|
89
|
+
// reports the credential rejected.
|
|
90
|
+
const assertion = await storedAssertionFor(manifest, connectionId, secrets);
|
|
91
|
+
if (assertion) {
|
|
92
|
+
return resolveAssertionToken({ manifest, connectionId, stored: assertion, credentials: secrets });
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
const provider = new CredentialOAuthProvider({
|
|
85
96
|
manifest,
|
|
86
97
|
connectionId,
|
|
@@ -53,6 +53,7 @@ export { defineLocalProvider, defineProviderWithCapabilities } from './provider.
|
|
|
53
53
|
export type {
|
|
54
54
|
ProviderManifest,
|
|
55
55
|
ConnectorConfig,
|
|
56
|
+
AuthAssertion,
|
|
56
57
|
AuthBroker,
|
|
57
58
|
AuthConfig,
|
|
58
59
|
SetupDeclaration,
|
|
@@ -69,6 +70,7 @@ export {
|
|
|
69
70
|
bundleSchema,
|
|
70
71
|
credentialRefForConnection,
|
|
71
72
|
rotatableCredentialRefs,
|
|
73
|
+
hasOwnClientPath,
|
|
72
74
|
setupRequirements,
|
|
73
75
|
UNNAMED_ID,
|
|
74
76
|
RESERVED_PROVIDER_IDS,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { credentialRef, identifier } from './primitives.ts';
|
|
3
|
+
import { setupSchema } from './setup.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Credential types — how we prove who we are, orthogonal to how we connect.
|
|
@@ -34,14 +35,86 @@ export const authBrokerSchema = z.object({
|
|
|
34
35
|
|
|
35
36
|
export type AuthBroker = z.infer<typeof authBrokerSchema>;
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A second way in, for an authorization server that also accepts an assertion.
|
|
40
|
+
*
|
|
41
|
+
* RFC 7523: instead of a person approving a consent screen, the operator holds
|
|
42
|
+
* a private key, signs a short-lived JWT with it, and exchanges that for an
|
|
43
|
+
* access token. There is no refresh token because there is nothing to refresh —
|
|
44
|
+
* a new assertion is signed whenever the last token ages out — which is the
|
|
45
|
+
* whole reason this exists beside `oauth`. An authorization-code refresh token
|
|
46
|
+
* can be expired by the issuer's own policy; a key the operator holds cannot.
|
|
47
|
+
*
|
|
48
|
+
* Declared *on* the OAuth block rather than as a fourth `kind`, because it is
|
|
49
|
+
* an alternative arrangement for the same provider rather than a different
|
|
50
|
+
* provider. Everything that branches on `kind` — where the credential lands,
|
|
51
|
+
* what setup requires, which refs a deployed revision may rewrite — is
|
|
52
|
+
* unchanged, and a manifest that omits this reads and behaves exactly as before.
|
|
53
|
+
*
|
|
54
|
+
* Which one a connection actually uses is not recorded here or in config. The
|
|
55
|
+
* stored credential's *shape* is the switch, the same way an `oauth_apps` entry
|
|
56
|
+
* is the switch between a broker's client and the operator's own.
|
|
57
|
+
*/
|
|
58
|
+
export const authAssertionSchema = z.object({
|
|
59
|
+
/**
|
|
60
|
+
* What the operator types after `--auth`, and how a chosen method is named
|
|
61
|
+
* back to them.
|
|
62
|
+
*
|
|
63
|
+
* The provider's word rather than the protocol's. "Assertion" is what this is
|
|
64
|
+
* to the authorization server and means nothing to the person holding the
|
|
65
|
+
* file; they downloaded a service account key, and that is what the prompt
|
|
66
|
+
* and the flag should say. Keeping it here is also what stops the CLI from
|
|
67
|
+
* learning a vendor's vocabulary in order to print it.
|
|
68
|
+
*/
|
|
69
|
+
method: identifier,
|
|
70
|
+
/** The short name shown beside the choice — a noun, not a sentence. */
|
|
71
|
+
label: z.string().min(1),
|
|
72
|
+
/**
|
|
73
|
+
* Whether the assertion may stand for itself, or must name a user to act as.
|
|
74
|
+
*
|
|
75
|
+
* `optional` — the key is an identity in its own right, and reaches whatever
|
|
76
|
+
* has been shared with it. `required` — it can only borrow someone else's,
|
|
77
|
+
* so a connection without a subject would authenticate cleanly and then find
|
|
78
|
+
* nothing there. The CLI refuses a blank subject on `required` for that
|
|
79
|
+
* reason: the failure is otherwise a 404 on every call with no explanation.
|
|
80
|
+
*/
|
|
81
|
+
delegation: z.enum(['optional', 'required']).default('optional'),
|
|
82
|
+
/**
|
|
83
|
+
* Where the profile-shared key lives.
|
|
84
|
+
*
|
|
85
|
+
* Shared, not per-connection: one key covers every provider of a vendor, and
|
|
86
|
+
* asking for it once per provider would be seven pastes of the same file.
|
|
87
|
+
* The per-connection half is the subject, which is not a secret and is small
|
|
88
|
+
* enough to sit in the pointer the connection stores.
|
|
89
|
+
*/
|
|
90
|
+
key_ref: credentialRef,
|
|
91
|
+
/** One line for the choice prompt: what this method reaches, and what it does not. */
|
|
92
|
+
reach: z.string().min(1),
|
|
93
|
+
/**
|
|
94
|
+
* What to call the account this acts as, when it acts as one.
|
|
95
|
+
*
|
|
96
|
+
* Asked per connection and stored beside the pointer. Not a secret — it is an
|
|
97
|
+
* address — but it lives in the credential store rather than in config
|
|
98
|
+
* because it is half of a credential, and splitting a credential across two
|
|
99
|
+
* files is how the halves come to disagree.
|
|
100
|
+
*/
|
|
101
|
+
subject_label: z.string().min(1),
|
|
102
|
+
/** The console walkthrough for this method, rendered by the same code as `setup`. */
|
|
103
|
+
setup: setupSchema,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export type AuthAssertion = z.infer<typeof authAssertionSchema>;
|
|
107
|
+
|
|
37
108
|
export const authOAuthSchema = z.object({
|
|
38
109
|
kind: z.literal('oauth'),
|
|
39
110
|
/**
|
|
40
111
|
* `dynamic` — the authorization server offers Dynamic Client Registration, so
|
|
41
112
|
* we register ourselves and the operator does nothing at all (Notion, Linear).
|
|
42
113
|
*
|
|
43
|
-
* `manual` — the vendor requires a pre-registered client
|
|
44
|
-
*
|
|
114
|
+
* `manual` — the vendor requires a pre-registered client. Who supplies it is
|
|
115
|
+
* the profile's to decide and not this field's: an id and secret the operator
|
|
116
|
+
* registered (Google, if they choose to), or the client behind `broker`.
|
|
117
|
+
* `manual` says only that self-registration is not on offer.
|
|
45
118
|
*/
|
|
46
119
|
registration: z.enum(['dynamic', 'manual']).default('dynamic'),
|
|
47
120
|
/** Which `oauth_apps` entry holds the client, for `manual`. Shared across providers of a vendor. */
|
|
@@ -57,6 +130,30 @@ export const authOAuthSchema = z.object({
|
|
|
57
130
|
* that claimed one or the other would be wrong half the time.
|
|
58
131
|
*/
|
|
59
132
|
broker: authBrokerSchema.optional(),
|
|
133
|
+
/**
|
|
134
|
+
* The other way in, where the vendor offers one. Absent means browser or nothing.
|
|
135
|
+
*
|
|
136
|
+
* Additive and inert on its own: declaring it makes `connect` offer a choice
|
|
137
|
+
* and makes the resolver able to read an assertion credential. It changes
|
|
138
|
+
* nothing about a connection that authorised in a browser.
|
|
139
|
+
*/
|
|
140
|
+
assertion: authAssertionSchema.optional(),
|
|
141
|
+
/**
|
|
142
|
+
* Whether a token response carrying no refresh token is a failure.
|
|
143
|
+
*
|
|
144
|
+
* `required` — it is, and stopping is kinder than succeeding: the connection
|
|
145
|
+
* would work until the access token expires and then quietly stop. Google
|
|
146
|
+
* omits one when the account was already authorised for the app, which is a
|
|
147
|
+
* real and recoverable mistake.
|
|
148
|
+
*
|
|
149
|
+
* `optional` — the vendor issues a long-lived token and no refresh token is
|
|
150
|
+
* the normal, successful answer. Slack does this unless token rotation is
|
|
151
|
+
* enabled on the app, so demanding one would refuse every connection that
|
|
152
|
+
* worked.
|
|
153
|
+
*/
|
|
154
|
+
refresh_token: z.enum(['required', 'optional']).default('required'),
|
|
155
|
+
/** Where the operator withdraws a grant, named in the refusal above. */
|
|
156
|
+
revoke_url: z.url().optional(),
|
|
60
157
|
scopes: z.array(z.string()).default([]),
|
|
61
158
|
/**
|
|
62
159
|
* Usually discovered from the resource's metadata; set only to override.
|
|
@@ -30,6 +30,18 @@ export const identitySchema = z.discriminatedUnion('kind', [
|
|
|
30
30
|
url: z.url(),
|
|
31
31
|
/** Dotted path into the JSON body, e.g. `emailAddress` or `user.emailAddress`. */
|
|
32
32
|
field: z.string().min(1),
|
|
33
|
+
/**
|
|
34
|
+
* A second path, shown in brackets, where `field` alone is not unique.
|
|
35
|
+
*
|
|
36
|
+
* Almost no provider needs one: an address identifies a Google or iCloud
|
|
37
|
+
* account globally, and a GitHub login is unique across GitHub. Slack is
|
|
38
|
+
* the exception, because the thing it calls a user is scoped to a
|
|
39
|
+
* workspace — the same person in two workspaces answers `auth.test` with
|
|
40
|
+
* the same `user`, and one account string is how `settleIdentity` decides a
|
|
41
|
+
* connect is a *reconnect*. Without this, connecting a second workspace
|
|
42
|
+
* matches the first and overwrites its credential.
|
|
43
|
+
*/
|
|
44
|
+
qualifier: z.string().min(1).optional(),
|
|
33
45
|
}),
|
|
34
46
|
z.object({
|
|
35
47
|
kind: z.literal('tool'),
|
|
@@ -22,11 +22,13 @@ export {
|
|
|
22
22
|
|
|
23
23
|
export {
|
|
24
24
|
authNoneSchema,
|
|
25
|
+
authAssertionSchema,
|
|
25
26
|
authBrokerSchema,
|
|
26
27
|
authOAuthSchema,
|
|
27
28
|
authSchema,
|
|
28
29
|
authStrategySchema,
|
|
29
30
|
authTokenSchema,
|
|
31
|
+
type AuthAssertion,
|
|
30
32
|
type AuthBroker,
|
|
31
33
|
type AuthConfig,
|
|
32
34
|
} from './auth.ts';
|
|
@@ -43,4 +45,4 @@ export {
|
|
|
43
45
|
} from './provider.ts';
|
|
44
46
|
|
|
45
47
|
export type { SetupRequirement, SetupNeeds } from './requirements.ts';
|
|
46
|
-
export { setupRequirements, UNNAMED_ID } from './requirements.ts';
|
|
48
|
+
export { hasOwnClientPath, setupRequirements, UNNAMED_ID } from './requirements.ts';
|
|
@@ -65,7 +65,7 @@ export const providerManifestSchema = z.object({
|
|
|
65
65
|
export type ProviderManifest = z.infer<typeof providerManifestSchema>;
|
|
66
66
|
|
|
67
67
|
/** Provider ids reserved for the owner layer. */
|
|
68
|
-
export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup'];
|
|
68
|
+
export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup', 'identity'];
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Validate a manifest, with the cross-field rules the schema alone cannot
|
|
@@ -114,18 +114,47 @@ export function defineProvider(input: unknown): ProviderManifest {
|
|
|
114
114
|
`Provider "${manifest.id}": a broker supplies a pre-registered client, so auth must declare registration "manual" and an "app" naming the oauth_apps entry that overrides it.`,
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
if (!manifest.auth.authorize_url) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Provider "${manifest.id}": a broker performs the exchange, but the browser still goes to the vendor, so auth.authorize_url is required.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// An MCP provider hands the whole flow to the SDK, which posts to the token
|
|
123
|
+
// endpoint with whatever `clientInformation()` returned and has nowhere to
|
|
124
|
+
// route an exchange somebody else performs. Declaring both endpoints is
|
|
125
|
+
// what opts it off that path and onto the direct one, where the exchange is
|
|
126
|
+
// ours — so on an mcp connector the two arrive together or the manifest is
|
|
127
|
+
// describing a flow that cannot run. Refused here rather than discovered
|
|
128
|
+
// after the operator has already approved a consent screen. See ADR-040.
|
|
129
|
+
if (manifest.connector.kind === 'mcp' && !manifest.auth.token_url) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot route it through a broker, unless the manifest declares its own endpoints. Add auth.token_url beside auth.authorize_url, or drop the broker and register dynamically.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (manifest.auth.kind === 'oauth' && manifest.auth.assertion) {
|
|
137
|
+
// Same seam, same absence as the broker rule above. The SDK owns an mcp
|
|
138
|
+
// provider's exchange and takes a client, not a signed assertion — so the
|
|
139
|
+
// choice would be offered, accepted, and then have nowhere to go.
|
|
121
140
|
if (manifest.connector.kind === 'mcp') {
|
|
122
141
|
throw new Error(
|
|
123
|
-
`Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot
|
|
142
|
+
`Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot present a signed assertion. Remove auth.assertion.`,
|
|
124
143
|
);
|
|
125
144
|
}
|
|
126
|
-
|
|
145
|
+
// The assertion carries `aud` from the key file, but the *scopes* it claims
|
|
146
|
+
// come from the manifest. A provider requesting none would mint a token
|
|
147
|
+
// permitted to do nothing and only find out at the first call.
|
|
148
|
+
if (manifest.auth.scopes.length === 0) {
|
|
127
149
|
throw new Error(
|
|
128
|
-
`Provider "${manifest.id}":
|
|
150
|
+
`Provider "${manifest.id}": auth.assertion exchanges a signed assertion for a token scoped to auth.scopes, which is empty. There would be nothing to grant.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// The whole point of the alternative is that it asks for something. A block
|
|
154
|
+
// with no prompt reaches the walkthrough and then has nothing to collect.
|
|
155
|
+
if (manifest.auth.assertion.setup.prompts.length === 0) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Provider "${manifest.id}": auth.assertion declares no setup prompts, so there is no way to learn what key to ask for.`,
|
|
129
158
|
);
|
|
130
159
|
}
|
|
131
160
|
}
|
|
@@ -55,6 +55,15 @@ export interface SetupNeeds {
|
|
|
55
55
|
* `connect` will never ask for.
|
|
56
56
|
*/
|
|
57
57
|
readonly brokered: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* What `--auth pasted_token` would ask for, where the provider offers it.
|
|
60
|
+
*
|
|
61
|
+
* Separate from `requirements` because it is an alternative rather than a
|
|
62
|
+
* prerequisite: nothing needs it unless the browser route is closed to you.
|
|
63
|
+
* Reported only when it is *not* the chosen route, since when it is chosen it
|
|
64
|
+
* is a requirement above and naming it twice would read as two values.
|
|
65
|
+
*/
|
|
66
|
+
readonly pastedCredential: string | undefined;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/**
|
|
@@ -64,8 +73,23 @@ export interface SetupNeeds {
|
|
|
64
73
|
* command someone is told to paste and a command the CLI suggests should not be
|
|
65
74
|
* two different sentences.
|
|
66
75
|
*/
|
|
67
|
-
function storeCommand(ref: string, placeholder: string,
|
|
68
|
-
return
|
|
76
|
+
function storeCommand(ref: string, placeholder: string, where: Selection): string {
|
|
77
|
+
return (
|
|
78
|
+
`printf %s "${placeholder}" | lanes link secrets set ${ref}` +
|
|
79
|
+
` --profile ${where.profile} --target ${where.target}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which store a suggested command should write into.
|
|
85
|
+
*
|
|
86
|
+
* One object rather than two positional strings, because the pair travels
|
|
87
|
+
* together everywhere and a caller that transposed them would produce a command
|
|
88
|
+
* that runs, writes a credential, and puts it somewhere nobody looks.
|
|
89
|
+
*/
|
|
90
|
+
export interface Selection {
|
|
91
|
+
readonly profile: string;
|
|
92
|
+
readonly target: string;
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
/** How the value is spelled, for a ref that several prompts combine into. */
|
|
@@ -80,15 +104,79 @@ function placeholderFor(prompts: readonly SetupPrompt[]): string {
|
|
|
80
104
|
return '<value>';
|
|
81
105
|
}
|
|
82
106
|
|
|
107
|
+
/**
|
|
108
|
+
* What the key route needs, which is the key and nothing else.
|
|
109
|
+
*
|
|
110
|
+
* Never `needsId`, and that is the substantive difference from the block below:
|
|
111
|
+
* the key is shared across every connection of a vendor, so its ref does not
|
|
112
|
+
* derive from a connection id and nothing has to be named before it can be
|
|
113
|
+
* stored. The subject does derive per connection — but it is not a requirement
|
|
114
|
+
* because it cannot be placed ahead of time: it lives inside the pointer that
|
|
115
|
+
* `connect` itself writes, and there is no `secrets set` that would put it
|
|
116
|
+
* there.
|
|
117
|
+
*/
|
|
118
|
+
function assertionRequirements(
|
|
119
|
+
assertion: NonNullable<Extract<ProviderManifest['auth'], { kind: 'oauth' }>['assertion']>,
|
|
120
|
+
where: Selection,
|
|
121
|
+
): SetupNeeds {
|
|
122
|
+
const shared = assertion.setup.prompts.filter((prompt) => prompt.scope === 'shared');
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
requirements: shared.map((prompt) => ({
|
|
126
|
+
ref: assertion.key_ref,
|
|
127
|
+
label: prompt.label,
|
|
128
|
+
secret: prompt.secret,
|
|
129
|
+
scope: 'shared' as const,
|
|
130
|
+
prompts: [prompt.key],
|
|
131
|
+
command: storeCommand(assertion.key_ref, '<value>', where),
|
|
132
|
+
})),
|
|
133
|
+
needsId: false,
|
|
134
|
+
brokered: false,
|
|
135
|
+
pastedCredential: undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether the manifest describes an OAuth client of the operator's own.
|
|
141
|
+
*
|
|
142
|
+
* `defineProvider` permits a broker, or a shipped client id, with no client
|
|
143
|
+
* prompts — a provider with no bring-your-own path is a legal thing to be. Every
|
|
144
|
+
* surface that offers that path has to ask this first, or it offers a route with
|
|
145
|
+
* nothing behind it: the chooser, the client resolver, and the setup plan.
|
|
146
|
+
*
|
|
147
|
+
* Here rather than in any of the three because it was already written twice in
|
|
148
|
+
* two different components under two different names, and this change would
|
|
149
|
+
* have made it three.
|
|
150
|
+
*/
|
|
151
|
+
export function hasOwnClientPath(manifest: ProviderManifest): boolean {
|
|
152
|
+
return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
|
|
153
|
+
}
|
|
154
|
+
|
|
83
155
|
export function setupRequirements(
|
|
84
156
|
manifest: ProviderManifest,
|
|
85
157
|
connectionId: string | undefined,
|
|
86
|
-
|
|
158
|
+
where: Selection,
|
|
87
159
|
options: {
|
|
88
160
|
/** `oauth_apps` entries this profile declares — the clients that are its own. */
|
|
89
161
|
readonly ownClients?: readonly string[];
|
|
162
|
+
/**
|
|
163
|
+
* Which way in, for a provider offering two.
|
|
164
|
+
*
|
|
165
|
+
* Defaults to the browser flow, which is every provider's only route until
|
|
166
|
+
* one declares `auth.assertion` — so an omitted value reports exactly what
|
|
167
|
+
* it always reported. Passed by `connect` once the operator has chosen,
|
|
168
|
+
* because the two methods need different things and reporting the union
|
|
169
|
+
* would tell someone to store a client they are not going to use.
|
|
170
|
+
*/
|
|
171
|
+
readonly method?: 'oauth' | 'assertion' | 'pasted';
|
|
90
172
|
} = {},
|
|
91
173
|
): SetupNeeds {
|
|
174
|
+
const assertion = manifest.auth.kind === 'oauth' ? manifest.auth.assertion : undefined;
|
|
175
|
+
|
|
176
|
+
if (options.method === 'assertion' && assertion) {
|
|
177
|
+
return assertionRequirements(assertion, where);
|
|
178
|
+
}
|
|
179
|
+
|
|
92
180
|
const prompts = manifest.setup?.prompts ?? [];
|
|
93
181
|
|
|
94
182
|
// A shared prompt exists to collect a client the operator registers. When a
|
|
@@ -101,7 +189,20 @@ export function setupRequirements(
|
|
|
101
189
|
!(manifest.auth.app !== undefined && (options.ownClients ?? []).includes(manifest.auth.app));
|
|
102
190
|
|
|
103
191
|
const shared = brokered ? [] : prompts.filter((prompt) => prompt.scope === 'shared');
|
|
104
|
-
|
|
192
|
+
|
|
193
|
+
// An OAuth provider's per-connection prompt belongs to one route, not to the
|
|
194
|
+
// provider — it is what `--auth pasted_token` asks for, and nothing the
|
|
195
|
+
// browser route ever needs. Reporting it either way would put a
|
|
196
|
+
// mandatory-looking field in front of somebody whose whole path is a browser
|
|
197
|
+
// round trip, which is the same mistake `brokered` avoids one line above.
|
|
198
|
+
//
|
|
199
|
+
// No OAuth manifest had one of these until Slack kept its pasted token as the
|
|
200
|
+
// way past a workspace that has not approved the Lanes app.
|
|
201
|
+
const connectionPrompts = prompts.filter((prompt) => prompt.scope === 'connection');
|
|
202
|
+
const routed = manifest.auth.kind !== 'oauth' || options.method === 'pasted';
|
|
203
|
+
|
|
204
|
+
const pasted = routed ? [] : connectionPrompts;
|
|
205
|
+
const perConnection = routed ? connectionPrompts : [];
|
|
105
206
|
|
|
106
207
|
const requirements: SetupRequirement[] = [];
|
|
107
208
|
|
|
@@ -115,7 +216,7 @@ export function setupRequirements(
|
|
|
115
216
|
secret: prompt.secret,
|
|
116
217
|
scope: 'shared',
|
|
117
218
|
prompts: [prompt.key],
|
|
118
|
-
command: storeCommand(prompt.credential_ref, '<value>',
|
|
219
|
+
command: storeCommand(prompt.credential_ref, '<value>', where),
|
|
119
220
|
});
|
|
120
221
|
}
|
|
121
222
|
|
|
@@ -133,7 +234,7 @@ export function setupRequirements(
|
|
|
133
234
|
secret: perConnection.some((prompt) => prompt.secret),
|
|
134
235
|
scope: 'connection',
|
|
135
236
|
prompts: perConnection.map((prompt) => prompt.key),
|
|
136
|
-
command: storeCommand(ref, placeholderFor(perConnection),
|
|
237
|
+
command: storeCommand(ref, placeholderFor(perConnection), where),
|
|
137
238
|
});
|
|
138
239
|
}
|
|
139
240
|
}
|
|
@@ -142,5 +243,7 @@ export function setupRequirements(
|
|
|
142
243
|
requirements,
|
|
143
244
|
needsId: perConnection.length > 0 && connectionId === undefined,
|
|
144
245
|
brokered,
|
|
246
|
+
pastedCredential:
|
|
247
|
+
pasted.length > 0 ? pasted.map((prompt) => prompt.label).join(', then ') : undefined,
|
|
145
248
|
};
|
|
146
249
|
}
|
|
@@ -166,7 +166,16 @@ const BY_EXTENSION: Record<string, string> = {
|
|
|
166
166
|
'.yml': 'application/yaml',
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Exported so `github.ts` answers identically.
|
|
171
|
+
*
|
|
172
|
+
* That adapter has nowhere to *store* a content type — a sidecar there would be
|
|
173
|
+
* a file in the owner's own repository, listed beside their entries — so the
|
|
174
|
+
* extension is all it has. Sharing the map means a `.md` written locally and
|
|
175
|
+
* the same `.md` in a repository do not report different types, which is what
|
|
176
|
+
* `#stores/blobs/conformance.ts` exists to hold.
|
|
177
|
+
*/
|
|
178
|
+
export function inferContentType(path: string): string | undefined {
|
|
170
179
|
const dot = path.lastIndexOf('.');
|
|
171
180
|
return dot === -1 ? undefined : BY_EXTENSION[path.slice(dot).toLowerCase()];
|
|
172
181
|
}
|