@lanes-sh/link 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
package/src/cli/usage.ts CHANGED
@@ -24,11 +24,13 @@ ${style.bold('Everyday')}
24
24
  ${PROGRAM} connect <provider> add an account (run once per account)
25
25
  ${PROGRAM} connect <provider>.<id> re-authorise one existing account
26
26
  ${PROGRAM} connect <...> --replace ask for the stored password or key again
27
+ ${PROGRAM} connect <...> --auth <method> pick how, where there is a choice
27
28
  ${PROGRAM} connect <...> --non-interactive [--json]
28
29
  answer nothing from a terminal: take every value
29
30
  from the credential store, or say what is missing
30
31
  ${PROGRAM} start [--only] reconcile and serve every profile on one endpoint
31
32
  ${PROGRAM} outputs [--show] [--json] the endpoint an agent needs
33
+ ${PROGRAM} dashboard [--print] open the local endpoint's page in a browser
32
34
  ${PROGRAM} mcp add [claude|codex] register this endpoint, and install the agent skill
33
35
  ${PROGRAM} mcp add --no-skill register only, leaving the agent's own files alone
34
36
  ${PROGRAM} mcp list where it is registered, and whether the skill is current
@@ -37,16 +39,27 @@ ${style.bold('Everyday')}
37
39
  ${PROGRAM} status [--json] connections, reachable capabilities, endpoint
38
40
 
39
41
  ${style.bold('Profiles')}
40
- ${PROGRAM} profile add <name> [--default] [--json]
42
+ ${PROGRAM} profile add <name> --target <name> [--target <name>] [--json]
43
+ a target per place it runs; local is derived, the
44
+ rest are copied from a sibling profile
41
45
  ${PROGRAM} profile list [--json]
42
- ${PROGRAM} profile default <name>
43
46
  ${PROGRAM} profile remove <name> [--target t] [--dry-run] [--yes] [--json]
44
47
  the profile, its credentials, and its data
45
48
 
46
49
  ${style.bold('Targets')}
