@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.
- package/README.md +1 -25
- package/instructions/skills/lanes-link/SKILL.md +12 -12
- package/package.json +1 -1
- package/src/auth/oauth/store.ts +21 -5
- package/src/cli/accepts.ts +6 -5
- package/src/cli/brand.ts +20 -15
- package/src/cli/callback-page.ts +8 -7
- package/src/cli/commands/connect/index.ts +2 -1
- package/src/cli/commands/connect/settle.ts +12 -4
- package/src/cli/commands/connection.ts +2 -1
- package/src/cli/commands/identity.ts +4 -4
- package/src/cli/commands/knowledge/index.ts +9 -16
- package/src/cli/commands/knowledge/migrate.ts +9 -4
- package/src/cli/commands/knowledge/show.ts +14 -9
- package/src/cli/commands/operate/migrate.ts +5 -2
- package/src/cli/commands/operate/pair.ts +1 -1
- package/src/cli/commands/operate/serve.ts +1 -1
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +100 -13
- package/src/cli/commands/profile/remove.ts +67 -6
- package/src/cli/commands/profile.ts +33 -6
- package/src/cli/commands/secrets.ts +4 -4
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +36 -23
- package/src/cli/config-edit.ts +29 -8
- package/src/cli/config-repair-sweep.ts +183 -0
- package/src/cli/config-repair.ts +90 -141
- package/src/cli/config-templates.ts +26 -24
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +143 -209
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +34 -8
- package/src/cli/contract3.ts +141 -24
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +349 -0
- package/src/cli/identity.ts +44 -26
- package/src/cli/main.ts +6 -1
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +3 -3
- package/src/cli/publish.ts +1 -5
- package/src/cli/runtime/open.ts +5 -5
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +16 -11
- package/src/cli/runtime/vault.ts +2 -2
- package/src/cli/usage.ts +5 -1
- package/src/cli/workspace-migrate.ts +32 -11
- package/src/connectivity/manifest/provider.ts +31 -12
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/deploy.ts +5 -5
- package/src/deployments/gcp/bucket.ts +42 -6
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/target.ts +28 -7
- package/src/deployments/upload.ts +39 -30
- package/src/profile/connections.ts +13 -1
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +5 -1
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +147 -71
- package/src/profile/load.ts +53 -17
- package/src/profile/schema.ts +11 -2
- package/src/profile/testing.ts +45 -10
- package/src/profile/workspace.ts +66 -30
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +6 -6
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/registry.ts +1 -1
- package/src/server/endpoint.ts +4 -0
- package/src/server/harness.ts +1 -1
- package/src/server/mcp/instructions.ts +21 -21
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +16 -3
- package/src/server/mcp/visibility.ts +56 -4
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- 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: '
|
|
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
|
},
|
package/src/registry/registry.ts
CHANGED
|
@@ -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 (
|
|
81
|
+
`Provider id "${manifest.id}" is reserved for the owner layer (the lanes_ surfaces) and cannot be claimed.`,
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
package/src/server/endpoint.ts
CHANGED
|
@@ -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),
|
package/src/server/harness.ts
CHANGED
|
@@ -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:
|
|
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 `
|
|
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 \`
|
|
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 `
|
|
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 \`
|
|
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. `
|
|
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 \`
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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('
|
|
232
|
-
const people = present.has('
|
|
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 === '
|
|
236
|
-
if (stores && id === '
|
|
237
|
-
if (people && id === '
|
|
238
|
-
if (people && id === '
|
|
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: `
|
|
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.
|
package/src/server/mcp/tools.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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].
|
|
175
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
128
|
-
const record = decodeConnection(await
|
|
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
|
|
140
|
-
|
|
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) =>
|
|
149
|
-
set: (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
|
-
|
|
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. */
|