@lanes-sh/link 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -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/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { identifier } from './primitives.ts';
3
+
4
+ /**
5
+ * Who the owner is, in this profile — the names, addresses, and handles they
6
+ * want used when something is written as them.
7
+ *
8
+ * A profile already says what is *reachable*: connections, credentials, what
9
+ * policy permits. It said nothing about *whose* they are. So an agent composing
10
+ * a message, opening a pull request, or signing off had to infer a name from
11
+ * whatever happened to be in the conversation, and it mixed them across
12
+ * profiles — the work signature on a personal mailbox, the wrong handle in a
13
+ * commit trailer. The information was knowable and simply not written down.
14
+ *
15
+ * Three shapes were possible and only this one is right:
16
+ *
17
+ * - **A list, not a map.** `names: [John, Semin]` cannot say *when* to use
18
+ * which, and the note is the whole point — an owner with two names has them
19
+ * for a reason. A flat list of `{kind, value, note}` mirrors `connections`,
20
+ * which is the other list in this file whose order a reader relies on.
21
+ * - **`kind` is free-form**, any `identifier`. Shipping an enum would mean a
22
+ * release every time someone wants `linkedin`, `pronouns`, or `signature`,
23
+ * and there is nothing this file could do with the knowledge that a value is
24
+ * an email that would be worth that. Rendering is generic and stays generic.
25
+ * - **`note` is prose, not a reference.** Binding an entry to a connection was
26
+ * the obvious alternative and is a trap: it puts a cross-reference into
27
+ * `assertReferentialIntegrity`, and then renaming a connection breaks config
28
+ * *load* — the profile stops opening because a name in a signature moved.
29
+ * The agent reading "use with the personal mailbox" gets it right without
30
+ * costing anything the day that mailbox is renamed.
31
+ *
32
+ * Declaration order is meaningful and preserved: the first entry of a kind is
33
+ * the one to reach for absent a reason, and the reader of the YAML sees the same
34
+ * order the agent is told.
35
+ *
36
+ * Not a secret, and worth being explicit about why, because this is the first
37
+ * block in a profile that holds the owner's own data rather than a pointer to
38
+ * it. A name and an address are disclosed by the first message of any mailbox
39
+ * this endpoint serves; withholding them here while serving the mailbox would
40
+ * be theatre. What is refused is a *credential* pasted into a value — a token
41
+ * beginning `ghp_` in a `github` entry trips `secret-detection.ts` like anything
42
+ * else in this file, which is that check working rather than getting in the way.
43
+ */
44
+ export const identityEntrySchema = z.object({
45
+ /** `name`, `email`, `github` — or anything else the owner finds useful. */
46
+ kind: identifier,
47
+ value: z.string().min(1),
48
+ /**
49
+ * When this one applies, in the owner's words.
50
+ *
51
+ * Optional, and usually absent on the only entry of its kind: a profile with
52
+ * one address needs no note explaining which address to use. It earns its
53
+ * place the moment there are two.
54
+ */
55
+ note: z.string().min(1).optional(),
56
+ });
57
+
58
+ export const identitySchema = z.array(identityEntrySchema);
59
+
60
+ export type IdentityEntry = z.infer<typeof identityEntrySchema>;
@@ -19,11 +19,21 @@ export {
19
19
  type Config,
20
20
  type ConnectionConfig,
21
21
  type DeployConfig,
22
+ type IdentityEntry,
22
23
  type PolicyRuleConfig,
23
24
  type TargetConfig,
24
25
  type WorkspaceConfig,
25
26
  } from './schema.ts';
26
27
 