47
- ${PROGRAM} target list [--urls] where this profile can run, and which one is in play
48
- ${PROGRAM} target show [<name>] one target's adapters, and the address it answers on
49
- ${PROGRAM} target use <name> make one the profile's default_target
50
+ ${PROGRAM} target list [--urls] where this profile can run
51
+ ${PROGRAM} target show <name> one target's adapters, and the address it answers on
52
+ ${PROGRAM} sync targets --target t [--from gs://bucket] [--discover]
53
+ [--prefer local|remote] [--dry-run]
54
+ reconcile this workspace with the copy the
55
+ deployment reads; recovers a target a profile
56
+ has lost
57
+
58
+ ${style.bold('Who you are')}
59
+ ${PROGRAM} identity add <kind> <value> [--note text] [--json]
60
+ e.g. name, email, github — any kind you like
61
+ ${PROGRAM} identity list [--json]
62
+ ${PROGRAM} identity remove <kind> <value> [--json]
50
63
 
51
64
  ${style.bold('Permissions')}
52
65
  ${PROGRAM} policy list
@@ -66,6 +79,14 @@ ${style.bold('Your own context')}
66
79
  ${PROGRAM} skills add <name> [--file f] document on stdin
67
80
  ${PROGRAM} skills remove <name>
68
81
 
82
+ ${PROGRAM} knowledge show where memory and skills are kept, and how many
83
+ ${PROGRAM} knowledge use github --repo <owner/name> [--branch b] [--path p]
84
+ keep both in a private repository, over the GitHub API
85
+ [--migrate] moves what is already stored, in one commit
86
+ [--no-migrate] switches and leaves it where it is
87
+ [--keep] moves it, and leaves the local copies unread
88
+ ${PROGRAM} knowledge use local [--migrate] bring them back onto this target
89
+
69
90
  ${PROGRAM} vault list names only, never values
70
91
  ${PROGRAM} vault get <id> [--show|--raw]
71
92
  ${PROGRAM} vault set <id> [--description d] value on stdin
@@ -73,10 +94,13 @@ ${style.bold('Your own context')}
73
94
  ${PROGRAM} vault key generate a fresh LANES_LINK_VAULT_KEY, printed once
74
95
 
75
96
  ${style.bold('Deploying')}
76
- ${PROGRAM} deploy [--dry-run] set up, build, and roll a revision
97
+ ${PROGRAM} deploy --target t [--dry-run]
98
+ set up, build, and roll one revision serving
99
+ every profile that declares the target
100
+ ${PROGRAM} deploy --target t --profile a --profile b
101
+ only these; the first owns the endpoint token
77
102
  ${PROGRAM} deploy --non-interactive take the stored answers, never prompt
78
103
  ${PROGRAM} deploy --access iam|public who gets past the platform's own door
79
- ${PROGRAM} deploy --target <name> deploy a second one, under its own name
80
104
  ${PROGRAM} secrets list credential references in this target
81
105
  ${PROGRAM} secrets set <ref> store one value, read from stdin
82
106
  ${PROGRAM} secrets push --from local --to cloud
@@ -96,9 +120,13 @@ ${style.bold('Attachments')}
96
120
  ${PROGRAM} attach <file> --connection <provider>.<account>
97
121
  stage a file, print a handle to send it by
98
122
 
99
- ${style.bold('Global flags')}
100
- --profile <name> overrides LANES_LINK_PROFILE and the workspace default
101
- --target <name> overrides LANES_LINK_TARGET and instance.default_target
123
+ ${style.bold('Naming what a command acts on')}
124
+ --profile <name> required by every command that reads or writes a profile
125
+ --target <name> required by every command that opens a target's stores.
126
+ There is no default and no environment variable: a
127
+ command that names neither refuses and lists what exists.
128
+
129
+ ${style.bold('Other flags')}
102
130
  --connection <id> which memory/skills/vault connection, if a profile has several
103
131
  --yes skip the confirmation a destructive command would ask for
104
132
  --json machine-readable output, where a command offers it
@@ -107,7 +135,10 @@ ${style.bold('Global flags')}
107
135
  --accept-broad-scopes agree in advance to scopes broader than a provider needs
108
136
  --own-client register your own OAuth client instead of using the
109
137
  one this project operates (connect only)
138
+ --auth <method> which way in, where a provider offers two (connect
139
+ only). "oauth" is the browser; the other is named
140
+ in the choice connect prints
110
141
  --port <n> override the configured port (start only)
111
142
 
112
- Every command prints the resolved profile and target before acting.
143
+ Every command prints the profile and target it is acting on, before it acts.
113
144
  `;
@@ -14,6 +14,7 @@ transport asks when it has a token to send and no request to attach it to.
14
14
  | `api-key/` | `api_key` | a key, in a header or the query string |
15
15
  | `basic/` | `basic` | `username:password`, RFC 7617's own encoding |
16
16
  | `oauth-authcode/` | `oauth` | a refresh token, exchanged on every use |
17
+ | `oauth-jwt/` | `oauth` + `assertion` | a private key, signed into an assertion per exchange |
17
18
  | `strategy/` | `strategy` | the escape hatch — per-vendor code, none registered |
18
19
 
19
20
  ## Adding one
@@ -22,13 +23,18 @@ A folder, a member of `authSchema` in `../manifest/auth.ts`, and a case in
22
23
  `resolve.ts` (plus `authorize.ts` if it touches the request). Nothing else in
23
24
  the codebase learns about it — that is the point of the split.
24
25
 
26
+ `oauth-jwt/` is the exception that proves the shape rather than breaking it. It
27
+ is not a `kind`, because it is a second way into a provider that already has
28
+ one, so it hangs off the OAuth block as `auth.assertion` and is selected by the
29
+ shape of the stored credential. Everything else about it is an ordinary folder
30
+ here.
31
+
25
32
  These are named in the credential-type list this design is measured against and
26
33
  are **not built**:
27
34
 
28
35
  - `sigv4/` — AWS SigV4 request signing
29
36
  - `gcp-token/` — service account → GCP access token
30
37
  - `gcp-iap/` — service account → an IAP-signed JWT
31
- - `oauth-jwt/` — OAuth 2.0 JWT bearer (RFC 7523)
32
38
  - `oauth-client-creds/` — OAuth 2.0 client credentials
33
39
  - `body-param/` — the credential as a request body parameter
34
40
 
@@ -15,7 +15,7 @@ export function resolveBasic(value: string, ref: string, providerId: string): Re
15
15
  if (colon === -1) {
16
16
  throw new Error(
17
17
  `The credential at ${ref} is not a "username:password" pair, which basic auth requires. ` +
18
- `Re-run: lanes link connect ${providerId}`,
18
+ `Connect "${providerId}" again for this profile and target.`,
19
19
  );
20
20
  }
21
21
 
@@ -7,6 +7,10 @@
7
7
  * know the whole set: `resolve.ts` and `authorize.ts` for anything HTTP-shaped,
8
8
  * and `token.ts` for a transport that takes a bare token instead of a request.
9
9
  *
10
+ * `oauth-jwt/` is the one folder that is not a `kind`: it is a second way into
11
+ * a provider that already declares `oauth`, selected by the shape of what is
12
+ * stored rather than by the manifest. Its own README says why.
13
+ *
10
14
  * This is the axis the manifest's `auth:` block selects, and it is deliberately
11
15
  * independent of `../transports/` — which is why iCloud can speak IMAP with a
12
16
  * password while Gmail speaks HTTP with OAuth, and neither costs the other any
@@ -24,9 +28,19 @@ export {
24
28
  type OAuthProviderOptions,
25
29
  } from './oauth-authcode/provider.ts';
26
30
  export { resolveUpstreamToken } from './oauth-authcode/index.ts';
31
+ export {
32
+ ASSERTION_GRANT,
33
+ clearMintedTokens,
34
+ isStoredAssertion,
35
+ resolveAssertionToken,
36
+ storedAssertionFor,
37
+ type StoredAssertion,
38
+ } from './oauth-jwt/index.ts';
39
+ export { assertionKeySchema, parseAssertionKey, signAssertion, type AssertionKey } from './oauth-jwt/key.ts';
27
40
  export {
28
41
  BROKER_ORIGIN_ENV,
29
42
  BROKERED,
43
+ PASTED,
30
44
  BrokerError,
31
45
  brokerConfig,
32
46
  brokerExchange,
@@ -20,6 +20,17 @@
20
20
  */
21
21
  export const BROKERED = 'broker';
22
22
 
23
+ /**
24
+ * Stamped on a credential nobody here minted — the operator pasted it.
25
+ *
26
+ * The third answer, and the one that is not a client at all. It matters for the
27
+ * same reason as the other two and one more: a pasted token cannot be
28
+ * refreshed, cannot be attributed to a registration, and cannot be re-obtained
29
+ * by re-running a flow. `doctor` reads it to say so rather than offering a
30
+ * re-authorisation that would not apply.
31
+ */
32
+ export const PASTED = 'pasted';
33
+
23
34
  /** What the broker will authorise, and whether it is currently doing so. */
24
35
  export interface BrokerConfig {
25
36
  readonly clientId: string;
@@ -27,6 +38,20 @@ export interface BrokerConfig {
27
38
  readonly scopesSupported: readonly string[];
28
39
  /** Added to every request so the exchange returns an identity assertion. */
29
40
  readonly identityScopes: readonly string[];
41
+ /**
42
+ * Where the vendor sends the browser back, when it will not send it here.
43
+ *
44
+ * Absent for a vendor that accepts a loopback redirect, which is all of them
45
+ * but Slack: the listener names itself and the broker is only asked to
46
+ * redeem. Present where the vendor demands HTTPS — then the redirect lands on
47
+ * the broker's own origin and is bounced down to the listener, and this is
48
+ * the URL both legs of the flow have to agree on.
49
+ *
50
+ * Published rather than derived, because which URL is correct depends on
51
+ * which deployment answered `/config` — and a broker running on loopback for
52
+ * a test would otherwise need a flag of its own.
53
+ */
54
+ readonly redirectUri: string | undefined;
30
55
  readonly open: boolean;
31
56
  /** Why it is closed, or near capacity. The broker's words, printed verbatim. */
32
57
  readonly notice: string | undefined;
@@ -217,6 +242,7 @@ export async function brokerConfig(
217
242
  clientId,
218
243
  scopesSupported: strings(data['scopes_supported']),
219
244
  identityScopes: strings(data['identity_scopes']),
245
+ redirectUri: str(data['redirect_uri']),
220
246
  open: data['status'] !== 'closed',
221
247
  notice: str(data['notice']),
222
248
  docsUrl: str(data['docs_url']),
@@ -1,6 +1,7 @@
1
1
  import { auth } from '@modelcontextprotocol/client';
2
2
  import type { ProviderManifest } from '#connectivity';
3
3
  import type { SecretStore } from '#secrets';
4
+ import { resolveAssertionToken, storedAssertionFor } from '../oauth-jwt/index.ts';
4
5
  import { CredentialOAuthProvider, upstreamAccessToken } from './provider.ts';
5
6
  import { refreshDirectly } from './refresh.ts';
6
7
 
@@ -12,8 +13,11 @@ import { refreshDirectly } from './refresh.ts';
12
13
  * trip at connect time, a refresh on every use, and two different ways to run
13
14
  * that refresh depending on whether the provider has a metadata document.
14
15
  *
15
- * The other flows the credential-type list names — JWT bearer, client
16
- * credentials — are sibling folders that do not exist yet. See ../README.md.
16
+ * The other flows the credential-type list names — client credentials, SigV4 —
17
+ * are sibling folders that do not exist yet. See ../README.md. JWT bearer now
18
+ * does exist, in `../oauth-jwt/`, and is reached from here rather than from
19
+ * `resolve.ts`: a provider offering both declares one `auth.kind`, so the fork
20
+ * belongs at the point where the stored credential is first read.
17
21
  */
18
22
 
19
23
  export async function resolveUpstreamToken(
@@ -23,6 +27,16 @@ export async function resolveUpstreamToken(
23
27
  ): Promise<string | null> {
24
28
  if (manifest.auth.kind !== 'oauth') return null;
25
29
 
30
+ // Before anything is built, because the two arrangements share a ref and only
31
+ // what is stored there tells them apart. A `CredentialOAuthProvider` over an
32
+ // assertion pointer would find no `access_token`, conclude the connection was
33
+ // never authorised, and advise a browser flow the operator deliberately
34
+ // declined.
35
+ const assertion = await storedAssertionFor(manifest, connectionId, credentials);
36
+ if (assertion) {
37
+ return resolveAssertionToken({ manifest, connectionId, stored: assertion, credentials });
38
+ }
39
+
26
40
  const provider = new CredentialOAuthProvider({
27
41
  manifest,
28
42
  connectionId,
@@ -133,7 +133,7 @@ export class CredentialOAuthProvider {
133
133
  if (!this.#options.openBrowser) {
134
134
  throw new Error(
135
135
  `Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
136
- `which requires a browser. Run: lanes link connect ${this.#options.manifest.id}.${this.#options.connectionId}`,
136
+ `which requires a browser. Connect ${this.#options.manifest.id}.${this.#options.connectionId} again for this profile and target.`,
137
137
  );
