@lanes-sh/link 0.8.0 → 0.9.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 (90) hide show
  1. package/README.md +1 -25
  2. package/instructions/skills/lanes-link/SKILL.md +12 -12
  3. package/package.json +1 -1
  4. package/src/auth/oauth/store.ts +21 -5
  5. package/src/cli/accepts.ts +6 -5
  6. package/src/cli/brand.ts +20 -15
  7. package/src/cli/callback-page.ts +8 -7
  8. package/src/cli/commands/connect/index.ts +2 -1
  9. package/src/cli/commands/connect/settle.ts +12 -4
  10. package/src/cli/commands/connection.ts +2 -1
  11. package/src/cli/commands/identity.ts +4 -4
  12. package/src/cli/commands/knowledge/index.ts +9 -16
  13. package/src/cli/commands/knowledge/migrate.ts +9 -4
  14. package/src/cli/commands/knowledge/show.ts +14 -9
  15. package/src/cli/commands/operate/migrate.ts +5 -2
  16. package/src/cli/commands/operate/pair.ts +1 -1
  17. package/src/cli/commands/operate/serve.ts +1 -1
  18. package/src/cli/commands/owner/assets.ts +2 -2
  19. package/src/cli/commands/owner/entities.ts +2 -2
  20. package/src/cli/commands/owner/memory.ts +2 -2
  21. package/src/cli/commands/owner/tasks.ts +2 -2
  22. package/src/cli/commands/owner/vault.ts +3 -3
  23. package/src/cli/commands/profile/disposition.ts +236 -0
  24. package/src/cli/commands/profile/removal.ts +100 -13
  25. package/src/cli/commands/profile/remove.ts +67 -6
  26. package/src/cli/commands/profile.ts +33 -6
  27. package/src/cli/commands/secrets.ts +4 -4
  28. package/src/cli/commands/update-migration.ts +54 -0
  29. package/src/cli/commands/update.ts +36 -23
  30. package/src/cli/config-edit.ts +29 -8
  31. package/src/cli/config-repair-sweep.ts +183 -0
  32. package/src/cli/config-repair.ts +90 -141
  33. package/src/cli/config-templates.ts +26 -24
  34. package/src/cli/contract3-credentials.ts +294 -0
  35. package/src/cli/contract3-data.ts +143 -209
  36. package/src/cli/contract3-layout.ts +46 -0
  37. package/src/cli/contract3-shape.ts +34 -8
  38. package/src/cli/contract3.ts +141 -24
  39. package/src/cli/contract4-credentials.ts +207 -0
  40. package/src/cli/contract4-data.ts +399 -0
  41. package/src/cli/contract4-rename.ts +73 -0
  42. package/src/cli/contract4-yaml.ts +223 -0
  43. package/src/cli/contract4.ts +349 -0
  44. package/src/cli/identity.ts +44 -26
  45. package/src/cli/main.ts +6 -1
  46. package/src/cli/migrate-move.ts +166 -0
  47. package/src/cli/migrate-plan.ts +3 -3
  48. package/src/cli/publish.ts +1 -5
  49. package/src/cli/runtime/open.ts +5 -5
  50. package/src/cli/runtime/select.ts +2 -11
  51. package/src/cli/runtime/stores.ts +16 -11
  52. package/src/cli/runtime/vault.ts +2 -2
  53. package/src/cli/usage.ts +5 -1
  54. package/src/cli/workspace-migrate.ts +32 -11
  55. package/src/connectivity/manifest/provider.ts +31 -12
  56. package/src/connectivity/transports/imap/parser.ts +70 -9
  57. package/src/deployments/adapters/filesystem.ts +18 -3
  58. package/src/deployments/deploy.ts +5 -5
  59. package/src/deployments/gcp/bucket.ts +42 -6
  60. package/src/deployments/knowledge.ts +9 -4
  61. package/src/deployments/target.ts +28 -7
  62. package/src/deployments/upload.ts +39 -30
  63. package/src/profile/connections.ts +13 -1
  64. package/src/profile/deployments.ts +86 -8
  65. package/src/profile/index.ts +5 -1
  66. package/src/profile/knowledge.ts +18 -5
  67. package/src/profile/layout.ts +147 -71
  68. package/src/profile/load.ts +53 -17
  69. package/src/profile/schema.ts +11 -2
  70. package/src/profile/testing.ts +45 -10
  71. package/src/profile/workspace.ts +66 -30
  72. package/src/providers/assets/provider.ts +6 -6
  73. package/src/providers/entities/provider.ts +6 -6
  74. package/src/providers/entities/writes.ts +1 -1
  75. package/src/providers/identity/provider.ts +1 -1
  76. package/src/providers/memory/provider.ts +6 -6
  77. package/src/providers/setup/provider.ts +3 -3
  78. package/src/providers/skills/provider.ts +2 -2
  79. package/src/providers/tasks/provider.ts +6 -6
  80. package/src/providers/vault/provider.ts +1 -1
  81. package/src/registry/registry.ts +1 -1
  82. package/src/server/endpoint.ts +4 -0
  83. package/src/server/harness.ts +1 -1
  84. package/src/server/mcp/instructions.ts +21 -21
  85. package/src/server/mcp/routing.ts +3 -3
  86. package/src/server/mcp/tools.ts +16 -3
  87. package/src/server/mcp/visibility.ts +56 -4
  88. package/src/stores/blobs/conformance.ts +19 -0
  89. package/src/stores/state/index.ts +76 -10
  90. package/src/stores/state/testing.ts +5 -1