28
+ export {
29
+ KNOWLEDGE_LAYOUT,
30
+ knowledgeRoot,
31
+ knowledgeTargetSchema,
32
+ parseRepository,
33
+ type KnowledgeArea,
34
+ type KnowledgeConfig,
35
+ } from './knowledge.ts';
36
+
27
37
  export {
28
38
  ConfigError,
29
39
  loadConfigFile,
@@ -41,10 +51,10 @@ export {
41
51
 
42
52
  export {
43
53
  WORKSPACE_FILE,
44
- describeSelection,
45
54
  installRoot,
46
55
  listProfiles,
47
56
  loadProfileConfig,
57
+ noProfileNamed,
48
58
  profilePath,
49
59
  readWorkspace,
50
60
  resolveSelection,
@@ -52,10 +62,9 @@ export {
52
62
  workspacePath,
53
63
  } from './workspace.ts';
54
64
  export {
55
- TARGET_ENV,
56
- askedTarget,
57
- resolveDeployTarget,
58
- resolveTarget,
65
+ LEGACY_TARGET_ENV,
66
+ noTargetNamed,
67
+ requireTarget,
59
68
  undeclaredTarget,
60
69
  } from './targets.ts';
61
70
  export {
@@ -65,6 +74,7 @@ export {
65
74
  writeWorkspaceFile,
66
75
  } from './files.ts';
67
76
  export {
77
+ type ProfileSelection,
68
78
  type Resolution,
69
79
  type ResolveOptions,
70
80
  } from './workspace.ts';
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+ import { credentialRef } from './primitives.ts';
3
+
4
+ /**
5
+ * Where a profile's memory and skills live, when that is not where everything
6
+ * else lives.
7
+ *
8
+ * A target's `storage:` block names one backend for everything a profile holds
9
+ * — runtime state, the audit log, memory entries, skills, cached attachments.
10
+ * That is right for most of it and wrong for two: state and the log are
11
+ * artefacts of one installation, and memory entries and skills are documents
12
+ * the owner wrote. Documents want history, review, and to be readable from more
13
+ * than one machine, and the backend that suits an object nobody reads is not
14
+ * the backend that suits those.
15
+ *
16
+ * So this block moves exactly those two, and it is the whole of what it can
17
+ * move. There is no `credentials` or `vault` value here and there will not be:
18
+ * a repository is a place to publish, and those two hold the material whose
19
+ * entire value is that it is not published (`docs/detailed/security.md`). The
20
+ * exclusion is structural — a field that does not exist cannot be set by
21
+ * accident, by a flag, or by an operator following an example.
22
+ *
23
+ * `adapter` is an enum of one, the same shape `vaultTargetSchema` uses, so a
24
+ * second host is a case rather than a schema rewrite.
25
+ *
26
+ * Absent means what has always happened, which is why every field that could
27
+ * carry a default does. A profile written before this block existed loads
28
+ * unchanged and stores its memory and skills exactly where it did.
29
+ *
30
+ * ADR-041.
31
+ */
32
+
33
+ /**
34
+ * `owner/name`, as GitHub spells it everywhere a person types one.
35
+ *
36
+ * A URL is deliberately not accepted. The browser URL, the SSH remote, and this
37
+ * are three spellings of one value, and a schema that takes all three has to
38
+ * normalise them somewhere — at which point the file no longer says what the
39
+ * operator wrote. The CLI accepts a pasted URL and reduces it to this before
40
+ * writing, which puts the leniency where a person is typing rather than in the
41
+ * contract.
42
+ */
43
+ const repository = z
44
+ .string()
45
+ .regex(
46
+ /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/,
47
+ 'must be "owner/name" — not a URL, and not a local path',
48
+ );
49
+
50
+ /**
51
+ * A directory inside the repository, for one that holds other things too.
52
+ *
53
+ * Normalised to no leading or trailing slash so `knowledge`, `/knowledge` and
54
+ * `knowledge/` are one prefix rather than three key spaces that look identical
55
+ * in a diff and address different objects.
56
+ */
57
+ const repositoryPath = z
58
+ .string()
59
+ .refine((value) => !value.split('/').includes('..'), {
60
+ message: 'must not contain ".." — it is a prefix inside the repository, not a path to walk',
61
+ })
62
+ .transform((value) => value.replace(/^\/+/, '').replace(/\/+$/, ''));
63
+
64
+ export const knowledgeTargetSchema = z.object({
65
+ adapter: z.literal('github'),
66
+ repo: repository,
67
+ /**
68
+ * Defaults at open time rather than here, to the repository's own default
69
+ * branch. Writing `main` into every generated config would be a guess, and a
70
+ * wrong one for anybody whose default is still `master` — a guess that then
71
+ * fails as "branch not found" rather than as "you did not say".
72
+ */
73
+ branch: z.string().min(1).optional(),
74
+ path: repositoryPath.optional(),
75
+ /**
76
+ * A reference, never the token.
77
+ *
78
+ * Its own ref rather than a reuse of the `github` provider connection's,
79
+ * which holds a token for GitHub's MCP server. That one needs Contents:
80
+ * *read*; this one needs Contents: *write*, has a different lifetime, and
81
+ * should be revocable on its own — revoking an MCP connection must not
82
+ * silently empty somebody's memory.
83
+ */
84
+ token_ref: credentialRef.default('knowledge/token'),
85
+ });
86
+
87
+ export type KnowledgeConfig = z.infer<typeof knowledgeTargetSchema>;
88
+
89
+ /**
90
+ * Where each area sits inside the repository.
91
+ *
92
+ * Two directories, named after the two things that move. `memory` is also the
93
+ * memory provider's own blob namespace — the prefix core scopes it into under
94
+ * the profile's blob root — which is why the same word does both jobs: the
95
+ * route that redirects it and the directory it lands in are the same fact, and
96
+ * spelling them separately is how they would come to disagree.
97
+ *
98
+ * Declared here, beside the schema, so the runtime that opens these stores and
99
+ * the command that migrates into them read one definition.
100
+ */
101
+ export const KNOWLEDGE_LAYOUT = {
102
+ memory: 'memory',
103
+ skills: 'skills',
104
+ } as const;
105
+
106
+ export type KnowledgeArea = keyof typeof KNOWLEDGE_LAYOUT;
107
+
108
+ /** The directory one area occupies, under the profile's optional path prefix. */
109
+ export function knowledgeRoot(knowledge: KnowledgeConfig, area: KnowledgeArea): string {
110
+ const directory = KNOWLEDGE_LAYOUT[area];
111
+ return knowledge.path ? `${knowledge.path}/${directory}` : directory;
112
+ }
113
+
114
+ /** `owner/name`, out of whatever a person pasted. Null when it is not one. */
115
+ export function parseRepository(given: string): string | null {
116
+ const trimmed = given
117
+ .trim()
118
+ .replace(/^https?:\/\/(?:www\.)?github\.com\//i, '')
119
+ .replace(/^(?:ssh:\/\/)?git@[^:]+[:/]/i, '')
120
+ .replace(/\.git$/i, '')
121
+ .replace(/\/+$/, '');
122
+
123
+ return repository.safeParse(trimmed).success ? trimmed : null;
124
+ }
@@ -114,11 +114,9 @@ function assertReferentialIntegrity(config: Config, source: string): void {
114
114
  if (targetNames.size === 0) {
115
115
  problems.push('targets: at least one target must be declared');
116
116
  }
117
- if (!targetNames.has(config.instance.default_target)) {
118
- problems.push(
119
- `instance.default_target: "${config.instance.default_target}" is not a declared target (have: ${[...targetNames].join(', ') || 'none'})`,
120
- );
121
- }
117
+ // `instance.default_target` is deliberately not checked. Nothing reads it
118
+ // (ADR-037), so validating it would be validating a comment — and failing
119
+ // `check` on a stale value would teach that the key still matters.
122
120
 
123
121
  // Only what holds for every platform. What one platform needs and the next
124
122
  // has no concept of — a GCP project, an AWS role ARN — is refused by the
@@ -146,6 +144,20 @@ function assertReferentialIntegrity(config: Config, source: string): void {
146
144
  connectionKeys.add(key);
147
145
  });
148
146
 
147
+ // Same reason as a duplicate connection: two entries with the same kind and
148
+ // value cannot both be meant, and the one that loses is invisible. It matters
149
+ // more here than it looks, because the two would usually differ only in their
150
+ // `note` — so the discarded one is precisely the guidance someone wrote down
151
+ // to stop an agent picking wrong.
152
+ const identityKeys = new Set<string>();
153
+ config.identity.forEach((entry, index) => {
154
+ const key = `${entry.kind}=${entry.value}`;
155
+ if (identityKeys.has(key)) {
156
+ problems.push(`identity[${index}]: duplicate entry "${entry.kind}: ${entry.value}"`);
157
+ }
158
+ identityKeys.add(key);
159
+ });
160
+
149
161
  const checkRules = (rules: readonly PolicyRuleConfig[], field: 'allow' | 'deny'): void => {
150
162
  rules.forEach((rule, index) => {
151
163
  const where = `policy.${field}[${index}]`;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  /**
4
- * The three string shapes the config contract is built out of.
4
+ * The four string shapes the config contract is built out of.
5
5
  *
6
6
  * Their own file because more than one schema module needs them, and the
7
7
  * alternative was either a circular import or a second copy of a regex that
@@ -43,3 +43,26 @@ export const capabilityPattern = z
43
43
  /^(?:\*|[a-z][a-z0-9_]*\.(?:\*|[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)*(?:\.\*)?))$/,
44
44
  'must be "*", "gmail.*", "gmail.users.*", or "gmail.users.drafts.send"',
45
45
  );
46
+
47
+ /**
48
+ * A browser origin, exactly — scheme, host, and port, with nothing after it. Or
49
+ * `*`, which is what an absent list means anyway and is accepted so that saying
50
+ * it explicitly is not an error.
51
+ *
52
+ * Checked by round trip rather than by a regex, because `URL` already knows what
53
+ * an origin is and the failure this catches is a trailing slash or a path that
54
+ * looks harmless and matches nothing: a browser sends `Origin: https://x.example`
55
+ * and a configured `https://x.example/` would compare unequal, silently refusing
56
+ * the origin its owner believed they had allowed.
57
+ */
58
+ export const browserOrigin = z.string().refine(
59
+ (value) => {
60
+ if (value === '*') return true;
61
+ try {
62
+ return new URL(value).origin === value;
63
+ } catch {
64
+ return false;
65
+ }
66
+ },
67
+ 'must be "*" or an origin with no trailing slash or path, e.g. "https://chat.example"',
68
+ );
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { capabilityPattern, credentialRef, identifier } from './primitives.ts';
2
+ import { browserOrigin, capabilityPattern, credentialRef, identifier } from './primitives.ts';
3
3
  import { authorizationSchema } from './authorization.ts';
4
+ import { identitySchema } from './identity.ts';
5
+ import { knowledgeTargetSchema } from './knowledge.ts';
4
6
 
5
7
  /**
6
8
  * Configuration is declarative desired state. This file is the source of truth
@@ -178,6 +180,20 @@ export const deployTargetSchema = z.object({
178
180
  billing_account: z.string().optional(),
179
181
  /** The identity the running revision assumes. Needs read access to the credential store. */
180
182
  service_account: z.string().optional(),
183
+ /**
184
+ * Instances kept running when nothing is calling.
185
+ *
186
+ * Zero is the default and the right answer for almost everything here: a cold
187
+ * start on the MCP path measures under three seconds, and the platform queues
188
+ * the request behind it, so scaling to zero is invisible to a caller.
189
+ *
190
+ * It is a knob because one path is not a caller. A client refreshes its token
191
+ * exactly when it wakes after an idle gap — which is exactly when the instance
192
+ * is cold — and a refresh that fails at the network level sends a remote
193
+ * client through a fresh browser authorization rather than surfacing an error.
194
+ * Raise it if a re-authorization ever lines up with a cold `/token`.
195
+ */
196
+ min_instances: z.number().int().min(0).max(10).default(0),
181
197
  });
182
198
 
183
199
  /**
@@ -200,6 +216,15 @@ export const targetSchema = z
200
216
  audit: auditTargetSchema.optional(),
201
217
  storage: storageTargetSchema,
202
218
  vault: vaultTargetSchema.optional(),
219
+ /**
220
+ * Memory and skills, somewhere other than `storage` above.
221
+ *
222
+ * Optional and absent by default, so every profile written before it keeps
223
+ * storing both exactly where it did. See `knowledge.ts` for why these two
224
+ * are separable from the rest and why the credential store and the vault
225
+ * are not.
226
+ */
227
+ knowledge: knowledgeTargetSchema.optional(),
203
228
  deploy: deployTargetSchema.optional(),
204
229
  /** @deprecated Write `deploy` with `platform: cloudrun`. */
205
230
  cloudrun: legacyCloudRunSchema.optional(),
@@ -221,7 +246,18 @@ export const targetSchema = z
221
246
  .transform(({ cloudrun, ...target }) =>
222
247
  target.deploy || !cloudrun
223
248
  ? target
224
- : { ...target, deploy: { ...cloudrun, platform: 'cloudrun' as const, access: 'iam' as const } },
249
+ : {
250
+ ...target,
251
+ // The pre-`deploy` spelling predates both of these, so it gets the
252
+ // same defaults the current one would: the closed door, and no
253
+ // instance kept warm.
254
+ deploy: {
255
+ ...cloudrun,
256
+ platform: 'cloudrun' as const,
257
+ access: 'iam' as const,
258
+ min_instances: 0,
259
+ },
260
+ },
225
261
  );
226
262
 
227
263
  /**
@@ -288,7 +324,22 @@ export const configSchema = z.object({
288
324
 
289
325
  instance: z.object({
290
326
  profile: identifier,
291
- default_target: z.string().min(1),
327
+ /**
328
+ * @deprecated Parsed, never read. See ADR-037.
329
+ *
330
+ * Every command names its target on the command line now, so nothing
331
+ * consults this. It stays *declared* rather than being dropped, and that is
332
+ * the whole point: an undeclared key is stripped silently by the schema,
333
+ * which would leave `check` and `doctor` with nothing to report and an
334
+ * operator staring at a line they reasonably believe still selects
335
+ * something. Declaring it is what lets them be told it is inert.
336
+ *
337
+ * Optional, so a profile written today needs no such line, and unvalidated,
338
+ * so a stale value naming a target that no longer exists is harmless rather
339
+ * than a failure on a key nothing reads. The `database:` note above records
340
+ * the same decision for the same reason.
341
+ */
342
+ default_target: z.string().min(1).optional(),
292
343
  port: z.number().int().min(1).max(65535).default(7337),
293
344
  /**
294
345
  * Loopback by default. Binding elsewhere is possible but the server
@@ -309,6 +360,15 @@ export const configSchema = z.object({
309
360
  * get one. Omitting it leaves every existing profile behaving identically.
310
361
  */
311
362
  authorization: authorizationSchema.optional(),
363
+ /**
364
+ * Browser origins that may call a *deployment's* MCP endpoint. Absent
365
+ * means `*`, so naming any is a narrowing and nothing needs setting.
366
+ *
367
+ * A deployment only, and this cannot widen that: a loopback endpoint
368
+ * refuses every cross-origin request and must keep doing so. Why the
369
+ * default is a wildcard is `src/server/cors.ts` and ADR-040.
370
+ */
371
+ allowed_origins: z.array(browserOrigin).optional(),
312
372
  })
313
373
  .default({ mode: 'bearer', token_ref: 'profile/token' }),
314
374
 
@@ -328,6 +388,13 @@ export const configSchema = z.object({
328
388
  */
329
389
  connections: z.array(connectionSchema).default([]),
330
390
  policy: policySchema.default({ allow: [], deny: [] }),
391
+
392
+ /**
393
+ * Who the owner is, for anything written as them. Optional and additive, so
394
+ * every profile written before it keeps loading unchanged — the same reasoning
395
+ * as `auth.authorization` above, and the reason `contract` does not move.
396
+ */
397
+ identity: identitySchema.default([]),
331
398
  });
332
399
 
333
400
  export type Config = z.infer<typeof configSchema>;
@@ -336,7 +403,8 @@ export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
336
403
  export type TargetConfig = z.infer<typeof targetSchema>;
337
404
  export type DeployConfig = z.infer<typeof deployTargetSchema>;
338
405
  export type AuthorizationConfig = z.infer<typeof authorizationSchema>;
339
- export { authorizationSchema };
406
+ export { authorizationSchema, identitySchema };
407
+ export type { IdentityEntry } from './identity.ts';
340
408
 
341
409
  /** The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory. */
342
410
  export const workspaceSchema = z.object({
@@ -6,147 +6,107 @@ import type { Config } from './schema.ts';
6
6
  *
7
7
  * A target names an adapter set — where credentials are kept and where bytes
8
8
  * go — and choosing one is a different subject from finding the workspace and
9
- * the profile, which is why it is a different file. `workspace.ts` reached the
10
- * size budget holding both, and the budget's job is to say which of the two
11
- * things a file is doing should leave.
9
+ * the profile, which is why it is a different file.
12
10
  *
13
- * The order is `--target`, then `LANES_LINK_TARGET`, then
14
- * `instance.default_target`. `deploy` resolves it differently and deliberately;
15
- * see `resolveDeployTarget`.
11
+ * **`--target`, or the command does not run** (ADR-037). There is no fallback:
12
+ * not `LANES_LINK_TARGET`, not `instance.default_target`. Both are still
13
+ * parsed, so no existing config file has to change, and neither is read.
14
+ *
15
+ * The chain this replaces resolved `--target`, then the variable, then the key,
16
+ * and printed which of the three it landed on. What that bought was one flag
17
+ * saved per command. What it cost was that an *ignored* flag still produced a
18
+ * working command — `profile add --target cloud` dropped the flag on the floor
19
+ * and the next command carried on from a different source, so the mistake
20
+ * surfaced one command later with nothing connecting it to its cause. A
21
+ * resolver with nowhere to fall back to cannot fail that way.
16
22
  */
17
23
 
18
- /** The variable that names a target for every command in a shell. */
19
- export const TARGET_ENV = 'LANES_LINK_TARGET';
20
-
21
24
  /**
22
- * The target somebody asked for, before the config gets a say.
23
- *
24
- * Split out because two callers need this precedence and must not disagree
25
- * about it: `resolveSelection` records a provisional answer for `announce`
26
- * before any config is loaded, and `resolveTarget` settles it afterwards. While
27
- * the flag was the only source, both could spell it `targetFlag ?? …` and stay
28
- * accidentally correct. With a second source, one of them learning about it and
29
- * the other not is a line reading `config-default` beside a target the
30
- * environment chose — which is the one line that exists to prevent exactly that.
25
+ * The target this command named, checked against what the profile declares.
31
26
  *
32
- * Returns no target rather than a default: what an unanswered question falls
33
- * back to is `instance.default_target`, and that is the config's to supply.
27
+ * `allowUndeclared` is for the one command whose job is to create the target it
28
+ * was given `deploy`, on a first run. Every other command naming a target that
29
+ * does not exist has made a typo, and the list of what does exist is the useful
30
+ * answer.
34
31
  */
35
- export function askedTarget(
32
+ export function requireTarget(
33
+ config: Config,
36
34
  targetFlag: string | undefined,
37
- env: Record<string, string | undefined>,
38
- ): { target: string | undefined; source: 'flag' | 'environment' | undefined } {
39
- if (targetFlag) return { target: targetFlag, source: 'flag' };
35
+ options: { allowUndeclared?: boolean; profile?: string } = {},
36
+ ): string {
37
+ if (!targetFlag) throw noTargetNamed(config, options.profile);
40
38
 
41
- const fromEnv = env[TARGET_ENV];
42
- if (fromEnv) return { target: fromEnv, source: 'environment' };
39
+ if (options.allowUndeclared !== true && !(targetFlag in config.targets)) {
40
+ throw undeclaredTarget(targetFlag, config, options.profile);
41
+ }
43
42
 
44
- return { target: undefined, source: undefined };
43
+ return targetFlag;
45
44
  }
46
45
 
47
46
  /**
48
- * The refusal for a target that is not in the file, in one spelling.
47
+ * The refusal for a command that named no target.
49
48
  *
50
- * It was two here and in the CLI's `openSecretStoreFor` — which is one more
51
- * than a sentence naming the available targets survives: the copies drift the
52
- * moment either learns something the other does not. This one knows about
53
- * `LANES_LINK_TARGET`, and that is precisely the knowledge a copy would lack
54
- * an exported typo otherwise fails every command in the shell with a message
55
- * that reads as a problem with the config file.
49
+ * It lists the targets with their adapters, because "which one" is the question
50
+ * being asked and the adapter set is what distinguishes them. It also reports
51
+ * the two things that used to answer this and no longer do — an exported
52
+ * variable and the key still sitting in the file since an operator looking at
53
+ * either has every reason to believe it is still working.
56
54
  */
57
- export function undeclaredTarget(
58
- target: string,
55
+ export function noTargetNamed(
59
56
  config: Config,
60
- source?: 'flag' | 'environment' | 'config-default',
57
+ profile?: string,
58
+ env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
61
59
  ): ConfigError {
62
- const have = Object.keys(config.targets).join(', ') || 'none';
60
+ const names = Object.keys(config.targets);
61
+ const whose = profile ? ` by profile "${profile}"` : '';
62
+
63
+ const rows = names
64
+ .map((name) => {
65
+ const declared = config.targets[name]!;
66
+ const deployed = declared.deploy ? ' deployed' : '';
67
+ return ` ${name} ${declared.credentials.adapter} ${declared.storage.adapter}${deployed}`;
68
+ })
69
+ .join('\n');
70
+
71
+ const stale = env[LEGACY_TARGET_ENV];
72
+ const inert = config.instance.default_target;
73
+
63
74
  return new ConfigError(
64
- `Target "${target}" is not declared in this profile (have: ${have})` +
65
- (source === 'environment'
66
- ? `\n${TARGET_ENV}=${target} is set in this shell — unset it, or pass --target.`
75
+ '--target is required. This command opens a target\'s stores, and nothing\n' +
76
+ 'else selects one.\n\n' +
77
+ ` Targets declared${whose}\n${rows}\n` +
78
+ `\n e.g. lanes link status --profile ${profile ?? '<name>'} --target ${names[0] ?? '<target>'}` +
79
+ (stale
80
+ ? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
81
+ ' Unset it, or pass --target.'
82
+ : '') +
83
+ (inert
84
+ ? `\n\n instance.default_target: ${inert} is still in this profile. It is no longer\n` +
85
+ ' read either, and is safe to delete.'
67
86
  : ''),
68
87
  );
69
88
  }
70
89
 
71
90
  /**
72
- * Fill in the target once the profile's config has been loaded.
73
- *
74
- * Order: `--target`, then `LANES_LINK_TARGET`, then `instance.default_target`.
75
- * The middle one is the same bargain `LANES_LINK_PROFILE` already offers — a
76
- * shell's worth of commands without retyping a flag, somewhere `env` shows it.
91
+ * The variable that used to name a target, kept only to say it is ignored.
77
92
  *
78
- * `allowUndeclared` is for the one command whose job is to create the target it
79
- * was given `deploy`, on a first run. Every other command naming a target that
80
- * does not exist has made a typo, and the list of what does exist is the useful
81
- * answer; refusing there is what stops `--target clod` opening the default one.
93
+ * Not read by anything that resolves. It exists so a refusal can name the thing
94
+ * an operator is looking at and reasonably believes is still working which is
95
+ * the whole difference between "this stopped working" and "this stopped working
96
+ * and here is why".
82
97
  */
83
- export function resolveTarget(
84
- config: Config,
85
- targetFlag?: string,
86
- options: {
87
- allowUndeclared?: boolean;
88
- env?: Record<string, string | undefined>;
89
- } = {},
90
- ): { target: string; source: 'flag' | 'environment' | 'config-default' } {
91
- const env = options.env ?? (process.env as Record<string, string | undefined>);
92
- const asked = askedTarget(targetFlag, env);
93
-
94
- const target = asked.target ?? config.instance.default_target;
95
- const source = asked.source ?? 'config-default';
96
-
97
- if (options.allowUndeclared !== true && !(target in config.targets)) {
98
- throw undeclaredTarget(target, config, source);
99
- }
100
- return { target, source };
101
- }
98
+ export const LEGACY_TARGET_ENV = 'LANES_LINK_TARGET';
102
99
 
103
100
  /**
104
- * The target a deploy means, when nobody said.
105
- *
106
- * `--target cloud` was required on every deploy, and the reason was an accident:
107
- * an absent flag falls back to `instance.default_target`, which is `local` —
108
- * a target that by definition is not deployed anywhere. So the one command whose
109
- * subject is never ambiguous was the one command that made you say it, and the
110
- * default it would otherwise have taken was not merely unhelpful but wrong.
111
- *
112
- * The rule is what someone would say out loud: deploy the target that has a
113
- * deployment. One is the answer; none means the first run, which conventionally
114
- * creates `cloud` and is what every example in the docs names; several is a
115
- * genuine question, and asking beats rolling a revision to whichever came first
116
- * in a YAML mapping.
117
- *
118
- * `--target` still wins, which is how you deploy the second one.
101
+ * The refusal for a target that is not in the file, in one spelling.
119
102
  *
120
- * **`LANES_LINK_TARGET` is deliberately not read here.** It is the same kind of
121
- * answer as `instance.default_target` a shell-wide "where do my commands
122
- * run" and the paragraph above is why that kind of answer is the wrong one
123
- * for this question. It is also the only place where being wrong creates cloud
124
- * resources rather than an error: `deploy` is the one caller that passes
125
- * `allowUndeclared`, so an exported typo would not be refused, it would be
126
- * surveyed, written into the profile, and rolled out as a new service. An
127
- * environment variable must not be able to name a Cloud Run service into
128
- * existence. Say `--target`; `deploy` is rare enough to afford it.
103
+ * It was two here and in the CLI's `openSecretStoreFor` — which is one more
104
+ * than a sentence naming the available targets survives: the copies drift the
105
+ * moment either learns something the other does not.
129
106
  */
130
- export const CONVENTIONAL_DEPLOY_TARGET = 'cloud';
131
-
132
- export function resolveDeployTarget(
133
- config: Config,
134
- targetFlag?: string,
135
- ): { target: string; source: 'flag' | 'deployable' } {
136
- if (targetFlag) return { target: targetFlag, source: 'flag' };
137
-
138
- // `deploy` alone: the legacy `cloudrun:` spelling is normalised into it by the
139
- // loader, so exactly one shape reaches here.
140
- const deployable = Object.entries(config.targets)
141
- .filter(([, declared]) => declared.deploy !== undefined)
142
- .map(([name]) => name);
143
-
144
- if (deployable.length > 1) {
145
- throw new ConfigError(
146
- `This profile declares ${deployable.length} deployable targets (${deployable.join(', ')}). ` +
147
- 'Name the one you mean with --target.',
148
- );
149
- }
107
+ export function undeclaredTarget(target: string, config: Config, profile?: string): ConfigError {
108
+ const have = Object.keys(config.targets).join(', ') || 'none';
109
+ const whose = profile ? `profile "${profile}"` : 'this profile';
150
110
 
151
- return { target: deployable[0] ?? CONVENTIONAL_DEPLOY_TARGET, source: 'deployable' };
111
+ return new ConfigError(`Target "${target}" is not declared by ${whose} (have: ${have})`);
152
112
  }