138
138
  }
139
139
  this.#options.openBrowser(authorizationUrl);
@@ -24,7 +24,7 @@ export async function refreshDirectly(
24
24
 
25
25
  if (!refreshToken) {
26
26
  throw new Error(
27
- `No refresh token stored for ${manifest.id}. Run: lanes link connect ${manifest.id}`,
27
+ `No refresh token stored for ${manifest.id}. Connecting it again for this profile and target would store one.`,
28
28
  );
29
29
  }
30
30
 
@@ -72,7 +72,7 @@ async function viaBroker(
72
72
  const notice = cause instanceof BrokerError && cause.notice ? `\n${cause.notice}` : '';
73
73
  throw new Error(
74
74
  `The credential for ${manifest.id} could not be refreshed. ` +
75
- `Re-authorise with: lanes link connect ${manifest.id}\n${String(
75
+ `Re-authorise ${manifest.id} for this profile and target.\n${String(
76
76
  cause instanceof Error ? cause.message : cause,
77
77
  ).slice(0, 200)}${notice}`,
78
78
  );
@@ -110,7 +110,7 @@ async function viaStoredClient(
110
110
  // is always the same, so say it rather than surfacing the raw grant error.
111
111
  throw new Error(
112
112
  `The credential for ${manifest.id} could not be refreshed (${response.status}). ` +
113
- `Re-authorise with: lanes link connect ${manifest.id}\n${text.slice(0, 200)}`,
113
+ `Re-authorise ${manifest.id} for this profile and target.\n${text.slice(0, 200)}`,
114
114
  );
115
115
  }
116
116
 
@@ -0,0 +1,33 @@
1
+ # OAuth 2.0 JWT bearer (RFC 7523)
2
+
3
+ A key the operator holds, signed into a short-lived assertion and exchanged for
4
+ an access token. No browser, and **no refresh token** — there is nothing to
5
+ refresh, because a new assertion is signed whenever the last token ages out.
6
+ That is the point: an authorization-code refresh token lives or dies by the
7
+ issuer's policy, and a key does not.
8
+
9
+ Not a `kind` of its own. It is declared as `auth.assertion` on an existing
10
+ `oauth` block, because it is a second arrangement for the same provider rather
11
+ than a different provider — so `credentialRefForConnection`, `setupRequirements`
12
+ and the deploy grants all stay as they were. Which arrangement a connection uses
13
+ is decided by the *shape* of what is stored at `<provider>/<connection>`:
14
+ `isStoredAssertion` is that test, and `resolve.ts` asks it before building an
15
+ authorization-code provider.
16
+
17
+ | File | What it owns |
18
+ |---|---|
19
+ | `key.ts` | the key file's layout, PEM to DER, and the signed claim set — no I/O |
20
+ | `index.ts` | reading the pointer, the exchange, the process-lifetime token cache |
21
+
22
+ The endpoint comes from `token_uri` **inside the key file**, never from a
23
+ constant here, which is what keeps this folder free of any vendor. A second
24
+ vendor offering the same grant is a manifest and no code.
25
+
26
+ ## What it cannot do
27
+
28
+ An assertion authenticates the key, and a key is not a person. It reaches only
29
+ what has been shared with its address — unless the identity provider is
30
+ configured to let it act as someone, which is an administrator's grant and not
31
+ the operator's. `auth.assertion.delegation` says which of the two a provider is,
32
+ and `cli/commands/connect/method.ts` is where that becomes a sentence someone
33
+ reads before choosing.
@@ -0,0 +1,237 @@
1
+ import type { ProviderManifest } from '#connectivity';
2
+ import type { SecretStore } from '#secrets';
3
+ import { credentialRefForConnection } from '../../manifest/credential-ref.ts';
4
+ import { parseAssertionKey, signAssertion } from './key.ts';
5
+
6
+ /**
7
+ * OAuth 2.0 JWT bearer (RFC 7523) — a key the operator holds, in place of a
8
+ * person approving a consent screen.
9
+ *
10
+ * The one property that earns this its own folder: there is no refresh token,
11
+ * because there is nothing to refresh. A fresh assertion is signed whenever the
12
+ * last access token ages out, so nothing an issuer can expire sits between the
13
+ * operator and their data. An authorization-code refresh token is subject to
14
+ * whatever policy the issuer applies to it — one such policy expires them after
15
+ * seven days, and re-approving a browser screen every week is the failure this
16
+ * folder exists to remove.
17
+ *
18
+ * What it costs is reach. An assertion authenticates the *key*, and a key is
19
+ * not a person: it holds only what has been shared with it, unless the identity
20
+ * provider has been configured to let it act as someone, which is an
21
+ * administrator's grant rather than the operator's. `auth.assertion.delegation`
22
+ * on the manifest is which of the two a provider is, and the CLI is where that
23
+ * becomes a sentence.
24
+ */
25
+
26
+ /** RFC 7523's grant type, and the marker that identifies a stored credential as one. */
27
+ export const ASSERTION_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
28
+
29
+ /**
30
+ * What a connection stores when it authenticates this way.
31
+ *
32
+ * A pointer and not the key itself. One key covers every provider of a vendor,
33
+ * so it lives at a profile-shared ref and each connection records where to find
34
+ * it plus the one thing that genuinely differs per connection — who it acts as.
35
+ * Copying the key into seven connections would mean seven things to rotate.
36
+ */
37
+ export interface StoredAssertion {
38
+ readonly grant: typeof ASSERTION_GRANT;
39
+ readonly key_ref: string;
40
+ readonly subject?: string;
41
+ }
42
+
43
+ /**
44
+ * Whether a stored credential is one of these.
45
+ *
46
+ * Both methods write to the same ref — `<provider>/<connection>` — and this is
47
+ * what tells them apart. Shape rather than a flag in config, because
48
+ * `credentialResolver` is handed a registry and a store and never a connection
49
+ * row, so a declaration in config would be invisible exactly where the decision
50
+ * has to be made.
51
+ */
52
+ export function isStoredAssertion(value: unknown): value is StoredAssertion {
53
+ return (
54
+ typeof value === 'object' &&
55
+ value !== null &&
56
+ (value as { grant?: unknown }).grant === ASSERTION_GRANT &&
57
+ typeof (value as { key_ref?: unknown }).key_ref === 'string'
58
+ );
59
+ }
60
+
61
+ /**
62
+ * The stored credential for this connection, if it is an assertion pointer.
63
+ *
64
+ * `null` covers both "nothing stored" and "stored, but an authorization-code
65
+ * blob" — the caller wants the same thing in either case, which is to carry on
66
+ * down the path it was already on. Asked through `credentialRefForConnection`
67
+ * rather than by assembling the ref here, because two files deriving that
68
+ * separately is exactly the disagreement that function was extracted to end.
69
+ */
70
+ export async function storedAssertionFor(
71
+ manifest: ProviderManifest,
72
+ connectionId: string,
73
+ credentials: SecretStore,
74
+ ): Promise<StoredAssertion | null> {
75
+ const ref = credentialRefForConnection(manifest, connectionId);
76
+ if (!ref) return null;
77
+
78
+ const raw = await credentials.get(ref);
79
+ if (!raw) return null;
80
+
81
+ try {
82
+ const parsed: unknown = JSON.parse(raw);
83
+ return isStoredAssertion(parsed) ? parsed : null;
84
+ } catch {
85
+ // A credential that is not JSON at all is a pasted token, which is somebody
86
+ // else's case entirely. Not an error here.
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Minted tokens, for as long as this process lives.
93
+ *
94
+ * In memory rather than in the store, and that is a deliberate difference from
95
+ * the authorization-code path. There, the refresh token is the credential and
96
+ * persisting the rotation is the whole point. Here the credential is the key,
97
+ * which nothing at request time modifies — so writing the token back would make
98
+ * this ref rotatable, which a deployed revision would then need write access to
99
+ * bind, to cache something that costs one signature and one POST to remake.
100
+ */
101
+ const minted = new Map<string, { token: string; expiresAt: number }>();
102
+
103
+ /** Re-mint slightly early: a token that expires mid-flight fails the call it was fetched for. */
104
+ const EXPIRY_SKEW_MS = 60_000;
105
+
106
+ /**
107
+ * Emptied when a reload lands, and by tests.
108
+ *
109
+ * The cache key is `<provider>.<connection>` with no subject in it, so a
110
+ * connection re-connected to act as somebody else — or re-connected to a route
111
+ * that is not this one at all — would otherwise keep serving the token minted
112
+ * for who it used to be, for up to an hour after the config said otherwise.
113
+ * `server/generations.ts` clears this beside `clearUpstreamTokens`, which
114
+ * exists for the same reason on the other path.
115
+ */
116
+ export function clearMintedTokens(): void {
117
+ minted.clear();
118
+ }
119
+
120
+ interface TokenResponse {
121
+ readonly access_token?: string;
122
+ readonly expires_in?: number;
123
+ readonly error?: string;
124
+ readonly error_description?: string;
125
+ }
126
+
127
+ /**
128
+ * An access token for a connection that authenticates with a key.
129
+ *
130
+ * Reads the pointer, reads the key it names, signs, exchanges, caches. The
131
+ * manifest supplies the scopes and nothing else — where to exchange comes from
132
+ * the key file, so this stays a protocol implementation rather than a vendor's.
133
+ */
134
+ export async function resolveAssertionToken(input: {
135
+ readonly manifest: ProviderManifest;
136
+ readonly connectionId: string;
137
+ readonly stored: StoredAssertion;
138
+ readonly credentials: SecretStore;
139
+ readonly fetch?: typeof globalThis.fetch;
140
+ }): Promise<string> {
141
+ const { manifest, connectionId, stored, credentials } = input;
142
+ const cacheKey = `${manifest.id}.${connectionId}`;
143
+
144
+ const cached = minted.get(cacheKey);
145
+ if (cached && cached.expiresAt > Date.now() + EXPIRY_SKEW_MS) return cached.token;
146
+
147
+ const raw = await credentials.get(stored.key_ref);
148
+ if (!raw) {
149
+ throw new Error(
150
+ `No key stored at ${stored.key_ref}, which ${manifest.id}.${connectionId} authenticates with. ` +
151
+ `Run: lanes link connect ${manifest.id} --replace`,
152
+ );
153
+ }
154
+
155
+ const key = parseAssertionKey(raw, stored.key_ref);
156
+ const scopes = manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [];
157
+
158
+ const assertion = await signAssertion({
159
+ key,
160
+ scopes,
161
+ ...(stored.subject ? { subject: stored.subject } : {}),
162
+ });
163
+
164
+ const response = await (input.fetch ?? globalThis.fetch)(key.token_uri, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
167
+ body: new URLSearchParams({ grant_type: ASSERTION_GRANT, assertion }),
168
+ });
169
+
170
+ const body = (await response.json().catch(() => ({}))) as TokenResponse;
171
+
172
+ if (!response.ok || !body.access_token) {
173
+ throw new Error(refusalMessage(manifest, stored, body, response.status));
174
+ }
175
+
176
+ minted.set(cacheKey, {
177
+ token: body.access_token,
178
+ expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
179
+ });
180
+
181
+ return body.access_token;
182
+ }
183
+
184
+ /**
185
+ * Why the exchange was refused, in terms of what the operator can act on.
186
+ *
187
+ * Three of these are the whole population in practice and each has a different
188
+ * fix in a different console, so the raw `invalid_grant` is worth translating.
189
+ * An operator who reads only the error code goes looking in the wrong place —
190
+ * most often at the key, when the actual gap is a grant an administrator has
191
+ * not made yet.
192
+ */
193
+ function refusalMessage(
194
+ manifest: ProviderManifest,
195
+ stored: StoredAssertion,
196
+ body: TokenResponse,
197
+ status: number,
198
+ ): string {
199
+ const detail = body.error_description ?? body.error ?? `HTTP ${status}`;
200
+ const scopes = manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [];
201
+
202
+ const lines = [`${manifest.name} refused the key at ${stored.key_ref}: ${detail}`];
203
+
204
+ if (body.error === 'unauthorized_client') {
205
+ lines.push(
206
+ '',
207
+ stored.subject
208
+ ? ` The key is not permitted to act as ${stored.subject}. An administrator of that` +
209
+ '\n domain has to authorise this key for these scopes, all of them, exactly:'
210
+ : ' The key is not authorised for these scopes:',
211
+ ...scopes.map((scope) => ` ${scope}`),
212
+ '',
213
+ ' A partial list is refused the same way a missing one is.',
214
+ );
215
+ } else if (body.error === 'invalid_grant') {
216
+ // Listed rather than diagnosed. This one code covers an account that does
217
+ // not exist, a key that was deleted, a clock that is wrong, and a missing
218
+ // subject — and the description above is the only thing that distinguishes
219
+ // them. Asserting one of the four would send the reader to the wrong
220
+ // console three times in four, which is worse than naming all of them.
221
+ lines.push(
222
+ '',
223
+ ' The description above is the part that identifies which of these it is:',
224
+ ' - the account in the key no longer exists, or the key was deleted or disabled;',
225
+ " - this machine's clock is wrong by more than a few minutes, and an assertion is",
226
+ ' signed with a timestamp;',
227
+ ...(stored.subject
228
+ ? [` - ${stored.subject} is not an account the key may act as.`]
229
+ : [
230
+ ' - this account has to be reached by acting as someone, and this connection acts',
231
+ ` as nobody. Re-run and name one: lanes link connect ${manifest.id} --replace`,
232
+ ]),
233
+ );
234
+ }
235
+
236
+ return lines.join('\n');
237
+ }