@@ -57,7 +57,7 @@ function matches(task: Task, needle: string): boolean {
57
57
  }
58
58
 
59
59
  export const tasksProvider: ProviderDefinition = defineLocalProvider({
60
- id: 'tasks',
60
+ id: 'lanes_tasks',
61
61
  name: 'Tasks',
62
62
  version: '1.0.0',
63
63
  description:
@@ -92,13 +92,13 @@ export const tasksProvider: ProviderDefinition = defineLocalProvider({
92
92
  name: 'task',
93
93
  title: 'Task',
94
94
  description: 'One task, addressed by its id.',
95
- uriTemplate: 'tasks://task/{id}',
95
+ uriTemplate: 'lanes-tasks://task/{id}',
96
96
  mimeType: 'text/markdown',
97
97
  redact: keepKeys('uri'),
98
98
 
99
99
  async list(context) {
100
100
  return (await allTasks(context.storage)).map((task) => ({
101
- uri: `tasks://task/${encodeURIComponent(task.id)}`,
101
+ uri: `lanes-tasks://task/${encodeURIComponent(task.id)}`,
102
102
  name: task.title,
103
103
  }));
104
104
  },
@@ -170,7 +170,7 @@ export const tasksProvider: ProviderDefinition = defineLocalProvider({
170
170
  ...shown.flatMap((task) => [
171
171
  {
172
172
  type: 'resource_link' as const,
173
- uri: `tasks://task/${encodeURIComponent(task.id)}`,
173
+ uri: `lanes-tasks://task/${encodeURIComponent(task.id)}`,
174
174
  name: task.title,
175
175
  },
176
176
  { type: 'text' as const, text: `${task.id} ${line(task)}` },
@@ -193,7 +193,7 @@ export const tasksProvider: ProviderDefinition = defineLocalProvider({
193
193
  name: 'get',
194
194
  title: 'Read a task',
195
195
  description:
196
- 'Return one task by id, notes included. The resource tasks://task/{id} is the same content; this exists for clients that do not read resources.',
196
+ 'Return one task by id, notes included. The resource lanes-tasks://task/{id} is the same content; this exists for clients that do not read resources.',
197
197
  inputSchema: z.object({ id: z.string().min(1).describe('Task id') }),
198
198
  redact: keepKeys('id'),
199
199
  async handler({ id }, context) {
@@ -261,7 +261,7 @@ export const tasksProvider: ProviderDefinition = defineLocalProvider({
261
261
  type: 'text',
262
262
  text: `${existing ? 'Replaced' : 'Added'} task "${taskId}" on ${context.connection.key}.`,
263
263
  },
264
- { type: 'resource_link', uri: `tasks://task/${taskId}`, name: title },
264
+ { type: 'resource_link', uri: `lanes-tasks://task/${taskId}`, name: title },
265
265
  ],
266
266
  };
267
267
  },
@@ -92,7 +92,7 @@ export function createVaultProvider(options: VaultProviderOptions): ProviderDefi
92
92
  }));
93
93
 
94
94
  return defineLocalProvider({
95
- id: 'vault',
95
+ id: 'lanes_vault',
96
96
  name: 'Vault',
97
97
  version: '1.0.0',
98
98
  description:
@@ -78,7 +78,7 @@ export class ProviderRegistry {
78
78
 
79
79
  if (!this.#allowReserved && RESERVED_PROVIDER_IDS.includes(manifest.id)) {
80
80
  throw new Error(
81
- `Provider id "${manifest.id}" is reserved for the owner layer (memory, skills, vault) and cannot be claimed.`,
81
+ `Provider id "${manifest.id}" is reserved for the owner layer (the lanes_ surfaces) and cannot be claimed.`,
82
82
  );
83
83
  }
84
84
 
@@ -176,6 +176,10 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
176
176
  name,
177
177
  {
178
178
  config: runtime.config,
179
+ // For the `connection` argument's description: an id says nothing about
180
+ // which account it is, and the caller choosing between two of a vendor
181
+ // has only that description to go on.
182
+ connections: runtime.connections,
179
183
  registry: runtime.registry,
180
184
  dispatcher: runtime.dispatcher,
181
185
  policy: toPolicyDocument(runtime.config),
@@ -75,7 +75,7 @@ export function configFor(profile: string, port: number, policy: string): Config
75
75
  ` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
76
76
 
77
77
  return parseConfig(`
78
- contract: 3
78
+ contract: 4
79
79
  instance:
80
80
  profile: ${profile}
81
81
  port: ${port}
@@ -102,12 +102,12 @@ quote it back, summarise it, or write it anywhere.`;
102
102
  * Asked to connect a second mailbox, a client with no setup surface and no
103
103
  * skill answered that it could not and then invented the procedure — edit the
104
104
  * profile YAML, run "the auth command" — neither of which is a thing. It had
105
- * no way to know `setup_overview` answers exactly that, so the instruction has
105
+ * no way to know `lanes_setup_overview` answers exactly that, so the instruction has
106
106
  * to arrive here: this is the only channel that reaches a client which has
107
107
  * merely been pointed at the URL.
108
108
  */
109
109
  const SETUP = `**What is set up is answerable.** Before saying something cannot be reached, or
110
- that an account must be added, call \`setup_overview\` — then \`setup_provider\`
110
+ that an account must be added, call \`lanes_setup_overview\` — then \`lanes_setup_provider\`
111
111
  for the exact command. Running it is the owner's to do; inventing it is not.`;
112
112
 
113
113
  /**
@@ -117,14 +117,14 @@ for the exact command. Running it is the owner's to do; inventing it is not.`;
117
117
  * declaration would put a per-profile list into a string with a fixed ceiling —
118
118
  * so the workspace with the most identities to keep straight is exactly the one
119
119
  * whose list would be summarised away first. A pointer costs the same for one
120
- * profile as for twenty, and `identity_list` has room to say when each applies,
120
+ * profile as for twenty, and `lanes_identity_list` has room to say when each applies,
121
121
  * which is the half that actually prevents the mistake.
122
122
  *
123
123
  * Conditional like the rest: a profile that declares nothing has no `identity`
124
124
  * connection, so the capability is unreachable and this paragraph is unspent.
125
125
  */
126
126
  const IDENTITY = `**Identity is declared, not inferred.** Where a name, address or handle of the
127
- owner's is needed, call \`identity_list\`: a profile may hold several, each with a
127
+ owner's is needed, call \`lanes_identity_list\`: a profile may hold several, each with a
128
128
  note on when it applies.`;
129
129
 
130
130
  /**
@@ -136,7 +136,7 @@ note on when it applies.`;
136
136
  * message sent to the wrong Jan has left.
137
137
  *
138
138
  * It carries two rules rather than one, because dropping the refusal made the
139
- * second necessary. `entities_find` returns every match and sets no error, so a
139
+ * second necessary. `lanes_entities_find` returns every match and sets no error, so a
140
140
  * client is not stopped by anything: nothing but this sentence stands between
141
141
  * "two candidates" and an agent using the first. The count and the wording of
142
142
  * the tool result say it too, and this says it before the first call rather
@@ -166,7 +166,7 @@ which is meant, not take the first.`;
166
166
  * form and this one arrives only once the owner has declared themselves.
167
167
  */
168
168
  const IDENTITY_AND_ENTITIES = `**Who someone is, is declared rather than inferred.** For the owner's own name,
169
- address or handle, call \`identity_list\`. For anyone else — a person, a company,
169
+ address or handle, call \`lanes_identity_list\`. For anyone else — a person, a company,
170
170
  a project — call \`entities_find\`, which returns every match and never chooses:
171
171
  more than one means ask which is meant, not take the first.`;
172
172
 
@@ -201,14 +201,14 @@ not land, do not redo what already succeeded, and offer to retry.`;
201
201
 
202
202
  /** Which paragraph each owner-layer provider brings, when it is reachable alone. */
203
203
  const OWNER_HABITS: Record<string, string> = {
204
- memory: MEMORY,
205
- tasks: TASKS,
206
- assets: ASSETS,
207
- skills: SKILLS,
208
- vault: VAULT,
209
- setup: SETUP,
210
- identity: IDENTITY,
211
- entities: ENTITIES,
204
+ lanes_memory: MEMORY,
205
+ lanes_tasks: TASKS,
206
+ lanes_assets: ASSETS,
207
+ lanes_skills: SKILLS,
208
+ lanes_vault: VAULT,
209
+ lanes_setup: SETUP,
210
+ lanes_identity: IDENTITY,
211
+ lanes_entities: ENTITIES,
212
212
  };
213
213
 
214
214
  /**
@@ -228,14 +228,14 @@ const OWNER_HABITS: Record<string, string> = {
228
228
  */
229
229
  function habitsFor(reachable: readonly string[]): string[] {
230
230
  const present = new Set(reachable);
231
- const stores = present.has('memory') && present.has('tasks');
232
- const people = present.has('identity') && present.has('entities');
231
+ const stores = present.has('lanes_memory') && present.has('lanes_tasks');
232
+ const people = present.has('lanes_identity') && present.has('lanes_entities');
233
233
 
234
234
  return reachable.flatMap((id) => {
235
- if (stores && id === 'memory') return [MEMORY_AND_TASKS];
236
- if (stores && id === 'tasks') return [];
237
- if (people && id === 'identity') return [IDENTITY_AND_ENTITIES];
238
- if (people && id === 'entities') return [];
235
+ if (stores && id === 'lanes_memory') return [MEMORY_AND_TASKS];
236
+ if (stores && id === 'lanes_tasks') return [];
237
+ if (people && id === 'lanes_identity') return [IDENTITY_AND_ENTITIES];
238
+ if (people && id === 'lanes_entities') return [];
239
239
  return OWNER_HABITS[id] ? [OWNER_HABITS[id]!] : [];
240
240
  });
241
241
  }
@@ -270,7 +270,7 @@ function habitsFor(reachable: readonly string[]): string[] {
270
270
  * the wrong address has already sent the message; the mistake happens at the
271
271
  * instant of the send, before a skill would have been loaded, and the client
272
272
  * most in need of the rule is again the one holding no skills directory. The
273
- * paragraph also carries a rule nothing else can enforce: `entities_find` sets
273
+ * paragraph also carries a rule nothing else can enforce: `lanes_entities_find` sets
274
274
  * no error on an ambiguous result, so between "two candidates" and an agent
275
275
  * using the first there is only prose.
276
276
  *
@@ -16,8 +16,8 @@ import type { MergedCapability, ProfileRuntime } from './visibility.ts';
16
16
  * They have to go somewhere that works for *any* template, not just one that
17
17
  * happens to spell `{key}`, so they are inserted as the first two path segments
18
18
  * directly after the authority: `example://note/{key}` becomes
19
- * `example://note/personal/a/{key}`, and `memory://entry/{id}` becomes
20
- * `memory://entry/personal/owner/{id}`.
19
+ * `example://note/personal/a/{key}`, and `lanes-memory://entry/{id}` becomes
20
+ * `lanes-memory://entry/personal/owner/{id}`.
21
21
  *
22
22
  * The previous form substituted the literal token `{key}`, which meant any
23
23
  * provider naming its variable anything else — every provider except `example` —
@@ -53,7 +53,7 @@ function originOf(uri: string): string {
53
53
  /**
54
54
  * Route the `resource_link`s a tool hands back.
55
55
  *
56
- * A provider names its own resources — `memory://entry/deploy_window` — because
56
+ * A provider names its own resources — `lanes-memory://entry/deploy_window` — because
57
57
  * it does not know, and must not learn, which profile or connection it is
58
58
  * serving. Core does. Without this, `memory.search` returns addresses that look
59
59
  * like resources and cannot be read, which is worse than returning none.
@@ -6,7 +6,12 @@ import { clientLabelFrom } from './client-info.ts';
6
6
  import { toolNameFor } from './naming.ts';
7
7
  import { resourceLinkRouter } from './routing.ts';
8
8
  import { sanitizeSchema } from './schema.ts';
9
- import { describeWithConnections, type BuildServerOptions, type MergedCapability } from './visibility.ts';
9
+ import {
10
+ accountsByProfile,
11
+ describeWithConnections,
12
+ type BuildServerOptions,
13
+ type MergedCapability,
14
+ } from './visibility.ts';
10
15
 
11
16
  /**
12
17
  * Tools — the capability kind everything else is measured against.
@@ -51,7 +56,11 @@ export function registerDiscoveredTool(
51
56
  toolNameFor(id),
52
57
  {
53
58
  ...(discovered.title ? { title: discovered.title } : {}),
54
- description: describeWithConnections(discovered.description, entry.reachable),
59
+ description: describeWithConnections(
60
+ discovered.description,
61
+ entry.reachable,
62
+ accountsByProfile(options),
63
+ ),
55
64
  // Spread the upstream schema rather than rebuilding it from properties
56
65
  // and required alone. Vendors put `$defs` beside those and `$ref` into
57
66
  // them — Linear's attachment tools do — and a rebuild drops the
@@ -87,7 +96,11 @@ export function registerLocalTool(
87
96
  toolNameFor(id),
88
97
  {
89
98
  ...(capability.title ? { title: capability.title } : {}),
90
- description: describeWithConnections(capability.description, entry.reachable),
99
+ description: describeWithConnections(
100
+ capability.description,
101
+ entry.reachable,
102
+ accountsByProfile(options),
103
+ ),
91
104
  inputSchema: {
92
105
  ...shape,
93
106
  // Injected by core, never declared by a provider — ADR-001. Both enums
@@ -1,6 +1,6 @@
1
1
  import { isTool } from '#connectivity';
2
2
  import type { Principal } from '#auth';
3
- import type { Config } from '#profile';
3
+ import type { Config, SelectedConnection } from '#profile';
4
4
  import type { ProviderRegistry } from '#registry';
5
5
  import type { Dispatcher } from '#dispatch';
6
6
  import type { PolicyDocument, ProfilePolicy } from '#policy';
@@ -24,6 +24,15 @@ import { mayReach } from '#auth';
24
24
  /** Everything one profile contributes to the endpoint. */
25
25
  export interface ProfileRuntime {
26
26
  readonly config: Config;
27
+ /**
28
+ * The accounts this profile selects, for the `connection` argument's
29
+ * description — the id alone cannot say which mailbox it is.
30
+ *
31
+ * Optional because a registry built to read manifests has no selection, the
32
+ * same reason `refreshSkills` is: a harness that omits it gets bare ids, which
33
+ * is what this listed for everybody before.
34
+ */
35
+ readonly connections?: readonly SelectedConnection[];
27
36
  readonly registry: ProviderRegistry;
28
37
  readonly dispatcher: Dispatcher;
29
38
  readonly policy: ProfilePolicy;
@@ -166,13 +175,56 @@ export function visibleToolCount(options: BuildServerOptions): number {
166
175
  * Grouped rather than flattened because the two arguments are not independent:
167
176
  * `profile: personal` with a connection belonging to `work` is refused, and a
168
177
  * flat list would read as though any pairing were valid.
178
+ *
179
+ * **The account, not just the id.** This listed bare ids, and the id is the
180
+ * only thing a model has to choose on — so two accounts of one vendor were
181
+ * `ada_lovelace` and `ada_lovelace2` and nothing said which mailbox either was.
182
+ * `idFromAccount` takes only the local part, so that is what two addresses at
183
+ * different domains actually produce. Picking the wrong one sends mail as the
184
+ * wrong person, which is the same class of failure ADR-056 rules out for
185
+ * entities: ordering is not selection, and a caller that cannot tell two
186
+ * candidates apart must be given what tells them apart.
187
+ *
188
+ * The enum stays bare ids, because the id is what the caller passes.
169
189
  */
170
190
  export function describeWithConnections(
171
191
  description: string,
172
192
  reachable: ReadonlyMap<string, readonly string[]>,
193
+ accounts: ReadonlyMap<string, ReadonlyMap<string, string>> = new Map(),
173
194
  ): string {
174
- const lines = [...reachable].map(
175
- ([profile, connections]) => ` ${profile}: ${connections.join(', ')}`,
176
- );
195
+ const lines = [...reachable].flatMap(([profile, connections]) => {
196
+ const known = accounts.get(profile);
197
+ return [
198
+ ` ${profile}:`,
199
+ ...connections.map((id) => ` ${id}${known?.get(id) === undefined ? '' : ` — ${known.get(id)!}`}`),
200
+ ];
201
+ });
177
202
  return `${description}\n\nAvailable connections, by profile:\n${lines.join('\n')}`;
178
203
  }
204
+
205
+ /**
206
+ * How each profile's connections should read to a caller choosing between them.
207
+ *
208
+ * `account` always, `label` where the operator set one — the two fields that
209
+ * exist because the id cannot carry meaning and must not pretend to.
210
+ */
211
+ export function accountsByProfile(
212
+ options: BuildServerOptions,
213
+ ): Map<string, Map<string, string>> {
214
+ const accounts = new Map<string, Map<string, string>>();
215
+
216
+ for (const [name, runtime] of options.profiles) {
217
+ const rows = new Map<string, string>();
218
+ for (const { connection } of runtime.connections ?? []) {
219
+ rows.set(
220
+ connection.id,
221
+ connection.label === undefined
222
+ ? connection.account
223
+ : `${connection.account} (${connection.label})`,
224
+ );
225
+ }
226
+ accounts.set(name, rows);
227
+ }
228
+
229
+ return accounts;
230
+ }
@@ -177,6 +177,25 @@ export function describeBlobStoreContract(
177
177
  });
178
178
  });
179
179
 
180
+ test('allows a name that merely begins with dots', async () => {
181
+ await use(async (store) => {
182
+ // Not a traversal — an ordinary name that happens to start with two
183
+ // dots, which a provider will hand over verbatim because it is a
184
+ // caller's to choose. One adapter refused these and the rest stored
185
+ // them, so the same workspace behaved differently depending on which
186
+ // target it was opened against. That is the divergence this whole
187
+ // suite exists to catch, and it caught nothing until there was a case
188
+ // for it.
189
+ // Distinct names, because a filesystem cannot hold `x` as a file and
190
+ // `x/y` beneath it at once — a real difference between an object store
191
+ // and a directory tree, and not the one under test here.
192
+ for (const key of ['..config', '...rc', '..nested/inner.txt']) {
193
+ await store.put(key, bytes(key));
194
+ expect(text(await store.get(key))).toBe(key);
195
+ }
196
+ });
197
+ });
198
+
180
199
  test('allows a traversal that stays inside, normalised', async () => {
181
200
  await use(async (store) => {
182
201
  // Weaker than `scopeBlobStore`'s rule on purpose: the namespace
@@ -26,6 +26,11 @@
26
26
  import type { BlobStore } from '../blobs/index.ts';
27
27
  import { keyFromEntry, namespacePrefix, objectKey } from './keys.ts';
28
28
 
29
+ // Re-exported because the contract-3 migration has to address objects this
30
+ // module wrote, and deriving `connections%2Ev1/gmail%2Emain.json` by hand at the
31
+ // call site is the second spelling of an encoding that must not have two.
32
+ export { decodeSegment, encodeSegment, objectKey } from './keys.ts';
33
+
29
34
  export type ConnectionStatus =
30
35
  | 'active'
31
36
  /** Declared, but its credential is missing or rejected. Does not block startup. */
@@ -94,17 +99,78 @@ export interface RuntimeState {
94
99
  * contain. The same rule keeps `audit.log` and `state.kv` out of reach in
95
100
  * `#profile`'s layout.
96
101
  */
97
- const CONNECTIONS = 'connections.v1';
102
+ export const CONNECTIONS_NAMESPACE = 'connections.v1';
98
103
  const CURSORS = 'cursors.v1';
99
104
 
105
+ /**
106
+ * The discovery cache, and what the endpoint's own OAuth server remembers.
107
+ *
108
+ * Both carry a `.v1` for the reason `connections.v1` and `cursors.v1` do, and
109
+ * here it is doing more than versioning. A provider's own keys are namespaced
110
+ * `<provider>/<connection>` in this same store, a provider id is
111
+ * `[a-z][a-z0-9_]*`, and only `custom` is refused by grammar — so while these
112
+ * were spelled `discovery` and `oauth/clients`, a manifest with id `discovery`
113
+ * or `oauth` landed inside them. That was a collision when everything shared
114
+ * one store; with the workspace/profile split below it would be a silent
115
+ * cross-profile leak, because these two route to the workspace. A dotted name
116
+ * is one no provider can reach.
117
+ */
118
+ export const DISCOVERY_NAMESPACE = 'discovery.v1';
119
+ export const OAUTH_NAMESPACE = 'oauth.v1';
120
+
121
+ /**
122
+ * Whether a namespace belongs to the workspace rather than to one profile.
123
+ *
124
+ * Connection records and the discovery cache are facts about an account or a
125
+ * provider — a `connect` run once must read as connected from every profile —
126
+ * and the OAuth server is the endpoint's, not a profile's. Everything else is
127
+ * this profile's use of an account: its cursors, and each provider's own keys.
128
+ *
129
+ * Every name here carries a dot, so the list cannot be entered by a provider.
130
+ */
131
+ export function isWorkspaceNamespace(namespace: string): boolean {
132
+ return (
133
+ namespace === CONNECTIONS_NAMESPACE ||
134
+ namespace === DISCOVERY_NAMESPACE ||
135
+ namespace === OAUTH_NAMESPACE ||
136
+ namespace.startsWith(`${OAUTH_NAMESPACE}/`)
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Runtime state over two stores: the workspace's, and one profile's.
142
+ *
143
+ * `kv` routes on the namespace rather than being two stores the caller picks
144
+ * between, because the callers are providers and they do not know which of
145
+ * their keys are shared — nor should they. `isWorkspaceNamespace` is the whole
146
+ * rule, and it is closed: a namespace it does not name is the profile's.
147
+ *
148
+ * Both are required, with no default that falls back to one. A default would
149
+ * mean forgetting the second argument shares every profile's cursors and
150
+ * provider keys silently — which is the failure this split exists to remove.
151
+ * Passing the same store twice is still how a test that is not exercising the
152
+ * split says so, and it says it out loud.
153
+ */
100
154
  export function createRuntimeState(
101
155
  blobs: BlobStore,
156
+ profileBlobs: BlobStore,
102
157
  now: () => Date = () => new Date(),
103
158
  ): RuntimeState {
104
- const kv = createKeyValue(blobs);
159
+ const workspaceKv = createKeyValue(blobs);
160
+ const profileKv = createKeyValue(profileBlobs);
161
+ const forNamespace = (namespace: string): KeyValueStore =>
162
+ isWorkspaceNamespace(namespace) ? workspaceKv : profileKv;
163
+
164
+ const kv: KeyValueStore = {
165
+ get: (namespace, key) => forNamespace(namespace).get(namespace, key),
166
+ set: (namespace, key, value) => forNamespace(namespace).set(namespace, key, value),
167
+ delete: (namespace, key) => forNamespace(namespace).delete(namespace, key),
168
+ keys: (namespace) => forNamespace(namespace).keys(namespace),
169
+ clearNamespace: (namespace) => forNamespace(namespace).clearNamespace(namespace),
170
+ };
105
171
 
106
172
  const readConnection = async (provider: string, id: string): Promise<ConnectionRecord | null> =>
107
- decodeConnection(await kv.get(CONNECTIONS, connectionKey(provider, id)));
173
+ decodeConnection(await workspaceKv.get(CONNECTIONS_NAMESPACE, connectionKey(provider, id)));
108
174
 
109
175
  const connections: ConnectionRepository = {
110
176
  async upsert(record) {
@@ -116,7 +182,7 @@ export function createRuntimeState(
116
182
  createdAt: existing?.createdAt ?? now(),
117
183
  updatedAt: now(),
118
184
  };
119
- await kv.set(CONNECTIONS, connectionKey(record.provider, record.id), encodeConnection(stored));
185
+ await workspaceKv.set(CONNECTIONS_NAMESPACE, connectionKey(record.provider, record.id), encodeConnection(stored));
120
186
  return stored;
121
187
  },
122
188
 
@@ -124,8 +190,8 @@ export function createRuntimeState(
124
190
 
125
191
  async list() {
126
192
  const records: ConnectionRecord[] = [];
127
- for (const key of await kv.keys(CONNECTIONS)) {
128
- const record = decodeConnection(await kv.get(CONNECTIONS, key));
193
+ for (const key of await workspaceKv.keys(CONNECTIONS_NAMESPACE)) {
194
+ const record = decodeConnection(await workspaceKv.get(CONNECTIONS_NAMESPACE, key));
129
195
  if (record) records.push(record);
130
196
  }
131
197
  return records.sort(
@@ -136,8 +202,8 @@ export function createRuntimeState(
136
202
  async setStatus(provider, id, status) {
137
203
  const existing = await readConnection(provider, id);
138
204
  if (!existing) return;
139
- await kv.set(
140
- CONNECTIONS,
205
+ await workspaceKv.set(
206
+ CONNECTIONS_NAMESPACE,
141
207
  connectionKey(provider, id),
142
208
  encodeConnection({ ...existing, status, updatedAt: now() }),
143
209
  );
@@ -145,8 +211,8 @@ export function createRuntimeState(
145
211
  };
146
212
 
147
213
  const cursors: CursorRepository = {
148
- get: (namespace) => kv.get(CURSORS, namespace),
149
- set: (namespace, cursor) => kv.set(CURSORS, namespace, cursor),
214
+ get: (namespace) => profileKv.get(CURSORS, namespace),
215
+ set: (namespace, cursor) => profileKv.set(CURSORS, namespace, cursor),
150
216
  };
151
217
 
152
218
  return { connections, kv, cursors };
@@ -15,7 +15,11 @@ import { createMemoryBlobStore } from '../blobs/testing.ts';
15
15
  import { createRuntimeState, type RuntimeState } from './index.ts';
16
16
 
17
17
  export function createMemoryState(now: () => Date = () => new Date()): RuntimeState {
18
- return createRuntimeState(createMemoryBlobStore(), now);
18
+ // The same store twice: this harness is for callers that are not exercising
19
+ // the workspace/profile split, and saying so explicitly is the point of
20
+ // `createRuntimeState` having no default.
21
+ const blobs = createMemoryBlobStore();
22
+ return createRuntimeState(blobs, blobs, now);
19
23
  }
20
24
 
21
25
  /** An in-memory credential store, for the same reason. */